Compare commits

..

143 Commits

Author SHA1 Message Date
Simon Larsen
8219f44708 fix: Adjust job removal counts in Queue class to manage Redis bloat more effectively 2025-08-08 22:05:54 +01:00
Simon Larsen
59e6505aa3 Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-08 22:03:46 +01:00
Simon Larsen
1f971b932a feat: Implement initial cleanup for legacy jobs in Queue class to manage memory and prevent Redis bloat 2025-08-08 22:03:43 +01:00
Nawaz Dhandala
d0e12ae86f fix: Adjust indentation for improved readability in Config and Routes files 2025-08-08 21:56:35 +01:00
Simon Larsen
cd11a450cd feat: Introduce configurable concurrency settings for ingest workers in environment variables 2025-08-08 21:52:49 +01:00
Simon Larsen
c0259fc041 feat: Add concurrency setting for OpenTelemetry Ingest worker and update related configurations 2025-08-08 21:21:53 +01:00
Simon Larsen
db1f5a29bb fix: Update notification handling to mark announcements and public notes as Skipped when no related entities are found 2025-08-08 19:17:55 +01:00
Simon Larsen
a9ecaf2dc8 fix: Update incident handling to mark subscriber notifications as Skipped when no monitors are attached 2025-08-08 19:14:52 +01:00
Nawaz Dhandala
138aad596a Refactor logging statements for improved readability and consistency across worker jobs; ensure all debug messages are formatted uniformly. Update migration index to include trailing comma for consistency. Simplify route initialization in Workers feature set. 2025-08-08 18:53:40 +01:00
Simon Larsen
2577b339aa fix: Reorder route initialization to ensure worker routes are registered before default catch-alls 2025-08-08 18:44:09 +01:00
Simon Larsen
80e7731cca fix: Remove ClusterKeyAuthorization middleware from inspector route 2025-08-08 18:13:03 +01:00
Simon Larsen
9da7b258f9 feat: Add migration to rename subscriber notification fields and update database schema 2025-08-08 17:48:07 +01:00
Simon Larsen
0ec3b1aa39 fix: Remove obsolete migration files and update index to reflect changes 2025-08-08 17:42:27 +01:00
Simon Larsen
7a9bb22813 fix: Add UpdateSubscriberNotificationStatusToEnum migration for subscriber notification status updates 2025-08-08 17:34:12 +01:00
Simon Larsen
92550ac7d6 fix: Remove unnecessary createdAt condition in subscriber notification jobs and add debug logging for better traceability 2025-08-08 16:56:47 +01:00
Nawaz Dhandala
101df5b9b7 fix: Refactor error message in StatusPageDelete for better readability and clarity 2025-08-08 15:17:54 +01:00
Simon Larsen
c3d7672935 fix: Remove unnecessary NOT NULL constraint on subscriberNotificationStatusOnIncidentCreated in Incident table migration 2025-08-08 15:08:03 +01:00
Simon Larsen
859c6378af fix: Update Dockerfile to use apt-get for installing bash and curl 2025-08-08 14:05:51 +01:00
Simon Larsen
620979eab2 fix: Correct typo in notification status message 2025-08-08 13:37:52 +01:00
Simon Larsen
0aa1c51efa fix: Update error message in StatusPageDelete to clarify environment variable setup for Docker and Helm 2025-08-08 13:37:13 +01:00
Simon Larsen
1985e9fc25 fix: Update error message in StatusPageDelete to include environment variable requirement for custom domains 2025-08-08 13:35:27 +01:00
Simon Larsen
ba4093838b fix: Add type assertion for categoryColors in ChartLegend to ensure correct type usage 2025-08-07 22:31:54 +01:00
Nawaz Dhandala
4bd7902afe fix: Refactor ResourceGenerator to improve type annotations and code clarity 2025-08-07 22:05:47 +01:00
Simon Larsen
5c300ed513 feat: Enhance update method to conditionally include fields based on change detection 2025-08-07 22:03:21 +01:00
Simon Larsen
c4a50e853c feat: Add logging for unauthorized update attempts in ColumnPermissions 2025-08-07 21:29:13 +01:00
Simon Larsen
20c1f13876 fix: Exclude computed fields from default empty list assignment in ResourceGenerator 2025-08-07 21:22:48 +01:00
Simon Larsen
09426ed6be Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-07 21:11:42 +01:00
Nawaz Dhandala
675a031ee6 fix: Correct return type of getYAxisDomain function to match expected output 2025-08-07 21:02:59 +01:00
Simon Larsen
92986ac1f8 feat: Add isDefaultValueColumn flag to Downtime Monitor Statuses field in StatusPage model 2025-08-07 17:38:07 +01:00
Simon Larsen
2b95d608dc fix: Remove redundant build command for darwin arm architecture 2025-08-07 16:55:43 +01:00
Nawaz Dhandala
2696071933 fix: Correct formatting in ProjectSSO and UserNotificationSetting models; update conditional logic in DatabaseService 2025-08-07 16:46:14 +01:00
Nawaz Dhandala
684a61b599 feat: Add default values for boolean fields in various database models 2025-08-07 16:45:33 +01:00
Nawaz Dhandala
633a89161e Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-08-07 15:03:02 +01:00
Nawaz Dhandala
fd24781783 refactor: Optimize onBarClick and shape rendering logic in BarChart component 2025-08-07 15:03:00 +01:00
Simon Larsen
903b13d515 feat: Handle null values for required fields in data processing 2025-08-07 14:48:01 +01:00
Simon Larsen
58a128a05e Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-07 14:45:51 +01:00
Simon Larsen
ec1d567813 feat: Set default values for status page configuration options 2025-08-07 14:45:48 +01:00
Nawaz Dhandala
0a53161eac refactor: Improve type annotations and code consistency across various components 2025-08-07 14:31:13 +01:00
Nawaz Dhandala
83b91af708 Refactor code for consistency and readability across various components
- Updated import statements for better formatting in multiple files.
- Added semicolons for consistency in BarChart and SparkChart components.
- Improved code readability by using braces for single-line if statements in various functions.
- Enhanced the structure of return statements for clarity in SubscriberNotificationStatus and other components.
- Refactored map functions to use explicit return statements for better readability.
- Cleaned up whitespace and formatting in multiple files for a more uniform code style.
2025-08-07 13:55:08 +01:00
Simon Larsen
3fefee8725 feat: Enhance notification status handling and improve UI components 2025-08-07 13:49:53 +01:00
Simon Larsen
9b2cc7d377 feat: Update create permissions for Incident and Scheduled Maintenance models 2025-08-07 12:52:00 +01:00
Simon Larsen
1fb71ed2e3 feat: Implement subscriber notification status handling across multiple services 2025-08-07 12:46:40 +01:00
Simon Larsen
94a5abdb31 feat: Add SparkChart component with Area, Line, and Bar chart implementations 2025-08-07 11:52:44 +01:00
Simon Larsen
73f4559943 feat: Refactor BarChart component for improved type safety and event handling 2025-08-07 11:47:59 +01:00
Simon Larsen
9c3c6ee4e9 feat: Add BarChart component for enhanced data visualization 2025-08-07 11:29:19 +01:00
Simon Larsen
56743214a0 feat: Update button style in SubscriberNotificationStatus for improved UI consistency 2025-08-07 11:29:14 +01:00
Simon Larsen
9136c6d40e feat: Update color imports in SubscriberNotificationStatus for consistent branding 2025-08-07 11:23:46 +01:00
Simon Larsen
920a9baee9 feat: Update ConfirmModal behavior in SubscriberNotificationStatus for improved user interaction 2025-08-07 11:20:23 +01:00
Simon Larsen
1c4aad2d81 feat: Add SubscriberNotificationStatus component to IncidentViewStateTimeline and ScheduledMaintenanceDelete for enhanced notification display 2025-08-07 11:17:34 +01:00
Simon Larsen
8a4644922a feat: Add textColor prop to IconText component and update SubscriberNotificationStatus to utilize it 2025-08-07 11:04:20 +01:00
Simon Larsen
c3f4b7d3d4 feat: Enhance SubscriberNotificationStatus component with IconText and ConfirmModal for improved status display and user interaction 2025-08-07 10:54:10 +01:00
Simon Larsen
6f7c0814ee feat: Implement IconText component and refactor CheckboxViewer to use it 2025-08-07 10:49:21 +01:00
Nawaz Dhandala
77cd3fc4c0 refactor: Enhance type definitions for handleResendNotification and getNotificationStatusInfo across components 2025-08-06 16:02:57 +01:00
Nawaz Dhandala
e7cbc3d739 Refactor notification status assignments and improve code readability in incident and scheduled maintenance notification jobs
- Updated subscriber notification status assignments for better readability by breaking long lines.
- Added handling for non-visible scheduled maintenance events to set status to Skipped.
- Improved error handling and logging for scheduled maintenance public notes and state timelines.
- Ensured consistent formatting and structure across notification jobs for clarity and maintainability.
2025-08-06 15:41:28 +01:00
Simon Larsen
6d14ea19b9 feat: Add subscriberNotificationStatusMessage to selectMoreFields in multiple components 2025-08-06 14:47:13 +01:00
Simon Larsen
1290d3b946 fix: Remove unnecessary className from subscriberNotificationStatusMessage in PublicNote component 2025-08-06 14:29:51 +01:00
Simon Larsen
c0c58546d0 feat: Integrate Tooltip for subscriberNotificationStatusMessage in SubscriberNotificationStatus component 2025-08-06 14:17:37 +01:00
Simon Larsen
6c5ef10606 feat: Update migration files to rename subscriberNotificationFailedReason to subscriberNotificationStatusMessage and adjust imports 2025-08-06 14:05:26 +01:00
Simon Larsen
ec4c6ff7c5 feat: Rename subscriberNotificationFailedReason to subscriberNotificationStatusMessage across models and update related components 2025-08-06 14:04:06 +01:00
Simon Larsen
616e6e43ab feat: Update access control permissions for Incident, Scheduled Maintenance, and related models 2025-08-06 13:03:55 +01:00
Simon Larsen
aa08cd904b feat: Update SCIMPage to use Route for documentation link instead of URL 2025-08-06 12:34:21 +01:00
Simon Larsen
fef1c1055c feat: Integrate SubscriberNotificationStatus component in Incident and Scheduled Maintenance views, replacing checkbox logic 2025-08-06 12:31:14 +01:00
Simon Larsen
22e33809f9 feat: Remove style prop from SubscriberNotificationStatus component usage across various views 2025-08-06 11:39:59 +01:00
Simon Larsen
eb8324a3c2 feat: Replace NotificationStatusPill with SubscriberNotificationStatus component across various views 2025-08-06 11:25:35 +01:00
Simon Larsen
fa6dedc9a1 feat: Add resend notification functionality to NotificationStatusPill and related components 2025-08-06 11:24:20 +01:00
Simon Larsen
099cd807bf feat: Remove unused notification status fields and components from Announcements, Incidents, and Scheduled Maintenance tables 2025-08-06 10:58:15 +01:00
Simon Larsen
5d0b010fc4 feat: Remove unused StatusPageSCIM from AllModelTypes 2025-08-05 21:56:55 +01:00
Simon Larsen
1fc421f92a feat: Refactor notification status handling with NotificationStatusPill component 2025-08-05 21:31:08 +01:00
Simon Larsen
14a14e2341 feat: Add .claude/settings.local.json to .gitignore 2025-08-05 21:09:10 +01:00
Simon Larsen
ab23cca264 feat: Remove unused ActionButtonSchema import from multiple components 2025-08-05 20:42:06 +01:00
Simon Larsen
678a961fb9 feat: Add migration for updating subscriber notification status to the migration index 2025-08-05 18:35:49 +01:00
Simon Larsen
c2e458f035 feat: Rename notification failure reason columns for consistency and update types to text 2025-08-05 18:35:14 +01:00
Simon Larsen
4daf17dc8c feat: Add SCIM documentation for automated user provisioning and deprovisioning 2025-08-05 18:04:59 +01:00
Simon Larsen
842aa4b88d feat: Rename notification failure reason fields to subscriberNotificationFailedReason for consistency across incident and scheduled maintenance jobs 2025-08-05 18:03:33 +01:00
Simon Larsen
fd51142693 feat: Update notification failure reason fields to subscriberNotificationFailedReason and change type to VeryLongText 2025-08-05 17:57:17 +01:00
Simon Larsen
e0ddf80aa6 feat: Add migration for updating subscriber notification status and handling failure reasons in Incident and ScheduledMaintenance tables 2025-08-05 17:53:06 +01:00
Simon Larsen
e8d55164c6 feat: Update notification handling for subscribers across various jobs
- Introduced a new enum `StatusPageSubscriberNotificationStatus` to manage notification statuses (Skipped, Pending, InProgress, Success, Failed).
- Updated `SendNotificationToSubscribers` jobs for Announcements, Incidents, Scheduled Maintenance, and Public Notes to utilize the new notification status system.
- Added logic to mark notifications as Skipped if they should not be sent, and to update the status to InProgress when notifications are being processed.
- Implemented success and failure handling for notifications, updating the respective status and logging errors as needed.
- Modified database schema to replace old boolean notification flags with the new enum-based status fields, ensuring backward compatibility with existing records.
- Added migration script to handle the transition of existing records to the new notification status system.
2025-08-05 17:49:28 +01:00
Nawaz Dhandala
5cd8795e7a refactor: Clean up code formatting and improve readability in SCIM and StatusPageSCIM files 2025-08-05 12:49:04 +01:00
Simon Larsen
aebf7a4f2e feat: Remove Groups Endpoint display and enhance user identifier information in SCIM settings 2025-08-05 12:48:10 +01:00
Simon Larsen
3ef093eee1 feat: Add password generation for private users and update SideMenu icon 2025-08-05 12:46:09 +01:00
Simon Larsen
b4c530a6a5 feat: Update SCIM links and enhance HiddenText component for copy functionality 2025-08-05 12:37:34 +01:00
Simon Larsen
166228cad5 feat: Enhance SCIM configuration handling and update UI elements 2025-08-05 12:28:48 +01:00
Simon Larsen
1eb95c71fe feat: Add StatusPageSCIM API integration to BaseAPIFeatureSet 2025-08-05 12:16:24 +01:00
Simon Larsen
56f33f256b feat: Increase concurrency limit for ingest job processing to improve throughput 2025-08-05 11:13:49 +01:00
Simon Larsen
42afd164b7 feat: Increase concurrency limit for telemetry job processing to improve performance 2025-08-05 11:11:12 +01:00
Nawaz Dhandala
0796166a55 fix: Correct syntax errors in navigation group array in Nav.ts 2025-08-05 11:07:45 +01:00
Nawaz Dhandala
170bfa8515 refactor: Improve code formatting and consistency across SCIM-related files 2025-08-05 11:03:00 +01:00
Simon Larsen
2f517d8dcc feat: Update SCIM documentation URLs to use environment configuration 2025-08-05 11:00:18 +01:00
Simon Larsen
cb5c4dce45 feat: Add SCIM API documentation for user provisioning and deprovisioning 2025-08-05 10:52:57 +01:00
Simon Larsen
d9abeda60d feat: Refactor SCIM utility functions for improved modularity and logging 2025-08-05 10:33:38 +01:00
Simon Larsen
15c4c89310 feat: Add StatusPageSCIM model and related database migration
- Implemented StatusPageSCIM model with necessary fields and access controls.
- Created migration script to set up StatusPageSCIM table in the database.
- Developed StatusPageSCIMService for handling SCIM configurations, including bearer token generation.
- Added SCIM management page in the dashboard with functionalities for creating, editing, and resetting bearer tokens.
2025-08-05 10:07:24 +01:00
Simon Larsen
8c1d5652f4 feat: Change button style type for resetting bearer token to outline 2025-08-04 22:14:31 +01:00
Nawaz Dhandala
fbf87cf8d4 refactor: Add type annotations to formatUserForSCIM and resetBearerToken functions for improved type safety 2025-08-04 22:10:06 +01:00
Nawaz Dhandala
1c12ad94dd fix: Add type annotations for improved type safety in SCIM and Metrics modules 2025-08-04 22:07:10 +01:00
Nawaz Dhandala
aa09bab7c9 Refactor SCIM migrations and models; update formatting and improve readability
- Added missing comma in AllModelTypes array in Index.ts.
- Refactored MigrationName1754304193228 to improve query formatting and readability.
- Refactored MigrationName1754315774827 for consistency in formatting.
- Updated migration index file to include new migration.
- Standardized string quotes in Queue.ts for consistency.
- Cleaned up SCIMAuthorization.ts by removing unnecessary whitespace and improving log formatting.
- Refactored StartServer.ts to standardize content-type header handling.
- Improved formatting in SCIM.tsx for better readability and consistency.
- Refactored Metrics.ts to standardize queueSize extraction and type checking.
- Enhanced Probe.ts logging for clarity and consistency.
2025-08-04 21:36:11 +01:00
Simon Larsen
f7d1975ab0 feat: Add debug logging for parsing names from SCIM users 2025-08-04 21:35:30 +01:00
Simon Larsen
99c9a591cb feat: Refactor SCIM user handling to improve name parsing and team operations 2025-08-04 21:29:16 +01:00
Simon Larsen
c956d01789 feat: Enhance user name handling in SCIM responses by parsing full names into given and family names 2025-08-04 21:22:01 +01:00
Simon Larsen
17c829869b feat: Implement user activation handling by adding users to configured teams 2025-08-04 21:18:03 +01:00
Simon Larsen
d65e91a912 feat: Enhance SCIM user update logging and handle user deactivation by removing from teams 2025-08-04 21:17:28 +01:00
Simon Larsen
39710ba9b0 feat: Enhance SCIM user update and delete logging, and improve team removal logic 2025-08-04 21:12:49 +01:00
Simon Larsen
8c70a4dfae feat: Update SCIM user handling to improve pagination and remove duplicates 2025-08-04 18:00:20 +01:00
Simon Larsen
ff99055594 feat: Refactor SCIM endpoints to enhance logging and improve user query handling 2025-08-04 17:44:23 +01:00
Simon Larsen
f01cc2fd71 feat: Enhance logging for SCIM requests and responses across various endpoints 2025-08-04 17:34:28 +01:00
Simon Larsen
49b43593b1 feat: Add middleware to handle SCIM content type before JSON parsing 2025-08-04 17:25:01 +01:00
Simon Larsen
e293ffd0eb feat: Remove isEnabled column from ProjectSCIM and update related services and migrations 2025-08-04 14:58:25 +01:00
Simon Larsen
b62a5e7722 feat: Add functionality to reset Bearer Token with confirmation modals 2025-08-04 14:47:45 +01:00
Simon Larsen
8f8ba0abb8 feat: Enhance SCIM middleware logging and update SCIM page state management 2025-08-04 13:01:09 +01:00
Simon Larsen
5525556b54 feat: Rename ProjectScima to ProjectSCIM and update imports 2025-08-04 12:28:09 +01:00
Simon Larsen
669066b70a feat: Implement ProjectSCIM model and SCIM page functionality 2025-08-04 12:27:48 +01:00
Simon Larsen
76d2abed08 fix: Update SCIM endpoint URLs to include versioning 2025-08-04 12:01:50 +01:00
Simon Larsen
a6c18b3f21 fix: Remove HTTP_PROTOCOL from SCIM endpoint URLs in SCIMPage component 2025-08-04 12:01:24 +01:00
Simon Larsen
955ea7bc31 feat: Restore ProjectSCIM service with bearer token generation logic 2025-08-04 11:58:18 +01:00
Simon Larsen
45719d4656 feat: Reintroduce ProjectSCIM service with bearer token generation logic 2025-08-04 11:58:07 +01:00
Simon Larsen
796c94a261 fix: Correct import casing for ProjectSCIM across multiple files 2025-08-04 11:46:56 +01:00
Simon Larsen
d2fe822cb7 feat: Integrate ProjectSCIM model and service into the Base API feature set 2025-08-04 11:44:02 +01:00
Simon Larsen
289a369eab feat: Add migration for ProjectSCIM and ProjectScimTeam tables with foreign key constraints 2025-08-04 11:43:44 +01:00
Simon Larsen
6f07e3e119 feat: Update SCIM API endpoints to include versioning in the URL 2025-08-04 11:19:17 +01:00
Simon Larsen
8cdc1e9faf feat: Add SCIM API endpoints and middleware for user management and configuration 2025-08-04 11:09:52 +01:00
Simon Larsen
d4609a84ef feat: Implement Project SCIM service with bearer token generation 2025-08-04 10:15:34 +01:00
Simon Larsen
eb4a91a598 feat: Add SCIM settings page and routing to the dashboard 2025-08-04 10:14:47 +01:00
Simon Larsen
5bea404d6c feat: Add SCIM API integration to Identity feature set 2025-08-04 10:13:44 +01:00
Simon Larsen
df3f8b6a74 feat: Add optional stackTrace field to job data structures for enhanced error tracking 2025-08-03 12:59:51 +01:00
Simon Larsen
0c9d2c821a feat: Add advanced horizontal pod autoscaler configuration for improved scaling behavior 2025-08-02 13:05:05 +01:00
Simon Larsen
ba49aaf0c3 fix: Skip probe offline email notifications when billing is enabled 2025-08-02 12:36:50 +01:00
Simon Larsen
6ea5ad7fe8 fix: Update nextPingAt calculation to use a 2-minute offset for improved timing accuracy 2025-08-02 11:42:01 +01:00
Simon Larsen
962866d109 fix: Improve queue size extraction and handling in metrics endpoint 2025-08-01 20:58:58 +01:00
Simon Larsen
115216561c feat: Add ports configuration for OneUptime probe service 2025-08-01 20:36:30 +01:00
Simon Larsen
f709c90cc4 fix: Update probe port handling in KEDA ScaledObjects for improved configuration 2025-08-01 20:21:41 +01:00
Simon Larsen
d7f01b0189 fix: Update default port value in probe template for better configuration handling 2025-08-01 20:19:31 +01:00
Simon Larsen
c3eaa8995c fix ports 2025-08-01 20:19:10 +01:00
Simon Larsen
53b482b9f3 refactor: Update Helm templates to use new port structure in values.yaml 2025-08-01 20:13:30 +01:00
Simon Larsen
d52670f39c refactor: Update Helm templates to use new port structure in values.yaml 2025-08-01 18:22:05 +01:00
Simon Larsen
fdc1332b9e Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-01 16:17:09 +01:00
Simon Larsen
a937416663 fix: Update autoscaler condition to prevent conflicts with KEDA configuration 2025-08-01 16:17:05 +01:00
Nawaz Dhandala
546d41da81 fix: Clean up formatting and ensure consistent return structure in metrics endpoints 2025-08-01 16:13:05 +01:00
Simon Larsen
c4c6793b29 feat: Implement KEDA autoscaling configuration for probes and add metrics endpoints 2025-08-01 15:38:04 +01:00
Simon Larsen
c894b112e6 fix: Await monitorResource call to ensure proper error handling in incoming request processing 2025-08-01 14:34:17 +01:00
Simon Larsen
304baf1bb4 fix: Await monitorResource call to ensure proper error handling in probe response processing 2025-08-01 14:33:17 +01:00
Simon Larsen
9adea6b1ba feat: Remove Helm annotations for post-install and post-upgrade hooks from templates 2025-08-01 14:01:04 +01:00
Simon Larsen
5498521e02 feat: Add Helm annotations for post-install and post-upgrade hooks 2025-08-01 13:47:52 +01:00
Simon Larsen
9e97c6ddbc feat: Update autoscaler conditions for fluent-ingest, incoming-request-ingest, probe-ingest, and server-monitor-ingest templates 2025-08-01 13:23:39 +01:00
Nawaz Dhandala
63272e09f8 refactor: Simplify function parameter formatting and improve readability in various files 2025-08-01 10:45:55 +01:00
138 changed files with 9281 additions and 1547 deletions

1
.gitignore vendored
View File

@@ -127,3 +127,4 @@ MCP/build/
MCP/.env
MCP/node_modules
Dashboard/public/sw.js
.claude/settings.local.json

View File

@@ -583,6 +583,18 @@ import StatusPageAnnouncementTemplateService, {
Service as StatusPageAnnouncementTemplateServiceType,
} from "Common/Server/Services/StatusPageAnnouncementTemplateService";
// ProjectSCIM
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
import ProjectSCIMService, {
Service as ProjectSCIMServiceType,
} from "Common/Server/Services/ProjectSCIMService";
// StatusPageSCIM
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
import StatusPageSCIMService, {
Service as StatusPageSCIMServiceType,
} from "Common/Server/Services/StatusPageSCIMService";
// Open API Spec
import OpenAPI from "Common/Server/API/OpenAPI";
@@ -618,6 +630,24 @@ const BaseAPIFeatureSet: FeatureSet = {
).getRouter(),
);
// Project SCIM
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<ProjectSCIM, ProjectSCIMServiceType>(
ProjectSCIM,
ProjectSCIMService,
).getRouter(),
);
// Status Page SCIM
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<StatusPageSCIM, StatusPageSCIMServiceType>(
StatusPageSCIM,
StatusPageSCIMService,
).getRouter(),
);
// status page announcement templates
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,

View File

@@ -0,0 +1,722 @@
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
import UserService from "Common/Server/Services/UserService";
import TeamMemberService from "Common/Server/Services/TeamMemberService";
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
OneUptimeRequest,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
import logger from "Common/Server/Utils/Logger";
import ObjectID from "Common/Types/ObjectID";
import Email from "Common/Types/Email";
import Name from "Common/Types/Name";
import { JSONObject } from "Common/Types/JSON";
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
import BadRequestException from "Common/Types/Exception/BadRequestException";
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 {
parseNameFromSCIM,
formatUserForSCIM,
generateServiceProviderConfig,
generateUsersListResponse,
parseSCIMQueryParams,
logSCIMOperation,
} from "../Utils/SCIMUtils";
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
const router: ExpressRouter = Express.getRouter();
const handleUserTeamOperations: (
operation: "add" | "remove",
projectId: ObjectID,
userId: ObjectID,
scimConfig: ProjectSCIM,
) => Promise<void> = async (
operation: "add" | "remove",
projectId: ObjectID,
userId: ObjectID,
scimConfig: ProjectSCIM,
): Promise<void> => {
const teamsIds: Array<ObjectID> =
scimConfig.teams?.map((team: any) => {
return team.id;
}) || [];
if (teamsIds.length === 0) {
logger.debug(`SCIM Team operations - no teams configured for SCIM`);
return;
}
if (operation === "add") {
logger.debug(
`SCIM Team operations - adding user to ${teamsIds.length} configured teams`,
);
for (const team of scimConfig.teams || []) {
const existingMember: TeamMember | null =
await TeamMemberService.findOneBy({
query: {
projectId: projectId,
userId: userId,
teamId: team.id!,
},
select: { _id: true },
props: { isRoot: true },
});
if (!existingMember) {
logger.debug(`SCIM Team operations - adding user to team: ${team.id}`);
const teamMember: TeamMember = new TeamMember();
teamMember.projectId = projectId;
teamMember.userId = userId;
teamMember.teamId = team.id!;
teamMember.hasAcceptedInvitation = true;
teamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate();
await TeamMemberService.create({
data: teamMember,
props: {
isRoot: true,
ignoreHooks: true,
},
});
} else {
logger.debug(
`SCIM Team operations - user already member of team: ${team.id}`,
);
}
}
} else if (operation === "remove") {
logger.debug(
`SCIM Team operations - removing user from ${teamsIds.length} configured teams`,
);
await TeamMemberService.deleteBy({
query: {
projectId: projectId,
userId: userId,
teamId: QueryHelper.any(teamsIds),
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: { isRoot: true },
});
}
};
// SCIM Service Provider Configuration - GET /scim/v2/ServiceProviderConfig
router.get(
"/scim/v2/:projectScimId/ServiceProviderConfig",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logSCIMOperation(
"ServiceProviderConfig",
"project",
req.params["projectScimId"]!,
);
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
req,
req.params["projectScimId"]!,
"project",
DocsClientUrl.toString() + "/identity/scim",
);
logger.debug(
"Project SCIM ServiceProviderConfig response prepared successfully",
);
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Basic Users endpoint - GET /scim/v2/Users
router.get(
"/scim/v2/:projectScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
// Parse query parameters
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"}`,
);
// Build query for team members in this project
const query: Query<ProjectUser> = {
projectId: projectId,
};
// Handle SCIM filter for userName
if (filter) {
const emailMatch: RegExpMatchArray | null = filter.match(
/userName eq "([^"]+)"/i,
);
if (emailMatch) {
const email: string = emailMatch[1]!;
logSCIMOperation(
"Users list",
"project",
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}`,
);
} else {
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`user not found for email: ${email}`,
);
return Response.sendJsonObjectResponse(
req,
res,
generateUsersListResponse([], startIndex, 0),
);
}
}
}
}
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`query built for projectId: ${projectId}`,
);
// Get team members
const teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
query: query,
limit: LIMIT_MAX,
skip: 0,
props: { isRoot: true },
select: {
userId: true,
user: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
},
});
// now get unique users.
const usersInProjects: Array<JSONObject> = teamMembers
.filter((tm: TeamMember) => {
return tm.user && tm.user.id;
})
.map((tm: TeamMember) => {
return formatUserForSCIM(
tm.user!,
req,
req.params["projectScimId"]!,
"project",
);
});
// remove duplicates
const uniqueUserIds: Set<string> = new Set<string>();
const users: Array<JSONObject> = usersInProjects.filter(
(user: JSONObject) => {
if (uniqueUserIds.has(user["id"]?.toString() || "")) {
return false;
}
uniqueUserIds.add(user["id"]?.toString() || "");
return true;
},
);
// now paginate the results
const paginatedUsers: Array<JSONObject> = users.slice(
(startIndex - 1) * count,
startIndex * count,
);
logger.debug(`SCIM Users response prepared with ${users.length} users`);
return Response.sendJsonObjectResponse(
req,
res,
generateUsersListResponse(paginatedUsers, startIndex, users.length),
);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Get Individual User - GET /scim/v2/Users/{id}
router.get(
"/scim/v2/:projectScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`SCIM Get individual user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const userId: string = req.params["userId"]!;
logger.debug(
`SCIM Get user - projectId: ${projectId}, userId: ${userId}`,
);
if (!userId) {
throw new BadRequestException("User ID is required");
}
// Check if user exists and is part of the project
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
query: {
projectId: projectId,
userId: new ObjectID(userId),
},
select: {
userId: true,
user: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
},
props: { isRoot: true },
});
if (!projectUser || !projectUser.user) {
logger.debug(
`SCIM Get user - user not found or not part of project for userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this project",
);
}
logger.debug(`SCIM Get user - found user: ${projectUser.user.id}`);
const user: JSONObject = formatUserForSCIM(
projectUser.user,
req,
req.params["projectScimId"]!,
"project",
);
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Update User - PUT /scim/v2/Users/{id}
router.put(
"/scim/v2/:projectScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`SCIM Update user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const userId: string = req.params["userId"]!;
const scimUser: JSONObject = req.body;
logger.debug(
`SCIM Update user - projectId: ${projectId}, userId: ${userId}`,
);
logger.debug(
`Request body for SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
);
if (!userId) {
throw new BadRequestException("User ID is required");
}
// Check if user exists and is part of the project
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
query: {
projectId: projectId,
userId: new ObjectID(userId),
},
select: {
userId: true,
user: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
},
props: { isRoot: true },
});
if (!projectUser || !projectUser.user) {
logger.debug(
`SCIM Update user - user not found or not part of project for userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this project",
);
}
// Update user information
const email: string =
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
const name: string = parseNameFromSCIM(scimUser);
const active: boolean = scimUser["active"] as boolean;
logger.debug(
`SCIM Update user - email: ${email}, name: ${name}, active: ${active}`,
);
// Handle user deactivation by removing from teams
if (active === false) {
logger.debug(
`SCIM Update user - user marked as inactive, removing from teams`,
);
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
await handleUserTeamOperations(
"remove",
projectId,
new ObjectID(userId),
scimConfig,
);
logger.debug(
`SCIM Update user - user successfully removed from teams due to deactivation`,
);
}
// Handle user activation by adding to teams
if (active === true) {
logger.debug(
`SCIM Update user - user marked as active, adding to teams`,
);
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
await handleUserTeamOperations(
"add",
projectId,
new ObjectID(userId),
scimConfig,
);
logger.debug(
`SCIM Update user - user successfully added to teams due to activation`,
);
}
if (email || name) {
const updateData: any = {};
if (email) {
updateData.email = new Email(email);
}
if (name) {
updateData.name = new Name(name);
}
logger.debug(
`SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
);
await UserService.updateOneById({
id: new ObjectID(userId),
data: updateData,
props: { isRoot: true },
});
logger.debug(`SCIM Update user - user updated successfully`);
// Fetch updated user
const updatedUser: User | null = await UserService.findOneById({
id: new ObjectID(userId),
select: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (updatedUser) {
const user: JSONObject = formatUserForSCIM(
updatedUser,
req,
req.params["projectScimId"]!,
"project",
);
return Response.sendJsonObjectResponse(req, res, user);
}
}
logger.debug(
`SCIM Update user - no updates made, returning existing user`,
);
// If no updates were made, return the existing user
const user: JSONObject = formatUserForSCIM(
projectUser.user,
req,
req.params["projectScimId"]!,
"project",
);
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Groups endpoint - GET /scim/v2/Groups
router.get(
"/scim/v2/:projectScimId/Groups",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`SCIM Groups list request for projectScimId: ${req.params["projectScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
logger.debug(
`SCIM Groups - found ${scimConfig.teams?.length || 0} configured teams`,
);
// Return configured teams as groups
const groups: JSONObject[] = (scimConfig.teams || []).map((team: any) => {
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
id: team.id?.toString(),
displayName: team.name?.toString(),
members: [],
meta: {
resourceType: "Group",
location: `${req.protocol}://${req.get("host")}/scim/v2/${req.params["projectScimId"]}/Groups/${team.id?.toString()}`,
},
};
});
return Response.sendJsonObjectResponse(req, res, {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: groups.length,
startIndex: 1,
itemsPerPage: groups.length,
Resources: groups,
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Create User - POST /scim/v2/Users
router.post(
"/scim/v2/:projectScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`SCIM Create user request for projectScimId: ${req.params["projectScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
if (!scimConfig.autoProvisionUsers) {
throw new BadRequestException(
"Auto-provisioning is disabled for this project",
);
}
const scimUser: JSONObject = req.body;
const email: string =
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
const name: string = parseNameFromSCIM(scimUser);
logger.debug(`SCIM Create user - email: ${email}, name: ${name}`);
if (!email) {
throw new BadRequestException("userName or email is required");
}
// Check if user already exists
let user: User | null = await UserService.findOneBy({
query: { email: new Email(email) },
select: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
// Create user if doesn't exist
if (!user) {
logger.debug(
`SCIM Create user - creating new user for email: ${email}`,
);
user = await UserService.createByEmail({
email: new Email(email),
name: name ? new Name(name) : new Name("Unknown"),
isEmailVerified: true,
generateRandomPassword: true,
props: { isRoot: true },
});
} else {
logger.debug(
`SCIM Create user - user already exists with id: ${user.id}`,
);
}
// Add user to default teams if configured
if (scimConfig.teams && scimConfig.teams.length > 0) {
logger.debug(
`SCIM Create user - adding user to ${scimConfig.teams.length} configured teams`,
);
await handleUserTeamOperations("add", projectId, user.id!, scimConfig);
}
const createdUser: JSONObject = formatUserForSCIM(
user,
req,
req.params["projectScimId"]!,
"project",
);
logger.debug(
`SCIM Create user - returning created user with id: ${user.id}`,
);
res.status(201);
return Response.sendJsonObjectResponse(req, res, createdUser);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Delete User - DELETE /scim/v2/Users/{id}
router.delete(
"/scim/v2/:projectScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`SCIM Delete user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
const userId: string = req.params["userId"]!;
if (!scimConfig.autoDeprovisionUsers) {
logger.debug("SCIM Delete user - auto-deprovisioning is disabled");
throw new BadRequestException(
"Auto-deprovisioning is disabled for this project",
);
}
if (!userId) {
throw new BadRequestException("User ID is required");
}
logger.debug(
`SCIM Delete user - removing user from all teams in project: ${projectId}`,
);
// Remove user from teams the SCIM configured
if (!scimConfig.teams || scimConfig.teams.length === 0) {
logger.debug("SCIM Delete user - no teams configured for SCIM");
throw new BadRequestException("No teams configured for SCIM");
}
await handleUserTeamOperations(
"remove",
projectId,
new ObjectID(userId),
scimConfig,
);
logger.debug(
`SCIM Delete user - user successfully deprovisioned from project`,
);
res.status(204);
return Response.sendJsonObjectResponse(req, res, {
message: "User deprovisioned",
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
export default router;

View File

@@ -0,0 +1,536 @@
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
OneUptimeRequest,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
import logger from "Common/Server/Utils/Logger";
import ObjectID from "Common/Types/ObjectID";
import Email from "Common/Types/Email";
import { JSONObject } from "Common/Types/JSON";
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
import BadRequestException from "Common/Types/Exception/BadRequestException";
import NotFoundException from "Common/Types/Exception/NotFoundException";
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";
const router: ExpressRouter = Express.getRouter();
// SCIM Service Provider Configuration - GET /status-page-scim/v2/ServiceProviderConfig
router.get(
"/status-page-scim/v2/:statusPageScimId/ServiceProviderConfig",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logSCIMOperation(
"ServiceProviderConfig",
"status-page",
req.params["statusPageScimId"]!,
);
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
req,
req.params["statusPageScimId"]!,
"status-page",
);
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Status Page Users endpoint - GET /status-page-scim/v2/Users
router.get(
"/status-page-scim/v2/:statusPageScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Users list request for statusPageScimId: ${req.params["statusPageScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
// Parse query parameters
const startIndex: number =
parseInt(req.query["startIndex"] as string) || 1;
const count: number = Math.min(
parseInt(req.query["count"] as string) || 100,
LIMIT_PER_PROJECT,
);
logger.debug(
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
);
// Get all private users for this status page
const statusPageUsers: Array<StatusPagePrivateUser> =
await StatusPagePrivateUserService.findBy({
query: {
statusPageId: statusPageId,
},
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
skip: 0,
limit: LIMIT_MAX,
props: { isRoot: true },
});
logger.debug(
`Status Page SCIM Users - found ${statusPageUsers.length} users`,
);
// Format users for SCIM
const users: Array<JSONObject> = statusPageUsers.map(
(user: StatusPagePrivateUser) => {
return formatUserForSCIM(
user,
req,
req.params["statusPageScimId"]!,
"status-page",
);
},
);
// Paginate the results
const paginatedUsers: Array<JSONObject> = users.slice(
(startIndex - 1) * count,
startIndex * count,
);
logger.debug(
`Status Page SCIM Users response prepared with ${users.length} users`,
);
return Response.sendJsonObjectResponse(req, res, {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: users.length,
startIndex: startIndex,
itemsPerPage: paginatedUsers.length,
Resources: paginatedUsers,
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Get Individual Status Page User - GET /status-page-scim/v2/Users/{id}
router.get(
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Get individual user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const userId: string = req.params["userId"]!;
logger.debug(
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userId: ${userId}`,
);
if (!userId) {
throw new BadRequestException("User ID is required");
}
// Check if user exists and belongs to this status page
const statusPageUser: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
_id: new ObjectID(userId),
},
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (!statusPageUser) {
logger.debug(
`Status Page SCIM Get user - user not found for userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this status page",
);
}
const user: JSONObject = formatUserForSCIM(
statusPageUser,
req,
req.params["statusPageScimId"]!,
"status-page",
);
logger.debug(
`Status Page SCIM Get user - returning user with id: ${statusPageUser.id}`,
);
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Create Status Page User - POST /status-page-scim/v2/Users
router.post(
"/status-page-scim/v2/:statusPageScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Create user request for statusPageScimId: ${req.params["statusPageScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData[
"scimConfig"
] as StatusPageSCIM;
if (!scimConfig.autoProvisionUsers) {
throw new BadRequestException(
"Auto-provisioning is disabled for this status page",
);
}
const scimUser: JSONObject = req.body;
logger.debug(
`Status Page SCIM Create user - statusPageId: ${statusPageId}`,
);
logger.debug(
`Request body for Status Page SCIM Create user: ${JSON.stringify(scimUser, null, 2)}`,
);
// Extract user data from SCIM payload
const email: string =
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
if (!email) {
throw new BadRequestException("Email is required for user creation");
}
logger.debug(`Status Page SCIM Create user - email: ${email}`);
// Check if user already exists for this status page
let user: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
email: new Email(email),
},
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (!user) {
logger.debug(
`Status Page SCIM Create user - creating new user with email: ${email}`,
);
const privateUser: StatusPagePrivateUser = new StatusPagePrivateUser();
privateUser.statusPageId = statusPageId;
privateUser.email = new Email(email);
privateUser.password = new HashedString(Text.generateRandomText(32));
privateUser.projectId = bearerData["projectId"] as ObjectID;
// Create new status page private user
user = await StatusPagePrivateUserService.create({
data: privateUser as any,
props: { isRoot: true },
});
} else {
logger.debug(
`Status Page SCIM Create user - user already exists with id: ${user.id}`,
);
}
const createdUser: JSONObject = formatUserForSCIM(
user,
req,
req.params["statusPageScimId"]!,
"status-page",
);
logger.debug(
`Status Page SCIM Create user - returning created user with id: ${user.id}`,
);
res.status(201);
return Response.sendJsonObjectResponse(req, res, createdUser);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Update Status Page User - PUT /status-page-scim/v2/Users/{id}
router.put(
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Update user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const userId: string = req.params["userId"]!;
const scimUser: JSONObject = req.body;
logger.debug(
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userId: ${userId}`,
);
logger.debug(
`Request body for Status Page SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
);
if (!userId) {
throw new BadRequestException("User ID is required");
}
// Check if user exists and belongs to this status page
const statusPageUser: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
_id: new ObjectID(userId),
},
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (!statusPageUser) {
logger.debug(
`Status Page SCIM Update user - user not found for userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this status page",
);
}
// Update user information
const email: string =
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
const active: boolean = scimUser["active"] as boolean;
logger.debug(
`Status Page SCIM Update user - email: ${email}, active: ${active}`,
);
// Handle user deactivation by deleting from status page
if (active === false) {
logger.debug(
`Status Page SCIM Update user - user marked as inactive, removing from status page`,
);
const scimConfig: StatusPageSCIM = bearerData[
"scimConfig"
] as StatusPageSCIM;
if (scimConfig.autoDeprovisionUsers) {
await StatusPagePrivateUserService.deleteOneById({
id: new ObjectID(userId),
props: { isRoot: true },
});
logger.debug(
`Status Page SCIM Update user - user removed from status page`,
);
// Return empty response for deleted user
return Response.sendJsonObjectResponse(req, res, {});
}
}
// Prepare update data
const updateData: {
email?: Email;
} = {};
if (email && email !== statusPageUser.email?.toString()) {
updateData.email = new Email(email);
}
// Only update if there are changes
if (Object.keys(updateData).length > 0) {
logger.debug(
`Status Page SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
);
await StatusPagePrivateUserService.updateOneById({
id: new ObjectID(userId),
data: updateData,
props: { isRoot: true },
});
logger.debug(
`Status Page SCIM Update user - user updated successfully`,
);
// Fetch updated user
const updatedUser: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneById({
id: new ObjectID(userId),
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (updatedUser) {
const user: JSONObject = formatUserForSCIM(
updatedUser,
req,
req.params["statusPageScimId"]!,
"status-page",
);
return Response.sendJsonObjectResponse(req, res, user);
}
}
logger.debug(
`Status Page SCIM Update user - no updates made, returning existing user`,
);
// If no updates were made, return the existing user
const user: JSONObject = formatUserForSCIM(
statusPageUser,
req,
req.params["statusPageScimId"]!,
"status-page",
);
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
// Delete Status Page User - DELETE /status-page-scim/v2/Users/{id}
router.delete(
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Delete user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData[
"scimConfig"
] as StatusPageSCIM;
const userId: string = req.params["userId"]!;
if (!scimConfig.autoDeprovisionUsers) {
throw new BadRequestException(
"Auto-deprovisioning is disabled for this status page",
);
}
logger.debug(
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userId: ${userId}`,
);
if (!userId) {
throw new BadRequestException("User ID is required");
}
// Check if user exists and belongs to this status page
const statusPageUser: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
_id: new ObjectID(userId),
},
select: {
_id: true,
},
props: { isRoot: true },
});
if (!statusPageUser) {
logger.debug(
`Status Page SCIM Delete user - user not found for userId: ${userId}`,
);
// SCIM spec says to return 404 for non-existent resources
throw new NotFoundException("User not found");
}
// Delete the user from status page
await StatusPagePrivateUserService.deleteOneById({
id: new ObjectID(userId),
props: { isRoot: true },
});
logger.debug(
`Status Page SCIM Delete user - user deleted successfully for userId: ${userId}`,
);
// Return 204 No Content for successful deletion
res.status(204);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
}
},
);
export default router;

View File

@@ -1,8 +1,10 @@
import AuthenticationAPI from "./API/Authentication";
import ResellerAPI from "./API/Reseller";
import SsoAPI from "./API/SSO";
import SCIMAPI from "./API/SCIM";
import StatusPageAuthenticationAPI from "./API/StatusPageAuthentication";
import StatusPageSsoAPI from "./API/StatusPageSSO";
import StatusPageSCIMAPI from "./API/StatusPageSCIM";
import FeatureSet from "Common/Server/Types/FeatureSet";
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
import "ejs";
@@ -19,6 +21,10 @@ const IdentityFeatureSet: FeatureSet = {
app.use([`/${APP_NAME}`, "/"], SsoAPI);
app.use([`/${APP_NAME}`, "/"], SCIMAPI);
app.use([`/${APP_NAME}`, "/"], StatusPageSCIMAPI);
app.use([`/${APP_NAME}`, "/"], StatusPageSsoAPI);
app.use(

View File

@@ -0,0 +1,264 @@
import { ExpressRequest } from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import { JSONObject } from "Common/Types/JSON";
import Email from "Common/Types/Email";
import Name from "Common/Types/Name";
import ObjectID from "Common/Types/ObjectID";
/**
* Shared SCIM utility functions for both Project SCIM and Status Page SCIM
*/
// Base interface for SCIM user-like objects - compatible with User model
export interface SCIMUser {
id?: ObjectID | null;
email?: Email;
name?: Name | string;
createdAt?: Date;
updatedAt?: Date;
}
/**
* Parse name information from SCIM user payload
*/
export const parseNameFromSCIM: (scimUser: JSONObject) => string = (
scimUser: JSONObject,
): string => {
logger.debug(
`SCIM - Parsing name from SCIM user: ${JSON.stringify(scimUser, null, 2)}`,
);
const givenName: string =
((scimUser["name"] as JSONObject)?.["givenName"] as string) || "";
const familyName: string =
((scimUser["name"] as JSONObject)?.["familyName"] as string) || "";
const formattedName: string = (scimUser["name"] as JSONObject)?.[
"formatted"
] as string;
// Construct full name: prefer formatted, then combine given+family, then fallback to displayName
if (formattedName) {
return formattedName;
} else if (givenName || familyName) {
return `${givenName} ${familyName}`.trim();
} else if (scimUser["displayName"]) {
return scimUser["displayName"] as string;
}
return "";
};
/**
* Parse full name into SCIM name format
*/
export const parseNameToSCIMFormat: (fullName: string) => {
givenName: string;
familyName: string;
formatted: string;
} = (
fullName: string,
): { givenName: string; familyName: string; formatted: string } => {
const nameParts: string[] = fullName.trim().split(/\s+/);
const givenName: string = nameParts[0] || "";
const familyName: string = nameParts.slice(1).join(" ") || "";
return {
givenName,
familyName,
formatted: fullName,
};
};
/**
* Format user object for SCIM response
*/
export const formatUserForSCIM: (
user: SCIMUser,
req: ExpressRequest,
scimId: string,
scimType: "project" | "status-page",
) => JSONObject = (
user: SCIMUser,
req: ExpressRequest,
scimId: string,
scimType: "project" | "status-page",
): JSONObject => {
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
const userName: string = user.email?.toString() || "";
const fullName: string =
user.name?.toString() || userName.split("@")[0] || "Unknown User";
const nameData: { givenName: string; familyName: string; formatted: string } =
parseNameToSCIMFormat(fullName);
// Determine the correct endpoint path based on SCIM type
const endpointPath: string =
scimType === "project"
? `/scim/v2/${scimId}/Users/${user.id?.toString()}`
: `/status-page-scim/v2/${scimId}/Users/${user.id?.toString()}`;
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: user.id?.toString(),
userName: userName,
displayName: nameData.formatted,
name: {
formatted: nameData.formatted,
familyName: nameData.familyName,
givenName: nameData.givenName,
},
emails: [
{
value: userName,
type: "work",
primary: true,
},
],
active: true,
meta: {
resourceType: "User",
created: user.createdAt?.toISOString(),
lastModified: user.updatedAt?.toISOString(),
location: `${baseUrl}${endpointPath}`,
},
};
};
/**
* Extract email from SCIM user payload
*/
export const extractEmailFromSCIM: (scimUser: JSONObject) => string = (
scimUser: JSONObject,
): string => {
return (
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string) ||
""
);
};
/**
* Extract active status from SCIM user payload
*/
export const extractActiveFromSCIM: (scimUser: JSONObject) => boolean = (
scimUser: JSONObject,
): boolean => {
return scimUser["active"] !== false; // Default to true if not specified
};
/**
* Generate SCIM ServiceProviderConfig response
*/
export const generateServiceProviderConfig: (
req: ExpressRequest,
scimId: string,
scimType: "project" | "status-page",
documentationUrl?: string,
) => JSONObject = (
req: ExpressRequest,
scimId: string,
scimType: "project" | "status-page",
documentationUrl: string = "https://oneuptime.com/docs/identity/scim",
): JSONObject => {
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
const endpointPath: string =
scimType === "project"
? `/scim/v2/${scimId}`
: `/status-page-scim/v2/${scimId}`;
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
documentationUri: documentationUrl,
patch: {
supported: true,
},
bulk: {
supported: true,
maxOperations: 1000,
maxPayloadSize: 1048576,
},
filter: {
supported: true,
maxResults: 200,
},
changePassword: {
supported: false,
},
sort: {
supported: true,
},
etag: {
supported: false,
},
authenticationSchemes: [
{
type: "httpbearer",
name: "HTTP Bearer",
description: "Authentication scheme using HTTP Bearer Token",
primary: true,
},
],
meta: {
location: `${baseUrl}${endpointPath}/ServiceProviderConfig`,
resourceType: "ServiceProviderConfig",
created: "2023-01-01T00:00:00Z",
lastModified: "2023-01-01T00:00:00Z",
},
};
};
/**
* Generate SCIM ListResponse for users
*/
export const generateUsersListResponse: (
users: JSONObject[],
startIndex: number,
totalResults: number,
) => JSONObject = (
users: JSONObject[],
startIndex: number,
totalResults: number,
): JSONObject => {
return {
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: totalResults,
startIndex: startIndex,
itemsPerPage: users.length,
Resources: users,
};
};
/**
* Parse query parameters for SCIM list requests
*/
export const parseSCIMQueryParams: (req: ExpressRequest) => {
startIndex: number;
count: number;
} = (req: ExpressRequest): { startIndex: number; count: number } => {
const startIndex: number = parseInt(req.query["startIndex"] as string) || 1;
const count: number = Math.min(
parseInt(req.query["count"] as string) || 100,
200, // SCIM recommended max
);
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

@@ -729,6 +729,7 @@ export default class CopilotAction extends BaseModel {
type: TableColumnType.Boolean,
title: "Is Priority",
description: "Is Priority",
defaultValue: false,
})
@Column({
nullable: false,

View File

@@ -48,6 +48,7 @@ export default class GlobalConfig extends GlobalConfigModel {
type: TableColumnType.Boolean,
title: "Disable Signup",
description: "Should we disable new user sign up to this server?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -338,6 +339,7 @@ export default class GlobalConfig extends GlobalConfigModel {
type: TableColumnType.Boolean,
title: "Is Master API Key Enabled",
description: "Is Master API Key Enabled?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -27,6 +27,7 @@ import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import {
Column,
Entity,
@@ -733,29 +734,74 @@ export default class Incident extends BaseModel {
public changeMonitorStatusToId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectIncident,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectIncident,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectIncident,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
title: "Are subscribers notified?",
description: "Are subscribers notified about this incident?",
defaultValue: false,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description:
"Status of notification sent to subscribers about this incident",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotifiedOnIncidentCreated?: boolean = undefined;
public subscriberNotificationStatusOnIncidentCreated?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectIncident,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectIncident,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectIncident,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [

View File

@@ -17,6 +17,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@@ -341,29 +342,73 @@ export default class IncidentPublicNote extends BaseModel {
public note?: string = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentPublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentPublicNote,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentPublicNote,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
title: "Are subscribers notified?",
description: "Are subscribers notified about this note?",
defaultValue: false,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description: "Status of notification sent to subscribers about this note",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotifiedOnNoteCreated?: boolean = undefined;
public subscriberNotificationStatusOnNoteCreated?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentPublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentPublicNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentPublicNote,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [

View File

@@ -19,6 +19,7 @@ import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@@ -391,29 +392,74 @@ export default class IncidentStateTimeline extends BaseModel {
public incidentStateId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentStateTimeline,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentStateTimeline,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
title: "Are subscribers notified?",
description: "Are subscribers notified about this incident state change?",
defaultValue: false,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description:
"Status of notification sent to subscribers about this incident state change",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotified?: boolean = undefined;
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentPublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentStateTimeline,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentStateTimeline,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [
@@ -435,6 +481,7 @@ export default class IncidentStateTimeline extends BaseModel {
type: TableColumnType.Boolean,
title: "Should subscribers be notified?",
description: "Should subscribers be notified about this state change?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -461,6 +508,7 @@ export default class IncidentStateTimeline extends BaseModel {
isDefaultValueColumn: true,
title: "Are Owners Notified",
description: "Are owners notified of state change?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -114,6 +114,7 @@ import StatusPageOwnerTeam from "./StatusPageOwnerTeam";
import StatusPageOwnerUser from "./StatusPageOwnerUser";
import StatusPagePrivateUser from "./StatusPagePrivateUser";
import StatusPageResource from "./StatusPageResource";
import StatusPageSCIM from "./StatusPageSCIM";
import StatusPageSSO from "./StatusPageSso";
import StatusPageSubscriber from "./StatusPageSubscriber";
// Team
@@ -179,6 +180,7 @@ import ProjectUser from "./ProjectUser";
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
import MonitorFeed from "./MonitorFeed";
import MetricType from "./MetricType";
import ProjectSCIM from "./ProjectSCIM";
const AllModelTypes: Array<{
new (): BaseModel;
@@ -276,6 +278,7 @@ const AllModelTypes: Array<{
ProjectSSO,
StatusPageSSO,
StatusPageSCIM,
MonitorProbe,
@@ -380,6 +383,8 @@ const AllModelTypes: Array<{
MetricType,
OnCallDutyPolicyTimeLog,
ProjectSCIM,
];
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};

View File

@@ -456,6 +456,7 @@ export default class MonitorStatus extends BaseModel {
canReadOnRelationQuery: true,
title: "Is Offline State",
description: "Is this monitor in offline state?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -0,0 +1,451 @@
import Project from "./Project";
import Team from "./Team";
import User from "./User";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
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 UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
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";
@TableBillingAccessControl({
create: PlanType.Scale,
read: PlanType.Scale,
update: PlanType.Scale,
delete: PlanType.Scale,
})
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.DeleteProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@CrudApiEndpoint(new Route("/project-scim"))
@TableMetadata({
tableName: "ProjectSCIM",
singularName: "SCIM",
pluralName: "SCIM",
icon: IconProp.Lock,
tableDescription: "Manage SCIM auto-provisioning for your project",
})
@Entity({
name: "ProjectSCIM",
})
export default class ProjectSCIM extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Name",
description: "Any friendly name for this SCIM configuration",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
@UniqueColumnBy("projectId")
public name?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
title: "Description",
description: "Friendly description to help you remember",
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public description?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ReadProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@TableColumn({
required: true,
type: TableColumnType.LongText,
title: "Bearer Token",
description: "Bearer token for SCIM authentication. Keep this secure.",
})
@Column({
nullable: false,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public bearerToken?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: Team,
title: "Default Teams",
description: "Default teams that new users will be added to via SCIM",
})
@ManyToMany(
() => {
return Team;
},
{ eager: false },
)
@JoinTable({
name: "ProjectScimTeam",
inverseJoinColumn: {
name: "teamId",
referencedColumnName: "_id",
},
joinColumn: {
name: "projectScimId",
referencedColumnName: "_id",
},
})
public teams?: Array<Team> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Auto Provision Users",
description: "Automatically create users when they are added via SCIM",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public autoProvisionUsers?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditProjectSSO,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Auto Deprovision Users",
description: "Automatically remove users when they are removed via SCIM",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public autoDeprovisionUsers?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
modelType: User,
title: "Deleted by 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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
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

@@ -578,7 +578,11 @@ export default class ProjectSSO extends BaseModel {
],
update: [],
})
@TableColumn({ isDefaultValueColumn: true, type: TableColumnType.Boolean })
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,

View File

@@ -49,8 +49,8 @@ import {
})
@TableMetadata({
tableName: "ProjectUser",
singularName: "User",
pluralName: "Users",
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.",

View File

@@ -269,6 +269,7 @@ export default class Reseller extends BaseModel {
canReadOnRelationQuery: true,
title: "Enable Telemetry Features",
description: "Should we enable telemetry features for this reseller?",
defaultValue: false,
})
@Column({
nullable: true,

View File

@@ -25,6 +25,7 @@ import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import {
Column,
Entity,
@@ -715,29 +716,74 @@ export default class ScheduledMaintenance extends BaseModel {
public endsAt?: Date = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectScheduledMaintenance,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectScheduledMaintenance,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectScheduledMaintenance,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
title: "Status Page Subscribers Notified On Event Scheduled",
description: "Status Page Subscribers Notified On Event Scheduled",
defaultValue: false,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status On Event Scheduled",
description:
"Status of notification sent to subscribers when event was scheduled",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotifiedOnEventScheduled?: boolean = undefined;
public subscriberNotificationStatusOnEventScheduled?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentPublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectScheduledMaintenance,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectScheduledMaintenance,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message On Event Scheduled",
description:
"Status message for subscriber notifications when event is scheduled - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [

View File

@@ -17,6 +17,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@@ -342,29 +343,73 @@ export default class ScheduledMaintenancePublicNote extends BaseModel {
public note?: string = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateScheduledMaintenancePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadScheduledMaintenancePublicNote,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditScheduledMaintenancePublicNote,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
title: "Are subscribers notified?",
description: "Are subscribers notified about this note?",
defaultValue: false,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description: "Status of notification sent to subscribers about this note",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotifiedOnNoteCreated?: boolean = undefined;
public subscriberNotificationStatusOnNoteCreated?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateScheduledMaintenancePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadScheduledMaintenancePublicNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditScheduledMaintenancePublicNote,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [

View File

@@ -423,6 +423,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
canReadOnRelationQuery: true,
title: "Scheduled State",
description: "Is this state a scheduled state?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -456,6 +457,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
canReadOnRelationQuery: true,
title: "Ongoing State",
description: "Is this state a ongoing state?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -489,6 +491,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
canReadOnRelationQuery: true,
title: "Ended State",
description: "Is this state a ended state?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -522,6 +525,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
canReadOnRelationQuery: true,
title: "Resolved State",
description: "Is this state a resolved state?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -18,6 +18,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@@ -388,29 +389,74 @@ export default class ScheduledMaintenanceStateTimeline extends BaseModel {
public scheduledMaintenanceStateId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateScheduledMaintenanceStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadScheduledMaintenanceStateTimeline,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditScheduledMaintenanceStateTimeline,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
title: "Are subscribers notified?",
description: "Are subscribers notified about this incident state change?",
defaultValue: false,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description:
"Status of notification sent to subscribers about this scheduled maintenance state change",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotified?: boolean = undefined;
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateScheduledMaintenanceStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadScheduledMaintenanceStateTimeline,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditScheduledMaintenanceStateTimeline,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [

View File

@@ -1438,7 +1438,12 @@ export default class StatusPage extends BaseModel {
public callSmsConfigId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
@@ -1490,6 +1495,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Number,
required: true,
isDefaultValueColumn: true,
defaultValue: 14,
title: "Show incident history in days",
description:
"How many days of incident history should be shown on the status page (in days)?",
@@ -1526,6 +1532,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Number,
required: true,
isDefaultValueColumn: true,
defaultValue: 14,
title: "Show announcement history in days",
description:
"How many days of announcement history should be shown on the status page (in days)?",
@@ -1562,6 +1569,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Number,
required: true,
isDefaultValueColumn: true,
defaultValue: 14,
title: "Show scheduled event history in days",
description:
"How many days of scheduled event history should be shown on the status page (in days)?",
@@ -1633,6 +1641,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Hide Powered By OneUptime Branding",
description: "Hide Powered By OneUptime Branding?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -1706,6 +1715,7 @@ export default class StatusPage extends BaseModel {
required: false,
type: TableColumnType.EntityArray,
modelType: MonitorStatus,
isDefaultValueColumn: true,
title: "Downtime Monitor Statuses",
description:
'List of monitors statuses that are considered as "down" for this status page.',
@@ -1788,6 +1798,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Is Report Enabled",
description: "Is Report Enabled for this Status Page?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -1931,6 +1942,7 @@ export default class StatusPage extends BaseModel {
})
@TableColumn({
type: TableColumnType.Number,
defaultValue: 30,
title: "Report data for the last N days",
description: "How many days of data should be included in the report?",
})
@@ -1971,6 +1983,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Show Overall Uptime Percent on Status Page",
description: "Show Overall Uptime Percent on Status Page?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -2007,6 +2020,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.ShortText,
title: "Overall Uptime Percent Precision",
required: false,
defaultValue: UptimePrecision.TWO_DECIMAL,
description: "Overall Precision of uptime percent for this status page.",
})
@Column({
@@ -2109,6 +2123,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Show Incidents on Status Page",
description: "Show Incidents on Status Page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -2147,6 +2162,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Show Announcements on Status Page",
description: "Show Announcements on Status Page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -2185,6 +2201,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Show Scheduled Maintenance Events on Status Page",
description: "Show Scheduled Maintenance Events on Status Page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -2223,6 +2240,7 @@ export default class StatusPage extends BaseModel {
type: TableColumnType.Boolean,
title: "Show Subscriber Page on Status Page",
description: "Show Subscriber Page on Status Page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -21,6 +21,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import {
Column,
Entity,
@@ -443,27 +444,71 @@ export default class StatusPageAnnouncement extends BaseModel {
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageAnnouncement,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageAnnouncement,
],
update: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditStatusPageAnnouncement,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.Boolean,
defaultValue: false,
type: TableColumnType.ShortText,
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.Boolean,
default: false,
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public isStatusPageSubscribersNotified?: boolean = undefined;
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
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({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [
@@ -485,6 +530,7 @@ export default class StatusPageAnnouncement extends BaseModel {
type: TableColumnType.Boolean,
title: "Should subscribers be notified?",
description: "Should subscribers be notified about this announcement?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -493,7 +539,12 @@ export default class StatusPageAnnouncement extends BaseModel {
public shouldStatusPageSubscribersBeNotified?: boolean = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageAnnouncement,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
@@ -511,6 +562,7 @@ export default class StatusPageAnnouncement extends BaseModel {
isDefaultValueColumn: true,
title: "Are Owners Notified",
description: "Are owners notified of this announcement?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -474,7 +474,12 @@ export default class StatusPageDomain extends BaseModel {
public isCnameVerified?: boolean = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
@@ -489,6 +494,7 @@ export default class StatusPageDomain extends BaseModel {
type: TableColumnType.Boolean,
title: "SSL Ordered",
description: "Is SSL ordered?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
@@ -499,7 +505,12 @@ export default class StatusPageDomain extends BaseModel {
public isSslOrdered?: boolean = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateStatusPageDomain,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
@@ -514,6 +525,7 @@ export default class StatusPageDomain extends BaseModel {
type: TableColumnType.Boolean,
title: "SSL Provisioned",
description: "Is SSL provisioned?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -0,0 +1,469 @@
import Project from "./Project";
import StatusPage from "./StatusPage";
import User from "./User";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
import CanAccessIfCanReadOn from "../../Types/Database/CanAccessIfCanReadOn";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
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 UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@TableBillingAccessControl({
create: PlanType.Scale,
read: PlanType.Scale,
update: PlanType.Scale,
delete: PlanType.Scale,
})
@CanAccessIfCanReadOn("statusPage")
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.DeleteStatusPageSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPageSSO,
],
})
@CrudApiEndpoint(new Route("/status-page-scim"))
@TableMetadata({
tableName: "StatusPageSCIM",
singularName: "Status Page SCIM",
pluralName: "Status Page SCIM",
icon: IconProp.Lock,
tableDescription: "Manage SCIM auto-provisioning for your status page",
})
@Entity({
name: "StatusPageSCIM",
})
export default class StatusPageSCIM extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPageId",
type: TableColumnType.Entity,
modelType: StatusPage,
title: "Status Page",
description:
"Relation to Status Page Resource in which this object belongs",
})
@ManyToOne(
() => {
return StatusPage;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "statusPageId" })
public statusPage?: StatusPage = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Status Page ID",
description: "ID of your Status Page resource where this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPageId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPageSSO,
],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Name",
description: "Any friendly name for this SCIM configuration",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
@UniqueColumnBy("statusPageId")
public name?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPageSSO,
],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
title: "Description",
description: "Friendly description to help you remember",
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public description?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ReadStatusPageSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPageSSO,
],
})
@TableColumn({
required: true,
type: TableColumnType.LongText,
title: "Bearer Token",
description: "Bearer token for SCIM authentication. Keep this secure.",
})
@Column({
nullable: false,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public bearerToken?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPageSSO,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Auto Provision Users",
description:
"Automatically create status page users when they are added via SCIM",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public autoProvisionUsers?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPageSSO,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Auto Deprovision Users",
description:
"Automatically remove status page users when they are removed via SCIM",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public autoDeprovisionUsers?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPageSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
modelType: User,
title: "Deleted by 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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPageSSO,
],
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

@@ -608,6 +608,7 @@ export default class StatusPageSubscriber extends BaseModel {
type: TableColumnType.Boolean,
title: "Send You Have Subscribed Message",
description: "Send You Have Subscribed Message when subscriber is created?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -642,6 +643,7 @@ export default class StatusPageSubscriber extends BaseModel {
title: "Is Subscribed to All Resources",
description:
"Is Subscriber Subscribed to All Resources on this status page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
@@ -676,6 +678,7 @@ export default class StatusPageSubscriber extends BaseModel {
title: "Is Subscribed to All Event Types",
description:
"Is Subscriber Subscribed to All Event Types (like Incidents, Scheduled Events, Announcements) on this status page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,

View File

@@ -442,6 +442,7 @@ export default class TableView extends BaseModel {
type: TableColumnType.Number,
canReadOnRelationQuery: true,
description: "Items on page",
defaultValue: 10,
})
@Column({
type: ColumnType.Number,

View File

@@ -146,7 +146,11 @@ export default class TelemetryUsageBilling extends BaseModel {
public productType?: ProductType = undefined;
@ColumnAccessControl({
create: [],
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ManageProjectBilling,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
@@ -158,6 +162,7 @@ export default class TelemetryUsageBilling extends BaseModel {
type: TableColumnType.Number,
title: "Retain Telemetry Data For Days",
description: "Number of days to retain telemetry data for this service.",
defaultValue: DEFAULT_RETENTION_IN_DAYS,
})
@Column({
type: ColumnType.Number,

View File

@@ -278,7 +278,11 @@ class UserNotificationSetting extends BaseModel {
read: [Permission.CurrentUser],
update: [Permission.CurrentUser],
})
@TableColumn({ isDefaultValueColumn: true, type: TableColumnType.Boolean })
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,

View File

@@ -4,6 +4,7 @@ import {
DashboardRoute,
AppApiRoute,
StatusPageApiRoute,
DocsRoute,
} from "../ServiceRoute";
import BillingConfig from "./BillingConfig";
import Protocol from "../Types/API/Protocol";
@@ -150,6 +151,12 @@ export const AdminDashboardHostname: Hostname = Hostname.fromString(
}`,
);
export const DocsHostname: Hostname = Hostname.fromString(
`${process.env["SERVER_DOCS_HOSTNAME"] || "localhost"}:${
process.env["DOCS_PORT"] || 80
}`,
);
export const Env: string = process.env["NODE_ENV"] || "production";
// Redis does not require password.
@@ -318,6 +325,8 @@ export const AccountsClientUrl: URL = new URL(
AccountsRoute,
);
export const DocsClientUrl: URL = new URL(HttpProtocol, Host, DocsRoute);
export const DisableTelemetry: boolean =
process.env["DISABLE_TELEMETRY"] === "true";

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1754304193228 implements MigrationInterface {
public name = "MigrationName1754304193228";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "ProjectSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying(500), "bearerToken" character varying(500) NOT NULL, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT true, "isEnabled" boolean NOT NULL DEFAULT false, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_51e71d70211675a5c918aee4e68" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f916360335859c26c4d7051239" ON "ProjectSCIM" ("projectId") `,
);
await queryRunner.query(
`CREATE TABLE "ProjectScimTeam" ("projectScimId" uuid NOT NULL, "teamId" uuid NOT NULL, CONSTRAINT "PK_db724b66b4fa8c880ce5ccf820b" PRIMARY KEY ("projectScimId", "teamId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_b9a28efd66600267f0e9de0731" ON "ProjectScimTeam" ("projectScimId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bb0eda2ef0c773f975e9ad8448" ON "ProjectScimTeam" ("teamId") `,
);
await queryRunner.query(
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_f916360335859c26c4d7051239b" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_5d5d587984f156e5215d51daff7" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_b9a28efd66600267f0e9de0731b" FOREIGN KEY ("projectScimId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a"`,
);
await queryRunner.query(
`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_b9a28efd66600267f0e9de0731b"`,
);
await queryRunner.query(
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76"`,
);
await queryRunner.query(
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_5d5d587984f156e5215d51daff7"`,
);
await queryRunner.query(
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_f916360335859c26c4d7051239b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bb0eda2ef0c773f975e9ad8448"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_b9a28efd66600267f0e9de0731"`,
);
await queryRunner.query(`DROP TABLE "ProjectScimTeam"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_f916360335859c26c4d7051239"`,
);
await queryRunner.query(`DROP TABLE "ProjectSCIM"`);
}
}

View File

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

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1754384418632 implements MigrationInterface {
public name = "MigrationName1754384418632";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "StatusPageSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying(500), "bearerToken" character varying(500) NOT NULL, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT true, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_9d65d486be515b9608347cf66d4" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0a241118fe6b4a8665deef444b" ON "StatusPageSCIM" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7200e368657773fde2836c57eb" ON "StatusPageSCIM" ("statusPageId") `,
);
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 "StatusPageSCIM" ADD CONSTRAINT "FK_0a241118fe6b4a8665deef444b2" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_7200e368657773fde2836c57ebe" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_adb05dd1cbe0e734a76b3dbdcf1" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_2fded7c784a5c2f56ad2553cb80" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_2fded7c784a5c2f56ad2553cb80"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_adb05dd1cbe0e734a76b3dbdcf1"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_7200e368657773fde2836c57ebe"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_0a241118fe6b4a8665deef444b2"`,
);
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_7200e368657773fde2836c57eb"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0a241118fe6b4a8665deef444b"`,
);
await queryRunner.query(`DROP TABLE "StatusPageSCIM"`);
}
}

View File

@@ -0,0 +1,150 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1754671483948 implements MigrationInterface {
public name =
"RenameSubscriberNotificationFailedReasonToStatusMessage1754484441976";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "Incident" DROP COLUMN "isStatusPageSubscribersNotifiedOnIncidentCreated"`,
);
await queryRunner.query(
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "isStatusPageSubscribersNotifiedOnNoteCreated"`,
);
await queryRunner.query(
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "isStatusPageSubscribersNotified"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "isStatusPageSubscribersNotifiedOnEventScheduled"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "isStatusPageSubscribersNotifiedOnNoteCreated"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "isStatusPageSubscribersNotified"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "isStatusPageSubscribersNotified"`,
);
await queryRunner.query(
`ALTER TABLE "Incident" ADD "subscriberNotificationStatusOnIncidentCreated" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "Incident" ADD "subscriberNotificationStatusMessage" text`,
);
await queryRunner.query(
`ALTER TABLE "IncidentPublicNote" ADD "subscriberNotificationStatusOnNoteCreated" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "IncidentPublicNote" ADD "subscriberNotificationStatusMessage" text`,
);
await queryRunner.query(
`ALTER TABLE "IncidentStateTimeline" ADD "subscriberNotificationStatus" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "IncidentStateTimeline" ADD "subscriberNotificationStatusMessage" text`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenance" ADD "subscriberNotificationStatusOnEventScheduled" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenance" ADD "subscriberNotificationStatusMessage" text`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "subscriberNotificationStatusOnNoteCreated" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "subscriberNotificationStatusMessage" text`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "subscriberNotificationStatus" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "subscriberNotificationStatusMessage" text`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationStatus" character varying NOT NULL DEFAULT 'Pending'`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationStatusMessage" text`,
);
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 "StatusPageAnnouncement" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageAnnouncement" DROP COLUMN "subscriberNotificationStatus"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenanceStateTimeline" DROP COLUMN "subscriberNotificationStatus"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenancePublicNote" DROP COLUMN "subscriberNotificationStatusOnNoteCreated"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenance" DROP COLUMN "subscriberNotificationStatusOnEventScheduled"`,
);
await queryRunner.query(
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "IncidentStateTimeline" DROP COLUMN "subscriberNotificationStatus"`,
);
await queryRunner.query(
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "IncidentPublicNote" DROP COLUMN "subscriberNotificationStatusOnNoteCreated"`,
);
await queryRunner.query(
`ALTER TABLE "Incident" DROP COLUMN "subscriberNotificationStatusMessage"`,
);
await queryRunner.query(
`ALTER TABLE "Incident" DROP COLUMN "subscriberNotificationStatusOnIncidentCreated"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPageAnnouncement" ADD "isStatusPageSubscribersNotified" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenanceStateTimeline" ADD "isStatusPageSubscribersNotified" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenancePublicNote" ADD "isStatusPageSubscribersNotifiedOnNoteCreated" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "ScheduledMaintenance" ADD "isStatusPageSubscribersNotifiedOnEventScheduled" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "IncidentStateTimeline" ADD "isStatusPageSubscribersNotified" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "IncidentPublicNote" ADD "isStatusPageSubscribersNotifiedOnNoteCreated" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "Incident" ADD "isStatusPageSubscribersNotifiedOnIncidentCreated" boolean NOT NULL DEFAULT false`,
);
}
}

View File

@@ -146,6 +146,10 @@ import { MigrationName1753343522987 } from "./1753343522987-MigrationName";
import { MigrationName1753377161288 } from "./1753377161288-MigrationName";
import { AddPerformanceIndexes1753378524062 } from "./1753378524062-AddPerformanceIndexes";
import { MigrationName1753383711511 } from "./1753383711511-MigrationName";
import { MigrationName1754304193228 } from "./1754304193228-MigrationName";
import { MigrationName1754315774827 } from "./1754315774827-MigrationName";
import { MigrationName1754384418632 } from "./1754384418632-MigrationName";
import { MigrationName1754671483948 } from "./1754671483948-MigrationName";
export default [
InitialMigration,
@@ -296,4 +300,8 @@ export default [
MigrationName1753377161288,
AddPerformanceIndexes1753378524062,
MigrationName1753383711511,
MigrationName1754304193228,
MigrationName1754315774827,
MigrationName1754384418632,
MigrationName1754671483948,
];

View File

@@ -27,6 +27,8 @@ export type QueueJob = Job;
export default class Queue {
private static queueDict: Dictionary<BullQueue> = {};
// track queues we have already run initial cleanup on
private static cleanedQueueNames: Set<string> = new Set<string>();
@CaptureSpan()
public static getQueue(queueName: QueueName): BullQueue {
@@ -41,11 +43,37 @@ export default class Queue {
port: RedisPort.toNumber(),
password: RedisPassword,
},
// Keep BullMQ data under control to avoid Redis bloat
defaultJobOptions: {
// keep only recent completed/failed jobs
removeOnComplete: { count: 500 }, // keep last 1000 completed jobs
removeOnFail: { count: 100 }, // keep last 500 failed jobs
},
// Optionally cap the event stream length (supported in BullMQ >= v5)
// This helps prevent the :events stream from growing indefinitely
streams: {
events: { maxLen: 1000 },
},
});
// save it to the dictionary
this.queueDict[queueName] = queue;
// Fire-and-forget initial cleanup for legacy/old data if not done before
if (!this.cleanedQueueNames.has(queueName)) {
this.cleanedQueueNames.add(queueName);
// Clean jobs older than 1 days to reclaim memory from historic runs
const oneDaysMs: number = 1 * 24 * 60 * 60 * 1000;
void (async () => {
try {
await queue.clean(oneDaysMs, 1000, "completed");
await queue.clean(oneDaysMs, 1000, "failed");
} catch {
// ignore cleanup errors to not impact normal flow
}
})();
}
return queue;
}
@@ -193,6 +221,7 @@ export default class Queue {
name: string;
data: JSONObject;
failedReason: string;
stackTrace?: string;
processedOn: Date | null;
finishedOn: Date | null;
attemptsMade: number;
@@ -204,7 +233,16 @@ export default class Queue {
const failed: Job[] = await queue.getFailed(start, end);
return failed.map((job: Job) => {
return {
const result: {
id: string;
name: string;
data: JSONObject;
failedReason: string;
stackTrace?: string;
processedOn: Date | null;
finishedOn: Date | null;
attemptsMade: number;
} = {
id: job.id || "unknown",
name: job.name || "unknown",
data: job.data as JSONObject,
@@ -213,6 +251,12 @@ export default class Queue {
finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
attemptsMade: job.attemptsMade || 0,
};
if (job.stacktrace && job.stacktrace.length > 0) {
result.stackTrace = job.stacktrace.join("\n");
}
return result;
});
}
}

View File

@@ -0,0 +1,127 @@
import ProjectSCIMService from "../Services/ProjectSCIMService";
import StatusPageSCIMService from "../Services/StatusPageSCIMService";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import ObjectID from "../../Types/ObjectID";
import ProjectSCIM from "../../Models/DatabaseModels/ProjectSCIM";
import StatusPageSCIM from "../../Models/DatabaseModels/StatusPageSCIM";
import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
import BadRequestException from "../../Types/Exception/BadRequestException";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import logger from "../Utils/Logger";
export default class SCIMMiddleware {
@CaptureSpan()
public static async isAuthorizedSCIMRequest(
req: ExpressRequest,
_res: ExpressResponse,
next: NextFunction,
): Promise<void> {
try {
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
// Extract SCIM ID from URL path (could be project or status page)
const scimId: string | undefined =
req.params["projectScimId"] || req.params["statusPageScimId"];
if (!scimId) {
throw new BadRequestException("SCIM ID is required");
}
// Extract bearer token from Authorization header
let bearerToken: string | undefined;
if (req.headers?.["authorization"]) {
const authHeader: string = req.headers["authorization"] as string;
if (authHeader.startsWith("Bearer ")) {
bearerToken = authHeader.substring(7);
}
}
logger.debug(
`SCIM Authorization: scimId=${scimId}, bearerToken=${
bearerToken ? "***" : "missing"
}`,
);
if (!bearerToken) {
throw new NotAuthorizedException(
"Bearer token is required for SCIM authentication",
);
}
// Try to find Project SCIM configuration first
const projectScimConfig: ProjectSCIM | null =
await ProjectSCIMService.findOneBy({
query: {
_id: new ObjectID(scimId),
bearerToken: bearerToken,
},
select: {
_id: true,
projectId: true,
autoProvisionUsers: true,
autoDeprovisionUsers: true,
teams: {
_id: true,
name: true,
},
},
props: {
isRoot: true,
},
});
if (projectScimConfig) {
// Store Project SCIM configuration
oneuptimeRequest.bearerTokenData = {
scimConfig: projectScimConfig,
projectId: projectScimConfig.projectId,
projectScimId: new ObjectID(scimId),
type: "project-scim",
};
return next();
}
// If not found, try Status Page SCIM configuration
const statusPageScimConfig: StatusPageSCIM | null =
await StatusPageSCIMService.findOneBy({
query: {
_id: new ObjectID(scimId),
bearerToken: bearerToken,
},
select: {
_id: true,
projectId: true,
statusPageId: true,
autoProvisionUsers: true,
autoDeprovisionUsers: true,
},
props: {
isRoot: true,
},
});
if (statusPageScimConfig) {
// Store Status Page SCIM configuration
oneuptimeRequest.bearerTokenData = {
scimConfig: statusPageScimConfig,
projectId: statusPageScimConfig.projectId,
statusPageId: statusPageScimConfig.statusPageId,
statusPageScimId: new ObjectID(scimId),
type: "status-page-scim",
};
return next();
}
// If neither found, throw error
throw new NotAuthorizedException(
"Invalid bearer token or SCIM configuration not found",
);
} catch (err) {
return next(err);
}
}
}

View File

@@ -228,6 +228,11 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
}
throw new BadDataException(`${requiredField} is required`);
} else if (
(data as any)[requiredField] === null &&
data.isDefaultValueColumn(requiredField)
) {
delete (data as any)[requiredField];
}
}

View File

@@ -11,6 +11,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import IncidentService from "./IncidentService";
import Incident from "../../Models/DatabaseModels/Incident";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -47,6 +48,21 @@ export class Service extends DatabaseService<Model> {
createBy.data.postedAt = OneUptimeDate.getCurrentDate();
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnNoteCreated
if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === false
) {
createBy.data.subscriberNotificationStatusOnNoteCreated =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this incident note.";
} else if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === true
) {
createBy.data.subscriberNotificationStatusOnNoteCreated =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy: createBy,
carryForward: null,

View File

@@ -24,6 +24,7 @@ import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import Typeof from "../../Types/Typeof";
import UserNotificationEventType from "../../Types/UserNotification/UserNotificationEventType";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import Model from "../../Models/DatabaseModels/Incident";
import IncidentOwnerTeam from "../../Models/DatabaseModels/IncidentOwnerTeam";
import IncidentOwnerUser from "../../Models/DatabaseModels/IncidentOwnerUser";
@@ -426,6 +427,28 @@ export class Service extends DatabaseService<Model> {
}
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnIncidentCreated if it's being updated
if (
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated !==
undefined
) {
if (
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
false
) {
updateBy.data.subscriberNotificationStatusOnIncidentCreated =
StatusPageSubscriberNotificationStatus.Skipped;
updateBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this incident.";
} else if (
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
true
) {
updateBy.data.subscriberNotificationStatusOnIncidentCreated =
StatusPageSubscriberNotificationStatus.Pending;
}
}
return {
updateBy: updateBy,
carryForward: carryForward,
@@ -523,6 +546,23 @@ export class Service extends DatabaseService<Model> {
}
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnIncidentCreated
if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
false
) {
createBy.data.subscriberNotificationStatusOnIncidentCreated =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this incident.";
} else if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated ===
true
) {
createBy.data.subscriberNotificationStatusOnIncidentCreated =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy,
carryForward: {
@@ -1685,7 +1725,10 @@ ${incidentSeverity.name}
statusTimeline.shouldStatusPageSubscribersBeNotified =
shouldNotifyStatusPageSubscribers;
statusTimeline.isStatusPageSubscribersNotified = isSubscribersNotified;
// Map boolean to enum value
statusTimeline.subscriberNotificationStatus = isSubscribersNotified
? StatusPageSubscriberNotificationStatus.Success
: StatusPageSubscriberNotificationStatus.Pending;
if (stateChangeLog) {
statusTimeline.stateChangeLog = stateChangeLog;

View File

@@ -13,6 +13,7 @@ import BadDataException from "../../Types/Exception/BadDataException";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import Incident from "../../Models/DatabaseModels/Incident";
import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote";
import IncidentState from "../../Models/DatabaseModels/IncidentState";
@@ -245,10 +246,26 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
if (publicNote) {
// mark status page subscribers as notified for this state change because we dont want to send duplicate (two) emails one for public note and one for state change.
if (createBy.data.shouldStatusPageSubscribersBeNotified) {
createBy.data.isStatusPageSubscribersNotified = true;
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Success;
}
}
// Set notification status based on shouldStatusPageSubscribersBeNotified
if (createBy.data.shouldStatusPageSubscribersBeNotified === false) {
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this incident state change.";
} else if (
createBy.data.shouldStatusPageSubscribersBeNotified === true &&
!publicNote
) {
// Only set to Pending if there's no public note (public note handling sets it to Success)
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy,
carryForward: {

View File

@@ -0,0 +1,27 @@
import CreateBy from "../Types/Database/CreateBy";
import { OnCreate } from "../Types/Database/Hooks";
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/ProjectSCIM";
import ObjectID from "../../Types/ObjectID";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
if (!createBy.data.bearerToken) {
// Generate a secure bearer token if not provided
createBy.data.bearerToken = ObjectID.generate().toString();
}
return {
createBy: createBy,
carryForward: {},
};
}
}
export default new Service();

View File

@@ -11,6 +11,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import ScheduledMaintenanceService from "./ScheduledMaintenanceService";
import ScheduledMaintenance from "../../Models/DatabaseModels/ScheduledMaintenance";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -25,6 +26,21 @@ export class Service extends DatabaseService<Model> {
createBy.data.postedAt = OneUptimeDate.getCurrentDate();
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnNoteCreated
if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === false
) {
createBy.data.subscriberNotificationStatusOnNoteCreated =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance note.";
} else if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === true
) {
createBy.data.subscriberNotificationStatusOnNoteCreated =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy: createBy,
carryForward: null,

View File

@@ -16,6 +16,7 @@ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import Typeof from "../../Types/Typeof";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import Monitor from "../../Models/DatabaseModels/Monitor";
import Model from "../../Models/DatabaseModels/ScheduledMaintenance";
import ScheduledMaintenanceOwnerTeam from "../../Models/DatabaseModels/ScheduledMaintenanceOwnerTeam";
@@ -369,6 +370,28 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
nextTimeToNotifyBeforeTheEvent;
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnEventCreated if it's being updated
if (
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated !==
undefined
) {
if (
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
false
) {
updateBy.data.subscriberNotificationStatusOnEventScheduled =
StatusPageSubscriberNotificationStatus.Skipped;
updateBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance.";
} else if (
updateBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
true
) {
updateBy.data.subscriberNotificationStatusOnEventScheduled =
StatusPageSubscriberNotificationStatus.Pending;
}
}
return {
updateBy,
carryForward: null,
@@ -539,6 +562,22 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
}
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnEventCreated
if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated ===
false
) {
createBy.data.subscriberNotificationStatusOnEventScheduled =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance.";
} else if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnEventCreated === true
) {
createBy.data.subscriberNotificationStatusOnEventScheduled =
StatusPageSubscriberNotificationStatus.Pending;
}
return { createBy, carryForward: null };
}
@@ -775,9 +814,11 @@ ${createdItem.description || "No description provided."}
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
);
timeline.isStatusPageSubscribersNotified = Boolean(
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
); // ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
// Map boolean to enum value - ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
timeline.subscriberNotificationStatus =
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated
? StatusPageSubscriberNotificationStatus.Success
: StatusPageSubscriberNotificationStatus.Pending;
timeline.scheduledMaintenanceStateId =
createdItem.currentScheduledMaintenanceStateId!;
@@ -1275,7 +1316,10 @@ ${labels
statusTimeline.scheduledMaintenanceStateId = scheduledMaintenanceStateId;
statusTimeline.projectId = projectId;
statusTimeline.isOwnerNotified = !notifyOwners;
statusTimeline.isStatusPageSubscribersNotified = isSubscribersNotified;
// Map boolean to enum value
statusTimeline.subscriberNotificationStatus = isSubscribersNotified
? StatusPageSubscriberNotificationStatus.Success
: StatusPageSubscriberNotificationStatus.Pending;
statusTimeline.shouldStatusPageSubscribersBeNotified =
shouldNotifyStatusPageSubscribers;

View File

@@ -15,6 +15,7 @@ import BadDataException from "../../Types/Exception/BadDataException";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import Monitor from "../../Models/DatabaseModels/Monitor";
import MonitorStatus from "../../Models/DatabaseModels/MonitorStatus";
import MonitorStatusTimeline from "../../Models/DatabaseModels/MonitorStatusTimeline";
@@ -255,7 +256,8 @@ export class Service extends DatabaseService<ScheduledMaintenanceStateTimeline>
if (
scheduledMaintenancePublicNote.shouldStatusPageSubscribersBeNotifiedOnNoteCreated
) {
createBy.data.isStatusPageSubscribersNotified = true;
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Success;
}
await ScheduledMaintenancePublicNoteService.create({
@@ -264,6 +266,17 @@ export class Service extends DatabaseService<ScheduledMaintenanceStateTimeline>
});
}
// Set notification status based on shouldStatusPageSubscribersBeNotified
if (createBy.data.shouldStatusPageSubscribersBeNotified === false) {
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this scheduled maintenance state change.";
} else if (createBy.data.shouldStatusPageSubscribersBeNotified === true) {
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy,
carryForward: {

View File

@@ -1,10 +1,56 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/StatusPageAnnouncement";
import CreateBy from "../Types/Database/CreateBy";
import UpdateBy from "../Types/Database/UpdateBy";
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
// Set notification status based on shouldStatusPageSubscribersBeNotified
if (createBy.data.shouldStatusPageSubscribersBeNotified === false) {
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this announcement.";
} else if (createBy.data.shouldStatusPageSubscribersBeNotified === true) {
createBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy,
carryForward: null,
};
}
protected override async onBeforeUpdate(
updateBy: UpdateBy<Model>,
): Promise<OnUpdate<Model>> {
// Set notification status based on shouldStatusPageSubscribersBeNotified if it's being updated
if (updateBy.data.shouldStatusPageSubscribersBeNotified !== undefined) {
if (updateBy.data.shouldStatusPageSubscribersBeNotified === false) {
updateBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Skipped;
updateBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this announcement.";
} else if (updateBy.data.shouldStatusPageSubscribersBeNotified === true) {
updateBy.data.subscriberNotificationStatus =
StatusPageSubscriberNotificationStatus.Pending;
}
}
return {
updateBy,
carryForward: null,
};
}
}
export default new Service();

View File

@@ -0,0 +1,27 @@
import CreateBy from "../Types/Database/CreateBy";
import { OnCreate } from "../Types/Database/Hooks";
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/StatusPageSCIM";
import ObjectID from "../../Types/ObjectID";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
if (!createBy.data.bearerToken) {
// Generate a secure bearer token if not provided
createBy.data.bearerToken = ObjectID.generate().toString();
}
return {
createBy: createBy,
carryForward: {},
};
}
}
export default new Service();

View File

@@ -24,6 +24,7 @@ import Permission, {
} from "../../../../Types/Permission";
import CaptureSpan from "../../../Utils/Telemetry/CaptureSpan";
import logger from "../../../Utils/Logger";
export default class ColumnPermissions {
@CaptureSpan()
@@ -220,6 +221,10 @@ export default class ColumnPermissions {
getAllEnvVars(),
)
) {
logger.debug(
`User does not have access to update ${key} column of ${model.singularName}`,
);
throw new PaymentRequiredException(
"Please upgrade your plan to " +
billingAccessControl.update +

View File

@@ -89,6 +89,16 @@ app.set("view engine", "ejs");
* https://stackoverflow.com/questions/19917401/error-request-entity-too-large
*/
// Handle SCIM content type before JSON middleware
app.use((req: ExpressRequest, _res: ExpressResponse, next: NextFunction) => {
const contentType: string | undefined = req.headers["content-type"];
if (contentType && contentType.includes("application/scim+json")) {
// Set content type to application/json so express.json() can parse it
req.headers["content-type"] = "application/json";
}
next();
});
app.use((req: OneUptimeRequest, res: ExpressResponse, next: NextFunction) => {
if (req.headers["content-encoding"] === "gzip") {
const buffers: any = [];

View File

@@ -0,0 +1,9 @@
enum StatusPageSubscriberNotificationStatus {
Skipped = "Skipped",
Pending = "Pending",
InProgress = "InProgress",
Success = "Success",
Failed = "Failed",
}
export default StatusPageSubscriberNotificationStatus;

View File

@@ -0,0 +1,991 @@
// Tremor BarChart [v1.0.0]
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React from "react";
import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
import {
Bar,
CartesianGrid,
Label,
BarChart as RechartsBarChart,
Legend as RechartsLegend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { AxisDomain } from "recharts/types/util/types";
import {
AvailableChartColors,
type AvailableChartColorsKeys,
constructCategoryColors,
getColorClassName,
} from "../Utils/ChartColors";
import { cx } from "../Utils/Cx";
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
import { useOnWindowResize } from "../Utils/UseWindowOnResize";
//#region Shape
function deepEqual<T>(obj1: T, obj2: T): boolean {
if (obj1 === obj2) {
return true;
}
if (
typeof obj1 !== "object" ||
typeof obj2 !== "object" ||
obj1 === null ||
obj2 === null
) {
return false;
}
const keys1: Array<keyof T> = Object.keys(obj1) as Array<keyof T>;
const keys2: Array<keyof T> = Object.keys(obj2) as Array<keyof T>;
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
return false;
}
}
return true;
}
const renderShape: (
props: any,
activeBar: any | undefined,
activeLegend: string | undefined,
layout: string,
) => React.ReactElement = (
props: any,
activeBar: any | undefined,
activeLegend: string | undefined,
layout: string,
): React.ReactElement => {
const { fillOpacity, name, payload, value } = props;
let { x, width, y, height } = props;
if (layout === "horizontal" && height < 0) {
y += height;
height = Math.abs(height); // height must be a positive number
} else if (layout === "vertical" && width < 0) {
x += width;
width = Math.abs(width); // width must be a positive number
}
return (
<rect
x={x}
y={y}
width={width}
height={height}
opacity={
activeBar || (activeLegend && activeLegend !== name)
? deepEqual(activeBar, { ...payload, value })
? fillOpacity
: 0.3
: fillOpacity
}
/>
);
};
//#region Legend
interface LegendItemProps {
name: string;
color: AvailableChartColorsKeys;
onClick?: (name: string, color: string) => void;
activeLegend?: string;
}
const LegendItem: React.FunctionComponent<LegendItemProps> = ({
name,
color,
onClick,
activeLegend,
}: LegendItemProps): React.ReactElement => {
const hasOnValueChange: boolean = Boolean(onClick);
return (
<li
className={cx(
// base
"group inline-flex flex-nowrap items-center gap-1.5 rounded-sm px-2 py-1 whitespace-nowrap transition",
hasOnValueChange
? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
: "cursor-default",
)}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(name, color as string);
}}
>
<span
className={cx(
"size-2 shrink-0 rounded-xs",
getColorClassName(color, "bg"),
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
aria-hidden={true}
/>
<p
className={cx(
// base
"truncate text-xs whitespace-nowrap",
// text color
"text-gray-700 dark:text-gray-300",
hasOnValueChange &&
"group-hover:text-gray-900 dark:group-hover:text-gray-50",
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
>
{name}
</p>
</li>
);
};
interface ScrollButtonProps {
icon: React.ElementType;
onClick?: () => void;
disabled?: boolean;
}
const ScrollButton: React.FunctionComponent<ScrollButtonProps> = ({
icon,
onClick,
disabled,
}: ScrollButtonProps): React.ReactElement => {
const Icon: React.ElementType = icon;
const [isPressed, setIsPressed] = React.useState(false);
const intervalRef: React.MutableRefObject<NodeJS.Timeout | null> =
React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (isPressed) {
intervalRef.current = setInterval(() => {
onClick?.();
}, 300);
} else {
clearInterval(intervalRef.current as NodeJS.Timeout);
}
return () => {
return clearInterval(intervalRef.current as NodeJS.Timeout);
};
}, [isPressed, onClick]);
React.useEffect(() => {
if (disabled) {
clearInterval(intervalRef.current as NodeJS.Timeout);
setIsPressed(false);
}
}, [disabled]);
return (
<button
type="button"
className={cx(
// base
"group inline-flex size-5 items-center truncate rounded-sm transition",
disabled
? "cursor-not-allowed text-gray-400 dark:text-gray-600"
: "cursor-pointer text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-50",
)}
disabled={disabled}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
}}
onMouseDown={(e: React.MouseEvent) => {
e.stopPropagation();
setIsPressed(true);
}}
onMouseUp={(e: React.MouseEvent) => {
e.stopPropagation();
setIsPressed(false);
}}
>
<Icon className="size-full" aria-hidden="true" />
</button>
);
};
interface LegendProps extends React.OlHTMLAttributes<HTMLOListElement> {
categories: string[];
colors?: AvailableChartColorsKeys[];
onClickLegendItem?: (category: string, color: string) => void;
activeLegend?: string;
enableLegendSlider?: boolean;
}
type HasScrollProps = {
left: boolean;
right: boolean;
};
const Legend: React.ForwardRefExoticComponent<
LegendProps & React.RefAttributes<HTMLOListElement>
> = React.forwardRef<HTMLOListElement, LegendProps>(
(
props: LegendProps,
ref: React.Ref<HTMLOListElement>,
): React.ReactElement => {
const {
categories,
colors = AvailableChartColors,
className,
onClickLegendItem,
activeLegend,
enableLegendSlider = false,
...other
} = props;
const scrollableRef: React.RefObject<HTMLInputElement> =
React.useRef<HTMLInputElement>(null);
const scrollButtonsRef: React.RefObject<HTMLDivElement> =
React.useRef<HTMLDivElement>(null);
const [hasScroll, setHasScroll] = React.useState<HasScrollProps | null>(
null,
);
const [isKeyDowned, setIsKeyDowned] = React.useState<string | null>(null);
const intervalRef: React.MutableRefObject<NodeJS.Timeout | null> =
React.useRef<NodeJS.Timeout | null>(null);
const checkScroll: () => void = React.useCallback(() => {
const scrollable: HTMLInputElement | null = scrollableRef?.current;
if (!scrollable) {
return;
}
const hasLeftScroll: boolean = scrollable.scrollLeft > 0;
const hasRightScroll: boolean =
scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft;
setHasScroll({ left: hasLeftScroll, right: hasRightScroll });
}, [setHasScroll]);
const scrollToTest: (direction: "left" | "right") => void =
React.useCallback(
(direction: "left" | "right") => {
const element: HTMLInputElement | null = scrollableRef?.current;
const scrollButtons: HTMLDivElement | null =
scrollButtonsRef?.current;
const scrollButtonsWith: number = scrollButtons?.clientWidth ?? 0;
const width: number = element?.clientWidth ?? 0;
if (element && enableLegendSlider) {
element.scrollTo({
left:
direction === "left"
? element.scrollLeft - width + scrollButtonsWith
: element.scrollLeft + width - scrollButtonsWith,
behavior: "smooth",
});
setTimeout(() => {
checkScroll();
}, 400);
}
},
[enableLegendSlider, checkScroll],
);
React.useEffect(() => {
const keyDownHandler: (key: string) => void = (key: string): void => {
if (key === "ArrowLeft") {
scrollToTest("left");
} else if (key === "ArrowRight") {
scrollToTest("right");
}
};
if (isKeyDowned) {
keyDownHandler(isKeyDowned);
intervalRef.current = setInterval(() => {
keyDownHandler(isKeyDowned);
}, 300);
} else {
clearInterval(intervalRef.current as NodeJS.Timeout);
}
return () => {
return clearInterval(intervalRef.current as NodeJS.Timeout);
};
}, [isKeyDowned, scrollToTest]);
const keyDown: (e: KeyboardEvent) => void = (e: KeyboardEvent): void => {
e.stopPropagation();
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
setIsKeyDowned(e.key);
}
};
const keyUp: (e: KeyboardEvent) => void = (e: KeyboardEvent): void => {
e.stopPropagation();
setIsKeyDowned(null);
};
React.useEffect(() => {
const scrollable: HTMLInputElement | null = scrollableRef?.current;
if (enableLegendSlider) {
checkScroll();
scrollable?.addEventListener("keydown", keyDown);
scrollable?.addEventListener("keyup", keyUp);
}
return () => {
scrollable?.removeEventListener("keydown", keyDown);
scrollable?.removeEventListener("keyup", keyUp);
};
}, [checkScroll, enableLegendSlider]);
return (
<ol
ref={ref}
className={cx("relative overflow-hidden", className)}
{...other}
>
<div
ref={scrollableRef}
tabIndex={0}
className={cx(
"flex h-full",
enableLegendSlider
? hasScroll?.right || hasScroll?.left
? "snap-mandatory items-center overflow-auto pr-12 pl-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: ""
: "flex-wrap",
)}
>
{categories.map((category: string, index: number) => {
return (
<LegendItem
key={`item-${index}`}
name={category}
color={colors[index] as AvailableChartColorsKeys}
{...(onClickLegendItem ? { onClick: onClickLegendItem } : {})}
{...(activeLegend ? { activeLegend: activeLegend } : {})}
/>
);
})}
</div>
{enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
<>
<div
className={cx(
// base
"absolute top-0 right-0 bottom-0 flex h-full items-center justify-center pr-1",
// background color
"bg-white dark:bg-gray-950",
)}
>
<ScrollButton
icon={RiArrowLeftSLine}
onClick={() => {
setIsKeyDowned(null);
scrollToTest("left");
}}
disabled={!hasScroll?.left}
/>
<ScrollButton
icon={RiArrowRightSLine}
onClick={() => {
setIsKeyDowned(null);
scrollToTest("right");
}}
disabled={!hasScroll?.right}
/>
</div>
</>
) : null}
</ol>
);
},
);
Legend.displayName = "Legend";
const ChartLegend: (
payload: any,
categoryColors: Map<string, AvailableChartColorsKeys>,
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
activeLegend: string | undefined,
onClick?: (category: string, color: string) => void,
enableLegendSlider?: boolean,
legendPosition?: "left" | "center" | "right",
yAxisWidth?: number,
) => React.ReactElement = (
{ payload }: any,
categoryColors: Map<string, AvailableChartColorsKeys>,
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
activeLegend: string | undefined,
onClick?: (category: string, color: string) => void,
enableLegendSlider?: boolean,
legendPosition?: "left" | "center" | "right",
yAxisWidth?: number,
) => {
const legendRef: React.RefObject<HTMLDivElement> =
React.useRef<HTMLDivElement>(null);
useOnWindowResize(() => {
const calculateHeight: (height: number | undefined) => number = (
height: number | undefined,
): number => {
return height ? Number(height) + 15 : 60;
};
setLegendHeight(calculateHeight(legendRef.current?.clientHeight));
});
const filteredPayload: any[] = payload.filter((item: any) => {
return item.type !== "none";
});
const paddingLeft: number =
legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0;
return (
<div
style={{ paddingLeft: paddingLeft }}
ref={legendRef}
className={cx(
"flex items-center",
{ "justify-center": legendPosition === "center" },
{
"justify-start": legendPosition === "left",
},
{ "justify-end": legendPosition === "right" },
)}
>
<Legend
categories={filteredPayload.map((entry: any) => {
return entry.value;
})}
colors={filteredPayload.map((entry: any) => {
return categoryColors.get(entry.value) as AvailableChartColorsKeys;
})}
{...(onClick ? { onClickLegendItem: onClick } : {})}
{...(activeLegend ? { activeLegend: activeLegend } : {})}
{...(enableLegendSlider
? { enableLegendSlider: enableLegendSlider }
: {})}
/>
</div>
);
};
//#region Tooltip
type TooltipProps = Pick<ChartTooltipProps, "active" | "payload" | "label">;
// eslint-disable-next-line react/no-unused-prop-types
type PayloadItem = {
category: string; // eslint-disable-line react/no-unused-prop-types
value: number; // eslint-disable-line react/no-unused-prop-types
index: string; // eslint-disable-line react/no-unused-prop-types
color: AvailableChartColorsKeys; // eslint-disable-line react/no-unused-prop-types
type?: string; // eslint-disable-line react/no-unused-prop-types
payload: any;
};
interface ChartTooltipProps {
active: boolean | undefined;
payload: PayloadItem[];
label: string;
valueFormatter: (value: number) => string;
}
const ChartTooltip: React.FunctionComponent<ChartTooltipProps> = ({
active,
payload,
label,
valueFormatter,
}: ChartTooltipProps): React.ReactElement | null => {
if (active && payload && payload.length) {
return (
<div
className={cx(
// base
"rounded-md border text-sm shadow-md",
// border color
"border-gray-200 dark:border-gray-800",
// background color
"bg-white dark:bg-gray-950",
)}
>
<div className={cx("border-b border-inherit px-4 py-2")}>
<p
className={cx(
// base
"font-medium",
// text color
"text-gray-900 dark:text-gray-50",
)}
>
{label}
</p>
</div>
<div className={cx("space-y-1 px-4 py-2")}>
{payload.map(
({ value, category, color }: PayloadItem, index: number) => {
return (
<div
key={`id-${index}`}
className="flex items-center justify-between space-x-8"
>
<div className="flex items-center space-x-2">
<span
aria-hidden="true"
className={cx(
"size-2 shrink-0 rounded-xs",
getColorClassName(color, "bg"),
)}
/>
<p
className={cx(
// base
"text-right whitespace-nowrap",
// text color
"text-gray-700 dark:text-gray-300",
)}
>
{category}
</p>
</div>
<p
className={cx(
// base
"text-right font-medium whitespace-nowrap tabular-nums",
// text color
"text-gray-900 dark:text-gray-50",
)}
>
{valueFormatter(value)}
</p>
</div>
);
},
)}
</div>
</div>
);
}
return null;
};
//#region BarChart
type BaseEventProps = {
eventType: "category" | "bar";
categoryClicked: string;
[key: string]: number | string;
};
type BarChartEventProps = BaseEventProps | null | undefined;
interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>[];
index: string;
categories: string[];
colors?: AvailableChartColorsKeys[];
valueFormatter?: (value: number) => string;
startEndOnly?: boolean;
showXAxis?: boolean;
showYAxis?: boolean;
showGridLines?: boolean;
yAxisWidth?: number;
intervalType?: "preserveStartEnd" | "equidistantPreserveStart";
showTooltip?: boolean;
showLegend?: boolean;
autoMinValue?: boolean;
minValue?: number;
maxValue?: number;
allowDecimals?: boolean;
onValueChange?: (value: BarChartEventProps) => void;
enableLegendSlider?: boolean;
tickGap?: number;
barCategoryGap?: string | number;
xAxisLabel?: string;
yAxisLabel?: string;
layout?: "vertical" | "horizontal";
type?: "default" | "stacked" | "percent";
legendPosition?: "left" | "center" | "right";
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void;
customTooltip?: React.ComponentType<TooltipProps>;
}
const BarChart: React.ForwardRefExoticComponent<
BarChartProps & React.RefAttributes<HTMLDivElement>
> = React.forwardRef<HTMLDivElement, BarChartProps>(
(
props: BarChartProps,
forwardedRef: React.Ref<HTMLDivElement>,
): React.ReactElement => {
const {
data = [],
categories = [],
index,
colors = AvailableChartColors,
valueFormatter = (value: number) => {
return value.toString();
},
startEndOnly = false,
showXAxis = true,
showYAxis = true,
showGridLines = true,
yAxisWidth = 56,
intervalType = "equidistantPreserveStart",
showTooltip = true,
showLegend = true,
autoMinValue = false,
minValue,
maxValue,
allowDecimals = true,
className,
onValueChange,
enableLegendSlider = false,
barCategoryGap,
tickGap = 5,
xAxisLabel,
yAxisLabel,
layout = "horizontal",
type = "default",
legendPosition = "right",
tooltipCallback,
customTooltip,
...other
} = props;
const CustomTooltip: React.ComponentType<any> | undefined = customTooltip;
const paddingValue: number =
(!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20;
const [legendHeight, setLegendHeight] = React.useState(60);
const [activeLegend, setActiveLegend] = React.useState<string | undefined>(
undefined,
);
const categoryColors: Map<string, AvailableChartColorsKeys> =
constructCategoryColors(categories, colors);
const [activeBar, setActiveBar] = React.useState<any | undefined>(
undefined,
);
const yAxisDomain: AxisDomain = getYAxisDomain(
autoMinValue,
minValue,
maxValue,
);
const hasOnValueChange: boolean = Boolean(onValueChange);
const stacked: boolean = type === "stacked" || type === "percent";
const prevActiveRef: React.MutableRefObject<boolean | undefined> =
React.useRef<boolean | undefined>(undefined);
const prevLabelRef: React.MutableRefObject<string | undefined> =
React.useRef<string | undefined>(undefined);
function valueToPercent(value: number): string {
return `${(value * 100).toFixed(0)}%`;
}
const onBarClick: (data: any, _: any, event: React.MouseEvent) => void =
React.useCallback(
(data: any, _: any, event: React.MouseEvent): void => {
event.stopPropagation();
if (!onValueChange) {
return;
}
if (deepEqual(activeBar, { ...data.payload, value: data.value })) {
setActiveLegend(undefined);
setActiveBar(undefined);
onValueChange?.(null);
} else {
setActiveLegend(data.tooltipPayload?.[0]?.dataKey);
setActiveBar({
...data.payload,
value: data.value,
});
onValueChange?.({
eventType: "bar",
categoryClicked: data.tooltipPayload?.[0]?.dataKey,
...data.payload,
});
}
},
[activeBar, onValueChange, setActiveLegend, setActiveBar],
);
function onCategoryClick(dataKey: string): void {
if (!hasOnValueChange) {
return;
}
if (dataKey === activeLegend && !activeBar) {
setActiveLegend(undefined);
onValueChange?.(null);
} else {
setActiveLegend(dataKey);
onValueChange?.({
eventType: "category",
categoryClicked: dataKey,
});
}
setActiveBar(undefined);
}
const shapeRenderer: (props: any) => React.ReactElement = (
props: any,
): React.ReactElement => {
return renderShape(props, activeBar, activeLegend, layout);
};
const handleChartClick: () => void = (): void => {
setActiveBar(undefined);
setActiveLegend(undefined);
onValueChange?.(null);
};
return (
<div
ref={forwardedRef}
className={cx("h-80 w-full", className)}
data-tremor-id="tremor-raw"
{...other}
>
<ResponsiveContainer>
<RechartsBarChart
data={data}
{...(hasOnValueChange && (activeLegend || activeBar)
? {
onClick: handleChartClick,
}
: {})}
margin={{
bottom: xAxisLabel ? 30 : 0,
left: yAxisLabel ? 20 : 0,
right: yAxisLabel ? 5 : 0,
top: 5,
}}
stackOffset={type === "percent" ? "expand" : "none"}
layout={layout}
barCategoryGap={barCategoryGap ?? "10%"}
>
{showGridLines ? (
<CartesianGrid
className={cx("stroke-gray-200 stroke-1 dark:stroke-gray-800")}
horizontal={layout !== "vertical"}
vertical={layout === "vertical"}
/>
) : null}
<XAxis
hide={!showXAxis}
tick={{
transform:
layout !== "vertical" ? "translate(0, 6)" : undefined,
}}
fill=""
stroke=""
className={cx(
// base
"text-xs",
// text fill
"fill-gray-500 dark:fill-gray-500",
{ "mt-4": layout !== "vertical" },
)}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
{...(layout !== "vertical"
? {
padding: {
left: paddingValue,
right: paddingValue,
},
dataKey: index,
interval: startEndOnly ? "preserveStartEnd" : intervalType,
...(startEndOnly && data.length > 0
? {
ticks: [
data[0]?.[index],
data[data.length - 1]?.[index],
].filter(Boolean),
}
: {}),
}
: {
type: "number" as const,
domain: yAxisDomain as AxisDomain,
tickFormatter:
type === "percent" ? valueToPercent : valueFormatter,
allowDecimals: allowDecimals,
})}
>
{xAxisLabel && (
<Label
position="insideBottom"
offset={-20}
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
>
{xAxisLabel}
</Label>
)}
</XAxis>
<YAxis
width={yAxisWidth}
hide={!showYAxis}
axisLine={false}
tickLine={false}
fill=""
stroke=""
className={cx(
// base
"text-xs",
// text fill
"fill-gray-500 dark:fill-gray-500",
)}
tick={{
transform:
layout !== "vertical"
? "translate(-3, 0)"
: "translate(0, 0)",
}}
{...(layout !== "vertical"
? {
type: "number" as const,
domain: yAxisDomain as AxisDomain,
tickFormatter:
type === "percent" ? valueToPercent : valueFormatter,
allowDecimals: allowDecimals,
}
: {
dataKey: index,
...(startEndOnly && data.length > 0
? {
ticks: [
data[0]?.[index],
data[data.length - 1]?.[index],
].filter(Boolean),
}
: {}),
type: "category" as const,
interval: "equidistantPreserveStart" as const,
})}
>
{yAxisLabel && (
<Label
position="insideLeft"
style={{ textAnchor: "middle" }}
angle={-90}
offset={-15}
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
>
{yAxisLabel}
</Label>
)}
</YAxis>
<Tooltip
wrapperStyle={{ outline: "none" }}
isAnimationActive={true}
animationDuration={100}
cursor={{ fill: "#d1d5db", opacity: "0.15" }}
offset={20}
{...(layout === "horizontal"
? { position: { y: 0 } }
: { position: { x: yAxisWidth + 20 } })}
content={({ active, payload, label }: any) => {
const cleanPayload: TooltipProps["payload"] = payload
? payload.map((item: any) => {
return {
category: item.dataKey,
value: item.value,
index: item.payload[index],
color: categoryColors.get(
item.dataKey,
) as AvailableChartColorsKeys,
type: item.type,
payload: item.payload,
};
})
: [];
if (
tooltipCallback &&
(active !== prevActiveRef.current ||
label !== prevLabelRef.current)
) {
tooltipCallback({ active, payload: cleanPayload, label });
prevActiveRef.current = active;
prevLabelRef.current = label;
}
return showTooltip && active ? (
CustomTooltip ? (
<CustomTooltip
active={active}
payload={cleanPayload}
label={label}
/>
) : (
<ChartTooltip
active={active}
payload={cleanPayload}
label={label}
valueFormatter={valueFormatter}
/>
)
) : null;
}}
/>
{showLegend ? (
<RechartsLegend
verticalAlign="top"
height={legendHeight}
content={({ payload }: any) => {
return ChartLegend(
{ payload },
categoryColors,
setLegendHeight,
activeLegend,
hasOnValueChange
? (clickedLegendItem: string): void => {
return onCategoryClick(clickedLegendItem);
}
: undefined,
enableLegendSlider,
legendPosition,
yAxisWidth,
);
}}
/>
) : null}
{categories.map((category: string) => {
return (
<Bar
className={cx(
getColorClassName(
categoryColors.get(category) as AvailableChartColorsKeys,
"fill",
),
onValueChange ? "cursor-pointer" : "",
)}
key={category}
name={category}
type="linear"
dataKey={category}
{...(stacked ? { stackId: "stack" } : {})}
isAnimationActive={false}
fill=""
shape={shapeRenderer}
onClick={onBarClick}
/>
);
})}
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
},
);
BarChart.displayName = "BarChart";
export { BarChart, type BarChartEventProps, type TooltipProps };

View File

@@ -0,0 +1,367 @@
// Tremor Spark Chart [v1.0.0]
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React from "react";
import {
Area,
Bar,
Line,
AreaChart as RechartsAreaChart,
BarChart as RechartsBarChart,
LineChart as RechartsLineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import type { AxisDomain } from "recharts/types/util/types";
import {
AvailableChartColors,
type AvailableChartColorsKeys,
constructCategoryColors,
getColorClassName,
} from "../Utils/ChartColors";
import { cx } from "../Utils/Cx";
import { getYAxisDomain } from "../Utils/GetYAxisDomain";
//#region SparkAreaChart
interface SparkAreaChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>[];
categories: string[];
index: string;
colors?: AvailableChartColorsKeys[];
autoMinValue?: boolean;
minValue?: number;
maxValue?: number;
connectNulls?: boolean;
type?: "default" | "stacked" | "percent";
fill?: "gradient" | "solid" | "none";
}
const SparkAreaChart: React.ForwardRefExoticComponent<
SparkAreaChartProps & React.RefAttributes<HTMLDivElement>
> = React.forwardRef<HTMLDivElement, SparkAreaChartProps>(
(
props: SparkAreaChartProps,
forwardedRef: React.Ref<HTMLDivElement>,
): React.ReactElement => {
const {
data = [],
categories = [],
index,
colors = AvailableChartColors,
autoMinValue = false,
minValue,
maxValue,
connectNulls = false,
type = "default",
className,
fill = "gradient",
...other
} = props;
const categoryColors: Map<string, AvailableChartColorsKeys> =
constructCategoryColors(categories, colors);
const yAxisDomain: AxisDomain = getYAxisDomain(
autoMinValue,
minValue,
maxValue,
);
const stacked: boolean = type === "stacked" || type === "percent";
const areaId: string = React.useId();
const getFillContent: (
fillType: SparkAreaChartProps["fill"],
) => React.ReactElement = (
fillType: SparkAreaChartProps["fill"],
): React.ReactElement => {
switch (fillType) {
case "none":
return <stop stopColor="currentColor" stopOpacity={0} />;
case "gradient":
return (
<>
<stop offset="5%" stopColor="currentColor" stopOpacity={0.4} />
<stop offset="95%" stopColor="currentColor" stopOpacity={0} />
</>
);
case "solid":
return <stop stopColor="currentColor" stopOpacity={0.3} />;
default:
return <stop stopColor="currentColor" stopOpacity={0.3} />;
}
};
return (
<div
ref={forwardedRef}
className={cx("h-12 w-28", className)}
data-tremor-id="tremor-raw"
{...other}
>
<ResponsiveContainer>
<RechartsAreaChart
data={data}
margin={{
bottom: 1,
left: 1,
right: 1,
top: 1,
}}
{...(type === "percent" && { stackOffset: "expand" })}
>
<XAxis hide dataKey={index} />
<YAxis hide={true} domain={yAxisDomain as AxisDomain} />
{categories.map((category: string) => {
const categoryId: string = `${areaId}-${category.replace(/[^a-zA-Z0-9]/g, "")}`;
return (
<React.Fragment key={category}>
<defs>
<linearGradient
key={category}
className={cx(
getColorClassName(
categoryColors.get(
category,
) as AvailableChartColorsKeys,
"text",
),
)}
id={categoryId}
x1="0"
y1="0"
x2="0"
y2="1"
>
{getFillContent(fill)}
</linearGradient>
</defs>
<Area
className={cx(
getColorClassName(
categoryColors.get(
category,
) as AvailableChartColorsKeys,
"stroke",
),
)}
dot={false}
strokeOpacity={1}
name={category}
type="linear"
dataKey={category}
stroke=""
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
isAnimationActive={false}
connectNulls={connectNulls}
{...(stacked && { stackId: "stack" })}
fill={`url(#${categoryId})`}
/>
</React.Fragment>
);
})}
</RechartsAreaChart>
</ResponsiveContainer>
</div>
);
},
);
SparkAreaChart.displayName = "SparkAreaChart";
//#region SparkLineChart
interface SparkLineChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>[];
categories: string[];
index: string;
colors?: AvailableChartColorsKeys[];
autoMinValue?: boolean;
minValue?: number;
maxValue?: number;
connectNulls?: boolean;
}
const SparkLineChart: React.ForwardRefExoticComponent<
SparkLineChartProps & React.RefAttributes<HTMLDivElement>
> = React.forwardRef<HTMLDivElement, SparkLineChartProps>(
(
props: SparkLineChartProps,
forwardedRef: React.Ref<HTMLDivElement>,
): React.ReactElement => {
const {
data = [],
categories = [],
index,
colors = AvailableChartColors,
autoMinValue = false,
minValue,
maxValue,
connectNulls = false,
className,
...other
} = props;
const categoryColors: Map<string, AvailableChartColorsKeys> =
constructCategoryColors(categories, colors);
const yAxisDomain: AxisDomain = getYAxisDomain(
autoMinValue,
minValue,
maxValue,
);
return (
<div
ref={forwardedRef}
className={cx("h-12 w-28", className)}
data-tremor-id="tremor-raw"
{...other}
>
<ResponsiveContainer>
<RechartsLineChart
data={data}
margin={{
bottom: 1,
left: 1,
right: 1,
top: 1,
}}
>
<XAxis hide dataKey={index} />
<YAxis hide={true} domain={yAxisDomain as AxisDomain} />
{categories.map((category: string) => {
return (
<Line
className={cx(
getColorClassName(
categoryColors.get(category) as AvailableChartColorsKeys,
"stroke",
),
)}
dot={false}
strokeOpacity={1}
key={category}
name={category}
type="linear"
dataKey={category}
stroke=""
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
isAnimationActive={false}
connectNulls={connectNulls}
/>
);
})}
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
},
);
SparkLineChart.displayName = "SparkLineChart";
//#region SparkBarChart
interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: Record<string, any>[];
index: string;
categories: string[];
colors?: AvailableChartColorsKeys[];
autoMinValue?: boolean;
minValue?: number;
maxValue?: number;
barCategoryGap?: string | number;
type?: "default" | "stacked" | "percent";
}
const SparkBarChart: React.ForwardRefExoticComponent<
BarChartProps & React.RefAttributes<HTMLDivElement>
> = React.forwardRef<HTMLDivElement, BarChartProps>(
(
props: BarChartProps,
forwardedRef: React.Ref<HTMLDivElement>,
): React.ReactElement => {
const {
data = [],
categories = [],
index,
colors = AvailableChartColors,
autoMinValue = false,
minValue,
maxValue,
barCategoryGap,
type = "default",
className,
...other
} = props;
const categoryColors: Map<string, AvailableChartColorsKeys> =
constructCategoryColors(categories, colors);
const yAxisDomain: AxisDomain = getYAxisDomain(
autoMinValue,
minValue,
maxValue,
);
const stacked: boolean = type === "stacked" || type === "percent";
return (
<div
ref={forwardedRef}
className={cx("h-12 w-28", className)}
data-tremor-id="tremor-raw"
{...other}
>
<ResponsiveContainer>
<RechartsBarChart
data={data}
margin={{
bottom: 1,
left: 1,
right: 1,
top: 1,
}}
{...(type === "percent" && { stackOffset: "expand" })}
{...(barCategoryGap !== undefined && { barCategoryGap })}
>
<XAxis hide dataKey={index} />
<YAxis hide={true} domain={yAxisDomain as AxisDomain} />
{categories.map((category: string) => {
return (
<Bar
className={cx(
getColorClassName(
categoryColors.get(category) as AvailableChartColorsKeys,
"fill",
),
)}
key={category}
name={category}
type="linear"
dataKey={category}
{...(stacked && { stackId: "stack" })}
isAnimationActive={false}
fill=""
/>
);
})}
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
},
);
SparkBarChart.displayName = "SparkBarChart";
export { SparkAreaChart, SparkLineChart, SparkBarChart };

View File

@@ -4,11 +4,11 @@ export const getYAxisDomain: (
autoMinValue: boolean,
minValue: number | undefined,
maxValue: number | undefined,
) => (number | "auto")[] = (
) => [number | "auto", number | "auto"] = (
autoMinValue: boolean,
minValue: number | undefined,
maxValue: number | undefined,
): (number | "auto")[] => {
): [number | "auto", number | "auto"] => {
const minDomain: number | "auto" = autoMinValue ? "auto" : minValue ?? 0;
const maxDomain: number | "auto" = maxValue ?? "auto";
return [minDomain, maxDomain];

View File

@@ -1,4 +1,4 @@
import Icon from "../Icon/Icon";
import IconText from "../IconText/IconText";
import { Green, Red } from "../../../Types/BrandColors";
import IconProp from "../../../Types/Icon/IconProp";
import React, { FunctionComponent, ReactElement } from "react";
@@ -12,23 +12,12 @@ const CheckboxViewer: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<div>
<div className="flex">
<div className="h-6 w-6">
{props.isChecked ? (
<Icon
className="h-5 w-5"
icon={IconProp.CheckCircle}
color={Green}
/>
) : (
<Icon className="h-5 w-5" icon={IconProp.CircleClose} color={Red} />
)}
</div>
<div className="text-sm text-gray-900 flex justify-left">
{props.text}
</div>
</div>
<div className="h-6">
<IconText
text={props.text}
icon={props.isChecked ? IconProp.CheckCircle : IconProp.CircleClose}
iconColor={props.isChecked ? Green : Red}
/>
</div>
);
};

View File

@@ -0,0 +1,91 @@
import Icon, { SizeProp } from "../Icon/Icon";
import Color from "../../../Types/Color";
import IconProp from "../../../Types/Icon/IconProp";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
text: string;
icon: IconProp;
iconColor?: Color | null;
textColor?: Color | null;
iconSize?: SizeProp;
iconClassName?: string;
textClassName?: string;
containerClassName?: string;
spacing?: "sm" | "md" | "lg";
alignment?: "left" | "center" | "right";
onClick?: (() => void) | undefined;
"data-testid"?: string;
}
const IconText: FunctionComponent<ComponentProps> = ({
text,
icon,
iconColor = null,
textColor = null,
iconSize = SizeProp.Regular,
iconClassName = "h-5 w-5",
textClassName = "text-sm text-gray-900",
containerClassName = "",
spacing = "sm",
alignment = "left",
onClick,
"data-testid": dataTestId,
}: ComponentProps): ReactElement => {
const getSpacingClass: () => string = (): string => {
switch (spacing) {
case "sm":
return "gap-1";
case "md":
return "gap-2";
case "lg":
return "gap-3";
default:
return "gap-1";
}
};
const getAlignmentClass: () => string = (): string => {
switch (alignment) {
case "center":
return "justify-center";
case "right":
return "justify-end";
case "left":
default:
return "justify-start";
}
};
const handleClick: () => void = (): void => {
if (onClick) {
onClick();
}
};
return (
<div
className={`flex items-center ${getSpacingClass()} ${getAlignmentClass()} ${containerClassName}`}
onClick={handleClick}
data-testid={dataTestId}
style={{ cursor: onClick ? "pointer" : "default" }}
>
<div className="flex-shrink-0">
<Icon
className={iconClassName}
icon={icon}
color={iconColor}
size={iconSize}
/>
</div>
<div
className={`flex ${getAlignmentClass()} ${textClassName}`}
style={{ color: textColor?.toString() || "inherit" }}
>
{text}
</div>
</div>
);
};
export default IconText;

View File

@@ -0,0 +1,3 @@
import IconText from "./IconText";
export default IconText;

View File

@@ -44,7 +44,7 @@ const ConfirmModal: FunctionComponent<ComponentProps> = (
>
<div
data-testid="confirm-modal-description"
className="text-gray-500 mt-5 text-sm"
className="text-gray-500 mt-5 text-sm text-wrap"
>
{props.description}
</div>

View File

@@ -0,0 +1,221 @@
import {
Blue500,
Gray500,
Green500,
Red500,
Yellow500,
} from "Common/Types/BrandColors";
import Color from "Common/Types/Color";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import IconText from "Common/UI/Components/IconText/IconText";
import Button, {
ButtonStyleType,
ButtonSize,
} from "Common/UI/Components/Button/Button";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import IconProp from "Common/Types/Icon/IconProp";
import React, { FunctionComponent, ReactElement, useState } from "react";
export interface ComponentProps {
status?: StatusPageSubscriberNotificationStatus | undefined | null;
subscriberNotificationStatusMessage?: string | undefined | null;
className?: string;
onResendNotification?: (() => void) | undefined;
}
/**
* Utility function to get status info for notification status
* @param status - The notification status
* @returns Object with color, tailwindColor, text, and icon for the status
*/
export const getNotificationStatusInfo: (
status?: StatusPageSubscriberNotificationStatus | undefined | null,
) => {
color: string;
tailwindColor: string;
text: string;
icon: IconProp;
} = (
status?: StatusPageSubscriberNotificationStatus | undefined | null,
): {
color: string;
tailwindColor: string;
text: string;
icon: IconProp;
} => {
if (!status || status === StatusPageSubscriberNotificationStatus.Skipped) {
return {
color: "gray",
tailwindColor: "gray",
text: "Notifications skipped.",
icon: IconProp.CircleClose,
};
}
if (status === StatusPageSubscriberNotificationStatus.Pending) {
return {
color: "yellow",
tailwindColor: "yellow",
text: "Sending Soon",
icon: IconProp.Clock,
};
}
if (status === StatusPageSubscriberNotificationStatus.InProgress) {
return {
color: "blue",
tailwindColor: "blue",
text: "Notifications Being Sent",
icon: IconProp.Info,
};
}
if (status === StatusPageSubscriberNotificationStatus.Success) {
return {
color: "green",
tailwindColor: "green",
text: "Notifications Sent",
icon: IconProp.CheckCircle,
};
}
if (status === StatusPageSubscriberNotificationStatus.Failed) {
return {
color: "red",
tailwindColor: "red",
text: "Failed",
icon: IconProp.Error,
};
}
return {
color: "gray",
tailwindColor: "gray",
text: "Unknown",
icon: IconProp.Info,
};
};
/**
* SubscriberNotificationStatus Component
*
* A reusable component for displaying notification status with consistent styling.
* Uses IconText component for status display and provides a "more" button for detailed messages.
* Shows ConfirmModal with message details and retry button for failed notifications.
*
* @param status - The notification status to display
* @param subscriberNotificationStatusMessage - The detailed status message
* @param className - Additional CSS classes to apply
* @param onResendNotification - Callback function to handle resend notification action
*
* Usage Examples:
*
* // Basic usage
* <SubscriberNotificationStatus status={item.subscriberNotificationStatus} />
*
* // With message and resend callback
* <SubscriberNotificationStatus
* status={item.subscriberNotificationStatus}
* subscriberNotificationStatusMessage={item.subscriberNotificationStatusMessage}
* onResendNotification={() => handleResend(item)}
* />
*/
const SubscriberNotificationStatus: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const {
status,
subscriberNotificationStatusMessage,
className = "",
onResendNotification,
} = props;
const [showModal, setShowModal] = useState<boolean>(false);
const statusInfo: {
color: string;
tailwindColor: string;
text: string;
icon: IconProp;
} = getNotificationStatusInfo(status);
const showResendButton: boolean =
status === StatusPageSubscriberNotificationStatus.Failed &&
Boolean(onResendNotification);
const showMoreButton: boolean = Boolean(
subscriberNotificationStatusMessage &&
(status === StatusPageSubscriberNotificationStatus.Failed ||
status === StatusPageSubscriberNotificationStatus.Skipped),
);
// Color mapping for IconText
const colorMap: Record<string, Color> = {
gray: Gray500,
yellow: Yellow500,
blue: Blue500,
green: Green500,
red: Red500,
};
const iconColor: Color =
colorMap[statusInfo.color as keyof typeof colorMap] || Gray500;
const handleModalConfirm: () => void = (): void => {
if (showResendButton && onResendNotification) {
onResendNotification();
}
setShowModal(false);
};
const handleModalClose: () => void = (): void => {
setShowModal(false);
};
return (
<div className={`flex items-center gap-2 ${className}`}>
<IconText
text={statusInfo.text}
icon={statusInfo.icon}
iconColor={iconColor}
textColor={iconColor}
iconClassName="h-4 w-4"
textClassName="text-sm font-medium"
spacing="sm"
alignment="left"
/>
{showMoreButton && (
<div className="-ml-2 text-gray-500">
<Button
title="more details"
buttonStyle={ButtonStyleType.SECONDARY_LINK}
buttonSize={ButtonSize.Small}
onClick={() => {
return setShowModal(true);
}}
/>
</div>
)}
{showModal && (
<ConfirmModal
title="Notification Status Details"
description={
subscriberNotificationStatusMessage ||
"No additional information available."
}
onClose={showResendButton ? handleModalClose : undefined}
onSubmit={handleModalConfirm}
submitButtonText={showResendButton ? "Retry" : "Close"}
closeButtonText={showResendButton ? "Close" : undefined}
submitButtonType={
showResendButton ? ButtonStyleType.PRIMARY : ButtonStyleType.NORMAL
}
/>
)}
</div>
);
};
export default SubscriberNotificationStatus;

View File

@@ -2,6 +2,7 @@ import ChangeIncidentState from "../../../Components/Incident/ChangeState";
import LabelsElement from "../../../Components/Label/Labels";
import MonitorsElement from "../../../Components/Monitor/Monitors";
import OnCallDutyPoliciesView from "../../../Components/OnCallPolicy/OnCallPolicies";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import PageComponentProps from "../../PageComponentProps";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import { Black } from "Common/Types/BrandColors";
@@ -11,7 +12,6 @@ import BadDataException from "Common/Types/Exception/BadDataException";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
@@ -52,6 +52,7 @@ import ColorSwatch from "Common/Types/ColorSwatch";
import IncidentFeedElement from "../../../Components/Incident/IncidentFeed";
import Monitor from "Common/Models/DatabaseModels/Monitor";
import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
const IncidentView: FunctionComponent<
PageComponentProps
@@ -140,6 +141,32 @@ const IncidentView: FunctionComponent<
setIsLoading(false);
};
const handleResendNotification: () => Promise<void> =
async (): Promise<void> => {
try {
setIsLoading(true);
// Reset the notification status to Pending so the worker can pick it up again
await ModelAPI.updateById({
id: modelId,
modelType: Incident,
data: {
subscriberNotificationStatusOnIncidentCreated:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage:
"Notification queued for resending",
},
});
// Refresh the data to show updated status
await fetchData();
} catch (err) {
setError(BaseAPI.getFriendlyMessage(err));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(BaseAPI.getFriendlyMessage(err));
@@ -360,6 +387,7 @@ const IncidentView: FunctionComponent<
email: true,
profilePictureId: true,
},
subscriberNotificationStatusMessage: true,
},
onBeforeFetch: async (): Promise<JSONObject> => {
// get ack incident.
@@ -530,28 +558,19 @@ const IncidentView: FunctionComponent<
},
{
field: {
shouldStatusPageSubscribersBeNotifiedOnIncidentCreated: true,
subscriberNotificationStatusOnIncidentCreated: true,
},
title: "Notify Status Page Subscribers",
fieldType: FieldType.Boolean,
title: "Subscriber Notification Status",
fieldType: FieldType.Element,
getElement: (item: Incident): ReactElement => {
return (
<div className="">
<CheckboxViewer
isChecked={
item[
"shouldStatusPageSubscribersBeNotifiedOnIncidentCreated"
] as boolean
}
text={
item[
"shouldStatusPageSubscribersBeNotifiedOnIncidentCreated"
]
? "Subscribers Notified"
: "Subscribers Not Notified"
}
/>{" "}
</div>
<SubscriberNotificationStatus
status={item.subscriberNotificationStatusOnIncidentCreated}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={handleResendNotification}
/>
);
},
},

View File

@@ -12,7 +12,6 @@ import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import ProjectUtil from "Common/UI/Utils/Project";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
import BasicFormModal from "Common/UI/Components/FormModal/BasicFormModal";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
@@ -28,6 +27,8 @@ import Navigation from "Common/UI/Utils/Navigation";
import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate";
import IncidentPublicNote from "Common/Models/DatabaseModels/IncidentPublicNote";
import User from "Common/Models/DatabaseModels/User";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import React, {
Fragment,
FunctionComponent,
@@ -49,6 +50,26 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
useState<boolean>(false);
const [initialValuesForIncident, setInitialValuesForIncident] =
useState<JSONObject>({});
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
const handleResendNotification: (
item: IncidentPublicNote,
) => Promise<void> = async (item: IncidentPublicNote): Promise<void> => {
try {
await ModelAPI.updateById({
modelType: IncidentPublicNote,
id: item.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage: null,
},
});
setRefreshToggle(!refreshToggle);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
};
const fetchIncidentNoteTemplate: (id: ObjectID) => Promise<void> = async (
id: ObjectID,
@@ -126,6 +147,7 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
isEditable={true}
createEditModalWidth={ModalWidth.Large}
isViewable={false}
refreshToggle={refreshToggle.toString()}
query={{
incidentId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
@@ -200,6 +222,9 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
showAs={ShowAs.List}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
}}
filters={[
{
field: {
@@ -281,27 +306,22 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
},
{
field: {
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
subscriberNotificationStatusOnNoteCreated: true,
},
title: "",
type: FieldType.Boolean,
colSpan: 2,
title: "Subscriber Notification Status",
type: FieldType.Element,
colSpan: 1,
getElement: (item: IncidentPublicNote): ReactElement => {
return (
<div className="-mt-5">
<CheckboxViewer
isChecked={
item[
"shouldStatusPageSubscribersBeNotifiedOnNoteCreated"
] as boolean
}
text={
item["shouldStatusPageSubscribersBeNotifiedOnNoteCreated"]
? "Status Page Subscribers Notified"
: "Status Page Subscribers Not Notified"
}
/>{" "}
</div>
<SubscriberNotificationStatus
status={item.subscriberNotificationStatusOnNoteCreated}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={() => {
return handleResendNotification(item);
}}
/>
);
},
},

View File

@@ -16,6 +16,9 @@ import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import IncidentState from "Common/Models/DatabaseModels/IncidentState";
import IncidentStateTimeline from "Common/Models/DatabaseModels/IncidentStateTimeline";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import React, {
Fragment,
FunctionComponent,
@@ -31,9 +34,28 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [showViewLogsModal, setShowViewLogsModal] = useState<boolean>(false);
const [logs, setLogs] = useState<string>("");
const [showRootCause, setShowRootCause] = useState<boolean>(false);
const [rootCause, setRootCause] = useState<string>("");
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
const handleResendNotification: (
item: IncidentStateTimeline,
) => Promise<void> = async (item: IncidentStateTimeline): Promise<void> => {
try {
await ModelAPI.updateById({
modelType: IncidentStateTimeline,
id: item.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage: null,
},
});
setRefreshToggle(!refreshToggle);
} catch {
// Error resending notification: handle appropriately
}
};
return (
<Fragment>
@@ -47,6 +69,7 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
isCreateable={true}
isViewable={false}
showViewIdButton={true}
refreshToggle={refreshToggle.toString()}
query={{
incidentId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
@@ -54,6 +77,7 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
selectMoreFields={{
stateChangeLog: true,
rootCause: true,
subscriberNotificationStatusMessage: true,
}}
sortBy="startsAt"
sortOrder={SortOrder.Descending}
@@ -242,10 +266,23 @@ const IncidentViewStateTimeline: FunctionComponent<PageComponentProps> = (
},
{
field: {
shouldStatusPageSubscribersBeNotified: true,
subscriberNotificationStatus: true,
},
title: "Subscriber Notification Status",
type: FieldType.Text,
getElement: (item: IncidentStateTimeline): ReactElement => {
return (
<SubscriberNotificationStatus
status={item.subscriberNotificationStatus}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={() => {
return handleResendNotification(item);
}}
/>
);
},
title: "Subscribers Notified",
type: FieldType.Boolean,
},
]}
/>

View File

@@ -2,13 +2,13 @@ import LabelsElement from "../../../Components/Label/Labels";
import MonitorsElement from "../../../Components/Monitor/Monitors";
import ChangeScheduledMaintenanceState from "../../../Components/ScheduledMaintenance/ChangeState";
import StatusPagesElement from "../../../Components/StatusPage/StatusPagesElement";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import PageComponentProps from "../../PageComponentProps";
import { Black } from "Common/Types/BrandColors";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import Pill from "Common/UI/Components/Pill/Pill";
@@ -20,7 +20,13 @@ import Monitor from "Common/Models/DatabaseModels/Monitor";
import ScheduledMaintenance from "Common/Models/DatabaseModels/ScheduledMaintenance";
import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
import RecurringArrayFieldElement from "Common/UI/Components/Events/RecurringArrayFieldElement";
@@ -33,6 +39,29 @@ const ScheduledMaintenanceView: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
const handleResendNotification: () => Promise<void> =
async (): Promise<void> => {
try {
// Reset the notification status to Pending so the worker can pick it up again
await ModelAPI.updateById({
id: modelId,
modelType: ScheduledMaintenance,
data: {
subscriberNotificationStatusOnEventScheduled:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage:
"Notification queued for resending",
},
});
// Trigger a refresh by toggling the refresh state
setRefreshToggle(!refreshToggle);
} catch {
// Error resending notification: handle appropriately
}
};
return (
<Fragment>
@@ -260,6 +289,7 @@ const ScheduledMaintenanceView: FunctionComponent<
true,
shouldStatusPageSubscribersBeNotifiedWhenEventChangedToEnded: true,
nextSubscriberNotificationBeforeTheEventAt: true,
subscriberNotificationStatusMessage: true,
},
fields: [
{
@@ -396,62 +426,19 @@ const ScheduledMaintenanceView: FunctionComponent<
},
{
field: {
shouldStatusPageSubscribersBeNotifiedOnEventCreated: true,
subscriberNotificationStatusOnEventScheduled: true,
},
title: "Notify Status Page Subscribers",
fieldType: FieldType.Boolean,
title: "Subscriber Notification Status",
fieldType: FieldType.Element,
getElement: (item: ScheduledMaintenance): ReactElement => {
return (
<div>
<div className="">
<CheckboxViewer
isChecked={
item[
"shouldStatusPageSubscribersBeNotifiedOnEventCreated"
] as boolean
}
text={
item[
"shouldStatusPageSubscribersBeNotifiedOnEventCreated"
]
? "Event Created: Notify Subscribers"
: "Event Created: Do Not Notify Subscribers"
}
/>{" "}
</div>
<div className="">
<CheckboxViewer
isChecked={
item[
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToOngoing"
] as boolean
}
text={
item[
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToOngoing"
]
? "Event Ongoing: Notify Subscribers"
: "Event Ongoing: Do Not Notify Subscribers"
}
/>{" "}
</div>
<div className="">
<CheckboxViewer
isChecked={
item[
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToEnded"
] as boolean
}
text={
item[
"shouldStatusPageSubscribersBeNotifiedWhenEventChangedToEnded"
]
? "Event Ended: Notify Subscribers"
: "Event Ended: Do Not Notify Subscribers"
}
/>{" "}
</div>
</div>
<SubscriberNotificationStatus
status={item.subscriberNotificationStatusOnEventScheduled}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={handleResendNotification}
/>
);
},
},

View File

@@ -10,7 +10,6 @@ import IconProp from "Common/Types/Icon/IconProp";
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
import BasicFormModal from "Common/UI/Components/FormModal/BasicFormModal";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
@@ -27,6 +26,8 @@ import ScheduledMaintenanceNoteTemplate from "Common/Models/DatabaseModels/Sched
import ScheduledMaintenancePublicNote from "Common/Models/DatabaseModels/ScheduledMaintenancePublicNote";
import User from "Common/Models/DatabaseModels/User";
import ProjectUtil from "Common/UI/Utils/Project";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import React, {
Fragment,
FunctionComponent,
@@ -53,6 +54,28 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
initialValuesForScheduledMaintenance,
setInitialValuesForScheduledMaintenance,
] = useState<JSONObject>({});
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
const handleResendNotification: (
item: ScheduledMaintenancePublicNote,
) => Promise<void> = async (
item: ScheduledMaintenancePublicNote,
): Promise<void> => {
try {
await ModelAPI.updateById({
modelType: ScheduledMaintenancePublicNote,
id: item.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage: null,
},
});
setRefreshToggle(!refreshToggle);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
};
const fetchScheduledMaintenanceNoteTemplate: (
id: ObjectID,
@@ -135,6 +158,7 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
}
createInitialValues={initialValuesForScheduledMaintenance}
isViewable={false}
refreshToggle={refreshToggle.toString()}
query={{
scheduledMaintenanceId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
@@ -211,6 +235,9 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
showRefreshButton={true}
showAs={ShowAs.List}
viewPageRoute={Navigation.getCurrentRoute()}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
}}
filters={[
{
field: {
@@ -293,29 +320,24 @@ const PublicNote: FunctionComponent<PageComponentProps> = (
},
{
field: {
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
subscriberNotificationStatusOnNoteCreated: true,
},
title: "",
type: FieldType.Boolean,
colSpan: 2,
title: "Subscriber Notification Status",
type: FieldType.Text,
colSpan: 1,
getElement: (
item: ScheduledMaintenancePublicNote,
): ReactElement => {
return (
<div className="-mt-5">
<CheckboxViewer
isChecked={
item[
"shouldStatusPageSubscribersBeNotifiedOnNoteCreated"
] as boolean
}
text={
item["shouldStatusPageSubscribersBeNotifiedOnNoteCreated"]
? "Status Page Subscribers Notified"
: "Status Page Subscribers Not Notified"
}
/>{" "}
</div>
<SubscriberNotificationStatus
status={item.subscriberNotificationStatusOnNoteCreated}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={() => {
return handleResendNotification(item);
}}
/>
);
},
},

View File

@@ -10,7 +10,16 @@ import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import ScheduledMaintenanceState from "Common/Models/DatabaseModels/ScheduledMaintenanceState";
import ScheduledMaintenanceStateTimeline from "Common/Models/DatabaseModels/ScheduledMaintenanceStateTimeline";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import ProjectUtil from "Common/UI/Utils/Project";
@@ -18,6 +27,28 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
const handleResendNotification: (
item: ScheduledMaintenanceStateTimeline,
) => Promise<void> = async (
item: ScheduledMaintenanceStateTimeline,
): Promise<void> => {
try {
await ModelAPI.updateById({
modelType: ScheduledMaintenanceStateTimeline,
id: item.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage: null,
},
});
setRefreshToggle(!refreshToggle);
} catch {
// Error resending notification: handle appropriately
}
};
return (
<Fragment>
@@ -30,6 +61,7 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
isCreateable={true}
showViewIdButton={true}
isViewable={false}
refreshToggle={refreshToggle.toString()}
query={{
scheduledMaintenanceId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
@@ -127,6 +159,9 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
type: FieldType.Boolean,
},
]}
selectMoreFields={{
subscriberNotificationStatusMessage: true,
}}
columns={[
{
field: {
@@ -193,8 +228,44 @@ const ScheduledMaintenanceDelete: FunctionComponent<PageComponentProps> = (
field: {
shouldStatusPageSubscribersBeNotified: true,
},
title: "Subscribers Notified",
title: "Notification Enabled",
type: FieldType.Boolean,
getElement: (
item: ScheduledMaintenanceStateTimeline,
): ReactElement => {
return (
<CheckboxViewer
isChecked={
item.shouldStatusPageSubscribersBeNotified as boolean
}
text={
item.shouldStatusPageSubscribersBeNotified ? "Yes" : "No"
}
/>
);
},
},
{
field: {
subscriberNotificationStatus: true,
},
title: "Subscriber Notification Status",
type: FieldType.Text,
getElement: (
item: ScheduledMaintenanceStateTimeline,
): ReactElement => {
return (
<SubscriberNotificationStatus
status={item.subscriberNotificationStatus}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={() => {
return handleResendNotification(item);
}}
/>
);
},
},
]}
/>

View File

@@ -0,0 +1,393 @@
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import Banner from "Common/UI/Components/Banner/Banner";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import HiddenText from "Common/UI/Components/HiddenText/HiddenText";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import { IDENTITY_URL } from "Common/UI/Config";
import Navigation from "Common/UI/Utils/Navigation";
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
import Team from "Common/Models/DatabaseModels/Team";
import ObjectID from "Common/Types/ObjectID";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import IconProp from "Common/Types/Icon/IconProp";
import Route from "Common/Types/API/Route";
const SCIMPage: FunctionComponent<PageComponentProps> = (
_props: PageComponentProps,
): ReactElement => {
const [showSCIMUrlId, setShowSCIMUrlId] = useState<string>("");
const [currentSCIMConfig, setCurrentSCIMConfig] =
useState<ProjectSCIM | null>(null);
const [refresher, setRefresher] = useState<boolean>(false);
const [resetSCIMId, setResetSCIMId] = useState<string>("");
const [showResetModal, setShowResetModal] = useState<boolean>(false);
const [isResetLoading, setIsResetLoading] = useState<boolean>(false);
const [resetError, setResetError] = useState<string>("");
const [showResetErrorModal, setShowResetErrorModal] =
useState<boolean>(false);
const [showResetSuccessModal, setShowResetSuccessModal] =
useState<boolean>(false);
const [newBearerToken, setNewBearerToken] = useState<string>("");
const resetBearerToken: () => Promise<void> = async (): Promise<void> => {
setIsResetLoading(true);
try {
const newToken: ObjectID = ObjectID.generate();
await ModelAPI.updateById<ProjectSCIM>({
modelType: ProjectSCIM,
id: new ObjectID(resetSCIMId),
data: {
bearerToken: newToken.toString(),
},
});
setNewBearerToken(newToken.toString());
setShowResetModal(false);
setShowResetSuccessModal(true);
setRefresher(!refresher);
} catch (err) {
setResetError(API.getFriendlyMessage(err));
setShowResetErrorModal(true);
setShowResetModal(false);
}
setIsResetLoading(false);
};
return (
<Fragment>
<>
<Banner
openInNewTab={true}
title="Need help with configuring SCIM?"
description="Learn more about SCIM (System for Cross-domain Identity Management) setup and configuration"
link={Route.fromString("/docs/identity/scim")}
hideOnMobile={true}
/>
<ModelTable<ProjectSCIM>
key={refresher.toString()}
modelType={ProjectSCIM}
userPreferencesKey={"project-scim-table"}
query={{
projectId: ProjectUtil.getCurrentProjectId()!,
}}
id="scim-table"
name="Settings > Project SCIM"
isDeleteable={true}
isEditable={true}
isCreateable={true}
cardProps={{
title: "SCIM (System for Cross-domain Identity Management)",
description:
"SCIM is an open standard for automating the exchange of user identity information between identity domains, or IT systems. Use SCIM to automatically provision and deprovision users from your identity provider.",
}}
formSteps={[
{
title: "Basic Info",
id: "basic",
},
{
title: "Configuration",
id: "configuration",
},
{
title: "Teams",
id: "teams",
},
]}
noItemsMessage={"No SCIM configuration found."}
viewPageRoute={Navigation.getCurrentRoute()}
formFields={[
{
field: {
name: true,
},
title: "Name",
fieldType: FormFieldSchemaType.Text,
required: true,
description:
"Friendly name to help you remember this SCIM configuration.",
placeholder: "Okta SCIM",
validation: {
minLength: 2,
},
stepId: "basic",
},
{
field: {
description: true,
},
title: "Description",
fieldType: FormFieldSchemaType.LongText,
required: false,
description: "Optional description for this SCIM configuration.",
placeholder:
"SCIM configuration for automatic user provisioning from Okta",
stepId: "basic",
},
{
field: {
autoProvisionUsers: true,
},
title: "Auto Provision Users",
fieldType: FormFieldSchemaType.Checkbox,
required: false,
description:
"Automatically create users when they are added in your identity provider.",
stepId: "configuration",
},
{
field: {
autoDeprovisionUsers: true,
},
title: "Auto Deprovision Users",
fieldType: FormFieldSchemaType.Checkbox,
required: false,
description:
"Automatically remove users from teams when they are removed from your identity provider.",
stepId: "configuration",
},
{
field: {
teams: true,
},
title: "Default Teams",
fieldType: FormFieldSchemaType.MultiSelectDropdown,
dropdownModal: {
type: Team,
labelField: "name",
valueField: "_id",
},
required: false,
description:
"New users will be automatically added to these teams.",
stepId: "teams",
},
]}
columns={[
{
field: {
name: true,
},
title: "Name",
type: FieldType.Text,
},
{
field: {
autoProvisionUsers: true,
},
title: "Auto Provision",
type: FieldType.Boolean,
},
{
field: {
autoDeprovisionUsers: true,
},
title: "Auto Deprovision",
type: FieldType.Boolean,
},
]}
selectMoreFields={{
bearerToken: true,
createdAt: true,
updatedAt: true,
teams: {
name: true,
_id: true,
},
}}
filters={[
{
field: {
name: true,
},
title: "Name",
type: FieldType.Text,
},
]}
actionButtons={[
{
title: "View SCIM URLs",
buttonStyleType: ButtonStyleType.NORMAL,
onClick: async (
item: ProjectSCIM,
onCompleteAction: () => void,
_onError: (error: Error) => void,
) => {
onCompleteAction();
setCurrentSCIMConfig(item);
setShowSCIMUrlId(item.id?.toString() || "");
},
},
{
title: "Reset Bearer Token",
buttonStyleType: ButtonStyleType.OUTLINE,
icon: IconProp.Refresh,
onClick: async (
item: ProjectSCIM,
onCompleteAction: () => void,
_onError: (error: Error) => void,
) => {
onCompleteAction();
setResetSCIMId(item.id?.toString() || "");
setShowResetModal(true);
},
},
]}
/>
{showSCIMUrlId && currentSCIMConfig && (
<ConfirmModal
title={`SCIM Configuration URLs`}
description={
<div>
<p className="text-gray-500 mb-4">
Use these URLs to configure SCIM in your identity provider:
</p>
<div className="space-y-4">
<div>
<p className="font-medium text-gray-700 mb-1">
SCIM Base URL:
</p>
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}
</code>
<p className="text-xs text-gray-500 mt-1">
Use this as the SCIM endpoint URL in your identity
provider
</p>
</div>
<div>
<p className="font-medium text-gray-700 mb-1">
Service Provider Config URL:
</p>
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}
/ServiceProviderConfig
</code>
</div>
<div>
<p className="font-medium text-gray-700 mb-1">
Users Endpoint:
</p>
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}/Users
</code>
</div>
<div>
<p className="font-medium text-gray-700 mb-1">
Unique identifier field for users:
</p>
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
userName
</code>
<p className="text-xs text-gray-500 mt-1">
Use this field as the unique identifier for users in your
identity provider SCIM configuration
</p>
</div>
<div className="border-t pt-4">
<p className="font-medium text-gray-700 mb-1">
Bearer Token:
</p>
<div className="mb-2">
<HiddenText
text={currentSCIMConfig.bearerToken || ""}
isCopyable={true}
/>
</div>
<p className="text-xs text-gray-500">
Use this bearer token for authentication in your identity
provider SCIM configuration.
</p>
</div>
</div>
</div>
}
submitButtonText={"Close"}
onSubmit={() => {
setShowSCIMUrlId("");
setCurrentSCIMConfig(null);
}}
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
{/* Reset Bearer Token Modals */}
{showResetModal && (
<ConfirmModal
title="Reset Bearer Token"
description="Are you sure you want to reset the Bearer Token? You will need to update your identity provider with the new token."
onSubmit={async () => {
await resetBearerToken();
}}
isLoading={isResetLoading}
onClose={() => {
setShowResetModal(false);
setResetSCIMId("");
}}
submitButtonText="Reset"
submitButtonType={ButtonStyleType.DANGER}
/>
)}
{showResetErrorModal && (
<ConfirmModal
title="Reset Error"
description={resetError}
onSubmit={() => {
setShowResetErrorModal(false);
setResetError("");
setResetSCIMId("");
}}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
{showResetSuccessModal && (
<ConfirmModal
title="New Bearer Token"
description={
<div>
<p className="mb-3">
Your new Bearer Token has been generated:
</p>
<div className="mb-2">
<HiddenText text={newBearerToken} isCopyable={true} />
</div>
<p className="text-sm text-gray-500">
Please update your identity provider with this new token.
</p>
</div>
}
onSubmit={() => {
setShowResetSuccessModal(false);
setNewBearerToken("");
setResetSCIMId("");
}}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
</>
</Fragment>
);
};
export default SCIMPage;

View File

@@ -398,6 +398,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => {
},
icon: IconProp.Lock,
},
{
link: {
title: "SCIM",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SETTINGS_SCIM] as Route,
),
},
icon: IconProp.Refresh,
},
],
},
{

View File

@@ -1,14 +1,21 @@
import StatusPagesElement from "../../Components/StatusPage/StatusPagesElement";
import PageComponentProps from "../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import CheckboxViewer from "Common/UI/Components/Checkbox/CheckboxViewer";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import SubscriberNotificationStatus from "../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import MarkdownUtil from "Common/UI/Utils/Markdown";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
@@ -20,6 +27,29 @@ const AnnouncementView: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
const [refreshToggle, setRefreshToggle] = useState<boolean>(false);
const handleResendNotification: () => Promise<void> =
async (): Promise<void> => {
try {
// Reset the notification status to Pending so the worker can pick it up again
await ModelAPI.updateById({
id: modelId,
modelType: StatusPageAnnouncement,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Pending,
subscriberNotificationStatusMessage:
"Notification queued for resending",
},
});
// Trigger a refresh by toggling the refresh state
setRefreshToggle(!refreshToggle);
} catch {
// Error resending notification: handle appropriately
}
};
return (
<Page
@@ -146,6 +176,9 @@ const AnnouncementView: FunctionComponent<
showDetailsInNumberOfColumns: 2,
modelType: StatusPageAnnouncement,
id: "model-detail-status-page-announcement",
selectMoreFields: {
subscriberNotificationStatusMessage: true,
},
fields: [
{
field: {
@@ -199,21 +232,18 @@ const AnnouncementView: FunctionComponent<
},
{
field: {
shouldStatusPageSubscribersBeNotified: true,
subscriberNotificationStatus: true,
},
title: "Notify Status Page Subscribers",
fieldType: FieldType.Boolean,
title: "Subscriber Notification Status",
fieldType: FieldType.Element,
getElement: (item: StatusPageAnnouncement): ReactElement => {
return (
<CheckboxViewer
isChecked={
item.shouldStatusPageSubscribersBeNotified as boolean
}
text={
item.shouldStatusPageSubscribersBeNotified
? "Notify Subscribers"
: "Do Not Notify Subscribers"
<SubscriberNotificationStatus
status={item.subscriberNotificationStatus}
subscriberNotificationStatusMessage={
item.subscriberNotificationStatusMessage
}
onResendNotification={handleResendNotification}
/>
);
},

View File

@@ -352,7 +352,12 @@ const StatusPageDelete: FunctionComponent<PageComponentProps> = (
<div>
<span>
Custom Domains not enabled for this OneUptime installation.
Please contact your server admin to enable this feature.
Please contact your server admin to enable this feature. To
enable this feature, if you are using Docker compose, the
<b>STATUS_PAGE_CNAME_RECORD</b> environment variable must be
set when starting the OneUptime cluster. If you are using
Helm and Kubernetes then set statusPage.cnameRecord in the
values.yaml file.
</span>
</div>
)

View File

@@ -0,0 +1,362 @@
import PageComponentProps from "../../PageComponentProps";
import { VoidFunction } from "Common/Types/FunctionTypes";
import ObjectID from "Common/Types/ObjectID";
import Banner from "Common/UI/Components/Banner/Banner";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import HiddenText from "Common/UI/Components/HiddenText/HiddenText";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import { IDENTITY_URL } from "Common/UI/Config";
import Navigation from "Common/UI/Utils/Navigation";
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useState,
} from "react";
import IconProp from "Common/Types/Icon/IconProp";
import Route from "Common/Types/API/Route";
const SCIMPage: FunctionComponent<PageComponentProps> = (
_props: PageComponentProps,
): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [showSCIMUrlId, setShowSCIMUrlId] = useState<string>("");
const [currentSCIMConfig, setCurrentSCIMConfig] =
useState<StatusPageSCIM | null>(null);
const [refresher, setRefresher] = useState<boolean>(false);
const [resetSCIMId, setResetSCIMId] = useState<string>("");
const [showResetModal, setShowResetModal] = useState<boolean>(false);
const [isResetLoading, setIsResetLoading] = useState<boolean>(false);
const [resetError, setResetError] = useState<string>("");
const [showResetErrorModal, setShowResetErrorModal] =
useState<boolean>(false);
const [showResetSuccessModal, setShowResetSuccessModal] =
useState<boolean>(false);
const [newBearerToken, setNewBearerToken] = useState<string>("");
const resetBearerToken: () => Promise<void> = async (): Promise<void> => {
setIsResetLoading(true);
try {
const newToken: ObjectID = ObjectID.generate();
await ModelAPI.updateById<StatusPageSCIM>({
modelType: StatusPageSCIM,
id: new ObjectID(resetSCIMId),
data: {
bearerToken: newToken.toString(),
},
});
setNewBearerToken(newToken.toString());
setShowResetModal(false);
setShowResetSuccessModal(true);
setRefresher(!refresher);
} catch (err) {
setResetError(API.getFriendlyMessage(err));
setShowResetErrorModal(true);
setShowResetModal(false);
}
setIsResetLoading(false);
};
return (
<Fragment>
<>
<Banner
openInNewTab={true}
title="Need help with configuring SCIM?"
description="Learn more about SCIM (System for Cross-domain Identity Management) setup and configuration for Status Pages"
link={Route.fromString("/docs/identity/scim")}
hideOnMobile={true}
/>
<ModelTable<StatusPageSCIM>
key={refresher.toString()}
modelType={StatusPageSCIM}
userPreferencesKey={"status-page-scim-table"}
query={{
statusPageId: modelId,
}}
id="status-page-scim-table"
name="Status Page > SCIM"
isDeleteable={true}
isEditable={true}
isCreateable={true}
cardProps={{
title: "SCIM (System for Cross-domain Identity Management)",
description:
"SCIM is an open standard for automating the exchange of user identity information between identity domains, or IT systems. Use SCIM to automatically provision and deprovision users with access to your private Status Page.",
}}
formSteps={[
{
title: "Basic Info",
id: "basic",
},
{
title: "Configuration",
id: "configuration",
},
]}
onBeforeCreate={(scim: StatusPageSCIM) => {
scim.statusPageId = modelId;
return Promise.resolve(scim);
}}
noItemsMessage={"No SCIM configuration found."}
viewPageRoute={Navigation.getCurrentRoute()}
formFields={[
{
field: {
name: true,
},
title: "Name",
fieldType: FormFieldSchemaType.Text,
required: true,
description:
"Friendly name to help you remember this SCIM configuration.",
placeholder: "Okta SCIM for Status Page",
validation: {
minLength: 2,
},
stepId: "basic",
},
{
field: {
description: true,
},
title: "Description",
fieldType: FormFieldSchemaType.LongText,
required: false,
description: "Optional description for this SCIM configuration.",
placeholder:
"SCIM configuration for automatic user provisioning to the Status Page from Okta",
stepId: "basic",
},
{
field: {
autoProvisionUsers: true,
},
title: "Auto Provision Users",
fieldType: FormFieldSchemaType.Checkbox,
required: false,
description:
"Automatically create users when they are added in your identity provider.",
stepId: "configuration",
},
{
field: {
autoDeprovisionUsers: true,
},
title: "Auto Deprovision Users",
fieldType: FormFieldSchemaType.Checkbox,
required: false,
description:
"Automatically remove users when they are removed from your identity provider.",
stepId: "configuration",
},
]}
showRefreshButton={true}
selectMoreFields={{
bearerToken: true,
}}
filters={[]}
columns={[
{
field: {
name: true,
},
title: "Name",
type: FieldType.Text,
},
{
field: {
autoProvisionUsers: true,
},
title: "Auto Provision Users",
type: FieldType.Boolean,
},
{
field: {
autoDeprovisionUsers: true,
},
title: "Auto Deprovision Users",
type: FieldType.Boolean,
},
]}
actionButtons={[
{
title: "Show SCIM Endpoint URLs",
buttonStyleType: ButtonStyleType.NORMAL,
icon: IconProp.List,
onClick: async (
item: StatusPageSCIM,
onCompleteAction: VoidFunction,
onError: (err: Error) => void,
) => {
try {
setCurrentSCIMConfig(item);
setShowSCIMUrlId(item["_id"] as string);
onCompleteAction();
} catch (err) {
onError(err as Error);
}
},
},
{
title: "Reset Bearer Token",
buttonStyleType: ButtonStyleType.OUTLINE,
icon: IconProp.Refresh,
onClick: async (
item: StatusPageSCIM,
onCompleteAction: VoidFunction,
onError: (err: Error) => void,
) => {
try {
setResetSCIMId(item["_id"] as string);
setShowResetModal(true);
onCompleteAction();
} catch (err) {
onError(err as Error);
}
},
},
]}
/>
{showSCIMUrlId && currentSCIMConfig ? (
<ConfirmModal
title={`SCIM URLs - ${currentSCIMConfig.name}`}
description={
<div>
<p>
Configure your identity provider with these SCIM endpoint
URLs:
</p>
<br />
<div>
<strong>SCIM Base URL:</strong>
<br />
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
{IDENTITY_URL.toString()}/status-page-scim/v2/
{showSCIMUrlId}
</code>
</div>
<br />
<div>
<p className="font-medium text-gray-700 mb-1">
Unique identifier field for users:
</p>
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
userName
</code>
<p className="text-xs text-gray-500 mt-1">
Use this field as the unique identifier for users in your
identity provider SCIM configuration
</p>
</div>
<br />
<div>
<strong>Users Endpoint:</strong>
<br />
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
{IDENTITY_URL.toString()}/status-page-scim/v2/
{showSCIMUrlId}/Users
</code>
</div>
<br />
<div>
<strong>Bearer Token:</strong>
<br />
<HiddenText
text={currentSCIMConfig.bearerToken as string}
isCopyable={true}
/>
</div>
<br />
<p>
<strong>Note:</strong> Make sure to use this bearer token in
the Authorization header when making SCIM API requests.
</p>
</div>
}
submitButtonText={"Close"}
onSubmit={() => {
setShowSCIMUrlId("");
setCurrentSCIMConfig(null);
}}
submitButtonType={ButtonStyleType.NORMAL}
/>
) : (
<></>
)}
{showResetModal ? (
<ConfirmModal
title={"Reset Bearer Token"}
description={
"Are you sure you want to reset the bearer token? This will invalidate the current token and you will need to update your identity provider with the new token."
}
submitButtonText={"Reset"}
onSubmit={resetBearerToken}
isLoading={isResetLoading}
submitButtonType={ButtonStyleType.DANGER}
onClose={() => {
setShowResetModal(false);
}}
/>
) : (
<></>
)}
{showResetErrorModal ? (
<ConfirmModal
title={"Error"}
description={resetError}
submitButtonText={"Close"}
onSubmit={() => {
setShowResetErrorModal(false);
}}
submitButtonType={ButtonStyleType.NORMAL}
/>
) : (
<></>
)}
{showResetSuccessModal ? (
<ConfirmModal
title={"Bearer Token Reset"}
description={
<div>
<p>Bearer token has been reset successfully.</p>
<br />
<div>
<strong>New Bearer Token:</strong>
<br />
<HiddenText text={newBearerToken} isCopyable={true} />
</div>
<br />
<p>
<strong>Important:</strong> Make sure to update your identity
provider with this new bearer token.
</p>
</div>
}
submitButtonText={"Close"}
onSubmit={() => {
setShowResetSuccessModal(false);
setNewBearerToken("");
}}
submitButtonType={ButtonStyleType.NORMAL}
/>
) : (
<></>
)}
</>
</Fragment>
);
};
export default SCIMPage;

View File

@@ -230,6 +230,17 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
icon={IconProp.Lock}
/>
<SideMenuItem
link={{
title: "SCIM",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.STATUS_PAGE_VIEW_SCIM] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Refresh}
/>
<SideMenuItem
link={{
title: "Authentication Settings",

View File

@@ -196,6 +196,12 @@ const SettingsSSO: LazyExoticComponent<FunctionComponent<ComponentProps>> =
lazy(() => {
return import("../Pages/Settings/SSO");
});
const SettingsSCIM: LazyExoticComponent<FunctionComponent<ComponentProps>> =
lazy(() => {
return import("../Pages/Settings/SCIM");
});
const SettingsSmsLog: LazyExoticComponent<FunctionComponent<ComponentProps>> =
lazy(() => {
return import("../Pages/Settings/SmsLog");
@@ -680,6 +686,18 @@ const SettingsRoutes: FunctionComponent<ComponentProps> = (
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.SETTINGS_SCIM)}
element={
<Suspense fallback={Loader}>
<SettingsSCIM
{...props}
pageRoute={RouteMap[PageMap.SETTINGS_SCIM] as Route}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.SETTINGS_INCIDENTS_SEVERITY,

View File

@@ -119,6 +119,11 @@ const StatusPageViewSSO: LazyExoticComponent<
> = lazy(() => {
return import("../Pages/StatusPages/View/SSO");
});
const StatusPageViewSCIM: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/StatusPages/View/SCIM");
});
const StatusPageViewPrivateUser: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
@@ -360,6 +365,18 @@ const StatusPagesRoutes: FunctionComponent<ComponentProps> = (
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.STATUS_PAGE_VIEW_SCIM)}
element={
<Suspense fallback={Loader}>
<StatusPageViewSCIM
{...props}
pageRoute={RouteMap[PageMap.STATUS_PAGE_VIEW_SCIM] as Route}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS,

View File

@@ -211,6 +211,7 @@ enum PageMap {
STATUS_PAGE_VIEW_CUSTOM_FIELDS = "STATUS_PAGE_VIEW_CUSTOM_FIELDS",
STATUS_PAGE_VIEW_REPORTS = "STATUS_PAGE_VIEW_REPORTS",
STATUS_PAGE_VIEW_SSO = "STATUS_PAGE_VIEW_SSO",
STATUS_PAGE_VIEW_SCIM = "STATUS_PAGE_VIEW_SCIM",
STATUS_PAGE_VIEW_OWNERS = "STATUS_PAGE_VIEW_OWNERS",
STATUS_PAGE_VIEW_SETTINGS = "STATUS_PAGE_VIEW_SETTINGS",
@@ -338,6 +339,9 @@ enum PageMap {
// SSO.
SETTINGS_SSO = "SETTINGS_SSO",
// SCIM.
SETTINGS_SCIM = "SETTINGS_SCIM",
// Domains
SETTINGS_DOMAINS = "SETTINGS_DOMAINS",

View File

@@ -136,6 +136,7 @@ export const StatusPagesRoutePath: Dictionary<string> = {
[PageMap.STATUS_PAGE_VIEW_EMBEDDED]: `${RouteParams.ModelID}/embedded`,
[PageMap.STATUS_PAGE_VIEW_SUBSCRIBER_SETTINGS]: `${RouteParams.ModelID}/subscriber-settings`,
[PageMap.STATUS_PAGE_VIEW_SSO]: `${RouteParams.ModelID}/sso`,
[PageMap.STATUS_PAGE_VIEW_SCIM]: `${RouteParams.ModelID}/scim`,
[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]: `${RouteParams.ModelID}/custom-code`,
[PageMap.STATUS_PAGE_VIEW_RESOURCES]: `${RouteParams.ModelID}/resources`,
[PageMap.STATUS_PAGE_VIEW_ADVANCED_OPTIONS]: `${RouteParams.ModelID}/advanced-options`,
@@ -244,6 +245,7 @@ export const SettingsRoutePath: Dictionary<string> = {
[PageMap.SETTINGS_DOMAINS]: "domains",
[PageMap.SETTINGS_FEATURE_FLAGS]: "feature-flags",
[PageMap.SETTINGS_SSO]: "sso",
[PageMap.SETTINGS_SCIM]: "scim",
[PageMap.SETTINGS_TEAMS]: "teams",
[PageMap.SETTINGS_USERS]: "users",
[PageMap.SETTINGS_USER_VIEW]: `users/${RouteParams.ModelID}`,
@@ -1096,6 +1098,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.STATUS_PAGE_VIEW_SCIM]: new Route(
`/dashboard/${RouteParams.ProjectID}/status-pages/${
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_SCIM]
}`,
),
[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]: new Route(
`/dashboard/${RouteParams.ProjectID}/status-pages/${
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]
@@ -1702,6 +1710,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.SETTINGS_SCIM]: new Route(
`/dashboard/${RouteParams.ProjectID}/settings/${
SettingsRoutePath[PageMap.SETTINGS_SCIM]
}`,
),
[PageMap.SETTINGS_TEAMS]: new Route(
`/dashboard/${RouteParams.ProjectID}/settings/${
SettingsRoutePath[PageMap.SETTINGS_TEAMS]

View File

@@ -0,0 +1,136 @@
# SCIM (System for Cross-domain Identity Management)
OneUptime supports SCIM v2.0 protocol for automated user provisioning and deprovisioning. SCIM enables identity providers (IdPs) like Azure AD, Okta, and other enterprise identity systems to automatically manage user access to OneUptime projects and status pages.
## Overview
SCIM integration provides the following benefits:
- **Automated User Provisioning**: Automatically create users in OneUptime when they're assigned in your IdP
- **Automated User Deprovisioning**: Automatically remove users from OneUptime when they're unassigned in your IdP
- **User Attribute Synchronization**: Keep user information synchronized between your IdP and OneUptime
- **Centralized Access Management**: Manage OneUptime access from your existing identity management system
## SCIM for Projects
Project SCIM allows identity providers to manage team members within OneUptime projects.
### Setting Up Project SCIM
1. **Navigate to Project Settings**
- Go to your OneUptime project
- Navigate to **Project Settings** > **Team** > **SCIM**
2. **Configure SCIM Settings**
- Enable **Auto Provision Users** to automatically add users when they're assigned in your IdP
- Enable **Auto Deprovision Users** to automatically remove users when they're unassigned in your IdP
- Select the **Default Teams** that new users should be added to
- Copy the **SCIM Base URL** and **Bearer Token** for your IdP configuration
3. **Configure Your Identity Provider**
- Use the SCIM Base URL: `https://oneuptime.com/scim/v2/{scimId}`
- Configure bearer token authentication with the provided token
- Map user attributes (email is required)
### Project SCIM Endpoints
- **Service Provider Config**: `GET /scim/v2/{scimId}/ServiceProviderConfig`
- **List Users**: `GET /scim/v2/{scimId}/Users`
- **Get User**: `GET /scim/v2/{scimId}/Users/{userId}`
- **Create User**: `POST /scim/v2/{scimId}/Users`
- **Update User**: `PUT /scim/v2/{scimId}/Users/{userId}`
- **Delete User**: `DELETE /scim/v2/{scimId}/Users/{userId}`
### Project SCIM User Lifecycle
1. **User Assignment in IdP**: When a user is assigned to OneUptime in your IdP
2. **SCIM Provisioning**: IdP calls OneUptime SCIM API to create the user
3. **Team Membership**: User is automatically added to configured default teams
4. **Access Granted**: User can now access the OneUptime project
5. **User Unassignment**: When user is unassigned in IdP
6. **SCIM Deprovisioning**: IdP calls OneUptime SCIM API to remove the user
7. **Access Revoked**: User loses access to the project
## SCIM for Status Pages
Status Page SCIM allows identity providers to manage subscribers to private status pages.
### Setting Up Status Page SCIM
1. **Navigate to Status Page Settings**
- Go to your OneUptime status page
- Navigate to **Status Page Settings** > **Private Users** > **SCIM**
2. **Configure SCIM Settings**
- Enable **Auto Provision Users** to automatically add subscribers when they're assigned in your IdP
- Enable **Auto Deprovision Users** to automatically remove subscribers when they're unassigned in your IdP
- Copy the **SCIM Base URL** and **Bearer Token** for your IdP configuration
3. **Configure Your Identity Provider**
- Use the SCIM Base URL: `https://oneuptime.com/status-page-scim/v2/{scimId}`
- Configure bearer token authentication with the provided token
- Map user attributes (email is required)
### Status Page SCIM Endpoints
- **Service Provider Config**: `GET /status-page-scim/v2/{scimId}/ServiceProviderConfig`
- **List Users**: `GET /status-page-scim/v2/{scimId}/Users`
- **Get User**: `GET /status-page-scim/v2/{scimId}/Users/{userId}`
- **Create User**: `POST /status-page-scim/v2/{scimId}/Users`
- **Update User**: `PUT /status-page-scim/v2/{scimId}/Users/{userId}`
- **Delete User**: `DELETE /status-page-scim/v2/{scimId}/Users/{userId}`
### Status Page SCIM User Lifecycle
1. **User Assignment in IdP**: When a user is assigned to OneUptime Status Page in your IdP
2. **SCIM Provisioning**: IdP calls OneUptime SCIM API to create the subscriber
3. **Access Granted**: User can now access the private status page
4. **User Unassignment**: When user is unassigned in IdP
5. **SCIM Deprovisioning**: IdP calls OneUptime SCIM API to remove the subscriber
6. **Access Revoked**: User loses access to the status page
## Identity Provider Configuration
### Azure Active Directory (Azure AD)
1. **Add OneUptime from Azure AD Gallery**
- In Azure AD, go to **Enterprise Applications** > **New Application**
- Search for "OneUptime" or add a **Non-gallery application**
2. **Configure SCIM Settings**
- In the OneUptime application, go to **Provisioning**
- Set **Provisioning Mode** to **Automatic**
- Enter the **Tenant URL** (SCIM Base URL from OneUptime)
- Enter the **Secret Token** (Bearer Token from OneUptime)
- Test the connection and save
3. **Configure Attribute Mappings**
- Map Azure AD attributes to OneUptime SCIM attributes
- Ensure `userPrincipalName` or `mail` is mapped to `userName`
- Configure any additional attribute mappings as needed
4. **Assign Users**
- Go to **Users and groups** and assign users to the OneUptime application
- Users will be automatically provisioned to OneUptime
### Okta
1. **Add OneUptime Application**
- In Okta Admin Console, go to **Applications** > **Add Application**
- Create a **Web** application or use **SCIM 2.0 Test App (Header Auth)**
2. **Configure SCIM Settings**
- In the application settings, go to **Provisioning**
- Set **SCIM connector base URL** to the OneUptime SCIM Base URL
- Set **Unique identifier field for users** to `userName`
- Enter the **Bearer Token** in the authentication header
3. **Configure Attribute Mappings**
- Map Okta user attributes to SCIM attributes
- Ensure `email` is mapped to `userName`
- Configure additional mappings as needed
4. **Assign Users**
- Assign users to the OneUptime application
- Users will be automatically provisioned to OneUptime

View File

@@ -81,6 +81,15 @@ const DocsNav: NavGroup[] = [
},
],
},
{
title: "Identity",
links: [
{
title: "SCIM",
url: "/docs/identity/scim",
},
],
},
{
title: "Terraform Provider",
links: [

View File

@@ -52,7 +52,9 @@ router.post(
Response.sendEmptySuccessResponse(req, res);
// Add to queue for asynchronous processing
await FluentIngestQueueService.addFluentIngestJob(req as TelemetryRequest);
await FluentIngestQueueService.addFluentIngestJob(
req as TelemetryRequest,
);
return;
} catch (err) {
@@ -123,6 +125,7 @@ router.get(
name: string;
data: any;
failedReason: string;
stackTrace?: string;
processedOn: Date | null;
finishedOn: Date | null;
attemptsMade: number;

9
FluentIngest/Config.ts Normal file
View File

@@ -0,0 +1,9 @@
let concurrency: string | number =
process.env["FLUENT_INGEST_CONCURRENCY"] || 100;
if (typeof concurrency === "string") {
const parsed: number = parseInt(concurrency, 10);
concurrency = !isNaN(parsed) && parsed > 0 ? parsed : 100;
}
export const FLUENT_INGEST_CONCURRENCY: number = concurrency as number;

View File

@@ -11,6 +11,7 @@ import LogSeverity from "Common/Types/Log/LogSeverity";
import OTelIngestService from "Common/Server/Services/OpenTelemetryIngestService";
import JSONFunctions from "Common/Types/JSONFunctions";
import Log from "Common/Models/AnalyticsModels/Log";
import { FLUENT_INGEST_CONCURRENCY } from "../../Config";
interface FluentIngestProcessData {
projectId: ObjectID;
@@ -34,16 +35,14 @@ QueueWorker.getWorker(
requestHeaders: jobData.requestHeaders,
});
logger.debug(
`Successfully processed fluent ingestion job: ${job.name}`,
);
logger.debug(`Successfully processed fluent ingestion job: ${job.name}`);
} catch (error) {
logger.error(`Error processing fluent ingestion job:`);
logger.error(error);
throw error;
}
},
{ concurrency: 20 }, // Process up to 20 fluent ingest jobs concurrently
{ concurrency: FLUENT_INGEST_CONCURRENCY },
);
async function processFluentIngestFromQueue(
@@ -55,8 +54,9 @@ async function processFluentIngestFromQueue(
| Array<JSONObject | string>
| JSONObject;
let oneuptimeServiceName: string | string[] | undefined =
data.requestHeaders["x-oneuptime-service-name"] as string | string[] | undefined;
let oneuptimeServiceName: string | string[] | undefined = data.requestHeaders[
"x-oneuptime-service-name"
] as string | string[] | undefined;
if (!oneuptimeServiceName) {
oneuptimeServiceName = "Unknown Service";
@@ -124,7 +124,9 @@ async function processFluentIngestFromQueue(
OTelIngestService.recordDataIngestedUsgaeBilling({
services: {
[oneuptimeServiceName as string]: {
dataIngestedInGB: JSONFunctions.getSizeOfJSONinGB(data.requestBody as JSONObject),
dataIngestedInGB: JSONFunctions.getSizeOfJSONinGB(
data.requestBody as JSONObject,
),
dataRententionInDays: telemetryService.dataRententionInDays,
serviceId: telemetryService.serviceId,
serviceName: oneuptimeServiceName as string,

View File

@@ -12,9 +12,7 @@ export interface FluentIngestJobData {
}
export default class FluentIngestQueueService {
public static async addFluentIngestJob(
req: TelemetryRequest,
): Promise<void> {
public static async addFluentIngestJob(req: TelemetryRequest): Promise<void> {
try {
const jobData: FluentIngestJobData = {
projectId: req.projectId.toString(),
@@ -64,6 +62,7 @@ export default class FluentIngestQueueService {
name: string;
data: JSONObject;
failedReason: string;
stackTrace?: string;
processedOn: Date | null;
finishedOn: Date | null;
attemptsMade: number;

View File

@@ -4,7 +4,9 @@ FROM fluentd
USER root
# Install bash and curl.
RUN apk add bash curl
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash curl \
&& rm -rf /var/lib/apt/lists/*
EXPOSE 24224
EXPOSE 8888

View File

@@ -98,39 +98,39 @@ Usage:
value: {{ $.Release.Name }}-docs.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }}
- name: APP_PORT
value: {{ $.Values.port.app | squote }}
value: {{ $.Values.app.ports.http | squote }}
- name: PROBE_INGEST_PORT
value: {{ $.Values.port.probeIngest | squote }}
value: {{ $.Values.probeIngest.ports.http | squote }}
- name: SERVER_MONITOR_INGEST_PORT
value: {{ $.Values.port.serverMonitorIngest | squote }}
value: {{ $.Values.serverMonitorIngest.ports.http | squote }}
- name: OPEN_TELEMETRY_INGEST_PORT
value: {{ $.Values.port.openTelemetryIngest | squote }}
value: {{ $.Values.openTelemetryIngest.ports.http | squote }}
- name: INCOMING_REQUEST_INGEST_PORT
value: {{ $.Values.port.incomingRequestIngest | squote }}
value: {{ $.Values.incomingRequestIngest.ports.http | squote }}
- name: FLUENT_INGEST_PORT
value: {{ $.Values.port.fluentIngest | squote }}
value: {{ $.Values.fluentIngest.ports.http | squote }}
- name: TEST_SERVER_PORT
value: {{ $.Values.port.testServer | squote }}
value: {{ $.Values.testServer.ports.http | squote }}
- name: ACCOUNTS_PORT
value: {{ $.Values.port.accounts | squote }}
value: {{ $.Values.accounts.ports.http | squote }}
- name: ISOLATED_VM_PORT
value: {{ $.Values.port.isolatedVM | squote }}
value: {{ $.Values.isolatedVM.ports.http | squote }}
- name: HOME_PORT
value: {{ $.Values.port.home | squote }}
value: {{ $.Values.home.ports.http | squote }}
- name: WORKER_PORT
value: {{ $.Values.port.worker | squote }}
value: {{ $.Values.worker.ports.http | squote }}
- name: WORKFLOW_PORT
value: {{ $.Values.port.workflow | squote }}
value: {{ $.Values.workflow.ports.http | squote }}
- name: STATUS_PAGE_PORT
value: {{ $.Values.port.statusPage | squote }}
value: {{ $.Values.statusPage.ports.http | squote }}
- name: DASHBOARD_PORT
value: {{ $.Values.port.dashboard | squote }}
value: {{ $.Values.dashboard.ports.http | squote }}
- name: ADMIN_DASHBOARD_PORT
value: {{ $.Values.port.adminDashboard | squote }}
value: {{ $.Values.adminDashboard.ports.http | squote }}
- name: API_REFERENCE_PORT
value: {{ $.Values.port.apiReference | squote }}
value: {{ $.Values.apiReference.ports.http | squote }}
- name: DOCS_PORT
value: {{ $.Values.port.docs | squote }}
value: {{ $.Values.docs.ports.http | squote }}
{{- end }}
@@ -724,6 +724,29 @@ spec:
maxReplicaCount: {{ .MetricsConfig.maxReplicas }}
pollingInterval: {{ .MetricsConfig.pollingInterval }}
cooldownPeriod: {{ .MetricsConfig.cooldownPeriod }}
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 120
- type: Pods
value: 2
periodSeconds: 120
selectPolicy: Min
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Percent
value: 10
periodSeconds: 180
- type: Pods
value: 1
periodSeconds: 180
selectPolicy: Min
triggers:
{{- range .MetricsConfig.triggers }}
- type: metrics-api

View File

@@ -1,12 +1,12 @@
# OneUptime accounts Deployment
{{- $accountsEnv := dict "PORT" $.Values.port.accounts "DISABLE_TELEMETRY" $.Values.accounts.disableTelemetryCollection -}}
{{- $accountsPorts := dict "port" $.Values.port.accounts -}}
{{- $accountsEnv := dict "PORT" $.Values.accounts.ports.http "DISABLE_TELEMETRY" $.Values.accounts.disableTelemetryCollection -}}
{{- $accountsPorts := $.Values.accounts.ports -}}
{{- $accountsDeploymentArgs :=dict "IsUI" true "ServiceName" "accounts" "Ports" $accountsPorts "Release" $.Release "Values" $.Values "Env" $accountsEnv "Resources" $.Values.accounts.resources "DisableAutoscaler" $.Values.accounts.disableAutoscaler "ReplicaCount" $.Values.accounts.replicaCount -}}
{{- include "oneuptime.deployment" $accountsDeploymentArgs }}
---
# OneUptime accounts Service
{{- $accountsPorts := dict "port" $.Values.port.accounts -}}
{{- $accountsPorts := $.Values.accounts.ports -}}
{{- $accountsServiceArgs := dict "ServiceName" "accounts" "Ports" $accountsPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $accountsServiceArgs }}
---

View File

@@ -1,12 +1,12 @@
# OneUptime adminDashboard Deployment
{{- $adminDashboardEnv := dict "PORT" $.Values.port.adminDashboard "DISABLE_TELEMETRY" $.Values.adminDashboard.disableTelemetryCollection -}}
{{- $adminDashboardPorts := dict "port" $.Values.port.adminDashboard -}}
{{- $adminDashboardDeploymentArgs :=dict "IsUI" true "ServiceName" "admin-dashboard" "Ports" $adminDashboardPorts "Release" $.Release "Values" $.Values "Env" $adminDashboardEnv "Resources" $.Values.adminDashboard.resources "DisableAutoscaler" $.Values.adminDashboard.disableAutoscaler "ReplicaCount" $.Values.adminDashboard.replicaCount -}}
# OneUptime admin-dashboard Deployment
{{- $adminDashboardEnv := dict "PORT" $.Values.adminDashboard.ports.http "DISABLE_TELEMETRY" $.Values.adminDashboard.disableTelemetryCollection -}}
{{- $adminDashboardPorts := $.Values.adminDashboard.ports -}}
{{- $adminDashboardDeploymentArgs :=dict "IsUI" true "ServiceName" "admin-dashboard" "Ports" $adminDashboardPorts "Release" $.Release "Values" $.Values "Env" $adminDashboardEnv "Resources" $.Values.adminDashboard.resources "DisableAutoscaler" $.Values.adminDashboard.disableAutoscaler "ReplicaCount" $.Values.adminDashboard.replicaCount -}}
{{- include "oneuptime.deployment" $adminDashboardDeploymentArgs }}
---
# OneUptime adminDashboard Service
{{- $adminDashboardPorts := dict "port" $.Values.port.adminDashboard -}}
# OneUptime admin-dashboard Service
{{- $adminDashboardPorts := $.Values.adminDashboard.ports -}}
{{- $adminDashboardServiceArgs := dict "ServiceName" "admin-dashboard" "Ports" $adminDashboardPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $adminDashboardServiceArgs }}
---

View File

@@ -52,7 +52,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.apiReference }}
port: {{ $.Values.apiReference.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -61,7 +61,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.apiReference }}
port: {{ $.Values.apiReference.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -71,7 +71,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.apiReference }}
port: {{ $.Values.apiReference.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -85,11 +85,11 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.apiReference | quote }}
value: {{ $.Values.apiReference.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.apiReference.disableTelemetryCollection | quote }}
ports:
- containerPort: {{ $.Values.port.apiReference }}
- containerPort: {{ $.Values.apiReference.ports.http }}
protocol: TCP
name: http
{{- if $.Values.apiReference.resources }}
@@ -101,7 +101,8 @@ spec:
---
# OneUptime app Service
{{- $apiReferencePorts := dict "port" $.Values.port.apiReference -}}
# OneUptime apiReference Service
{{- $apiReferencePorts := dict "port" $.Values.apiReference.ports.http -}}
{{- $apiReferenceServiceArgs := dict "ServiceName" "api-reference" "Ports" $apiReferencePorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $apiReferenceServiceArgs }}
---

View File

@@ -53,7 +53,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.app }}
port: {{ $.Values.app.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -62,7 +62,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.app }}
port: {{ $.Values.app.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -72,7 +72,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.app }}
port: {{ $.Values.app.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -86,7 +86,7 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.app | quote }}
value: {{ $.Values.app.ports.http | quote }}
- name: SMS_HIGH_RISK_COST_IN_CENTS
value: {{ $.Values.billing.smsHighRiskValueInCents | quote }}
- name: CALL_HIGH_RISK_COST_IN_CENTS_PER_MINUTE
@@ -99,7 +99,7 @@ spec:
value: {{ $.Values.app.disableTelemetryCollection | quote }}
ports:
- containerPort: {{ $.Values.port.app }}
- containerPort: {{ $.Values.app.ports.http }}
protocol: TCP
name: http
{{- if $.Values.app.resources }}
@@ -111,7 +111,7 @@ spec:
---
# OneUptime app Service
{{- $appPorts := dict "port" $.Values.port.app -}}
{{- $appPorts := dict "port" $.Values.app.ports.http -}}
{{- $appServiceArgs := dict "ServiceName" "app" "Ports" $appPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $appServiceArgs }}
---

View File

@@ -1,12 +1,12 @@
# OneUptime dashboard Deployment
{{- $dashboardPorts := dict "port" $.Values.port.dashboard -}}
{{- $dashboardEnv := dict "PORT" $.Values.port.dashboard "DISABLE_TELEMETRY" $.Values.dashboard.disableTelemetryCollection -}}
{{- $dashboardPorts := $.Values.dashboard.ports -}}
{{- $dashboardEnv := dict "PORT" $.Values.dashboard.ports.http "DISABLE_TELEMETRY" $.Values.dashboard.disableTelemetryCollection -}}
{{- $dashboardDeploymentArgs :=dict "IsUI" true "ServiceName" "dashboard" "Ports" $dashboardPorts "Release" $.Release "Values" $.Values "Env" $dashboardEnv "Resources" $.Values.dashboard.resources "DisableAutoscaler" $.Values.dashboard.disableAutoscaler "ReplicaCount" $.Values.dashboard.replicaCount -}}
{{- include "oneuptime.deployment" $dashboardDeploymentArgs }}
---
# OneUptime dashboard Service
{{- $dashboardPorts := dict "port" $.Values.port.dashboard -}}
{{- $dashboardPorts := $.Values.dashboard.ports -}}
{{- $dashboardServiceArgs := dict "ServiceName" "dashboard" "Ports" $dashboardPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $dashboardServiceArgs }}
---

View File

@@ -52,7 +52,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.docs }}
port: {{ $.Values.docs.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -61,7 +61,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.docs }}
port: {{ $.Values.docs.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -71,7 +71,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.docs }}
port: {{ $.Values.docs.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -85,11 +85,11 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.docs | quote }}
value: {{ $.Values.docs.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.docs.disableTelemetryCollection | quote }}
ports:
- containerPort: {{ $.Values.port.docs }}
- containerPort: {{ $.Values.docs.ports.http }}
protocol: TCP
name: http
{{- if $.Values.docs.resources }}
@@ -101,7 +101,8 @@ spec:
---
# OneUptime app Service
{{- $docsPorts := dict "port" $.Values.port.docs -}}
# OneUptime docs Service
{{- $docsPorts := dict "port" $.Values.docs.ports.http -}}
{{- $docsServiceArgs := dict "ServiceName" "docs" "Ports" $docsPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $docsServiceArgs }}
---

View File

@@ -57,7 +57,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.fluentIngest }}
port: {{ $.Values.fluentIngest.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -66,7 +66,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.fluentIngest }}
port: {{ $.Values.fluentIngest.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -76,7 +76,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.fluentIngest }}
port: {{ $.Values.fluentIngest.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -90,11 +90,13 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.fluentIngest | quote }}
value: {{ $.Values.fluentIngest.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.fluentIngest.disableTelemetryCollection | quote }}
- name: FLUENT_INGEST_CONCURRENCY
value: {{ $.Values.fluentIngest.concurrency | squote }}
ports:
- containerPort: {{ $.Values.port.fluentIngest }}
- containerPort: {{ $.Values.fluentIngest.ports.http }}
protocol: TCP
name: http
{{- if $.Values.fluentIngest.resources }}
@@ -106,13 +108,13 @@ spec:
---
# OneUptime fluent-ingest Service
{{- $fluentIngestPorts := dict "port" $.Values.port.fluentIngest -}}
{{- $fluentIngestPorts := dict "port" $.Values.fluentIngest.ports.http -}}
{{- $fluentIngestServiceArgs := dict "ServiceName" "fluent-ingest" "Ports" $fluentIngestPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $fluentIngestServiceArgs }}
---
# OneUptime fluent-ingest autoscaler
{{- if not $.Values.fluentIngest.disableAutoscaler }}
{{- if and (not $.Values.fluentIngest.disableAutoscaler) (not (and $.Values.keda.enabled $.Values.fluentIngest.keda.enabled)) }}
{{- $fluentIngestAutoScalerArgs := dict "ServiceName" "fluent-ingest" "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.autoscaler" $fluentIngestAutoScalerArgs }}
{{- end }}

View File

@@ -52,7 +52,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.home }}
port: {{ $.Values.home.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -61,7 +61,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.home }}
port: {{ $.Values.home.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -71,7 +71,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.home }}
port: {{ $.Values.home.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -85,11 +85,11 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.home | quote }}
value: {{ $.Values.home.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.home.disableTelemetryCollection | quote }}
ports:
- containerPort: {{ $.Values.port.home }}
- containerPort: {{ $.Values.home.ports.http }}
protocol: TCP
name: http
{{- if $.Values.home.resources }}
@@ -101,7 +101,7 @@ spec:
---
# OneUptime app Service
{{- $homePorts := dict "port" $.Values.port.home -}}
{{- $homePorts := $.Values.home.ports -}}
{{- $homeServiceArgs := dict "ServiceName" "home" "Ports" $homePorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $homeServiceArgs }}
---

View File

@@ -57,7 +57,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.incomingRequestIngest }}
port: {{ $.Values.incomingRequestIngest.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -66,7 +66,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.incomingRequestIngest }}
port: {{ $.Values.incomingRequestIngest.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -76,7 +76,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.incomingRequestIngest }}
port: {{ $.Values.incomingRequestIngest.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -90,11 +90,13 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.incomingRequestIngest | quote }}
value: {{ $.Values.incomingRequestIngest.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.incomingRequestIngest.disableTelemetryCollection | quote }}
- name: INCOMING_REQUEST_INGEST_CONCURRENCY
value: {{ $.Values.incomingRequestIngest.concurrency | squote }}
ports:
- containerPort: {{ $.Values.port.incomingRequestIngest }}
- containerPort: {{ $.Values.incomingRequestIngest.ports.http }}
protocol: TCP
name: http
{{- if $.Values.incomingRequestIngest.resources }}
@@ -106,13 +108,13 @@ spec:
---
# OneUptime incoming-request-ingest Service
{{- $incomingRequestIngestPorts := dict "port" $.Values.port.incomingRequestIngest -}}
{{- $incomingRequestIngestPorts := dict "port" $.Values.incomingRequestIngest.ports.http -}}
{{- $incomingRequestIngestServiceArgs := dict "ServiceName" "incoming-request-ingest" "Ports" $incomingRequestIngestPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $incomingRequestIngestServiceArgs }}
---
# OneUptime incoming-request-ingest autoscaler
{{- if not $.Values.incomingRequestIngest.disableAutoscaler }}
{{- if and (not $.Values.incomingRequestIngest.disableAutoscaler) (not (and $.Values.keda.enabled $.Values.incomingRequestIngest.keda.enabled)) }}
{{- $incomingRequestIngestAutoScalerArgs := dict "ServiceName" "incoming-request-ingest" "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.autoscaler" $incomingRequestIngestAutoScalerArgs }}
{{- end }}

View File

@@ -56,12 +56,12 @@ spec:
{{- include "oneuptime.env.common" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.isolatedVM | quote }}
value: {{ $.Values.isolatedVM.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.isolatedVM.disableTelemetryCollection | quote }}
ports:
- containerPort: {{ $.Values.port.isolatedVM }}
- containerPort: {{ $.Values.isolatedVM.ports.http }}
protocol: TCP
name: http
{{- if $.Values.isolatedVM.resources }}
@@ -73,7 +73,7 @@ spec:
---
# OneUptime isolatedVM Service
{{- $isolatedVMPorts := dict "port" $.Values.port.isolatedVM -}}
{{- $isolatedVMPorts := $.Values.isolatedVM.ports -}}
{{- $isolatedVMServiceArgs := dict "ServiceName" "isolated-vm" "Ports" $isolatedVMPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $isolatedVMServiceArgs }}
---

View File

@@ -4,35 +4,49 @@ KEDA ScaledObjects for various services
{{/* OpenTelemetry Ingest KEDA ScaledObject */}}
{{- if and .Values.keda.enabled .Values.openTelemetryIngest.keda.enabled (not .Values.openTelemetryIngest.disableAutoscaler) }}
{{- $metricsConfig := dict "enabled" .Values.openTelemetryIngest.keda.enabled "minReplicas" .Values.openTelemetryIngest.keda.minReplicas "maxReplicas" .Values.openTelemetryIngest.keda.maxReplicas "pollingInterval" .Values.openTelemetryIngest.keda.pollingInterval "cooldownPeriod" .Values.openTelemetryIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_telemetry_queue_size" "threshold" .Values.openTelemetryIngest.keda.queueSizeThreshold "port" .Values.port.openTelemetryIngest)) }}
{{- $metricsConfig := dict "enabled" .Values.openTelemetryIngest.keda.enabled "minReplicas" .Values.openTelemetryIngest.keda.minReplicas "maxReplicas" .Values.openTelemetryIngest.keda.maxReplicas "pollingInterval" .Values.openTelemetryIngest.keda.pollingInterval "cooldownPeriod" .Values.openTelemetryIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_telemetry_queue_size" "threshold" .Values.openTelemetryIngest.keda.queueSizeThreshold "port" .Values.openTelemetryIngest.ports.http)) }}
{{- $openTelemetryIngestKedaArgs := dict "ServiceName" "open-telemetry-ingest" "Release" .Release "Values" .Values "MetricsConfig" $metricsConfig "DisableAutoscaler" .Values.openTelemetryIngest.disableAutoscaler }}
{{- include "oneuptime.kedaScaledObject" $openTelemetryIngestKedaArgs }}
{{- end }}
{{/* Fluent Ingest KEDA ScaledObject */}}
{{- if and .Values.keda.enabled .Values.fluentIngest.keda.enabled (not .Values.fluentIngest.disableAutoscaler) }}
{{- $metricsConfig := dict "enabled" .Values.fluentIngest.keda.enabled "minReplicas" .Values.fluentIngest.keda.minReplicas "maxReplicas" .Values.fluentIngest.keda.maxReplicas "pollingInterval" .Values.fluentIngest.keda.pollingInterval "cooldownPeriod" .Values.fluentIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_fluent_ingest_queue_size" "threshold" .Values.fluentIngest.keda.queueSizeThreshold "port" .Values.port.fluentIngest)) }}
{{- $metricsConfig := dict "enabled" .Values.fluentIngest.keda.enabled "minReplicas" .Values.fluentIngest.keda.minReplicas "maxReplicas" .Values.fluentIngest.keda.maxReplicas "pollingInterval" .Values.fluentIngest.keda.pollingInterval "cooldownPeriod" .Values.fluentIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_fluent_ingest_queue_size" "threshold" .Values.fluentIngest.keda.queueSizeThreshold "port" .Values.fluentIngest.ports.http)) }}
{{- $fluentIngestKedaArgs := dict "ServiceName" "fluent-ingest" "Release" .Release "Values" .Values "MetricsConfig" $metricsConfig "DisableAutoscaler" .Values.fluentIngest.disableAutoscaler }}
{{- include "oneuptime.kedaScaledObject" $fluentIngestKedaArgs }}
{{- end }}
{{/* Incoming Request Ingest KEDA ScaledObject */}}
{{- if and .Values.keda.enabled .Values.incomingRequestIngest.keda.enabled (not .Values.incomingRequestIngest.disableAutoscaler) }}
{{- $metricsConfig := dict "enabled" .Values.incomingRequestIngest.keda.enabled "minReplicas" .Values.incomingRequestIngest.keda.minReplicas "maxReplicas" .Values.incomingRequestIngest.keda.maxReplicas "pollingInterval" .Values.incomingRequestIngest.keda.pollingInterval "cooldownPeriod" .Values.incomingRequestIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_incoming_request_ingest_queue_size" "threshold" .Values.incomingRequestIngest.keda.queueSizeThreshold "port" .Values.port.incomingRequestIngest)) }}
{{- $metricsConfig := dict "enabled" .Values.incomingRequestIngest.keda.enabled "minReplicas" .Values.incomingRequestIngest.keda.minReplicas "maxReplicas" .Values.incomingRequestIngest.keda.maxReplicas "pollingInterval" .Values.incomingRequestIngest.keda.pollingInterval "cooldownPeriod" .Values.incomingRequestIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_incoming_request_ingest_queue_size" "threshold" .Values.incomingRequestIngest.keda.queueSizeThreshold "port" .Values.incomingRequestIngest.ports.http)) }}
{{- $incomingRequestIngestKedaArgs := dict "ServiceName" "incoming-request-ingest" "Release" .Release "Values" .Values "MetricsConfig" $metricsConfig "DisableAutoscaler" .Values.incomingRequestIngest.disableAutoscaler }}
{{- include "oneuptime.kedaScaledObject" $incomingRequestIngestKedaArgs }}
{{- end }}
{{/* Server Monitor Ingest KEDA ScaledObject */}}
{{- if and .Values.keda.enabled .Values.serverMonitorIngest.keda.enabled (not .Values.serverMonitorIngest.disableAutoscaler) }}
{{- $metricsConfig := dict "enabled" .Values.serverMonitorIngest.keda.enabled "minReplicas" .Values.serverMonitorIngest.keda.minReplicas "maxReplicas" .Values.serverMonitorIngest.keda.maxReplicas "pollingInterval" .Values.serverMonitorIngest.keda.pollingInterval "cooldownPeriod" .Values.serverMonitorIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_server_monitor_ingest_queue_size" "threshold" .Values.serverMonitorIngest.keda.queueSizeThreshold "port" .Values.port.serverMonitorIngest)) }}
{{- $metricsConfig := dict "enabled" .Values.serverMonitorIngest.keda.enabled "minReplicas" .Values.serverMonitorIngest.keda.minReplicas "maxReplicas" .Values.serverMonitorIngest.keda.maxReplicas "pollingInterval" .Values.serverMonitorIngest.keda.pollingInterval "cooldownPeriod" .Values.serverMonitorIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_server_monitor_ingest_queue_size" "threshold" .Values.serverMonitorIngest.keda.queueSizeThreshold "port" .Values.serverMonitorIngest.ports.http)) }}
{{- $serverMonitorIngestKedaArgs := dict "ServiceName" "server-monitor-ingest" "Release" .Release "Values" .Values "MetricsConfig" $metricsConfig "DisableAutoscaler" .Values.serverMonitorIngest.disableAutoscaler }}
{{- include "oneuptime.kedaScaledObject" $serverMonitorIngestKedaArgs }}
{{- end }}
{{/* Probe Ingest KEDA ScaledObject */}}
{{- if and .Values.keda.enabled .Values.probeIngest.keda.enabled (not .Values.probeIngest.disableAutoscaler) }}
{{- $metricsConfig := dict "enabled" .Values.probeIngest.keda.enabled "minReplicas" .Values.probeIngest.keda.minReplicas "maxReplicas" .Values.probeIngest.keda.maxReplicas "pollingInterval" .Values.probeIngest.keda.pollingInterval "cooldownPeriod" .Values.probeIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_probe_ingest_queue_size" "threshold" .Values.probeIngest.keda.queueSizeThreshold "port" .Values.port.probeIngest)) }}
{{- $metricsConfig := dict "enabled" .Values.probeIngest.keda.enabled "minReplicas" .Values.probeIngest.keda.minReplicas "maxReplicas" .Values.probeIngest.keda.maxReplicas "pollingInterval" .Values.probeIngest.keda.pollingInterval "cooldownPeriod" .Values.probeIngest.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_probe_ingest_queue_size" "threshold" .Values.probeIngest.keda.queueSizeThreshold "port" .Values.probeIngest.ports.http)) }}
{{- $probeIngestKedaArgs := dict "ServiceName" "probe-ingest" "Release" .Release "Values" .Values "MetricsConfig" $metricsConfig "DisableAutoscaler" .Values.probeIngest.disableAutoscaler }}
{{- include "oneuptime.kedaScaledObject" $probeIngestKedaArgs }}
{{- end }}
{{/* Probe KEDA ScaledObjects - one for each probe configuration */}}
{{- range $key, $val := $.Values.probes }}
{{- if and $.Values.keda.enabled $val.keda.enabled (not $val.disableAutoscaler) }}
{{- $serviceName := printf "probe-%s" $key }}
{{- $probePort := 3874 }}
{{- if and $val.ports $val.ports.http }}
{{- $probePort = $val.ports.http }}
{{- end }}
{{- $metricsConfig := dict "enabled" $val.keda.enabled "minReplicas" $val.keda.minReplicas "maxReplicas" $val.keda.maxReplicas "pollingInterval" $val.keda.pollingInterval "cooldownPeriod" $val.keda.cooldownPeriod "triggers" (list (dict "query" "oneuptime_probe_queue_size" "threshold" $val.keda.queueSizeThreshold "port" $probePort)) }}
{{- $probeKedaArgs := dict "ServiceName" $serviceName "Release" $.Release "Values" $.Values "MetricsConfig" $metricsConfig "DisableAutoscaler" $val.disableAutoscaler }}
{{- include "oneuptime.kedaScaledObject" $probeKedaArgs }}
{{- end }}
{{- end }}

View File

@@ -112,7 +112,7 @@ spec:
- name: NGINX_LISTEN_OPTIONS
value: {{ $.Values.nginx.listenOptions | quote }}
- name: ONEUPTIME_HTTP_PORT
value: {{ $.Values.port.nginxHttp | quote }}
value: {{ $.Values.nginx.ports.http | quote }}
- name: PORT
value: "7851" # Port for the nodejs server for live and ready status
- name: DISABLE_TELEMETRY
@@ -158,10 +158,10 @@ spec:
{{- end }}
{{- end }}
ports:
- port: {{ $.Values.port.nginxHttp }}
- port: {{ $.Values.nginx.ports.http }}
targetPort: 7849
name: oneuptime-http
- port: {{ $.Values.port.statusPageHttpsPort }}
- port: {{ $.Values.nginx.ports.https }}
targetPort: 7850
name: statuspage-ssl
selector:

View File

@@ -57,7 +57,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.openTelemetryIngest }}
port: {{ $.Values.openTelemetryIngest.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -66,7 +66,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.openTelemetryIngest }}
port: {{ $.Values.openTelemetryIngest.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -76,7 +76,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.openTelemetryIngest }}
port: {{ $.Values.openTelemetryIngest.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -90,11 +90,13 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.openTelemetryIngest | quote }}
value: {{ $.Values.openTelemetryIngest.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.openTelemetryIngest.disableTelemetryCollection | quote }}
- name: OPEN_TELEMETRY_INGEST_CONCURRENCY
value: {{ $.Values.openTelemetryIngest.concurrency | squote }}
ports:
- containerPort: {{ $.Values.port.openTelemetryIngest }}
- containerPort: {{ $.Values.openTelemetryIngest.ports.http }}
protocol: TCP
name: http
{{- if $.Values.openTelemetryIngest.resources }}
@@ -106,7 +108,7 @@ spec:
---
# OneUptime open-telemetry-ingest Service
{{- $openTelemetryIngestPorts := dict "port" $.Values.port.openTelemetryIngest -}}
{{- $openTelemetryIngestPorts := dict "port" $.Values.openTelemetryIngest.ports.http -}}
{{- $openTelemetryIngestServiceArgs := dict "ServiceName" "open-telemetry-ingest" "Ports" $openTelemetryIngestPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $openTelemetryIngestServiceArgs }}
---

View File

@@ -91,7 +91,7 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.otelCollectorGrpc | quote }}
value: {{ $.Values.openTelemetryCollector.ports.grpc | quote }}
- name: OPENTELEMETRY_COLLECTOR_SENDING_QUEUE_ENABLED
value: {{ $.Values.openTelemetryCollector.sendingQueue.enabled | quote }}
- name: OPENTELEMETRY_COLLECTOR_SENDING_QUEUE_NUM_CONSUMERS
@@ -101,10 +101,10 @@ spec:
- name: DISABLE_TELEMETRY
value: {{ $.Values.openTelemetryCollector.disableTelemetryCollection | quote }}
ports:
- containerPort: {{ $.Values.port.otelCollectorHttp }}
- containerPort: {{ $.Values.openTelemetryCollector.ports.http }}
protocol: TCP
name: http
- containerPort: {{ $.Values.port.otelCollectorGrpc }}
- containerPort: {{ $.Values.openTelemetryCollector.ports.grpc }}
protocol: TCP
name: grpc
{{- if $.Values.openTelemetryCollector.resources }}
@@ -115,7 +115,7 @@ spec:
---
# OneUptime otel-collector Service
{{- $otelCollectorPorts := dict "grpc" $.Values.port.otelCollectorGrpc "http" $.Values.port.otelCollectorHttp -}}
{{- $otelCollectorPorts := dict "grpc" $.Values.openTelemetryCollector.ports.grpc "http" $.Values.openTelemetryCollector.ports.http -}}
{{- $identityServiceArgs := dict "ServiceName" "otel-collector" "Ports" $otelCollectorPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $identityServiceArgs }}
---

View File

@@ -57,7 +57,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.probeIngest }}
port: {{ $.Values.probeIngest.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -66,7 +66,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.probeIngest }}
port: {{ $.Values.probeIngest.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -76,7 +76,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.probeIngest }}
port: {{ $.Values.probeIngest.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -90,11 +90,13 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.probeIngest | quote }}
value: {{ $.Values.probeIngest.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.probeIngest.disableTelemetryCollection | quote }}
- name: PROBE_INGEST_CONCURRENCY
value: {{ $.Values.probeIngest.concurrency | squote }}
ports:
- containerPort: {{ $.Values.port.probeIngest }}
- containerPort: {{ $.Values.probeIngest.ports.http }}
protocol: TCP
name: http
{{- if $.Values.probeIngest.resources }}
@@ -106,13 +108,13 @@ spec:
---
# OneUptime probe-ingest Service
{{- $probeIngestPorts := dict "port" $.Values.port.probeIngest -}}
{{- $probeIngestPorts := dict "port" $.Values.probeIngest.ports.http -}}
{{- $probeIngestServiceArgs := dict "ServiceName" "probe-ingest" "Ports" $probeIngestPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $probeIngestServiceArgs }}
---
# OneUptime probe-ingest autoscaler
{{- if not $.Values.probeIngest.disableAutoscaler }}
{{- if and (not $.Values.probeIngest.disableAutoscaler) (not (and $.Values.keda.enabled $.Values.probeIngest.keda.enabled)) }}
{{- $probeIngestAutoScalerArgs := dict "ServiceName" "probe-ingest" "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.autoscaler" $probeIngestAutoScalerArgs }}
{{- end }}

View File

@@ -59,13 +59,17 @@ spec:
- name: LOG_LEVEL
value: {{ $.Values.logLevel }}
- name: PORT
value: {{ $.Values.port.probe | squote }}
{{- if and $val.ports $val.ports.http }}
value: {{ $val.ports.http | squote }}
{{- else }}
value: "3874"
{{- end }}
- name: OPENTELEMETRY_EXPORTER_OTLP_HEADERS
value: {{ $.Values.openTelemetryExporter.headers }}
- name: OPENTELEMETRY_EXPORTER_OTLP_ENDPOINT
value: {{ $.Values.openTelemetryExporter.endpoint }}
- name: ONEUPTIME_URL
value: http://{{ $.Release.Name }}-probe-ingest.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }}:{{ $.Values.port.probeIngest }}
value: http://{{ $.Release.Name }}-probe-ingest.{{ $.Release.Namespace }}.svc.{{ $.Values.global.clusterDomain }}:{{ $.Values.probeIngest.ports.http }}
- name: PROBE_NAME
value: {{ $val.name }}
- name: PROBE_DESCRIPTION
@@ -100,6 +104,10 @@ spec:
value: {{ $val.disableTelemetryCollection | quote }}
{{- end }}
{{- include "oneuptime.env.oneuptimeSecret" $ | nindent 12 }}
ports:
- containerPort: {{ if and $val.ports $val.ports.http }}{{ $val.ports.http }}{{ else }}3874{{ end }}
protocol: TCP
name: http
{{- if $val.resources }}
resources:
{{- toYaml $val.resources | nindent 12 }}
@@ -110,12 +118,22 @@ spec:
restartPolicy: {{ $.Values.image.restartPolicy }}
---
{{- if not $val.disableAutoscaler }}
# OneUptime probe Service
{{- $probePort := 3874 }}
{{- if and $val.ports $val.ports.http }}
{{- $probePort = $val.ports.http }}
{{- end }}
{{- $probePorts := dict "port" $probePort -}}
{{- $probeServiceArgs := dict "ServiceName" (printf "probe-%s" $key) "Ports" $probePorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $probeServiceArgs }}
---
{{- if and (not $val.disableAutoscaler) (not (and $.Values.keda.enabled $val.keda.enabled)) }}
# OneUptime probe autoscaler
{{- $probeAutoScalerArgs := dict "ServiceName" (printf "probe-%s" $key) "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.autoscaler" $probeAutoScalerArgs }}
{{- end }}
---
{{- end }}
{{- end }}

View File

@@ -57,7 +57,7 @@ spec:
startupProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.serverMonitorIngest }}
port: {{ $.Values.serverMonitorIngest.ports.http }}
periodSeconds: {{ $.Values.startupProbe.periodSeconds }}
failureThreshold: {{ $.Values.startupProbe.failureThreshold }}
{{- end }}
@@ -66,7 +66,7 @@ spec:
livenessProbe:
httpGet:
path: /status/live
port: {{ $.Values.port.serverMonitorIngest }}
port: {{ $.Values.serverMonitorIngest.ports.http }}
periodSeconds: {{ $.Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $.Values.livenessProbe.timeoutSeconds }}
initialDelaySeconds: {{ $.Values.livenessProbe.initialDelaySeconds }}
@@ -76,7 +76,7 @@ spec:
readinessProbe:
httpGet:
path: /status/ready
port: {{ $.Values.port.serverMonitorIngest }}
port: {{ $.Values.serverMonitorIngest.ports.http }}
periodSeconds: {{ $.Values.readinessProbe.periodSeconds }}
initialDelaySeconds: {{ $.Values.readinessProbe.initialDelaySeconds }}
timeoutSeconds: {{ $.Values.readinessProbe.timeoutSeconds }}
@@ -90,11 +90,13 @@ spec:
{{- include "oneuptime.env.commonServer" . | nindent 12 }}
{{- include "oneuptime.env.oneuptimeSecret" . | nindent 12 }}
- name: PORT
value: {{ $.Values.port.serverMonitorIngest | quote }}
value: {{ $.Values.serverMonitorIngest.ports.http | quote }}
- name: DISABLE_TELEMETRY
value: {{ $.Values.serverMonitorIngest.disableTelemetryCollection | quote }}
- name: SERVER_MONITOR_INGEST_CONCURRENCY
value: {{ $.Values.serverMonitorIngest.concurrency | squote }}
ports:
- containerPort: {{ $.Values.port.serverMonitorIngest }}
- containerPort: {{ $.Values.serverMonitorIngest.ports.http }}
protocol: TCP
name: http
{{- if $.Values.serverMonitorIngest.resources }}
@@ -106,13 +108,13 @@ spec:
---
# OneUptime server-monitor-ingest Service
{{- $serverMonitorIngestPorts := dict "port" $.Values.port.serverMonitorIngest -}}
{{- $serverMonitorIngestPorts := dict "port" $.Values.serverMonitorIngest.ports.http -}}
{{- $serverMonitorIngestServiceArgs := dict "ServiceName" "server-monitor-ingest" "Ports" $serverMonitorIngestPorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $serverMonitorIngestServiceArgs }}
---
# OneUptime server-monitor-ingest autoscaler
{{- if not $.Values.serverMonitorIngest.disableAutoscaler }}
{{- if and (not $.Values.serverMonitorIngest.disableAutoscaler) (not (and $.Values.keda.enabled $.Values.serverMonitorIngest.keda.enabled)) }}
{{- $serverMonitorIngestAutoScalerArgs := dict "ServiceName" "server-monitor-ingest" "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.autoscaler" $serverMonitorIngestAutoScalerArgs }}
{{- end }}

View File

@@ -1,12 +1,12 @@
# OneUptime statusPage Deployment
{{- $statusPagePorts := dict "port" $.Values.port.statusPage -}}
{{- $statusPageEnv := dict "PORT" $.Values.port.statusPage "DISABLE_TELEMETRY" $.Values.statusPage.disableTelemetryCollection -}}
{{- $statusPagePorts := dict "port" $.Values.statusPage.ports.http -}}
{{- $statusPageEnv := dict "PORT" $.Values.statusPage.ports.http "DISABLE_TELEMETRY" $.Values.statusPage.disableTelemetryCollection -}}
{{- $statusPageDeploymentArgs :=dict "IsUI" true "ServiceName" "status-page" "Ports" $statusPagePorts "Release" $.Release "Values" $.Values "Env" $statusPageEnv "Resources" $.Values.statusPage.resources "DisableAutoscaler" $.Values.statusPage.disableAutoscaler "ReplicaCount" $.Values.statusPage.replicaCount -}}
{{- include "oneuptime.deployment" $statusPageDeploymentArgs }}
---
# OneUptime statusPage Service
{{- $statusPagePorts := dict "port" $.Values.port.statusPage -}}
{{- $statusPagePorts := dict "port" $.Values.statusPage.ports.http -}}
{{- $statusPageServiceArgs := dict "ServiceName" "status-page" "Ports" $statusPagePorts "Release" $.Release "Values" $.Values -}}
{{- include "oneuptime.service" $statusPageServiceArgs }}
---

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