Compare commits

...

315 Commits

Author SHA1 Message Date
Simon Larsen
3938637b84 feat: Enhance MarkdownViewer styling and improve preformatted code handling 2025-09-10 20:08:30 +01:00
Simon Larsen
3ed9e21271 Merge branch 'master' of github.com:OneUptime/oneuptime 2025-09-10 19:09:57 +01:00
Simon Larsen
63e1266e2b feat: Improve MarkdownViewer styling with enhanced Tailwind CSS classes for better readability and aesthetics 2025-09-10 19:09:53 +01:00
Nawaz Dhandala
a552812711 feat: Add projectId support to SlackUtil message sending for incident and scheduled maintenance actions 2025-09-10 18:21:30 +01:00
Nawaz Dhandala
ad07ab75fe feat: Add elkjs dependency for enhanced functionality 2025-09-10 18:13:59 +01:00
Nawaz Dhandala
c8deffebb0 refactor: Improve code readability by standardizing formatting and spacing in SlackUtil methods 2025-09-10 17:12:51 +01:00
Simon Larsen
67a3ea5109 feat: Enhance SlackUtil with projectId support and caching for channel operations 2025-09-10 17:11:04 +01:00
Simon Larsen
6728cc0458 feat: Add projectId parameter to channel-related methods for improved context handling 2025-09-10 17:08:02 +01:00
Simon Larsen
f84ab2474f feat: Optimize channel existence checks by introducing getWorkspaceChannelByName method and streamline channel name normalization 2025-09-10 16:47:36 +01:00
Simon Larsen
5c8ce04eed feat: Enhance pagination handling by supporting 'skip' and 'limit' parameters from both query and body 2025-09-09 16:56:55 +01:00
Nawaz Dhandala
3064aa0364 feat: Improve code formatting and descriptions in announcement-related components and migrations for better readability 2025-09-09 14:36:32 +01:00
Simon Larsen
9625f1381c feat: Add migration for AnnouncementMonitor and AnnouncementTemplateMonitor tables with foreign key constraints 2025-09-09 14:12:33 +01:00
Simon Larsen
6ecd3ad166 Merge branch 'master' into announcement-monitor 2025-09-09 14:04:36 +01:00
Simon Larsen
8e54cac86e feat: Add createEditModalWidth prop with large size to multiple template views for consistent modal presentation 2025-09-09 14:01:46 +01:00
Nawaz Dhandala
cc52bb76d1 feat: Enhance incident state handling by adding type definitions, improving error handling, and updating default state display 2025-09-09 13:54:52 +01:00
Nawaz Dhandala
4c037f54f4 feat: Refactor incident state migration and update related components for improved clarity and functionality 2025-09-09 13:49:34 +01:00
Simon Larsen
b869628d4a feat: Implement fetch for initial incident state and update form values 2025-09-09 13:48:11 +01:00
Simon Larsen
0fbeb503ad feat: Update incident state field title and description for clarity 2025-09-09 13:06:29 +01:00
Simon Larsen
a302e4dc6c feat: Implement automatic selection of the first incident state and update related references 2025-09-09 12:58:07 +01:00
Nawaz Dhandala
00c8783137 feat: Add monitor selection to status page announcements and templates, enhancing resource notification capabilities 2025-09-09 12:56:42 +01:00
Simon Larsen
11211f4a62 feat: Update initial incident state description to reflect default behavior 2025-09-09 12:53:30 +01:00
Simon Larsen
d29750d66e feat: Add initialIncidentState field to IncidentTemplates for incident creation 2025-09-09 12:47:15 +01:00
Simon Larsen
7dc590dab4 feat: Add initialIncidentStateId migration and update index references 2025-09-09 12:37:43 +01:00
Simon Larsen
1d0ed64c1a feat: Rename currentIncidentState to initialIncidentState and update related references in IncidentTemplate, IncidentService, and IncidentTemplatesView 2025-09-09 12:26:48 +01:00
Simon Larsen
0cf3884be4 Merge branch 'master' into select-incident-state 2025-09-09 12:21:12 +01:00
Simon Larsen
165f5608e6 feat: Add step to free disk space in GitHub Actions runner for improved image build efficiency 2025-09-09 12:18:07 +01:00
Nawaz Dhandala
f2b8cfbffb Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-09-09 12:12:57 +01:00
Nawaz Dhandala
6084e15f20 refactor: Enhance type annotations in MarkdownEditor and tests for improved type safety 2025-09-09 12:12:55 +01:00
Simon Larsen
b1db4187de Learn more about Markdown syntax. 2025-09-09 12:12:09 +01:00
Nawaz Dhandala
20ce8a8c74 refactor: Clean up MarkdownEditor and FormField code for improved readability and consistency 2025-09-09 11:45:00 +01:00
Simon Larsen
39200249d1 feat: Update spell check handling in MarkdownEditor and tests for improved functionality 2025-09-09 11:38:58 +01:00
Simon Larsen
27533125e4 feat: Add createEditModalWidth prop to multiple components for consistent modal sizing 2025-09-09 11:23:20 +01:00
Simon Larsen
99dd421329 feat: Add createEditModalWidth prop to IncidentDelete for consistent modal sizing 2025-09-09 11:17:11 +01:00
Simon Larsen
4184894f27 feat: Refactor MarkdownEditor toolbar layout for improved organization and readability 2025-09-09 11:12:40 +01:00
Simon Larsen
a7a00dc0fa feat: Add dataTestId prop to MarkdownEditor and FormField for improved testing 2025-09-09 10:58:06 +01:00
Simon Larsen
9340f69789 feat: Add additional toolbar buttons and formatting options in MarkdownEditor 2025-09-09 10:35:10 +01:00
Simon Larsen
ba33bc0c23 feat: Enhance MarkdownEditor with improved heading handling and toolbar buttons 2025-09-09 10:27:41 +01:00
Simon Larsen
b8cac60c6e feat: Enhance Markdown preview with improved code block handling and styling 2025-09-09 09:59:32 +01:00
Simon Larsen
3a5d5253d0 feat: Enhance MarkdownEditor with toolbar buttons and preview functionality 2025-09-09 09:53:44 +01:00
Simon Larsen
64010b0348 feat: Add initial incident state selection to incident templates and creation forms 2025-09-09 08:35:46 +01:00
Simon Larsen
84ca2ff311 fix: Remove redundant APP_VERSION build argument in Docker image deployment steps 2025-09-08 22:08:00 +01:00
Simon Larsen
c0becebadc feat: Update date formatting to user-friendly display in getMonitorStatusTimelineForStatusPage method 2025-09-08 21:58:53 +01:00
Nawaz Dhandala
6ef99fd890 refactor: Specify types for format and testDate in OneUptimeDate class methods 2025-09-08 21:52:31 +01:00
Nawaz Dhandala
a55f2f7842 refactor: Improve code readability by formatting function arguments and return values in date handling methods 2025-09-08 21:51:53 +01:00
Simon Larsen
0aae7877c7 feat: Update date formatting to user-friendly display in various components 2025-09-08 21:49:19 +01:00
Simon Larsen
8d6cc37f7a feat: Update date formatting to user-friendly display across various components 2025-09-08 21:41:57 +01:00
Simon Larsen
1a0f7eb1e7 feat: Enhance date formatting to user-friendly display in Scheduled Maintenance components 2025-09-08 21:36:47 +01:00
Simon Larsen
6ed65ed3ef fix: Change tag type from semver to raw for Docker image deployments 2025-09-08 20:43:09 +01:00
Simon Larsen
2ac342e26a feat: Add billing_enabled variable to Nginx configuration 2025-09-08 20:29:28 +01:00
Simon Larsen
fe80d6b1ff fix: Remove unnecessary markdown syntax from upgrading guide 2025-09-08 18:44:43 +01:00
Simon Larsen
a68254be6d fix: Clarify reason for discontinuing Bitnami charts in upgrading guide 2025-09-08 18:43:14 +01:00
Simon Larsen
49a9e355fe feat: Add upgrading guide and navigation link to documentation 2025-09-08 18:41:40 +01:00
Simon Larsen
7091e35393 Update GitHub Actions workflow to read version prefix from VERSION_PREFIX file and adjust versioning scheme
- Added a new job 'read-version' to read the major and minor version from VERSION_PREFIX file.
- Updated dependent jobs to use the version read from 'read-version' instead of hardcoded version.
- Changed versioning format in multiple jobs to reflect the new versioning scheme based on the content of VERSION_PREFIX.
- Created VERSION_PREFIX file with initial version set to 8.0.
2025-09-07 15:17:47 +01:00
Simon Larsen
34cc8a43ab Merge pull request #1995 from OneUptime/bitnami-mgr-postgres
Bitnami mgr postgres
2025-09-07 13:21:41 +01:00
Simon Larsen
75333ef36c feat: Add pod security context configuration for ClickHouse and Redis StatefulSets 2025-09-07 13:03:09 +01:00
Simon Larsen
d4b3f1b60b feat: Add primary pod security context configuration for PostgreSQL 2025-09-07 12:59:27 +01:00
Simon Larsen
318d20a5a5 feat: Update PostgreSQL StatefulSet to use primary nodeSelector, affinity, tolerations, and resources 2025-09-07 12:56:29 +01:00
Simon Larsen
44b9c33e5c feat: Add primary ConfigMaps for PostgreSQL configuration and pg_hba settings 2025-09-07 12:45:32 +01:00
Simon Larsen
317a17cbab feat: Rename PostgreSQL ConfigMaps to include 'primary' in their names for clarity 2025-09-07 12:40:23 +01:00
Simon Larsen
6d2cb53760 feat: Update PostgreSQL configuration to use primary settings for ConfigMaps 2025-09-07 12:37:30 +01:00
Simon Larsen
7ddc4be319 feat: Add pg_hba.conf configuration and corresponding ConfigMap for PostgreSQL 2025-09-07 12:32:47 +01:00
Simon Larsen
604776551b feat: Add PostgreSQL configuration checksum and update container args 2025-09-07 12:25:41 +01:00
Simon Larsen
26b085030d refactor: Remove initContainers from PostgreSQL StatefulSet and enable default configuration settings 2025-09-07 12:17:13 +01:00
Simon Larsen
e1046d2424 Merge branch 'master' into bitnami-mgr-postgres 2025-09-07 11:08:46 +01:00
Simon Larsen
cf2a7b9dfa feat: Enhance diagnostics collection in KinD setup script 2025-09-07 11:05:34 +01:00
Simon Larsen
55f4c0b65d docs: Add SQL query to check used and free space in Postgres 2025-09-06 20:38:34 +01:00
Simon Larsen
5100fbda52 docs: Add SQL query to check used and free space in Clickhouse 2025-09-06 20:35:17 +01:00
Simon Larsen
9e36188975 fix: Update image registry and repository in ci-values.yaml 2025-09-06 17:47:47 +01:00
Simon Larsen
26c2d41dfa Merge branch 'master' into bitnami-mgr-postgres 2025-09-06 17:45:00 +01:00
Simon Larsen
a511a433b1 refactor: Remove security context and default profiles from ClickHouse configuration 2025-09-06 11:27:50 +01:00
Simon Larsen
cc581e91b5 Merge pull request #1994 from OneUptime/bitnami-mgr
Bitnami mgr
2025-09-06 11:09:13 +01:00
Simon Larsen
3fd95fe8aa Merge branch 'master' into bitnami-mgr 2025-09-06 11:09:01 +01:00
Simon Larsen
6f2455c265 fix: Set ClickHouse resourcesPreset to "none" to override default value 2025-09-05 21:26:00 +01:00
Simon Larsen
de5b32a609 refactor: Remove default resourcesPreset for ClickHouse configuration 2025-09-05 21:17:13 +01:00
Simon Larsen
155b0d90f1 refactor: Transition from MicroK8s to KinD for Kubernetes cluster setup in CI scripts 2025-09-05 21:15:58 +01:00
Simon Larsen
3da5e12a0d feat: Add auto-generated password option for ClickHouse configuration 2025-09-05 21:14:58 +01:00
Simon Larsen
8accdc6bd4 Merge branch 'master' of github.com:OneUptime/oneuptime 2025-09-05 20:56:35 +01:00
Simon Larsen
22a10702ac feat: Add comprehensive architecture diagram and explanation for self-hosted setup 2025-09-05 20:56:32 +01:00
Nawaz Dhandala
a013c86fae refactor: Standardize wait duration for pod readiness checks 2025-09-05 16:24:25 +01:00
Nawaz Dhandala
361626d21f refactor: Enhance pod readiness check with improved diagnostics and error handling 2025-09-05 16:23:01 +01:00
Simon Larsen
6615ac63d7 refactor: Simplify legal page structure and enhance navigation with Bootstrap styling 2025-09-05 14:50:22 +01:00
Simon Larsen
655611b28d refactor: Revamp support page layout and content for improved user experience 2025-09-05 14:45:55 +01:00
Simon Larsen
9bd45ecd14 Merge branch 'master' into bitnami-mgr-postgres 2025-09-05 14:01:46 +01:00
Simon Larsen
53c9babb83 refactor: Replace commented wait logic with active polling for pod readiness 2025-09-05 14:01:00 +01:00
Simon Larsen
ccc0b0142b feat: Remove default profiles configuration from ClickHouse settings in values.yaml 2025-09-05 13:51:07 +01:00
Simon Larsen
4a2f7f68cb feat: Update PostgreSQL password field to use 'postgresPassword' in values.yaml and secrets.yaml 2025-09-05 12:48:10 +01:00
Simon Larsen
de994e10de feat: Fix PostgreSQL username to be fixed as "postgres" in values.yaml 2025-09-05 12:21:43 +01:00
Simon Larsen
2ff22ca079 feat: Enhance security context handling for ClickHouse, PostgreSQL, and Redis StatefulSets 2025-09-05 12:06:28 +01:00
Simon Larsen
e6b8f60977 feat: Update security context for init and primary PostgreSQL containers 2025-09-05 11:59:37 +01:00
Simon Larsen
b823b5924a feat: Add init container for PostgreSQL configuration symlinks and update security context 2025-09-05 11:42:35 +01:00
Simon Larsen
a58ddd94d5 feat: Add POSTGRES_INITDB_ARGS environment variable for PostgreSQL initialization 2025-09-04 20:19:08 +01:00
Simon Larsen
275e15ce96 feat: Update PostgreSQL environment variables and liveness probe configuration 2025-09-04 20:12:20 +01:00
Simon Larsen
1e2a30823c feat: Remove PostgreSQL authentication environment variables from StatefulSet 2025-09-04 19:48:21 +01:00
Simon Larsen
a326e7084e feat: Remove PostgreSQL dependency and associated chart from Helm configuration 2025-09-04 17:55:18 +01:00
Simon Larsen
2c1d20f680 feat: Remove PostgreSQL dependency from Helm chart 2025-09-04 17:54:48 +01:00
Simon Larsen
95bd2db0dd feat: Add security context support for ClickHouse, PostgreSQL, and Redis StatefulSets 2025-09-04 17:44:59 +01:00
Simon Larsen
3ca875254c feat: Add PostgreSQL StatefulSet configuration to Helm chart 2025-09-04 17:35:39 +01:00
Simon Larsen
7f1f78dad6 feat: Add PostgreSQL Secret configuration to Helm chart 2025-09-04 17:27:49 +01:00
Simon Larsen
a0d6468aee feat: Add PostgreSQL ConfigMap and Service templates for Helm chart 2025-09-04 17:27:30 +01:00
Simon Larsen
914c9bc58e feat: Update PostgreSQL configuration in values.yaml and README for built-in support 2025-09-04 17:26:21 +01:00
Simon Larsen
b38031e9f7 feat: Add subPath for data and config mounts in ClickHouse StatefulSet 2025-09-04 17:03:57 +01:00
Simon Larsen
487ca71f84 fix: Rename volume and mount paths for Redis data in StatefulSet configuration 2025-09-04 15:30:27 +01:00
Simon Larsen
67cd8e7db6 refactor: Remove initContainers from ClickHouse StatefulSet configuration 2025-09-04 15:28:17 +01:00
Simon Larsen
f44017d710 feat: Add Helm annotations for release name and namespace in templates 2025-09-04 12:23:16 +01:00
Simon Larsen
78240b906b feat: Add ClickHouse StatefulSet configuration to Helm chart 2025-09-03 21:57:59 +01:00
Simon Larsen
2ef0b3be27 feat: Add ClickHouse ConfigMap template for configuration management 2025-09-03 21:43:20 +01:00
Simon Larsen
0792d8367a feat: Add ClickHouse service and secret configurations to Helm chart 2025-09-03 21:43:01 +01:00
Simon Larsen
920397cead fix: Remove ClickHouse chart package from the repository 2025-09-03 21:41:35 +01:00
Simon Larsen
42c18e94ab feat: Update ClickHouse configuration and service settings in values.yaml 2025-09-03 21:41:29 +01:00
Simon Larsen
533f7eb238 fix: Remove ClickHouse dependency from Chart.yaml and Chart.lock 2025-09-03 21:38:03 +01:00
Simon Larsen
e2f16e85f1 Merge pull request #1993 from OneUptime/bitnami-mgr
Bitnami mgr
2025-09-03 20:12:22 +01:00
Simon Larsen
c98e6b8471 feat: Add KEDA chart dependency to README 2025-09-03 20:12:09 +01:00
Simon Larsen
c16c13fd89 feat: Add built-in Redis configuration to README and update external Redis instructions 2025-09-03 20:10:43 +01:00
Simon Larsen
c8ce0e8819 fix: Remove Redis cluster configuration options from values.yaml 2025-09-03 20:00:14 +01:00
Simon Larsen
9e98f6acdb fix: Remove Redis replica persistence configuration from values.yaml 2025-09-03 19:50:07 +01:00
Simon Larsen
a7a5b15dde feat: Implement Redis StatefulSet configuration in Helm chart 2025-09-03 19:49:49 +01:00
Simon Larsen
3ebb5217a2 feat: Add Redis master and headless service definitions to Helm chart 2025-09-03 19:46:10 +01:00
Simon Larsen
f570ffe1e3 feat: Add Redis ConfigMap template to Helm chart for Redis configuration management 2025-09-03 19:43:57 +01:00
Simon Larsen
ae94bf6d7c fix: Simplify Redis password handling in Helm chart by removing unnecessary conditional checks 2025-09-03 19:42:28 +01:00
Simon Larsen
d9a6e465bb fix: Remove Redis authentication requirement in values.yaml 2025-09-03 19:42:11 +01:00
Simon Larsen
020b171b77 fix: Update Redis password handling in Helm chart to support optional authentication 2025-09-03 19:38:06 +01:00
Simon Larsen
afc4932c28 fix: Remove Redis dependency and related configurations from Helm chart 2025-09-03 19:37:30 +01:00
Nawaz Dhandala
324851c57e fix: Refactor service operations to execute sequentially with improved error handling in AlertService, IncidentService, MonitorService, and ScheduledMaintenanceService 2025-09-03 15:41:23 +01:00
Nawaz Dhandala
380ecfa096 Refactor code for consistency and readability
- Updated array and object property access from single quotes to double quotes in Pagination.ts and Permissions.ts for consistency.
- Added missing commas in function parameters and object literals across multiple files in AlertService.ts, IncidentService.ts, MonitorService.ts, ScheduledMaintenanceService.ts, and WorkspaceNotificationRuleService.ts.
- Improved error logging messages in various services for better clarity.
- Removed unnecessary line breaks in Slack.ts and Workspace.ts for cleaner code.
- Ensured consistent formatting in Routes.ts by adding missing commas and adjusting line breaks.
2025-09-03 15:37:39 +01:00
Simon Larsen
5f9f73ceaa fix: Refactor monitor creation operations to execute sequentially with improved error handling in MonitorService 2025-09-03 15:35:13 +01:00
Simon Larsen
038ca4a920 fix: Update imports and improve formatting in Routes.ts for consistency and readability 2025-09-03 15:34:29 +01:00
Simon Larsen
d15629da0f fix: Refactor scheduled maintenance operations to execute sequentially with improved error handling in ScheduledMaintenanceService 2025-09-03 15:21:03 +01:00
Simon Larsen
363bbf9dea fix: Refactor incident creation operations to execute sequentially with improved error handling in IncidentService 2025-09-03 15:17:16 +01:00
Simon Larsen
6f0a0c8e38 fix: Remove unnecessary line breaks in error messages and logging for improved readability in WorkspaceNotificationRuleService 2025-09-03 15:06:19 +01:00
Simon Larsen
a75a62c708 fix: Refactor promise chain to use async/await for better readability in AlertService; add debug logging in WorkspaceNotificationRuleService 2025-09-03 14:18:05 +01:00
Simon Larsen
db76d716b9 fix: Remove redundant logging of existing workspace channels for cleaner output 2025-09-03 14:02:12 +01:00
Simon Larsen
b0abbf64b4 fix: Improve logging format in postToWorkspaceChannels for better readability 2025-09-03 13:55:43 +01:00
Simon Larsen
3a432cf8e6 Response from Slack API for getting all channels: 2025-09-03 13:54:58 +01:00
Simon Larsen
5c7d18e3ed fix: Update createdByUser field to use _id for consistency in Alert, Incident, and Scheduled Maintenance services 2025-09-03 13:27:37 +01:00
Simon Larsen
2590850ffa fix: Correct projectId usage in alert feed info generation for accurate monitor links 2025-09-03 13:13:14 +01:00
Simon Larsen
0eeb80e16e fix: Add createdByUserId and createdByUser fields to alert, incident, and scheduled maintenance services for improved tracking 2025-09-03 13:09:38 +01:00
Simon Larsen
e1cfe24a24 fix: Update pageData property access to bracket notation for consistency 2025-09-02 22:59:23 +01:00
Simon Larsen
4e4f3a889d fix: Update type casting for statusReport and probeMonitorResponse to 'any' for improved flexibility 2025-09-02 22:51:41 +01:00
Nawaz Dhandala
ede7ae103d fix: Enhance MonitorTemplateUtil to support additional monitor types and improve type safety 2025-09-02 22:15:04 +01:00
Simon Larsen
075c0fb6bd fix: Enhance template variable support for additional monitor types in MonitorTemplateUtil and update documentation 2025-09-02 22:08:51 +01:00
Nawaz Dhandala
5ebdb1ef7d fix: Refactor code for improved readability and maintainability in various components 2025-09-02 21:57:15 +01:00
Simon Larsen
387dbf332e fix: Correct spelling in API endpoint routes for escalation rules 2025-09-02 21:48:54 +01:00
Simon Larsen
9681e1dc88 fix: Remove fallback syntax from incident alert templating examples for clarity 2025-09-02 21:47:05 +01:00
Simon Larsen
fb29014480 Merge branch 'dynamic-alert' 2025-09-02 21:45:15 +01:00
Simon Larsen
1a5c2efc59 fix: Add debug logging for storage map and template value replacement in MonitorTemplateUtil 2025-09-02 21:43:28 +01:00
Simon Larsen
3e31e44ed5 fix: Enhance value replacement logic to properly serialize objects in VMUtil class 2025-09-02 21:26:55 +01:00
Simon Larsen
9e69d69429 fix: Update titles and descriptions for Global Probes settings for clarity 2025-09-02 21:15:21 +01:00
Simon Larsen
a108deac0f fix: Improve documentation links in MonitorCriteriaAlertForm and MonitorCriteriaIncidentForm for clarity 2025-09-02 21:14:03 +01:00
Simon Larsen
c767f14bf1 fix: Correct syntax error in AlertService class 2025-09-02 21:11:28 +01:00
Simon Larsen
d69485c436 fix: Update Global Probes status messages for clarity in ProbePage component 2025-09-02 20:50:20 +01:00
Simon Larsen
67a5bdb7b8 feat: Update Global Probe settings card with improved descriptions and toggle functionality 2025-09-02 20:47:56 +01:00
Nawaz Dhandala
6504731025 refactor: Replace 'any' types with specific types for improved type safety across multiple files 2025-09-02 20:41:24 +01:00
Nawaz Dhandala
773692081c docs: Update guideline to specify stopping after fixing 25 files for review 2025-09-02 20:25:52 +01:00
Nawaz Dhandala
51c6234966 docs: Add guideline to replace "any" types with proper types 2025-09-02 20:23:53 +01:00
Nawaz Dhandala
fac6e9a1fe fix: Correct formatting issues in MonitorTemplateUtil and MonitorCriteriaAlertForm 2025-09-02 20:14:33 +01:00
Nawaz Dhandala
86e5d85d55 feat: Enhance dynamic template documentation links in MonitorCriteriaAlertForm and MonitorCriteriaIncidentForm 2025-09-02 20:10:14 +01:00
Simon Larsen
1c592435e9 Merge pull request #1990 from OneUptime/any-type
Any type
2025-09-02 20:02:38 +01:00
Nawaz Dhandala
02fed5bd6e refactor: Enhance type safety by explicitly defining ref types and simplifying conditional checks 2025-09-02 19:58:41 +01:00
Nawaz Dhandala
dd724fcc6e refactor: Improve type safety by updating formRef initialization and adding optional chaining for setFieldValue 2025-09-02 19:52:28 +01:00
Nawaz Dhandala
6ba26bcb82 refactor: Improve type safety by adding LayoutOptions type and removing 'any' casts in ServiceDependencyGraph 2025-09-02 19:51:45 +01:00
Nawaz Dhandala
799ab3220d refactor: Replace 'any' type with specific types for ref and input for improved type safety 2025-09-02 19:50:53 +01:00
Nawaz Dhandala
f73f2fb732 refactor: Change argValue type from any to unknown for better type safety 2025-09-02 19:50:34 +01:00
Simon Larsen
43d6ead92c fix: Correct formatting issues in MonitorAlert class logging and data processing 2025-09-02 19:22:31 +01:00
Simon Larsen
4c053b3f31 refactor: Improve formatting and readability in MonitorTemplateUtil methods 2025-09-02 19:02:29 +01:00
Simon Larsen
c026e411cf feat: Add dynamic template usage descriptions in MonitorCriteriaAlertForm and MonitorCriteriaIncidentForm 2025-09-02 19:01:22 +01:00
Simon Larsen
65a9e32db1 feat: Implement MonitorTemplateUtil for dynamic template processing in incidents and alerts 2025-09-02 18:58:15 +01:00
Simon Larsen
0b15e97e08 feat: Integrate MonitorTemplateUtil for dynamic alert and incident title/description processing 2025-09-02 18:56:19 +01:00
Simon Larsen
bd74b96596 feat: Add link to Incident & Alert Dynamic Templating documentation in navigation 2025-09-02 18:45:48 +01:00
Nawaz Dhandala
990d3ea750 feat: Add doNotAddGlobalProbesByDefaultOnNewMonitors column to Project table and update related files 2025-09-02 14:59:23 +01:00
Simon Larsen
30665a1907 feat: Add migration to introduce doNotAddGlobalProbesByDefaultOnNewMonitors column in Project table 2025-09-02 14:58:09 +01:00
Simon Larsen
04db4289fa feat: Add setting to control auto-adding of global probes to new monitors 2025-09-02 14:57:18 +01:00
Nawaz Dhandala
01f4c030a7 style: Format allowedDomains and homeUrl for improved readability 2025-09-02 14:29:17 +01:00
Simon Larsen
d060ed8b64 feat: Add dynamic robots.txt route to control indexing based on domain 2025-09-02 14:27:40 +01:00
Nawaz Dhandala
413240733e fix: Remove unnecessary blank lines in Markdown and Probe initialization files 2025-09-02 14:17:15 +01:00
Simon Larsen
b0799093dd feat: Enhance proxy agent support by specifying types for httpAgent and httpsAgent in WebsiteRequest and API classes 2025-09-02 14:10:42 +01:00
Simon Larsen
0ecdc775db feat: Refactor proxy configuration to use getRequestProxyAgents method across multiple modules 2025-09-02 14:02:40 +01:00
Simon Larsen
82065c20b1 feat: Add HTTP/HTTPS proxy support in FetchMonitorTest, FetchList, and Alive jobs 2025-09-02 13:58:15 +01:00
Simon Larsen
3dcd1ee604 feat: Add HTTP/HTTPS proxy support in probeMonitorTest and probeMonitor methods 2025-09-02 13:53:57 +01:00
Simon Larsen
f63c69e6a6 feat: Add per-request HTTP/HTTPS proxy agent support in API and WebsiteRequest classes 2025-09-02 13:52:25 +01:00
Simon Larsen
6ba793e871 refactor: Remove axios proxy configuration from ProxyConfig class 2025-09-02 13:49:58 +01:00
Simon Larsen
7afd243992 feat: Remove backticks from inline code rendering in Markdown 2025-09-01 21:19:39 +01:00
Simon Larsen
4f58155719 feat: Enhance inline code rendering by removing backticks from code content 2025-09-01 21:12:30 +01:00
Simon Larsen
553adc4aef feat: Add custom styling for inline code in Markdown renderer 2025-09-01 21:11:37 +01:00
Simon Larsen
f668a626d7 refactor: Simplify code block styling in Markdown renderer 2025-09-01 21:02:19 +01:00
Nawaz Dhandala
e3bd534295 refactor: Add missing commas in logging statements for SCIM and StatusPageSCIM 2025-09-01 20:50:53 +01:00
Simon Larsen
6aa5c3b314 feat: Enhance SCIM Users endpoint to support filtering by email and improve logging 2025-09-01 20:50:12 +01:00
Simon Larsen
3bc4f7267d refactor: Clean up logging statements and remove unnecessary commas in SCIM.ts 2025-09-01 20:48:54 +01:00
Simon Larsen
a7021cf045 refactor: Replace logSCIMOperation with logger.debug for consistent logging in SCIM and StatusPageSCIM 2025-09-01 20:40:27 +01:00
Simon Larsen
2709e1d976 feat: Update values.yaml to allow insecure images temporarily and specify legacy image repositories for PostgreSQL, ClickHouse, and Redis 2025-09-01 16:38:56 +01:00
Nawaz Dhandala
8ec9d2a930 feat: Add type annotations for proxy-related variables in ProxyConfig and monitors 2025-09-01 14:58:33 +01:00
Nawaz Dhandala
224c225789 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-09-01 14:53:33 +01:00
Simon Larsen
85dae7a307 feat: Add proxy configuration options for probes in values.yaml and update README and probe.yaml 2025-09-01 14:53:24 +01:00
Nawaz Dhandala
332a479c22 feat: Improve proxy configuration handling and logging in monitors 2025-09-01 14:50:52 +01:00
Simon Larsen
d708fbbb52 feat: Add proxy configuration examples to Custom Probe documentation and component 2025-09-01 14:49:40 +01:00
Simon Larsen
03bceb959e feat: Enhance proxy support in SSL and Synthetic Monitors to prefer HTTPS, fallback to HTTP 2025-09-01 14:47:01 +01:00
Simon Larsen
efa411206e feat: Update SSL and Synthetic Monitors to use HTTPS proxy configuration 2025-09-01 14:41:57 +01:00
Simon Larsen
27fd99f2e8 feat: Update proxy configuration to support separate HTTP and HTTPS proxy URLs 2025-09-01 14:40:54 +01:00
Simon Larsen
07361bfeb7 feat: Enhance SyntheticMonitor with proxy support in browser launch options 2025-09-01 14:07:15 +01:00
Simon Larsen
bc8a5be0fa feat: Add proxy support for CustomCodeMonitor and SyntheticMonitor with logging 2025-09-01 14:00:55 +01:00
Simon Larsen
518768078a feat: Implement proxy configuration for HTTP requests and add ProxyConfig utility 2025-09-01 13:56:29 +01:00
Simon Larsen
86e95f99ff feat: Add PROXY_URL configuration option for probe and update example env file 2025-09-01 12:52:50 +01:00
Simon Larsen
ea48f56097 feat: Add custom styles for code blocks in blog posts 2025-09-01 12:43:03 +01:00
Nawaz Dhandala
b8b9dd859a Refactor migration files for consistency and readability; update BillingService and ProjectService for improved code clarity; enhance Countries interface formatting; standardize string quotes in various components; fix minor formatting issues in Settings and SendAnnouncementCreatedNotification. 2025-08-27 14:50:48 +01:00
Simon Larsen
d28c14ef24 feat: Update projectId reference in status page notification logic 2025-08-27 14:49:33 +01:00
Simon Larsen
670bec2a12 feat: Validate projectId and statusPageId in getStatusPageLinkInDashboard method 2025-08-27 14:48:40 +01:00
Simon Larsen
aff24845a8 feat: Add financeAccountingEmail handling in updateCustomerBusinessDetails method 2025-08-27 14:26:59 +01:00
Simon Larsen
f280e97c1b feat: Add migration for financeAccountingEmail field in Project model 2025-08-27 14:14:02 +01:00
Simon Larsen
62facf62dd feat: Add financeAccountingEmail field to Project model and update billing settings 2025-08-27 14:12:16 +01:00
Simon Larsen
db0387d81a feat: Update placeholder condition to include empty string check 2025-08-27 14:06:33 +01:00
Simon Larsen
5c4b19ab3d feat: Update nodemon configurations to improve performance and debugging options 2025-08-27 13:41:05 +01:00
Simon Larsen
463755fa4d This will be synced to Stripe and appear on future invoices. 2025-08-27 13:40:26 +01:00
Simon Larsen
85888572de feat: Update subscriber notification statuses to 'Success' for existing records in Incident and related tables 2025-08-27 13:20:07 +01:00
Simon Larsen
475bb25b2d feat: Add businessDetailsCountry field to Project migration and update index 2025-08-27 13:14:40 +01:00
Simon Larsen
badd200aed feat: Add country selection dropdown for billing details and implement country options 2025-08-27 13:14:01 +01:00
Simon Larsen
b40d87cbc9 feat: Add business details country field to Project model and update billing services to handle country code 2025-08-27 13:05:54 +01:00
Simon Larsen
36d0066b3a refactor: Simplify migration by removing unnecessary constraints and columns from Project and GlobalConfig tables 2025-08-27 13:03:01 +01:00
Simon Larsen
a49a0b2cba fix: Ensure blog post cards maintain full height for consistent layout 2025-08-27 12:48:41 +01:00
Simon Larsen
bada97d474 feat: Enhance customer address handling in Stripe by mapping business details to structured address fields 2025-08-27 12:42:17 +01:00
Simon Larsen
a1699f2d55 feat: Add business details field to Project model and update Stripe customer details 2025-08-27 12:37:09 +01:00
Simon Larsen
a11e054291 feat: Add custom link rendering with Tailwind styles and external link handling 2025-08-27 11:19:20 +01:00
Simon Larsen
47cf7ba763 fix: Increase timeout for SSL provisioning and delete old data jobs to accommodate longer processing times 2025-08-27 10:16:35 +01:00
Nawaz Dhandala
4e0dfb3664 fix: Simplify BadDataException handling for disabled and missing monitors in ingestion processes 2025-08-27 10:12:13 +01:00
Simon Larsen
250cb9e547 fix: Gracefully handle expected BadDataException cases for disabled and missing monitors in probe ingestion 2025-08-27 10:10:59 +01:00
Simon Larsen
541257e3c6 fix: Handle expected BadDataException cases for disabled and missing monitors in server monitor ingestion 2025-08-27 10:10:23 +01:00
Simon Larsen
ed43686736 fix: Centralize "Monitor disabled" message and improve error handling for disabled monitors 2025-08-27 10:09:26 +01:00
Simon Larsen
9ca45f23e3 fix: Replace hardcoded "Monitor not found" messages with centralized exception messages 2025-08-27 09:58:34 +01:00
Simon Larsen
e3573a9b77 fix: Refactor monitor not found error handling to use centralized exception messages 2025-08-27 09:55:19 +01:00
Simon Larsen
c9e78044e6 fix: Improve error handling in incoming request ingestion worker to handle disabled monitors gracefully 2025-08-27 09:48:54 +01:00
Nawaz Dhandala
813581dec5 fix: Add return type to logoutUser method and specify type for route in navigateToLoginPage method 2025-08-26 21:39:35 +01:00
Nawaz Dhandala
e528decf73 fix: Refactor QueueWorker options handling; improve logoutUser method formatting and navigation logic in StatusPageUtil 2025-08-26 21:36:56 +01:00
Simon Larsen
42ef41ede8 fix: Enhance QueueWorker options with lock duration and max stalled count; improve telemetry processing with yielding to avoid stall detection 2025-08-26 21:32:38 +01:00
Simon Larsen
af26472db4 fix: Simplify email validation logic and improve user lookup in SCIM user operations 2025-08-26 21:24:39 +01:00
Simon Larsen
44b5c8b668 fix: Enhance email validation and logging in SCIM user operations 2025-08-26 20:47:50 +01:00
Simon Larsen
d821b88ed7 fix: Update Docker image tags and labels for multiple services in release workflow 2025-08-26 18:52:15 +01:00
Simon Larsen
1df43e21ff fix: Refactor logoutUser method and enhance navigation logging in StatusPageUtil 2025-08-26 18:37:49 +01:00
Simon Larsen
76ca6ee7e1 fix: Add missing continuation for APP_VERSION build argument in multiple Docker image deploy jobs 2025-08-26 18:25:32 +01:00
Simon Larsen
dac731a57b refactor: Remove unused mock for ProjectUserService in TeamMemberService tests 2025-08-26 16:56:52 +01:00
Simon Larsen
0f4b248598 fix: Add missing context for Docker image build in nginx deployment 2025-08-26 16:53:25 +01:00
Simon Larsen
b2c14e0380 fix: Add missing build context for multiple Docker image deploy jobs 2025-08-26 16:51:51 +01:00
Simon Larsen
3ab9705bbe fix: Allow deletion of teams in Users component by setting isDeleteable to true 2025-08-26 16:34:19 +01:00
Nawaz Dhandala
40812c8749 refactor: Clean up whitespace in TeamMemberService and SCIM files; update description formatting in Users component 2025-08-26 15:59:22 +01:00
Simon Larsen
45ae1501f2 refactor: Replace ProjectUser with TeamMember in SCIM query for team members 2025-08-26 15:55:23 +01:00
Simon Larsen
13d9f19606 refactor: Remove ProjectUserService calls to streamline TeamMemberService operations 2025-08-26 15:52:28 +01:00
Simon Larsen
ad3221310a refactor: Remove ProjectUser model and associated service to streamline user management 2025-08-26 15:51:06 +01:00
Simon Larsen
659042fcfb fix: Update isCreateable property to false for Teams component 2025-08-26 14:43:54 +01:00
Simon Larsen
d65b9c7b29 feat: Refactor Teams component to use TeamMember model and update filtering logic 2025-08-26 14:42:14 +01:00
Simon Larsen
dc77206e6f feat: Add debug logging for SCIM team operations to track user additions 2025-08-26 14:25:31 +01:00
Nawaz Dhandala
9c1910d3f1 refactor: Remove unnecessary context argument from Docker build commands in workflows 2025-08-26 14:09:31 +01:00
Nawaz Dhandala
afe8f8e6f4 refactor: Implement retry mechanism for account and isolated VM compilation steps in workflows 2025-08-26 13:15:54 +01:00
Nawaz Dhandala
015bd0f870 refactor: Implement retry mechanism for Docker image builds in multiple workflows 2025-08-26 13:12:18 +01:00
Nawaz Dhandala
383c145186 refactor: Integrate retry mechanism for Docker image builds in workflows 2025-08-26 13:06:55 +01:00
Simon Larsen
f155795e6b fix: Remove single quotes from changelog delimiter for correct output formatting 2025-08-26 11:56:22 +01:00
Nawaz Dhandala
757f5b5721 refactor: Improve type annotations for better clarity in error handling 2025-08-26 11:42:15 +01:00
Nawaz Dhandala
694215df06 refactor: Improve code formatting for better readability in multiple files 2025-08-26 11:40:01 +01:00
Nawaz Dhandala
0eb6022f1d Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-08-26 11:38:10 +01:00
Nawaz Dhandala
3109006828 refactor: Replace regex literals with RegExp constructor for improved clarity in MarkdownViewer and StringUtils 2025-08-26 11:38:08 +01:00
Simon Larsen
272695bd11 feat: Enhance error handling and logging in processMetricsAsync and processTracesAsync methods for improved robustness 2025-08-26 11:36:47 +01:00
Simon Larsen
330e3bc106 fix: Correct syntax error in JavaScript expression example for incoming request monitors 2025-08-26 11:26:01 +01:00
Nawaz Dhandala
c7876bf3a3 refactor: Improve regex pattern for fact extraction and enhance code formatting in OtelIngestService 2025-08-26 11:22:36 +01:00
Simon Larsen
345ada5404 feat: Enhance error handling and logging in processLogsAsync method for improved telemetry data ingestion 2025-08-26 11:16:32 +01:00
Simon Larsen
4f97b1b460 feat: Enhance image loading and layout stability across blog views 2025-08-25 10:46:19 +01:00
Nawaz Dhandala
e35ef1809f refactor: Improve code formatting and consistency across Microsoft Teams integration files 2025-08-21 14:45:34 +01:00
Simon Larsen
c2926f3542 feat: Implement structured MessageCard creation from markdown for Microsoft Teams
- Added a method to build a structured MessageCard from markdown input, enhancing message formatting for Teams.
- Extracted title, facts, and actions from markdown to improve rendering in Teams notifications.
2025-08-21 14:43:13 +01:00
Simon Larsen
9495b4bd47 feat: Add Microsoft Teams incoming webhook option to subscriber settings
- Introduced a new property for Microsoft Teams incoming webhook URL in the StatusPageSubscriberService.
- Enhanced subscriber configuration to support Microsoft Teams notifications.
2025-08-21 14:25:49 +01:00
Simon Larsen
ad3f36fdf5 refactor: Simplify Slack and Microsoft Teams notification handling in StatusPageSubscriberService
- Removed try-catch blocks for sending notifications and replaced them with promise chaining for better readability.
- Added logging for successful notification sends and error handling directly in the promise catch.
2025-08-21 14:11:06 +01:00
Simon Larsen
f2221b0a40 feat: Implement Microsoft Teams webhook validation and notification in StatusPage subscriber service
- Added validation for Microsoft Teams incoming webhook URL during subscriber setup.
- Implemented notification sending to Microsoft Teams channel upon successful subscription.
- Updated SideMenu components to reflect the new naming convention for Microsoft Teams subscribers.
2025-08-21 14:04:20 +01:00
Simon Larsen
62fbc1f4be feat: Enhance Microsoft Teams subscriber validation and handling in StatusPage API
- Added validation to ensure Microsoft Teams subscribers are only processed if enabled.
- Updated error messages to include Microsoft Teams workspace name requirements.
- Implemented handling for Microsoft Teams incoming webhook URL and workspace name in subscriber setup.
2025-08-21 13:52:35 +01:00
Simon Larsen
054a2bc8f5 feat: Enable Microsoft Teams subscribers in StatusPage API 2025-08-21 13:49:28 +01:00
Simon Larsen
896787109c feat: Add Microsoft Teams subscriber option to Email, Slack, and SMS subscription pages 2025-08-21 13:47:13 +01:00
Simon Larsen
3a55fcc872 feat: Update microsoftTeamsIncomingWebhookUrl column type to text and add migration 2025-08-21 13:27:44 +01:00
Simon Larsen
2945a48d05 feat: Update microsoftTeamsWorkspaceName column type to VeryLongText and add migration 2025-08-21 13:15:44 +01:00
Simon Larsen
da3a7ddb2e feat: Add migration for Microsoft Teams subscriber functionality in StatusPage 2025-08-21 12:57:16 +01:00
Simon Larsen
04a0bfedaa fix: Make Microsoft Teams subscriber prop required in SideMenu component 2025-08-21 12:17:00 +01:00
Simon Larsen
fa5c7b1e73 feat: Add Microsoft Teams subscriber functionality
- Implemented Microsoft Teams subscribers in the dashboard side menu.
- Created Microsoft Teams subscriber settings page with toggle options.
- Added routes for Microsoft Teams subscribers in the routing configuration.
- Updated page map and route map to include Microsoft Teams subscriber paths.
- Enhanced the app state management to handle Microsoft Teams subscription settings.
- Integrated Microsoft Teams notification sending in the announcement and incident jobs.
- Developed Microsoft Teams subscription management and creation forms.
- Added UI components for managing Microsoft Teams subscribers in the status page.
2025-08-21 12:09:53 +01:00
Simon Larsen
a1c2918cd7 feat: Update homepage heading for improved clarity and emphasis on monitoring capabilities 2025-08-21 10:24:49 +01:00
Simon Larsen
91b11b12c1 fix: Update canonical links for blog posts and remove redundant canonical tags in head partials 2025-08-21 10:15:52 +01:00
Simon Larsen
778a34d631 feat: Implement fallback to commit messages in changelog if empty 2025-08-20 09:17:53 +01:00
Simon Larsen
6dbd838ca4 refactor: Remove mobile redirect script from homepage for cleaner code 2025-08-19 22:23:20 +01:00
Simon Larsen
e09634dc6f feat: Enhance blog listing with featured post display and improved layout 2025-08-19 22:21:12 +01:00
Simon Larsen
af60715de2 Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-19 22:11:14 +01:00
Simon Larsen
3b4c54876e feat: Update default page size for blog pagination from 50 to 24 for improved performance 2025-08-19 22:11:12 +01:00
Nawaz Dhandala
e357100e46 refactor: Remove redundant dynamic section checks from sitemap tests 2025-08-19 20:43:08 +01:00
Simon Larsen
7f3a50076d feat: Add reference section check to sitemap tests for improved coverage 2025-08-19 14:25:37 +01:00
Simon Larsen
9d182b6d55 feat: Add additional static paths to sitemap generation for improved coverage 2025-08-19 14:25:16 +01:00
Simon Larsen
33fce0b53c feat: Implement caching for home URL retrieval to improve performance 2025-08-19 14:15:47 +01:00
Nawaz Dhandala
3db8419349 refactor: Simplify error handling in BlogPostUtil by removing unused error variables 2025-08-19 13:50:35 +01:00
Nawaz Dhandala
dfc324b099 refactor: Improve code formatting and readability in Markdown and BlogPost utilities 2025-08-19 12:58:17 +01:00
Simon Larsen
36521ef37c feat: Update blog post listing to improve layout and add Open Source Commitment section 2025-08-19 12:55:09 +01:00
Simon Larsen
a6f336340e feat: Implement pagination and tag filtering for blog posts 2025-08-19 12:40:51 +01:00
Simon Larsen
c36f782192 feat: Refactor blog post listing and tags display for improved layout and user experience 2025-08-19 12:34:23 +01:00
Simon Larsen
5219f1cfc0 feat: Remove unused imports and the getNameOfGitHubUser function from BlogPostUtil class 2025-08-19 12:29:27 +01:00
Simon Larsen
7f84d50baa feat: Add optional bio field for authors and update blog post template to display it 2025-08-19 12:28:12 +01:00
Simon Larsen
cd2ce3f1a8 feat: Add a newline for improved readability in BlogPostUtil class 2025-08-19 12:15:23 +01:00
Simon Larsen
01b0e01ca8 feat: Implement caching for blog metadata and improve author resolution without GitHub API calls 2025-08-19 12:07:33 +01:00
Simon Larsen
73dc6bb5db feat: Remove social media image display from blog post template 2025-08-19 11:55:38 +01:00
Simon Larsen
ab7fc1c244 feat: Enhance Markdown renderer with improved code block styling and add support for horizontal rules, emphasis, and strikethrough 2025-08-19 11:50:48 +01:00
Simon Larsen
3927bea29c feat: Enhance Markdown rendering with improved list, table, and inline code support; update tag display styling for better visual consistency 2025-08-19 11:47:21 +01:00
Simon Larsen
6060d66c2b feat: Revamp blog post layout with enhanced styling, author details, and reading time estimation 2025-08-19 11:37:10 +01:00
Nawaz Dhandala
9f4869b05f test: Enhance sitemap tests to validate presence of dynamic sections 2025-08-19 10:07:11 +01:00
Nawaz Dhandala
17bdfee012 refactor: Simplify regex usage in sitemap tests and improve middleware formatting 2025-08-19 10:05:59 +01:00
Simon Larsen
4988b9fc7a feat: Refactor mobile menu implementation for improved accessibility and usability 2025-08-19 10:04:20 +01:00
Simon Larsen
9edc6b9f18 feat: Enhance mobile navigation and improve header styling for better accessibility 2025-08-19 09:58:03 +01:00
Simon Larsen
525e19faa6 feat: Improve tab accessibility and keyboard navigation with semantic identifiers 2025-08-19 09:49:23 +01:00
Simon Larsen
588e8976d2 feat: Add middleware to inject home URL for canonical links and update canonical tag in head-basic.ejs 2025-08-19 09:47:59 +01:00
Simon Larsen
d5e28e98fb Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-19 09:39:45 +01:00
Simon Larsen
0e84bc9c40 feat: Enhance accessibility and keyboard navigation for product tabs 2025-08-19 09:39:42 +01:00
Nawaz Dhandala
39e8b1da6b refactor: Enhance type annotations and improve code readability in sitemap tests 2025-08-18 13:47:35 +01:00
Nawaz Dhandala
66c4badd94 refactor: Improve formatting of product pages check in sitemap tests 2025-08-18 13:41:01 +01:00
Simon Larsen
a245fabc34 feat: Add end-to-end tests for sitemap loading and validation 2025-08-18 13:39:19 +01:00
Nawaz Dhandala
fa9fce2774 refactor: Improve type annotations and error handling in various modules 2025-08-18 12:59:17 +01:00
Nawaz Dhandala
a256f4be54 Refactor logging statements for improved readability and consistency across services
- Updated logging statements in Sitemap.ts to enhance code clarity.
- Reformatted logger.info calls in IncomingRequestIngest, OpenTelemetryIngest, Probe, ProbeIngest, and ServerMonitorIngest to use multi-line formatting for better readability.
- Adjusted import statements in Probe to follow a consistent multi-line format.
2025-08-18 12:48:23 +01:00
Simon Larsen
4869172648 feat: Ensure home URL is prioritized in sitemap XML generation 2025-08-18 12:46:45 +01:00
Simon Larsen
1abd323b00 feat: Refactor sitemap generation and clean up unused imports 2025-08-18 12:45:06 +01:00
Simon Larsen
fa196a55cd feat: Update XML builder import and fix attribute assignment in URL set creation 2025-08-18 12:32:57 +01:00
Simon Larsen
29b4417aca feat: Update robots.txt to remove disallowed paths and simplify access 2025-08-18 12:31:57 +01:00
305 changed files with 12506 additions and 4480 deletions

View File

@@ -23,7 +23,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./Accounts/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Accounts/Dockerfile .
docker-build-isolated-vm:
runs-on: ubuntu-latest
@@ -38,7 +42,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./IsolatedVM/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./IsolatedVM/Dockerfile .
docker-build-home:
runs-on: ubuntu-latest
@@ -53,7 +61,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./Home/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Home/Dockerfile .
docker-build-worker:
runs-on: ubuntu-latest
@@ -68,7 +80,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./Worker/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Worker/Dockerfile .
docker-build-workflow:
runs-on: ubuntu-latest
@@ -83,7 +99,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./Workflow/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Workflow/Dockerfile .
docker-build-api-reference:
runs-on: ubuntu-latest
@@ -98,7 +118,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./APIReference/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./APIReference/Dockerfile .
docker-build-docs:
runs-on: ubuntu-latest
@@ -113,7 +137,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./Docs/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Docs/Dockerfile .
docker-build-otel-collector:
@@ -129,7 +157,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./OTelCollector/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./OTelCollector/Dockerfile .
docker-build-app:
runs-on: ubuntu-latest
@@ -145,7 +177,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./App/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./App/Dockerfile .
docker-build-copilot:
@@ -161,7 +197,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./Copilot/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Copilot/Dockerfile .
docker-build-e2e:
runs-on: ubuntu-latest
@@ -177,7 +217,11 @@ jobs:
# build image for accounts service
- name: build docker image
run: sudo docker build -f ./E2E/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./E2E/Dockerfile .
docker-build-admin-dashboard:
runs-on: ubuntu-latest
@@ -192,7 +236,11 @@ jobs:
# build image for home
- name: build docker image
run: sudo docker build -f ./AdminDashboard/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./AdminDashboard/Dockerfile .
docker-build-dashboard:
runs-on: ubuntu-latest
@@ -207,7 +255,11 @@ jobs:
# build image for home
- name: build docker image
run: sudo docker build -f ./Dashboard/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Dashboard/Dockerfile .
docker-build-probe:
runs-on: ubuntu-latest
@@ -222,7 +274,11 @@ jobs:
# build image probe api
- name: build docker image
run: sudo docker build -f ./Probe/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./Probe/Dockerfile .
docker-build-probe-ingest:
runs-on: ubuntu-latest
@@ -237,7 +293,11 @@ jobs:
# build image probe api
- name: build docker image
run: sudo docker build -f ./ProbeIngest/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./ProbeIngest/Dockerfile .
docker-build-server-monitor-ingest:
runs-on: ubuntu-latest
@@ -252,7 +312,11 @@ jobs:
# build image probe api
- name: build docker image
run: sudo docker build -f ./ServerMonitorIngest/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./ServerMonitorIngest/Dockerfile .
docker-build-open-telemetry-ingest:
runs-on: ubuntu-latest
@@ -267,7 +331,11 @@ jobs:
# build image probe api
- name: build docker image
run: sudo docker build -f ./OpenTelemetryIngest/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./OpenTelemetryIngest/Dockerfile .
docker-build-incoming-request-ingest:
runs-on: ubuntu-latest
@@ -282,7 +350,11 @@ jobs:
# build image probe api
- name: build docker image
run: sudo docker build -f ./IncomingRequestIngest/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./IncomingRequestIngest/Dockerfile .
docker-build-fluent-ingest:
runs-on: ubuntu-latest
@@ -297,7 +369,11 @@ jobs:
# build image probe api
- name: build docker image
run: sudo docker build -f ./FluentIngest/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./FluentIngest/Dockerfile .
docker-build-status-page:
runs-on: ubuntu-latest
@@ -312,7 +388,11 @@ jobs:
# build image for home
- name: build docker image
run: sudo docker build -f ./StatusPage/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./StatusPage/Dockerfile .
docker-build-test-server:
runs-on: ubuntu-latest
@@ -327,4 +407,8 @@ jobs:
# build image for mail service
- name: build docker image
run: sudo docker build -f ./TestServer/Dockerfile .
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: sudo docker build -f ./TestServer/Dockerfile .

View File

@@ -20,7 +20,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Accounts && npm install && npm run compile && npm run dep-check
- name: Compile Accounts
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Accounts && npm install && npm run compile && npm run dep-check
compile-isolated-vm:
runs-on: ubuntu-latest
@@ -32,7 +37,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd IsolatedVM && npm install && npm run compile && npm run dep-check
- name: Compile IsolatedVM
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd IsolatedVM && npm install && npm run compile && npm run dep-check
compile-common:
runs-on: ubuntu-latest
@@ -43,7 +53,12 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: latest
- run: cd Common && npm install && npm run compile && npm run dep-check
- name: Compile Common
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Common && npm install && npm run compile && npm run dep-check
compile-app:
runs-on: ubuntu-latest
@@ -55,7 +70,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd App && npm install && npm run compile && npm run dep-check
- name: Compile App
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd App && npm install && npm run compile && npm run dep-check
compile-home:
runs-on: ubuntu-latest
@@ -67,7 +87,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Home && npm install && npm run compile && npm run dep-check
- name: Compile Home
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Home && npm install && npm run compile && npm run dep-check
compile-worker:
runs-on: ubuntu-latest
@@ -79,7 +104,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Worker && npm install && npm run compile && npm run dep-check
- name: Compile Worker
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Worker && npm install && npm run compile && npm run dep-check
compile-workflow:
runs-on: ubuntu-latest
@@ -91,7 +121,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Workflow && npm install && npm run compile && npm run dep-check
- name: Compile Workflow
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Workflow && npm install && npm run compile && npm run dep-check
compile-api-reference:
runs-on: ubuntu-latest
@@ -103,7 +138,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd APIReference && npm install && npm run compile && npm run dep-check
- name: Compile API Reference
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd APIReference && npm install && npm run compile && npm run dep-check
compile-docs-reference:
runs-on: ubuntu-latest
@@ -115,7 +155,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Docs && npm install && npm run compile && npm run dep-check
- name: Compile Docs Reference
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Docs && npm install && npm run compile && npm run dep-check
compile-copilot:
runs-on: ubuntu-latest
@@ -127,7 +172,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Copilot && npm install && npm run compile && npm run dep-check
- name: Compile Copilot
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Copilot && npm install && npm run compile && npm run dep-check
compile-nginx:
runs-on: ubuntu-latest
@@ -140,7 +190,12 @@ jobs:
node-version: latest
- run: cd Common && npm install
- run: cd Nginx && npm install && npm run compile && npm run dep-check
- name: Compile Nginx
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Nginx && npm install && npm run compile && npm run dep-check
compile-infrastructure-agent:
runs-on: ubuntu-latest
@@ -150,7 +205,12 @@ jobs:
- uses: actions/checkout@v4
# Setup Go
- uses: actions/setup-go@v5
- run: cd InfrastructureAgent && go build .
- name: Compile Infrastructure Agent
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd InfrastructureAgent && go build .
compile-admin-dashboard:
@@ -164,7 +224,12 @@ jobs:
node-version: latest
- run: cd Common && npm install
- run: cd AdminDashboard && npm install && npm run compile && npm run dep-check
- name: Compile Admin Dashboard
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd AdminDashboard && npm install && npm run compile && npm run dep-check
compile-dashboard:
runs-on: ubuntu-latest
@@ -177,7 +242,12 @@ jobs:
node-version: latest
- run: cd Common && npm install
- run: cd Dashboard && npm install && npm run compile && npm run dep-check
- name: Compile Dashboard
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Dashboard && npm install && npm run compile && npm run dep-check
compile-e2e:
@@ -191,7 +261,12 @@ jobs:
node-version: latest
- run: sudo apt-get update
- run: cd Common && npm install
- run: cd E2E && npm install && npm run compile && npm run dep-check
- name: Compile E2E
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd E2E && npm install && npm run compile && npm run dep-check
compile-probe:
runs-on: ubuntu-latest
@@ -203,7 +278,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd Probe && npm install && npm run compile && npm run dep-check
- name: Compile Probe
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd Probe && npm install && npm run compile && npm run dep-check
compile-probe-ingest:
runs-on: ubuntu-latest
@@ -215,7 +295,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd ProbeIngest && npm install && npm run compile && npm run dep-check
- name: Compile Probe Ingest
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd ProbeIngest && npm install && npm run compile && npm run dep-check
compile-server-monitor-ingest:
runs-on: ubuntu-latest
@@ -227,7 +312,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd ServerMonitorIngest && npm install && npm run compile && npm run dep-check
- name: Compile Server Monitor Ingest
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd ServerMonitorIngest && npm install && npm run compile && npm run dep-check
compile-open-telemetry-ingest:
runs-on: ubuntu-latest
@@ -239,7 +329,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd OpenTelemetryIngest && npm install && npm run compile && npm run dep-check
- name: Compile Open Telemetry Ingest
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd OpenTelemetryIngest && npm install && npm run compile && npm run dep-check
compile-incoming-request-ingest:
@@ -252,7 +347,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd IncomingRequestIngest && npm install && npm run compile && npm run dep-check
- name: Compile Incoming Request Ingest
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd IncomingRequestIngest && npm install && npm run compile && npm run dep-check
compile-fluent-ingest:
runs-on: ubuntu-latest
@@ -264,7 +364,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd FluentIngest && npm install && npm run compile && npm run dep-check
- name: Compile Fluent Ingest
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd FluentIngest && npm install && npm run compile && npm run dep-check
compile-status-page:
@@ -278,7 +383,12 @@ jobs:
node-version: latest
- run: cd Common && npm install
- run: cd StatusPage && npm install && npm run compile && npm run dep-check
- name: Compile Status Page
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd StatusPage && npm install && npm run compile && npm run dep-check
compile-test-server:
runs-on: ubuntu-latest
@@ -290,7 +400,12 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd TestServer && npm install && npm run compile && npm run dep-check
- name: Compile Test Server
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd TestServer && npm install && npm run compile && npm run dep-check
compile-mcp:
runs-on: ubuntu-latest
@@ -302,4 +417,9 @@ jobs:
with:
node-version: latest
- run: cd Common && npm install
- run: cd MCP && npm install && npm run compile && npm run dep-check
- name: Compile MCP
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: cd MCP && npm install && npm run compile && npm run dep-check

File diff suppressed because it is too large Load Diff

View File

@@ -77,17 +77,21 @@ jobs:
ls -la "$PROVIDER_DIR" || true
- name: Test Go build
run: |
PROVIDER_DIR="./Terraform"
if [ -d "$PROVIDER_DIR" ] && [ -f "$PROVIDER_DIR/go.mod" ]; then
cd "$PROVIDER_DIR"
echo "🔨 Testing Go build..."
go mod tidy
go build -v ./...
echo "✅ Go build successful"
else
echo "⚠️ Cannot test build - missing go.mod or provider directory"
fi
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: |
PROVIDER_DIR="./Terraform"
if [ -d "$PROVIDER_DIR" ] && [ -f "$PROVIDER_DIR/go.mod" ]; then
cd "$PROVIDER_DIR"
echo "🔨 Testing Go build..."
go mod tidy
go build -v ./...
echo "✅ Go build successful"
else
echo "⚠️ Cannot test build - missing go.mod or provider directory"
fi
- name: Upload Terraform provider as artifact
uses: actions/upload-artifact@v4

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import { ViewsPath } from "../Utils/Config";
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import Dictionary from "Common/Types/Dictionary";
// Retrieve resources documentation
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
@@ -16,7 +17,7 @@ export default class ServiceHandler {
// Extract page parameter from request
const page: string | undefined = req.params["page"];
const pageData: any = {};
const pageData: Dictionary<unknown> = {};
// Set default page title and description for the authentication page
pageTitle = "Authentication";

View File

@@ -4,6 +4,7 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import LocalCache from "Common/Server/Infrastructure/LocalCache";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import LocalFile from "Common/Server/Utils/LocalFile";
import Dictionary from "Common/Types/Dictionary";
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
@@ -12,9 +13,9 @@ export default class ServiceHandler {
_req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
const pageData: any = {};
const pageData: Dictionary<unknown> = {};
pageData.selectCode = await LocalCache.getOrSetString(
pageData["selectCode"] = await LocalCache.getOrSetString(
"data-type",
"select",
async () => {
@@ -22,7 +23,7 @@ export default class ServiceHandler {
},
);
pageData.sortCode = await LocalCache.getOrSetString(
pageData["sortCode"] = await LocalCache.getOrSetString(
"data-type",
"sort",
async () => {
@@ -30,7 +31,7 @@ export default class ServiceHandler {
},
);
pageData.equalToCode = await LocalCache.getOrSetString(
pageData["equalToCode"] = await LocalCache.getOrSetString(
"data-type",
"equal-to",
async () => {
@@ -38,7 +39,7 @@ export default class ServiceHandler {
},
);
pageData.equalToOrNullCode = await LocalCache.getOrSetString(
pageData["equalToOrNullCode"] = await LocalCache.getOrSetString(
"data-type",
"equal-to-or-null",
async () => {
@@ -48,7 +49,7 @@ export default class ServiceHandler {
},
);
pageData.greaterThanCode = await LocalCache.getOrSetString(
pageData["greaterThanCode"] = await LocalCache.getOrSetString(
"data-type",
"greater-than",
async () => {
@@ -58,7 +59,7 @@ export default class ServiceHandler {
},
);
pageData.greaterThanOrEqualCode = await LocalCache.getOrSetString(
pageData["greaterThanOrEqualCode"] = await LocalCache.getOrSetString(
"data-type",
"greater-than-or-equal",
async () => {
@@ -68,7 +69,7 @@ export default class ServiceHandler {
},
);
pageData.lessThanCode = await LocalCache.getOrSetString(
pageData["lessThanCode"] = await LocalCache.getOrSetString(
"data-type",
"less-than",
async () => {
@@ -78,7 +79,7 @@ export default class ServiceHandler {
},
);
pageData.lessThanOrEqualCode = await LocalCache.getOrSetString(
pageData["lessThanOrEqualCode"] = await LocalCache.getOrSetString(
"data-type",
"less-than-or-equal",
async () => {
@@ -88,7 +89,7 @@ export default class ServiceHandler {
},
);
pageData.includesCode = await LocalCache.getOrSetString(
pageData["includesCode"] = await LocalCache.getOrSetString(
"data-type",
"includes",
async () => {
@@ -98,7 +99,7 @@ export default class ServiceHandler {
},
);
pageData.lessThanOrNullCode = await LocalCache.getOrSetString(
pageData["lessThanOrNullCode"] = await LocalCache.getOrSetString(
"data-type",
"less-than-or-equal",
async () => {
@@ -108,7 +109,7 @@ export default class ServiceHandler {
},
);
pageData.greaterThanOrNullCode = await LocalCache.getOrSetString(
pageData["greaterThanOrNullCode"] = await LocalCache.getOrSetString(
"data-type",
"less-than-or-equal",
async () => {
@@ -118,7 +119,7 @@ export default class ServiceHandler {
},
);
pageData.isNullCode = await LocalCache.getOrSetString(
pageData["isNullCode"] = await LocalCache.getOrSetString(
"data-type",
"is-null",
async () => {
@@ -126,7 +127,7 @@ export default class ServiceHandler {
},
);
pageData.notNullCode = await LocalCache.getOrSetString(
pageData["notNullCode"] = await LocalCache.getOrSetString(
"data-type",
"not-null",
async () => {
@@ -134,7 +135,7 @@ export default class ServiceHandler {
},
);
pageData.notEqualToCode = await LocalCache.getOrSetString(
pageData["notEqualToCode"] = await LocalCache.getOrSetString(
"data-type",
"not-equals",
async () => {

View File

@@ -2,6 +2,7 @@ import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import { ViewsPath } from "../Utils/Config";
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import Dictionary from "Common/Types/Dictionary";
// Fetch a list of resources used in the application
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
@@ -17,7 +18,7 @@ export default class ServiceHandler {
// Get the 'page' parameter from the request
const page: string | undefined = req.params["page"];
const pageData: any = {};
const pageData: Dictionary<unknown> = {};
// Set the default page title and description
pageTitle = "Errors";

View File

@@ -2,6 +2,7 @@ import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import { ViewsPath } from "../Utils/Config";
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import Dictionary from "Common/Types/Dictionary";
// Get all resources and featured resources from ResourceUtil
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
@@ -20,10 +21,10 @@ export default class ServiceHandler {
// Get the requested page from the URL parameters
const page: string | undefined = req.params["page"];
const pageData: any = {};
const pageData: Dictionary<unknown> = {};
// Set featured resources for the page
pageData.featuredResources = FeaturedResources;
pageData["featuredResources"] = FeaturedResources;
// Set page title and description
pageTitle = "Introduction";

View File

@@ -3,7 +3,10 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import PageNotFoundServiceHandler from "./PageNotFound";
import { AppApiRoute } from "Common/ServiceRoute";
import { ColumnAccessControl } from "Common/Types/BaseDatabase/AccessControl";
import { getTableColumns } from "Common/Types/Database/TableColumn";
import {
getTableColumns,
TableColumnMetadata,
} from "Common/Types/Database/TableColumn";
import Dictionary from "Common/Types/Dictionary";
import ObjectID from "Common/Types/ObjectID";
import Permission, {
@@ -33,7 +36,7 @@ export default class ServiceHandler {
let pageTitle: string = "";
let pageDescription: string = "";
let page: string | undefined = req.params["page"];
const pageData: any = {};
const pageData: Dictionary<unknown> = {};
// Check if page is provided
if (!page) {
@@ -56,7 +59,9 @@ export default class ServiceHandler {
page = "model";
// Get table columns for current resource
const tableColumns: any = getTableColumns(currentResource.model);
const tableColumns: Dictionary<TableColumnMetadata> = getTableColumns(
currentResource.model,
);
// Filter out columns with no access
for (const key in tableColumns) {
@@ -77,12 +82,14 @@ export default class ServiceHandler {
continue;
}
if (tableColumns[key].hideColumnInDocumentation) {
if (tableColumns[key] && tableColumns[key]!.hideColumnInDocumentation) {
delete tableColumns[key];
continue;
}
tableColumns[key].permissions = accessControl;
if (tableColumns[key]) {
(tableColumns[key] as any).permissions = accessControl;
}
}
// Remove unnecessary columns
@@ -92,11 +99,11 @@ export default class ServiceHandler {
delete tableColumns["version"];
// Set page data
pageData.title = currentResource.model.singularName;
pageData.description = currentResource.model.tableDescription;
pageData.columns = tableColumns;
pageData["title"] = currentResource.model.singularName;
pageData["description"] = currentResource.model.tableDescription;
pageData["columns"] = tableColumns;
pageData.tablePermissions = {
pageData["tablePermissions"] = {
read: currentResource.model.readRecordPermissions.map(
(permission: Permission) => {
return PermissionDictionary[permission];
@@ -120,7 +127,7 @@ export default class ServiceHandler {
};
// Cache the list request data
pageData.listRequest = await LocalCache.getOrSetString(
pageData["listRequest"] = await LocalCache.getOrSetString(
"model",
"list-request",
async () => {
@@ -130,7 +137,7 @@ export default class ServiceHandler {
);
// Cache the item request data
pageData.itemRequest = await LocalCache.getOrSetString(
pageData["itemRequest"] = await LocalCache.getOrSetString(
"model",
"item-request",
async () => {
@@ -140,7 +147,7 @@ export default class ServiceHandler {
);
// Cache the item response data
pageData.itemResponse = await LocalCache.getOrSetString(
pageData["itemResponse"] = await LocalCache.getOrSetString(
"model",
"item-response",
async () => {
@@ -152,7 +159,7 @@ export default class ServiceHandler {
);
// Cache the count request data
pageData.countRequest = await LocalCache.getOrSetString(
pageData["countRequest"] = await LocalCache.getOrSetString(
"model",
"count-request",
async () => {
@@ -164,7 +171,7 @@ export default class ServiceHandler {
);
// Cache the count response data
pageData.countResponse = await LocalCache.getOrSetString(
pageData["countResponse"] = await LocalCache.getOrSetString(
"model",
"count-response",
async () => {
@@ -175,7 +182,7 @@ export default class ServiceHandler {
},
);
pageData.updateRequest = await LocalCache.getOrSetString(
pageData["updateRequest"] = await LocalCache.getOrSetString(
"model",
"update-request",
async () => {
@@ -186,7 +193,7 @@ export default class ServiceHandler {
},
);
pageData.updateResponse = await LocalCache.getOrSetString(
pageData["updateResponse"] = await LocalCache.getOrSetString(
"model",
"update-response",
async () => {
@@ -197,7 +204,7 @@ export default class ServiceHandler {
},
);
pageData.createRequest = await LocalCache.getOrSetString(
pageData["createRequest"] = await LocalCache.getOrSetString(
"model",
"create-request",
async () => {
@@ -208,7 +215,7 @@ export default class ServiceHandler {
},
);
pageData.createResponse = await LocalCache.getOrSetString(
pageData["createResponse"] = await LocalCache.getOrSetString(
"model",
"create-response",
async () => {
@@ -219,7 +226,7 @@ export default class ServiceHandler {
},
);
pageData.deleteRequest = await LocalCache.getOrSetString(
pageData["deleteRequest"] = await LocalCache.getOrSetString(
"model",
"delete-request",
async () => {
@@ -230,7 +237,7 @@ export default class ServiceHandler {
},
);
pageData.deleteResponse = await LocalCache.getOrSetString(
pageData["deleteResponse"] = await LocalCache.getOrSetString(
"model",
"delete-response",
async () => {
@@ -242,7 +249,7 @@ export default class ServiceHandler {
);
// Get list response from cache or set it if it's not available
pageData.listResponse = await LocalCache.getOrSetString(
pageData["listResponse"] = await LocalCache.getOrSetString(
"model",
"list-response",
async () => {
@@ -254,14 +261,15 @@ export default class ServiceHandler {
);
// Generate a unique ID for the example object
pageData.exampleObjectID = ObjectID.generate();
pageData["exampleObjectID"] = ObjectID.generate();
// Construct the API path for the current resource
pageData.apiPath =
pageData["apiPath"] =
AppApiRoute.toString() + currentResource.model.crudApiPath?.toString();
// Check if the current resource is a master admin API
pageData.isMasterAdminApiDocs = currentResource.model.isMasterAdminApiDocs;
pageData["isMasterAdminApiDocs"] =
currentResource.model.isMasterAdminApiDocs;
// Render the index page with the required data
return res.render(`${ViewsPath}/pages/index`, {

View File

@@ -7,6 +7,7 @@ import { ViewsPath } from "../Utils/Config";
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import URL from "Common/Types/API/URL";
import Dictionary from "Common/Types/Dictionary";
// Fetch a list of resources used in the application
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
@@ -22,7 +23,7 @@ export default class ServiceHandler {
// Get the 'page' parameter from the request
const page: string | undefined = req.params["page"];
const pageData: any = {
const pageData: Dictionary<unknown> = {
hostUrl: new URL(HttpProtocol, Host).toString(),
};

View File

@@ -4,6 +4,7 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import LocalCache from "Common/Server/Infrastructure/LocalCache";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import LocalFile from "Common/Server/Utils/LocalFile";
import Dictionary from "Common/Types/Dictionary";
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources(); // Get all resources from ResourceUtil
@@ -15,14 +16,14 @@ export default class ServiceHandler {
let pageTitle: string = ""; // Initialize page title
let pageDescription: string = ""; // Initialize page description
const page: string | undefined = req.params["page"]; // Get the page parameter from the request
const pageData: any = {}; // Initialize page data object
const pageData: Dictionary<unknown> = {}; // Initialize page data object
// Set page title and description
pageTitle = "Pagination";
pageDescription = "Learn how to paginate requests with OneUptime API";
// Get response and request code from LocalCache or LocalFile
pageData.responseCode = await LocalCache.getOrSetString(
pageData["responseCode"] = await LocalCache.getOrSetString(
"pagination",
"response",
async () => {
@@ -33,7 +34,7 @@ export default class ServiceHandler {
},
);
pageData.requestCode = await LocalCache.getOrSetString(
pageData["requestCode"] = await LocalCache.getOrSetString(
"pagination",
"request",
async () => {

View File

@@ -3,6 +3,7 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
import { PermissionHelper, PermissionProps } from "Common/Types/Permission";
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import Dictionary from "Common/Types/Dictionary";
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
@@ -17,14 +18,14 @@ export default class ServiceHandler {
// Get the requested page
const page: string | undefined = req.params["page"];
const pageData: any = {};
const pageData: Dictionary<unknown> = {};
// Set page title and description
pageTitle = "Permissions";
pageDescription = "Learn how permissions work with OneUptime";
// Filter permissions to only include those assignable to tenants
pageData.permissions = PermissionHelper.getAllPermissionProps().filter(
pageData["permissions"] = PermissionHelper.getAllPermissionProps().filter(
(i: PermissionProps) => {
return i.isAssignableToTenant;
},

View File

@@ -4,5 +4,7 @@
"ignore": [
"greenlock.d/*"
],
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
"watchOptions": {"useFsEvents": false, "interval": 500},
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
}

View File

@@ -66,6 +66,7 @@
"crypto-js": "^4.2.0",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
"esbuild": "^0.25.5",
"express": "^4.21.1",
"formik": "^2.4.6",

View File

@@ -135,7 +135,6 @@
<link rel="apple-touch-icon-precomposed" href="/img/ou-wb.svg">
<link rel="icon" href="/img/ou-wb.svg">
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
<link rel="canonical" href="/">
<link rel="manifest" href="/manifest.json">
<meta property="og:title" content="OneUptime - One Complete Observability platform.">
<meta property="og:url" content="https://oneuptime.com">

View File

@@ -70,6 +70,7 @@
"crypto-js": "^4.2.0",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
"esbuild": "^0.25.5",
"express": "^4.21.1",
"formik": "^2.4.6",

View File

@@ -69,6 +69,7 @@
"crypto-js": "^4.2.0",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
"esbuild": "^0.25.5",
"express": "^4.21.1",
"formik": "^2.4.6",

View File

@@ -538,11 +538,6 @@ import WorkspaceSettingService, {
Service as WorkspaceSettingServiceType,
} from "Common/Server/Services/WorkspaceSettingService";
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
import ProjectUserService, {
Service as ProjectUserServiceType,
} from "Common/Server/Services/ProjectUserService";
import MonitorFeed from "Common/Models/DatabaseModels/MonitorFeed";
import MonitorFeedService, {
Service as MonitorFeedServiceType,
@@ -736,14 +731,6 @@ const BaseAPIFeatureSet: FeatureSet = {
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<ProjectUser, ProjectUserServiceType>(
ProjectUser,
ProjectUserService,
).getRouter(),
);
//service provider setting
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,

View File

@@ -20,7 +20,6 @@ import NotFoundException from "Common/Types/Exception/NotFoundException";
import OneUptimeDate from "Common/Types/Date";
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import Query from "Common/Types/BaseDatabase/Query";
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
import User from "Common/Models/DatabaseModels/User";
import {
@@ -29,7 +28,6 @@ import {
generateServiceProviderConfig,
generateUsersListResponse,
parseSCIMQueryParams,
logSCIMOperation,
} from "../Utils/SCIMUtils";
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
@@ -89,6 +87,8 @@ const handleUserTeamOperations: (
ignoreHooks: true,
},
});
logger.debug(`SCIM Team operations - user added to team: ${team.id}`);
} else {
logger.debug(
`SCIM Team operations - user already member of team: ${team.id}`,
@@ -119,10 +119,8 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logSCIMOperation(
"ServiceProviderConfig",
"project",
req.params["projectScimId"]!,
logger.debug(
`Project SCIM ServiceProviderConfig - scimId: ${req.params["projectScimId"]!}`,
);
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
@@ -149,7 +147,9 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
@@ -160,15 +160,12 @@ router.get(
const { startIndex, count } = parseSCIMQueryParams(req);
const filter: string = req.query["filter"] as string;
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
);
// Build query for team members in this project
const query: Query<ProjectUser> = {
const query: Query<TeamMember> = {
projectId: projectId,
};
@@ -179,33 +176,35 @@ router.get(
);
if (emailMatch) {
const email: string = emailMatch[1]!;
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`filter by email: ${email}`,
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, filter by email: ${email}`,
);
if (email) {
const user: User | null = await UserService.findOneBy({
query: { email: new Email(email) },
select: { _id: true },
props: { isRoot: true },
});
if (user && user.id) {
query.userId = user.id;
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`found user with id: ${user.id}`,
);
if (Email.isValid(email)) {
const user: User | null = await UserService.findOneBy({
query: { email: new Email(email) },
select: { _id: true },
props: { isRoot: true },
});
if (user && user.id) {
query.userId = user.id;
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, found user with id: ${user.id}`,
);
} else {
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, user not found for email: ${email}`,
);
return Response.sendJsonObjectResponse(
req,
res,
generateUsersListResponse([], startIndex, 0),
);
}
} else {
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`user not found for email: ${email}`,
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, invalid email format in filter: ${email}`,
);
return Response.sendJsonObjectResponse(
req,
@@ -217,11 +216,8 @@ router.get(
}
}
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`query built for projectId: ${projectId}`,
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, query built for projectId: ${projectId}`,
);
// Get team members

View File

@@ -19,7 +19,6 @@ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import {
formatUserForSCIM,
generateServiceProviderConfig,
logSCIMOperation,
} from "../Utils/SCIMUtils";
import Text from "Common/Types/Text";
import HashedString from "Common/Types/HashedString";
@@ -32,10 +31,8 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logSCIMOperation(
"ServiceProviderConfig",
"status-page",
req.params["statusPageScimId"]!,
logger.debug(
`Status Page SCIM ServiceProviderConfig - scimId: ${req.params["statusPageScimId"]!}`,
);
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
@@ -73,17 +70,54 @@ router.get(
parseInt(req.query["count"] as string) || 100,
LIMIT_PER_PROJECT,
);
const filter: string = req.query["filter"] as string;
logger.debug(
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
);
// Build query for status page users
const query: any = {
statusPageId: statusPageId,
};
// Handle SCIM filter for userName
if (filter) {
const emailMatch: RegExpMatchArray | null = filter.match(
/userName eq "([^"]+)"/i,
);
if (emailMatch) {
const email: string = emailMatch[1]!;
logger.debug(
`Status Page SCIM Users list - statusPageScimId: ${req.params["statusPageScimId"]!}, filter by email: ${email}`,
);
if (email) {
if (Email.isValid(email)) {
query.email = new Email(email);
logger.debug(
`Status Page SCIM Users list - statusPageScimId: ${req.params["statusPageScimId"]!}, filtering by email: ${email}`,
);
} else {
logger.debug(
`Status Page SCIM Users list - statusPageScimId: ${req.params["statusPageScimId"]!}, invalid email format in filter: ${email}`,
);
return Response.sendJsonObjectResponse(req, res, {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: 0,
startIndex: startIndex,
itemsPerPage: 0,
Resources: [],
});
}
}
}
}
// Get all private users for this status page
const statusPageUsers: Array<StatusPagePrivateUser> =
await StatusPagePrivateUserService.findBy({
query: {
statusPageId: statusPageId,
},
query: query,
select: {
_id: true,
email: true,

View File

@@ -242,23 +242,3 @@ export const parseSCIMQueryParams: (req: ExpressRequest) => {
return { startIndex, count };
};
/**
* Log SCIM operation with consistent format
*/
export const logSCIMOperation: (
operation: string,
scimType: "project" | "status-page",
scimId: string,
details?: string,
) => void = (
operation: string,
scimType: "project" | "status-page",
scimId: string,
details?: string,
): void => {
const logPrefix: string =
scimType === "project" ? "Project SCIM" : "Status Page SCIM";
const message: string = `${logPrefix} ${operation} - scimId: ${scimId}${details ? `, ${details}` : ""}`;
logger.debug(message);
};

View File

@@ -67,7 +67,6 @@
<link rel="apple-touch-icon-precomposed" href="/img/ou-wb.svg">
<link rel="icon" href="/img/ou-wb.svg">
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
<link rel="canonical" href="/">
<link rel="manifest" href="/manifest.json">
<meta property="og:title" content="OneUptime - One Complete Observability platform.">
<meta property="og:url" content="https://oneuptime.com">

View File

@@ -4,5 +4,13 @@
"ignore": [
"greenlock.d/*"
],
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
"watchOptions": {
"useFsEvents": false,
"interval": 500
},
"env": {
"TS_NODE_TRANSPILE_ONLY": "1",
"TS_NODE_FILES": "false"
},
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
}

1
App/package-lock.json generated
View File

@@ -76,6 +76,7 @@
"crypto-js": "^4.2.0",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
"esbuild": "^0.25.5",
"express": "^4.21.1",
"formik": "^2.4.6",

View File

@@ -716,6 +716,77 @@ export default class IncidentTemplate extends BaseModel {
})
public changeMonitorStatusToId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentTemplate,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentTemplate,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "initialIncidentStateId",
type: TableColumnType.Entity,
modelType: IncidentState,
title: "Initial Incident State",
description:
"Relation to Incident State Object. Incidents created from this template will start in this state.",
})
@ManyToOne(
() => {
return IncidentState;
},
{
eager: false,
nullable: true,
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "initialIncidentStateId" })
public initialIncidentState?: IncidentState = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentTemplate,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentTemplate,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentTemplate,
],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
title: "Initial Incident State ID",
description:
"Relation to Incident State Object ID. Incidents created from this template will start in this state.",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public initialIncidentStateId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -178,7 +178,7 @@ import WorkspaceUserAuthToken from "./WorkspaceUserAuthToken";
import WorkspaceProjectAuthToken from "./WorkspaceProjectAuthToken";
import WorkspaceSetting from "./WorkspaceSetting";
import WorkspaceNotificationRule from "./WorkspaceNotificationRule";
import ProjectUser from "./ProjectUser";
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
import MonitorFeed from "./MonitorFeed";
import MetricType from "./MetricType";
@@ -380,8 +380,6 @@ const AllModelTypes: Array<{
WorkspaceSetting,
WorkspaceNotificationRule,
ProjectUser,
MonitorFeed,
MetricType,

View File

@@ -46,7 +46,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
Permission.EditProjectOnCallDutyPolicyEscalationRule,
],
})
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule"))
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule"))
@Entity({
name: "OnCallDutyPolicyEscalationRule",
})

View File

@@ -47,7 +47,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
Permission.EditProjectOnCallDutyPolicyEscalationRuleSchedule,
],
})
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule-schedule"))
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule-schedule"))
@Entity({
name: "OnCallDutyPolicyEscalationRuleSchedule",
})

View File

@@ -47,7 +47,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
Permission.EditProjectOnCallDutyPolicyEscalationRuleTeam,
],
})
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule-team"))
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule-team"))
@Entity({
name: "OnCallDutyPolicyEscalationRuleTeam",
})

View File

@@ -46,7 +46,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
Permission.EditProjectOnCallDutyPolicyEscalationRuleUser,
],
})
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule-user"))
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule-user"))
@Entity({
name: "OnCallDutyPolicyEscalationRuleUser",
})

View File

@@ -248,6 +248,84 @@ export default class Project extends TenantModel {
})
public paymentProviderCustomerId?: string = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
type: TableColumnType.LongText,
title: "Business Details / Billing Address",
description:
"Business legal name, address and any tax information to appear on invoices.",
})
@Column({
type: ColumnType.LongText,
length: ColumnLength.LongText,
nullable: true,
unique: false,
})
public businessDetails?: string = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Business Country (ISO Alpha-2)",
description:
"Two-letter ISO country code for billing address (e.g., US, GB, DE).",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
unique: false,
})
public businessDetailsCountry?: string = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
type: TableColumnType.Email,
title: "Finance / Accounting Email",
description:
"Invoices, receipts and billing related notifications will be sent to this email in addition to project owner.",
})
@Column({
type: ColumnType.Email,
length: ColumnLength.Email,
nullable: true,
unique: false,
})
public financeAccountingEmail?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
@@ -1213,4 +1291,37 @@ export default class Project extends TenantModel {
type: ColumnType.Boolean,
})
public letCustomerSupportAccessProject?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProject,
],
})
@TableColumn({
required: false,
type: TableColumnType.Boolean,
isDefaultValueColumn: false,
title: "Do NOT auto-add Global Probes to new monitors",
description:
"If enabled, global probes will NOT be automatically added to new monitors. Enable this only if you are using ONLY custom probes to monitor your resources.",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
unique: false,
default: false,
})
public doNotAddGlobalProbesByDefaultOnNewMonitors?: boolean = undefined;
}

View File

@@ -1,336 +0,0 @@
import Project from "./Project";
import Team from "./Team";
import User from "./User";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import AllowUserQueryWithoutTenant from "../../Types/Database/AllowUserQueryWithoutTenant";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy";
import MultiTenentQueryAllowed from "../../Types/Database/MultiTenentQueryAllowed";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@TableAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectTeam,
Permission.CurrentUser,
],
delete: [],
update: [],
})
@MultiTenentQueryAllowed(true)
@AllowUserQueryWithoutTenant(true)
@CurrentUserCanAccessRecordBy("userId")
@TenantColumn("projectId")
@CrudApiEndpoint(new Route("/project-user"))
@Entity({
name: "ProjectUser",
})
@TableMetadata({
tableName: "ProjectUser",
singularName: "Project User",
pluralName: "Project Users",
icon: IconProp.User,
tableDescription:
"This model connects users and teams. This is an internal table. Its a view on TeamMembers table.",
})
export default class ProjectUser extends BaseModel {
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectTeam,
Permission.CurrentUser,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: Team,
title: "Teams",
description: "Teams to which this user belongs.",
})
@ManyToMany(
() => {
return Team;
},
{ eager: false },
)
@JoinTable({
name: "ProjectUserAcceptedTeams",
inverseJoinColumn: {
name: "teamId",
referencedColumnName: "_id",
},
joinColumn: {
name: "projectUserId",
referencedColumnName: "_id",
},
})
public acceptedTeams?: Array<Team> = undefined; // user is accepted to these teams. This is a view on TeamMembers table.
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectTeam,
Permission.CurrentUser,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: Team,
title: "Teams",
description: "Teams to which this user belongs.",
})
@ManyToMany(
() => {
return Team;
},
{ eager: false },
)
@JoinTable({
name: "ProjectUserInvitedTeams",
inverseJoinColumn: {
name: "teamId",
referencedColumnName: "_id",
},
joinColumn: {
name: "projectUserId",
referencedColumnName: "_id",
},
})
public invitedTeams?: Array<Team> = undefined; // user is invited to these teams.
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectTeam,
Permission.CurrentUser,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "projectId",
type: TableColumnType.Entity,
modelType: Project,
title: "Project",
description: "Relation to Project Resource in which this object belongs",
})
@ManyToOne(
() => {
return Project;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectTeam,
Permission.CurrentUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ReadProjectTeam,
Permission.ProjectMember,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "User who belongs to this team.",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectTeam,
Permission.CurrentUser,
],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "User ID",
description: "ID of User who belongs to this team",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "createdByUserId",
type: TableColumnType.Entity,
modelType: User,
title: "Created by User",
description:
"Relation to User who created this object (if this object was created by a User)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "createdByUserId" })
public createdByUser?: User = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Created by User ID",
description:
"User ID who created this object (if this object was created by a User)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
title: "Deleted by User",
modelType: User,
description:
"Relation to User who deleted this object (if this object was deleted by a User)",
})
@ManyToOne(
() => {
return User;
},
{
cascade: false,
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "deletedByUserId" })
public deletedByUser?: User = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Deleted by User ID",
description:
"User ID who deleted this object (if this object was deleted by a User)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
}

View File

@@ -1179,6 +1179,45 @@ export default class StatusPage extends BaseModel {
})
public enableSlackSubscribers?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectStatusPage,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectStatusPage,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Enable Microsoft Teams Subscribers",
description:
"Can Microsoft Teams subscribers subscribe to this Status Page?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,
})
@ColumnBillingAccessControl({
read: PlanType.Free,
update: PlanType.Scale,
create: PlanType.Free,
})
public enableMicrosoftTeamsSubscribers?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -1,3 +1,4 @@
import Monitor from "./Monitor";
import Project from "./Project";
import StatusPage from "./StatusPage";
import User from "./User";
@@ -198,6 +199,53 @@ export default class StatusPageAnnouncement extends BaseModel {
})
public statusPages?: Array<StatusPage> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageAnnouncement,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageAnnouncement,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditStatusPageAnnouncement,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: Monitor,
title: "Monitors",
description:
"List of monitors affected by this announcement. If none are selected, all subscribers will be notified.",
})
@ManyToMany(
() => {
return Monitor;
},
{ eager: false },
)
@JoinTable({
name: "AnnouncementMonitor",
inverseJoinColumn: {
name: "monitorId",
referencedColumnName: "_id",
},
joinColumn: {
name: "announcementId",
referencedColumnName: "_id",
},
})
public monitors?: Array<Monitor> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -1,3 +1,4 @@
import Monitor from "./Monitor";
import Project from "./Project";
import StatusPage from "./StatusPage";
import User from "./User";
@@ -328,6 +329,53 @@ export default class StatusPageAnnouncementTemplate extends BaseModel {
})
public statusPages?: Array<StatusPage> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageAnnouncementTemplate,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageAnnouncementTemplate,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditStatusPageAnnouncementTemplate,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: Monitor,
title: "Monitors",
description:
"List of monitors affected by this announcement template. If none are selected, all subscribers will be notified.",
})
@ManyToMany(
() => {
return Monitor;
},
{ eager: false },
)
@JoinTable({
name: "AnnouncementTemplateMonitor",
inverseJoinColumn: {
name: "monitorId",
referencedColumnName: "_id",
},
joinColumn: {
name: "announcementTemplateId",
referencedColumnName: "_id",
},
})
public monitors?: Array<Monitor> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -379,6 +379,65 @@ export default class StatusPageSubscriber extends BaseModel {
})
public slackWorkspaceName?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageSubscriber,
Permission.Public,
],
read: [],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.LongURL,
title: "Microsoft Teams Incoming Webhook URL",
description:
"Microsoft Teams incoming webhook URL to send notifications to Teams channel",
})
@Column({
nullable: true,
type: ColumnType.LongURL,
transformer: URL.getDatabaseTransformer(),
})
public microsoftTeamsIncomingWebhookUrl?: URL = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageSubscriber,
Permission.Public,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSubscriber,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditStatusPageSubscriber,
],
})
@TableColumn({
required: false,
type: TableColumnType.VeryLongText,
title: "Microsoft Teams Workspace Name",
description:
"Name of the Microsoft Teams workspace for validation and identification",
})
@Column({
nullable: true,
type: ColumnType.VeryLongText,
})
public microsoftTeamsWorkspaceName?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -18,13 +18,20 @@ import WorkspaceType from "../../Types/Workspace/WorkspaceType";
import Permission from "../../Types/Permission";
export interface MiscData {
[key: string]: string;
[key: string]: any;
}
export interface SlackMiscData extends MiscData {
teamId: string;
teamName: string;
botUserId: string;
channelCache?: {
[channelName: string]: {
id: string;
name: string;
lastUpdated: string;
};
};
}
@TenantColumn("projectId")

View File

@@ -235,13 +235,25 @@ export default class BaseAPI<
): Promise<void> {
await this.onBeforeList(req, res);
const skip: PositiveNumber = req.query["skip"]
? new PositiveNumber(req.query["skip"] as string)
: new PositiveNumber(0);
// Extract pagination parameters from query or body (for POST requests)
// Support both 'skip' and 'offset' parameters (offset is alias for skip)
let skipValue: number = 0;
let limitValue: number = DEFAULT_LIMIT;
const limit: PositiveNumber = req.query["limit"]
? new PositiveNumber(req.query["limit"] as string)
: new PositiveNumber(DEFAULT_LIMIT);
if (req.query["skip"]) {
skipValue = parseInt(req.query["skip"] as string, 10) || 0;
} else if (req.body && req.body["skip"] !== undefined) {
skipValue = parseInt(req.body["skip"] as string, 10) || 0;
}
if (req.query["limit"]) {
limitValue = parseInt(req.query["limit"] as string, 10) || DEFAULT_LIMIT;
} else if (req.body && req.body["limit"] !== undefined) {
limitValue = parseInt(req.body["limit"] as string, 10) || DEFAULT_LIMIT;
}
const skip: PositiveNumber = new PositiveNumber(skipValue);
const limit: PositiveNumber = new PositiveNumber(limitValue);
if (limit.toNumber() > LIMIT_PER_PROJECT) {
throw new BadRequestException(

View File

@@ -502,6 +502,7 @@ export default class StatusPageAPI extends BaseAPI<
footerHTML: true,
enableEmailSubscribers: true,
enableSlackSubscribers: true,
enableMicrosoftTeamsSubscribers: true,
enableSmsSubscribers: true,
isPublicStatusPage: true,
allowSubscribersToChooseResources: true,
@@ -2075,6 +2076,10 @@ export default class StatusPageAPI extends BaseAPI<
_id: true,
showAnnouncementAt: true,
endAnnouncementAt: true,
monitors: {
_id: true,
name: true,
},
},
skip: 0,
limit: LIMIT_PER_PROJECT,
@@ -2095,6 +2100,7 @@ export default class StatusPageAPI extends BaseAPI<
displayTooltip: true,
displayDescription: true,
displayName: true,
monitorGroupId: true,
monitor: {
_id: true,
currentMonitorStatusId: true,
@@ -2108,6 +2114,65 @@ export default class StatusPageAPI extends BaseAPI<
},
});
const monitorGroupIds: Array<ObjectID> = statusPageResources
.map((resource: StatusPageResource) => {
return resource.monitorGroupId!;
})
.filter((id: ObjectID) => {
return Boolean(id); // remove nulls
});
// get monitors in the group.
const monitorsInGroup: Dictionary<Array<ObjectID>> = {};
// get monitor status charts.
const monitorsOnStatusPage: Array<ObjectID> = statusPageResources
.map((monitor: StatusPageResource) => {
return monitor.monitorId!;
})
.filter((id: ObjectID) => {
return Boolean(id); // remove nulls
});
for (const monitorGroupId of monitorGroupIds) {
// get monitors in the group.
const groupResources: Array<MonitorGroupResource> =
await MonitorGroupResourceService.findBy({
query: {
monitorGroupId: monitorGroupId,
},
select: {
monitorId: true,
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
const monitorsInGroupIds: Array<ObjectID> = groupResources
.map((resource: MonitorGroupResource) => {
return resource.monitorId!;
})
.filter((id: ObjectID) => {
return Boolean(id); // remove nulls
});
for (const monitorId of monitorsInGroupIds) {
if (
!monitorsOnStatusPage.find((item: ObjectID) => {
return item.toString() === monitorId.toString();
})
) {
monitorsOnStatusPage.push(monitorId);
}
}
monitorsInGroup[monitorGroupId.toString()] = monitorsInGroupIds;
}
const response: JSONObject = {
announcements: BaseModel.toJSONArray(
announcements,
@@ -2117,6 +2182,7 @@ export default class StatusPageAPI extends BaseAPI<
statusPageResources,
StatusPageResource,
),
monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
};
return response;
@@ -2146,6 +2212,7 @@ export default class StatusPageAPI extends BaseAPI<
projectId: true,
enableEmailSubscribers: true,
enableSlackSubscribers: true,
enableMicrosoftTeamsSubscribers: true,
enableSmsSubscribers: true,
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
@@ -2419,6 +2486,7 @@ export default class StatusPageAPI extends BaseAPI<
enableEmailSubscribers: true,
enableSmsSubscribers: true,
enableSlackSubscribers: true,
enableMicrosoftTeamsSubscribers: true,
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
showSubscriberPageOnStatusPage: true,
@@ -2480,15 +2548,28 @@ export default class StatusPageAPI extends BaseAPI<
}
if (
!req.body.data["subscriberEmail"] &&
!req.body.data["subscriberPhone"] &&
!req.body.data["slackWorkspaceName"]
req.body.data["microsoftTeamsWorkspaceName"] &&
!statusPage.enableMicrosoftTeamsSubscribers
) {
logger.debug(
`No email, phone, or slack workspace name provided for subscription to status page with ID: ${objectId}`,
`Microsoft Teams subscribers not enabled for status page with ID: ${objectId}`,
);
throw new BadDataException(
"Email, phone or slack workspace name is required to subscribe to this status page.",
"Microsoft Teams subscribers not enabled for this status page.",
);
}
if (
!req.body.data["subscriberEmail"] &&
!req.body.data["subscriberPhone"] &&
!req.body.data["slackWorkspaceName"] &&
!req.body.data["microsoftTeamsWorkspaceName"]
) {
logger.debug(
`No email, phone, slack workspace name, or Microsoft Teams workspace name provided for subscription to status page with ID: ${objectId}`,
);
throw new BadDataException(
"Email, phone, slack workspace name, or Microsoft Teams workspace name is required to subscribe to this status page.",
);
}
@@ -2512,6 +2593,18 @@ export default class StatusPageAPI extends BaseAPI<
? (req.body.data["slackWorkspaceName"] as string)
: undefined;
const microsoftTeamsIncomingWebhookUrl: string | undefined = req.body.data[
"microsoftTeamsIncomingWebhookUrl"
]
? (req.body.data["microsoftTeamsIncomingWebhookUrl"] as string)
: undefined;
const microsoftTeamsWorkspaceName: string | undefined = req.body.data[
"microsoftTeamsWorkspaceName"
]
? (req.body.data["microsoftTeamsWorkspaceName"] as string)
: undefined;
let statusPageSubscriber: StatusPageSubscriber | null = null;
let isUpdate: boolean = false;
@@ -2570,6 +2663,23 @@ export default class StatusPageAPI extends BaseAPI<
statusPageSubscriber.slackWorkspaceName = slackWorkspaceName;
}
if (microsoftTeamsIncomingWebhookUrl) {
logger.debug(
`Setting subscriber Microsoft Teams webhook: ${microsoftTeamsIncomingWebhookUrl}`,
);
statusPageSubscriber.microsoftTeamsIncomingWebhookUrl = URL.fromString(
microsoftTeamsIncomingWebhookUrl,
);
}
if (microsoftTeamsWorkspaceName) {
logger.debug(
`Setting subscriber Microsoft Teams workspace name: ${microsoftTeamsWorkspaceName}`,
);
statusPageSubscriber.microsoftTeamsWorkspaceName =
microsoftTeamsWorkspaceName;
}
if (
req.body.data["statusPageResources"] &&
!statusPage.allowSubscribersToChooseResources

View File

@@ -68,6 +68,28 @@ export class MigrationName1754671483948 implements MigrationInterface {
await queryRunner.query(
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationStatusMessage" text`,
);
// Set all existing rows' subscriber notification statuses to 'Success' since they were previously considered notified
await queryRunner.query(
`UPDATE "Incident" SET "subscriberNotificationStatusOnIncidentCreated"='Success'`,
);
await queryRunner.query(
`UPDATE "IncidentPublicNote" SET "subscriberNotificationStatusOnNoteCreated"='Success'`,
);
await queryRunner.query(
`UPDATE "IncidentStateTimeline" SET "subscriberNotificationStatus"='Success'`,
);
await queryRunner.query(
`UPDATE "ScheduledMaintenance" SET "subscriberNotificationStatusOnEventScheduled"='Success'`,
);
await queryRunner.query(
`UPDATE "ScheduledMaintenancePublicNote" SET "subscriberNotificationStatusOnNoteCreated"='Success'`,
);
await queryRunner.query(
`UPDATE "ScheduledMaintenanceStateTimeline" SET "subscriberNotificationStatus"='Success'`,
);
await queryRunner.query(
`UPDATE "StatusPageAnnouncement" SET "subscriberNotificationStatus"='Success'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1755775040650 implements MigrationInterface {
public name = "MigrationName1755775040650";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPage" ADD "enableMicrosoftTeamsSubscribers" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsIncomingWebhookUrl" character varying`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsWorkspaceName" character varying(100)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsWorkspaceName"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsIncomingWebhookUrl"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPage" DROP COLUMN "enableMicrosoftTeamsSubscribers"`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1755778495455 implements MigrationInterface {
public name = "MigrationName1755778495455";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsWorkspaceName"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsWorkspaceName" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsWorkspaceName"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsWorkspaceName" character varying(100)`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1755778934927 implements MigrationInterface {
public name = "MigrationName1755778934927";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsIncomingWebhookUrl"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsIncomingWebhookUrl" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1756293325324 implements MigrationInterface {
public name = "MigrationName1756293325324";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" ADD "businessDetails" character varying(500)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" DROP COLUMN "businessDetails"`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1756296282627 implements MigrationInterface {
public name = "MigrationName1756296282627";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" ADD "businessDetailsCountry" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "Project" DROP COLUMN "businessDetailsCountry"`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1756300358095 implements MigrationInterface {
public name = "MigrationName1756300358095";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" ADD "financeAccountingEmail" character varying(100)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" DROP COLUMN "financeAccountingEmail"`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1756821449686 implements MigrationInterface {
public name = "MigrationName1756821449686";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" ADD "doNotAddGlobalProbesByDefaultOnNewMonitors" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Project" DROP COLUMN "doNotAddGlobalProbesByDefaultOnNewMonitors"`,
);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1757416939595 implements MigrationInterface {
public name = "MigrationName1757416939595";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "IncidentTemplate" ADD "initialIncidentStateId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_36317c99429a40d3344d838223" ON "IncidentTemplate" ("initialIncidentStateId") `,
);
await queryRunner.query(
`ALTER TABLE "IncidentTemplate" ADD CONSTRAINT "FK_36317c99429a40d3344d838223f" FOREIGN KEY ("initialIncidentStateId") REFERENCES "IncidentState"("_id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "IncidentTemplate" DROP CONSTRAINT "FK_36317c99429a40d3344d838223f"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_36317c99429a40d3344d838223"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "IncidentTemplate" DROP COLUMN "initialIncidentStateId"`,
);
}
}

View File

@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1757423505855 implements MigrationInterface {
public name = "MigrationName1757423505855";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "AnnouncementMonitor" ("announcementId" uuid NOT NULL, "monitorId" uuid NOT NULL, CONSTRAINT "PK_7acb54ddede76e67b5e2eb84519" PRIMARY KEY ("announcementId", "monitorId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_b43baa07f7be40b5cfb61153fd" ON "AnnouncementMonitor" ("announcementId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_751be8c61cfeb7e1a0af9fcc3a" ON "AnnouncementMonitor" ("monitorId") `,
);
await queryRunner.query(
`CREATE TABLE "AnnouncementTemplateMonitor" ("announcementTemplateId" uuid NOT NULL, "monitorId" uuid NOT NULL, CONSTRAINT "PK_ad19f2b65c1b6b77e7a8d80c028" PRIMARY KEY ("announcementTemplateId", "monitorId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_46bee9106e631ebe9f6c95ff15" ON "AnnouncementTemplateMonitor" ("announcementTemplateId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_0d979cb538fde87c7441d7bc93" ON "AnnouncementTemplateMonitor" ("monitorId") `,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementMonitor" ADD CONSTRAINT "FK_b43baa07f7be40b5cfb61153fd3" FOREIGN KEY ("announcementId") REFERENCES "StatusPageAnnouncement"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementMonitor" ADD CONSTRAINT "FK_751be8c61cfeb7e1a0af9fcc3a0" FOREIGN KEY ("monitorId") REFERENCES "Monitor"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementTemplateMonitor" ADD CONSTRAINT "FK_46bee9106e631ebe9f6c95ff153" FOREIGN KEY ("announcementTemplateId") REFERENCES "StatusPageAnnouncementTemplate"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementTemplateMonitor" ADD CONSTRAINT "FK_0d979cb538fde87c7441d7bc936" FOREIGN KEY ("monitorId") REFERENCES "Monitor"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AnnouncementTemplateMonitor" DROP CONSTRAINT "FK_0d979cb538fde87c7441d7bc936"`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementTemplateMonitor" DROP CONSTRAINT "FK_46bee9106e631ebe9f6c95ff153"`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementMonitor" DROP CONSTRAINT "FK_751be8c61cfeb7e1a0af9fcc3a0"`,
);
await queryRunner.query(
`ALTER TABLE "AnnouncementMonitor" DROP CONSTRAINT "FK_b43baa07f7be40b5cfb61153fd3"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0d979cb538fde87c7441d7bc93"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_46bee9106e631ebe9f6c95ff15"`,
);
await queryRunner.query(`DROP TABLE "AnnouncementTemplateMonitor"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_751be8c61cfeb7e1a0af9fcc3a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_b43baa07f7be40b5cfb61153fd"`,
);
await queryRunner.query(`DROP TABLE "AnnouncementMonitor"`);
}
}

View File

@@ -158,6 +158,15 @@ import { MigrationName1755088852971 } from "./1755088852971-MigrationName";
import { MigrationName1755093133870 } from "./1755093133870-MigrationName";
import { MigrationName1755109893911 } from "./1755109893911-MigrationName";
import { MigrationName1755110936888 } from "./1755110936888-MigrationName";
import { MigrationName1755775040650 } from "./1755775040650-MigrationName";
import { MigrationName1755778495455 } from "./1755778495455-MigrationName";
import { MigrationName1755778934927 } from "./1755778934927-MigrationName";
import { MigrationName1756293325324 } from "./1756293325324-MigrationName";
import { MigrationName1756296282627 } from "./1756296282627-MigrationName";
import { MigrationName1756300358095 } from "./1756300358095-MigrationName";
import { MigrationName1756821449686 } from "./1756821449686-MigrationName";
import { MigrationName1757416939595 } from "./1757416939595-MigrationName";
import { MigrationName1757423505855 } from "./1757423505855-MigrationName";
export default [
InitialMigration,
@@ -320,4 +329,13 @@ export default [
MigrationName1755093133870,
MigrationName1755109893911,
MigrationName1755110936888,
MigrationName1755775040650,
MigrationName1755778495455,
MigrationName1755778934927,
MigrationName1756293325324,
MigrationName1756296282627,
MigrationName1756300358095,
MigrationName1756821449686,
MigrationName1757416939595,
MigrationName1757423505855,
];

View File

@@ -14,7 +14,20 @@ export default class QueueWorker {
public static getWorker(
queueName: QueueName,
onJobInQueue: (job: QueueJob) => Promise<void>,
options: { concurrency: number },
options: {
concurrency: number;
/**
* How long (in ms) the worker will hold a lock on the job before it's considered stalled
* if the event loop is blocked and the lock cannot be extended in time.
* Defaults to BullMQ default (30s) if not provided.
*/
lockDuration?: number;
/**
* Maximum number of times a job can be re-processed due to stall detection
* before being moved to failed. Defaults to BullMQ default (1) if not provided.
*/
maxStalledCount?: number;
},
): Worker {
const worker: Worker = new Worker(queueName, onJobInQueue, {
connection: {
@@ -23,6 +36,11 @@ export default class QueueWorker {
password: RedisPassword,
},
concurrency: options.concurrency,
// Only set these values if provided so we do not override BullMQ defaults
...(options.lockDuration ? { lockDuration: options.lockDuration } : {}),
...(options.maxStalledCount !== undefined
? { maxStalledCount: options.maxStalledCount }
: {}),
});
process.on("SIGINT", async () => {

View File

@@ -273,109 +273,88 @@ export class Service extends DatabaseService<Model> {
throw new BadDataException("currentAlertStateId is required");
}
// Get alert data for feed creation
const alert: Model | null = await this.findOneById({
id: createdItem.id,
select: {
projectId: true,
alertNumber: true,
title: true,
description: true,
alertSeverity: {
name: true,
},
rootCause: true,
remediationNotes: true,
currentAlertState: {
name: true,
},
labels: {
name: true,
},
monitor: {
name: true,
_id: true,
},
},
props: {
isRoot: true,
},
});
if (!alert) {
throw new BadDataException("Alert not found");
}
// Execute core operations in parallel first
const coreOperations: Array<Promise<any>> = [];
// Create feed item asynchronously
coreOperations.push(this.createAlertFeedAsync(alert, createdItem));
// Handle state change asynchronously
coreOperations.push(this.handleAlertStateChangeAsync(createdItem));
// Handle owner assignment asynchronously
if (
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
coreOperations.push(
this.addOwners(
createdItem.projectId,
createdItem.id,
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
[],
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
[],
false,
onCreate.createBy.props,
),
);
}
// Execute core operations in parallel with error handling
Promise.allSettled(coreOperations)
.then((coreResults: any[]) => {
// Log any errors from core operations
coreResults.forEach((result: any, index: number) => {
if (result.status === "rejected") {
// Execute operations sequentially with error handling
Promise.resolve()
.then(async () => {
if (createdItem.projectId && createdItem.id) {
try {
return await this.handleAlertWorkspaceOperationsAsync(createdItem);
} catch (error) {
logger.error(
`Core operation ${index} failed in AlertService.onCreateSuccess: ${result.reason}`,
`Workspace operations failed in AlertService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
}
return Promise.resolve();
})
.then(async () => {
try {
return await this.createAlertFeedAsync(createdItem.id!);
} catch (error) {
logger.error(
`Create alert feed failed in AlertService.onCreateSuccess: ${error}`,
);
return Promise.resolve(); // Continue chain even on error
}
})
.then(async () => {
try {
return await this.handleAlertStateChangeAsync(createdItem);
} catch (error) {
logger.error(
`Handle alert state change failed in AlertService.onCreateSuccess: ${error}`,
);
return Promise.resolve(); // Continue chain even on error
}
})
.then(async () => {
try {
if (
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
return await this.addOwners(
createdItem.projectId!,
createdItem.id!,
(onCreate.createBy.miscDataProps![
"ownerUsers"
] as Array<ObjectID>) || [],
(onCreate.createBy.miscDataProps![
"ownerTeams"
] as Array<ObjectID>) || [],
false,
onCreate.createBy.props,
);
}
});
// Handle on-call duty policies asynchronously
return Promise.resolve();
} catch (error) {
logger.error(
`Add owners failed in AlertService.onCreateSuccess: ${error}`,
);
return Promise.resolve(); // Continue chain even on error
}
})
.then(async () => {
if (
createdItem.onCallDutyPolicies?.length &&
createdItem.onCallDutyPolicies?.length > 0
) {
this.executeAlertOnCallDutyPoliciesAsync(createdItem).catch(
(error: Error) => {
logger.error(
`On-call duty policy execution failed in AlertService.onCreateSuccess: ${error}`,
);
},
);
}
// Handle workspace operations after core operations complete
if (createdItem.projectId && createdItem.id) {
// Run workspace operations in background without blocking response
this.handleAlertWorkspaceOperationsAsync(createdItem).catch(
(error: Error) => {
logger.error(
`Workspace operations failed in AlertService.onCreateSuccess: ${error}`,
);
},
);
try {
return await this.executeAlertOnCallDutyPoliciesAsync(createdItem);
} catch (error) {
logger.error(
`On-call duty policy execution failed in AlertService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
}
return Promise.resolve();
})
.catch((error: Error) => {
logger.error(
`Critical error in AlertService core operations: ${error}`,
`Critical error in AlertService sequential operations: ${error}`,
);
});
@@ -426,21 +405,57 @@ export class Service extends DatabaseService<Model> {
}
@CaptureSpan()
private async createAlertFeedAsync(
alert: Model,
createdItem: Model,
): Promise<void> {
private async createAlertFeedAsync(alertId: ObjectID): Promise<void> {
try {
const createdByUserId: ObjectID | undefined | null =
createdItem.createdByUserId || createdItem.createdByUser?.id;
// Get alert data for feed creation
const alert: Model | null = await this.findOneById({
id: alertId,
select: {
projectId: true,
alertNumber: true,
title: true,
description: true,
alertSeverity: {
name: true,
},
rootCause: true,
createdByUserId: true,
createdByUser: {
_id: true,
name: true,
email: true,
},
remediationNotes: true,
currentAlertState: {
name: true,
},
labels: {
name: true,
},
monitor: {
name: true,
_id: true,
},
},
props: {
isRoot: true,
},
});
let feedInfoInMarkdown: string = `#### 🚨 Alert ${createdItem.alertNumber?.toString()} Created:
if (!alert) {
throw new BadDataException("Alert not found");
}
const createdByUserId: ObjectID | undefined | null =
alert.createdByUserId || alert.createdByUser?.id;
let feedInfoInMarkdown: string = `#### 🚨 Alert ${alert.alertNumber?.toString()} Created:
**${createdItem.title || "No title provided."}**:
**${alert.title || "No title provided."}**:
${createdItem.description || "No description provided."}
${alert.description || "No description provided."}
`;
`;
if (alert.currentAlertState?.name) {
feedInfoInMarkdown += `🔴 **Alert State**: ${alert.currentAlertState.name} \n\n`;
@@ -454,25 +469,25 @@ ${createdItem.description || "No description provided."}
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
const monitor: Monitor = alert.monitor;
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(alert.projectId!, monitor.id!)).toString()})\n`;
feedInfoInMarkdown += `\n\n`;
}
if (createdItem.rootCause) {
if (alert.rootCause) {
feedInfoInMarkdown += `\n
📄 **Root Cause**:
${createdItem.rootCause || "No root cause provided."}
${alert.rootCause || "No root cause provided."}
`;
}
if (createdItem.remediationNotes) {
if (alert.remediationNotes) {
feedInfoInMarkdown += `\n
🎯 **Remediation Notes**:
${createdItem.remediationNotes || "No remediation notes provided."}
${alert.remediationNotes || "No remediation notes provided."}
`;
@@ -480,13 +495,13 @@ ${createdItem.remediationNotes || "No remediation notes provided."}
const alertCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
await AlertWorkspaceMessages.getAlertCreateMessageBlocks({
alertId: createdItem.id!,
projectId: createdItem.projectId!,
alertId: alert.id!,
projectId: alert.projectId!,
});
await AlertFeedService.createAlertFeedItem({
alertId: createdItem.id!,
projectId: createdItem.projectId!,
alertId: alert.id!,
projectId: alert.projectId!,
alertFeedEventType: AlertFeedEventType.AlertCreated,
displayColor: Red500,
feedInfoInMarkdown: feedInfoInMarkdown,

View File

@@ -80,6 +80,94 @@ export class BillingService extends BaseService {
await this.stripe.customers.update(id, { name: newName });
}
@CaptureSpan()
public async updateCustomerBusinessDetails(
id: string,
businessDetails: string,
countryCode?: string | null,
financeAccountingEmail?: string | null,
): Promise<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
}
// Goal: Update Stripe Customer "Billing details" (address fields) rather than invoice footer.
// We only have a single free-form textarea. We'll map:
// First non-empty line -> address.line1
// Second non-empty line (if any) and remaining (joined, truncated) -> address.line2
// We also persist full text in metadata so we can reconstruct or improve parsing later.
// NOTE: Because Stripe requires structured address, any city/state/postal/country detection
// would be heuristic; we keep it simple unless we later add structured fields.
const lines: Array<string> = businessDetails
.split(/\r?\n/)
.map((l: string) => {
return l.trim();
})
.filter((l: string) => {
return l.length > 0;
});
let line1: string | undefined = undefined;
let line2: string | undefined = undefined;
if (lines && lines.length > 0) {
const first: string = lines[0]!; // non-null
line1 = first.substring(0, 200); // Stripe typical limit safeguard.
}
if (lines && lines.length > 1) {
const rest: string = lines.slice(1).join(", ");
line2 = rest.substring(0, 200);
}
const metadata: Record<string, string> = {
business_details_full: businessDetails.substring(0, 5000),
};
if (financeAccountingEmail) {
metadata["finance_accounting_email"] = financeAccountingEmail.substring(
0,
200,
);
} else {
// Remove if cleared
metadata["finance_accounting_email"] = "";
}
const updateParams: Stripe.CustomerUpdateParams = {
metadata,
address: {},
};
// If finance / accounting email provided, set it as the customer email so Stripe sends
// invoices / receipts there. (Stripe only supports a single email via API currently.)
if (financeAccountingEmail && financeAccountingEmail.trim().length > 0) {
updateParams.email = financeAccountingEmail.trim();
}
if (line1) {
updateParams.address = updateParams.address || {};
updateParams.address.line1 = line1;
}
if (line2) {
updateParams.address = updateParams.address || {};
updateParams.address.line2 = line2;
}
if (countryCode) {
updateParams.address = updateParams.address || {};
// Stripe expects uppercase 2-letter ISO code
updateParams.address.country = countryCode.toUpperCase();
}
if (!line1 && !line2 && !countryCode) {
// Clear address if empty details submitted.
updateParams.address = {
line1: "",
line2: "",
} as any;
}
await this.stripe.customers.update(id, updateParams);
}
@CaptureSpan()
public async deleteCustomer(id: string): Promise<void> {
if (!this.isBillingEnabled()) {

View File

@@ -64,6 +64,8 @@ import MetricType from "../../Models/DatabaseModels/MetricType";
import UpdateBy from "../Types/Database/UpdateBy";
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
import Dictionary from "../../Types/Dictionary";
import IncidentTemplateService from "./IncidentTemplateService";
import IncidentTemplate from "../../Models/DatabaseModels/IncidentTemplate";
// key is incidentId for this dictionary.
type UpdateCarryForward = Dictionary<{
@@ -466,24 +468,97 @@ export class Service extends DatabaseService<Model> {
const projectId: ObjectID =
createBy.props.tenantId || createBy.data.projectId!;
const incidentState: IncidentState | null =
await IncidentStateService.findOneBy({
query: {
projectId: projectId,
isCreatedState: true,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
// Determine the initial incident state
let initialIncidentStateId: ObjectID | undefined = undefined;
if (!incidentState || !incidentState.id) {
throw new BadDataException(
"Created incident state not found for this project. Please add created incident state from settings.",
);
// If currentIncidentStateId is already provided (manual selection), use it
if (createBy.data.currentIncidentStateId) {
initialIncidentStateId = createBy.data.currentIncidentStateId;
// Validate that the provided state exists and belongs to the project
const providedState: IncidentState | null =
await IncidentStateService.findOneBy({
query: {
_id: initialIncidentStateId.toString(),
projectId: projectId,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!providedState) {
throw new BadDataException(
"Invalid incident state provided. The state does not exist or does not belong to this project.",
);
}
} else if (createBy.data.createdIncidentTemplateId) {
// If created from a template, check if template has a custom initial state
const incidentTemplate: IncidentTemplate | null =
await IncidentTemplateService.findOneBy({
query: {
_id: createBy.data.createdIncidentTemplateId.toString(),
projectId: projectId,
},
select: {
initialIncidentStateId: true,
},
props: {
isRoot: true,
},
});
if (incidentTemplate?.initialIncidentStateId) {
initialIncidentStateId = incidentTemplate.initialIncidentStateId;
// Validate that the template's state exists and belongs to the project
const templateState: IncidentState | null =
await IncidentStateService.findOneBy({
query: {
_id: initialIncidentStateId.toString(),
projectId: projectId,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!templateState) {
// Fall back to default if template state is invalid
initialIncidentStateId = undefined;
}
}
}
// If no custom state is provided or found, fall back to default created state
if (!initialIncidentStateId) {
const incidentState: IncidentState | null =
await IncidentStateService.findOneBy({
query: {
projectId: projectId,
isCreatedState: true,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!incidentState || !incidentState.id) {
throw new BadDataException(
"Created incident state not found for this project. Please add created incident state from settings.",
);
}
initialIncidentStateId = incidentState.id;
}
let mutex: SemaphoreMutex | null = null;
@@ -517,7 +592,7 @@ export class Service extends DatabaseService<Model> {
projectId: projectId,
})) + 1;
createBy.data.currentIncidentStateId = incidentState.id;
createBy.data.currentIncidentStateId = initialIncidentStateId;
createBy.data.incidentNumber = incidentNumberForThisIncident;
if (
@@ -597,6 +672,12 @@ export class Service extends DatabaseService<Model> {
name: true,
},
rootCause: true,
createdByUserId: true,
createdByUser: {
_id: true,
name: true,
email: true,
},
remediationNotes: true,
currentIncidentState: {
name: true,
@@ -618,90 +699,121 @@ export class Service extends DatabaseService<Model> {
throw new BadDataException("Incident not found");
}
// Execute core operations in parallel first
const coreOperations: Array<Promise<any>> = [];
// Create feed item asynchronously
coreOperations.push(this.createIncidentFeedAsync(incident, createdItem));
// Handle state change asynchronously
coreOperations.push(this.handleIncidentStateChangeAsync(createdItem));
// Handle owner assignment asynchronously
if (
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
coreOperations.push(
this.addOwners(
createdItem.projectId,
createdItem.id,
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
[],
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
[],
false,
onCreate.createBy.props,
),
);
}
// Handle monitor status change and active monitoring asynchronously
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
coreOperations.push(
this.handleMonitorStatusChangeAsync(createdItem, onCreate),
);
}
coreOperations.push(
this.disableActiveMonitoringIfManualIncident(createdItem.id!),
);
// Release mutex immediately
this.releaseMutexAsync(onCreate, createdItem.projectId!);
// Execute core operations in parallel with error handling
Promise.allSettled(coreOperations)
.then((coreResults: any[]) => {
// Log any errors from core operations
coreResults.forEach((result: any, index: number) => {
if (result.status === "rejected") {
logger.error(
`Core operation ${index} failed in IncidentService.onCreateSuccess: ${result.reason}`,
// Execute operations sequentially with error handling
Promise.resolve()
.then(async () => {
try {
if (createdItem.projectId && createdItem.id) {
return await this.handleIncidentWorkspaceOperationsAsync(
createdItem,
);
}
});
// Handle on-call duty policies asynchronously
if (
createdItem.onCallDutyPolicies?.length &&
createdItem.onCallDutyPolicies?.length > 0
) {
this.executeOnCallDutyPoliciesAsync(createdItem).catch(
(error: Error) => {
logger.error(
`On-call duty policy execution failed in IncidentService.onCreateSuccess: ${error}`,
);
},
return Promise.resolve();
} catch (error) {
logger.error(
`Workspace operations failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
// Handle workspace operations after core operations complete
if (createdItem.projectId && createdItem.id) {
// Run workspace operations in background without blocking response
this.handleIncidentWorkspaceOperationsAsync(createdItem).catch(
(error: Error) => {
logger.error(
`Workspace operations failed in IncidentService.onCreateSuccess: ${error}`,
);
},
})
.then(async () => {
try {
return await this.createIncidentFeedAsync(incident);
} catch (error) {
logger.error(
`Create incident feed failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
return await this.handleIncidentStateChangeAsync(createdItem);
} catch (error) {
logger.error(
`Handle incident state change failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
if (
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
return await this.addOwners(
createdItem.projectId!,
createdItem.id!,
(onCreate.createBy.miscDataProps[
"ownerUsers"
] as Array<ObjectID>) || [],
(onCreate.createBy.miscDataProps[
"ownerTeams"
] as Array<ObjectID>) || [],
false,
onCreate.createBy.props,
);
}
return Promise.resolve();
} catch (error) {
logger.error(
`Add owners failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
return await this.handleMonitorStatusChangeAsync(
createdItem,
onCreate,
);
}
return Promise.resolve();
} catch (error) {
logger.error(
`Monitor status change failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
return await this.disableActiveMonitoringIfManualIncident(
createdItem.id!,
);
} catch (error) {
logger.error(
`Disable active monitoring failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
if (
createdItem.onCallDutyPolicies?.length &&
createdItem.onCallDutyPolicies?.length > 0
) {
return await this.executeOnCallDutyPoliciesAsync(createdItem);
}
return Promise.resolve();
} catch (error) {
logger.error(
`On-call duty policy execution failed in IncidentService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.catch((error: Error) => {
logger.error(
`Critical error in IncidentService core operations: ${error}`,
`Critical error in IncidentService sequential operations: ${error}`,
);
});
@@ -749,19 +861,16 @@ export class Service extends DatabaseService<Model> {
}
@CaptureSpan()
private async createIncidentFeedAsync(
incident: Model,
createdItem: Model,
): Promise<void> {
private async createIncidentFeedAsync(incident: Model): Promise<void> {
try {
const createdByUserId: ObjectID | undefined | null =
createdItem.createdByUserId || createdItem.createdByUser?.id;
incident.createdByUserId || incident.createdByUser?.id;
let feedInfoInMarkdown: string = `#### 🚨 Incident ${createdItem.incidentNumber?.toString()} Created:
let feedInfoInMarkdown: string = `#### 🚨 Incident ${incident.incidentNumber?.toString()} Created:
**${createdItem.title || "No title provided."}**:
**${incident.title || "No title provided."}**:
${createdItem.description || "No description provided."}
${incident.description || "No description provided."}
`;
@@ -777,26 +886,26 @@ ${createdItem.description || "No description provided."}
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
for (const monitor of incident.monitors) {
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(incident.projectId!, monitor.id!)).toString()})\n`;
}
feedInfoInMarkdown += `\n\n`;
}
if (createdItem.rootCause) {
if (incident.rootCause) {
feedInfoInMarkdown += `\n
📄 **Root Cause**:
${createdItem.rootCause || "No root cause provided."}
${incident.rootCause || "No root cause provided."}
`;
}
if (createdItem.remediationNotes) {
if (incident.remediationNotes) {
feedInfoInMarkdown += `\n
🎯 **Remediation Notes**:
${createdItem.remediationNotes || "No remediation notes provided."}
${incident.remediationNotes || "No remediation notes provided."}
`;
@@ -804,13 +913,13 @@ ${createdItem.remediationNotes || "No remediation notes provided."}
const incidentCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
await IncidentWorkspaceMessages.getIncidentCreateMessageBlocks({
incidentId: createdItem.id!,
projectId: createdItem.projectId!,
incidentId: incident.id!,
projectId: incident.projectId!,
});
await IncidentFeedService.createIncidentFeedItem({
incidentId: createdItem.id!,
projectId: createdItem.projectId!,
incidentId: incident.id!,
projectId: incident.projectId!,
incidentFeedEventType: IncidentFeedEventType.IncidentCreated,
displayColor: Red500,
feedInfoInMarkdown: feedInfoInMarkdown,

View File

@@ -67,6 +67,8 @@ import QueryOperator from "../../Types/BaseDatabase/QueryOperator";
import { FindWhere } from "../../Types/BaseDatabase/Query";
import logger from "../Utils/Logger";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
import Project from "../../Models/DatabaseModels/Project";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -162,11 +164,11 @@ export class Service extends DatabaseService<Model> {
});
if (!monitor) {
throw new BadDataException("Monitor not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
if (!monitor.id) {
throw new BadDataException("Monitor id not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
projectId = monitor.projectId!;
@@ -502,105 +504,122 @@ ${createdItem.description?.trim() || "No description provided."}
feedInfoInMarkdown += `\n\n`;
}
// Parallelize operations that don't depend on each other
const parallelOperations: Array<Promise<any>> = [];
// 1. Essential monitor status operation (must complete first)
await this.changeMonitorStatus(
createdItem.projectId,
[createdItem.id],
createdItem.currentMonitorStatusId,
false, // notifyOwners = false
"This status was created when the monitor was created.",
undefined,
onCreate.createBy.props,
);
// 2. Start core operations in parallel that can run asynchronously (excluding workspace operations)
// Add default probes if needed (can be slow with many probes)
if (
createdItem.monitorType &&
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
) {
parallelOperations.push(
this.addDefaultProbesToMonitor(
createdItem.projectId,
createdItem.id,
).catch((error: Error) => {
logger.error("Error in adding default probes");
logger.error(error);
// Don't fail monitor creation due to probe creation issues
}),
);
}
// Billing operations
if (IsBillingEnabled) {
parallelOperations.push(
ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
createdItem.projectId,
).catch((error: Error) => {
logger.error("Error in billing operations");
logger.error(error);
// Don't fail monitor creation due to billing issues
}),
);
}
// Owner operations
if (
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
parallelOperations.push(
this.addOwners(
createdItem.projectId,
createdItem.id,
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
[],
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
[],
false,
onCreate.createBy.props,
).catch((error: Error) => {
logger.error("Error in adding owners");
logger.error(error);
// Don't fail monitor creation due to owner issues
}),
);
}
// Probe status refresh (can be expensive with many probes)
parallelOperations.push(
this.refreshMonitorProbeStatus(createdItem.id).catch((error: Error) => {
logger.error("Error in refreshing probe status");
logger.error(error);
// Don't fail monitor creation due to probe status issues
}),
);
// Wait for core operations to complete, then handle workspace operations
Promise.allSettled(parallelOperations)
.then(() => {
// Handle workspace operations after core operations complete
// Run workspace operations in background without blocking response
this.handleWorkspaceOperationsAsync({
projectId: createdItem.projectId!,
monitorId: createdItem.id!,
monitorName: createdItem.name!,
feedInfoInMarkdown,
createdByUserId,
}).catch((error: Error) => {
logger.error("Error in workspace operations");
logger.error(error);
// Don't fail monitor creation due to workspace issues
});
// Execute operations sequentially with error handling (workspace first)
Promise.resolve()
.then(async () => {
try {
return await this.handleWorkspaceOperationsAsync({
projectId: createdItem.projectId!,
monitorId: createdItem.id!,
monitorName: createdItem.name!,
feedInfoInMarkdown,
createdByUserId,
});
} catch (error) {
logger.error(
"Workspace operations failed in MonitorService.onCreateSuccess",
);
logger.error(error as Error);
return Promise.resolve();
}
})
.then(async () => {
try {
return await this.changeMonitorStatus(
createdItem.projectId!,
[createdItem.id!],
createdItem.currentMonitorStatusId!,
false, // notifyOwners = false
"This status was created when the monitor was created.",
undefined,
onCreate.createBy.props,
);
} catch (error) {
logger.error(
"Change monitor status failed in MonitorService.onCreateSuccess",
);
logger.error(error as Error);
return Promise.resolve();
}
})
.then(async () => {
try {
if (
createdItem.monitorType &&
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
) {
return await this.addDefaultProbesToMonitor(
createdItem.projectId!,
createdItem.id!,
);
}
return Promise.resolve();
} catch (error) {
logger.error(
"Add default probes failed in MonitorService.onCreateSuccess",
);
logger.error(error as Error);
return Promise.resolve();
}
})
.then(async () => {
try {
if (IsBillingEnabled) {
return await ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
createdItem.projectId!,
);
}
return Promise.resolve();
} catch (error) {
logger.error(
"Billing operations failed in MonitorService.onCreateSuccess",
);
logger.error(error as Error);
return Promise.resolve();
}
})
.then(async () => {
try {
if (
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
return await this.addOwners(
createdItem.projectId!,
createdItem.id!,
(onCreate.createBy.miscDataProps[
"ownerUsers"
] as Array<ObjectID>) || [],
(onCreate.createBy.miscDataProps[
"ownerTeams"
] as Array<ObjectID>) || [],
false,
onCreate.createBy.props,
);
}
return Promise.resolve();
} catch (error) {
logger.error("Add owners failed in MonitorService.onCreateSuccess");
logger.error(error as Error);
return Promise.resolve();
}
})
.then(async () => {
try {
return await this.refreshMonitorProbeStatus(createdItem.id!);
} catch (error) {
logger.error(
"Refresh probe status failed in MonitorService.onCreateSuccess",
);
logger.error(error as Error);
return Promise.resolve();
}
})
.catch((error: Error) => {
logger.error("Error in parallel monitor creation operations");
logger.error(error);
logger.error(
`Critical error in MonitorService sequential operations: ${error}`,
);
});
return createdItem;
@@ -790,21 +809,40 @@ ${createdItem.description?.trim() || "No description provided."}
projectId: ObjectID,
monitorId: ObjectID,
): Promise<void> {
const globalProbes: Array<Probe> = await ProbeService.findBy({
query: {
isGlobalProbe: true,
shouldAutoEnableProbeOnNewMonitors: true,
},
// Fetch project to see if global probes should be added automatically.
const project: Project | null = await ProjectService.findOneById({
id: projectId,
select: {
_id: true,
doNotAddGlobalProbesByDefaultOnNewMonitors: true,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
const shouldSkipGlobalProbes: boolean =
project?.doNotAddGlobalProbesByDefaultOnNewMonitors === true;
let globalProbes: Array<Probe> = [];
if (!shouldSkipGlobalProbes) {
globalProbes = await ProbeService.findBy({
query: {
isGlobalProbe: true,
shouldAutoEnableProbeOnNewMonitors: true,
},
select: {
_id: true,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
const projectProbes: Array<Probe> = await ProbeService.findBy({
query: {
isGlobalProbe: false,
@@ -1389,7 +1427,7 @@ ${createdItem.description?.trim() || "No description provided."}
});
if (!monitor) {
throw new BadDataException("Monitor not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
return (monitor.postUpdatesToWorkspaceChannels || []).filter(
@@ -1419,7 +1457,7 @@ ${createdItem.description?.trim() || "No description provided."}
});
if (!monitor) {
throw new BadDataException("Monitor not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
return monitor.name || "";

View File

@@ -128,18 +128,50 @@ export default class OTelIngestService {
Metric,
) as Metric;
// Handle start timestamp safely
if (datapoint["startTimeUnixNano"]) {
newDbMetric.startTimeUnixNano = datapoint["startTimeUnixNano"] as number;
newDbMetric.startTime = OneUptimeDate.fromUnixNano(
datapoint["startTimeUnixNano"] as number,
);
try {
let startTimeUnixNano: number;
if (typeof datapoint["startTimeUnixNano"] === "string") {
startTimeUnixNano = parseFloat(datapoint["startTimeUnixNano"]);
if (isNaN(startTimeUnixNano)) {
startTimeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
}
} else {
startTimeUnixNano =
(datapoint["startTimeUnixNano"] as number) ||
OneUptimeDate.getCurrentDateAsUnixNano();
}
newDbMetric.startTimeUnixNano = startTimeUnixNano;
newDbMetric.startTime = OneUptimeDate.fromUnixNano(startTimeUnixNano);
} catch {
const currentNano: number = OneUptimeDate.getCurrentDateAsUnixNano();
newDbMetric.startTimeUnixNano = currentNano;
newDbMetric.startTime = OneUptimeDate.getCurrentDate();
}
}
// Handle end timestamp safely
if (datapoint["timeUnixNano"]) {
newDbMetric.timeUnixNano = datapoint["timeUnixNano"] as number;
newDbMetric.time = OneUptimeDate.fromUnixNano(
datapoint["timeUnixNano"] as number,
);
try {
let timeUnixNano: number;
if (typeof datapoint["timeUnixNano"] === "string") {
timeUnixNano = parseFloat(datapoint["timeUnixNano"]);
if (isNaN(timeUnixNano)) {
timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
}
} else {
timeUnixNano =
(datapoint["timeUnixNano"] as number) ||
OneUptimeDate.getCurrentDateAsUnixNano();
}
newDbMetric.timeUnixNano = timeUnixNano;
newDbMetric.time = OneUptimeDate.fromUnixNano(timeUnixNano);
} catch {
const currentNano: number = OneUptimeDate.getCurrentDateAsUnixNano();
newDbMetric.timeUnixNano = currentNano;
newDbMetric.time = OneUptimeDate.getCurrentDate();
}
}
if (Object.keys(datapoint).includes("asInt")) {
@@ -174,7 +206,7 @@ export default class OTelIngestService {
serviceName: data.telemetryServiceName,
}),
...TelemetryUtil.getAttributes({
items: datapoint["attributes"] as JSONArray,
items: (datapoint["attributes"] as JSONArray) || [],
prefixKeysWithString: "metricAttributes",
}),
};

View File

@@ -273,6 +273,38 @@ export class ProjectService extends DatabaseService<Model> {
updateBy: UpdateBy<Model>,
): Promise<OnUpdate<Model>> {
if (IsBillingEnabled) {
if (
updateBy.data.businessDetails ||
updateBy.data.businessDetailsCountry ||
updateBy.data.financeAccountingEmail
) {
// Sync to Stripe.
const project: Model | null = await this.findOneById({
id: new ObjectID(updateBy.query._id! as string),
select: {
paymentProviderCustomerId: true,
financeAccountingEmail: true,
},
props: { isRoot: true },
});
if (project?.paymentProviderCustomerId) {
try {
await BillingService.updateCustomerBusinessDetails(
project.paymentProviderCustomerId,
(updateBy.data.businessDetails as string) || "",
(updateBy.data.businessDetailsCountry as string) || null,
(updateBy.data.financeAccountingEmail as string) ||
(project as any).financeAccountingEmail ||
null,
);
} catch (err) {
logger.error(
"Failed to update Stripe customer business details: " + err,
);
}
}
}
if (updateBy.data.enableAutoRechargeSmsOrCallBalance) {
await NotificationService.rechargeIfBalanceIsLow(
new ObjectID(updateBy.query._id! as string),

View File

@@ -1,132 +0,0 @@
import TeamMember from "../../Models/DatabaseModels/TeamMember";
import LIMIT_MAX from "../../Types/Database/LimitMax";
import ObjectID from "../../Types/ObjectID";
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/ProjectUser";
import TeamMemberService from "./TeamMemberService";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
@CaptureSpan()
public async refreshProjectUsersByProject(data: {
projectId: ObjectID;
}): Promise<void> {
// get all team members by user
// first delete all project users by project id.
await this.deleteBy({
query: {
projectId: data.projectId,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
// get all team members by project.
const teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
query: {
projectId: data.projectId,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
select: {
userId: true,
teamId: true,
team: {
_id: true,
},
hasAcceptedInvitation: true,
},
});
// create project users by team members.
const projectUsersToCreate: Array<Model> = [];
for (const teamMember of teamMembers) {
// check if the user already exists in the project users.
// if yes then add the team to the project user acceptedTeams, if the invitation is accepted.
// if no then create a new project user.
// if the user is not accepted the invitation then add the team to invitedTeams of the project user.
// if the user is accepted the invitation then add the team to acceptedTeams of the project user.
let doesProjectUserExist: boolean = false;
for (const item of projectUsersToCreate) {
if (item.userId?.toString() === teamMember.userId?.toString()) {
doesProjectUserExist = true;
break;
}
}
if (doesProjectUserExist) {
// add the team to the project user acceptedTeams, if the invitation is accepted.
if (teamMember.hasAcceptedInvitation) {
for (const projectUser of projectUsersToCreate) {
if (
projectUser.userId?.toString() === teamMember.userId?.toString()
) {
if (!projectUser.acceptedTeams) {
projectUser.acceptedTeams = [];
}
projectUser.acceptedTeams?.push(teamMember.team!);
}
}
} else {
for (const projectUser of projectUsersToCreate) {
if (
projectUser.userId?.toString() === teamMember.userId?.toString()
) {
if (!projectUser.invitedTeams) {
projectUser.invitedTeams = [];
}
projectUser.invitedTeams?.push(teamMember.team!);
}
}
}
} else {
// create a new project user.
const projectUser: Model = new Model();
projectUser.userId = teamMember.userId!;
projectUser.projectId = data.projectId;
if (teamMember.hasAcceptedInvitation) {
projectUser.acceptedTeams = [teamMember.team!];
} else {
projectUser.invitedTeams = [teamMember.team!];
}
projectUsersToCreate.push(projectUser);
}
}
// now create the project users.
for (const projectUser of projectUsersToCreate) {
await this.create({
data: projectUser,
props: {
isRoot: true,
},
});
}
}
}
export default new Service();

View File

@@ -261,7 +261,7 @@ export class Service extends DatabaseService<Model> {
if (subscriber.slackIncomingWebhookUrl) {
const slackMessage: string = `## 🔧 Scheduled Maintenance - ${event.title || ""}
**Scheduled Date:** ${OneUptimeDate.getDateAsFormattedString(event.startsAt!)}
**Scheduled Date:** ${OneUptimeDate.getDateAsUserFriendlyFormattedString(event.startsAt!)}
${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
@@ -305,6 +305,7 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
OneUptimeDate.getDateAsFormattedHTMLInMultipleTimezones({
date: event.startsAt!,
timezones: statuspage.subscriberTimezones || [],
use12HourFormat: true,
}),
eventTitle: event.title || "",
eventDescription: await Markdown.convertToHTML(
@@ -610,6 +611,12 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
labels: {
name: true,
},
createdByUserId: true,
createdByUser: {
_id: true,
name: true,
email: true,
},
},
props: {
isRoot: true,
@@ -620,71 +627,80 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
throw new BadDataException("Scheduled Maintenance not found");
}
// Execute core operations in parallel first
const coreOperations: Array<Promise<any>> = [];
// Create feed item asynchronously
coreOperations.push(
this.createScheduledMaintenanceFeedAsync(
scheduledMaintenance,
createdItem,
),
);
// Create state timeline asynchronously
coreOperations.push(
this.createScheduledMaintenanceStateTimelineAsync(createdItem),
);
// Handle owner assignment asynchronously
if (
createdItem.projectId &&
createdItem.id &&
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
coreOperations.push(
this.addOwners(
createdItem.projectId!,
createdItem.id!,
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
[],
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
[],
false,
onCreate.createBy.props,
),
);
}
// Execute core operations in parallel with error handling
Promise.allSettled(coreOperations)
.then((coreResults: any[]) => {
// Log any errors from core operations
coreResults.forEach((result: any, index: number) => {
if (result.status === "rejected") {
logger.error(
`Core operation ${index} failed in ScheduledMaintenanceService.onCreateSuccess: ${result.reason}`,
// Execute operations sequentially with error handling
Promise.resolve()
.then(async () => {
try {
if (createdItem.projectId && createdItem.id) {
return await this.handleScheduledMaintenanceWorkspaceOperationsAsync(
createdItem,
);
}
});
// Handle workspace operations after core operations complete
if (createdItem.projectId && createdItem.id) {
// Run workspace operations in background without blocking response
this.handleScheduledMaintenanceWorkspaceOperationsAsync(
return Promise.resolve();
} catch (error) {
logger.error(
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
return await this.createScheduledMaintenanceFeedAsync(
scheduledMaintenance,
);
} catch (error) {
logger.error(
`Create scheduled maintenance feed failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
return await this.createScheduledMaintenanceStateTimelineAsync(
createdItem,
).catch((error: Error) => {
logger.error(
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
);
} catch (error) {
logger.error(
`Create scheduled maintenance state timeline failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.then(async () => {
try {
if (
createdItem.projectId &&
createdItem.id &&
onCreate.createBy.miscDataProps &&
(onCreate.createBy.miscDataProps["ownerTeams"] ||
onCreate.createBy.miscDataProps["ownerUsers"])
) {
return await this.addOwners(
createdItem.projectId!,
createdItem.id!,
(onCreate.createBy.miscDataProps[
"ownerUsers"
] as Array<ObjectID>) || [],
(onCreate.createBy.miscDataProps[
"ownerTeams"
] as Array<ObjectID>) || [],
false,
onCreate.createBy.props,
);
});
}
return Promise.resolve();
} catch (error) {
logger.error(
`Add owners failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.catch((error: Error) => {
logger.error(
`Critical error in ScheduledMaintenanceService core operations: ${error}`,
`Critical error in ScheduledMaintenanceService sequential operations: ${error}`,
);
});
@@ -738,27 +754,27 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
@CaptureSpan()
private async createScheduledMaintenanceFeedAsync(
scheduledMaintenance: Model,
createdItem: Model,
): Promise<void> {
try {
const createdByUserId: ObjectID | undefined | null =
createdItem.createdByUserId || createdItem.createdByUser?.id;
scheduledMaintenance.createdByUserId ||
scheduledMaintenance.createdByUser?.id;
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${createdItem.scheduledMaintenanceNumber?.toString()} Created:
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${scheduledMaintenance.scheduledMaintenanceNumber?.toString()} Created:
**${createdItem.title || "No title provided."}**:
**${scheduledMaintenance.title || "No title provided."}**:
${createdItem.description || "No description provided."}
${scheduledMaintenance.description || "No description provided."}
`;
// add starts at and ends at.
if (scheduledMaintenance.startsAt) {
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
}
if (scheduledMaintenance.endsAt) {
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
}
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
@@ -772,7 +788,7 @@ ${createdItem.description || "No description provided."}
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
for (const monitor of scheduledMaintenance.monitors) {
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(scheduledMaintenance.projectId!, monitor.id!)).toString()})\n`;
}
feedInfoInMarkdown += `\n\n`;
@@ -781,14 +797,14 @@ ${createdItem.description || "No description provided."}
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
{
scheduledMaintenanceId: createdItem.id!,
projectId: createdItem.projectId!,
scheduledMaintenanceId: scheduledMaintenance.id!,
projectId: scheduledMaintenance.projectId!,
},
);
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
scheduledMaintenanceId: createdItem.id!,
projectId: createdItem.projectId!,
scheduledMaintenanceId: scheduledMaintenance.id!,
projectId: scheduledMaintenance.projectId!,
scheduledMaintenanceFeedEventType:
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
displayColor: Red500,
@@ -1049,7 +1065,7 @@ ${onUpdate.updateBy.data.title || "No title provided."}
// add scheduledMaintenance feed.
feedInfoInMarkdown += `\n\n**Starts At**:
${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
`;
shouldAddScheduledMaintenanceFeed = true;
}
@@ -1058,7 +1074,7 @@ ${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.startsAt as
// add scheduledMaintenance feed.
feedInfoInMarkdown += `\n\n**Ends At**:
${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
`;
shouldAddScheduledMaintenanceFeed = true;
}

View File

@@ -340,10 +340,26 @@ export class Service extends DatabaseService<StatusPage> {
projectId: ObjectID,
statusPageId: ObjectID,
): Promise<URL> {
const dahboardUrl: URL = await DatabaseConfig.getDashboardUrl();
if (!projectId) {
throw new BadDataException(
"projectId is required to build status page dashboard link",
);
}
return URL.fromString(dahboardUrl.toString()).addRoute(
`/${projectId.toString()}/status-pages/${statusPageId.toString()}`,
if (!statusPageId) {
throw new BadDataException(
"statusPageId is required to build status page dashboard link",
);
}
const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
// Defensive: ensure objects have toString
const projectIdStr: string = projectId.toString();
const statusPageIdStr: string = statusPageId.toString();
return URL.fromString(dashboardUrl.toString()).addRoute(
`/${projectIdStr}/status-pages/${statusPageIdStr}`,
);
}
@@ -861,7 +877,7 @@ export class Service extends DatabaseService<StatusPage> {
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.getSomeDaysAgo(numberOfDays);
const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsLocalFormattedString(endDate, true)})`;
const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(endDate, true)})`;
if (statusPageResources.length === 0) {
return {

View File

@@ -32,6 +32,7 @@ import PositiveNumber from "../../Types/PositiveNumber";
import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
import NumberUtil from "../../Utils/Number";
import SlackUtil from "../Utils/Workspace/Slack/Slack";
import MicrosoftTeamsUtil from "../Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -221,6 +222,23 @@ export class Service extends DatabaseService<Model> {
}
}
// Validate Microsoft Teams webhook URL if provided
if (data.data.microsoftTeamsIncomingWebhookUrl) {
logger.debug(
`Microsoft Teams Incoming Webhook URL: ${data.data.microsoftTeamsIncomingWebhookUrl}`,
);
if (
!MicrosoftTeamsUtil.isValidMicrosoftTeamsIncomingWebhookUrl(
data.data.microsoftTeamsIncomingWebhookUrl,
)
) {
logger.debug("Invalid Microsoft Teams Incoming Webhook URL.");
throw new BadDataException(
"Invalid Microsoft Teams Incoming Webhook URL.",
);
}
}
data.data.subscriptionConfirmationToken = NumberUtil.getRandomNumber(
100000,
999999,
@@ -370,16 +388,55 @@ Stay informed about service availability! 🚀`;
logger.debug(`Slack Message: ${slackMessage}`);
try {
await SlackUtil.sendMessageToChannelViaIncomingWebhook({
url: URL.fromString(createdItem.slackIncomingWebhookUrl.toString()),
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
SlackUtil.sendMessageToChannelViaIncomingWebhook({
url: URL.fromString(createdItem.slackIncomingWebhookUrl.toString()),
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
})
.then(() => {
logger.debug("Slack notification sent successfully.");
})
.catch((err: Error) => {
logger.error("Error sending Slack notification:");
logger.error(err);
});
}
// if Microsoft Teams incoming webhook is provided and sendYouHaveSubscribedMessage is true, then send a message to the Teams channel.
if (
createdItem.microsoftTeamsIncomingWebhookUrl &&
createdItem.sendYouHaveSubscribedMessage
) {
logger.debug("Sending Microsoft Teams notification for new subscriber.");
const teamsMessage: string = `## 📢 New Subscription to ${statusPageName}
**You have successfully subscribed to receive status updates!**
🔗 **Status Page:** [${statusPageName}](${statusPageURL})
📧 **Manage Subscription:** [Update preferences or unsubscribe](${unsubscribeLink})
You will receive real-time notifications for:
• Incidents and outages
• Scheduled maintenance events
• Service announcements
• Status updates
Stay informed about service availability! 🚀`;
logger.debug(`Teams Message: ${teamsMessage}`);
MicrosoftTeamsUtil.sendMessageToChannelViaIncomingWebhook({
url: URL.fromString(
createdItem.microsoftTeamsIncomingWebhookUrl.toString(),
),
text: teamsMessage,
})
.then(() => {
logger.debug("Microsoft Teams notification sent successfully.");
})
.catch((err: Error) => {
logger.error("Error sending Microsoft Teams notification:");
logger.error(err);
});
logger.debug("Slack notification sent successfully.");
} catch (error) {
logger.error("Error sending Slack notification:");
logger.error(error);
}
}
logger.debug("onCreateSuccess completed.");
@@ -691,6 +748,7 @@ Stay informed about service availability! 🚀`;
subscriberPhone: true,
subscriberWebhook: true,
slackIncomingWebhookUrl: true,
microsoftTeamsIncomingWebhookUrl: true,
isSubscribedToAllResources: true,
statusPageResources: true,
isSubscribedToAllEventTypes: true,
@@ -746,7 +804,10 @@ Stay informed about service availability! 🚀`;
if (
data.statusPage.allowSubscribersToChooseResources &&
!data.subscriber.isSubscribedToAllResources &&
data.eventType !== StatusPageEventType.Announcement // announcements dont have resources
!(
data.eventType === StatusPageEventType.Announcement &&
data.statusPageResources.length === 0
) // announcements with no monitors don't use resource filtering
) {
logger.debug(
"Subscriber can choose resources and is not subscribed to all resources.",

View File

@@ -33,7 +33,6 @@ import PositiveNumber from "../../Types/PositiveNumber";
import Project from "../../Models/DatabaseModels/Project";
import TeamMember from "../../Models/DatabaseModels/TeamMember";
import User from "../../Models/DatabaseModels/User";
import ProjectUserService from "./ProjectUserService";
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
import OneUptimeDate from "../../Types/Date";
@@ -198,12 +197,6 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
onCreate.createBy.data.projectId!,
);
ProjectUserService.refreshProjectUsersByProject({
projectId: onCreate.createBy.data.projectId!,
}).catch((err: Error) => {
logger.error(err);
});
return createdItem;
}
@@ -247,12 +240,6 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
item.user?.email as Email,
);
}
ProjectUserService.refreshProjectUsersByProject({
projectId: item.projectId!,
}).catch((err: Error) => {
logger.error(err);
});
}
return { updateBy, carryForward: onUpdate.carryForward };
@@ -335,13 +322,6 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
item.userId!,
item.projectId!,
);
// refresh project users.
ProjectUserService.refreshProjectUsersByProject({
projectId: item.projectId!,
}).catch((err: Error) => {
logger.error(err);
});
}
return onDelete;

View File

@@ -49,6 +49,7 @@ import WorkspaceNotificationLog from "../../Models/DatabaseModels/WorkspaceNotif
import WorkspaceNotificationLogService from "./WorkspaceNotificationLogService";
import WorkspaceNotificationStatus from "../../Types/Workspace/WorkspaceNotificationStatus";
import WorkspaceNotificationActionType from "../../Types/Workspace/WorkspaceNotificationActionType";
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
export interface MessageBlocksByWorkspaceType {
workspaceType: WorkspaceType;
@@ -213,6 +214,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
).doesChannelExist({
authToken: projectAuthToken,
channelName: channelName,
projectId: data.projectId,
});
if (!channelExists) {
@@ -458,6 +460,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
workspaceType: workspaceType,
}),
sendMessageBeforeArchiving: data.sendMessageBeforeArchiving,
projectId: data.projectId,
});
}
}
@@ -610,6 +613,9 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
notificationFor: NotificationFor;
workspaceType: WorkspaceType;
}): Promise<Array<WorkspaceChannel>> {
logger.debug("getWorkspaceChannelsByNotificationFor called with data:");
logger.debug(JSON.stringify(data, null, 2));
let monitorChannels: Array<WorkspaceChannel> = [];
if (data.notificationFor.monitorId) {
@@ -653,6 +659,10 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
},
);
}
logger.debug("Workspace channels found:");
logger.debug(monitorChannels);
return monitorChannels;
}
@@ -757,112 +767,132 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
}): Promise<{
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
} | null> {
logger.debug(
"WorkspaceNotificationRuleService.createInviteAndPostToChannelsBasedOnRules",
);
logger.debug(data);
try {
logger.debug(
"WorkspaceNotificationRuleService.createInviteAndPostToChannelsBasedOnRules",
);
logger.debug(data);
const channelsCreated: Array<NotificationRuleWorkspaceChannel> = [];
const channelsCreated: Array<NotificationRuleWorkspaceChannel> = [];
const projectAuths: Array<WorkspaceProjectAuthToken> =
await WorkspaceProjectAuthTokenService.getProjectAuths({
projectId: data.projectId,
});
logger.debug("projectAuths");
logger.debug(projectAuths);
if (!projectAuths || projectAuths.length === 0) {
// do nothing.
return null;
}
for (const projectAuth of projectAuths) {
if (!projectAuth.authToken) {
continue;
}
if (!projectAuth.workspaceType) {
continue;
}
const authToken: string = projectAuth.authToken;
const workspaceType: WorkspaceType = projectAuth.workspaceType;
const notificationRules: Array<WorkspaceNotificationRule> =
await this.getMatchingNotificationRules({
const projectAuths: Array<WorkspaceProjectAuthToken> =
await WorkspaceProjectAuthTokenService.getProjectAuths({
projectId: data.projectId,
workspaceType: workspaceType,
notificationRuleEventType: data.notificationRuleEventType,
notificationFor: data.notificationFor,
});
logger.debug("notificationRules");
logger.debug(notificationRules);
logger.debug("projectAuths");
logger.debug(projectAuths);
if (!notificationRules || notificationRules.length === 0) {
if (!projectAuths || projectAuths.length === 0) {
// do nothing.
return null;
}
logger.debug("Creating channels based on rules");
const createdWorkspaceChannels: Array<NotificationRuleWorkspaceChannel> =
await this.createChannelsBasedOnRules({
projectId: data.projectId,
projectOrUserAuthTokenForWorkspace: authToken,
workspaceType: workspaceType,
notificationRules: notificationRules,
channelNameSiffix: data.channelNameSiffix,
notificationEventType: data.notificationRuleEventType,
notificationFor: data.notificationFor,
});
for (const projectAuth of projectAuths) {
try {
if (!projectAuth.authToken) {
continue;
}
logger.debug("createdWorkspaceChannels");
logger.debug(createdWorkspaceChannels);
if (!projectAuth.workspaceType) {
continue;
}
logger.debug("Inviting users and teams to channels based on rules");
await this.inviteUsersAndTeamsToChannelsBasedOnRules({
projectId: data.projectId,
projectAuth: projectAuth,
workspaceType: workspaceType,
notificationRules: notificationRules,
notificationChannels: createdWorkspaceChannels,
});
const authToken: string = projectAuth.authToken;
const workspaceType: WorkspaceType = projectAuth.workspaceType;
logger.debug("Getting existing channel names from notification rules");
const existingChannelNames: Array<string> =
this.getExistingChannelNamesFromNotificationRules({
notificationRules: notificationRules.map(
(rule: WorkspaceNotificationRule) => {
return rule.notificationRule as BaseNotificationRule;
},
),
}) || [];
const notificationRules: Array<WorkspaceNotificationRule> =
await this.getMatchingNotificationRules({
projectId: data.projectId,
workspaceType: workspaceType,
notificationRuleEventType: data.notificationRuleEventType,
notificationFor: data.notificationFor,
});
logger.debug("Existing channel names:");
logger.debug(existingChannelNames);
logger.debug("notificationRules");
logger.debug(notificationRules);
logger.debug("Adding created channel names to existing channel names");
for (const channel of createdWorkspaceChannels) {
if (!existingChannelNames.includes(channel.name)) {
existingChannelNames.push(channel.name);
if (!notificationRules || notificationRules.length === 0) {
return null;
}
logger.debug("Creating channels based on rules");
const createdWorkspaceChannels: Array<NotificationRuleWorkspaceChannel> =
await this.createChannelsBasedOnRules({
projectId: data.projectId,
projectOrUserAuthTokenForWorkspace: authToken,
workspaceType: workspaceType,
notificationRules: notificationRules,
channelNameSiffix: data.channelNameSiffix,
notificationEventType: data.notificationRuleEventType,
notificationFor: data.notificationFor,
});
logger.debug("createdWorkspaceChannels");
logger.debug(createdWorkspaceChannels);
logger.debug("Inviting users and teams to channels based on rules");
await this.inviteUsersAndTeamsToChannelsBasedOnRules({
projectId: data.projectId,
projectAuth: projectAuth,
workspaceType: workspaceType,
notificationRules: notificationRules,
notificationChannels: createdWorkspaceChannels,
});
logger.debug(
"Getting existing channel names from notification rules",
);
const existingChannelNames: Array<string> =
this.getExistingChannelNamesFromNotificationRules({
notificationRules: notificationRules.map(
(rule: WorkspaceNotificationRule) => {
return rule.notificationRule as BaseNotificationRule;
},
),
}) || [];
logger.debug("Existing channel names:");
logger.debug(existingChannelNames);
logger.debug(
"Adding created channel names to existing channel names",
);
for (const channel of createdWorkspaceChannels) {
if (!existingChannelNames.includes(channel.name)) {
existingChannelNames.push(channel.name);
}
}
logger.debug("Final list of channel names to post messages to:");
logger.debug(existingChannelNames);
logger.debug("Posting messages to workspace channels");
logger.debug("Channels created:");
logger.debug(createdWorkspaceChannels);
channelsCreated.push(...createdWorkspaceChannels);
} catch (err) {
logger.error(
"Error in creating channels and inviting users to channels for workspace type " +
projectAuth.workspaceType,
);
logger.error(err);
}
}
logger.debug("Final list of channel names to post messages to:");
logger.debug(existingChannelNames);
logger.debug("Posting messages to workspace channels");
logger.debug("Channels created:");
logger.debug(createdWorkspaceChannels);
channelsCreated.push(...createdWorkspaceChannels);
logger.debug("Returning created channels");
return {
channelsCreated: channelsCreated,
};
} catch (err) {
logger.error(
"Error in createChannelsAndInviteUsersToChannelsBasedOnRules:",
);
logger.error(err);
return null;
}
logger.debug("Returning created channels");
return {
channelsCreated: channelsCreated,
};
}
@CaptureSpan()
@@ -1004,6 +1034,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
} as WorkspacePayloadMarkdown,
],
},
projectId: data.projectId,
});
} catch (e) {
logger.error("Error in sending message to channel");
@@ -1031,6 +1062,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
}),
workspaceUserIds: workspaceUserIds,
},
projectId: data.projectId,
});
}
}
@@ -1159,6 +1191,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
} as WorkspacePayloadMarkdown,
],
},
projectId: data.projectId,
},
);
} catch (e) {
@@ -1188,6 +1221,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
channelNames: channelNames,
workspaceUserIds: workspaceUserIds,
},
projectId: data.projectId,
});
// Log user invitations
@@ -1345,6 +1379,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
).createChannel({
authToken: data.projectOrUserAuthTokenForWorkspace,
channelName: notificationChannel.channelName,
projectId: data.projectId,
});
const notificationWorkspaceChannel: NotificationRuleWorkspaceChannel = {
@@ -1911,7 +1946,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
if (!monitor) {
logger.debug("Monitor not found for ID:");
logger.debug(data.notificationFor.monitorId);
throw new BadDataException("Monitor ID not found");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
const monitorLabels: Array<Label> = monitor?.labels || [];

View File

@@ -19,9 +19,8 @@ export default class Markdown {
markdown: string,
contentType: MarkdownContentType,
): Promise<string> {
// convert tags > and < to &gt; and &lt;
markdown = markdown.replace(/</g, "&lt;");
markdown = markdown.replace(/>/g, "&gt;");
// Basic sanitization: neutralize script tags but preserve markdown syntax like '>' for blockquotes.
markdown = markdown.replace(/<script/gi, "&lt;script");
let renderer: Renderer | null = null;
@@ -78,7 +77,7 @@ export default class Markdown {
};
renderer.code = function (code, language) {
return `<pre class="language-${language} rounded-md"><code class="language-${language} rounded-md">${code}</code></pre>`;
return `<pre><code class="language-${language}">${code}</code></pre>`;
};
renderer.heading = function (text, level) {
@@ -96,6 +95,11 @@ export default class Markdown {
return `<h6 class="my-5 tracking-tight font-bold text-gray-800">${text}</h6>`;
};
// Inline code
renderer.codespan = function (code) {
return `<code class="rounded-md bg-slate-100 px-1.5 py-0.5 text-sm text-slate-700 font-mono">${code}</code>`;
};
this.docsRenderer = renderer;
return renderer;
@@ -137,6 +141,102 @@ export default class Markdown {
return `<h6 class="my-5 tracking-tight font-bold text-gray-800">${text}</h6>`;
};
// Lists
renderer.list = function (body, ordered, start) {
const tag: string = ordered ? "ol" : "ul";
const cls: string = ordered
? "list-decimal pl-6 my-6 space-y-2 text-gray-700"
: "list-disc pl-6 my-6 space-y-2 text-gray-700";
const startAttr: string =
ordered && start !== 1 ? ` start="${start}"` : "";
return `<${tag}${startAttr} class="${cls}">${body}</${tag}>`;
};
renderer.listitem = function (text) {
return `<li class="leading-7">${text}</li>`;
};
// Tables
renderer.table = function (header, body) {
return `<div class="overflow-x-auto my-8"><table class="min-w-full border border-gray-200 text-sm text-left">
${header}${body}
</table></div>`;
};
renderer.tablerow = function (content) {
return `<tr class="border-b last:border-b-0">${content}</tr>`;
};
renderer.tablecell = function (content, flags) {
const type: string = flags.header ? "th" : "td";
const base: string = "px-4 py-2 border-r last:border-r-0 border-gray-200";
const align: string = flags.align ? ` text-${flags.align}` : "";
const weight: string = flags.header ? " font-semibold bg-gray-50" : "";
return `<${type} class="${base}${align}${weight}">${content}</${type}>`;
};
// Inline code
renderer.codespan = function (code) {
return `<code class="rounded-md bg-gray-100 px-1.5 py-0.5 text-sm text-pink-600">${code}</code>`;
};
// Horizontal rule
renderer.hr = function () {
return '<hr class="my-12 border-t border-gray-200" />';
};
// Emphasis / Strong / Strikethrough
renderer.strong = function (text) {
return `<strong class="font-semibold text-gray-800">${text}</strong>`;
};
renderer.em = function (text) {
return `<em class="italic text-gray-700">${text}</em>`;
};
renderer.del = function (text) {
return `<del class="line-through text-gray-400">${text}</del>`;
};
// Images
renderer.image = function (href, _title, text) {
return `<figure class="my-8"><img src="${href}" alt="${text}" class="rounded-xl shadow-sm border border-gray-200" loading="lazy"/><figcaption class="mt-2 text-center text-sm text-gray-500">${text || ""}</figcaption></figure>`;
};
// Links
// We explicitly add underline + color classes because Tailwind Typography (prose-*)
// styles may get overridden by surrounding utility classes or global resets.
// External links open in a new tab with proper rel attributes; internal links stay in-page.
renderer.link = function (href, title, text) {
// Guard: if no href, just return the text.
if (!href) {
return text as string;
}
const isHash: boolean = href.startsWith("#");
const isMailTo: boolean = href.startsWith("mailto:");
const isTel: boolean = href.startsWith("tel:");
const isInternal: boolean =
href.startsWith("/") ||
href.includes("oneuptime.com") ||
isHash ||
isMailTo ||
isTel;
const baseClasses: string = [
"font-semibold",
"text-indigo-600",
"underline",
"underline-offset-2",
"decoration-indigo-300",
"hover:decoration-indigo-500",
"hover:text-indigo-500",
"transition-colors",
].join(" ");
const titleAttr: string = title ? ` title="${title}"` : "";
const externalAttrs: string = isInternal
? ""
: ' target="_blank" rel="noopener noreferrer"';
return `<a href="${href}"${titleAttr} class="${baseClasses}"${externalAttrs}>${text}</a>`;
};
this.blogRenderer = renderer;
return renderer;

View File

@@ -19,6 +19,8 @@ import AlertStateTimelineService from "../../Services/AlertStateTimelineService"
import logger from "../Logger";
import CaptureSpan from "../Telemetry/CaptureSpan";
import DataToProcess from "./DataToProcess";
import MonitorTemplateUtil from "./MonitorTemplateUtil";
import { JSONObject } from "../../../Types/JSON";
export default class MonitorAlert {
@CaptureSpan()
@@ -130,9 +132,20 @@ export default class MonitorAlert {
logger.debug(`${input.monitor.id?.toString()} - Create alert.`);
const alert: Alert = new Alert();
const storageMap: JSONObject =
MonitorTemplateUtil.buildTemplateStorageMap({
monitorType: input.monitor.monitorType!,
dataToProcess: input.dataToProcess,
});
alert.title = criteriaAlert.title;
alert.description = criteriaAlert.description;
alert.title = MonitorTemplateUtil.processTemplateString({
value: criteriaAlert.title,
storageMap,
});
alert.description = MonitorTemplateUtil.processTemplateString({
value: criteriaAlert.description,
storageMap,
});
if (!criteriaAlert.alertSeverityId) {
// pick the critical criteria.
@@ -194,7 +207,10 @@ export default class MonitorAlert {
}
if (criteriaAlert.remediationNotes) {
alert.remediationNotes = criteriaAlert.remediationNotes;
alert.remediationNotes = MonitorTemplateUtil.processTemplateString({
value: criteriaAlert.remediationNotes,
storageMap,
});
}
if (DisableAutomaticAlertCreation) {

View File

@@ -19,6 +19,8 @@ import IncidentStateTimelineService from "../../Services/IncidentStateTimelineSe
import logger from "../Logger";
import CaptureSpan from "../Telemetry/CaptureSpan";
import DataToProcess from "./DataToProcess";
import MonitorTemplateUtil from "./MonitorTemplateUtil";
import { JSONObject } from "../../../Types/JSON";
export default class MonitorIncident {
@CaptureSpan()
@@ -34,7 +36,7 @@ export default class MonitorIncident {
// check active incidents and if there are open incidents, do not cretae anothr incident.
const openIncidents: Array<Incident> = await IncidentService.findBy({
query: {
monitors: [input.monitorId] as any,
monitors: [input.monitorId],
currentIncidentState: {
isResolvedState: false,
},
@@ -136,9 +138,20 @@ export default class MonitorIncident {
logger.debug(`${input.monitor.id?.toString()} - Create incident.`);
const incident: Incident = new Incident();
const storageMap: JSONObject =
MonitorTemplateUtil.buildTemplateStorageMap({
monitorType: input.monitor.monitorType!,
dataToProcess: input.dataToProcess,
});
incident.title = criteriaIncident.title;
incident.description = criteriaIncident.description;
incident.title = MonitorTemplateUtil.processTemplateString({
value: criteriaIncident.title,
storageMap,
});
incident.description = MonitorTemplateUtil.processTemplateString({
value: criteriaIncident.description,
storageMap,
});
if (!criteriaIncident.incidentSeverityId) {
// pick the critical criteria.
@@ -204,7 +217,12 @@ export default class MonitorIncident {
}
if (criteriaIncident.remediationNotes) {
incident.remediationNotes = criteriaIncident.remediationNotes;
incident.remediationNotes = MonitorTemplateUtil.processTemplateString(
{
value: criteriaIncident.remediationNotes,
storageMap,
},
);
}
if (DisableAutomaticIncidentCreation) {

View File

@@ -58,6 +58,7 @@ import CaptureSpan from "../Telemetry/CaptureSpan";
import MetricType from "../../../Models/DatabaseModels/MetricType";
import MonitorLog from "../../../Models/AnalyticsModels/MonitorLog";
import MonitorLogService from "../../Services/MonitorLogService";
import ExceptionMessages from "../../../Types/Exception/ExceptionMessages";
export default class MonitorResourceUtil {
@CaptureSpan()
@@ -109,7 +110,7 @@ export default class MonitorResourceUtil {
if (!monitor) {
logger.debug(`${dataToProcess.monitorId.toString()} Monitor not found`);
throw new BadDataException("Monitor not found");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
if (!monitor.projectId) {
@@ -126,9 +127,7 @@ export default class MonitorResourceUtil {
`${dataToProcess.monitorId.toString()} Monitor is disabled. Please enable it to start monitoring again.`,
);
throw new BadDataException(
"Monitor is disabled. Please enable it to start monitoring again.",
);
throw new BadDataException(ExceptionMessages.MonitorDisabled);
}
if (monitor.disableActiveMonitoringBecauseOfManualIncident) {

View File

@@ -0,0 +1,246 @@
import MonitorType from "../../../Types/Monitor/MonitorType";
import { JSONObject } from "../../../Types/JSON";
import ProbeMonitorResponse from "../../../Types/Probe/ProbeMonitorResponse";
import IncomingMonitorRequest from "../../../Types/Monitor/IncomingMonitor/IncomingMonitorRequest";
import ServerMonitorResponse, {
ServerProcess,
} from "../../../Types/Monitor/ServerMonitor/ServerMonitorResponse";
import BasicInfrastructureMetrics, {
BasicDiskMetrics,
} from "../../../Types/Infrastructure/BasicMetrics";
import SslMonitorResponse from "../../../Types/Monitor/SSLMonitor/SslMonitorResponse";
import CustomCodeMonitorResponse from "../../../Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse";
import SyntheticMonitorResponse from "../../../Types/Monitor/SyntheticMonitors/SyntheticMonitorResponse";
import Typeof from "../../../Types/Typeof";
import VMUtil from "../VM/VMAPI";
import DataToProcess from "./DataToProcess";
import logger from "../Logger";
/**
* Utility for building template variable storage map and processing dynamic placeholders
* shared between Incident and Alert auto-creation.
*/
export default class MonitorTemplateUtil {
/**
* Build a storage map of variables available for templating based on monitor type.
*/
public static buildTemplateStorageMap(data: {
monitorType: MonitorType;
dataToProcess: DataToProcess;
}): JSONObject {
let storageMap: JSONObject = {};
try {
if (
data.monitorType === MonitorType.API ||
data.monitorType === MonitorType.Website
) {
let responseBody: JSONObject | null = null;
try {
responseBody = JSON.parse(
((data.dataToProcess as ProbeMonitorResponse)
.responseBody as string) || "{}",
);
} catch (err) {
logger.error(err);
responseBody = (data.dataToProcess as ProbeMonitorResponse)
.responseBody as JSONObject;
}
if (
typeof responseBody === Typeof.String &&
responseBody?.toString() === ""
) {
responseBody = {};
}
storageMap = {
responseBody: responseBody,
responseHeaders: (data.dataToProcess as ProbeMonitorResponse)
.responseHeaders,
responseStatusCode: (data.dataToProcess as ProbeMonitorResponse)
.responseCode,
responseTimeInMs: (data.dataToProcess as ProbeMonitorResponse)
.responseTimeInMs,
isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline,
} as JSONObject;
}
if (data.monitorType === MonitorType.IncomingRequest) {
storageMap = {
requestBody: (data.dataToProcess as IncomingMonitorRequest)
.requestBody,
requestHeaders: (data.dataToProcess as IncomingMonitorRequest)
.requestHeaders,
requestMethod: (data.dataToProcess as IncomingMonitorRequest)
.requestMethod,
incomingRequestReceivedAt: (
data.dataToProcess as IncomingMonitorRequest
).incomingRequestReceivedAt,
} as JSONObject;
}
if (
data.monitorType === MonitorType.Ping ||
data.monitorType === MonitorType.IP ||
data.monitorType === MonitorType.Port
) {
storageMap = {
isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline,
responseTimeInMs: (data.dataToProcess as ProbeMonitorResponse)
.responseTimeInMs,
failureCause: (data.dataToProcess as ProbeMonitorResponse)
.failureCause,
isTimeout: (data.dataToProcess as ProbeMonitorResponse).isTimeout,
} as JSONObject;
}
if (data.monitorType === MonitorType.SSLCertificate) {
const sslResponse: SslMonitorResponse | undefined = (
data.dataToProcess as ProbeMonitorResponse
).sslResponse;
storageMap = {
isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline,
isSelfSigned: sslResponse?.isSelfSigned,
createdAt: sslResponse?.createdAt,
expiresAt: sslResponse?.expiresAt,
commonName: sslResponse?.commonName,
organizationalUnit: sslResponse?.organizationalUnit,
organization: sslResponse?.organization,
locality: sslResponse?.locality,
state: sslResponse?.state,
country: sslResponse?.country,
serialNumber: sslResponse?.serialNumber,
fingerprint: sslResponse?.fingerprint,
fingerprint256: sslResponse?.fingerprint256,
failureCause: (data.dataToProcess as ProbeMonitorResponse)
.failureCause,
} as JSONObject;
}
if (data.monitorType === MonitorType.Server) {
const serverResponse: ServerMonitorResponse =
data.dataToProcess as ServerMonitorResponse;
const infraMetrics: BasicInfrastructureMetrics | undefined =
serverResponse.basicInfrastructureMetrics;
storageMap = {
hostname: serverResponse.hostname,
requestReceivedAt: serverResponse.requestReceivedAt,
failureCause: serverResponse.failureCause,
} as JSONObject;
// Add CPU metrics if available
if (infraMetrics?.cpuMetrics) {
storageMap["cpuUsagePercent"] = infraMetrics.cpuMetrics.percentUsed;
storageMap["cpuCores"] = infraMetrics.cpuMetrics.cores;
}
// Add memory metrics if available
if (infraMetrics?.memoryMetrics) {
storageMap["memoryUsagePercent"] =
infraMetrics.memoryMetrics.percentUsed;
storageMap["memoryFreePercent"] =
infraMetrics.memoryMetrics.percentFree;
storageMap["memoryTotalBytes"] = infraMetrics.memoryMetrics.total;
}
// Add disk metrics if available
if (infraMetrics?.diskMetrics) {
storageMap["diskMetrics"] = infraMetrics.diskMetrics.map(
(disk: BasicDiskMetrics) => {
return {
diskPath: disk.diskPath,
usagePercent: disk.percentUsed,
freePercent: disk.percentFree,
totalBytes: disk.total,
};
},
);
}
// Add processes if available
if (serverResponse.processes) {
storageMap["processes"] = serverResponse.processes.map(
(process: ServerProcess) => {
return {
pid: process.pid,
name: process.name,
command: process.command,
};
},
);
}
}
if (
data.monitorType === MonitorType.SyntheticMonitor ||
data.monitorType === MonitorType.CustomJavaScriptCode
) {
const customCodeResponse: CustomCodeMonitorResponse | undefined = (
data.dataToProcess as ProbeMonitorResponse
).customCodeMonitorResponse;
const syntheticResponse: SyntheticMonitorResponse[] | undefined = (
data.dataToProcess as ProbeMonitorResponse
).syntheticMonitorResponse;
storageMap = {
executionTimeInMs: customCodeResponse?.executionTimeInMS,
result: customCodeResponse?.result,
scriptError: customCodeResponse?.scriptError,
logMessages: customCodeResponse?.logMessages || [],
failureCause: (data.dataToProcess as ProbeMonitorResponse)
.failureCause,
} as JSONObject;
// Add synthetic monitor specific fields if available
if (syntheticResponse && syntheticResponse.length > 0) {
const firstResponse: SyntheticMonitorResponse = syntheticResponse[0]!;
if (firstResponse) {
storageMap["screenshots"] = firstResponse.screenshots;
storageMap["browserType"] = firstResponse.browserType;
storageMap["screenSizeType"] = firstResponse.screenSizeType;
}
}
}
} catch (err) {
logger.error(err);
}
logger.debug(`Storage Map: ${JSON.stringify(storageMap, null, 2)}`);
return storageMap;
}
/**
* Replace {{var}} placeholders in the given string with values from the storage map.
*/
public static processTemplateString(data: {
value: string | undefined;
storageMap: JSONObject;
}): string {
try {
const { value, storageMap } = data;
if (!value) {
return "";
}
let replaced: string = VMUtil.replaceValueInPlace(
storageMap,
value,
false,
);
replaced =
replaced !== undefined && replaced !== null ? `${replaced}` : "";
logger.debug(`Original Value: ${data.value}`);
logger.debug(`Replaced Value: ${replaced}`);
return replaced;
} catch (err) {
logger.error(err);
return data.value || "";
}
}
}

View File

@@ -82,10 +82,19 @@ export default class VMUtil {
}
for (const variable of variablesInArgument) {
const valueToReplaceInPlace: string = VMUtil.deepFind(
const foundValue: JSONValue = VMUtil.deepFind(
storageMap as any,
variable as any,
) as string;
);
let valueToReplaceInPlace: string;
// Properly serialize objects to JSON strings
if (typeof foundValue === "object" && foundValue !== null) {
valueToReplaceInPlace = JSON.stringify(foundValue, null, 2);
} else {
valueToReplaceInPlace = foundValue as string;
}
if (valueToReplaceInPlaceCopy.trim() === "{{" + variable + "}}") {
valueToReplaceInPlaceCopy = valueToReplaceInPlace;

View File

@@ -3,414 +3,154 @@ import HTTPResponse from "../../../../Types/API/HTTPResponse";
import URL from "../../../../Types/API/URL";
import { JSONObject } from "../../../../Types/JSON";
import API from "../../../../Utils/API";
import WorkspaceMessagePayload from "../../../../Types/Workspace/WorkspaceMessagePayload";
import logger from "../../Logger";
import Dictionary from "../../../../Types/Dictionary";
import WorkspaceBase, {
WorkspaceChannel,
WorkspaceSendMessageResponse,
WorkspaceThread,
} from "../WorkspaceBase";
import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
import OneUptimeDate from "../../../../Types/Date";
import WorkspaceBase from "../WorkspaceBase";
import CaptureSpan from "../../Telemetry/CaptureSpan";
import BadDataException from "../../../../Types/Exception/BadDataException";
export default class MicrosoftTeams extends WorkspaceBase {
@CaptureSpan()
public static override async getAllWorkspaceChannels(data: {
authToken: string;
}): Promise<Dictionary<WorkspaceChannel>> {
logger.debug("Getting all workspace channels with data:");
logger.debug(data);
const channels: Dictionary<WorkspaceChannel> = {};
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.get<JSONObject>(
URL.fromString("https://graph.microsoft.com/v1.0/me/joinedTeams"),
{
Authorization: `Bearer ${data.authToken}`,
},
);
logger.debug("Response from Microsoft Graph API for getting all channels:");
logger.debug(JSON.stringify(response, null, 2));
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Microsoft Graph API:");
logger.error(response);
throw response;
}
for (const team of (response.jsonData as JSONObject)?.[
"value"
] as Array<JSONObject>) {
if (!team["id"] || !team["displayName"]) {
continue;
}
channels[team["displayName"].toString()] = {
id: team["id"] as string,
name: team["displayName"] as string,
workspaceType: WorkspaceType.MicrosoftTeams,
};
}
logger.debug("All workspace channels obtained:");
logger.debug(channels);
return channels;
}
@CaptureSpan()
public static override getDividerBlock(): JSONObject {
return {
type: "divider",
};
}
@CaptureSpan()
public static getValuesFromView(data: {
view: JSONObject;
}): Dictionary<string | number | Array<string | number> | Date> {
logger.debug("Getting values from view with data:");
logger.debug(JSON.stringify(data, null, 2));
const teamsView: JSONObject = data.view;
const values: Dictionary<string | number | Array<string | number> | Date> =
{};
if (!teamsView["state"] || !(teamsView["state"] as JSONObject)["values"]) {
return {};
}
for (const valueId in (teamsView["state"] as JSONObject)[
"values"
] as JSONObject) {
for (const blockId in (
(teamsView["state"] as JSONObject)["values"] as JSONObject
)[valueId] as JSONObject) {
const valueObject: JSONObject = (
(teamsView["state"] as JSONObject)["values"] as JSONObject
)[valueId] as JSONObject;
const value: JSONObject = valueObject[blockId] as JSONObject;
values[blockId] = value["value"] as string | number;
if ((value["selected_option"] as JSONObject)?.["value"]) {
values[blockId] = (value["selected_option"] as JSONObject)?.[
"value"
] as string;
}
if (Array.isArray(value["selected_options"])) {
values[blockId] = (
value["selected_options"] as Array<JSONObject>
).map((option: JSONObject) => {
return option["value"] as string | number;
});
}
// if date picker
if (value["selected_date_time"]) {
values[blockId] = OneUptimeDate.fromUnixTimestamp(
value["selected_date_time"] as number,
);
}
}
}
logger.debug("Values obtained from view:");
logger.debug(values);
return values;
}
@CaptureSpan()
public static override async inviteUserToChannelByChannelName(data: {
authToken: string;
channelName: string;
workspaceUserId: string;
}): Promise<void> {
logger.debug("Inviting user to channel with data:");
logger.debug(data);
const channelId: string = (
await this.getWorkspaceChannelFromChannelName({
authToken: data.authToken,
channelName: data.channelName,
private static buildMessageCardFromMarkdown(markdown: string): JSONObject {
// Teams MessageCard has limited markdown support. Headings like '##' are not supported
// and single newlines can collapse. Convert common patterns to a structured card.
const lines: Array<string> = markdown
.split("\n")
.map((l: string) => {
return l.trim();
})
).id;
return this.inviteUserToChannelByChannelId({
authToken: data.authToken,
channelId: channelId,
workspaceUserId: data.workspaceUserId,
});
}
@CaptureSpan()
public static override async createChannelsIfDoesNotExist(data: {
authToken: string;
channelNames: Array<string>;
}): Promise<Array<WorkspaceChannel>> {
logger.debug("Creating channels if they do not exist with data:");
logger.debug(data);
const workspaceChannels: Array<WorkspaceChannel> = [];
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
.filter((l: string) => {
return l.length > 0;
});
logger.debug("Existing workspace channels:");
logger.debug(existingWorkspaceChannels);
let title: string = "";
const facts: Array<JSONObject> = [];
const actions: Array<JSONObject> = [];
const bodyTextParts: Array<string> = [];
for (let channelName of data.channelNames) {
// if channel name starts with #, remove it
if (channelName && channelName.startsWith("#")) {
channelName = channelName.substring(1);
}
// convert channel name to lowercase
channelName = channelName.toLowerCase();
// replace spaces with hyphens
channelName = channelName.replace(/\s+/g, "-");
if (existingWorkspaceChannels[channelName]) {
logger.debug(`Channel ${channelName} already exists.`);
workspaceChannels.push(existingWorkspaceChannels[channelName]!);
continue;
}
logger.debug(`Channel ${channelName} does not exist. Creating channel.`);
const channel: WorkspaceChannel = await this.createChannel({
authToken: data.authToken,
channelName: channelName,
});
if (channel) {
logger.debug(`Channel ${channelName} created successfully.`);
workspaceChannels.push(channel);
}
// Extract title from the first non-empty line and strip markdown heading markers
if (lines.length > 0) {
const firstLine: string = lines[0] ?? "";
title = firstLine
.replace(/^#+\s*/, "") // remove leading markdown headers like ##
.replace(/^\*\*|\*\*$/g, "") // remove stray bold markers if any
.trim();
lines.shift();
}
logger.debug("Channels created or found:");
logger.debug(workspaceChannels);
return workspaceChannels;
}
const linkRegex: RegExp = /\[([^\]]+)\]\(([^)]+)\)/g; // [text](url)
@CaptureSpan()
public static override async getWorkspaceChannelFromChannelName(data: {
authToken: string;
channelName: string;
}): Promise<WorkspaceChannel> {
logger.debug("Getting workspace channel ID from channel name with data:");
logger.debug(data);
const channels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
logger.debug("All workspace channels:");
logger.debug(channels);
if (!channels[data.channelName]) {
logger.error("Channel not found.");
throw new BadDataException("Channel not found.");
}
logger.debug("Workspace channel ID obtained:");
logger.debug(channels[data.channelName]!.id);
return channels[data.channelName]!;
}
@CaptureSpan()
public static override async getWorkspaceChannelFromChannelId(data: {
authToken: string;
channelId: string;
}): Promise<WorkspaceChannel> {
logger.debug("Getting workspace channel from channel ID with data:");
logger.debug(data);
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.get<JSONObject>(
URL.fromString(
`https://graph.microsoft.com/v1.0/teams/${data.channelId}`,
),
{
Authorization: `Bearer ${data.authToken}`,
},
);
logger.debug("Response from Microsoft Graph API for getting channel info:");
logger.debug(response);
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Microsoft Graph API:");
logger.error(response);
throw response;
}
if (!(response.jsonData as JSONObject)?.["displayName"]) {
logger.error("Invalid response from Microsoft Graph API:");
logger.error(response.jsonData);
throw new Error("Invalid response");
}
const channel: WorkspaceChannel = {
name: (response.jsonData as JSONObject)["displayName"] as string,
id: data.channelId,
workspaceType: WorkspaceType.MicrosoftTeams,
};
logger.debug("Workspace channel obtained:");
logger.debug(channel);
return channel;
}
@CaptureSpan()
public static override async doesChannelExist(data: {
authToken: string;
channelName: string;
}): Promise<boolean> {
// if channel name starts with #, remove it
if (data.channelName && data.channelName.startsWith("#")) {
data.channelName = data.channelName.substring(1);
}
// convert channel name to lowercase
data.channelName = data.channelName.toLowerCase();
// get channel id from channel name
const channels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
// if this channel exists
if (channels[data.channelName]) {
return true;
}
return false;
}
@CaptureSpan()
public static override async sendMessage(data: {
workspaceMessagePayload: WorkspaceMessagePayload;
authToken: string; // which auth token should we use to send.
userId: string;
}): Promise<WorkspaceSendMessageResponse> {
logger.debug("Sending message to Microsoft Teams with data:");
logger.debug(data);
const blocks: Array<JSONObject> = this.getBlocksFromWorkspaceMessagePayload(
data.workspaceMessagePayload,
);
logger.debug("Blocks generated from workspace message payload:");
logger.debug(blocks);
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
logger.debug("Existing workspace channels:");
logger.debug(existingWorkspaceChannels);
const workspaceChannelsToPostTo: Array<WorkspaceChannel> = [];
for (let channelName of data.workspaceMessagePayload.channelNames) {
if (channelName && channelName.startsWith("#")) {
// trim # from channel name
channelName = channelName.substring(1);
}
let channel: WorkspaceChannel | null = null;
if (existingWorkspaceChannels[channelName]) {
channel = existingWorkspaceChannels[channelName]!;
}
if (channel) {
workspaceChannelsToPostTo.push(channel);
} else {
logger.debug(`Channel ${channelName} does not exist.`);
}
}
// add channel ids.
for (const channelId of data.workspaceMessagePayload.channelIds) {
try {
// Get the channel info including name from channel ID
const channel: WorkspaceChannel =
await this.getWorkspaceChannelFromChannelId({
authToken: data.authToken,
channelId: channelId,
});
workspaceChannelsToPostTo.push(channel);
} catch (err) {
logger.error(`Error getting channel info for channel ID ${channelId}:`);
logger.error(err);
// Fallback: create channel object with empty name if API call fails
const channel: WorkspaceChannel = {
id: channelId,
name: channelId,
workspaceType: WorkspaceType.MicrosoftTeams,
};
workspaceChannelsToPostTo.push(channel);
}
}
logger.debug("Channel IDs to post to:");
logger.debug(workspaceChannelsToPostTo);
const workspaceMessageResponse: WorkspaceSendMessageResponse = {
threads: [],
workspaceType: WorkspaceType.MicrosoftTeams,
};
for (const channel of workspaceChannelsToPostTo) {
try {
// check if the user is in the channel.
const isUserInChannel: boolean = await this.isUserInChannel({
authToken: data.authToken,
channelId: channel.id,
userId: data.userId,
for (const line of lines) {
// Extract links to actions and strip them from text
let lineWithoutLinks: string = line;
let match: RegExpExecArray | null = null;
while ((match = linkRegex.exec(line))) {
const name: string = match[1] ?? "";
const url: string = match[2] ?? "";
actions.push({
["@type"]: "OpenUri",
name: name,
targets: [
{
os: "default",
uri: url,
},
],
});
lineWithoutLinks = lineWithoutLinks.replace(match[0], "").trim();
}
if (!isUserInChannel) {
// add user to the channel
await this.joinChannel({
authToken: data.authToken,
channelId: channel.id,
});
// Parse facts of the form **Label:** value
const factMatch: RegExpExecArray | null = new RegExp(
"\\*\\*(.*?):\\*\\*\\s*(.*)",
).exec(lineWithoutLinks);
if (factMatch) {
const name: string = (factMatch[1] ?? "").trim();
const value: string = (factMatch[2] ?? "").trim();
if (
name.toLowerCase() === "description" ||
name.toLowerCase() === "note"
) {
bodyTextParts.push(`**${name}:** ${value}`);
} else {
facts.push({ name: name, value: value });
}
const thread: WorkspaceThread = await this.sendPayloadBlocksToChannel({
authToken: data.authToken,
workspaceChannel: channel,
blocks: blocks,
});
workspaceMessageResponse.threads.push(thread);
logger.debug(`Message sent to channel ID ${channel.id} successfully.`);
} catch (e) {
logger.error(`Error sending message to channel ID ${channel.id}:`);
logger.error(e);
} else if (lineWithoutLinks) {
bodyTextParts.push(lineWithoutLinks);
}
}
logger.debug("Message sent successfully.");
logger.debug(workspaceMessageResponse);
const payload: JSONObject = {
["@type"]: "MessageCard",
["@context"]: "https://schema.org/extensions",
title: title,
summary: title,
};
return workspaceMessageResponse;
if (bodyTextParts.length > 0) {
payload["text"] = bodyTextParts.join("\n\n");
}
if (facts.length > 0) {
payload["sections"] = [
{
facts: facts,
},
];
}
if (actions.length > 0) {
payload["potentialAction"] = actions;
}
return payload;
}
@CaptureSpan()
public static override async sendMessageToChannelViaIncomingWebhook(data: {
url: URL;
text: string;
}): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> {
logger.debug("Sending message to Teams channel via incoming webhook:");
logger.debug(data);
// Build a structured MessageCard from markdown for better rendering in Teams
const payload: JSONObject = this.buildMessageCardFromMarkdown(data.text);
const apiResult: HTTPResponse<JSONObject> | HTTPErrorResponse | null =
await API.post(data.url, payload);
if (!apiResult) {
logger.error(
"Could not send message to Teams channel via incoming webhook.",
);
throw new Error(
"Could not send message to Teams channel via incoming webhook.",
);
}
if (apiResult instanceof HTTPErrorResponse) {
logger.error(
"Error sending message to Teams channel via incoming webhook:",
);
logger.error(apiResult);
throw apiResult;
}
logger.debug(
"Message sent to Teams channel via incoming webhook successfully:",
);
logger.debug(apiResult);
return apiResult;
}
public static isValidMicrosoftTeamsIncomingWebhookUrl(
incomingWebhookUrl: URL,
): boolean {
// Check if the URL contains outlook.office.com or office.com webhook pattern
const urlString: string = incomingWebhookUrl.toString();
return (
urlString.includes("outlook.office.com") ||
urlString.includes("office.com")
);
}
}

View File

@@ -225,6 +225,7 @@ export default class SlackIncidentActions {
await SlackUtil.sendMessage({
authToken: projectAuthToken,
userId: botUserId,
projectId: slackRequest.projectId!,
workspaceMessagePayload: {
_type: "WorkspaceMessagePayload",
channelIds: [slackChannelId],

View File

@@ -280,6 +280,7 @@ export default class SlackScheduledMaintenanceActions {
await SlackUtil.sendMessage({
authToken: projectAuthToken,
userId: botUserId,
projectId: slackRequest.projectId!,
workspaceMessagePayload: {
_type: "WorkspaceMessagePayload",
channelIds: [slackChannelId],

View File

@@ -31,6 +31,8 @@ import { DropdownOption } from "../../../../UI/Components/Dropdown/Dropdown";
import OneUptimeDate from "../../../../Types/Date";
import CaptureSpan from "../../Telemetry/CaptureSpan";
import BadDataException from "../../../../Types/Exception/BadDataException";
import ObjectID from "../../../../Types/ObjectID";
import WorkspaceProjectAuthTokenService from "../../../Services/WorkspaceProjectAuthTokenService";
export default class SlackUtil extends WorkspaceBase {
public static isValidSlackIncomingWebhookUrl(
@@ -181,6 +183,7 @@ export default class SlackUtil extends WorkspaceBase {
channelIds: Array<string>;
authToken: string;
sendMessageBeforeArchiving: WorkspacePayloadMarkdown;
projectId: ObjectID;
}): Promise<void> {
if (data.sendMessageBeforeArchiving) {
await this.sendMessage({
@@ -193,6 +196,7 @@ export default class SlackUtil extends WorkspaceBase {
},
authToken: data.authToken,
userId: data.userId,
projectId: data.projectId,
});
}
@@ -349,6 +353,7 @@ export default class SlackUtil extends WorkspaceBase {
authToken: string;
channelName: string;
workspaceUserId: string;
projectId: ObjectID;
}): Promise<void> {
if (data.channelName && data.channelName.startsWith("#")) {
// trim # from channel name
@@ -362,6 +367,7 @@ export default class SlackUtil extends WorkspaceBase {
await this.getWorkspaceChannelFromChannelName({
authToken: data.authToken,
channelName: data.channelName,
projectId: data.projectId,
})
).id;
@@ -376,34 +382,32 @@ export default class SlackUtil extends WorkspaceBase {
public static override async createChannelsIfDoesNotExist(data: {
authToken: string;
channelNames: Array<string>;
projectId: ObjectID;
}): Promise<Array<WorkspaceChannel>> {
logger.debug("Creating channels if they do not exist with data:");
logger.debug(data);
const workspaceChannels: Array<WorkspaceChannel> = [];
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
logger.debug("Existing workspace channels:");
logger.debug(existingWorkspaceChannels);
for (let channelName of data.channelNames) {
// if channel name starts with #, remove it
// Normalize channel name
if (channelName && channelName.startsWith("#")) {
channelName = channelName.substring(1);
}
// convert channel name to lowercase
channelName = channelName.toLowerCase();
// replace spaces with hyphens
channelName = channelName.replace(/\s+/g, "-");
if (existingWorkspaceChannels[channelName]) {
// Check if channel exists using optimized method
const existingChannel: WorkspaceChannel | null =
await this.getWorkspaceChannelByName({
authToken: data.authToken,
channelName: channelName,
projectId: data.projectId,
});
if (existingChannel) {
logger.debug(`Channel ${channelName} already exists.`);
workspaceChannels.push(existingWorkspaceChannels[channelName]!);
workspaceChannels.push(existingChannel);
continue;
}
@@ -411,6 +415,7 @@ export default class SlackUtil extends WorkspaceBase {
const channel: WorkspaceChannel = await this.createChannel({
authToken: data.authToken,
channelName: channelName,
projectId: data.projectId,
});
if (channel) {
@@ -428,27 +433,27 @@ export default class SlackUtil extends WorkspaceBase {
public static override async getWorkspaceChannelFromChannelName(data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<WorkspaceChannel> {
logger.debug("Getting workspace channel ID from channel name with data:");
logger.debug(data);
const channels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
const channel: WorkspaceChannel | null =
await this.getWorkspaceChannelByName({
authToken: data.authToken,
channelName: data.channelName,
projectId: data.projectId,
});
logger.debug("All workspace channels:");
logger.debug(channels);
if (!channels[data.channelName]) {
if (!channel) {
logger.error("Channel not found.");
throw new BadDataException("Channel not found.");
}
logger.debug("Workspace channel ID obtained:");
logger.debug(channels[data.channelName]!.id);
logger.debug("Workspace channel obtained:");
logger.debug(channel);
return channels[data.channelName]!;
return channel;
}
@CaptureSpan()
@@ -549,9 +554,6 @@ export default class SlackUtil extends WorkspaceBase {
},
);
logger.debug("Response from Slack API for getting all channels:");
logger.debug(JSON.stringify(response, null, 2));
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Slack API:");
logger.error(response);
@@ -588,10 +590,219 @@ export default class SlackUtil extends WorkspaceBase {
} while (cursor);
logger.debug("All workspace channels obtained:");
logger.debug(channels);
return channels;
}
@CaptureSpan()
public static async getChannelFromCache(data: {
projectId: ObjectID;
channelName: string;
}): Promise<WorkspaceChannel | null> {
logger.debug("Getting channel from cache with data:");
logger.debug(data);
const projectAuth: any =
await WorkspaceProjectAuthTokenService.getProjectAuth({
projectId: data.projectId,
workspaceType: WorkspaceType.Slack,
});
if (!projectAuth || !projectAuth.miscData) {
logger.debug("No project auth found or no misc data");
return null;
}
const miscData: any = projectAuth.miscData;
const channelCache: any = miscData.channelCache;
if (!channelCache || !channelCache[data.channelName]) {
logger.debug("Channel not found in cache");
return null;
}
const cachedChannelData: WorkspaceChannel = channelCache[
data.channelName
] as WorkspaceChannel;
const channel: WorkspaceChannel = {
id: cachedChannelData.id,
name: cachedChannelData.name,
workspaceType: WorkspaceType.Slack,
};
logger.debug("Channel found in cache:");
logger.debug(channel);
return channel;
}
@CaptureSpan()
public static async updateChannelCache(data: {
projectId: ObjectID;
channelName: string;
channel: WorkspaceChannel;
}): Promise<void> {
logger.debug("Updating channel cache with data:");
logger.debug(data);
const projectAuth: any =
await WorkspaceProjectAuthTokenService.getProjectAuth({
projectId: data.projectId,
workspaceType: WorkspaceType.Slack,
});
if (!projectAuth) {
logger.debug("No project auth found, cannot update cache");
return;
}
const miscData: any = projectAuth.miscData || {};
const channelCache: any = miscData.channelCache || {};
// Update the cache
channelCache[data.channelName] = {
id: data.channel.id,
name: data.channel.name,
lastUpdated: new Date().toISOString(),
};
// Update miscData
miscData.channelCache = channelCache;
// Save back to database
await WorkspaceProjectAuthTokenService.refreshAuthToken({
projectId: data.projectId,
workspaceType: WorkspaceType.Slack,
authToken: projectAuth.authToken,
workspaceProjectId: projectAuth.workspaceProjectId,
miscData: miscData,
});
logger.debug("Channel cache updated successfully");
}
@CaptureSpan()
public static async getWorkspaceChannelByName(data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<WorkspaceChannel | null> {
logger.debug("Getting workspace channel by name with data:");
logger.debug(data);
// Normalize channel name
let normalizedChannelName: string = data.channelName;
if (normalizedChannelName && normalizedChannelName.startsWith("#")) {
normalizedChannelName = normalizedChannelName.substring(1);
}
normalizedChannelName = normalizedChannelName.toLowerCase();
// Try to get from cache first
try {
const cachedChannel: WorkspaceChannel | null =
await this.getChannelFromCache({
projectId: data.projectId,
channelName: normalizedChannelName,
});
if (cachedChannel) {
logger.debug("Channel found in cache:");
logger.debug(cachedChannel);
return cachedChannel;
}
} catch (error) {
logger.error("Error getting channel from cache, falling back to API:");
logger.error(error);
}
let cursor: string | undefined = undefined;
const maxPages: number = 10; // Limit search to prevent excessive API calls
let pageCount: number = 0;
do {
const requestBody: JSONObject = {
limit: 200, // Use smaller limit for faster searches
types: "public_channel,private_channel",
};
if (cursor) {
requestBody["cursor"] = cursor;
}
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>(
URL.fromString("https://slack.com/api/conversations.list"),
requestBody,
{
Authorization: `Bearer ${data.authToken}`,
["Content-Type"]: "application/x-www-form-urlencoded",
},
{
retries: 3,
exponentialBackoff: true,
},
);
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Slack API:");
logger.error(response);
throw response;
}
// check for ok response
if ((response.jsonData as JSONObject)?.["ok"] !== true) {
logger.error("Invalid response from Slack API:");
logger.error(response.jsonData);
const messageFromSlack: string = (response.jsonData as JSONObject)?.[
"error"
] as string;
throw new BadRequestException("Error from Slack " + messageFromSlack);
}
for (const channel of (response.jsonData as JSONObject)[
"channels"
] as Array<JSONObject>) {
if (!channel["id"] || !channel["name"]) {
continue;
}
const channelName: string = (channel["name"] as string).toLowerCase();
if (channelName === normalizedChannelName) {
logger.debug("Channel found:");
logger.debug(channel);
const foundChannel: WorkspaceChannel = {
id: channel["id"] as string,
name: channel["name"] as string,
workspaceType: WorkspaceType.Slack,
};
// Update cache if projectId is provided
if (data.projectId) {
try {
await this.updateChannelCache({
projectId: data.projectId,
channelName: normalizedChannelName,
channel: foundChannel,
});
} catch (error) {
logger.error("Error updating channel cache:");
logger.error(error);
// Don't fail the request if cache update fails
}
}
return foundChannel;
}
}
cursor = (
(response.jsonData as JSONObject)["response_metadata"] as JSONObject
)?.["next_cursor"] as string;
pageCount++;
} while (cursor && pageCount < maxPages);
logger.debug("Channel not found:");
return null;
}
@CaptureSpan()
public static override getDividerBlock(): JSONObject {
return {
@@ -659,6 +870,7 @@ export default class SlackUtil extends WorkspaceBase {
public static override async doesChannelExist(data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<boolean> {
// if channel name starts with #, remove it
if (data.channelName && data.channelName.startsWith("#")) {
@@ -668,18 +880,15 @@ export default class SlackUtil extends WorkspaceBase {
// convert channel name to lowercase
data.channelName = data.channelName.toLowerCase();
// get channel id from channel name
const channels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
// Check if channel exists using optimized method
const channel: WorkspaceChannel | null =
await this.getWorkspaceChannelByName({
authToken: data.authToken,
channelName: data.channelName,
projectId: data.projectId,
});
// if this channel exists
if (channels[data.channelName]) {
return true;
}
return false;
return channel !== null;
}
@CaptureSpan()
@@ -687,6 +896,7 @@ export default class SlackUtil extends WorkspaceBase {
workspaceMessagePayload: WorkspaceMessagePayload;
authToken: string; // which auth token should we use to send.
userId: string;
projectId: ObjectID;
}): Promise<WorkspaceSendMessageResponse> {
logger.debug("Sending message to Slack with data:");
logger.debug(data);
@@ -698,27 +908,21 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug("Blocks generated from workspace message payload:");
logger.debug(blocks);
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
logger.debug("Existing workspace channels:");
logger.debug(existingWorkspaceChannels);
const workspaceChannelsToPostTo: Array<WorkspaceChannel> = [];
// Resolve channel names efficiently
for (let channelName of data.workspaceMessagePayload.channelNames) {
if (channelName && channelName.startsWith("#")) {
// trim # from channel name
channelName = channelName.substring(1);
}
let channel: WorkspaceChannel | null = null;
if (existingWorkspaceChannels[channelName]) {
channel = existingWorkspaceChannels[channelName]!;
}
const channel: WorkspaceChannel | null =
await this.getWorkspaceChannelByName({
authToken: data.authToken,
channelName: channelName,
projectId: data.projectId,
});
if (channel) {
workspaceChannelsToPostTo.push(channel);
@@ -879,6 +1083,7 @@ export default class SlackUtil extends WorkspaceBase {
public static override async createChannel(data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<WorkspaceChannel> {
if (data.channelName && data.channelName.startsWith("#")) {
data.channelName = data.channelName.substring(1);
@@ -946,6 +1151,20 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug("Channel created successfully:");
logger.debug(channel);
// Cache the created channel
try {
await this.updateChannelCache({
projectId: data.projectId,
channelName: data.channelName,
channel: channel,
});
} catch (error) {
logger.error("Error caching created channel:");
logger.error(error);
// Don't fail the creation if caching fails
}
return channel;
}

View File

@@ -145,7 +145,7 @@ export default class WorkspaceUtil {
messagePayloadsByWorkspace: Array<WorkspaceMessagePayload>;
}): Promise<Array<WorkspaceSendMessageResponse>> {
logger.debug("postToWorkspaceChannels called with data:");
logger.debug(data);
logger.debug(JSON.stringify(data, null, 2));
const responses: Array<WorkspaceSendMessageResponse> = [];
@@ -194,6 +194,7 @@ export default class WorkspaceUtil {
await WorkspaceUtil.getWorkspaceTypeUtil(workspaceType).sendMessage({
userId: botUserId,
authToken: projectAuthToken.authToken,
projectId: data.projectId,
workspaceMessagePayload: messagePayloadByWorkspace,
});
@@ -202,7 +203,7 @@ export default class WorkspaceUtil {
logger.debug("Message posted to workspace channels successfully");
logger.debug("Returning thread IDs");
logger.debug(responses);
logger.debug(JSON.stringify(responses, null, 2));
return responses;
}
@@ -213,6 +214,7 @@ export default class WorkspaceUtil {
projectOrUserAuthTokenForWorkspace: string;
workspaceType: WorkspaceType;
workspaceMessagePayload: WorkspaceMessagePayload;
projectId: ObjectID;
}): Promise<WorkspaceSendMessageResponse> {
logger.debug("postToWorkspaceChannels called with data:");
logger.debug(data);
@@ -222,6 +224,7 @@ export default class WorkspaceUtil {
userId: data.workspaceUserId,
workspaceMessagePayload: data.workspaceMessagePayload,
authToken: data.projectOrUserAuthTokenForWorkspace,
projectId: data.projectId,
});
logger.debug("Message posted to workspace channels successfully");

View File

@@ -3,6 +3,7 @@ import HTTPResponse from "../../../Types/API/HTTPResponse";
import Dictionary from "../../../Types/Dictionary";
import NotImplementedException from "../../../Types/Exception/NotImplementedException";
import { JSONObject } from "../../../Types/JSON";
import ObjectID from "../../../Types/ObjectID";
import WorkspaceChannelInvitationPayload from "../../../Types/Workspace/WorkspaceChannelInvitationPayload";
import WorkspaceMessagePayload, {
WorkspaceCheckboxBlock,
@@ -53,6 +54,7 @@ export default class WorkspaceBase {
public static async doesChannelExist(_data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<boolean> {
throw new NotImplementedException();
}
@@ -63,6 +65,7 @@ export default class WorkspaceBase {
authToken: string;
userId: string;
sendMessageBeforeArchiving: WorkspacePayloadMarkdown;
projectId: ObjectID;
}): Promise<void> {
throw new NotImplementedException();
}
@@ -121,6 +124,7 @@ export default class WorkspaceBase {
public static async inviteUsersToChannels(data: {
authToken: string;
workspaceChannelInvitationPayload: WorkspaceChannelInvitationPayload;
projectId: ObjectID;
}): Promise<void> {
for (const channelName of data.workspaceChannelInvitationPayload
.channelNames) {
@@ -129,6 +133,7 @@ export default class WorkspaceBase {
channelName: channelName,
workspaceUserIds:
data.workspaceChannelInvitationPayload.workspaceUserIds,
projectId: data.projectId,
});
}
}
@@ -138,12 +143,14 @@ export default class WorkspaceBase {
authToken: string;
channelName: string;
workspaceUserIds: Array<string>;
projectId: ObjectID;
}): Promise<void> {
for (const userId of data.workspaceUserIds) {
await this.inviteUserToChannelByChannelName({
authToken: data.authToken,
channelName: data.channelName,
workspaceUserId: userId,
projectId: data.projectId,
});
}
}
@@ -153,6 +160,7 @@ export default class WorkspaceBase {
authToken: string;
channelName: string;
workspaceUserId: string;
projectId: ObjectID;
}): Promise<void> {
throw new NotImplementedException();
}
@@ -175,6 +183,7 @@ export default class WorkspaceBase {
public static async createChannelsIfDoesNotExist(_data: {
authToken: string;
channelNames: Array<string>;
projectId: ObjectID;
}): Promise<Array<WorkspaceChannel>> {
throw new NotImplementedException();
}
@@ -192,6 +201,7 @@ export default class WorkspaceBase {
workspaceMessagePayload: WorkspaceMessagePayload;
authToken: string; // which auth token should we use to send.
userId: string;
projectId: ObjectID;
}): Promise<WorkspaceSendMessageResponse> {
throw new NotImplementedException();
}
@@ -207,6 +217,7 @@ export default class WorkspaceBase {
public static async getWorkspaceChannelFromChannelName(_data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<WorkspaceChannel> {
throw new NotImplementedException();
}
@@ -215,6 +226,7 @@ export default class WorkspaceBase {
public static async createChannel(_data: {
authToken: string;
channelName: string;
projectId: ObjectID;
}): Promise<WorkspaceChannel> {
throw new NotImplementedException();
}

View File

@@ -67,7 +67,6 @@
<link rel="apple-touch-icon-precomposed" href="/img/ou-wb.svg">
<link rel="icon" href="/img/ou-wb.svg">
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
<link rel="canonical" href="/">
<link rel="manifest" href="/manifest.json">
<meta property="og:title" content="OneUptime - One Complete Observability platform.">
<meta property="og:url" content="https://oneuptime.com">

View File

@@ -28,21 +28,10 @@ import EmptyResponseData from "../../../Types/API/EmptyResponse";
jest.setTimeout(60000); // Increase test timeout to 60 seconds becuase GitHub runners are slow
jest.mock("../../../Server/Services/ProjectUserService", () => {
return {
refreshProjectUsersByProject: jest.fn().mockResolvedValue(undefined),
};
});
// Get the mocked module to access the mock functions
import ProjectUserService from "../../../Server/Services/ProjectUserService";
const mockProjectUserService = ProjectUserService as jest.Mocked<typeof ProjectUserService>;
describe("TeamMemberService", () => {
beforeEach(async () => {
jest.resetAllMocks();
// Re-setup the mock after resetAllMocks
mockProjectUserService.refreshProjectUsersByProject.mockResolvedValue(undefined);
await TestDatabaseMock.connectDbMock();
});

View File

@@ -1,9 +1,55 @@
import React from "react";
import MarkdownEditor from "../../../UI/Components/Markdown.tsx/MarkdownEditor";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, test } from "@jest/globals";
describe("MarkdownEditor with SpellCheck", () => {
describe("MarkdownEditor", () => {
test("should render with toolbar buttons", () => {
render(
<MarkdownEditor
initialValue="This is a test"
placeholder="Enter markdown here..."
/>,
);
// Check for toolbar buttons
expect(screen.getByTitle("Bold (Ctrl+B)")).toBeInTheDocument();
expect(screen.getByTitle("Italic (Ctrl+I)")).toBeInTheDocument();
expect(screen.getByTitle("Underline")).toBeInTheDocument();
expect(screen.getByTitle("Strikethrough")).toBeInTheDocument();
expect(screen.getByTitle("Heading 1")).toBeInTheDocument();
expect(screen.getByTitle("Heading 2")).toBeInTheDocument();
expect(screen.getByTitle("Heading 3")).toBeInTheDocument();
expect(screen.getByTitle("Bullet List")).toBeInTheDocument();
expect(screen.getByTitle("Numbered List")).toBeInTheDocument();
expect(screen.getByTitle("Task List")).toBeInTheDocument();
expect(screen.getByTitle("Link")).toBeInTheDocument();
expect(screen.getByTitle("Image")).toBeInTheDocument();
expect(screen.getByTitle("Table")).toBeInTheDocument();
expect(screen.getByTitle("Code")).toBeInTheDocument();
expect(screen.getByTitle("Quote")).toBeInTheDocument();
expect(screen.getByTitle("Horizontal Rule")).toBeInTheDocument();
});
test("should toggle preview mode", () => {
render(
<MarkdownEditor
initialValue="**bold text**"
placeholder="Enter markdown here..."
/>,
);
const previewButton: HTMLElement = screen.getByText("Preview");
fireEvent.click(previewButton);
// Should show preview
expect(screen.getByText("Write")).toBeInTheDocument();
// Click to go back to write mode
fireEvent.click(screen.getByText("Write"));
expect(screen.getByText("Preview")).toBeInTheDocument();
});
test("should enable spell check by default", () => {
render(
<MarkdownEditor
@@ -18,6 +64,21 @@ describe("MarkdownEditor with SpellCheck", () => {
expect(textarea.spellcheck).toBe(true);
});
test("should enable spell check when disableSpellCheck is undefined", () => {
render(
<MarkdownEditor
initialValue="This is a test with spelling errors"
placeholder="Enter markdown here..."
disableSpellCheck={undefined}
/>,
);
const textarea: HTMLTextAreaElement = screen.getByRole(
"textbox",
) as HTMLTextAreaElement;
expect(textarea.spellcheck).toBe(true);
});
test("should disable spell check when disableSpellCheck is true", () => {
render(
<MarkdownEditor
@@ -58,4 +119,28 @@ describe("MarkdownEditor with SpellCheck", () => {
textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(textarea.spellcheck).toBe(false);
});
test("should show help text", () => {
render(
<MarkdownEditor initialValue="" placeholder="Enter markdown here..." />,
);
expect(screen.getByText("Markdown help")).toBeInTheDocument();
});
test("should handle onChange callback", () => {
const mockOnChange: jest.Mock = jest.fn();
render(
<MarkdownEditor
initialValue=""
placeholder="Enter markdown here..."
onChange={mockOnChange}
/>,
);
const textarea: HTMLElement = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "new text" } });
expect(mockOnChange).toHaveBeenCalledWith("new text");
});
});

View File

@@ -201,12 +201,19 @@ export default class OneUptimeDate {
return this.secondsToFormattedFriendlyTimeString(seconds);
}
public static toTimeString(date: Date | string): string {
public static toTimeString(
date: Date | string,
use12HourFormat?: boolean,
): string {
if (typeof date === "string") {
date = this.fromString(date);
}
return moment(date).format("HH:mm");
const format: "hh:mm A" | "HH:mm" =
use12HourFormat || this.getUserPrefers12HourFormat()
? "hh:mm A"
: "HH:mm";
return moment(date).format(format);
}
public static isSame(date1: Date, date2: Date): boolean {
@@ -879,23 +886,72 @@ export default class OneUptimeDate {
public static getCurrentDateAsFormattedString(options?: {
onlyShowDate?: boolean;
showSeconds?: boolean;
use12HourFormat?: boolean;
}): string {
return this.getDateAsFormattedString(new Date(), options);
}
public static getUserPrefers12HourFormat(): boolean {
if (typeof window === "undefined") {
// Server-side: default to 12-hour format for user-friendly display
return true;
}
// Client-side: detect user's preferred time format from browser locale
const testDate: Date = new Date();
const timeString: string = testDate.toLocaleTimeString();
return (
timeString.toLowerCase().includes("am") ||
timeString.toLowerCase().includes("pm")
);
}
public static getDateAsUserFriendlyFormattedString(
date: string | Date,
options?: {
onlyShowDate?: boolean;
showSeconds?: boolean;
},
): string {
return this.getDateAsFormattedString(date, {
...options,
use12HourFormat: this.getUserPrefers12HourFormat(),
});
}
public static getDateAsUserFriendlyLocalFormattedString(
date: string | Date,
onlyShowDate?: boolean,
): string {
return this.getDateAsLocalFormattedString(
date,
onlyShowDate,
this.getUserPrefers12HourFormat(),
);
}
public static getDateAsFormattedString(
date: string | Date,
options?: {
onlyShowDate?: boolean;
showSeconds?: boolean;
use12HourFormat?: boolean;
},
): string {
date = this.fromString(date);
let formatstring: string = "MMM DD YYYY, HH:mm";
if (options?.use12HourFormat) {
formatstring = "MMM DD YYYY, hh:mm A";
}
if (options?.showSeconds) {
formatstring = "MMM DD YYYY, HH:mm:ss";
if (options?.use12HourFormat) {
formatstring = "MMM DD YYYY, hh:mm:ss A";
} else {
formatstring = "MMM DD YYYY, HH:mm:ss";
}
}
if (options?.onlyShowDate) {
@@ -1061,15 +1117,22 @@ export default class OneUptimeDate {
date: string | Date;
onlyShowDate?: boolean | undefined;
timezones?: Array<Timezone> | undefined;
use12HourFormat?: boolean | undefined;
}): Array<string> {
let date: string | Date = data.date;
const onlyShowDate: boolean | undefined = data.onlyShowDate;
let timezones: Array<Timezone> | undefined = data.timezones;
const use12HourFormat: boolean =
data.use12HourFormat ?? this.getUserPrefers12HourFormat();
date = this.fromString(date);
let formatstring: string = "MMM DD YYYY, HH:mm";
if (use12HourFormat) {
formatstring = "MMM DD YYYY, hh:mm A";
}
if (onlyShowDate) {
formatstring = "MMM DD, YYYY";
}
@@ -1106,15 +1169,18 @@ export default class OneUptimeDate {
date: string | Date;
onlyShowDate?: boolean;
timezones?: Array<Timezone> | undefined; // if this is skipped, then it will show the default timezones in the order of UTC, EST, PST, IST, ACT
use12HourFormat?: boolean | undefined;
}): string {
const date: string | Date = data.date;
const onlyShowDate: boolean | undefined = data.onlyShowDate;
const timezones: Array<Timezone> | undefined = data.timezones;
const use12HourFormat: boolean | undefined = data.use12HourFormat;
return this.getDateAsFormattedArrayInMultipleTimezones({
date,
onlyShowDate,
timezones,
use12HourFormat,
}).join("<br/>");
}
@@ -1122,26 +1188,34 @@ export default class OneUptimeDate {
date: string | Date;
onlyShowDate?: boolean | undefined;
timezones?: Array<Timezone> | undefined; // if this is skipped, then it will show the default timezones in the order of UTC, EST, PST, IST, ACT
use12HourFormat?: boolean | undefined;
}): string {
const date: string | Date = data.date;
const onlyShowDate: boolean | undefined = data.onlyShowDate;
const timezones: Array<Timezone> | undefined = data.timezones;
const use12HourFormat: boolean | undefined = data.use12HourFormat;
return this.getDateAsFormattedArrayInMultipleTimezones({
date,
onlyShowDate,
timezones,
use12HourFormat,
}).join("\n");
}
public static getDateAsLocalFormattedString(
date: string | Date,
onlyShowDate?: boolean,
use12HourFormat?: boolean,
): string {
date = this.fromString(date);
let formatstring: string = "MMM DD YYYY, HH:mm";
if (use12HourFormat) {
formatstring = "MMM DD YYYY, hh:mm A";
}
if (onlyShowDate) {
formatstring = "MMM DD, YYYY";
}

View File

@@ -0,0 +1,6 @@
enum ExceptionMessages {
MonitorNotFound = "Monitor not found.",
MonitorDisabled = "Monitor is disabled. Please enable it to start monitoring again.",
}
export default ExceptionMessages;

View File

@@ -4,6 +4,8 @@ import URL from "./API/URL";
import Dictionary from "./Dictionary";
import HTML from "./Html";
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
import type { Agent as HttpAgent } from "http";
import type { Agent as HttpsAgent } from "https";
export interface WebsiteResponse {
url: URL;
@@ -22,6 +24,8 @@ export default class WebsiteRequest {
timeout?: number | undefined;
isHeadRequest?: boolean | undefined;
doNotFollowRedirects?: boolean | undefined;
httpAgent?: HttpAgent | undefined; // per-request HTTP proxy agent
httpsAgent?: HttpsAgent | undefined; // per-request HTTPS proxy agent
},
): Promise<WebsiteResponse> {
const axiosOptions: AxiosRequestConfig = {
@@ -41,6 +45,13 @@ export default class WebsiteRequest {
axiosOptions.maxRedirects = 0;
}
if (options.httpAgent) {
(axiosOptions as AxiosRequestConfig).httpAgent = options.httpAgent;
}
if (options.httpsAgent) {
(axiosOptions as AxiosRequestConfig).httpsAgent = options.httpsAgent;
}
// use axios to fetch an HTML page
let response: AxiosResponse | null = null;

View File

@@ -29,10 +29,10 @@ const DashboardStartAndEndDateView: FunctionComponent<ComponentProps> = (
const getContent: GetReactElementFunction = (): ReactElement => {
const title: string = isCustomRange
? `${OneUptimeDate.getDateAsLocalFormattedString(
? `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.dashboardStartAndEndDate.startAndEndDate?.startValue ||
OneUptimeDate.getCurrentDate(),
)} - ${OneUptimeDate.getDateAsLocalFormattedString(
)} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.dashboardStartAndEndDate.startAndEndDate?.endValue ||
OneUptimeDate.getCurrentDate(),
)}`

View File

@@ -178,7 +178,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
if (field.fieldType === FieldType.Date) {
if (data) {
data = OneUptimeDate.getDateAsLocalFormattedString(
data = OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
data as string,
true,
);
@@ -197,7 +197,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
if (field.fieldType === FieldType.DateTime) {
if (data) {
data = OneUptimeDate.getDateAsLocalFormattedString(
data = OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
data as string,
false,
);
@@ -420,9 +420,8 @@ const Detail: DetailFunction = <T extends GenericObject>(
)}
</div>
)}
{(data === null || data === undefined) && field.placeholder && (
<PlaceholderText text={field.placeholder} />
)}
{(data === null || data === undefined || data === "") &&
field.placeholder && <PlaceholderText text={field.placeholder} />}
</div>
</div>
);

View File

@@ -55,7 +55,10 @@ const EventHistoryDayList: FunctionComponent<ComponentProps> = (
width: isMobile ? "100%" : "15%",
}}
>
{OneUptimeDate.getDateAsLocalFormattedString(props.date, true)}
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.date,
true,
)}
</div>
<div
style={{

View File

@@ -224,7 +224,7 @@ const EventItem: FunctionComponent<ComponentProps> = (
</div>
<div>
<span className="text-sm leading-8 text-gray-500 whitespace-nowrap">
{OneUptimeDate.getDateAsLocalFormattedString(
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
item.date,
)}
</span>
@@ -269,7 +269,7 @@ const EventItem: FunctionComponent<ComponentProps> = (
</div>
<p className="mt-0.5 text-sm text-gray-500">
posted on{" "}
{OneUptimeDate.getDateAsLocalFormattedString(
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
item.date,
)}
</p>

View File

@@ -117,7 +117,7 @@ const FeedItem: FunctionComponent<ComponentProps> = (
)}
<div className="mt-0.5 text-sm text-gray-500 w-fit">
<Tooltip
text={OneUptimeDate.getDateAsLocalFormattedString(
text={OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.itemDateTime,
)}
>

View File

@@ -208,11 +208,11 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
const shouldOnlyShowDate: boolean = data.filter.type === FieldType.Date;
if (
OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
startAndEndDates.startValue as Date,
shouldOnlyShowDate,
) ===
OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
startAndEndDates.endValue as Date,
shouldOnlyShowDate,
)
@@ -222,7 +222,7 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
{" "}
<span className="font-medium">{data.filter.title}</span> at{" "}
<span className="font-medium">
{OneUptimeDate.getDateAsLocalFormattedString(
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
startAndEndDates.startValue as Date,
data.filter.type === FieldType.Date,
)}
@@ -235,14 +235,14 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
{" "}
<span className="font-medium">{data.filter.title}</span> is in between{" "}
<span className="font-medium">
{OneUptimeDate.getDateAsLocalFormattedString(
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
startAndEndDates.startValue as Date,
shouldOnlyShowDate,
)}
</span>{" "}
and{" "}
<span className="font-medium">
{OneUptimeDate.getDateAsLocalFormattedString(
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
startAndEndDates.endValue as Date,
shouldOnlyShowDate,
)}

View File

@@ -34,6 +34,7 @@ import React, { ReactElement, useEffect } from "react";
import Radio, { RadioValue } from "../../Radio/Radio";
import { BasicRadioButtonOption } from "../../RadioButtons/BasicRadioButtons";
import HorizontalRule from "../../HorizontalRule/HorizontalRule";
import MarkdownEditor from "../../Markdown.tsx/MarkdownEditor";
export interface ComponentProps<T extends GenericObject> {
field: Field<T>;
@@ -473,11 +474,10 @@ const FormField: <T extends GenericObject>(
)}
{props.field.fieldType === FormFieldSchemaType.Markdown && (
<CodeEditor
<MarkdownEditor
error={props.touched && props.error ? props.error : undefined}
dataTestId={props.field.dataTestId}
tabIndex={index}
type={CodeType.Markdown}
disableSpellCheck={props.field.disableSpellCheck}
onChange={async (value: string) => {
onChange(value);

View File

@@ -115,6 +115,7 @@ export default interface Field<TEntity> {
hideOptionalLabel?: boolean | undefined;
// Spell check configuration (primarily for Markdown and text fields)
// Default: false (spell check enabled). Set to true to disable spell check.
disableSpellCheck?: boolean | undefined;
getSummaryElement?: (item: FormValues<TEntity>) => ReactElement | undefined;

View File

@@ -55,7 +55,7 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
dayNumber,
);
let toolTipText: string = `${OneUptimeDate.getDateAsLocalFormattedString(
let toolTipText: string = `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
todaysDay,
true,
)}`;
@@ -189,7 +189,7 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
if (todaysEvents.length === 1) {
hasEvents = true;
toolTipText = `${OneUptimeDate.getDateAsLocalFormattedString(
toolTipText = `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
todaysDay,
true,
)} - 100% ${todaysEvents[0]?.label || "Operational"}.`;

View File

@@ -14,9 +14,14 @@ import ReactFlow, {
Position,
} from "reactflow";
import "reactflow/dist/style.css";
import type { ElkExtendedEdge, ElkNode } from "elkjs";
import type { ElkExtendedEdge, ElkNode, LayoutOptions } from "elkjs";
import ELK from "elkjs/lib/elk.bundled.js";
// Minimal interface for the ELK layout engine we rely on.
interface ElkLayoutEngine {
layout: (graph: ElkNode) => Promise<ElkNode>;
}
export interface ServiceNodeData {
id: string;
name: string;
@@ -96,7 +101,7 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
useEffect((): void => {
const elk: any = new ELK();
const elk: ElkLayoutEngine = new ELK() as unknown as ElkLayoutEngine;
// fixed node dimensions for layout (px)
const NODE_WIDTH: number = 220;
const NODE_HEIGHT: number = 56;
@@ -123,7 +128,7 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
"elk.layered.spacing.nodeNodeBetweenLayers": "120",
"elk.spacing.nodeNode": "60",
"elk.edgeRouting": "POLYLINE",
},
} as LayoutOptions,
children: sortedServices.map((svc: ServiceNodeData): ElkNode => {
return {
id: svc.id,
@@ -142,9 +147,9 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
const layout: () => Promise<void> = async (): Promise<void> => {
try {
const res: any = await elk.layout(elkGraph as any);
const res: ElkNode = (await elk.layout(elkGraph)) as ElkNode; // casting to bundled ElkNode shape
const placedNodes: Node[] = (res.children || []).map(
(child: any): Node => {
(child: ElkNode): Node => {
const svc: ServiceNodeData | undefined = sortedServices.find(
(s: ServiceNodeData): boolean => {
return s.id === child.id;

View File

@@ -64,7 +64,8 @@ const Input: FunctionComponent<ComponentProps> = (
const [value, setValue] = useState<string | Date>("");
const [displayValue, setDisplayValue] = useState<string>("");
const ref: any = useRef<any>(null);
const ref: React.MutableRefObject<HTMLInputElement | null> =
useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (
@@ -120,9 +121,9 @@ const Input: FunctionComponent<ComponentProps> = (
}, [value]);
useEffect(() => {
const input: any = ref.current;
const input: HTMLInputElement | null = ref.current;
if (input) {
(input as any).value = displayValue;
input.value = displayValue;
}
}, [ref, displayValue]);
@@ -195,7 +196,7 @@ const Input: FunctionComponent<ComponentProps> = (
tabIndex={props.tabIndex}
onKeyDown={
props.onEnterPress
? (event: any) => {
? (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
props.onEnterPress?.();
}

View File

@@ -144,7 +144,8 @@ const LogItem: FunctionComponent<ComponentProps> = (
DATE TIME:
</div>
<div className="text-slate-500 courier-prime">
{OneUptimeDate.getDateAsFormattedString(props.log.time)} &nbsp;{" "}
{OneUptimeDate.getDateAsUserFriendlyFormattedString(props.log.time)}{" "}
&nbsp;{" "}
</div>
</div>
)}

View File

@@ -1,5 +1,12 @@
import TextArea from "../TextArea/TextArea";
import React, { FunctionComponent, ReactElement } from "react";
import Icon from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import React, {
FunctionComponent,
ReactElement,
useState,
useRef,
useEffect,
} from "react";
export interface ComponentProps {
initialValue?: undefined | string;
@@ -10,24 +17,656 @@ export interface ComponentProps {
onBlur?: (() => void) | undefined;
tabIndex?: number | undefined;
error?: string | undefined;
// Default: false (spell check enabled). Set to true to disable spell check.
disableSpellCheck?: boolean | undefined;
dataTestId?: string | undefined;
}
interface ToolbarButtonProps {
icon: IconProp;
title: string;
onClick: () => void;
isActive?: boolean;
}
const ToolbarButton: FunctionComponent<ToolbarButtonProps> = ({
icon,
title,
onClick,
isActive = false,
}: ToolbarButtonProps): ReactElement => {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`p-2 rounded-md transition-colors duration-200 ${
isActive
? "bg-indigo-100 text-indigo-700"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
} focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2`}
>
<Icon icon={icon} className="h-4 w-4" />
</button>
);
};
const MarkdownEditor: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [text, setText] = useState<string>(props.initialValue || "");
const [showPreview, setShowPreview] = useState<boolean>(false);
const textareaRef: React.RefObject<HTMLTextAreaElement> =
useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (props.initialValue !== undefined) {
setText(props.initialValue);
}
}, [props.initialValue]);
const handleChange: (value: string) => void = (value: string): void => {
setText(value);
if (props.onChange) {
props.onChange(value);
}
};
const insertText: (
before: string,
after?: string,
placeholder?: string,
) => void = (
before: string,
after: string = "",
placeholder: string = "",
): void => {
const textarea: HTMLTextAreaElement | null = textareaRef.current;
if (!textarea) {
return;
}
const start: number = textarea.selectionStart;
const end: number = textarea.selectionEnd;
const selectedText: string = text.substring(start, end);
const textToInsert: string = selectedText || placeholder;
const newText: string =
text.substring(0, start) +
before +
textToInsert +
after +
text.substring(end);
handleChange(newText);
// Set cursor position after insertion
setTimeout(() => {
if (selectedText) {
textarea.setSelectionRange(
start + before.length,
start + before.length + textToInsert.length,
);
} else {
textarea.setSelectionRange(
start + before.length,
start + before.length + placeholder.length,
);
}
textarea.focus();
}, 0);
};
const insertAtLineStart: (prefix: string) => void = (
prefix: string,
): void => {
const textarea: HTMLTextAreaElement | null = textareaRef.current;
if (!textarea) {
return;
}
const start: number = textarea.selectionStart;
const lineStart: number = text.lastIndexOf("\n", start - 1) + 1;
const lineEnd: number = text.indexOf("\n", start);
const actualLineEnd: number = lineEnd === -1 ? text.length : lineEnd;
const currentLine: string = text.substring(lineStart, actualLineEnd);
// Special handling for headings - replace existing heading levels
if (prefix.startsWith("#")) {
// Remove any existing heading markers
const cleanLine: string = currentLine.replace(/^#+\s*/, "");
if (currentLine.startsWith(prefix)) {
// Same heading level - remove it
const newText: string =
text.substring(0, lineStart) +
cleanLine +
text.substring(actualLineEnd);
handleChange(newText);
setTimeout(() => {
textarea.setSelectionRange(
start - prefix.length,
start - prefix.length,
);
textarea.focus();
}, 0);
} else {
// Different heading level or no heading - apply new heading
const newText: string =
text.substring(0, lineStart) +
prefix +
cleanLine +
text.substring(actualLineEnd);
handleChange(newText);
setTimeout(() => {
const adjustment: number =
prefix.length - (currentLine.length - cleanLine.length);
textarea.setSelectionRange(start + adjustment, start + adjustment);
textarea.focus();
}, 0);
}
} else if (currentLine.startsWith(prefix)) {
// Non-heading prefixes (lists, quotes) - remove prefix
const newText: string =
text.substring(0, lineStart) +
currentLine.substring(prefix.length) +
text.substring(actualLineEnd);
handleChange(newText);
setTimeout(() => {
textarea.setSelectionRange(
start - prefix.length,
start - prefix.length,
);
textarea.focus();
}, 0);
} else {
// Add prefix
const newText: string =
text.substring(0, lineStart) +
prefix +
currentLine +
text.substring(actualLineEnd);
handleChange(newText);
setTimeout(() => {
textarea.setSelectionRange(
start + prefix.length,
start + prefix.length,
);
textarea.focus();
}, 0);
}
};
const formatActions: {
bold: () => void;
italic: () => void;
underline: () => void;
strikethrough: () => void;
heading1: () => void;
heading2: () => void;
heading3: () => void;
unorderedList: () => void;
orderedList: () => void;
taskList: () => void;
link: () => void;
image: () => void;
code: () => void;
codeBlock: () => void;
quote: () => void;
horizontalRule: () => void;
table: () => void;
} = {
bold: () => {
return insertText("**", "**", "bold text");
},
italic: () => {
return insertText("*", "*", "italic text");
},
underline: () => {
return insertText("<u>", "</u>", "underlined text");
},
strikethrough: () => {
return insertText("~~", "~~", "strikethrough text");
},
heading1: () => {
return insertAtLineStart("# ");
},
heading2: () => {
return insertAtLineStart("## ");
},
heading3: () => {
return insertAtLineStart("### ");
},
unorderedList: () => {
return insertAtLineStart("- ");
},
orderedList: () => {
return insertAtLineStart("1. ");
},
taskList: () => {
return insertAtLineStart("- [ ] ");
},
link: () => {
return insertText("[", "](url)", "link text");
},
image: () => {
return insertText("![", "](image-url)", "alt text");
},
code: () => {
return insertText("`", "`", "code");
},
codeBlock: () => {
return insertText("```\n", "\n```", "code block");
},
quote: () => {
return insertAtLineStart("> ");
},
horizontalRule: () => {
return insertText("\n---\n", "", "");
},
table: () => {
return insertText(
"\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |\n",
"",
"",
);
},
};
let className: string = "";
if (!props.className) {
className =
"block w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-3 text-sm placeholder-gray-500 focus:border-indigo-500 focus:text-gray-900 focus:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none";
} else {
className = props.className;
}
if (props.error) {
className +=
" border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500";
}
const renderPreview: () => ReactElement = (): ReactElement => {
// Enhanced markdown preview with proper code block handling
let htmlContent: string = text;
// Handle code blocks first (before inline code)
htmlContent = htmlContent.replace(
/```([^`]*?)```/g,
(_match: string, code: string) => {
const escapedCode: string = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return `<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4"><code class="text-sm font-mono whitespace-pre">${escapedCode}</code></pre>`;
},
);
// Handle inline code (after code blocks to avoid conflicts)
htmlContent = htmlContent.replace(
/`([^`]+)`/g,
'<code class="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono">$1</code>',
);
// Handle other markdown elements
htmlContent = htmlContent
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/<u>(.*?)<\/u>/g, '<u class="underline">$1</u>')
.replace(/~~(.*?)~~/g, '<s class="line-through">$1</s>')
.replace(
/^# (.*$)/gm,
'<h1 class="text-3xl font-bold mb-4 mt-6 first:mt-0">$1</h1>',
)
.replace(
/^## (.*$)/gm,
'<h2 class="text-2xl font-bold mb-3 mt-5 first:mt-0">$1</h2>',
)
.replace(
/^### (.*$)/gm,
'<h3 class="text-xl font-bold mb-2 mt-4 first:mt-0">$1</h3>',
)
.replace(
/^#### (.*$)/gm,
'<h4 class="text-lg font-bold mb-2 mt-3 first:mt-0">$1</h4>',
)
.replace(
/^- \[ \] (.*$)/gm,
'<li class="ml-6 mb-1 flex items-center"><input type="checkbox" class="mr-2" disabled> $1</li>',
)
.replace(
/^- \[x\] (.*$)/gm,
'<li class="ml-6 mb-1 flex items-center"><input type="checkbox" class="mr-2" checked disabled> $1</li>',
)
.replace(
/^- (.*$)/gm,
'<li class="ml-6 mb-1 list-disc list-inside">$1</li>',
)
.replace(
/^\d+\. (.*$)/gm,
'<li class="ml-6 mb-1 list-decimal list-inside">$1</li>',
)
.replace(
/^> (.*$)/gm,
'<blockquote class="border-l-4 border-blue-400 pl-4 py-2 mb-4 bg-blue-50 italic text-gray-700">$1</blockquote>',
)
.replace(/^---$/gm, '<hr class="border-t-2 border-gray-300 my-6">')
.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
'<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg shadow-sm my-4">',
)
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" class="text-blue-600 hover:text-blue-800 underline font-medium" target="_blank" rel="noopener noreferrer">$1</a>',
);
// Handle tables
htmlContent = htmlContent.replace(
/^\|(.+)\|\n\|(-+\|)+\n((?:\|.+\|\n?)*)/gm,
(
_match: string,
headerRow: string,
_separatorRow: string,
bodyRows: string,
) => {
const headers: string = headerRow
.split("|")
.filter((cell: string) => {
return cell.trim();
})
.map((cell: string) => {
return `<th class="px-4 py-2 bg-gray-50 font-semibold text-left border-b border-gray-300">${cell.trim()}</th>`;
})
.join("");
const rows: string = bodyRows
.split("\n")
.filter((row: string) => {
return row.trim();
})
.map((row: string) => {
const cells: string = row
.split("|")
.filter((cell: string) => {
return cell.trim();
})
.map((cell: string) => {
return `<td class="px-4 py-2 border-b border-gray-200">${cell.trim()}</td>`;
})
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<table class="w-full border-collapse border border-gray-300 my-4 rounded-lg overflow-hidden"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
},
);
// Handle line breaks (convert \n to <br> but avoid double breaks)
htmlContent = htmlContent
.replace(/\n\n/g, '</p><p class="mb-4">')
.replace(/\n/g, "<br>");
// Wrap in paragraphs if there's content
if (htmlContent.trim()) {
htmlContent = `<p class="mb-4">${htmlContent}</p>`;
}
return (
<div className="p-4 min-h-32 bg-white prose prose-sm max-w-none">
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
</div>
);
};
return (
<TextArea
tabIndex={props.tabIndex}
className={props.className}
initialValue={props.initialValue || ""}
placeholder={props.placeholder}
onChange={props.onChange ? props.onChange : () => {}}
onFocus={props.onFocus ? props.onFocus : () => {}}
onBlur={props.onBlur ? props.onBlur : () => {}}
error={props.error}
disableSpellCheck={props.disableSpellCheck}
/>
<div className="relative" data-testid={props.dataTestId}>
{/* Toolbar */}
<div className="p-2 bg-gray-50 border border-gray-300 rounded-t-md border-b-0">
<div className="flex flex-wrap items-center gap-1">
{/* Text Formatting */}
<div className="flex items-center gap-1">
<ToolbarButton
icon={IconProp.Bold}
title="Bold (Ctrl+B)"
onClick={formatActions.bold}
/>
<ToolbarButton
icon={IconProp.Italic}
title="Italic (Ctrl+I)"
onClick={formatActions.italic}
/>
<ToolbarButton
icon={IconProp.Underline}
title="Underline"
onClick={formatActions.underline}
/>
<ToolbarButton
icon={IconProp.Minus}
title="Strikethrough"
onClick={formatActions.strikethrough}
/>
</div>
<div className="w-px h-6 bg-gray-300" />
{/* Headings */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={formatActions.heading1}
title="Heading 1"
className="px-2 py-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="text-sm font-bold">H1</span>
</button>
<button
type="button"
onClick={formatActions.heading2}
title="Heading 2"
className="px-2 py-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="text-sm font-bold">H2</span>
</button>
<button
type="button"
onClick={formatActions.heading3}
title="Heading 3"
className="px-2 py-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="text-sm font-bold">H3</span>
</button>
</div>
<div className="w-px h-6 bg-gray-300" />
{/* Lists */}
<div className="flex items-center gap-1">
<ToolbarButton
icon={IconProp.ListBullet}
title="Bullet List"
onClick={formatActions.unorderedList}
/>
<ToolbarButton
icon={IconProp.List}
title="Numbered List"
onClick={formatActions.orderedList}
/>
<ToolbarButton
icon={IconProp.Check}
title="Task List"
onClick={formatActions.taskList}
/>
</div>
<div className="w-px h-6 bg-gray-300" />
{/* Links and Media */}
<div className="flex items-center gap-1">
<ToolbarButton
icon={IconProp.Link}
title="Link"
onClick={formatActions.link}
/>
<ToolbarButton
icon={IconProp.Image}
title="Image"
onClick={formatActions.image}
/>
<ToolbarButton
icon={IconProp.Code}
title="Code"
onClick={formatActions.code}
/>
</div>
<div className="w-px h-6 bg-gray-300" />
{/* Advanced */}
<div className="flex items-center gap-1">
<ToolbarButton
icon={IconProp.TableCells}
title="Table"
onClick={formatActions.table}
/>
<button
type="button"
onClick={formatActions.horizontalRule}
title="Horizontal Rule"
className="p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="font-bold text-sm"></span>
</button>
<button
type="button"
onClick={formatActions.quote}
title="Quote"
className="p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="font-bold text-sm">&quot;</span>
</button>
<button
type="button"
onClick={formatActions.codeBlock}
title="Code Block"
className="p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="font-mono text-xs font-bold">{"{}"}</span>
</button>
</div>
<div className="w-px h-6 bg-gray-300" />
{/* Preview Toggle */}
<div className="flex items-center">
<button
type="button"
onClick={() => {
return setShowPreview(!showPreview);
}}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 ${
showPreview
? "bg-indigo-100 text-indigo-700"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
} focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2`}
>
{showPreview ? "Write" : "Preview"}
</button>
</div>
</div>
</div>
{/* Editor/Preview Area */}
<div className="relative">
{showPreview ? (
<div
className={`min-h-32 border border-gray-300 bg-white rounded-b-md ${props.error ? "border-red-300" : ""}`}
>
{text.trim() ? (
renderPreview()
) : (
<div className="p-3 text-gray-500 italic">Nothing to preview</div>
)}
</div>
) : (
<div className="relative">
<textarea
ref={textareaRef}
autoFocus={false}
placeholder={props.placeholder || "Type your markdown here..."}
className={`${className} rounded-t-none min-h-32`}
value={text}
spellCheck={props.disableSpellCheck !== true}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
handleChange(e.target.value);
}}
onFocus={() => {
if (props.onFocus) {
props.onFocus();
}
}}
onBlur={() => {
if (props.onBlur) {
props.onBlur();
}
}}
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle keyboard shortcuts
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case "b":
e.preventDefault();
formatActions.bold();
break;
case "i":
e.preventDefault();
formatActions.italic();
break;
}
}
}}
tabIndex={props.tabIndex}
rows={6}
/>
{props.error && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<Icon
icon={IconProp.ErrorSolid}
className="h-5 w-5 text-red-500"
/>
</div>
)}
</div>
)}
</div>
{/* Error Message */}
{props.error && (
<p className="mt-1 text-sm text-red-400">{props.error}</p>
)}
{/* Help Text */}
<div className="mt-2 text-xs text-gray-500">
<details className="cursor-pointer">
<summary className="hover:text-gray-700">Markdown help</summary>
<div className="mt-2 space-y-1">
<div>
<strong>**bold**</strong> or <em>*italic*</em>
</div>
<div>
<code className="bg-gray-100 px-1 rounded">`code`</code> or
```code block```
</div>
<div># Heading 1, ## Heading 2, ### Heading 3</div>
<div>- Bullet list or 1. Numbered list</div>
<div>[Link text](url) or &gt; Quote</div>
</div>
</details>
</div>
</div>
);
};

View File

@@ -14,14 +14,14 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<div>
<div className="max-w-none p-3">
<ReactMarkdown
components={{
// because tailwind does not supply <h1 ... /> styles https://tailwindcss.com/docs/preflight#headings-are-unstyled
h1: ({ ...props }: any) => {
return (
<h1
className="text-3xl mt-5 border border-gray-200 border-r-0 border-l-0 border-t-0 pb-1 mb-8 text-gray-800 font-medium"
className="text-4xl mt-8 mb-6 border-b-2 border-blue-500 pb-2 text-gray-900 font-bold"
{...props}
/>
);
@@ -29,7 +29,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
h2: ({ ...props }: any) => {
return (
<h2
className="text-2xl mt-4 border border-gray-200 border-r-0 border-l-0 border-t-0 pb-1 mb-8 text-gray-800 font-medium"
className="text-3xl mt-6 mb-4 border-b border-gray-300 pb-1 text-gray-900 font-semibold"
{...props}
/>
);
@@ -37,7 +37,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
h3: ({ ...props }: any) => {
return (
<h3
className="text-xl mt-12 mb-3 text-gray-800 font-medium"
className="text-2xl mt-6 mb-3 text-gray-900 font-semibold"
{...props}
/>
);
@@ -45,7 +45,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
h4: ({ ...props }: any) => {
return (
<h4
className="text-lg mt-8 mb-3 text-gray-800 font-medium"
className="text-xl mt-5 mb-3 text-gray-900 font-medium"
{...props}
/>
);
@@ -53,7 +53,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
h5: ({ ...props }: any) => {
return (
<h5
className="text-lg mt-5 mb-1 text-gray-800 font-medium"
className="text-lg mt-4 mb-2 text-gray-900 font-medium"
{...props}
/>
);
@@ -61,51 +61,103 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
h6: ({ ...props }: any) => {
return (
<h6
className="text-base mt-3 text-gray-800 font-medium"
className="text-base mt-3 mb-2 text-gray-900 font-medium"
{...props}
/>
);
},
p: ({ ...props }: any) => {
return <p className="text-sm mt-2 mb-3 text-gray-500" {...props} />;
return <p className="text-base mt-3 mb-4 text-gray-700 leading-relaxed" {...props} />;
},
a: ({ ...props }: any) => {
return (
<a className="underline text-blue-500 font-medium" {...props} />
<a className="underline text-blue-600 hover:text-blue-800 font-medium transition-colors" {...props} />
);
},
pre: ({ ...props }: any) => {
pre: ({ children, ...rest }: any) => {
// Avoid double borders when SyntaxHighlighter is already styling the block.
const isSyntaxHighlighter: boolean =
React.isValidElement(children) &&
// name can be 'SyntaxHighlighter' or wrapped/minified; fall back to presence of 'children' prop with 'react-syntax-highlighter' data attribute.
(((children as any).type &&
((children as any).type.name === "SyntaxHighlighter" ||
(children as any).type.displayName === "SyntaxHighlighter")) ||
(children as any).props?.className?.includes("syntax-highlighter"));
const baseClass: string = isSyntaxHighlighter
? "mt-4 mb-4 rounded-lg overflow-hidden"
: "bg-gray-900 text-gray-100 mt-4 mb-4 p-4 rounded-lg text-sm overflow-x-auto border border-gray-700";
return (
<pre
className="text-gray-600 mt-4 mb-2 rounded text-sm text-sm overflow-x-auto "
{...props}
/>
<pre className={baseClass} {...rest}>
{children}
</pre>
);
},
strong: ({ ...props }: any) => {
return (
<strong
className="text-sm mt-2 text-gray-900 font-medium"
className="text-base font-semibold text-gray-900"
{...props}
/>
);
},
li: ({ ...props }: any) => {
return (
<li className="text-sm mt-2 text-gray-500 list-disc" {...props} />
<li className="text-base mt-2 mb-1 text-gray-700 leading-relaxed" {...props} />
);
},
ul: ({ ...props }: any) => {
return <ul className="list-disc px-6 m-1" {...props} />;
return <ul className="list-disc pl-8 mt-2 mb-4" {...props} />;
},
ol: ({ ...props }: any) => {
return <ol className="list-decimal pl-8 mt-2 mb-4" {...props} />;
},
blockquote: ({ ...props }: any) => {
return (
<blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600 bg-gray-50 py-2 my-4" {...props} />
);
},
table: ({ ...props }: any) => {
return (
<table className="min-w-full table-auto border-collapse border border-gray-300 mt-4 mb-4" {...props} />
);
},
thead: ({ ...props }: any) => {
return (
<thead className="bg-gray-100" {...props} />
);
},
tbody: ({ ...props }: any) => {
return (
<tbody {...props} />
);
},
tr: ({ ...props }: any) => {
return (
<tr className="border-b border-gray-200" {...props} />
);
},
th: ({ ...props }: any) => {
return (
<th className="px-4 py-2 text-left text-sm font-semibold text-gray-900 border border-gray-300" {...props} />
);
},
td: ({ ...props }: any) => {
return (
<td className="px-4 py-2 text-sm text-gray-700 border border-gray-300" {...props} />
);
},
hr: ({ ...props }: any) => {
return <hr className="border-gray-300 my-6" {...props} />;
},
code: (props: any) => {
const { children, className, ...rest } = props;
// eslint-disable-next-line wrap-regex
const match: RegExpExecArray | null = /language-(\w+)/.exec(
className || "",
);
const match: RegExpExecArray | null = new RegExp(
"language-(\\w+)",
).exec(className || "");
const content: string = String(children as string).replace(
/\n$/,
@@ -119,7 +171,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
return item.includes("language-");
}).length > 0)
? ""
: "text-sm p-1 bg-gray-100 rounded text-gray-900 pl-2 pr-2 text-xs";
: "text-sm px-2 py-1 bg-gray-200 rounded text-gray-900 font-mono";
return match ? (
<SyntaxHighlighter
@@ -129,6 +181,8 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
children={content}
language={match[1]}
style={a11yDark}
className="rounded-lg mt-4 mb-4 !bg-gray-900 !p-4 text-sm"
codeTagProps={{ className: "font-mono" }}
/>
) : (
<code className={codeClassName} {...rest}>

View File

@@ -192,7 +192,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
column.key && !column.getElement ? (
column.type === FieldType.Date ? (
props.item[column.key] ? (
OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.item[column.key] as string,
true,
)
@@ -201,7 +201,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
)
) : column.type === FieldType.DateTime ? (
props.item[column.key] ? (
OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.item[column.key] as string,
false,
)
@@ -367,7 +367,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
{column.key && !column.getElement ? (
column.type === FieldType.Date ? (
props.item[column.key] ? (
OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.item[column.key] as string,
true,
)
@@ -376,7 +376,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
)
) : column.type === FieldType.DateTime ? (
props.item[column.key] ? (
OneUptimeDate.getDateAsLocalFormattedString(
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
props.item[column.key] as string,
false,
)

View File

@@ -27,7 +27,9 @@ export interface ComponentProps {
const ArgumentsForm: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const formRef: any = useRef<FormProps<FormValues<JSONObject>>>(null);
const formRef: React.MutableRefObject<FormProps<
FormValues<JSONObject>
> | null> = useRef<FormProps<FormValues<JSONObject>> | null>(null);
const [component, setComponent] = useState<NodeDataProp>(props.component);
const [showVariableModal, setShowVariableModal] = useState<boolean>(false);
const [showComponentPickerModal, setShowComponentPickerModal] =
@@ -143,7 +145,7 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
}}
onSave={(variableId: string) => {
setShowVariableModal(false);
formRef.current.setFieldValue(
formRef.current?.setFieldValue(
selectedArgId,
(component.arguments && component.arguments[selectedArgId]
? component.arguments[selectedArgId]
@@ -161,7 +163,7 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
}}
onSave={(returnValuePath: string) => {
setShowComponentPickerModal(false);
formRef.current.setFieldValue(
formRef.current?.setFieldValue(
selectedArgId,
(component.arguments && component.arguments[selectedArgId]
? component.arguments[selectedArgId]

View File

@@ -1,7 +1,6 @@
import { DropdownOption } from "../Dropdown/Dropdown";
import FormFieldSchemaType from "../Forms/Types/FormFieldSchemaType";
import IconProp from "../../../Types/Icon/IconProp";
import Typeof from "../../../Types/Typeof";
import ComponentMetadata, {
ComponentCategory,
ComponentInputType,
@@ -44,7 +43,7 @@ export const loadComponentsAndCategories: LoadComponentsAndCategoriesFunction =
type ComponentInputTypeToFormFieldTypeFunction = (
componentInputType: ComponentInputType,
argValue: any,
argValue: unknown,
) => {
fieldType: FormFieldSchemaType;
dropdownOptions?: Array<DropdownOption> | undefined;
@@ -53,7 +52,7 @@ type ComponentInputTypeToFormFieldTypeFunction = (
export const componentInputTypeToFormFieldType: ComponentInputTypeToFormFieldTypeFunction =
(
componentInputType: ComponentInputType,
argValue: any,
argValue: unknown,
): {
fieldType: FormFieldSchemaType;
dropdownOptions?: Array<DropdownOption> | undefined;
@@ -122,11 +121,7 @@ export const componentInputTypeToFormFieldType: ComponentInputTypeToFormFieldTyp
// Second priority.
if (
argValue &&
typeof argValue === Typeof.String &&
argValue.toString().includes("{{")
) {
if (typeof argValue === "string" && argValue.includes("{{")) {
return {
fieldType: FormFieldSchemaType.Text,
dropdownOptions: [],

View File

@@ -91,6 +91,10 @@ class BaseAPI extends API {
return defaultHeaders;
}
protected static logoutUser(): void {
return User.logout();
}
protected static override handleError(
error: HTTPErrorResponse | APIException,
): HTTPErrorResponse | APIException {
@@ -103,7 +107,7 @@ class BaseAPI extends API {
) {
const loginRoute: Route = this.getLoginRoute();
User.logout();
this.logoutUser();
if (Navigation.getQueryStringByName("token")) {
Navigation.navigate(loginRoute.addRouteParam("sso", "true"), {

Some files were not shown because too many files have changed in this diff Show More