Compare commits

...

129 Commits

Author SHA1 Message Date
Simon Larsen
7c17328ee3 feat: Add debug logging for SCIM API requests in ServiceProviderConfig, Users, Individual User, and User management endpoints 2025-09-01 18:52:02 +01:00
Simon Larsen
3eeb2a9eca feat: Enhance SCIM user update functionality to support user creation and improved error handling 2025-09-01 16:55:34 +01:00
Simon Larsen
51fa5705b1 feat: Add SCIMUser and StatusPageSCIMUser models with migration and indexing 2025-09-01 16:38:47 +01:00
Simon Larsen
6f952d0a5b feat: Implement SCIM external ID support with user mapping and enhanced API functionality 2025-09-01 16:34:16 +01:00
Simon Larsen
ade98cf1ed feat: Add StatusPageSCIMUser model with access control and relationships to related entities 2025-09-01 16:21:37 +01:00
Simon Larsen
a65e480bb6 feat: Add SCIMUserService and StatusPageSCIMUserService classes extending DatabaseService 2025-09-01 16:20:19 +01:00
Simon Larsen
c777a935c3 feat: Implement SCIM user mapping functionality and enhance user lookup methods 2025-09-01 16:20:06 +01:00
Nawaz Dhandala
8ec9d2a930 feat: Add type annotations for proxy-related variables in ProxyConfig and monitors 2025-09-01 14:58:33 +01:00
Nawaz Dhandala
224c225789 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-09-01 14:53:33 +01:00
Simon Larsen
85dae7a307 feat: Add proxy configuration options for probes in values.yaml and update README and probe.yaml 2025-09-01 14:53:24 +01:00
Nawaz Dhandala
332a479c22 feat: Improve proxy configuration handling and logging in monitors 2025-09-01 14:50:52 +01:00
Simon Larsen
d708fbbb52 feat: Add proxy configuration examples to Custom Probe documentation and component 2025-09-01 14:49:40 +01:00
Simon Larsen
03bceb959e feat: Enhance proxy support in SSL and Synthetic Monitors to prefer HTTPS, fallback to HTTP 2025-09-01 14:47:01 +01:00
Simon Larsen
efa411206e feat: Update SSL and Synthetic Monitors to use HTTPS proxy configuration 2025-09-01 14:41:57 +01:00
Simon Larsen
27fd99f2e8 feat: Update proxy configuration to support separate HTTP and HTTPS proxy URLs 2025-09-01 14:40:54 +01:00
Simon Larsen
07361bfeb7 feat: Enhance SyntheticMonitor with proxy support in browser launch options 2025-09-01 14:07:15 +01:00
Simon Larsen
bc8a5be0fa feat: Add proxy support for CustomCodeMonitor and SyntheticMonitor with logging 2025-09-01 14:00:55 +01:00
Simon Larsen
518768078a feat: Implement proxy configuration for HTTP requests and add ProxyConfig utility 2025-09-01 13:56:29 +01:00
Simon Larsen
86e95f99ff feat: Add PROXY_URL configuration option for probe and update example env file 2025-09-01 12:52:50 +01:00
Simon Larsen
ea48f56097 feat: Add custom styles for code blocks in blog posts 2025-09-01 12:43:03 +01:00
Nawaz Dhandala
b8b9dd859a Refactor migration files for consistency and readability; update BillingService and ProjectService for improved code clarity; enhance Countries interface formatting; standardize string quotes in various components; fix minor formatting issues in Settings and SendAnnouncementCreatedNotification. 2025-08-27 14:50:48 +01:00
Simon Larsen
d28c14ef24 feat: Update projectId reference in status page notification logic 2025-08-27 14:49:33 +01:00
Simon Larsen
670bec2a12 feat: Validate projectId and statusPageId in getStatusPageLinkInDashboard method 2025-08-27 14:48:40 +01:00
Simon Larsen
aff24845a8 feat: Add financeAccountingEmail handling in updateCustomerBusinessDetails method 2025-08-27 14:26:59 +01:00
Simon Larsen
f280e97c1b feat: Add migration for financeAccountingEmail field in Project model 2025-08-27 14:14:02 +01:00
Simon Larsen
62facf62dd feat: Add financeAccountingEmail field to Project model and update billing settings 2025-08-27 14:12:16 +01:00
Simon Larsen
db0387d81a feat: Update placeholder condition to include empty string check 2025-08-27 14:06:33 +01:00
Simon Larsen
5c4b19ab3d feat: Update nodemon configurations to improve performance and debugging options 2025-08-27 13:41:05 +01:00
Simon Larsen
463755fa4d This will be synced to Stripe and appear on future invoices. 2025-08-27 13:40:26 +01:00
Simon Larsen
85888572de feat: Update subscriber notification statuses to 'Success' for existing records in Incident and related tables 2025-08-27 13:20:07 +01:00
Simon Larsen
475bb25b2d feat: Add businessDetailsCountry field to Project migration and update index 2025-08-27 13:14:40 +01:00
Simon Larsen
badd200aed feat: Add country selection dropdown for billing details and implement country options 2025-08-27 13:14:01 +01:00
Simon Larsen
b40d87cbc9 feat: Add business details country field to Project model and update billing services to handle country code 2025-08-27 13:05:54 +01:00
Simon Larsen
36d0066b3a refactor: Simplify migration by removing unnecessary constraints and columns from Project and GlobalConfig tables 2025-08-27 13:03:01 +01:00
Simon Larsen
a49a0b2cba fix: Ensure blog post cards maintain full height for consistent layout 2025-08-27 12:48:41 +01:00
Simon Larsen
bada97d474 feat: Enhance customer address handling in Stripe by mapping business details to structured address fields 2025-08-27 12:42:17 +01:00
Simon Larsen
a1699f2d55 feat: Add business details field to Project model and update Stripe customer details 2025-08-27 12:37:09 +01:00
Simon Larsen
a11e054291 feat: Add custom link rendering with Tailwind styles and external link handling 2025-08-27 11:19:20 +01:00
Simon Larsen
47cf7ba763 fix: Increase timeout for SSL provisioning and delete old data jobs to accommodate longer processing times 2025-08-27 10:16:35 +01:00
Nawaz Dhandala
4e0dfb3664 fix: Simplify BadDataException handling for disabled and missing monitors in ingestion processes 2025-08-27 10:12:13 +01:00
Simon Larsen
250cb9e547 fix: Gracefully handle expected BadDataException cases for disabled and missing monitors in probe ingestion 2025-08-27 10:10:59 +01:00
Simon Larsen
541257e3c6 fix: Handle expected BadDataException cases for disabled and missing monitors in server monitor ingestion 2025-08-27 10:10:23 +01:00
Simon Larsen
ed43686736 fix: Centralize "Monitor disabled" message and improve error handling for disabled monitors 2025-08-27 10:09:26 +01:00
Simon Larsen
9ca45f23e3 fix: Replace hardcoded "Monitor not found" messages with centralized exception messages 2025-08-27 09:58:34 +01:00
Simon Larsen
e3573a9b77 fix: Refactor monitor not found error handling to use centralized exception messages 2025-08-27 09:55:19 +01:00
Simon Larsen
c9e78044e6 fix: Improve error handling in incoming request ingestion worker to handle disabled monitors gracefully 2025-08-27 09:48:54 +01:00
Nawaz Dhandala
813581dec5 fix: Add return type to logoutUser method and specify type for route in navigateToLoginPage method 2025-08-26 21:39:35 +01:00
Nawaz Dhandala
e528decf73 fix: Refactor QueueWorker options handling; improve logoutUser method formatting and navigation logic in StatusPageUtil 2025-08-26 21:36:56 +01:00
Simon Larsen
42ef41ede8 fix: Enhance QueueWorker options with lock duration and max stalled count; improve telemetry processing with yielding to avoid stall detection 2025-08-26 21:32:38 +01:00
Simon Larsen
af26472db4 fix: Simplify email validation logic and improve user lookup in SCIM user operations 2025-08-26 21:24:39 +01:00
Simon Larsen
44b5c8b668 fix: Enhance email validation and logging in SCIM user operations 2025-08-26 20:47:50 +01:00
Simon Larsen
d821b88ed7 fix: Update Docker image tags and labels for multiple services in release workflow 2025-08-26 18:52:15 +01:00
Simon Larsen
1df43e21ff fix: Refactor logoutUser method and enhance navigation logging in StatusPageUtil 2025-08-26 18:37:49 +01:00
Simon Larsen
76ca6ee7e1 fix: Add missing continuation for APP_VERSION build argument in multiple Docker image deploy jobs 2025-08-26 18:25:32 +01:00
Simon Larsen
dac731a57b refactor: Remove unused mock for ProjectUserService in TeamMemberService tests 2025-08-26 16:56:52 +01:00
Simon Larsen
0f4b248598 fix: Add missing context for Docker image build in nginx deployment 2025-08-26 16:53:25 +01:00
Simon Larsen
b2c14e0380 fix: Add missing build context for multiple Docker image deploy jobs 2025-08-26 16:51:51 +01:00
Simon Larsen
3ab9705bbe fix: Allow deletion of teams in Users component by setting isDeleteable to true 2025-08-26 16:34:19 +01:00
Nawaz Dhandala
40812c8749 refactor: Clean up whitespace in TeamMemberService and SCIM files; update description formatting in Users component 2025-08-26 15:59:22 +01:00
Simon Larsen
45ae1501f2 refactor: Replace ProjectUser with TeamMember in SCIM query for team members 2025-08-26 15:55:23 +01:00
Simon Larsen
13d9f19606 refactor: Remove ProjectUserService calls to streamline TeamMemberService operations 2025-08-26 15:52:28 +01:00
Simon Larsen
ad3221310a refactor: Remove ProjectUser model and associated service to streamline user management 2025-08-26 15:51:06 +01:00
Simon Larsen
659042fcfb fix: Update isCreateable property to false for Teams component 2025-08-26 14:43:54 +01:00
Simon Larsen
d65b9c7b29 feat: Refactor Teams component to use TeamMember model and update filtering logic 2025-08-26 14:42:14 +01:00
Simon Larsen
dc77206e6f feat: Add debug logging for SCIM team operations to track user additions 2025-08-26 14:25:31 +01:00
Nawaz Dhandala
9c1910d3f1 refactor: Remove unnecessary context argument from Docker build commands in workflows 2025-08-26 14:09:31 +01:00
Nawaz Dhandala
afe8f8e6f4 refactor: Implement retry mechanism for account and isolated VM compilation steps in workflows 2025-08-26 13:15:54 +01:00
Nawaz Dhandala
015bd0f870 refactor: Implement retry mechanism for Docker image builds in multiple workflows 2025-08-26 13:12:18 +01:00
Nawaz Dhandala
383c145186 refactor: Integrate retry mechanism for Docker image builds in workflows 2025-08-26 13:06:55 +01:00
Simon Larsen
f155795e6b fix: Remove single quotes from changelog delimiter for correct output formatting 2025-08-26 11:56:22 +01:00
Nawaz Dhandala
757f5b5721 refactor: Improve type annotations for better clarity in error handling 2025-08-26 11:42:15 +01:00
Nawaz Dhandala
694215df06 refactor: Improve code formatting for better readability in multiple files 2025-08-26 11:40:01 +01:00
Nawaz Dhandala
0eb6022f1d Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-08-26 11:38:10 +01:00
Nawaz Dhandala
3109006828 refactor: Replace regex literals with RegExp constructor for improved clarity in MarkdownViewer and StringUtils 2025-08-26 11:38:08 +01:00
Simon Larsen
272695bd11 feat: Enhance error handling and logging in processMetricsAsync and processTracesAsync methods for improved robustness 2025-08-26 11:36:47 +01:00
Simon Larsen
330e3bc106 fix: Correct syntax error in JavaScript expression example for incoming request monitors 2025-08-26 11:26:01 +01:00
Nawaz Dhandala
c7876bf3a3 refactor: Improve regex pattern for fact extraction and enhance code formatting in OtelIngestService 2025-08-26 11:22:36 +01:00
Simon Larsen
345ada5404 feat: Enhance error handling and logging in processLogsAsync method for improved telemetry data ingestion 2025-08-26 11:16:32 +01:00
Simon Larsen
4f97b1b460 feat: Enhance image loading and layout stability across blog views 2025-08-25 10:46:19 +01:00
Nawaz Dhandala
e35ef1809f refactor: Improve code formatting and consistency across Microsoft Teams integration files 2025-08-21 14:45:34 +01:00
Simon Larsen
c2926f3542 feat: Implement structured MessageCard creation from markdown for Microsoft Teams
- Added a method to build a structured MessageCard from markdown input, enhancing message formatting for Teams.
- Extracted title, facts, and actions from markdown to improve rendering in Teams notifications.
2025-08-21 14:43:13 +01:00
Simon Larsen
9495b4bd47 feat: Add Microsoft Teams incoming webhook option to subscriber settings
- Introduced a new property for Microsoft Teams incoming webhook URL in the StatusPageSubscriberService.
- Enhanced subscriber configuration to support Microsoft Teams notifications.
2025-08-21 14:25:49 +01:00
Simon Larsen
ad3f36fdf5 refactor: Simplify Slack and Microsoft Teams notification handling in StatusPageSubscriberService
- Removed try-catch blocks for sending notifications and replaced them with promise chaining for better readability.
- Added logging for successful notification sends and error handling directly in the promise catch.
2025-08-21 14:11:06 +01:00
Simon Larsen
f2221b0a40 feat: Implement Microsoft Teams webhook validation and notification in StatusPage subscriber service
- Added validation for Microsoft Teams incoming webhook URL during subscriber setup.
- Implemented notification sending to Microsoft Teams channel upon successful subscription.
- Updated SideMenu components to reflect the new naming convention for Microsoft Teams subscribers.
2025-08-21 14:04:20 +01:00
Simon Larsen
62fbc1f4be feat: Enhance Microsoft Teams subscriber validation and handling in StatusPage API
- Added validation to ensure Microsoft Teams subscribers are only processed if enabled.
- Updated error messages to include Microsoft Teams workspace name requirements.
- Implemented handling for Microsoft Teams incoming webhook URL and workspace name in subscriber setup.
2025-08-21 13:52:35 +01:00
Simon Larsen
054a2bc8f5 feat: Enable Microsoft Teams subscribers in StatusPage API 2025-08-21 13:49:28 +01:00
Simon Larsen
896787109c feat: Add Microsoft Teams subscriber option to Email, Slack, and SMS subscription pages 2025-08-21 13:47:13 +01:00
Simon Larsen
3a55fcc872 feat: Update microsoftTeamsIncomingWebhookUrl column type to text and add migration 2025-08-21 13:27:44 +01:00
Simon Larsen
2945a48d05 feat: Update microsoftTeamsWorkspaceName column type to VeryLongText and add migration 2025-08-21 13:15:44 +01:00
Simon Larsen
da3a7ddb2e feat: Add migration for Microsoft Teams subscriber functionality in StatusPage 2025-08-21 12:57:16 +01:00
Simon Larsen
04a0bfedaa fix: Make Microsoft Teams subscriber prop required in SideMenu component 2025-08-21 12:17:00 +01:00
Simon Larsen
fa5c7b1e73 feat: Add Microsoft Teams subscriber functionality
- Implemented Microsoft Teams subscribers in the dashboard side menu.
- Created Microsoft Teams subscriber settings page with toggle options.
- Added routes for Microsoft Teams subscribers in the routing configuration.
- Updated page map and route map to include Microsoft Teams subscriber paths.
- Enhanced the app state management to handle Microsoft Teams subscription settings.
- Integrated Microsoft Teams notification sending in the announcement and incident jobs.
- Developed Microsoft Teams subscription management and creation forms.
- Added UI components for managing Microsoft Teams subscribers in the status page.
2025-08-21 12:09:53 +01:00
Simon Larsen
a1c2918cd7 feat: Update homepage heading for improved clarity and emphasis on monitoring capabilities 2025-08-21 10:24:49 +01:00
Simon Larsen
91b11b12c1 fix: Update canonical links for blog posts and remove redundant canonical tags in head partials 2025-08-21 10:15:52 +01:00
Simon Larsen
778a34d631 feat: Implement fallback to commit messages in changelog if empty 2025-08-20 09:17:53 +01:00
Simon Larsen
6dbd838ca4 refactor: Remove mobile redirect script from homepage for cleaner code 2025-08-19 22:23:20 +01:00
Simon Larsen
e09634dc6f feat: Enhance blog listing with featured post display and improved layout 2025-08-19 22:21:12 +01:00
Simon Larsen
af60715de2 Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-19 22:11:14 +01:00
Simon Larsen
3b4c54876e feat: Update default page size for blog pagination from 50 to 24 for improved performance 2025-08-19 22:11:12 +01:00
Nawaz Dhandala
e357100e46 refactor: Remove redundant dynamic section checks from sitemap tests 2025-08-19 20:43:08 +01:00
Simon Larsen
7f3a50076d feat: Add reference section check to sitemap tests for improved coverage 2025-08-19 14:25:37 +01:00
Simon Larsen
9d182b6d55 feat: Add additional static paths to sitemap generation for improved coverage 2025-08-19 14:25:16 +01:00
Simon Larsen
33fce0b53c feat: Implement caching for home URL retrieval to improve performance 2025-08-19 14:15:47 +01:00
Nawaz Dhandala
3db8419349 refactor: Simplify error handling in BlogPostUtil by removing unused error variables 2025-08-19 13:50:35 +01:00
Nawaz Dhandala
dfc324b099 refactor: Improve code formatting and readability in Markdown and BlogPost utilities 2025-08-19 12:58:17 +01:00
Simon Larsen
36521ef37c feat: Update blog post listing to improve layout and add Open Source Commitment section 2025-08-19 12:55:09 +01:00
Simon Larsen
a6f336340e feat: Implement pagination and tag filtering for blog posts 2025-08-19 12:40:51 +01:00
Simon Larsen
c36f782192 feat: Refactor blog post listing and tags display for improved layout and user experience 2025-08-19 12:34:23 +01:00
Simon Larsen
5219f1cfc0 feat: Remove unused imports and the getNameOfGitHubUser function from BlogPostUtil class 2025-08-19 12:29:27 +01:00
Simon Larsen
7f84d50baa feat: Add optional bio field for authors and update blog post template to display it 2025-08-19 12:28:12 +01:00
Simon Larsen
cd2ce3f1a8 feat: Add a newline for improved readability in BlogPostUtil class 2025-08-19 12:15:23 +01:00
Simon Larsen
01b0e01ca8 feat: Implement caching for blog metadata and improve author resolution without GitHub API calls 2025-08-19 12:07:33 +01:00
Simon Larsen
73dc6bb5db feat: Remove social media image display from blog post template 2025-08-19 11:55:38 +01:00
Simon Larsen
ab7fc1c244 feat: Enhance Markdown renderer with improved code block styling and add support for horizontal rules, emphasis, and strikethrough 2025-08-19 11:50:48 +01:00
Simon Larsen
3927bea29c feat: Enhance Markdown rendering with improved list, table, and inline code support; update tag display styling for better visual consistency 2025-08-19 11:47:21 +01:00
Simon Larsen
6060d66c2b feat: Revamp blog post layout with enhanced styling, author details, and reading time estimation 2025-08-19 11:37:10 +01:00
Nawaz Dhandala
9f4869b05f test: Enhance sitemap tests to validate presence of dynamic sections 2025-08-19 10:07:11 +01:00
Nawaz Dhandala
17bdfee012 refactor: Simplify regex usage in sitemap tests and improve middleware formatting 2025-08-19 10:05:59 +01:00
Simon Larsen
4988b9fc7a feat: Refactor mobile menu implementation for improved accessibility and usability 2025-08-19 10:04:20 +01:00
Simon Larsen
9edc6b9f18 feat: Enhance mobile navigation and improve header styling for better accessibility 2025-08-19 09:58:03 +01:00
Simon Larsen
525e19faa6 feat: Improve tab accessibility and keyboard navigation with semantic identifiers 2025-08-19 09:49:23 +01:00
Simon Larsen
588e8976d2 feat: Add middleware to inject home URL for canonical links and update canonical tag in head-basic.ejs 2025-08-19 09:47:59 +01:00
Simon Larsen
d5e28e98fb Merge branch 'master' of github.com:OneUptime/oneuptime 2025-08-19 09:39:45 +01:00
Simon Larsen
0e84bc9c40 feat: Enhance accessibility and keyboard navigation for product tabs 2025-08-19 09:39:42 +01:00
Nawaz Dhandala
39e8b1da6b refactor: Enhance type annotations and improve code readability in sitemap tests 2025-08-18 13:47:35 +01:00
Nawaz Dhandala
66c4badd94 refactor: Improve formatting of product pages check in sitemap tests 2025-08-18 13:41:01 +01:00
Simon Larsen
a245fabc34 feat: Add end-to-end tests for sitemap loading and validation 2025-08-18 13:39:19 +01:00
Nawaz Dhandala
fa9fce2774 refactor: Improve type annotations and error handling in various modules 2025-08-18 12:59:17 +01:00
Nawaz Dhandala
a256f4be54 Refactor logging statements for improved readability and consistency across services
- Updated logging statements in Sitemap.ts to enhance code clarity.
- Reformatted logger.info calls in IncomingRequestIngest, OpenTelemetryIngest, Probe, ProbeIngest, and ServerMonitorIngest to use multi-line formatting for better readability.
- Adjusted import statements in Probe to follow a consistent multi-line format.
2025-08-18 12:48:23 +01:00
154 changed files with 8581 additions and 3441 deletions

View File

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

View File

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

View File

@@ -184,18 +184,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--file ./MCP/Dockerfile.tpl \
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag oneuptime/mcp-server:release \
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag ghcr.io/oneuptime/mcp-server:release \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
--push .
echo "✅ Pushed Docker images to Docker Hub and GitHub Container Registry"
uses: nick-fields/retry@v3
with:
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--file ./MCP/Dockerfile.tpl \
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag oneuptime/mcp-server:release \
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag ghcr.io/oneuptime/mcp-server:release \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
--push .
echo "✅ Pushed Docker images to Docker Hub and GitHub Container Registry"
- name: Upload MCP server artifact
uses: actions/upload-artifact@v4
@@ -252,17 +256,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Nginx/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Nginx/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/nginx:release \
--tag oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/nginx:release \
--tag ghcr.io/oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
e2e-docker-image-deploy:
needs: [generate-build-number]
@@ -312,17 +321,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./E2E/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./E2E/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/e2e:release \
--tag oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/e2e:release \
--tag ghcr.io/oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
isolated-vm-docker-image-deploy:
needs: [generate-build-number]
@@ -372,17 +386,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./IsolatedVM/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./IsolatedVM/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/isolated-vm:release \
--tag oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/isolated-vm:release \
--tag ghcr.io/oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
home-docker-image-deploy:
needs: [generate-build-number]
@@ -432,17 +451,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Home/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Home/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/home:release \
--tag oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/home:release \
--tag ghcr.io/oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -495,17 +519,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./TestServer/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./TestServer/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/test-server:release \
--tag oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/test-server:release \
--tag ghcr.io/oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
otel-collector-docker-image-deploy:
needs: [generate-build-number]
@@ -555,17 +584,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./OTelCollector/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./OTelCollector/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/otel-collector:release \
--tag oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/otel-collector:release \
--tag ghcr.io/oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -617,17 +651,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./StatusPage/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./StatusPage/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/status-page:release \
--tag oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/status-page:release \
--tag ghcr.io/oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
test-docker-image-deploy:
needs: [generate-build-number]
@@ -677,17 +716,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Tests/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Tests/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/test:release \
--tag oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/test:release \
--tag ghcr.io/oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
probe-ingest-docker-image-deploy:
needs: [generate-build-number]
@@ -737,17 +781,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./ProbeIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./ProbeIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/probe-ingest:release \
--tag oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/probe-ingest:release \
--tag ghcr.io/oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
server-monitor-ingest-docker-image-deploy:
@@ -798,17 +847,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./ServerMonitorIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./ServerMonitorIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/server-monitor-ingest:release \
--tag oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/server-monitor-ingest:release \
--tag ghcr.io/oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -860,17 +914,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./OpenTelemetryIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./OpenTelemetryIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/open-telemetry-ingest:release \
--tag oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/open-telemetry-ingest:release \
--tag ghcr.io/oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
incoming-request-ingest-docker-image-deploy:
@@ -921,17 +980,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./IncomingRequestIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./IncomingRequestIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/incoming-request-ingest:release \
--tag oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/incoming-request-ingest:release \
--tag ghcr.io/oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
fluent-ingest-docker-image-deploy:
needs: [generate-build-number]
@@ -981,17 +1045,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./FluentIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./FluentIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/fluent-ingest:release \
--tag oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/fluent-ingest:release \
--tag ghcr.io/oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
probe-docker-image-deploy:
needs: [generate-build-number]
@@ -1041,17 +1110,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Probe/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Probe/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/probe:release \
--tag oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/probe:release \
--tag ghcr.io/oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
admin-dashboard-docker-image-deploy:
needs: [generate-build-number]
@@ -1101,17 +1175,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./AdminDashboard/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./AdminDashboard/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/admin-dashboard:release \
--tag oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/admin-dashboard:release \
--tag ghcr.io/oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
dashboard-docker-image-deploy:
@@ -1162,17 +1241,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Dashboard/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Dashboard/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/dashboard:release \
--tag oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/dashboard:release \
--tag ghcr.io/oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
app-docker-image-deploy:
needs: [generate-build-number]
@@ -1222,17 +1306,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./App/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./App/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/app:release \
--tag oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/app:release \
--tag ghcr.io/oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
copilot-docker-image-deploy:
@@ -1283,17 +1372,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Copilot/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Copilot/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/copilot:release \
--tag oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/copilot:release \
--tag ghcr.io/oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
accounts-docker-image-deploy:
needs: [generate-build-number]
@@ -1343,17 +1437,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Accounts/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Accounts/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/accounts:release \
--tag oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/accounts:release \
--tag ghcr.io/oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
publish-npm-packages:
@@ -1443,17 +1542,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./LLM/Dockerfile
context: ./LLM
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./LLM/Dockerfile \
--platform linux/amd64 \
--push \
--tag oneuptime/llm:release \
--tag oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/llm:release \
--tag ghcr.io/oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
./LLM
docs-docker-image-deploy:
needs: generate-build-number
@@ -1505,17 +1609,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Docs/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Docs/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/docs:release \
--tag oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/docs:release \
--tag ghcr.io/oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -1570,17 +1679,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Worker/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Worker/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/worker:release \
--tag oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/worker:release \
--tag ghcr.io/oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -1635,17 +1749,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Workflow/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./Workflow/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/workflow:release \
--tag oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/workflow:release \
--tag ghcr.io/oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -1761,17 +1880,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./APIReference/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 45
max_attempts: 3
command: |
docker buildx build \
--file ./APIReference/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/api-reference:release \
--tag oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}} \
--tag ghcr.io/oneuptime/api-reference:release \
--tag ghcr.io/oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}} \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -1898,12 +2022,49 @@ jobs:
configuration: "./Scripts/Release/ChangelogConfig.json"
- run: echo "Changelog:"
- run: echo "${{steps.build_changelog.outputs.changelog}}"
- name: Fallback to commit messages if changelog empty
id: fallback_changelog
shell: bash
run: |
set -euo pipefail
CHANGELOG_CONTENT="${{steps.build_changelog.outputs.changelog}}"
OLD_PLACEHOLDER="No significant changes were made. We have just fixed minor bugs for this release. You can find the detailed information in the commit history."
NEW_PLACEHOLDER="(auto) No categorized pull requests. Fallback will list raw commit messages."
if echo "$CHANGELOG_CONTENT" | grep -Fq "$OLD_PLACEHOLDER" || echo "$CHANGELOG_CONTENT" | grep -Fq "$NEW_PLACEHOLDER"; then
echo "Detected empty placeholder changelog. Building commit list fallback."
# Find previous tag (skip the most recent tag which might be for an older release). If none, include all commits.
if prev_tag=$(git describe --tags --abbrev=0 $(git rev-list --tags --skip=1 --max-count=1) 2>/dev/null); then
echo "Previous tag: $prev_tag"
commits=$(git log --pretty=format:'- %s (%h)' "$prev_tag"..HEAD)
else
echo "No previous tag found; using full commit history on this branch."
commits=$(git log --pretty=format:'- %s (%h)')
fi
# If still empty (e.g., no commits), keep placeholder to avoid empty body.
if [ -z "$commits" ]; then
commits="(no commits found)"
fi
{
echo "changelog<<EOF"
echo "## Commit Messages"
echo ""
echo "$commits"
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
# Pass through original changelog
{
echo "changelog<<EOF"
echo "$CHANGELOG_CONTENT"
echo "EOF"
} >> "$GITHUB_OUTPUT"
fi
- uses: ncipollo/release-action@v1
with:
tag: "7.0.${{needs.generate-build-number.outputs.build_number}}"
artifactErrorsFailBuild: true
body: |
${{steps.build_changelog.outputs.changelog}}
${{steps.fallback_changelog.outputs.changelog}}
infrastructure-agent-deploy:

View File

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

View File

@@ -176,18 +176,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker images (test)
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--file ./MCP/Dockerfile.tpl \
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag oneuptime/mcp-server:test \
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag ghcr.io/oneuptime/mcp-server:test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
--push .
echo "✅ Pushed test Docker images to Docker Hub and GitHub Container Registry"
uses: nick-fields/retry@v3
with:
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--file ./MCP/Dockerfile.tpl \
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag oneuptime/mcp-server:test \
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
--tag ghcr.io/oneuptime/mcp-server:test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
--push .
echo "✅ Pushed test Docker images to Docker Hub and GitHub Container Registry"
- name: Upload MCP server artifact
uses: actions/upload-artifact@v4
@@ -269,18 +273,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./LLM/Dockerfile
context: ./LLM
# arm64 is not supported by the base image of the LLM
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./LLM/Dockerfile \
--platform linux/amd64 \
--push \
--tag oneuptime/llm:test \
--tag oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/llm:test \
--tag ghcr.io/oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
./LLM
nginx-docker-image-deploy:
@@ -332,17 +340,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Nginx/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Nginx/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/nginx:test \
--tag oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/nginx:test \
--tag ghcr.io/oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
e2e-docker-image-deploy:
@@ -394,17 +407,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./E2E/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./E2E/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/e2e:test \
--tag oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/e2e:test \
--tag ghcr.io/oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
test-server-docker-image-deploy:
needs: generate-build-number
@@ -455,17 +473,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./TestServer/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./TestServer/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/test-server:test \
--tag oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/test-server:test \
--tag ghcr.io/oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
otel-collector-docker-image-deploy:
needs: generate-build-number
@@ -516,17 +539,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./OTelCollector/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./OTelCollector/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/otel-collector:test \
--tag oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/otel-collector:test \
--tag ghcr.io/oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
isolated-vm-docker-image-deploy:
needs: generate-build-number
@@ -577,17 +605,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./IsolatedVM/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./IsolatedVM/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/isolated-vm:test \
--tag oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/isolated-vm:test \
--tag ghcr.io/oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
home-docker-image-deploy:
needs: generate-build-number
@@ -638,17 +671,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Home/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Home/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/home:test \
--tag oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/home:test \
--tag ghcr.io/oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -701,17 +739,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./StatusPage/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./StatusPage/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/status-page:test \
--tag oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/status-page:test \
--tag ghcr.io/oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -764,17 +807,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Tests/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Tests/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/test:test \
--tag oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/test:test \
--tag ghcr.io/oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
probe-ingest-docker-image-deploy:
needs: generate-build-number
@@ -825,17 +873,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./ProbeIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./ProbeIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/probe-ingest:test \
--tag oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/probe-ingest:test \
--tag ghcr.io/oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -888,17 +941,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./ServerMonitorIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./ServerMonitorIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/server-monitor-ingest:test \
--tag oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/server-monitor-ingest:test \
--tag ghcr.io/oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -952,17 +1010,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./IncomingRequestIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./IncomingRequestIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/incoming-request-ingest:test \
--tag oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/incoming-request-ingest:test \
--tag ghcr.io/oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
open-telemetry-ingest-docker-image-deploy:
needs: generate-build-number
@@ -1013,17 +1076,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./OpenTelemetryIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./OpenTelemetryIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/open-telemetry-ingest:test \
--tag oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/open-telemetry-ingest:test \
--tag ghcr.io/oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
fluent-ingest-docker-image-deploy:
needs: generate-build-number
@@ -1074,17 +1142,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./FluentIngest/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./FluentIngest/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/fluent-ingest:test \
--tag oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/fluent-ingest:test \
--tag ghcr.io/oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
probe-docker-image-deploy:
needs: generate-build-number
@@ -1135,17 +1208,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Probe/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Probe/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/probe:test \
--tag oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/probe:test \
--tag ghcr.io/oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
dashboard-docker-image-deploy:
needs: generate-build-number
@@ -1196,17 +1274,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Dashboard/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Dashboard/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/dashboard:test \
--tag oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/dashboard:test \
--tag ghcr.io/oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
admin-dashboard-docker-image-deploy:
needs: generate-build-number
@@ -1257,17 +1340,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./AdminDashboard/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./AdminDashboard/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/admin-dashboard:test \
--tag oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/admin-dashboard:test \
--tag ghcr.io/oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
app-docker-image-deploy:
needs: generate-build-number
@@ -1318,17 +1406,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./App/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./App/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/app:test \
--tag oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/app:test \
--tag ghcr.io/oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -1382,17 +1475,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./APIReference/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./APIReference/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/api-reference:test \
--tag oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/api-reference:test \
--tag ghcr.io/oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
@@ -1445,17 +1543,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Accounts/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Accounts/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/accounts:test \
--tag oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/accounts:test \
--tag ghcr.io/oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
worker-docker-image-deploy:
needs: generate-build-number
@@ -1506,17 +1609,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Worker/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.copilot-docker-image-deploybuild_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Worker/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/worker:test \
--tag oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/worker:test \
--tag ghcr.io/oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
copilot-docker-image-deploy:
needs: generate-build-number
@@ -1567,17 +1675,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Copilot/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Copilot/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/copilot:test \
--tag oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/copilot:test \
--tag ghcr.io/oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
workflow-docker-image-deploy:
@@ -1629,17 +1742,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Workflow/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Workflow/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/workflow:test \
--tag oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/workflow:test \
--tag ghcr.io/oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.
docs-docker-image-deploy:
@@ -1691,17 +1809,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: nick-fields/retry@v3
with:
file: ./Docs/Dockerfile
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_SHA=${{ github.sha }}
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
timeout_minutes: 30
max_attempts: 3
command: |
docker buildx build \
--file ./Docs/Dockerfile \
--platform linux/amd64,linux/arm64 \
--push \
--tag oneuptime/docs:test \
--tag oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--tag ghcr.io/oneuptime/docs:test \
--tag ghcr.io/oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
--build-arg GIT_SHA=${{ github.sha }} \
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
.

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
import UserService from "Common/Server/Services/UserService";
import TeamMemberService from "Common/Server/Services/TeamMemberService";
import SCIMUserService from "Common/Server/Services/SCIMUserService";
import Express, {
ExpressRequest,
ExpressResponse,
@@ -15,12 +16,12 @@ 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 SCIMUser from "Common/Models/DatabaseModels/SCIMUser";
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 {
@@ -30,11 +31,240 @@ import {
generateUsersListResponse,
parseSCIMQueryParams,
logSCIMOperation,
extractEmailFromSCIM,
extractExternalIdFromSCIM,
isUserNameEmail,
} from "../Utils/SCIMUtils";
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
const router: ExpressRouter = Express.getRouter();
// Helper function to find user by external ID or email
const findUserByExternalIdOrEmail: (
userName: string,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<User | null> = async (
userName: string,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<User | null> => {
// First check if userName is an external ID (not an email)
if (!isUserNameEmail(userName)) {
logSCIMOperation(
"User lookup",
"project",
scimConfigId.toString(),
`Looking for external ID: ${userName}`,
);
// Look up by external ID
const scimUser: SCIMUser | null = await SCIMUserService.findOneBy({
query: {
externalId: userName,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: {
userId: true,
user: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
},
props: { isRoot: true },
});
if (scimUser && scimUser.user) {
logSCIMOperation(
"User lookup",
"project",
scimConfigId.toString(),
`Found user by external ID: ${scimUser.user.id}`,
);
return scimUser.user;
}
}
// Fall back to email lookup
try {
logSCIMOperation(
"User lookup",
"project",
scimConfigId.toString(),
`Looking for email: ${userName}`,
);
const user: User | null = await UserService.findOneBy({
query: { email: new Email(userName) },
select: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (user) {
logSCIMOperation(
"User lookup",
"project",
scimConfigId.toString(),
`Found user by email: ${user.id}`,
);
}
return user;
} catch (error) {
// If email validation fails, userName is likely an external ID but no mapping exists
logSCIMOperation(
"User lookup",
"project",
scimConfigId.toString(),
`Email validation failed for: ${userName}, treating as external ID with no mapping`,
);
return null;
}
};
// Helper function to create or update SCIM user mapping
const createOrUpdateSCIMUserMapping: (
user: User,
externalId: string,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<void> = async (
user: User,
externalId: string,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<void> => {
// Check if mapping already exists
const existingMapping: SCIMUser | null = await SCIMUserService.findOneBy({
query: {
userId: user.id!,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: { _id: true, externalId: true },
props: { isRoot: true },
});
if (existingMapping) {
// Update existing mapping if external ID changed
if (existingMapping.externalId !== externalId) {
await SCIMUserService.updateOneById({
id: existingMapping.id!,
data: { externalId: externalId },
props: { isRoot: true },
});
logSCIMOperation(
"SCIM mapping",
"project",
scimConfigId.toString(),
`Updated external ID mapping for user ${user.id} from ${existingMapping.externalId} to ${externalId}`,
);
}
} else {
// Create new mapping
const scimUser: SCIMUser = new SCIMUser();
scimUser.projectId = projectId;
scimUser.scimConfigId = scimConfigId;
scimUser.userId = user.id!;
scimUser.externalId = externalId;
await SCIMUserService.create({
data: scimUser,
props: { isRoot: true },
});
logSCIMOperation(
"SCIM mapping",
"project",
scimConfigId.toString(),
`Created external ID mapping for user ${user.id} with external ID ${externalId}`,
);
}
};
// Helper function to resolve user ID (could be internal ID or external ID)
const resolveUserId: (
userIdParam: string,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<ObjectID | null> = async (
userIdParam: string,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<ObjectID | null> => {
// First try to parse as ObjectID (internal user ID)
try {
const objectId: ObjectID = new ObjectID(userIdParam);
// Verify this user exists in the project
const teamMember: TeamMember | null = await TeamMemberService.findOneBy({
query: {
projectId: projectId,
userId: objectId,
},
select: { userId: true },
props: { isRoot: true },
});
if (teamMember) {
return objectId;
}
} catch (error) {
// Not a valid ObjectID, continue to external ID lookup
}
// Try to find by external ID
const scimUser: SCIMUser | null = await SCIMUserService.findOneBy({
query: {
externalId: userIdParam,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: { userId: true },
props: { isRoot: true },
});
if (scimUser && scimUser.userId) {
return scimUser.userId;
}
return null;
};
// Helper function to get external ID for a user
const getExternalIdForUser: (
userId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<string | null> = async (
userId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<string | null> => {
const scimUser: SCIMUser | null = await SCIMUserService.findOneBy({
query: {
userId: userId,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: { externalId: true },
props: { isRoot: true },
});
return scimUser?.externalId || null;
};
const handleUserTeamOperations: (
operation: "add" | "remove",
projectId: ObjectID,
@@ -89,6 +319,8 @@ const handleUserTeamOperations: (
ignoreHooks: true,
},
});
logger.debug(`SCIM Team operations - user added to team: ${team.id}`);
} else {
logger.debug(
`SCIM Team operations - user already member of team: ${team.id}`,
@@ -119,6 +351,10 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: GET ServiceProviderConfig - projectScimId: ${req.params["projectScimId"]}`,
);
logSCIMOperation(
"ServiceProviderConfig",
"project",
@@ -149,6 +385,10 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: GET Users List - projectScimId: ${req.params["projectScimId"]}, query: ${JSON.stringify(req.query)}`,
);
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
@@ -168,7 +408,7 @@ router.get(
);
// Build query for team members in this project
const query: Query<ProjectUser> = {
const query: Query<TeamMember> = {
projectId: projectId,
};
@@ -178,20 +418,21 @@ router.get(
/userName eq "([^"]+)"/i,
);
if (emailMatch) {
const email: string = emailMatch[1]!;
const userName: string = emailMatch[1]!;
logSCIMOperation(
"Users list",
"project",
req.params["projectScimId"]!,
`filter by email: ${email}`,
`filter by userName: ${userName}`,
);
if (email) {
const user: User | null = await UserService.findOneBy({
query: { email: new Email(email) },
select: { _id: true },
props: { isRoot: true },
});
if (userName) {
const user: User | null = await findUserByExternalIdOrEmail(
userName,
projectId,
new ObjectID(req.params["projectScimId"]!),
);
if (user && user.id) {
query.userId = user.id;
logSCIMOperation(
@@ -205,7 +446,7 @@ router.get(
"Users list",
"project",
req.params["projectScimId"]!,
`user not found for email: ${email}`,
`user not found for userName: ${userName}`,
);
return Response.sendJsonObjectResponse(
req,
@@ -243,18 +484,28 @@ router.get(
});
// 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!,
const usersInProjects: Array<JSONObject> = [];
for (const tm of teamMembers) {
if (tm.user && tm.user.id) {
// Get external ID for this user if it exists
const externalId: string | null = await getExternalIdForUser(
tm.user.id,
projectId,
new ObjectID(req.params["projectScimId"]!),
);
const userFormatted: JSONObject = formatUserForSCIM(
tm.user,
req,
req.params["projectScimId"]!,
"project",
externalId,
);
});
usersInProjects.push(userFormatted);
}
}
// remove duplicates
const uniqueUserIds: Set<string> = new Set<string>();
@@ -294,6 +545,10 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: GET Individual User - projectScimId: ${req.params["projectScimId"]}, userId: ${req.params["userId"]}`,
);
logger.debug(
`SCIM Get individual user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
);
@@ -301,21 +556,38 @@ router.get(
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const userId: string = req.params["userId"]!;
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
const userIdParam: string = req.params["userId"]!;
logger.debug(
`SCIM Get user - projectId: ${projectId}, userId: ${userId}`,
`SCIM Get user - projectId: ${projectId}, userIdParam: ${userIdParam}`,
);
if (!userIdParam) {
throw new BadRequestException("User ID is required");
}
// Resolve user ID (could be internal ID or external ID)
const userId: ObjectID | null = await resolveUserId(
userIdParam,
projectId,
scimConfig.id!,
);
if (!userId) {
throw new BadRequestException("User ID is required");
logger.debug(
`SCIM Get user - could not resolve user ID for param: ${userIdParam}`,
);
throw new NotFoundException(
"User not found or not part of this project",
);
}
// Check if user exists and is part of the project
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
query: {
projectId: projectId,
userId: new ObjectID(userId),
userId: userId,
},
select: {
userId: true,
@@ -332,7 +604,7 @@ router.get(
if (!projectUser || !projectUser.user) {
logger.debug(
`SCIM Get user - user not found or not part of project for userId: ${userId}`,
`SCIM Get user - user not found or not part of project for resolved userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this project",
@@ -341,11 +613,19 @@ router.get(
logger.debug(`SCIM Get user - found user: ${projectUser.user.id}`);
// Get external ID for this user if it exists
const externalId: string | null = await getExternalIdForUser(
projectUser.user.id!,
projectId,
scimConfig.id!,
);
const user: JSONObject = formatUserForSCIM(
projectUser.user,
req,
req.params["projectScimId"]!,
"project",
externalId,
);
return Response.sendJsonObjectResponse(req, res, user);
@@ -362,6 +642,10 @@ router.put(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: PUT Update User - projectScimId: ${req.params["projectScimId"]}, userId: ${req.params["userId"]}`,
);
logger.debug(
`SCIM Update user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
);
@@ -369,70 +653,167 @@ router.put(
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const userId: string = req.params["userId"]!;
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
const userIdParam: string = req.params["userId"]!;
const scimUser: JSONObject = req.body;
logger.debug(
`SCIM Update user - projectId: ${projectId}, userId: ${userId}`,
`SCIM Update user - projectId: ${projectId}, userIdParam: ${userIdParam}`,
);
logger.debug(
`Request body for SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
);
if (!userId) {
if (!userIdParam) {
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);
// Extract user data from SCIM request
const userName: string = extractEmailFromSCIM(scimUser);
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
const name: string = parseNameFromSCIM(scimUser);
const active: boolean = scimUser["active"] as boolean;
logger.debug(
`SCIM Update user - email: ${email}, name: ${name}, active: ${active}`,
`SCIM Update user - userName: ${userName}, externalId: ${externalId}, name: ${name}, active: ${active}`,
);
// Extract email from emails array if userName is not an email
let email: string = "";
if (isUserNameEmail(userName)) {
email = userName;
} else {
// Look for email in the emails array
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
if (emailsArray && emailsArray.length > 0) {
email = emailsArray[0]?.["value"] as string;
}
}
// Resolve user ID (could be internal ID or external ID)
const userId: ObjectID | null = await resolveUserId(
userIdParam,
projectId,
scimConfig.id!,
);
let projectUser: TeamMember | null = null;
let user: User | null = null;
let isNewUser: boolean = false;
if (userId) {
// Check if user exists and is part of the project
projectUser = await TeamMemberService.findOneBy({
query: {
projectId: projectId,
userId: userId,
},
select: {
userId: true,
user: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
},
props: { isRoot: true },
});
if (projectUser && projectUser.user) {
user = projectUser.user;
}
}
// If user not found, create a new user (SCIM PUT should create if not exists)
if (!user) {
logger.debug(
`SCIM Update user - user not found for param: ${userIdParam}, creating new user`,
);
if (!scimConfig.autoProvisionUsers) {
throw new BadRequestException(
"Auto-provisioning is disabled for this project and user not found",
);
}
if (!email && !externalId) {
throw new BadRequestException(
"Either a valid email address or external ID is required to create user",
);
}
// Try to find existing user by email if we have one
if (email) {
try {
user = await UserService.findOneBy({
query: { email: new Email(email) },
select: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
} catch (error) {
// Email validation failed, continue without email lookup
logger.debug(`SCIM Update user - email validation failed for: ${email}`);
}
}
// Create new user if still not found
if (!user) {
if (!email) {
throw new BadRequestException(
"A valid email address is required to create a new user",
);
}
logger.debug(
`SCIM Update 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 },
});
isNewUser = true;
}
// Add user to default teams if configured
if (scimConfig.teams && scimConfig.teams.length > 0) {
logger.debug(
`SCIM Update user - adding new user to ${scimConfig.teams.length} configured teams`,
);
await handleUserTeamOperations("add", projectId, user.id!, scimConfig);
}
}
// Create or update SCIM user mapping if we have an external ID
if (externalId && user && user.id) {
await createOrUpdateSCIMUserMapping(
user,
externalId,
projectId,
scimConfig.id!,
);
}
// Handle user deactivation by removing from teams
if (active === false) {
if (active === false && user && user.id) {
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),
user.id,
scimConfig,
);
logger.debug(
@@ -441,15 +822,14 @@ router.put(
}
// Handle user activation by adding to teams
if (active === true) {
if (active === true && user && user.id) {
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),
user.id,
scimConfig,
);
logger.debug(
@@ -457,7 +837,8 @@ router.put(
);
}
if (email || name) {
// Update user information if needed and not a new user
if (!isNewUser && user && user.id && (email || name)) {
const updateData: any = {};
if (email) {
updateData.email = new Email(email);
@@ -467,11 +848,11 @@ router.put(
}
logger.debug(
`SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
`SCIM Update user - updating existing user with data: ${JSON.stringify(updateData)}`,
);
await UserService.updateOneById({
id: new ObjectID(userId),
id: user.id,
data: updateData,
props: { isRoot: true },
});
@@ -480,7 +861,7 @@ router.put(
// Fetch updated user
const updatedUser: User | null = await UserService.findOneById({
id: new ObjectID(userId),
id: user.id,
select: {
_id: true,
email: true,
@@ -492,29 +873,32 @@ router.put(
});
if (updatedUser) {
const user: JSONObject = formatUserForSCIM(
updatedUser,
req,
req.params["projectScimId"]!,
"project",
);
return Response.sendJsonObjectResponse(req, res, user);
user = updatedUser;
}
}
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,
// Get external ID for response
const userExternalId: string | null = user && user.id ?
await getExternalIdForUser(user.id, projectId, scimConfig.id!) :
externalId;
const responseUser: JSONObject = formatUserForSCIM(
user!,
req,
req.params["projectScimId"]!,
"project",
userExternalId,
);
return Response.sendJsonObjectResponse(req, res, user);
// Set status code based on whether user was created or updated
if (isNewUser) {
res.status(201);
logger.debug(`SCIM Update user - returning newly created user with id: ${user!.id}`);
} else {
logger.debug(`SCIM Update user - returning updated user with id: ${user!.id}`);
}
return Response.sendJsonObjectResponse(req, res, responseUser);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
@@ -528,6 +912,10 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: GET Groups - projectScimId: ${req.params["projectScimId"]}`,
);
logger.debug(
`SCIM Groups list request for projectScimId: ${req.params["projectScimId"]}`,
);
@@ -574,6 +962,10 @@ router.post(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: POST Create User - projectScimId: ${req.params["projectScimId"]}`,
);
logger.debug(
`SCIM Create user request for projectScimId: ${req.params["projectScimId"]}`,
);
@@ -590,32 +982,68 @@ router.post(
}
const scimUser: JSONObject = req.body;
const email: string =
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
const userName: string = extractEmailFromSCIM(scimUser);
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
const name: string = parseNameFromSCIM(scimUser);
logger.debug(`SCIM Create user - email: ${email}, name: ${name}`);
logger.debug(`SCIM Create user - userName: ${userName}, externalId: ${externalId}, name: ${name}`);
if (!email) {
throw new BadRequestException("userName or email is required");
// Extract email from emails array if userName is not an email
let email: string = "";
if (isUserNameEmail(userName)) {
email = userName;
} else {
// Look for email in the emails array
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
if (emailsArray && emailsArray.length > 0) {
email = emailsArray[0]?.["value"] as string;
}
}
// 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 },
});
if (!email && !externalId) {
throw new BadRequestException(
"Either a valid email address or external ID is required",
);
}
// Check if user already exists (by external ID first, then email)
let user: User | null = null;
if (externalId) {
user = await findUserByExternalIdOrEmail(
externalId,
projectId,
scimConfig.id!,
);
}
if (!user && email) {
try {
user = await UserService.findOneBy({
query: { email: new Email(email) },
select: {
_id: true,
email: true,
name: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
} catch (error) {
// Email validation failed, continue without email lookup
logger.debug(`SCIM Create user - email validation failed for: ${email}`);
}
}
// Create user if doesn't exist
if (!user) {
if (!email) {
throw new BadRequestException(
"A valid email address is required to create a new user",
);
}
logger.debug(
`SCIM Create user - creating new user for email: ${email}`,
);
@@ -632,6 +1060,16 @@ router.post(
);
}
// Create or update SCIM user mapping if we have an external ID
if (externalId && user.id) {
await createOrUpdateSCIMUserMapping(
user,
externalId,
projectId,
scimConfig.id!,
);
}
// Add user to default teams if configured
if (scimConfig.teams && scimConfig.teams.length > 0) {
logger.debug(
@@ -645,6 +1083,7 @@ router.post(
req,
req.params["projectScimId"]!,
"project",
externalId,
);
logger.debug(
@@ -666,6 +1105,10 @@ router.delete(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 PROJECT SCIM API HIT: DELETE User - projectScimId: ${req.params["projectScimId"]}, userId: ${req.params["userId"]}`,
);
logger.debug(
`SCIM Delete user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
);
@@ -674,7 +1117,7 @@ router.delete(
oneuptimeRequest.bearerTokenData as JSONObject;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
const userId: string = req.params["userId"]!;
const userIdParam: string = req.params["userId"]!;
if (!scimConfig.autoDeprovisionUsers) {
logger.debug("SCIM Delete user - auto-deprovisioning is disabled");
@@ -683,10 +1126,26 @@ router.delete(
);
}
if (!userId) {
if (!userIdParam) {
throw new BadRequestException("User ID is required");
}
// Resolve user ID (could be internal ID or external ID)
const userId: ObjectID | null = await resolveUserId(
userIdParam,
projectId,
scimConfig.id!,
);
if (!userId) {
logger.debug(
`SCIM Delete user - could not resolve user ID for param: ${userIdParam}`,
);
throw new NotFoundException(
"User not found or not part of this project",
);
}
logger.debug(
`SCIM Delete user - removing user from all teams in project: ${projectId}`,
);
@@ -700,7 +1159,7 @@ router.delete(
await handleUserTeamOperations(
"remove",
projectId,
new ObjectID(userId),
userId,
scimConfig,
);

View File

@@ -1,5 +1,6 @@
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
import StatusPageSCIMUserService from "Common/Server/Services/StatusPageSCIMUserService";
import Express, {
ExpressRequest,
ExpressResponse,
@@ -13,25 +14,276 @@ 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 StatusPageSCIMUser from "Common/Models/DatabaseModels/StatusPageSCIMUser";
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 LIMIT_MAX from "Common/Types/Database/LimitMax";
import {
parseNameFromSCIM,
formatUserForSCIM,
generateServiceProviderConfig,
generateUsersListResponse,
parseSCIMQueryParams,
logSCIMOperation,
extractEmailFromSCIM,
extractExternalIdFromSCIM,
isUserNameEmail,
} from "../Utils/SCIMUtils";
import Text from "Common/Types/Text";
import HashedString from "Common/Types/HashedString";
const router: ExpressRouter = Express.getRouter();
// Helper function to find user by external ID or email
const findUserByExternalIdOrEmail: (
userName: string,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<StatusPagePrivateUser | null> = async (
userName: string,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<StatusPagePrivateUser | null> => {
// First check if userName is an external ID (not an email)
if (!isUserNameEmail(userName)) {
logSCIMOperation(
"User lookup",
"status-page",
scimConfigId.toString(),
`Looking for external ID: ${userName}`,
);
// Look up by external ID
const scimUser: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
query: {
externalId: userName,
statusPageId: statusPageId,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: {
statusPagePrivateUserId: true,
statusPagePrivateUser: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
},
props: { isRoot: true },
});
if (scimUser && scimUser.statusPagePrivateUser) {
logSCIMOperation(
"User lookup",
"status-page",
scimConfigId.toString(),
`Found user by external ID: ${scimUser.statusPagePrivateUser.id}`,
);
return scimUser.statusPagePrivateUser;
}
}
// Fall back to email lookup
try {
logSCIMOperation(
"User lookup",
"status-page",
scimConfigId.toString(),
`Looking for email: ${userName}`,
);
const user: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
query: {
email: new Email(userName),
statusPageId: statusPageId,
},
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (user) {
logSCIMOperation(
"User lookup",
"status-page",
scimConfigId.toString(),
`Found user by email: ${user.id}`,
);
}
return user;
} catch (error) {
// If email validation fails, userName is likely an external ID but no mapping exists
logSCIMOperation(
"User lookup",
"status-page",
scimConfigId.toString(),
`Email validation failed for: ${userName}, treating as external ID with no mapping`,
);
return null;
}
};
// Helper function to create or update SCIM user mapping
const createOrUpdateSCIMUserMapping: (
user: StatusPagePrivateUser,
externalId: string,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<void> = async (
user: StatusPagePrivateUser,
externalId: string,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<void> => {
// Check if mapping already exists
const existingMapping: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
query: {
statusPagePrivateUserId: user.id!,
statusPageId: statusPageId,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: { _id: true, externalId: true },
props: { isRoot: true },
});
if (existingMapping) {
// Update existing mapping if external ID changed
if (existingMapping.externalId !== externalId) {
await StatusPageSCIMUserService.updateOneById({
id: existingMapping.id!,
data: { externalId: externalId },
props: { isRoot: true },
});
logSCIMOperation(
"SCIM mapping",
"status-page",
scimConfigId.toString(),
`Updated external ID mapping for user ${user.id} from ${existingMapping.externalId} to ${externalId}`,
);
}
} else {
// Create new mapping
const scimUser: StatusPageSCIMUser = new StatusPageSCIMUser();
scimUser.statusPageId = statusPageId;
scimUser.projectId = projectId;
scimUser.scimConfigId = scimConfigId;
scimUser.statusPagePrivateUserId = user.id!;
scimUser.externalId = externalId;
await StatusPageSCIMUserService.create({
data: scimUser,
props: { isRoot: true },
});
logSCIMOperation(
"SCIM mapping",
"status-page",
scimConfigId.toString(),
`Created external ID mapping for user ${user.id} with external ID ${externalId}`,
);
}
};
// Helper function to resolve user ID (could be internal ID or external ID)
const resolveUserId: (
userIdParam: string,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<ObjectID | null> = async (
userIdParam: string,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<ObjectID | null> => {
// First try to parse as ObjectID (internal user ID)
try {
const objectId: ObjectID = new ObjectID(userIdParam);
// Verify this user exists in the status page
const statusPageUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
query: {
_id: objectId,
statusPageId: statusPageId,
},
select: { _id: true },
props: { isRoot: true },
});
if (statusPageUser) {
return objectId;
}
} catch (error) {
// Not a valid ObjectID, continue to external ID lookup
}
// Try to find by external ID
const scimUser: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
query: {
externalId: userIdParam,
statusPageId: statusPageId,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: { statusPagePrivateUserId: true },
props: { isRoot: true },
});
if (scimUser && scimUser.statusPagePrivateUserId) {
return scimUser.statusPagePrivateUserId;
}
return null;
};
// Helper function to get external ID for a user
const getExternalIdForUser: (
userId: ObjectID,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
) => Promise<string | null> = async (
userId: ObjectID,
statusPageId: ObjectID,
projectId: ObjectID,
scimConfigId: ObjectID,
): Promise<string | null> => {
const scimUser: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
query: {
statusPagePrivateUserId: userId,
statusPageId: statusPageId,
projectId: projectId,
scimConfigId: scimConfigId,
},
select: { externalId: true },
props: { isRoot: true },
});
return scimUser?.externalId || null;
};
// 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 {
logger.debug(
`🔗 STATUS PAGE SCIM API HIT: GET ServiceProviderConfig - statusPageScimId: ${req.params["statusPageScimId"]}`,
);
logSCIMOperation(
"ServiceProviderConfig",
"status-page",
@@ -58,6 +310,10 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 STATUS PAGE SCIM API HIT: GET Users List - statusPageScimId: ${req.params["statusPageScimId"]}, query: ${JSON.stringify(req.query)}`,
);
logger.debug(
`Status Page SCIM Users list request for statusPageScimId: ${req.params["statusPageScimId"]}`,
);
@@ -65,22 +321,66 @@ router.get(
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
// 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,
const { startIndex, count } = parseSCIMQueryParams(req);
const filter: string = req.query["filter"] as string;
logSCIMOperation(
"Users list",
"status-page",
req.params["statusPageScimId"]!,
`statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
);
logger.debug(
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
);
let users: Array<StatusPagePrivateUser> = [];
// Get all private users for this status page
const statusPageUsers: Array<StatusPagePrivateUser> =
await StatusPagePrivateUserService.findBy({
// Handle SCIM filter for userName
if (filter) {
const emailMatch: RegExpMatchArray | null = filter.match(
/userName eq "([^"]+)"/i,
);
if (emailMatch) {
const userName: string = emailMatch[1]!;
logSCIMOperation(
"Users list",
"status-page",
req.params["statusPageScimId"]!,
`filter by userName: ${userName}`,
);
if (userName) {
const user: StatusPagePrivateUser | null = await findUserByExternalIdOrEmail(
userName,
statusPageId,
projectId,
scimConfig.id!,
);
if (user) {
users = [user];
logSCIMOperation(
"Users list",
"status-page",
req.params["statusPageScimId"]!,
`found user with id: ${user.id}`,
);
} else {
logSCIMOperation(
"Users list",
"status-page",
req.params["statusPageScimId"]!,
`user not found for userName: ${userName}`,
);
users = [];
}
}
}
} else {
// Get all private users for this status page
users = await StatusPagePrivateUserService.findBy({
query: {
statusPageId: statusPageId,
},
@@ -94,40 +394,50 @@ router.get(
limit: LIMIT_MAX,
props: { isRoot: true },
});
}
logger.debug(
`Status Page SCIM Users - found ${statusPageUsers.length} users`,
`Status Page SCIM Users - found ${users.length} users`,
);
// Format users for SCIM
const users: Array<JSONObject> = statusPageUsers.map(
(user: StatusPagePrivateUser) => {
return formatUserForSCIM(
user,
req,
req.params["statusPageScimId"]!,
"status-page",
);
},
);
// Format users for SCIM with external IDs
const formattedUsers: Array<JSONObject> = [];
for (const user of users) {
// Get external ID for this user if it exists
const externalId: string | null = await getExternalIdForUser(
user.id!,
statusPageId,
projectId,
scimConfig.id!,
);
const userFormatted: JSONObject = formatUserForSCIM(
user,
req,
req.params["statusPageScimId"]!,
"status-page",
externalId,
);
formattedUsers.push(userFormatted);
}
// Paginate the results
const paginatedUsers: Array<JSONObject> = users.slice(
const paginatedUsers: Array<JSONObject> = formattedUsers.slice(
(startIndex - 1) * count,
startIndex * count,
);
logger.debug(
`Status Page SCIM Users response prepared with ${users.length} users`,
`Status Page SCIM Users response prepared with ${formattedUsers.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,
});
return Response.sendJsonObjectResponse(
req,
res,
generateUsersListResponse(paginatedUsers, startIndex, formattedUsers.length),
);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
@@ -141,6 +451,10 @@ router.get(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 STATUS PAGE SCIM API HIT: GET Individual User - statusPageScimId: ${req.params["statusPageScimId"]}, userId: ${req.params["userId"]}`,
);
logger.debug(
`Status Page SCIM Get individual user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
);
@@ -148,14 +462,33 @@ router.get(
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const userId: string = req.params["userId"]!;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
const userIdParam: string = req.params["userId"]!;
logger.debug(
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userId: ${userId}`,
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userIdParam: ${userIdParam}`,
);
if (!userIdParam) {
throw new BadRequestException("User ID is required");
}
// Resolve user ID (could be internal ID or external ID)
const userId: ObjectID | null = await resolveUserId(
userIdParam,
statusPageId,
projectId,
scimConfig.id!,
);
if (!userId) {
throw new BadRequestException("User ID is required");
logger.debug(
`Status Page SCIM Get user - could not resolve user ID for param: ${userIdParam}`,
);
throw new NotFoundException(
"User not found or not part of this status page",
);
}
// Check if user exists and belongs to this status page
@@ -163,7 +496,7 @@ router.get(
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
_id: new ObjectID(userId),
_id: userId,
},
select: {
_id: true,
@@ -176,18 +509,27 @@ router.get(
if (!statusPageUser) {
logger.debug(
`Status Page SCIM Get user - user not found for userId: ${userId}`,
`Status Page SCIM Get user - user not found for resolved userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this status page",
);
}
// Get external ID for this user if it exists
const externalId: string | null = await getExternalIdForUser(
statusPageUser.id!,
statusPageId,
projectId,
scimConfig.id!,
);
const user: JSONObject = formatUserForSCIM(
statusPageUser,
req,
req.params["statusPageScimId"]!,
"status-page",
externalId,
);
logger.debug(
@@ -208,6 +550,10 @@ router.post(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 STATUS PAGE SCIM API HIT: POST Create User - statusPageScimId: ${req.params["statusPageScimId"]}`,
);
logger.debug(
`Status Page SCIM Create user request for statusPageScimId: ${req.params["statusPageScimId"]}`,
);
@@ -215,6 +561,7 @@ router.post(
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData[
"scimConfig"
] as StatusPageSCIM;
@@ -226,6 +573,9 @@ router.post(
}
const scimUser: JSONObject = req.body;
const userName: string = extractEmailFromSCIM(scimUser);
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
const name: string = parseNameFromSCIM(scimUser);
logger.debug(
`Status Page SCIM Create user - statusPageId: ${statusPageId}`,
@@ -235,34 +585,67 @@ router.post(
`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);
logger.debug(`Status Page SCIM Create user - userName: ${userName}, externalId: ${externalId}, name: ${name}`);
if (!email) {
throw new BadRequestException("Email is required for user creation");
// Extract email from emails array if userName is not an email
let email: string = "";
if (isUserNameEmail(userName)) {
email = userName;
} else {
// Look for email in the emails array
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
if (emailsArray && emailsArray.length > 0) {
email = emailsArray[0]?.["value"] as string;
}
}
logger.debug(`Status Page SCIM Create user - email: ${email}`);
if (!email && !externalId) {
throw new BadRequestException(
"Either a valid email address or external ID is required",
);
}
// 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 },
});
// Check if user already exists (by external ID first, then email)
let user: StatusPagePrivateUser | null = null;
if (externalId) {
user = await findUserByExternalIdOrEmail(
externalId,
statusPageId,
projectId,
scimConfig.id!,
);
}
if (!user && email) {
try {
user = await StatusPagePrivateUserService.findOneBy({
query: {
email: new Email(email),
statusPageId: statusPageId,
},
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
} catch (error) {
// Email validation failed, continue without email lookup
logger.debug(`Status Page SCIM Create user - email validation failed for: ${email}`);
}
}
// Create user if doesn't exist
if (!user) {
if (!email) {
throw new BadRequestException(
"A valid email address is required to create a new user",
);
}
logger.debug(
`Status Page SCIM Create user - creating new user with email: ${email}`,
);
@@ -271,7 +654,7 @@ router.post(
privateUser.statusPageId = statusPageId;
privateUser.email = new Email(email);
privateUser.password = new HashedString(Text.generateRandomText(32));
privateUser.projectId = bearerData["projectId"] as ObjectID;
privateUser.projectId = projectId;
// Create new status page private user
user = await StatusPagePrivateUserService.create({
@@ -284,11 +667,23 @@ router.post(
);
}
// Create or update SCIM user mapping if we have an external ID
if (externalId && user.id) {
await createOrUpdateSCIMUserMapping(
user,
externalId,
statusPageId,
projectId,
scimConfig.id!,
);
}
const createdUser: JSONObject = formatUserForSCIM(
user,
req,
req.params["statusPageScimId"]!,
"status-page",
externalId,
);
logger.debug(
@@ -310,6 +705,10 @@ router.put(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 STATUS PAGE SCIM API HIT: PUT Update User - statusPageScimId: ${req.params["statusPageScimId"]}, userId: ${req.params["userId"]}`,
);
logger.debug(
`Status Page SCIM Update user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
);
@@ -317,27 +716,46 @@ router.put(
const bearerData: JSONObject =
oneuptimeRequest.bearerTokenData as JSONObject;
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
const userId: string = req.params["userId"]!;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
const userIdParam: string = req.params["userId"]!;
const scimUser: JSONObject = req.body;
logger.debug(
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userId: ${userId}`,
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userIdParam: ${userIdParam}`,
);
logger.debug(
`Request body for Status Page SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
);
if (!userId) {
if (!userIdParam) {
throw new BadRequestException("User ID is required");
}
// Resolve user ID (could be internal ID or external ID)
const userId: ObjectID | null = await resolveUserId(
userIdParam,
statusPageId,
projectId,
scimConfig.id!,
);
if (!userId) {
logger.debug(
`Status Page SCIM Update user - could not resolve user ID for param: ${userIdParam}`,
);
throw new NotFoundException(
"User not found or not part of this status page",
);
}
// Check if user exists and belongs to this status page
const statusPageUser: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
_id: new ObjectID(userId),
_id: userId,
},
select: {
_id: true,
@@ -350,7 +768,7 @@ router.put(
if (!statusPageUser) {
logger.debug(
`Status Page SCIM Update user - user not found for userId: ${userId}`,
`Status Page SCIM Update user - user not found for resolved userId: ${userId}`,
);
throw new NotFoundException(
"User not found or not part of this status page",
@@ -358,27 +776,46 @@ router.put(
}
// Update user information
const email: string =
(scimUser["userName"] as string) ||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
const userName: string = extractEmailFromSCIM(scimUser);
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
const active: boolean = scimUser["active"] as boolean;
logger.debug(
`Status Page SCIM Update user - email: ${email}, active: ${active}`,
`Status Page SCIM Update user - userName: ${userName}, externalId: ${externalId}, active: ${active}`,
);
// Extract email from emails array if userName is not an email
let email: string = "";
if (isUserNameEmail(userName)) {
email = userName;
} else {
// Look for email in the emails array
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
if (emailsArray && emailsArray.length > 0) {
email = emailsArray[0]?.["value"] as string;
}
}
// Create or update SCIM user mapping if we have an external ID
if (externalId) {
await createOrUpdateSCIMUserMapping(
statusPageUser,
externalId,
statusPageId,
projectId,
scimConfig.id!,
);
}
// 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),
id: userId,
props: { isRoot: true },
});
@@ -387,54 +824,51 @@ router.put(
);
// Return empty response for deleted user
return Response.sendJsonObjectResponse(req, res, {});
res.status(204);
return Response.sendJsonObjectResponse(req, res, {
message: "User deprovisioned",
});
}
}
// Prepare update data
const updateData: {
email?: Email;
} = {};
// Update email if provided and changed
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)}`,
`Status Page SCIM Update user - updating email from ${statusPageUser.email?.toString()} to ${email}`,
);
await StatusPagePrivateUserService.updateOneById({
id: new ObjectID(userId),
data: updateData,
id: userId,
data: { email: new Email(email) },
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 },
});
const updatedUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneById({
id: userId,
select: {
_id: true,
email: true,
createdAt: true,
updatedAt: true,
},
props: { isRoot: true },
});
if (updatedUser) {
const userExternalId: string | null = await getExternalIdForUser(
updatedUser.id!,
statusPageId,
projectId,
scimConfig.id!,
);
const user: JSONObject = formatUserForSCIM(
updatedUser,
req,
req.params["statusPageScimId"]!,
"status-page",
userExternalId,
);
return Response.sendJsonObjectResponse(req, res, user);
}
@@ -445,11 +879,19 @@ router.put(
);
// If no updates were made, return the existing user
const userExternalId: string | null = await getExternalIdForUser(
statusPageUser.id!,
statusPageId,
projectId,
scimConfig.id!,
);
const user: JSONObject = formatUserForSCIM(
statusPageUser,
req,
req.params["statusPageScimId"]!,
"status-page",
userExternalId,
);
return Response.sendJsonObjectResponse(req, res, user);
@@ -466,6 +908,10 @@ router.delete(
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
logger.debug(
`🔗 STATUS PAGE SCIM API HIT: DELETE User - statusPageScimId: ${req.params["statusPageScimId"]}, userId: ${req.params["userId"]}`,
);
logger.debug(
`Status Page SCIM Delete user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
);
@@ -473,10 +919,9 @@ router.delete(
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"]!;
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
const userIdParam: string = req.params["userId"]!;
if (!scimConfig.autoDeprovisionUsers) {
throw new BadRequestException(
@@ -485,11 +930,26 @@ router.delete(
}
logger.debug(
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userId: ${userId}`,
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userIdParam: ${userIdParam}`,
);
if (!userIdParam) {
throw new BadRequestException("User ID is required");
}
// Resolve user ID (could be internal ID or external ID)
const userId: ObjectID | null = await resolveUserId(
userIdParam,
statusPageId,
projectId,
scimConfig.id!,
);
if (!userId) {
throw new BadRequestException("User ID is required");
logger.debug(
`Status Page SCIM Delete user - could not resolve user ID for param: ${userIdParam}`,
);
throw new NotFoundException("User not found");
}
// Check if user exists and belongs to this status page
@@ -497,7 +957,7 @@ router.delete(
await StatusPagePrivateUserService.findOneBy({
query: {
statusPageId: statusPageId,
_id: new ObjectID(userId),
_id: userId,
},
select: {
_id: true,
@@ -507,7 +967,7 @@ router.delete(
if (!statusPageUser) {
logger.debug(
`Status Page SCIM Delete user - user not found for userId: ${userId}`,
`Status Page SCIM Delete user - user not found for resolved userId: ${userId}`,
);
// SCIM spec says to return 404 for non-existent resources
throw new NotFoundException("User not found");
@@ -515,7 +975,7 @@ router.delete(
// Delete the user from status page
await StatusPagePrivateUserService.deleteOneById({
id: new ObjectID(userId),
id: userId,
props: { isRoot: true },
});

View File

@@ -76,16 +76,19 @@ export const formatUserForSCIM: (
req: ExpressRequest,
scimId: string,
scimType: "project" | "status-page",
externalId?: string | null,
) => JSONObject = (
user: SCIMUser,
req: ExpressRequest,
scimId: string,
scimType: "project" | "status-page",
externalId?: string | null,
): JSONObject => {
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
const userName: string = user.email?.toString() || "";
const userName: string = externalId || user.email?.toString() || "";
const email: string = user.email?.toString() || "";
const fullName: string =
user.name?.toString() || userName.split("@")[0] || "Unknown User";
user.name?.toString() || email.split("@")[0] || "Unknown User";
const nameData: { givenName: string; familyName: string; formatted: string } =
parseNameToSCIMFormat(fullName);
@@ -108,7 +111,7 @@ export const formatUserForSCIM: (
},
emails: [
{
value: userName,
value: email,
type: "work",
primary: true,
},
@@ -136,6 +139,40 @@ export const extractEmailFromSCIM: (scimUser: JSONObject) => string = (
);
};
/**
* Extract external ID from SCIM user payload (for non-email userNames)
*/
export const extractExternalIdFromSCIM: (scimUser: JSONObject) => string | null = (
scimUser: JSONObject,
): string | null => {
const userName: string = scimUser["userName"] as string;
if (!userName) {
return null;
}
// Check if userName is not an email - if it's not a valid email format, treat it as external ID
const emailRegex: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userName)) {
return userName;
}
return null;
};
/**
* Check if a userName field contains an email or external ID
*/
export const isUserNameEmail: (userName: string) => boolean = (
userName: string,
): boolean => {
if (!userName) {
return false;
}
const emailRegex: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(userName);
};
/**
* Extract active status from SCIM user payload
*/

View File

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

View File

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

View File

@@ -178,11 +178,13 @@ import WorkspaceUserAuthToken from "./WorkspaceUserAuthToken";
import WorkspaceProjectAuthToken from "./WorkspaceProjectAuthToken";
import WorkspaceSetting from "./WorkspaceSetting";
import WorkspaceNotificationRule from "./WorkspaceNotificationRule";
import ProjectUser from "./ProjectUser";
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
import MonitorFeed from "./MonitorFeed";
import MetricType from "./MetricType";
import ProjectSCIM from "./ProjectSCIM";
import SCIMUser from "./SCIMUser";
import StatusPageSCIMUser from "./StatusPageSCIMUser";
const AllModelTypes: Array<{
new (): BaseModel;
@@ -380,8 +382,6 @@ const AllModelTypes: Array<{
WorkspaceSetting,
WorkspaceNotificationRule,
ProjectUser,
MonitorFeed,
MetricType,
@@ -389,6 +389,9 @@ const AllModelTypes: Array<{
OnCallDutyPolicyTimeLog,
ProjectSCIM,
SCIMUser,
StatusPageSCIMUser
];
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};

View File

@@ -248,6 +248,84 @@ export default class Project extends TenantModel {
})
public paymentProviderCustomerId?: string = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
type: TableColumnType.LongText,
title: "Business Details / Billing Address",
description:
"Business legal name, address and any tax information to appear on invoices.",
})
@Column({
type: ColumnType.LongText,
length: ColumnLength.LongText,
nullable: true,
unique: false,
})
public businessDetails?: string = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Business Country (ISO Alpha-2)",
description:
"Two-letter ISO country code for billing address (e.g., US, GB, DE).",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
unique: false,
})
public businessDetailsCountry?: string = undefined;
@ColumnAccessControl({
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
type: TableColumnType.Email,
title: "Finance / Accounting Email",
description:
"Invoices, receipts and billing related notifications will be sent to this email in addition to project owner.",
})
@Column({
type: ColumnType.Email,
length: ColumnLength.Email,
nullable: true,
unique: false,
})
public financeAccountingEmail?: string = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

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

View File

@@ -0,0 +1,287 @@
import Project from "./Project";
import ProjectSCIM from "./ProjectSCIM";
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 IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import {
Column,
Entity,
Index,
JoinColumn,
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("/scim-user"))
@Entity({
name: "SCIMUser",
})
@TableMetadata({
tableName: "SCIMUser",
singularName: "SCIM User",
pluralName: "SCIM Users",
icon: IconProp.User,
tableDescription: "SCIM User mapping to store external provider user IDs",
})
export default class SCIMUser 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: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@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: [],
})
@TableColumn({
manyToOneRelationColumn: "scimConfigId",
type: TableColumnType.Entity,
modelType: ProjectSCIM,
title: "SCIM Config",
description: "Relation to Project SCIM Config Resource",
})
@ManyToOne(
() => {
return ProjectSCIM;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "scimConfigId" })
public scimConfig?: ProjectSCIM = 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,
title: "SCIM Config ID",
description: "ID of the SCIM Config this user mapping belongs to",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public scimConfigId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "Relation to User Resource",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "userId" })
public user?: User = 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,
title: "User ID",
description: "ID of the OneUptime User this external ID maps to",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateProjectSSO,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectSSO,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.LongText,
required: true,
title: "External ID",
description: "External user ID from SCIM provider (e.g., Azure AD user ID)",
})
@Column({
type: ColumnType.LongText,
nullable: false,
length: ColumnLength.LongText,
})
public externalId?: string = undefined;
}

View File

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

View File

@@ -0,0 +1,352 @@
import Project from "./Project";
import StatusPage from "./StatusPage";
import StatusPageSCIM from "./StatusPageSCIM";
import StatusPagePrivateUser from "./StatusPagePrivateUser";
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 IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import {
Column,
Entity,
Index,
JoinColumn,
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.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.DeleteStatusPagePrivateUser,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditStatusPagePrivateUser,
],
})
@CrudApiEndpoint(new Route("/status-page-scim-user"))
@Entity({
name: "StatusPageSCIMUser",
})
@TableMetadata({
tableName: "StatusPageSCIMUser",
singularName: "Status Page SCIM User",
pluralName: "Status Page SCIM Users",
icon: IconProp.User,
tableDescription: "Status Page SCIM User mapping to store external provider user IDs",
})
export default class StatusPageSCIMUser extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
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: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
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.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
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: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "statusPageId" })
public statusPage?: StatusPage = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Status Page ID",
description: "ID of your Status Page in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPageId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "scimConfigId",
type: TableColumnType.Entity,
modelType: StatusPageSCIM,
title: "SCIM Config",
description: "Relation to Status Page SCIM Config Resource",
})
@ManyToOne(
() => {
return StatusPageSCIM;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "scimConfigId" })
public scimConfig?: StatusPageSCIM = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "SCIM Config ID",
description: "ID of the Status Page SCIM Config this user mapping belongs to",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public scimConfigId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPagePrivateUserId",
type: TableColumnType.Entity,
modelType: StatusPagePrivateUser,
title: "Status Page Private User",
description: "Relation to Status Page Private User Resource",
})
@ManyToOne(
() => {
return StatusPagePrivateUser;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "statusPagePrivateUserId" })
public statusPagePrivateUser?: StatusPagePrivateUser = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Status Page Private User ID",
description: "ID of the Status Page Private User this external ID maps to",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPagePrivateUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateStatusPagePrivateUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadStatusPagePrivateUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.LongText,
required: true,
title: "External ID",
description: "External user ID from SCIM provider (e.g., Azure AD user ID)",
})
@Column({
type: ColumnType.LongText,
nullable: false,
length: ColumnLength.LongText,
})
public externalId?: string = undefined;
}

View File

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

View File

@@ -502,6 +502,7 @@ export default class StatusPageAPI extends BaseAPI<
footerHTML: true,
enableEmailSubscribers: true,
enableSlackSubscribers: true,
enableMicrosoftTeamsSubscribers: true,
enableSmsSubscribers: true,
isPublicStatusPage: true,
allowSubscribersToChooseResources: true,
@@ -2146,6 +2147,7 @@ export default class StatusPageAPI extends BaseAPI<
projectId: true,
enableEmailSubscribers: true,
enableSlackSubscribers: true,
enableMicrosoftTeamsSubscribers: true,
enableSmsSubscribers: true,
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
@@ -2419,6 +2421,7 @@ export default class StatusPageAPI extends BaseAPI<
enableEmailSubscribers: true,
enableSmsSubscribers: true,
enableSlackSubscribers: true,
enableMicrosoftTeamsSubscribers: true,
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
showSubscriberPageOnStatusPage: true,
@@ -2480,15 +2483,28 @@ export default class StatusPageAPI extends BaseAPI<
}
if (
!req.body.data["subscriberEmail"] &&
!req.body.data["subscriberPhone"] &&
!req.body.data["slackWorkspaceName"]
req.body.data["microsoftTeamsWorkspaceName"] &&
!statusPage.enableMicrosoftTeamsSubscribers
) {
logger.debug(
`No email, phone, or slack workspace name provided for subscription to status page with ID: ${objectId}`,
`Microsoft Teams subscribers not enabled for status page with ID: ${objectId}`,
);
throw new BadDataException(
"Email, phone or slack workspace name is required to subscribe to this status page.",
"Microsoft Teams subscribers not enabled for this status page.",
);
}
if (
!req.body.data["subscriberEmail"] &&
!req.body.data["subscriberPhone"] &&
!req.body.data["slackWorkspaceName"] &&
!req.body.data["microsoftTeamsWorkspaceName"]
) {
logger.debug(
`No email, phone, slack workspace name, or Microsoft Teams workspace name provided for subscription to status page with ID: ${objectId}`,
);
throw new BadDataException(
"Email, phone, slack workspace name, or Microsoft Teams workspace name is required to subscribe to this status page.",
);
}
@@ -2512,6 +2528,18 @@ export default class StatusPageAPI extends BaseAPI<
? (req.body.data["slackWorkspaceName"] as string)
: undefined;
const microsoftTeamsIncomingWebhookUrl: string | undefined = req.body.data[
"microsoftTeamsIncomingWebhookUrl"
]
? (req.body.data["microsoftTeamsIncomingWebhookUrl"] as string)
: undefined;
const microsoftTeamsWorkspaceName: string | undefined = req.body.data[
"microsoftTeamsWorkspaceName"
]
? (req.body.data["microsoftTeamsWorkspaceName"] as string)
: undefined;
let statusPageSubscriber: StatusPageSubscriber | null = null;
let isUpdate: boolean = false;
@@ -2570,6 +2598,23 @@ export default class StatusPageAPI extends BaseAPI<
statusPageSubscriber.slackWorkspaceName = slackWorkspaceName;
}
if (microsoftTeamsIncomingWebhookUrl) {
logger.debug(
`Setting subscriber Microsoft Teams webhook: ${microsoftTeamsIncomingWebhookUrl}`,
);
statusPageSubscriber.microsoftTeamsIncomingWebhookUrl = URL.fromString(
microsoftTeamsIncomingWebhookUrl,
);
}
if (microsoftTeamsWorkspaceName) {
logger.debug(
`Setting subscriber Microsoft Teams workspace name: ${microsoftTeamsWorkspaceName}`,
);
statusPageSubscriber.microsoftTeamsWorkspaceName =
microsoftTeamsWorkspaceName;
}
if (
req.body.data["statusPageResources"] &&
!statusPage.allowSubscribersToChooseResources

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1756740910798 implements MigrationInterface {
public name = 'MigrationName1756740910798'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "SCIMUser" ("_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, "scimConfigId" uuid NOT NULL, "userId" uuid NOT NULL, "externalId" character varying(500) NOT NULL, CONSTRAINT "PK_161711d359ba1935520b5aa313e" PRIMARY KEY ("_id"))`);
await queryRunner.query(`CREATE INDEX "IDX_7561dd17a97f143cdffe341184" ON "SCIMUser" ("projectId") `);
await queryRunner.query(`CREATE INDEX "IDX_ca31718fa40f6a1ac4aa63b5d8" ON "SCIMUser" ("scimConfigId") `);
await queryRunner.query(`CREATE INDEX "IDX_c0bebe6a5b38293c297a6e2b1c" ON "SCIMUser" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_3593cbfcd05e591bfe131bf58a" ON "SCIMUser" ("externalId") `);
await queryRunner.query(`CREATE TABLE "StatusPageSCIMUser" ("_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, "scimConfigId" uuid NOT NULL, "statusPagePrivateUserId" uuid NOT NULL, "externalId" character varying(500) NOT NULL, CONSTRAINT "PK_e2fb21d6da5fc881f7adf2310f6" PRIMARY KEY ("_id"))`);
await queryRunner.query(`CREATE INDEX "IDX_e0f38e455921c08948b9402e8f" ON "StatusPageSCIMUser" ("projectId") `);
await queryRunner.query(`CREATE INDEX "IDX_4282ed65830c3301d7b91297b3" ON "StatusPageSCIMUser" ("statusPageId") `);
await queryRunner.query(`CREATE INDEX "IDX_43712b2bba1e0f13970353bee6" ON "StatusPageSCIMUser" ("scimConfigId") `);
await queryRunner.query(`CREATE INDEX "IDX_8e7127bd5155fd551b218076e0" ON "StatusPageSCIMUser" ("statusPagePrivateUserId") `);
await queryRunner.query(`CREATE INDEX "IDX_3cbb3996ed387428369f45b3cb" ON "StatusPageSCIMUser" ("externalId") `);
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 "SCIMUser" ADD CONSTRAINT "FK_7561dd17a97f143cdffe341184f" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "SCIMUser" ADD CONSTRAINT "FK_ca31718fa40f6a1ac4aa63b5d8f" FOREIGN KEY ("scimConfigId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "SCIMUser" ADD CONSTRAINT "FK_c0bebe6a5b38293c297a6e2b1c7" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_e0f38e455921c08948b9402e8ff" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_4282ed65830c3301d7b91297b3f" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_43712b2bba1e0f13970353bee64" FOREIGN KEY ("scimConfigId") REFERENCES "StatusPageSCIM"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_8e7127bd5155fd551b218076e0e" FOREIGN KEY ("statusPagePrivateUserId") REFERENCES "StatusPagePrivateUser"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_8e7127bd5155fd551b218076e0e"`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_43712b2bba1e0f13970353bee64"`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_4282ed65830c3301d7b91297b3f"`);
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_e0f38e455921c08948b9402e8ff"`);
await queryRunner.query(`ALTER TABLE "SCIMUser" DROP CONSTRAINT "FK_c0bebe6a5b38293c297a6e2b1c7"`);
await queryRunner.query(`ALTER TABLE "SCIMUser" DROP CONSTRAINT "FK_ca31718fa40f6a1ac4aa63b5d8f"`);
await queryRunner.query(`ALTER TABLE "SCIMUser" DROP CONSTRAINT "FK_7561dd17a97f143cdffe341184f"`);
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_3cbb3996ed387428369f45b3cb"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8e7127bd5155fd551b218076e0"`);
await queryRunner.query(`DROP INDEX "public"."IDX_43712b2bba1e0f13970353bee6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4282ed65830c3301d7b91297b3"`);
await queryRunner.query(`DROP INDEX "public"."IDX_e0f38e455921c08948b9402e8f"`);
await queryRunner.query(`DROP TABLE "StatusPageSCIMUser"`);
await queryRunner.query(`DROP INDEX "public"."IDX_3593cbfcd05e591bfe131bf58a"`);
await queryRunner.query(`DROP INDEX "public"."IDX_c0bebe6a5b38293c297a6e2b1c"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ca31718fa40f6a1ac4aa63b5d8"`);
await queryRunner.query(`DROP INDEX "public"."IDX_7561dd17a97f143cdffe341184"`);
await queryRunner.query(`DROP TABLE "SCIMUser"`);
}
}

View File

@@ -158,6 +158,13 @@ import { MigrationName1755088852971 } from "./1755088852971-MigrationName";
import { MigrationName1755093133870 } from "./1755093133870-MigrationName";
import { MigrationName1755109893911 } from "./1755109893911-MigrationName";
import { MigrationName1755110936888 } from "./1755110936888-MigrationName";
import { MigrationName1755775040650 } from "./1755775040650-MigrationName";
import { MigrationName1755778495455 } from "./1755778495455-MigrationName";
import { MigrationName1755778934927 } from "./1755778934927-MigrationName";
import { MigrationName1756293325324 } from "./1756293325324-MigrationName";
import { MigrationName1756296282627 } from "./1756296282627-MigrationName";
import { MigrationName1756300358095 } from "./1756300358095-MigrationName";
import { MigrationName1756740910798 } from "./1756740910798-MigrationName";
export default [
InitialMigration,
@@ -320,4 +327,11 @@ export default [
MigrationName1755093133870,
MigrationName1755109893911,
MigrationName1755110936888,
MigrationName1755775040650,
MigrationName1755778495455,
MigrationName1755778934927,
MigrationName1756293325324,
MigrationName1756296282627,
MigrationName1756300358095,
MigrationName1756740910798
];

View File

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

View File

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

View File

@@ -67,6 +67,7 @@ import QueryOperator from "../../Types/BaseDatabase/QueryOperator";
import { FindWhere } from "../../Types/BaseDatabase/Query";
import logger from "../Utils/Logger";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -162,11 +163,11 @@ export class Service extends DatabaseService<Model> {
});
if (!monitor) {
throw new BadDataException("Monitor not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
if (!monitor.id) {
throw new BadDataException("Monitor id not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
projectId = monitor.projectId!;
@@ -1389,7 +1390,7 @@ ${createdItem.description?.trim() || "No description provided."}
});
if (!monitor) {
throw new BadDataException("Monitor not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
return (monitor.postUpdatesToWorkspaceChannels || []).filter(
@@ -1419,7 +1420,7 @@ ${createdItem.description?.trim() || "No description provided."}
});
if (!monitor) {
throw new BadDataException("Monitor not found.");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
return monitor.name || "";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import Model from "../../Models/DatabaseModels/SCIMUser";
import DatabaseService from "./DatabaseService";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -0,0 +1,10 @@
import Model from "../../Models/DatabaseModels/StatusPageSCIMUser";
import DatabaseService from "./DatabaseService";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -340,10 +340,26 @@ export class Service extends DatabaseService<StatusPage> {
projectId: ObjectID,
statusPageId: ObjectID,
): Promise<URL> {
const dahboardUrl: URL = await DatabaseConfig.getDashboardUrl();
if (!projectId) {
throw new BadDataException(
"projectId is required to build status page dashboard link",
);
}
return URL.fromString(dahboardUrl.toString()).addRoute(
`/${projectId.toString()}/status-pages/${statusPageId.toString()}`,
if (!statusPageId) {
throw new BadDataException(
"statusPageId is required to build status page dashboard link",
);
}
const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
// Defensive: ensure objects have toString
const projectIdStr: string = projectId.toString();
const statusPageIdStr: string = statusPageId.toString();
return URL.fromString(dashboardUrl.toString()).addRoute(
`/${projectIdStr}/status-pages/${statusPageIdStr}`,
);
}

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ import WorkspaceNotificationLog from "../../Models/DatabaseModels/WorkspaceNotif
import WorkspaceNotificationLogService from "./WorkspaceNotificationLogService";
import WorkspaceNotificationStatus from "../../Types/Workspace/WorkspaceNotificationStatus";
import WorkspaceNotificationActionType from "../../Types/Workspace/WorkspaceNotificationActionType";
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
export interface MessageBlocksByWorkspaceType {
workspaceType: WorkspaceType;
@@ -1911,7 +1912,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
if (!monitor) {
logger.debug("Monitor not found for ID:");
logger.debug(data.notificationFor.monitorId);
throw new BadDataException("Monitor ID not found");
throw new BadDataException(ExceptionMessages.MonitorNotFound);
}
const monitorLabels: Array<Label> = monitor?.labels || [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -420,9 +420,8 @@ const Detail: DetailFunction = <T extends GenericObject>(
)}
</div>
)}
{(data === null || data === undefined) && field.placeholder && (
<PlaceholderText text={field.placeholder} />
)}
{(data === null || data === undefined || data === "") &&
field.placeholder && <PlaceholderText text={field.placeholder} />}
</div>
</div>
);

View File

@@ -102,10 +102,9 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
code: (props: any) => {
const { children, className, ...rest } = props;
// eslint-disable-next-line wrap-regex
const match: RegExpExecArray | null = /language-(\w+)/.exec(
className || "",
);
const match: RegExpExecArray | null = new RegExp(
"language-(\\w+)",
).exec(className || "");
const content: string = String(children as string).replace(
/\n$/,

View File

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

View File

@@ -0,0 +1,259 @@
// ISO 3166-1 alpha-2 country codes and names.
// Limited to widely recognized sovereign states and territories supported by Stripe.
// If needed, expand or adjust for specific business logic.
export interface CountryOption {
value: string;
label: string;
}
export const Countries: Array<CountryOption> = [
{ value: "AF", label: "Afghanistan" },
{ value: "AL", label: "Albania" },
{ value: "DZ", label: "Algeria" },
{ value: "AS", label: "American Samoa" },
{ value: "AD", label: "Andorra" },
{ value: "AO", label: "Angola" },
{ value: "AI", label: "Anguilla" },
{ value: "AQ", label: "Antarctica" },
{ value: "AG", label: "Antigua and Barbuda" },
{ value: "AR", label: "Argentina" },
{ value: "AM", label: "Armenia" },
{ value: "AW", label: "Aruba" },
{ value: "AU", label: "Australia" },
{ value: "AT", label: "Austria" },
{ value: "AZ", label: "Azerbaijan" },
{ value: "BS", label: "Bahamas" },
{ value: "BH", label: "Bahrain" },
{ value: "BD", label: "Bangladesh" },
{ value: "BB", label: "Barbados" },
{ value: "BY", label: "Belarus" },
{ value: "BE", label: "Belgium" },
{ value: "BZ", label: "Belize" },
{ value: "BJ", label: "Benin" },
{ value: "BM", label: "Bermuda" },
{ value: "BT", label: "Bhutan" },
{ value: "BO", label: "Bolivia" },
{ value: "BQ", label: "Bonaire, Sint Eustatius and Saba" },
{ value: "BA", label: "Bosnia and Herzegovina" },
{ value: "BW", label: "Botswana" },
{ value: "BV", label: "Bouvet Island" },
{ value: "BR", label: "Brazil" },
{ value: "IO", label: "British Indian Ocean Territory" },
{ value: "BN", label: "Brunei Darussalam" },
{ value: "BG", label: "Bulgaria" },
{ value: "BF", label: "Burkina Faso" },
{ value: "BI", label: "Burundi" },
{ value: "KH", label: "Cambodia" },
{ value: "CM", label: "Cameroon" },
{ value: "CA", label: "Canada" },
{ value: "CV", label: "Cape Verde" },
{ value: "KY", label: "Cayman Islands" },
{ value: "CF", label: "Central African Republic" },
{ value: "TD", label: "Chad" },
{ value: "CL", label: "Chile" },
{ value: "CN", label: "China" },
{ value: "CX", label: "Christmas Island" },
{ value: "CC", label: "Cocos (Keeling) Islands" },
{ value: "CO", label: "Colombia" },
{ value: "KM", label: "Comoros" },
{ value: "CG", label: "Congo" },
{ value: "CD", label: "Congo, the Democratic Republic of the" },
{ value: "CK", label: "Cook Islands" },
{ value: "CR", label: "Costa Rica" },
{ value: "CI", label: "Côte d'Ivoire" },
{ value: "HR", label: "Croatia" },
{ value: "CU", label: "Cuba" },
{ value: "CW", label: "Curaçao" },
{ value: "CY", label: "Cyprus" },
{ value: "CZ", label: "Czech Republic" },
{ value: "DK", label: "Denmark" },
{ value: "DJ", label: "Djibouti" },
{ value: "DM", label: "Dominica" },
{ value: "DO", label: "Dominican Republic" },
{ value: "EC", label: "Ecuador" },
{ value: "EG", label: "Egypt" },
{ value: "SV", label: "El Salvador" },
{ value: "GQ", label: "Equatorial Guinea" },
{ value: "ER", label: "Eritrea" },
{ value: "EE", label: "Estonia" },
{ value: "ET", label: "Ethiopia" },
{ value: "FK", label: "Falkland Islands (Malvinas)" },
{ value: "FO", label: "Faroe Islands" },
{ value: "FJ", label: "Fiji" },
{ value: "FI", label: "Finland" },
{ value: "FR", label: "France" },
{ value: "GF", label: "French Guiana" },
{ value: "PF", label: "French Polynesia" },
{ value: "TF", label: "French Southern Territories" },
{ value: "GA", label: "Gabon" },
{ value: "GM", label: "Gambia" },
{ value: "GE", label: "Georgia" },
{ value: "DE", label: "Germany" },
{ value: "GH", label: "Ghana" },
{ value: "GI", label: "Gibraltar" },
{ value: "GR", label: "Greece" },
{ value: "GL", label: "Greenland" },
{ value: "GD", label: "Grenada" },
{ value: "GP", label: "Guadeloupe" },
{ value: "GU", label: "Guam" },
{ value: "GT", label: "Guatemala" },
{ value: "GG", label: "Guernsey" },
{ value: "GN", label: "Guinea" },
{ value: "GW", label: "Guinea-Bissau" },
{ value: "GY", label: "Guyana" },
{ value: "HT", label: "Haiti" },
{ value: "HM", label: "Heard Island and McDonald Islands" },
{ value: "HN", label: "Honduras" },
{ value: "HK", label: "Hong Kong" },
{ value: "HU", label: "Hungary" },
{ value: "IS", label: "Iceland" },
{ value: "IN", label: "India" },
{ value: "ID", label: "Indonesia" },
{ value: "IR", label: "Iran, Islamic Republic of" },
{ value: "IQ", label: "Iraq" },
{ value: "IE", label: "Ireland" },
{ value: "IM", label: "Isle of Man" },
{ value: "IL", label: "Israel" },
{ value: "IT", label: "Italy" },
{ value: "JM", label: "Jamaica" },
{ value: "JP", label: "Japan" },
{ value: "JE", label: "Jersey" },
{ value: "JO", label: "Jordan" },
{ value: "KZ", label: "Kazakhstan" },
{ value: "KE", label: "Kenya" },
{ value: "KI", label: "Kiribati" },
{ value: "KP", label: "Korea, Democratic People's Republic of" },
{ value: "KR", label: "Korea, Republic of" },
{ value: "KW", label: "Kuwait" },
{ value: "KG", label: "Kyrgyzstan" },
{ value: "LA", label: "Lao People's Democratic Republic" },
{ value: "LV", label: "Latvia" },
{ value: "LB", label: "Lebanon" },
{ value: "LS", label: "Lesotho" },
{ value: "LR", label: "Liberia" },
{ value: "LY", label: "Libya" },
{ value: "LI", label: "Liechtenstein" },
{ value: "LT", label: "Lithuania" },
{ value: "LU", label: "Luxembourg" },
{ value: "MO", label: "Macao" },
{ value: "MG", label: "Madagascar" },
{ value: "MW", label: "Malawi" },
{ value: "MY", label: "Malaysia" },
{ value: "MV", label: "Maldives" },
{ value: "ML", label: "Mali" },
{ value: "MT", label: "Malta" },
{ value: "MH", label: "Marshall Islands" },
{ value: "MQ", label: "Martinique" },
{ value: "MR", label: "Mauritania" },
{ value: "MU", label: "Mauritius" },
{ value: "YT", label: "Mayotte" },
{ value: "MX", label: "Mexico" },
{ value: "FM", label: "Micronesia, Federated States of" },
{ value: "MD", label: "Moldova, Republic of" },
{ value: "MC", label: "Monaco" },
{ value: "MN", label: "Mongolia" },
{ value: "ME", label: "Montenegro" },
{ value: "MS", label: "Montserrat" },
{ value: "MA", label: "Morocco" },
{ value: "MZ", label: "Mozambique" },
{ value: "MM", label: "Myanmar" },
{ value: "NA", label: "Namibia" },
{ value: "NR", label: "Nauru" },
{ value: "NP", label: "Nepal" },
{ value: "NL", label: "Netherlands" },
{ value: "NC", label: "New Caledonia" },
{ value: "NZ", label: "New Zealand" },
{ value: "NI", label: "Nicaragua" },
{ value: "NE", label: "Niger" },
{ value: "NG", label: "Nigeria" },
{ value: "NU", label: "Niue" },
{ value: "NF", label: "Norfolk Island" },
{ value: "MK", label: "North Macedonia" },
{ value: "MP", label: "Northern Mariana Islands" },
{ value: "NO", label: "Norway" },
{ value: "OM", label: "Oman" },
{ value: "PK", label: "Pakistan" },
{ value: "PW", label: "Palau" },
{ value: "PS", label: "Palestine, State of" },
{ value: "PA", label: "Panama" },
{ value: "PG", label: "Papua New Guinea" },
{ value: "PY", label: "Paraguay" },
{ value: "PE", label: "Peru" },
{ value: "PH", label: "Philippines" },
{ value: "PN", label: "Pitcairn" },
{ value: "PL", label: "Poland" },
{ value: "PT", label: "Portugal" },
{ value: "PR", label: "Puerto Rico" },
{ value: "QA", label: "Qatar" },
{ value: "RE", label: "Réunion" },
{ value: "RO", label: "Romania" },
{ value: "RU", label: "Russian Federation" },
{ value: "RW", label: "Rwanda" },
{ value: "BL", label: "Saint Barthélemy" },
{ value: "SH", label: "Saint Helena, Ascension and Tristan da Cunha" },
{ value: "KN", label: "Saint Kitts and Nevis" },
{ value: "LC", label: "Saint Lucia" },
{ value: "MF", label: "Saint Martin (French part)" },
{ value: "PM", label: "Saint Pierre and Miquelon" },
{ value: "VC", label: "Saint Vincent and the Grenadines" },
{ value: "WS", label: "Samoa" },
{ value: "SM", label: "San Marino" },
{ value: "ST", label: "Sao Tome and Principe" },
{ value: "SA", label: "Saudi Arabia" },
{ value: "SN", label: "Senegal" },
{ value: "RS", label: "Serbia" },
{ value: "SC", label: "Seychelles" },
{ value: "SL", label: "Sierra Leone" },
{ value: "SG", label: "Singapore" },
{ value: "SX", label: "Sint Maarten (Dutch part)" },
{ value: "SK", label: "Slovakia" },
{ value: "SI", label: "Slovenia" },
{ value: "SB", label: "Solomon Islands" },
{ value: "SO", label: "Somalia" },
{ value: "ZA", label: "South Africa" },
{ value: "GS", label: "South Georgia and the South Sandwich Islands" },
{ value: "SS", label: "South Sudan" },
{ value: "ES", label: "Spain" },
{ value: "LK", label: "Sri Lanka" },
{ value: "SD", label: "Sudan" },
{ value: "SR", label: "Suriname" },
{ value: "SJ", label: "Svalbard and Jan Mayen" },
{ value: "SZ", label: "Swaziland" },
{ value: "SE", label: "Sweden" },
{ value: "CH", label: "Switzerland" },
{ value: "SY", label: "Syrian Arab Republic" },
{ value: "TW", label: "Taiwan, Province of China" },
{ value: "TJ", label: "Tajikistan" },
{ value: "TZ", label: "Tanzania, United Republic of" },
{ value: "TH", label: "Thailand" },
{ value: "TL", label: "Timor-Leste" },
{ value: "TG", label: "Togo" },
{ value: "TK", label: "Tokelau" },
{ value: "TO", label: "Tonga" },
{ value: "TT", label: "Trinidad and Tobago" },
{ value: "TN", label: "Tunisia" },
{ value: "TR", label: "Turkey" },
{ value: "TM", label: "Turkmenistan" },
{ value: "TC", label: "Turks and Caicos Islands" },
{ value: "TV", label: "Tuvalu" },
{ value: "UG", label: "Uganda" },
{ value: "UA", label: "Ukraine" },
{ value: "AE", label: "United Arab Emirates" },
{ value: "GB", label: "United Kingdom" },
{ value: "US", label: "United States" },
{ value: "UM", label: "United States Minor Outlying Islands" },
{ value: "UY", label: "Uruguay" },
{ value: "UZ", label: "Uzbekistan" },
{ value: "VU", label: "Vanuatu" },
{ value: "VE", label: "Venezuela, Bolivarian Republic of" },
{ value: "VN", label: "Viet Nam" },
{ value: "VG", label: "Virgin Islands, British" },
{ value: "VI", label: "Virgin Islands, U.S." },
{ value: "WF", label: "Wallis and Futuna" },
{ value: "EH", label: "Western Sahara" },
{ value: "YE", label: "Yemen" },
{ value: "ZM", label: "Zambia" },
{ value: "ZW", label: "Zimbabwe" },
];
export default Countries;

View File

@@ -4,5 +4,7 @@
"../Common"
],
"ext": "ts,json,tsx,env,js,jsx,hbs",
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
"watchOptions": {"useFsEvents": false, "interval": 500},
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
}

View File

@@ -12,6 +12,7 @@ import React, {
} from "react";
import MonitorStatusElement from "./MonitorStatusElement";
import Loader, { LoaderType } from "Common/UI/Components/Loader/Loader";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
export interface ComponentProps {
monitorId: ObjectID;
@@ -45,7 +46,7 @@ const GetMonitorStatusElement: FunctionComponent<ComponentProps> = (
if (!monitor) {
setIsLoading(false);
setError("Monitor not found");
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -27,6 +27,33 @@ const CustomProbeDocumentation: FunctionComponent<ComponentProps> = (
docker run --name oneuptime-probe --network host -e PROBE_KEY=${props.probeKey.toString()} -e PROBE_ID=${props.probeId.toString()} -e ONEUPTIME_URL=${host.toString()} -d oneuptime/probe:release
`}
/>
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">
With Proxy Configuration (Optional)
</h4>
<CodeBlock
language="bash"
code={`
# With HTTP/HTTPS proxy
docker run --name oneuptime-probe --network host \\
-e PROBE_KEY=${props.probeKey.toString()} \\
-e PROBE_ID=${props.probeId.toString()} \\
-e ONEUPTIME_URL=${host.toString()} \\
-e HTTP_PROXY_URL=http://proxy.example.com:8080 \\
-e HTTPS_PROXY_URL=http://proxy.example.com:8080 \\
-d oneuptime/probe:release
# With proxy authentication
docker run --name oneuptime-probe --network host \\
-e PROBE_KEY=${props.probeKey.toString()} \\
-e PROBE_ID=${props.probeId.toString()} \\
-e ONEUPTIME_URL=${host.toString()} \\
-e HTTP_PROXY_URL=http://username:password@proxy.example.com:8080 \\
-e HTTPS_PROXY_URL=http://username:password@proxy.example.com:8080 \\
-d oneuptime/probe:release
`}
/>
</div>
</div>
}
/>

View File

@@ -29,6 +29,7 @@ import React, {
ReactElement,
useState,
} from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import { useAsyncEffect } from "use-async-effect";
import MonitorTestForm from "../../../Components/Form/Monitor/MonitorTest";
import Probe from "Common/Models/DatabaseModels/Probe";
@@ -61,7 +62,7 @@ const MonitorCriteria: FunctionComponent<
});
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -17,6 +17,7 @@ import React, {
ReactElement,
useState,
} from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import useAsyncEffect from "use-async-effect";
const MonitorDocumentation: FunctionComponent<
@@ -55,7 +56,7 @@ const MonitorDocumentation: FunctionComponent<
setMonitor(item);
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -47,6 +47,7 @@ import React, {
ReactElement,
useState,
} from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import useAsyncEffect from "use-async-effect";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import PageMap from "../../../Utils/PageMap";
@@ -215,7 +216,7 @@ const MonitorView: FunctionComponent<PageComponentProps> = (): ReactElement => {
);
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -22,6 +22,7 @@ import React, {
ReactElement,
useState,
} from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import useAsyncEffect from "use-async-effect";
const MonitorCriteria: FunctionComponent<
@@ -48,7 +49,7 @@ const MonitorCriteria: FunctionComponent<
});
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -11,6 +11,7 @@ import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import Navigation from "Common/UI/Utils/Navigation";
import Monitor from "Common/Models/DatabaseModels/Monitor";
import React, { FunctionComponent, ReactElement, useState } from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import { Outlet, useParams } from "react-router-dom";
import useAsyncEffect from "use-async-effect";
@@ -43,7 +44,7 @@ const MonitorViewLayout: FunctionComponent = (): ReactElement => {
setMonitor(item);
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -23,6 +23,7 @@ import React, {
ReactElement,
useState,
} from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import useAsyncEffect from "use-async-effect";
import AnalyticsModelTable from "Common/UI/Components/ModelTable/AnalyticsModelTable";
import SummaryInfo from "../../../Components/Monitor/SummaryView/SummaryInfo";
@@ -55,7 +56,7 @@ const MonitorLogs: FunctionComponent<PageComponentProps> = (): ReactElement => {
});
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -33,6 +33,7 @@ import React, {
import useAsyncEffect from "use-async-effect";
import SummaryInfo from "../../../Components/Monitor/SummaryView/SummaryInfo";
import ProbeMonitorResponse from "Common/Types/Probe/ProbeMonitorResponse";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
const MonitorProbes: FunctionComponent<
PageComponentProps
@@ -61,7 +62,7 @@ const MonitorProbes: FunctionComponent<
});
if (!item) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -25,6 +25,7 @@ import React, {
ReactElement,
useState,
} from "react";
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
import useAsyncEffect from "use-async-effect";
import OneUptimeDate from "Common/Types/Date";
@@ -61,7 +62,7 @@ const MonitorCriteria: FunctionComponent<
});
if (!monitor) {
setError(`Monitor not found`);
setError(ExceptionMessages.MonitorNotFound);
return;
}

View File

@@ -48,6 +48,7 @@ import React, {
useState,
} from "react";
import useAsyncEffect from "use-async-effect";
import Countries from "Common/UI/Utils/Countries";
export type ComponentProps = PageComponentProps;
@@ -523,6 +524,89 @@ const Settings: FunctionComponent<ComponentProps> = (
<></>
)}
<CardModelDetail<Project>
name="Business Details"
cardProps={{
title: "Business Details / Billing Address",
description:
"Enter your business legal name, address and optional tax info. This will appear on your invoices.",
}}
isEditable={true}
editButtonText={"Update"}
formFields={[
{
field: {
businessDetails: true,
},
title: "Business Details / Billing Address",
description:
"This information will appear on invoices. Include company legal name, address, and tax / VAT ID if applicable.",
required: false,
fieldType: FormFieldSchemaType.LongText,
validation: {
maxLength: 10000,
},
},
{
field: {
businessDetailsCountry: true,
},
title: "Country",
description: "Required by Stripe. Select your billing country.",
required: false,
placeholder: "Select Country",
fieldType: FormFieldSchemaType.Dropdown,
dropdownOptions: Countries,
},
{
field: {
financeAccountingEmail: true,
},
title: "Finance / Accounting Email",
description:
"Invoices, receipts and billing notifications will be sent here (optional).",
required: false,
placeholder: "finance@yourcompany.com",
fieldType: FormFieldSchemaType.Email,
validation: {
minLength: 3,
maxLength: 200,
},
},
]}
modelDetailProps={{
modelType: Project,
id: "model-detail-project-business-details",
fields: [
{
field: {
businessDetails: true,
},
title: "Business Details / Billing Address",
placeholder: "No business details added yet.",
fieldType: FieldType.LongText,
},
{
field: {
businessDetailsCountry: true,
},
title: "Country",
placeholder: "No country details added yet.",
fieldType: FieldType.Text,
},
{
field: {
financeAccountingEmail: true,
},
title: "Finance / Accounting Email",
placeholder: "No finance / accounting email added yet.",
fieldType: FieldType.Email,
},
],
modelId: ProjectUtil.getCurrentProjectId()!,
}}
/>
{!reseller && (
<Card
title={`Cancel Plan`}

View File

@@ -1,12 +1,11 @@
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import FieldType from "Common/UI/Components/Types/FieldType";
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import UserElement from "../../Components/User/User";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import Team from "Common/Models/DatabaseModels/Team";
import TeamsElement from "../../Components/Team/TeamsElement";
import TeamElement from "../../Components/Team/Team";
import Route from "Common/Types/API/Route";
import { RouteUtil } from "../../Utils/RouteMap";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
@@ -16,6 +15,8 @@ import TeamMember from "Common/Models/DatabaseModels/TeamMember";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import { FormType } from "Common/UI/Components/Forms/ModelForm";
import Navigation from "Common/UI/Utils/Navigation";
import Pill from "Common/UI/Components/Pill/Pill";
import { Green, Yellow } from "Common/Types/BrandColors";
const Teams: FunctionComponent<PageComponentProps> = (
props: PageComponentProps,
@@ -26,21 +27,22 @@ const Teams: FunctionComponent<PageComponentProps> = (
return (
<Fragment>
<ModelTable<ProjectUser>
modelType={ProjectUser}
<ModelTable<TeamMember>
modelType={TeamMember}
id="teams-table"
name="Settings > Users"
userPreferencesKey="users-table"
isDeleteable={false}
isDeleteable={true}
isEditable={false}
isCreateable={true}
isCreateable={false}
onFilterApplied={(isApplied: boolean) => {
setIsFilterApplied(isApplied);
}}
isViewable={true}
cardProps={{
title: "Users",
description: "Here is a list of all the users in this project.",
description:
"Here is a list of all the team members in this project.",
buttons: [
{
title: "Invite User",
@@ -61,7 +63,7 @@ const Teams: FunctionComponent<PageComponentProps> = (
projectId: ProjectUtil.getCurrentProjectId()!,
}}
showRefreshButton={true}
onViewPage={(item: ProjectUser) => {
onViewPage={(item: TeamMember) => {
const viewPageRoute: string =
RouteUtil.populateRouteParams(props.pageRoute).toString() +
"/" +
@@ -72,12 +74,12 @@ const Teams: FunctionComponent<PageComponentProps> = (
filters={[
{
field: {
acceptedTeams: {
team: {
name: true,
},
},
title: "Teams member of",
type: FieldType.EntityArray,
title: "Team",
type: FieldType.Entity,
filterEntityType: Team,
filterQuery: {
projectId: ProjectUtil.getCurrentProjectId()!,
@@ -89,20 +91,10 @@ const Teams: FunctionComponent<PageComponentProps> = (
},
{
field: {
invitedTeams: {
name: true,
},
},
title: "Teams invited to",
type: FieldType.EntityArray,
filterEntityType: Team,
filterQuery: {
projectId: ProjectUtil.getCurrentProjectId()!,
},
filterDropdownField: {
label: "name",
value: "_id",
hasAcceptedInvitation: true,
},
title: "Status",
type: FieldType.Boolean,
},
]}
columns={[
@@ -116,7 +108,7 @@ const Teams: FunctionComponent<PageComponentProps> = (
},
title: "User",
type: FieldType.Element,
getElement: (item: ProjectUser) => {
getElement: (item: TeamMember) => {
if (!item.user) {
return <p>User not found</p>;
}
@@ -125,26 +117,31 @@ const Teams: FunctionComponent<PageComponentProps> = (
},
{
field: {
acceptedTeams: {
team: {
name: true,
_id: true,
},
},
title: "Teams member of",
title: "Team",
type: FieldType.Element,
getElement: (item: ProjectUser) => {
return <TeamsElement teams={item.acceptedTeams || []} />;
getElement: (item: TeamMember) => {
if (!item.team) {
return <p>No team assigned</p>;
}
return <TeamElement team={item.team!} />;
},
},
{
field: {
invitedTeams: {
name: true,
},
hasAcceptedInvitation: true,
},
title: "Teams invited to",
title: "Status",
type: FieldType.Element,
getElement: (item: ProjectUser) => {
return <TeamsElement teams={item.invitedTeams || []} />;
getElement: (item: TeamMember) => {
if (item.hasAcceptedInvitation) {
return <Pill text="Member" color={Green} />;
}
return <Pill text="Invitation Sent" color={Yellow} />;
},
},
]}

View File

@@ -0,0 +1,393 @@
import PageComponentProps from "../../PageComponentProps";
import NotNull from "Common/Types/BaseDatabase/NotNull";
import { Green, Red } from "Common/Types/BrandColors";
import BadDataException from "Common/Types/Exception/BadDataException";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import ObjectID from "Common/Types/ObjectID";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import { CategoryCheckboxOptionsAndCategories } from "Common/UI/Components/CategoryCheckbox/Index";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { ModelField } from "Common/UI/Components/Forms/ModelForm";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import Pill from "Common/UI/Components/Pill/Pill";
import FieldType from "Common/UI/Components/Types/FieldType";
import API from "Common/UI/Utils/API/API";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import Navigation from "Common/UI/Utils/Navigation";
import SubscriberUtil from "Common/UI/Utils/StatusPage";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPageSubscriber from "Common/Models/DatabaseModels/StatusPageSubscriber";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ProjectUtil from "Common/UI/Utils/Project";
const StatusPageMicrosoftTeamsSubscribers: FunctionComponent<
PageComponentProps
> = (props: PageComponentProps): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [
allowSubscribersToChooseResources,
setAllowSubscribersToChooseResources,
] = React.useState<boolean>(false);
const [
allowSubscribersToChooseEventTypes,
setAllowSubscribersToChooseEventTypes,
] = React.useState<boolean>(false);
const [
isMicrosoftTeamsSubscribersEnabled,
setIsMicrosoftTeamsSubscribersEnabled,
] = React.useState<boolean>(false);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string>("");
const [
categoryCheckboxOptionsAndCategories,
setCategoryCheckboxOptionsAndCategories,
] = useState<CategoryCheckboxOptionsAndCategories>({
categories: [],
options: [],
});
const fetchCheckboxOptionsAndCategories: PromiseVoidFunction =
async (): Promise<void> => {
const result: CategoryCheckboxOptionsAndCategories =
await SubscriberUtil.getCategoryCheckboxPropsBasedOnResources(modelId);
setCategoryCheckboxOptionsAndCategories(result);
};
const fetchStatusPage: PromiseVoidFunction = async (): Promise<void> => {
try {
setIsLoading(true);
const statusPage: StatusPage | null = await ModelAPI.getItem({
modelType: StatusPage,
id: modelId,
select: {
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
enableMicrosoftTeamsSubscribers: true,
},
});
if (statusPage && statusPage.allowSubscribersToChooseResources) {
setAllowSubscribersToChooseResources(
statusPage.allowSubscribersToChooseResources,
);
await fetchCheckboxOptionsAndCategories();
}
if (statusPage && statusPage.allowSubscribersToChooseEventTypes) {
setAllowSubscribersToChooseEventTypes(
statusPage.allowSubscribersToChooseEventTypes,
);
}
if (statusPage && statusPage.enableMicrosoftTeamsSubscribers) {
setIsMicrosoftTeamsSubscribersEnabled(
statusPage.enableMicrosoftTeamsSubscribers,
);
}
setIsLoading(false);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchStatusPage().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
const [formFields, setFormFields] = React.useState<
Array<ModelField<StatusPageSubscriber>>
>([]);
useEffect(() => {
if (isLoading) {
return; // don't do anything if loading
}
const formFields: Array<ModelField<StatusPageSubscriber>> = [
{
field: {
microsoftTeamsWorkspaceName: true,
},
stepId: "subscriber-info",
title: "Microsoft Teams Workspace Name",
description:
"Name of the Microsoft Teams workspace for identification.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "my-company-workspace",
},
{
field: {
microsoftTeamsIncomingWebhookUrl: true,
},
stepId: "subscriber-info",
title: "Microsoft Teams Incoming Webhook URL",
description: "Status page updates will be sent to this Teams channel.",
fieldType: FormFieldSchemaType.URL,
required: true,
placeholder: "https://xxxxx.office.com/webhook/...",
disableSpellCheck: true,
},
{
field: {
sendYouHaveSubscribedMessage: true,
},
title: "Send Subscription Notification",
stepId: "subscriber-info",
description:
"Send a notification to the Teams channel confirming the subscription.",
fieldType: FormFieldSchemaType.Toggle,
required: false,
doNotShowWhenEditing: true,
},
{
field: {
isUnsubscribed: true,
},
title: "Unsubscribe",
stepId: "subscriber-info",
description: "Unsubscribe this Teams channel from the status page.",
fieldType: FormFieldSchemaType.Toggle,
required: false,
doNotShowWhenCreating: true,
},
];
if (allowSubscribersToChooseResources) {
formFields.push({
field: {
isSubscribedToAllResources: true,
},
title: "Subscribe to All Resources",
stepId: "subscriber-info",
description: "Send notifications for all resources.",
fieldType: FormFieldSchemaType.Checkbox,
required: false,
defaultValue: true,
});
formFields.push({
field: {
statusPageResources: true,
},
title: "Select Resources to Subscribe",
description: "Please select the resources you want to subscribe to.",
stepId: "subscriber-info",
fieldType: FormFieldSchemaType.CategoryCheckbox,
required: false,
categoryCheckboxProps: categoryCheckboxOptionsAndCategories,
showIf: (model: FormValues<StatusPageSubscriber>) => {
return !model || !model.isSubscribedToAllResources;
},
});
}
if (allowSubscribersToChooseEventTypes) {
formFields.push({
field: {
isSubscribedToAllEventTypes: true,
},
title: "Subscribe to All Event Types",
stepId: "subscriber-info",
description:
"Select this option if you want to subscribe to all event types.",
fieldType: FormFieldSchemaType.Checkbox,
required: false,
defaultValue: true,
});
formFields.push({
field: {
statusPageEventTypes: true,
},
title: "Select Event Types to Subscribe",
stepId: "subscriber-info",
description: "Please select the event types you want to subscribe to.",
fieldType: FormFieldSchemaType.MultiSelectDropdown,
required: false,
dropdownOptions: SubscriberUtil.getDropdownPropsBasedOnEventTypes(),
showIf: (model: FormValues<StatusPageSubscriber>) => {
return !model || !model.isSubscribedToAllEventTypes;
},
});
}
// add internal note field
formFields.push({
field: {
internalNote: true,
},
title: "Internal Note",
stepId: "internal-info",
description:
"Internal note for the subscriber. This is for internal use only and is visible only to the team members.",
fieldType: FormFieldSchemaType.Markdown,
required: false,
});
setFormFields(formFields);
}, [isLoading]);
return (
<Fragment>
{isLoading ? <PageLoader isVisible={true} /> : <></>}
{error ? <ErrorMessage message={error} /> : <></>}
{!error && !isLoading ? (
<>
{!isMicrosoftTeamsSubscribersEnabled && (
<Alert
type={AlertType.DANGER}
title="Microsoft Teams subscribers are not enabled for this status page. Please enable it in Subscriber Settings"
/>
)}
<ModelTable<StatusPageSubscriber>
modelType={StatusPageSubscriber}
id="table-microsoft-teams-subscriber"
name="Status Page > Microsoft Teams Subscribers"
userPreferencesKey="status-page-microsoft-teams-subscribers-table"
isDeleteable={true}
showViewIdButton={true}
isCreateable={true}
isEditable={true}
isViewable={false}
selectMoreFields={{
isSubscriptionConfirmed: true,
}}
query={{
statusPageId: modelId,
projectId: ProjectUtil.getCurrentProjectId()!,
microsoftTeamsWorkspaceName: new NotNull(),
}}
onBeforeCreate={(
item: StatusPageSubscriber,
): Promise<StatusPageSubscriber> => {
if (!props.currentProject || !props.currentProject._id) {
throw new BadDataException("Project ID cannot be null");
}
item.statusPageId = modelId;
item.projectId = new ObjectID(props.currentProject._id);
return Promise.resolve(item);
}}
cardProps={{
title: "Microsoft Teams Subscribers",
description:
"Here are the list of Microsoft Teams channels that have subscribed to the status page.",
}}
noItemsMessage={"No Microsoft Teams subscribers found."}
formSteps={[
{
title: "Subscriber Info",
id: "subscriber-info",
},
{
title: "Internal Info",
id: "internal-info",
},
]}
formFields={formFields}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
filters={[
{
field: {
microsoftTeamsWorkspaceName: true,
},
title: "Teams Workspace Name",
type: FieldType.Text,
},
{
field: {
isUnsubscribed: true,
},
title: "Is Unsubscribed",
type: FieldType.Boolean,
},
{
field: {
isSubscriptionConfirmed: true,
},
title: "Subscription Confirmed?",
type: FieldType.Boolean,
},
{
field: {
createdAt: true,
},
title: "Subscribed At",
type: FieldType.DateTime,
},
]}
columns={[
{
field: {
microsoftTeamsWorkspaceName: true,
},
title: "Workspace Name",
type: FieldType.Text,
},
{
field: {
isUnsubscribed: true,
},
title: "Status",
type: FieldType.Text,
getElement: (item: StatusPageSubscriber): ReactElement => {
if (item["isUnsubscribed"]) {
return <Pill color={Red} text={"Unsubscribed"} />;
}
if (!item["isSubscriptionConfirmed"]) {
return (
<Pill
color={Red}
text={"Awaiting Confirmation"}
tooltip="Subscription not yet confirmed"
/>
);
}
return <Pill color={Green} text={"Subscribed"} />;
},
},
{
field: {
createdAt: true,
},
title: "Subscribed At",
type: FieldType.DateTime,
hideOnMobile: true,
},
]}
/>
</>
) : (
<></>
)}
</Fragment>
);
};
export default StatusPageMicrosoftTeamsSubscribers;

View File

@@ -111,6 +111,18 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Slack}
/>
<SideMenuItem
link={{
title: "MS Teams Subscribers",
to: RouteUtil.populateRouteParams(
RouteMap[
PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS
] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.MicrosoftTeams}
/>
{/* <SideMenuItem
link={{

View File

@@ -127,6 +127,43 @@ const StatusPageDelete: FunctionComponent<
}}
/>
<CardModelDetail<StatusPage>
name="Status Page > Branding > Subscriber > Microsoft Teams"
cardProps={{
title: "Microsoft Teams Subscribers",
description:
"Microsoft Teams subscriber settings for this status page.",
}}
isEditable={true}
formFields={[
{
field: {
enableMicrosoftTeamsSubscribers: true,
},
title: "Enable Microsoft Teams Subscribers",
fieldType: FormFieldSchemaType.Toggle,
required: false,
placeholder:
"Can Microsoft Teams subscribers subscribe to this status page?",
},
]}
modelDetailProps={{
showDetailsInNumberOfColumns: 1,
modelType: StatusPage,
id: "model-detail-microsoft-teams-subscribers",
fields: [
{
field: {
enableMicrosoftTeamsSubscribers: true,
},
fieldType: FieldType.Boolean,
title: "Enable Microsoft Teams Subscribers",
},
],
modelId: modelId,
}}
/>
<CardModelDetail<StatusPage>
name="Status Page > Branding > Subscriber > Advanced"
cardProps={{

View File

@@ -49,6 +49,11 @@ const StatusPagesViewSlackSubscribers: LazyExoticComponent<
> = lazy(() => {
return import("../Pages/StatusPages/View/SlackSubscribers");
});
const StatusPagesViewMicrosoftTeamsSubscribers: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/StatusPages/View/MicrosoftTeamsSubscribers");
});
const StatusPagesViewWebhookSubscribers: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
@@ -640,6 +645,24 @@ const StatusPagesRoutes: FunctionComponent<ComponentProps> = (
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS,
)}
element={
<Suspense fallback={Loader}>
<StatusPagesViewMicrosoftTeamsSubscribers
{...props}
pageRoute={
RouteMap[
PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS
] as Route
}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(PageMap.STATUS_PAGE_VIEW_EMBEDDED)}
element={

View File

@@ -209,6 +209,7 @@ enum PageMap {
STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS = "STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS",
STATUS_PAGE_VIEW_SMS_SUBSCRIBERS = "STATUS_PAGE_VIEW_SMS_SUBSCRIBERS",
STATUS_PAGE_VIEW_SLACK_SUBSCRIBERS = "STATUS_PAGE_VIEW_SLACK_SUBSCRIBERS",
STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS = "STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS",
STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS = "STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS",
STATUS_PAGE_VIEW_RESOURCES = "STATUS_PAGE_VIEW_RESOURCES",
STATUS_PAGE_VIEW_ADVANCED_OPTIONS = "STATUS_PAGE_VIEW_ADVANCED_OPTIONS",

View File

@@ -128,6 +128,7 @@ export const StatusPagesRoutePath: Dictionary<string> = {
[PageMap.STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS]: `${RouteParams.ModelID}/email-subscribers`,
[PageMap.STATUS_PAGE_VIEW_SMS_SUBSCRIBERS]: `${RouteParams.ModelID}/sms-subscribers`,
[PageMap.STATUS_PAGE_VIEW_SLACK_SUBSCRIBERS]: `${RouteParams.ModelID}/slack-subscribers`,
[PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS]: `${RouteParams.ModelID}/microsoft-teams-subscribers`,
[PageMap.STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS]: `${RouteParams.ModelID}/webhook-subscribers`,
[PageMap.STATUS_PAGE_VIEW_HEADER_STYLE]: `${RouteParams.ModelID}/header-style`,
[PageMap.STATUS_PAGE_VIEW_FOOTER_STYLE]: `${RouteParams.ModelID}/footer-style`,
@@ -1081,6 +1082,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS]: new Route(
`/dashboard/${RouteParams.ProjectID}/status-pages/${
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS]
}`,
),
[PageMap.STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS]: new Route(
`/dashboard/${RouteParams.ProjectID}/status-pages/${
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS]

View File

@@ -92,7 +92,7 @@ The following example shows how to use a JavaScript expression to monitor an inc
// you can combine multiple expressions using logical operators
"{{requestBody.item}"} === "hello" && "{{requestHeaders.contentType}}" === "text/html"
"{{requestBody.item}}" === "hello" && "{{requestHeaders.contentType}}" === "text/html"
// you can use the following for arrays

View File

@@ -16,6 +16,37 @@ docker run --name oneuptime-probe --network host -e PROBE_KEY=<probe-key> -e PRO
If you are self hosting OneUptime, you can change `ONEUPTIME_URL` to your custom self hosted instance.
##### Proxy Configuration
If your probe needs to go through a proxy server to reach OneUptime or monitor external resources, you can configure proxy settings using these environment variables:
```
# For HTTP proxy
docker run --name oneuptime-probe --network host \
-e PROBE_KEY=<probe-key> \
-e PROBE_ID=<probe-id> \
-e ONEUPTIME_URL=https://oneuptime.com \
-e HTTP_PROXY_URL=http://proxy.example.com:8080 \
-d oneuptime/probe:release
# For HTTPS proxy
docker run --name oneuptime-probe --network host \
-e PROBE_KEY=<probe-key> \
-e PROBE_ID=<probe-id> \
-e ONEUPTIME_URL=https://oneuptime.com \
-e HTTPS_PROXY_URL=http://proxy.example.com:8080 \
-d oneuptime/probe:release
# With proxy authentication
docker run --name oneuptime-probe --network host \
-e PROBE_KEY=<probe-key> \
-e PROBE_ID=<probe-id> \
-e ONEUPTIME_URL=https://oneuptime.com \
-e HTTP_PROXY_URL=http://username:password@proxy.example.com:8080 \
-e HTTPS_PROXY_URL=http://username:password@proxy.example.com:8080 \
-d oneuptime/probe:release
```
#### Docker Compose
You can also run the probe using docker-compose. Create a `docker-compose.yml` file with the following content:
@@ -35,6 +66,31 @@ services:
restart: always
```
##### With Proxy Configuration
If you need to use a proxy server, you can add proxy environment variables:
```yaml
version: "3"
services:
oneuptime-probe:
image: oneuptime/probe:release
container_name: oneuptime-probe
environment:
- PROBE_KEY=<probe-key>
- PROBE_ID=<probe-id>
- ONEUPTIME_URL=https://oneuptime.com
# Proxy configuration (optional)
- HTTP_PROXY_URL=http://proxy.example.com:8080
- HTTPS_PROXY_URL=http://proxy.example.com:8080
# For proxy with authentication:
# - HTTP_PROXY_URL=http://username:password@proxy.example.com:8080
# - HTTPS_PROXY_URL=http://username:password@proxy.example.com:8080
network_mode: host
restart: always
```
Then run the following command:
```
@@ -56,20 +112,61 @@ spec:
selector:
matchLabels:
app: oneuptime-probe
template:
metadata:
labels:
app: oneuptime-probe
spec:
containers:
image: oneuptime/probe:release
env:
- name: PROBE_KEY
value: "<probe-key>"
- name: PROBE_ID
value: "<probe-id>"
- name: ONEUPTIME_URL
value: "https://oneuptime.com"
template:
metadata:
labels:
app: oneuptime-probe
spec:
containers:
- name: oneuptime-probe
image: oneuptime/probe:release
env:
- name: PROBE_KEY
value: "<probe-key>"
- name: PROBE_ID
value: "<probe-id>"
- name: ONEUPTIME_URL
value: "https://oneuptime.com"
```
##### With Proxy Configuration
If you need to use a proxy server, you can add proxy environment variables:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: oneuptime-probe
spec:
selector:
matchLabels:
app: oneuptime-probe
template:
metadata:
labels:
app: oneuptime-probe
spec:
containers:
- name: oneuptime-probe
image: oneuptime/probe:release
env:
- name: PROBE_KEY
value: "<probe-key>"
- name: PROBE_ID
value: "<probe-id>"
- name: ONEUPTIME_URL
value: "https://oneuptime.com"
# Proxy configuration (optional)
- name: HTTP_PROXY_URL
value: "http://proxy.example.com:8080"
- name: HTTPS_PROXY_URL
value: "http://proxy.example.com:8080"
# For proxy with authentication, use:
# - name: HTTP_PROXY_URL
# value: "http://username:password@proxy.example.com:8080"
# - name: HTTPS_PROXY_URL
# value: "http://username:password@proxy.example.com:8080"
```
Then run the following command:
@@ -80,6 +177,46 @@ kubectl apply -f oneuptime-probe.yaml
If you are self hosting OneUptime, you can change `ONEUPTIME_URL` to your custom self hosted instance.
### Environment Variables
The probe supports the following environment variables:
#### Required Variables
- `PROBE_KEY` - The probe key from your OneUptime dashboard
- `PROBE_ID` - The probe ID from your OneUptime dashboard
- `ONEUPTIME_URL` - The URL of your OneUptime instance (default: https://oneuptime.com)
#### Optional Variables
- `HTTP_PROXY_URL` - HTTP proxy server URL for HTTP requests
- `HTTPS_PROXY_URL` - HTTP proxy server URL for HTTPS requests
- `PROBE_NAME` - Custom name for the probe
- `PROBE_DESCRIPTION` - Description for the probe
- `PROBE_MONITORING_WORKERS` - Number of monitoring workers (default: 1)
- `PROBE_MONITOR_FETCH_LIMIT` - Number of monitors to fetch at once (default: 10)
- `PROBE_MONITOR_RETRY_LIMIT` - Number of retries for failed monitors (default: 3)
- `PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS` - Timeout for synthetic monitor scripts in milliseconds (default: 60000)
- `PROBE_CUSTOM_CODE_MONITOR_SCRIPT_TIMEOUT_IN_MS` - Timeout for custom code monitor scripts in milliseconds (default: 60000)
#### Proxy Configuration
The probe supports both HTTP and HTTPS proxy servers. When configured, the probe will route all monitoring traffic through the specified proxy servers.
**Proxy URL Format:**
```
http://[username:password@]proxy.server.com:port
```
**Examples:**
- Basic proxy: `http://proxy.example.com:8080`
- With authentication: `http://username:password@proxy.example.com:8080`
**Supported Features:**
- HTTP and HTTPS proxy support
- Proxy authentication (username/password)
- Automatic fallback between HTTP and HTTPS proxies
- Works with all monitor types (Website, API, SSL, Synthetic, etc.)
**Note:** Both standard environment variables (`HTTP_PROXY_URL`, `HTTPS_PROXY_URL`) and lowercase variants (`http_proxy`, `https_proxy`) are supported for compatibility.
### Verify

View File

@@ -7,30 +7,79 @@
}) %>
</head>
<body class="flex min-h-full bg-white ">
<body class="flex min-h-full bg-white">
<div class="flex w-full flex-col">
<%- include('./Partials/Header.ejs') %>
<!-- Mobile top bar with menu button -->
<div class="flex items-center justify-between lg:hidden border-b border-slate-200 px-4 py-2 gap-2">
<button aria-label="Open navigation" data-mobile-menu-open class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-600 shadow-sm active:scale-[.97]">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
Menu
</button>
<h1 class="text-sm font-semibold text-slate-700 truncate"><%- link.title %></h1>
</div>
<!-- Mobile nav overlay -->
<div data-mobile-menu-overlay class="fixed inset-0 z-40 bg-slate-900/40 lg:hidden hidden"></div>
<aside data-mobile-menu-drawer class="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto bg-white px-6 py-6 shadow-lg ring-1 ring-slate-900/10 lg:hidden transform transition ease-out duration-200 -translate-x-full hidden">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-semibold text-slate-700">Documentation</span>
<button aria-label="Close navigation" data-mobile-menu-close class="rounded-md p-2 text-slate-500 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"><svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
</div>
<%- include('./Partials/Nav.ejs') %>
</aside>
<div class="relative mx-auto flex w-full max-w-8xl flex-auto justify-center sm:px-2 lg:px-8 xl:px-12">
<div class="hidden lg:relative lg:block lg:flex-none">
<div class="absolute inset-y-0 right-0 w-[50vw] bg-slate-50 "></div>
<div
class="absolute bottom-0 right-0 top-16 hidden h-12 w-px bg-gradient-to-t from-slate-800 ">
</div>
<div class="absolute bottom-0 right-0 top-28 hidden w-px bg-slate-800 "></div>
<div
class="sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64 overflow-y-auto overflow-x-hidden py-16 pl-0.5 pr-8 xl:w-72 xl:pr-16">
<%- include('./Partials/Nav.ejs') %>
<div class="absolute inset-y-0 right-0 w-[50vw] bg-slate-50"></div>
<div class="absolute bottom-0 right-0 top-16 hidden h-12 w-px bg-gradient-to-t from-slate-800"></div>
<div class="absolute bottom-0 right-0 top-28 hidden w-px bg-slate-800"></div>
<div class="sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64 overflow-y-auto overflow-x-hidden py-16 pl-0.5 pr-8 xl:w-72 xl:pr-16">
<%- include('./Partials/Nav.ejs') %>
</div>
</div>
<div class="min-w-0 max-w-2xl flex-auto px-4 py-16 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16">
<div class="min-w-0 max-w-2xl flex-auto px-4 py-8 md:py-12 lg:py-16 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16">
<%- include('./Partials/Content.ejs', { category: category, link: link, content: content }) %>
<%- include('./Partials/OpenSourceCommitment.ejs', { githubPath: githubPath }) %>
</div>
</div>
</div>
<!-- Mobile menu toggle (vanilla JS) -->
<script>
(function(){
const openBtn = document.querySelector('[data-mobile-menu-open]');
const closeBtn = document.querySelector('[data-mobile-menu-close]');
const overlay = document.querySelector('[data-mobile-menu-overlay]');
const drawer = document.querySelector('[data-mobile-menu-drawer]');
if(!openBtn || !overlay || !drawer){ return; }
function openMenu(){
overlay.classList.remove('hidden');
drawer.classList.remove('hidden');
// trigger slide in
requestAnimationFrame(()=>{
drawer.classList.remove('-translate-x-full');
});
document.body.classList.add('overflow-hidden');
}
function closeMenu(){
drawer.classList.add('-translate-x-full');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
// after transition hide drawer
drawer.addEventListener('transitionend', function handler(e){
if(e.propertyName === 'transform'){
if(drawer.classList.contains('-translate-x-full')){
drawer.classList.add('hidden');
}
drawer.removeEventListener('transitionend', handler);
}
});
}
openBtn.addEventListener('click', openMenu);
overlay.addEventListener('click', closeMenu);
closeBtn && closeBtn.addEventListener('click', closeMenu);
// Close on escape
document.addEventListener('keydown', (e)=>{ if(e.key==='Escape'){ closeMenu(); }});
})();
</script>
</body>
</html>

View File

@@ -2,17 +2,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" href="/docs/static/fonts/f1.woff2" as="font" crossorigin="" type="font/woff2">
<link rel="preload" href="/docs/static/fonts/f2.woff2" as="font" crossorigin="" type="font/woff2">
<link rel="stylesheet" href="/docs/static/css/style.css" crossorigin="" data-precedence="next">
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdn.tailwindcss.com" crossorigin>
<link rel="stylesheet" href="/docs/static/css/style.css" crossorigin data-precedence="next">
<title>OneUptime Documentation</title>
<meta name="description"
content="Cache every single thing your app could ever do ahead of time, so your code never even has to run at all.">
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16">
<meta name="next-size-adjust">
<link rel="stylesheet" href="/docs/static/css/style.css" crossorigin="" data-precedence="next" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/a11y-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<script src="https://cdn.tailwindcss.com" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/a11y-dark.min.css" crossorigin>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" onload="hljs.highlightAll();"></script>
<% if(typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false){ %>
<!-- Google Tag Manager -->

View File

@@ -1,5 +1,5 @@
<header
class="sticky top-0 z-50 flex flex-none flex-wrap items-center justify-between bg-white px-4 py-5 shadow-md shadow-slate-900/5 transition duration-500 sm:px-6 lg:px-8 ">
class="sticky top-0 z-50 flex flex-none flex-wrap items-center justify-between bg-white px-4 py-4 sm:py-5 border-b border-slate-200 sm:px-6 lg:px-8">
<div class="relative flex flex-grow basis-0 items-center"><a aria-label="Home page" href="/">
<img class="h-8 w-auto" src="/img/3-transparent.svg" alt="">

View File

@@ -1,4 +1,4 @@
<nav class="text-base lg:text-sm">
<nav class="text-base lg:text-sm" style="width:16rem;flex:0 0 16rem;" aria-label="Documentation navigation">
<ul role="list" class="space-y-9">
<% for(var i=0; i<nav.length; i++) {%>
<li>

View File

@@ -0,0 +1,50 @@
import { BASE_URL, IS_BILLING_ENABLED } from "../../Config";
import { Page, expect, test, Response } from "@playwright/test";
import URL from "Common/Types/API/URL";
// Helper to fetch sitemap XML via a normal page navigation.
async function fetchSitemap(page: Page): Promise<string> {
const response: Response | null = await page.goto(
URL.fromString(`${BASE_URL.toString()}sitemap.xml`).toString(),
{ waitUntil: "networkidle" },
);
expect(response, "sitemap.xml should respond").toBeTruthy();
expect(response?.status(), "sitemap.xml should return 200").toBe(200);
// Raw content (Playwright wraps XML in HTML view sometimes); extract text.
const body: Awaited<ReturnType<typeof page.$>> = await page.$("body");
const xml: string = (await body?.innerText()) || (await page.content());
return xml;
}
function extractLocs(xml: string): string[] {
const regex: RegExp = /<loc>(.*?)<\/loc>/g;
const locs: string[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(xml)) !== null) {
if (match[1]) {
locs.push(match[1]);
}
}
return locs;
}
test.describe("Home: Sitemap", () => {
test("sitemap loads and has home first", async ({ page }: { page: Page }) => {
if (!IS_BILLING_ENABLED) {
return; // mirror existing pattern
}
page.setDefaultNavigationTimeout(120000);
const xml: string = await fetchSitemap(page);
expect(xml.includes("<urlset")).toBeTruthy();
const locs: string[] = extractLocs(xml);
expect(locs.length).toBeGreaterThan(0);
const first: string | undefined = locs[0];
expect(first, "First <loc> should exist").toBeTruthy();
expect(first!.endsWith("/")).toBeTruthy();
});
});

View File

@@ -36,7 +36,9 @@ const init: PromiseVoidFunction = async (): Promise<void> => {
serviceName: APP_NAME,
});
logger.info(`FluentIngest Service - Queue concurrency: ${FLUENT_INGEST_CONCURRENCY}`);
logger.info(
`FluentIngest Service - Queue concurrency: ${FLUENT_INGEST_CONCURRENCY}`,
);
// init the app
await App.init({

View File

@@ -1,5 +1,7 @@
{
"watch": ["./","../Common/Server", "../Common/Types", "../Common/Utils", "../Common/Models"],
"ext": "ts,json,tsx,env,js,jsx,hbs",
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
"watchOptions": {"useFsEvents": false, "interval": 500},
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
}

View File

@@ -103,6 +103,8 @@ The following table lists the configurable parameters of the OneUptime chart and
| `probes.<key>.monitorFetchLimit` | Number of resources to be monitored in parallel | `10` | |
| `probes.<key>.syntheticMonitorScriptTimeoutInMs` | Timeout for synthetic monitor script | `60000` | |
| `probes.<key>.customCodeMonitorScriptTimeoutInMs` | Timeout for custom code monitor script | `60000` | |
| `probes.<key>.proxy.httpProxyUrl` | HTTP proxy URL for HTTP requests made by the probe (optional) | `nil` | |
| `probes.<key>.proxy.httpsProxyUrl` | HTTPS proxy URL for HTTPS requests made by the probe (optional) | `nil` | |
| `probes.<key>.additionalContainers` | Additional containers to add to the probe pod | `nil` | |
| `probes.<key>.resources` | Pod resources (limits, requests) | `nil` | |
| `statusPage.cnameRecord` | CNAME record for the status page | `nil` | |

View File

@@ -103,6 +103,14 @@ spec:
- name: DISABLE_TELEMETRY
value: {{ $val.disableTelemetryCollection | quote }}
{{- end }}
{{- if and $val.proxy $val.proxy.httpProxyUrl }}
- name: HTTP_PROXY_URL
value: {{ $val.proxy.httpProxyUrl | squote }}
{{- end }}
{{- if and $val.proxy $val.proxy.httpsProxyUrl }}
- name: HTTPS_PROXY_URL
value: {{ $val.proxy.httpsProxyUrl | squote }}
{{- end }}
{{- include "oneuptime.env.oneuptimeSecret" $ | nindent 12 }}
ports:
- containerPort: {{ if and $val.ports $val.ports.http }}{{ $val.ports.http }}{{ else }}3874{{ end }}

View File

@@ -208,6 +208,18 @@ probes:
disableAutoscaler: false
ports:
http: 3874
# Proxy configuration for probe connections
proxy:
# HTTP proxy URL for HTTP requests (optional)
# Format: http://[username:password@]proxy.server.com:port
# Example: http://proxy.example.com:8080
# Example with auth: http://username:password@proxy.example.com:8080
httpProxyUrl:
# HTTPS proxy URL for HTTPS requests (optional)
# Format: http://[username:password@]proxy.server.com:port
# Example: http://proxy.example.com:8080
# Example with auth: http://username:password@proxy.example.com:8080
httpsProxyUrl:
# KEDA autoscaling configuration based on monitor queue metrics
keda:
enabled: false
@@ -234,6 +246,12 @@ probes:
# customCodeMonitorScriptTimeoutInMs: 60000
# disableTelemetryCollection: false
# disableAutoscaler: false
# # Proxy configuration for probe connections
# proxy:
# # HTTP proxy URL for HTTP requests (optional)
# httpProxyUrl:
# # HTTPS proxy URL for HTTPS requests (optional)
# httpsProxyUrl:
# resources:
# additionalContainers:
# KEDA autoscaling configuration based on monitor queue metrics

View File

@@ -93,9 +93,40 @@ app.get(
async (req: ExpressRequest, res: ExpressResponse) => {
try {
const tagName: string = req.params["tagName"] as string;
const tagSlug: string = tagName; // original slug
const blogPosts: Array<BlogPostHeader> =
// Pagination params
const pageParam: string | undefined = req.query["page"] as
| string
| undefined;
const pageSizeParam: string | undefined = req.query["pageSize"] as
| string
| undefined;
let page: number = pageParam ? parseInt(pageParam, 10) : 1;
let pageSize: number = pageSizeParam ? parseInt(pageSizeParam, 10) : 24;
if (isNaN(page) || page < 1) {
page = 1;
}
if (isNaN(pageSize) || pageSize < 1) {
pageSize = 24;
}
if (pageSize > 100) {
pageSize = 100;
}
const allPosts: Array<BlogPostHeader> =
await BlogPostUtil.getBlogPostList(tagName);
const totalPosts: number = allPosts.length;
const totalPages: number = Math.ceil(totalPosts / pageSize) || 1;
if (page > totalPages) {
page = totalPages;
}
const start: number = (page - 1) * pageSize;
const paginatedPosts: Array<BlogPostHeader> = allPosts.slice(
start,
start + pageSize,
);
const allTags: Array<string> = await BlogPostUtil.getTags();
res.render(`${ViewsPath}/Blog/ListByTag`, {
support: false,
@@ -103,8 +134,15 @@ app.get(
cta: true,
blackLogo: false,
requestDemoCta: false,
blogPosts: blogPosts,
blogPosts: paginatedPosts,
tagName: Text.fromDashesToPascalCase(tagName),
tagSlug: tagSlug,
allTags: allTags,
page: page,
pageSize: pageSize,
totalPages: totalPages,
totalPosts: totalPosts,
basePath: `/blog/tag/${tagSlug}`,
enableGoogleTagManager: IsBillingEnabled,
});
} catch (e) {
@@ -117,8 +155,38 @@ app.get(
// main blog page
app.get("/blog", async (_req: ExpressRequest, res: ExpressResponse) => {
try {
const blogPosts: Array<BlogPostHeader> =
const req: ExpressRequest = _req; // alias for clarity
const pageParam: string | undefined = req.query["page"] as
| string
| undefined;
const pageSizeParam: string | undefined = req.query["pageSize"] as
| string
| undefined;
let page: number = pageParam ? parseInt(pageParam, 10) : 1;
let pageSize: number = pageSizeParam ? parseInt(pageSizeParam, 10) : 24;
if (isNaN(page) || page < 1) {
page = 1;
}
if (isNaN(pageSize) || pageSize < 1) {
pageSize = 24;
}
if (pageSize > 100) {
pageSize = 100;
}
const allPosts: Array<BlogPostHeader> =
await BlogPostUtil.getBlogPostList();
const totalPosts: number = allPosts.length;
const totalPages: number = Math.ceil(totalPosts / pageSize) || 1;
if (page > totalPages) {
page = totalPages;
}
const start: number = (page - 1) * pageSize;
const paginatedPosts: Array<BlogPostHeader> = allPosts.slice(
start,
start + pageSize,
);
const allTags: Array<string> = await BlogPostUtil.getTags();
res.render(`${ViewsPath}/Blog/List`, {
support: false,
@@ -126,7 +194,13 @@ app.get("/blog", async (_req: ExpressRequest, res: ExpressResponse) => {
cta: true,
blackLogo: false,
requestDemoCta: false,
blogPosts: blogPosts,
blogPosts: paginatedPosts,
page: page,
pageSize: pageSize,
totalPages: totalPages,
totalPosts: totalPosts,
basePath: `/blog`,
allTags: allTags,
enableGoogleTagManager: IsBillingEnabled,
});
} catch (e) {

View File

@@ -4,6 +4,7 @@ import { StaticPath, ViewsPath } from "./Utils/Config";
import NotFoundUtil from "./Utils/NotFound";
import ProductCompare, { Product } from "./Utils/ProductCompare";
import generateSitemapXml from "./Utils/Sitemap";
import DatabaseConfig from "Common/Server/DatabaseConfig";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
@@ -25,12 +26,41 @@ import Reviews from "./Utils/Reviews";
// import jobs.
import "./Jobs/UpdateBlog";
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import LocalCache from "Common/Server/Infrastructure/LocalCache";
const HomeFeatureSet: FeatureSet = {
init: async (): Promise<void> => {
const app: ExpressApplication = Express.getExpressApp();
//Routes
// Middleware to inject baseUrl for templates (used for canonical links)
app.use(
async (_req: ExpressRequest, res: ExpressResponse, next: () => void) => {
if (!res.locals["homeUrl"]) {
try {
// Try to get cached home URL first.
let homeUrl: string | undefined = LocalCache.getString(
"home",
"url",
);
if (!homeUrl) {
homeUrl = (await DatabaseConfig.getHomeUrl())
.toString()
.replace(/\/$/, "");
LocalCache.setString("home", "url", homeUrl);
}
res.locals["homeUrl"] = homeUrl;
} catch {
// Fallback hard-coded production domain if env misconfigured
res.locals["homeUrl"] = "https://oneuptime.com";
}
}
next();
},
);
app.get("/", (_req: ExpressRequest, res: ExpressResponse) => {
const { reviewsList1, reviewsList2, reviewsList3 } = Reviews;
@@ -1331,18 +1361,21 @@ const HomeFeatureSet: FeatureSet = {
);
// Dynamic Sitemap
app.get("/sitemap.xml", async (_req: ExpressRequest, res: ExpressResponse) => {
try {
const xml: string = await generateSitemapXml();
res.setHeader("Content-Type", "text/xml");
res.send(xml);
} catch (err) {
// Fallback minimal static sitemap if dynamic generation fails
const fallback: string = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://oneuptime.com/</loc></url>\n</urlset>`;
res.setHeader("Content-Type", "text/xml");
res.status(200).send(fallback);
}
});
app.get(
"/sitemap.xml",
async (_req: ExpressRequest, res: ExpressResponse) => {
try {
const xml: string = await generateSitemapXml();
res.setHeader("Content-Type", "text/xml");
res.send(xml);
} catch {
// Fallback minimal static sitemap if dynamic generation fails
const fallback: string = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://oneuptime.com/</loc></url>\n</urlset>`;
res.setHeader("Content-Type", "text/xml");
res.status(200).send(fallback);
}
},
);
/*
* Cache policy for static contents

View File

@@ -1,14 +1,9 @@
import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import OneUptimeDate from "Common/Types/Date";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONArray, JSONObject, JSONObjectOrArray } from "Common/Types/JSON";
import { JSONArray, JSONObject } from "Common/Types/JSON";
import JSONFunctions from "Common/Types/JSONFunctions";
import Text from "Common/Types/Text";
import API from "Common/Utils/API";
import Markdown, { MarkdownContentType } from "Common/Server/Types/Markdown";
import { BlogRootPath } from "./Config";
import LocalFile from "Common/Server/Utils/LocalFile";
@@ -19,6 +14,7 @@ export interface BlogPostAuthor {
githubUrl: string;
profileImageUrl: string;
name: string;
bio?: string | undefined; // optional bio from Authors.json
}
export interface BlogPostBaseProps {
@@ -44,6 +40,45 @@ export interface BlogPost extends BlogPostBaseProps {
}
export default class BlogPostUtil {
// Cache Blogs.json contents to avoid repeated disk reads and external calls.
private static blogsMetaCache: Array<JSONObject> | null = null;
// Cache Authors.json (keyed by github username)
private static authorsMetaCache: JSONObject | null = null;
private static async getBlogsMeta(): Promise<Array<JSONObject>> {
if (this.blogsMetaCache) {
return this.blogsMetaCache;
}
const filePath: string = `${BlogRootPath}/Blogs.json`;
let jsonContent: string | JSONArray = await LocalFile.read(filePath);
if (typeof jsonContent === "string") {
jsonContent = JSONFunctions.parseJSONArray(jsonContent);
}
const blogs: Array<JSONObject> = JSONFunctions.deserializeArray(
jsonContent as Array<JSONObject>,
);
this.blogsMetaCache = blogs;
return blogs;
}
private static async getAuthorsMeta(): Promise<JSONObject> {
if (this.authorsMetaCache) {
return this.authorsMetaCache;
}
const filePath: string = `${BlogRootPath}/Authors.json`;
try {
let jsonContent: string | JSONObject = await LocalFile.read(filePath);
if (typeof jsonContent === "string") {
jsonContent = JSONFunctions.parse(jsonContent) as JSONObject;
}
this.authorsMetaCache = jsonContent as JSONObject;
return this.authorsMetaCache || ({} as JSONObject);
} catch {
this.authorsMetaCache = {} as JSONObject;
return this.authorsMetaCache;
}
}
public static async getBlogPostList(
tagName?: string | undefined,
): Promise<BlogPostHeader[]> {
@@ -98,30 +133,6 @@ export default class BlogPostUtil {
return blogPost;
}
public static async getNameOfGitHubUser(username: string): Promise<string> {
const fileUrl: URL = URL.fromString(
`https://api.github.com/users/${username}`,
);
const fileData:
| HTTPResponse<
| JSONObjectOrArray
| BaseModel
| BaseModel[]
| AnalyticsBaseModel
| AnalyticsBaseModel[]
>
| HTTPErrorResponse = await API.get(fileUrl);
if (fileData.isFailure()) {
throw fileData as HTTPErrorResponse;
}
const name: string =
(fileData.data as JSONObject)?.["name"]?.toString() || "";
return name;
}
public static async getTags(): Promise<string[]> {
// check if tags are in cache
@@ -168,8 +179,43 @@ export default class BlogPostUtil {
let markdownContent: string = await LocalFile.read(filePath);
const blogPostAuthor: BlogPostAuthor | null =
await this.getAuthorFromFileContent(markdownContent);
// Resolve author WITHOUT hitting GitHub API. Use Blogs.json to get username, Authors.json for name/bio.
let blogPostAuthor: BlogPostAuthor | null = null;
try {
const blogsMeta: Array<JSONObject> = await this.getBlogsMeta();
const blogMeta: JSONObject | undefined = blogsMeta.find(
(b: JSONObject) => {
return (b["post"] as string) === fileName;
},
);
const username: string | undefined = blogMeta?.[
"authorGitHubUsername"
] as string | undefined;
if (username) {
const authorsMeta: JSONObject = await this.getAuthorsMeta();
const authorMeta: JSONObject | undefined = authorsMeta[username] as
| JSONObject
| undefined;
const authorName: string | undefined =
(authorMeta?.["authorName"] as string) || undefined;
const authorBio: string | undefined =
(authorMeta?.["authorBio"] as string) || undefined;
blogPostAuthor = {
username,
githubUrl: `https://github.com/${username}`,
profileImageUrl: `https://avatars.githubusercontent.com/${username}`,
name: authorName || username,
bio: authorBio,
};
}
} catch {
// ignore and fallback
}
// Fallback to parsing markdown (no network) if metadata missing.
if (!blogPostAuthor) {
blogPostAuthor = await this.getAuthorFromFileContent(markdownContent);
}
const title: string = this.getTitleFromFileContent(markdownContent);
const description: string =
@@ -346,7 +392,8 @@ export default class BlogPostUtil {
username: authorUsername,
githubUrl: authorGitHubUrl,
profileImageUrl: authorProfileImageUrl,
name: await this.getNameOfGitHubUser(authorUsername),
// Do NOT call GitHub; use username as name placeholder.
name: authorUsername,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,130 +15,171 @@ interface CachedSitemap {
const TTL_MS: number = 10 * 60 * 1000;
let cache: CachedSitemap | null = null;
export const generateSitemapXml = async (): Promise<string> => {
const now: number = OneUptimeDate.getCurrentDate().getTime();
if (cache && now - cache.generatedAt < TTL_MS) {
return cache.xml;
}
export const generateSitemapXml: () => Promise<string> =
async (): Promise<string> => {
const now: number = OneUptimeDate.getCurrentDate().getTime();
if (cache && now - cache.generatedAt < TTL_MS) {
return cache.xml;
}
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
// Discover static (non-parameterized) routes from Express stack
const discoveredStaticPaths: Set<string> = new Set();
try {
const app: ExpressApplication = Express.getExpressApp();
const stack: any[] = (app as any)?._router?.stack || [];
for (const layer of stack) {
if (!layer) { continue; }
const route = layer.route || layer?.handle?.route;
if (!route) { continue; }
// Only include GET handlers
const methods: any = route.methods || {};
if (!methods.get) { continue; }
const path: string | string[] | undefined = route.path || route?.route?.path;
const rawPaths: Array<string | undefined> = Array.isArray(path) ? path : [path];
const paths: string[] = rawPaths.filter((p): p is string => !!p);
for (let p of paths) {
if (!p || typeof p !== "string") { continue; }
// Filters: skip parameterized, wildcard, api or file-serving or sitemap itself
if (p.includes(":") || p.includes("*") || p.includes("sitemap")) { continue; }
// Exclude script or installer endpoints
if (p.endsWith(".sh")) { continue; }
if (p.startsWith("/api") || p.startsWith("/blog/post")) { continue; }
// We'll add compare pages separately with real slugs; skip base compare param route
if (p.startsWith("/compare")) { continue; }
// Normalize slash
if (!p.startsWith("/")) { p = `/${p}`; }
discoveredStaticPaths.add(p);
// Discover static (non-parameterized) routes from Express stack
const discoveredStaticPaths: Set<string> = new Set();
try {
const app: ExpressApplication = Express.getExpressApp();
const stack: any[] = (app as any)?._router?.stack || [];
for (const layer of stack) {
if (!layer) {
continue;
}
const route: any = layer.route || layer?.handle?.route;
if (!route) {
continue;
}
// Only include GET handlers
const methods: any = route.methods || {};
if (!methods.get) {
continue;
}
const path: string | string[] | undefined =
route.path || route?.route?.path;
const rawPaths: Array<string | undefined> = Array.isArray(path)
? path
: [path];
const paths: string[] = rawPaths.filter(
(p: string | undefined): p is string => {
return Boolean(p);
},
);
for (let p of paths) {
if (!p || typeof p !== "string") {
continue;
}
// Filters: skip parameterized, wildcard, api or file-serving or sitemap itself
if (p.includes(":") || p.includes("*") || p.includes("sitemap")) {
continue;
}
// Exclude script or installer endpoints
if (p.endsWith(".sh")) {
continue;
}
if (p.startsWith("/api") || p.startsWith("/blog/post")) {
continue;
}
// We'll add compare pages separately with real slugs; skip base compare param route
if (p.startsWith("/compare")) {
continue;
}
// Normalize slash
if (!p.startsWith("/")) {
p = `/${p}`;
}
discoveredStaticPaths.add(p);
}
}
// Ensure root present
discoveredStaticPaths.add("/");
// Ensure docs main landing page present (may be served statically and not discoverable)
discoveredStaticPaths.add("/docs");
// add /reference
discoveredStaticPaths.add("/reference");
} catch {
// If introspection fails, fall back to minimal set
discoveredStaticPaths.add("/");
discoveredStaticPaths.add("/blog");
}
// Ensure root present
discoveredStaticPaths.add("/");
} catch {
// If introspection fails, fall back to minimal set
discoveredStaticPaths.add("/");
discoveredStaticPaths.add("/blog");
}
const staticPaths: string[] = Array.from(discoveredStaticPaths);
const staticPaths: string[] = Array.from(discoveredStaticPaths);
// Product compare pages
const productComparePaths: string[] = getProductCompareSlugs().map(
(slug: string) => `/compare/${slug}`,
);
// Product compare pages
const productComparePaths: string[] = getProductCompareSlugs().map(
(slug: string) => {
return `/compare/${slug}`;
},
);
// Blog posts
const blogPosts: Array<BlogPostHeader> = await BlogPostUtil.getBlogPostList();
const blogPostEntries = blogPosts.map((post: BlogPostHeader) => {
// post.blogUrl already contains /blog/post/<slug>/view relative or absolute? In BlogPostUtil it's relative (starts with /blog...), so ensure absolute.
const loc: string = post.blogUrl.startsWith("http")
? post.blogUrl
: `${baseUrl.toString()}${post.blogUrl.replace(/^\//, "")}`;
return {
loc,
lastmod: new Date(post.postDate).toISOString(),
};
});
// Blog posts
const blogPosts: Array<BlogPostHeader> =
await BlogPostUtil.getBlogPostList();
const blogPostEntries: any[] = blogPosts.map((post: BlogPostHeader) => {
// post.blogUrl already contains /blog/post/<slug>/view relative or absolute? In BlogPostUtil it's relative (starts with /blog...), so ensure absolute.
const loc: string = post.blogUrl.startsWith("http")
? post.blogUrl
: `${baseUrl.toString()}${post.blogUrl.replace(/^\//, "")}`;
return {
loc,
lastmod: new Date(post.postDate).toISOString(),
};
});
// Blog tags
const tags: string[] = await BlogPostUtil.getTags();
const tagEntries = tags.map((tag: string) => {
const tagSlug: string = tag
.toLowerCase()
.replace(/\s+/g, "-")
.trim();
return {
loc: `${baseUrl.toString()}blog/tag/${tagSlug}`,
lastmod: OneUptimeDate.getCurrentDate().toISOString(),
};
});
// Blog tags
const tags: string[] = await BlogPostUtil.getTags();
const tagEntries: any[] = tags.map((tag: string) => {
const tagSlug: string = tag.toLowerCase().replace(/\s+/g, "-").trim();
return {
loc: `${baseUrl.toString()}blog/tag/${tagSlug}`,
lastmod: OneUptimeDate.getCurrentDate().toISOString(),
};
});
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
interface Entry { loc: string; lastmod: string }
const entries: Entry[] = [
...staticPaths.map((p: string) => ({
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
lastmod: timestamp,
})),
...productComparePaths.map((p: string) => ({
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
lastmod: timestamp,
})),
...blogPostEntries,
...tagEntries,
];
// Remove duplicates (possible if overlap)
const dedupMap: Map<string, Entry> = new Map();
entries.forEach((e: Entry) => {
if (!dedupMap.has(e.loc)) {
dedupMap.set(e.loc, e);
interface Entry {
loc: string;
lastmod: string;
}
});
const entries: Entry[] = [
...staticPaths.map((p: string) => {
return {
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
lastmod: timestamp,
};
}),
...productComparePaths.map((p: string) => {
return {
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
lastmod: timestamp,
};
}),
...blogPostEntries,
...tagEntries,
];
const urlset: XMLBuilder = create().ele("urlset");
urlset.att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
// Remove duplicates (possible if overlap)
const dedupMap: Map<string, Entry> = new Map();
entries.forEach((e: Entry) => {
if (!dedupMap.has(e.loc)) {
dedupMap.set(e.loc, e);
}
});
// Ensure home URL is first
const baseUrlString: string = baseUrl.toString();
const orderedEntries = Array.from(dedupMap.values());
orderedEntries.sort((a, b) => {
if (a.loc === baseUrlString) { return -1; }
if (b.loc === baseUrlString) { return 1; }
return 0; // preserve relative order otherwise
});
const urlset: XMLBuilder = create().ele("urlset");
urlset.att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
for (const entry of orderedEntries) {
const urlEle: XMLBuilder = urlset.ele("url");
urlEle.ele("loc").txt(entry.loc);
urlEle.ele("lastmod").txt(entry.lastmod);
}
// Ensure home URL is first
const baseUrlString: string = baseUrl.toString();
const orderedEntries: any[] = Array.from(dedupMap.values());
orderedEntries.sort((a: any, b: any) => {
if (a.loc === baseUrlString) {
return -1;
}
if (b.loc === baseUrlString) {
return 1;
}
return 0; // preserve relative order otherwise
});
const xml: string = urlset.end({ prettyPrint: true });
for (const entry of orderedEntries) {
const urlEle: XMLBuilder = urlset.ele("url");
urlEle.ele("loc").txt(entry.loc);
urlEle.ele("lastmod").txt(entry.lastmod);
}
cache = { xml, generatedAt: now };
return xml;
};
const xml: string = urlset.end({ prettyPrint: true });
cache = { xml, generatedAt: now };
return xml;
};
export default generateSitemapXml;

View File

@@ -20,31 +20,55 @@
<div class="relative isolate overflow-hidden bg-white">
<div class="py-24 sm:py-32">
<%- include('./Partials/BlogTitleAndDescription', { title: 'Engineering Uptime', smallTitle: '- Blog by OneUptime', description: 'Latest posts on Observability, Monitoring, Reliability and more.' }) -%>
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div>
<%- include('./Partials/BlogTitleAndDescription', { title: 'Engineering Uptime', smallTitle: '- Blog by OneUptime', description: 'Latest posts on Observability, Monitoring, Reliability and more.' }) -%>
<div class="mt-4">
<% if(blogPosts.length> 0){ %>
<ul role="list" class="divide-y divide-gray-100 list-none">
<% for(var i=0; i<blogPosts.length; i++) {%>
<%- include('./Partials/ListItem', { blogPost: blogPosts[i] }) -%>
<% } %>
</ul>
<% } %>
<% const featured = blogPosts[0]; const rest = blogPosts.slice(1); %>
<!-- Featured Post -->
<div class="relative mb-14 rounded-3xl overflow-hidden border border-indigo-100 bg-gradient-to-br from-indigo-50 via-white to-indigo-100/40 p-8 md:p-12 shadow-sm ring-1 ring-indigo-100/60">
<div class="grid md:grid-cols-5 gap-10 items-center">
<div class="md:col-span-3">
<a href="<%- featured.blogUrl %>" class="group no-underline">
<p class="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-indigo-700 bg-indigo-100/70 px-3 py-1 rounded-full ring-1 ring-indigo-200">Featured</p>
<h2 class="mt-5 text-3xl md:text-4xl font-bold tracking-tight text-gray-900 group-hover:text-indigo-700 transition-colors"><%- featured.title %></h2>
<p class="mt-5 text-base md:text-lg leading-relaxed text-gray-600 line-clamp-5"><%- featured.description %></p>
<div class="mt-6 text-xs text-gray-500 flex flex-wrap items-center gap-2">
<img class="h-8 w-8 rounded-full ring-2 ring-white shadow" src="https://avatars.githubusercontent.com/<%- featured.authorGitHubUsername -%>?s=80" alt="@<%- featured.authorGitHubUsername -%>" width="32" height="32" loading="lazy" decoding="async">
<span>@<%- featured.authorGitHubUsername -%></span>
<span class="select-none">•</span>
<span><%- featured.formattedPostDate -%></span>
</div>
<div class="mt-6">
<%- include('./Partials/Tags', { blogPost: featured }) -%>
</div>
<div class="mt-8 inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 group-hover:text-indigo-500">Read article <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" /></svg></div>
</a>
</div>
<div class="md:col-span-2 relative">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(99,102,241,0.15),transparent_70%)] pointer-events-none"></div>
</div>
</div>
</div>
<!-- Posts Grid -->
<div id="blog-posts-grid" class="grid grid-cols-1 gap-10 sm:grid-cols-2 xl:grid-cols-3 items-stretch">
<% for(var i=0; i<rest.length; i++) { const post = rest[i]; %>
<div data-blog-card class="h-full" data-search="<%- (post.title + ' ' + post.description + ' ' + post.tags.join(' ')).toLowerCase() -%>">
<%- include('./Partials/ListItem', { blogPost: post }) -%>
</div>
<% } %>
</div>
<div id="no-search-results" class="hidden text-center text-gray-500 text-sm py-16">No posts match your search.</div>
<%- include('./Partials/Pagination', { page: page, pageSize: pageSize, totalPages: totalPages, totalPosts: totalPosts, basePath: basePath }) -%>
<% } else { %>
<div class="text-center text-gray-600 text-lg py-12">No blog posts found.</div>
<% } %>
<div class="mt-24">
<%- include('./Partials/OpenSourceCommitment', {blogPost: null}) -%>
</div>
</div>
</div>
</div>

View File

@@ -20,30 +20,23 @@
<div class="relative isolate overflow-hidden bg-white">
<div class="py-24 sm:py-32">
<%- include('./Partials/BlogTitleAndDescription', { title: 'Latest posts on '+tagName, smallTitle: "", description: 'Here are some of the latest posts on '+tagName }) -%>
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div>
<%- include('./Partials/BlogTitleAndDescription', { title: 'Latest posts on '+tagName, smallTitle: "", description: 'Here are some of the latest posts on '+tagName }) -%>
<div class="mt-16">
<% if(blogPosts.length> 0){ %>
<ul role="list" class="divide-y divide-gray-100 list-none">
<% for(var i=0; i<blogPosts.length; i++) {%>
<div class="grid grid-cols-1 gap-10 sm:grid-cols-2 xl:grid-cols-3">
<% for(var i=0; i<blogPosts.length; i++) { %>
<%- include('./Partials/ListItem', { blogPost: blogPosts[i] }) -%>
<% } %>
</ul>
<% } %>
<% } %>
</div>
<%- include('./Partials/Pagination', { page: page, pageSize: pageSize, totalPages: totalPages, totalPosts: totalPosts, basePath: basePath }) -%>
<% } else { %>
<div class="text-center text-gray-600 text-lg py-12">No posts found for this tag.</div>
<% } %>
<div class="mt-20">
<%- include('./Partials/OpenSourceCommitment', {blogPost: null}) -%>
</div>
</div>
<%- include('./Partials/OpenSourceCommitment', {blogPost: null}) -%>
</div>
</div>

View File

@@ -0,0 +1,78 @@
<% if(allTags && allTags.length){ %>
<div class="mb-14" id="tag-filter-root">
<div class="mb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-3">
<span class="text-sm font-semibold text-gray-700 flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M4 6a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0012.414 6H18a2 2 0 012 2v2M4 8v8a2 2 0 002 2h2m12-8v4a2 2 0 01-2 2h-4m-6 0h6m-6 0a2 2 0 01-2-2v-2"/></svg> Tags</span>
<span class="hidden md:inline text-xs rounded-full bg-indigo-50 text-indigo-700 px-2 py-0.5 font-medium"> <%- allTags.length %> </span>
</div>
<div class="relative w-full md:w-72">
<input id="tag-filter-search" type="text" placeholder="Search tags..." class="w-full rounded-md border border-gray-200 bg-white py-2 pl-9 pr-3 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" aria-label="Search tags" />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
</div>
</div>
</div>
<div class="relative group">
<div id="tag-filter-container" class="flex gap-2 overflow-x-auto pb-2 scrollbar-thin snap-x snap-mandatory" style="scrollbar-width: none; -ms-overflow-style: none;">
<style>
#tag-filter-container::-webkit-scrollbar{display:none;}
</style>
<a href="/blog" data-tag="all" class="tag-chip <%= !tagName ? 'tag-chip-active' : '' %>" aria-label="Show all posts">All</a>
<% for(var i=0;i<allTags.length;i++){ const t = allTags[i]; const slug = t.replaceAll(' ','-').toLowerCase(); const active = (tagName && tagName.toLowerCase()===t.toLowerCase()); %>
<a href="/blog/tag/<%- slug -%>" data-tag="<%- slug %>" class="tag-chip <%= active ? 'tag-chip-active' : '' %>" aria-label="Filter by <%- t %>"><%- t %></a>
<% } %>
<div id="no-tag-results" class="hidden px-3 py-1.5 text-xs text-gray-500">No tags match.</div>
</div>
<div class="pointer-events-none absolute top-0 right-0 h-full w-10 bg-gradient-to-l from-white to-transparent hidden md:block"></div>
</div>
<div class="mt-3 flex items-center justify-between text-[11px] text-gray-500">
<div id="tag-count-info">Showing <%- allTags.length %> tags</div>
<button id="toggle-more-tags" type="button" class="hidden text-indigo-600 hover:text-indigo-500 font-medium">Show more</button>
</div>
</div>
<script>
(function(){
if(window.__TAG_FILTER_INITIALIZED__) { return; }
window.__TAG_FILTER_INITIALIZED__ = true;
const searchInput = document.getElementById('tag-filter-search');
const container = document.getElementById('tag-filter-container');
const info = document.getElementById('tag-count-info');
const noResults = document.getElementById('no-tag-results');
if(!searchInput || !container) { return; }
const chips = Array.from(container.querySelectorAll('.tag-chip'));
function normalize(s){ return (s||'').toLowerCase(); }
function filter(){
const q = normalize(searchInput.value);
let visible = 0;
chips.forEach(chip => {
if(chip.dataset.tag === 'all'){ // always show All
chip.classList.remove('hidden');
return; }
const text = normalize(chip.textContent);
if(!q || text.includes(q)) { chip.classList.remove('hidden'); visible++; }
else { chip.classList.add('hidden'); }
});
if(visible === 0 && q){ noResults.classList.remove('hidden'); }
else { noResults.classList.add('hidden'); }
info && (info.textContent = q ? `Matched ${visible} tag${visible!==1?'s':''}` : `Showing ${chips.length-1} tags`);
}
searchInput.addEventListener('input', filter);
// Keyboard horizontal scroll convenience
searchInput.addEventListener('keydown', function(e){
if(e.key === 'ArrowRight'){ container.scrollBy({left:120,behavior:'smooth'}); }
if(e.key === 'ArrowLeft'){ container.scrollBy({left:-120,behavior:'smooth'}); }
});
})();
</script>
<style>
/* Inline fallback styling since Tailwind @apply isn't processed in EJS runtime */
.tag-chip { font-size: 0.75rem; font-weight:500; padding:0.375rem 0.75rem; border-radius:9999px; border:1px solid #e5e7eb; background:#ffffff; color:#4b5563; display:inline-flex; align-items:center; transition:all .15s; white-space:nowrap; text-decoration:none; }
.tag-chip:hover { color:#4f46e5; border-color:#818cf8; background:#eef2ff; }
.tag-chip-active { background:#4f46e5; border-color:#4f46e5; color:#ffffff; }
.tag-chip-active:hover { background:#4f46e5; color:#ffffff; }
</style>
<% } %>

View File

@@ -1,17 +1,17 @@
<div class="mb-24">
<div class="mx-auto flex justify-center text-center ">
<h1 class="max-w-5xl text-center text-6xl font-bold text-gray-900 leading-tight">
<div class="relative mb-16 md:mb-20">
<div class="mx-auto flex justify-center text-center">
<div>
<h1 class="max-w-5xl text-5xl md:text-6xl font-extrabold tracking-tight text-gray-900 leading-tight bg-clip-text">
<%= title %>
</h1>
</h1>
<% if(smallTitle){ %>
<h3 class="max-w-5xl mt-4 text-2xl font-semibold text-indigo-600/80 leading-tight">
<%= smallTitle %>
</h3>
<% } %>
<p class="max-w-3xl mx-auto text-lg md:text-2xl leading-8 text-gray-600 mt-6">
<%= description %>
</p>
</div>
<div class="mx-auto flex justify-center text-center ">
<% if(smallTitle){ %>
<h3 class="max-w-5xl mt-5 text-center text-2xl font-medium text-gray-900 leading-tight">
<%= smallTitle %>
</h3>
<% } %>
</div>
<p class="text-2xl text-center leading-8 text-gray-600 mt-8 leading-normal">
<%= description %>
</p>
</div>

View File

@@ -1,33 +1,30 @@
<li class="py-5">
<div class="min-w-0">
<a href="<%= blogPost.blogUrl %>">
<div class="min-w-0 flex-auto">
<p class="font-semibold text-2xl leading-6 text-gray-900">
<%= blogPost.title %>
</p>
<p class="mt-2 leading-5 text-gray-600">
<%= blogPost.description %>
</p>
<div class="group relative flex flex-col h-full rounded-2xl border border-gray-200/80 bg-white/70 backdrop-blur-sm p-6 shadow-sm ring-1 ring-gray-200/60 hover:shadow-lg hover:border-indigo-300 hover:ring-indigo-200 transition-all duration-300 focus-within:shadow-lg" tabindex="0" aria-label="Blog post card: <%- blogPost.title.replace(/\"/g,'') -%>">
<div class="absolute -inset-px rounded-2xl opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition pointer-events-none bg-gradient-to-br from-indigo-500/5 via-transparent to-indigo-400/10"></div>
<div class="flex-1 flex flex-col relative z-10">
<div class="flex items-start gap-3">
<div class="flex-1">
<a href="<%= blogPost.blogUrl %>" class="no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-md">
<h2 class="text-xl font-semibold tracking-tight text-gray-900 group-hover:text-indigo-600 transition-colors line-clamp-2">
<%= blogPost.title %>
</h2>
</a>
<p class="mt-3 text-sm leading-6 text-gray-600 line-clamp-4"><%= blogPost.description %></p>
</div>
</a>
</div>
<div class="-mt-5">
<div class="mt-4">
<%- include('./Tags', { blogPost: blogPost }) -%>
</div>
<div class="mt-5">
<div class="">
<a href="https://github.com/<%- blogPost.authorGitHubUsername -%>" target="_blank">
<div class="flex items-center gap-x-6">
<p class="text-sm font-medium leading-7 tracking-tight text-gray-600">
<span class="text-gray-500">By</span> @<%- blogPost.authorGitHubUsername -%> <span
class="text-gray-500"> on
<%- blogPost.formattedPostDate -%></span>
</p>
</div>
</div>
<div class="mt-6 flex items-center justify-between">
<a href="https://github.com/<%- blogPost.authorGitHubUsername -%>" target="_blank" class="flex items-center gap-3 group/author">
<img loading="lazy" src="https://avatars.githubusercontent.com/<%- blogPost.authorGitHubUsername -%>?s=64" alt="@<%- blogPost.authorGitHubUsername -%>" class="h-10 w-10 rounded-full ring-2 ring-white shadow-sm group-hover/author:ring-indigo-200 transition" width="40" height="40" decoding="async">
<div class="text-[11px] leading-4 text-gray-600">
<span class="text-gray-500">By</span> @<%- blogPost.authorGitHubUsername -%><br/>
<span class="text-gray-400"><%- blogPost.formattedPostDate -%></span>
</div>
</a>
<a href="<%= blogPost.blogUrl %>" class="shrink-0 text-sm font-medium text-indigo-600 hover:text-indigo-500 inline-flex items-center gap-1 group/cta">Read
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 transition-transform group-hover/cta:translate-x-0.5"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</a>
</div>
</li>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<% if(totalPages && totalPages > 1){ %>
<nav class="mt-16 flex items-center justify-between" aria-label="Pagination">
<div>
<% if(page > 1){ %>
<a href="<%- basePath %>?page=<%- page-1 %>&pageSize=<%- pageSize %>" class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-700 hover:bg-gray-50">Previous</a>
<% } else { %>
<span class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-200 bg-gray-50 text-gray-300 cursor-not-allowed">Previous</span>
<% } %>
</div>
<div class="hidden md:flex items-center gap-1">
<% const windowSize = 5; let start = Math.max(1, page - Math.floor(windowSize/2)); let end = Math.min(totalPages, start + windowSize -1); if(end-start+1 < windowSize){ start = Math.max(1, end-windowSize+1);} %>
<% if(start > 1){ %>
<a href="<%- basePath %>?page=1&pageSize=<%- pageSize %>" class="px-3 py-2 text-sm font-medium rounded-md border border-gray-200 bg-white hover:bg-gray-50">1</a>
<% if(start > 2){ %><span class="px-2 text-gray-400">…</span><% } %>
<% } %>
<% for(let p = start; p<=end; p++){ %>
<% if(p === page){ %>
<span class="px-3 py-2 text-sm font-semibold rounded-md bg-indigo-600 text-white border border-indigo-600"><%- p %></span>
<% } else { %>
<a href="<%- basePath %>?page=<%- p %>&pageSize=<%- pageSize %>" class="px-3 py-2 text-sm font-medium rounded-md border border-gray-200 bg-white hover:bg-gray-50"><%- p %></a>
<% } %>
<% } %>
<% if(end < totalPages){ %>
<% if(end < totalPages-1){ %><span class="px-2 text-gray-400">…</span><% } %>
<a href="<%- basePath %>?page=<%- totalPages %>&pageSize=<%- pageSize %>" class="px-3 py-2 text-sm font-medium rounded-md border border-gray-200 bg-white hover:bg-gray-50"><%- totalPages %></a>
<% } %>
</div>
<div class="flex items-center gap-2">
<% if(page < totalPages){ %>
<a href="<%- basePath %>?page=<%- page+1 %>&pageSize=<%- pageSize %>" class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-700 hover:bg-gray-50">Next</a>
<% } else { %>
<span class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-200 bg-gray-50 text-gray-300 cursor-not-allowed">Next</span>
<% } %>
</div>
</nav>
<p class="mt-4 text-center text-xs text-gray-500">Showing <%- ((page-1)*pageSize)+1 %> - <%- Math.min(page*pageSize, totalPosts) %> of <%- totalPosts %> posts</p>
<% } %>

View File

@@ -1,22 +1,12 @@
<% if(blogPost.tags.length> 0){ %>
<div class="flex mt-10">
<div class="space-x-1">
<!-- Loop over blogPost.tags and show them here-->
<% for(var i=0; i<blogPost.tags.length; i++) {%>
<a href="/blog/tag/<%- blogPost.tags[i].replaceAll(' ','-').toLowerCase() -%>">
<div
class="relative inline-flex items-center rounded-full border border-gray-300 px-3 py-0.5 text-sm">
<div class="absolute flex flex-shrink-0 items-center justify-center">
<div class="h-1.5 w-1.5 rounded-full bg-indigo-500" aria-hidden="true">
</div>
</div>
<div class="ml-3.5 font-medium text-gray-900">
<%- blogPost.tags[i] -%>
</div>
</div>
</a>
<% } %>
</div>
<div class="flex flex-wrap gap-2">
<% for(var i=0; i<blogPost.tags.length; i++) { %>
<a href="/blog/tag/<%- blogPost.tags[i].replaceAll(' ','-').toLowerCase() -%>" class="group/tag">
<span class="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700 transition">
<span class="h-1.5 w-1.5 rounded-full bg-indigo-500 group-hover/tag:bg-indigo-600"></span>
<%- blogPost.tags[i] -%>
</span>
</a>
<% } %>
</div>
<% } %>
<% } %>

View File

@@ -9,7 +9,7 @@
</title>
<meta name="description" content="<%= blogPost.description %>">
<%- include('../head-basic') -%>
<link rel="canonical" href="https://oneuptime.com/blog/post/<%= blogPost.fileName %>" />
<link rel="canonical" href="https://oneuptime.com/blog/post/<%= blogPost.fileName %>/view" />
<meta property="og:site_name" content="OneUptime | One Complete Observability platform.">
<meta property="og:type" content="article">
<meta property="og:title" content="<%= blogPost.title %>">
@@ -63,56 +63,131 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<style>
/* Custom styles for code blocks in blog posts */
.blog-body pre {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.5 !important;
font-weight: 400 !important;
}
.blog-body pre code {
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
font-weight: inherit !important;
}
/* Ensure highlight.js doesn't override our font settings */
.blog-body .hljs {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.5 !important;
font-weight: 400 !important;
}
</style>
</head>
<body>
<%- include('../nav') -%>
<div class="relative isolate overflow-hidden bg-white">
<div class="py-24 sm:py-32">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<%- include('./Partials/BlogTitleAndDescription', { title: blogPost.title , smallTitle: "", description:
blogPost.description }) -%>
<div class="blog-body">
<%- blogPost.htmlBody -%>
</div>
<div class="relative isolate overflow-hidden bg-gradient-to-b from-white via-white to-indigo-50/40">
<!-- Hero / Title Section -->
<div class="pt-20 pb-12 sm:pt-28 sm:pb-20">
<div class="mx-auto max-w-4xl px-6 lg:px-8 text-center">
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight text-gray-900 leading-tight">
<%= blogPost.title %>
</h1>
<p class="mt-6 text-xl text-gray-600 leading-relaxed max-w-3xl mx-auto">
<%= blogPost.description %>
</p>
<div class="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-gray-500">
<% if(blogPost.author){ %>
<a href="<%- blogPost.author.githubUrl -%>" target="_blank" class="flex items-center space-x-2 group">
<!-- Added explicit width & height to stabilize layout (CLS) -->
<img class="h-8 w-8 rounded-full ring-2 ring-indigo-200 group-hover:ring-indigo-400 transition" src="<%- blogPost.author.profileImageUrl -%>" alt="<%- blogPost.author.name -%>" width="32" height="32" decoding="async">
<span class="font-medium text-gray-700 group-hover:text-gray-900 transition">@<%- blogPost.author.username -%></span>
</a>
<span class="hidden sm:inline select-none">•</span>
<span><%- blogPost.formattedPostDate -%></span>
<span class="hidden sm:inline select-none">•</span>
<% } %>
<span id="reading-time" class="inline-flex items-center gap-1">
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="sr-only">Reading time</span>
</span>
</div>
<div class="mt-8">
<%- include('./Partials/Tags', { blogPost: blogPost }) -%>
<div class="bg-white my-10 mt-20">
<div class="">
<ul role="list" class="">
<li>
<a href="<%- blogPost.author.githubUrl -%>" target="_blank">
<div class="flex items-center gap-x-6">
<img class="h-12 w-12 rounded-full"
src="<%- blogPost.author.profileImageUrl -%>" alt="">
<div class="-ml-4">
<h3
class="text-base font-medium leading-7 tracking-tight text-gray-600">
<span class="text-gray-500">By</span> <%-
blogPost.author.name -%> <span class="text-gray-500"> on
<%- blogPost.formattedPostDate -%></span>
</h3>
<p class="text-sm font-medium leading-6 text-indigo-500 -mt-1">
@<%- blogPost.author.username -%></p>
</div>
</div>
</a>
</li>
</ul>
</div>
</div>
<%- include('./Partials/OpenSourceCommitment', { blogPost: blogPost }) -%>
</div>
</div>
</div>
<!-- Content + Sidebar -->
<div class="pb-24">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<!-- Main content -->
<article class="lg:col-span-8 xl:col-span-9">
<div class="blog-body prose prose-slate max-w-none prose-headings:scroll-mt-28 prose-headings:font-display prose-headings:font-semibold lg:prose-headings:scroll-mt-[8.5rem] prose-a:font-semibold prose-a:text-indigo-600 hover:prose-a:text-indigo-500 prose-img:rounded-xl prose-pre:rounded-xl prose-code:text-indigo-600">
<%- blogPost.htmlBody -%>
</div>
<!-- Share -->
<div class="mt-14 border-t border-gray-200/70 pt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
<div class="text-sm font-medium text-gray-600">Share this article</div>
<div class="flex gap-3">
<a title="Share on X" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-indigo-400 hover:bg-indigo-50 transition" href="https://twitter.com/intent/tweet?text=<%- encodeURIComponent(blogPost.title) -%>&url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-indigo-600" viewBox="0 0 24 24" fill="currentColor"><path d="M17.53 3h3.77l-8.26 9.45L23 21h-6.17l-4.8-6.01L6.4 21H2.62l8.63-9.87L1 3h6.32l4.33 5.41L17.53 3Zm-1.33 15.62h2.09L7.94 4.29H5.71l10.49 14.33Z"/></svg>
</a>
<a title="Share on LinkedIn" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-indigo-400 hover:bg-indigo-50 transition" href="https://www.linkedin.com/sharing/share-offsite/?url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-indigo-600" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.049c.476-.9 1.637-1.85 3.37-1.85 3.601 0 4.266 2.37 4.266 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124ZM7.119 20.452H3.553V9h3.566v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>
</a>
<a title="Discuss on Hacker News" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-indigo-400 hover:bg-indigo-50 transition" href="https://news.ycombinator.com/submitlink?u=<%- encodeURIComponent(blogPost.blogUrl) -%>&t=<%- encodeURIComponent(blogPost.title) -%>">
<svg class="w-4 h-4 text-gray-500 group-hover:text-indigo-600" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3v18h18V3H3Zm9.774 10.876L17 6h-2.117l-2.742 5.274L9.4 6H7l4.226 7.876V18h2.548v-4.124Z"/></svg>
</a>
</div>
</div>
<!-- Author -->
<% if(blogPost.author){ %>
<div class="mt-16 p-6 rounded-2xl bg-white/60 backdrop-blur border border-gray-200 shadow-sm flex gap-6 items-start">
<!-- Added explicit width/height + lazy loading for below-the-fold author bio image -->
<img class="h-16 w-16 rounded-full ring-2 ring-indigo-200" src="<%- blogPost.author.profileImageUrl -%>" alt="<%- blogPost.author.name -%>" width="64" height="64" loading="lazy" decoding="async">
<div>
<h3 class="text-lg font-semibold text-gray-900"><%- blogPost.author.name -%></h3>
<p class="text-sm text-gray-600 mb-2">@<%- blogPost.author.username -%> • <%- blogPost.formattedPostDate -%> • <span id="reading-time-inline"></span></p>
<div class="text-sm text-gray-700"><%- blogPost.author.bio || 'Building reliable software at OneUptime. Follow along for more on observability & reliability.' -%></div>
<div class="mt-3 flex gap-3">
<a href="<%- blogPost.author.githubUrl -%>" target="_blank" class="text-xs font-medium text-indigo-600 hover:text-indigo-500 inline-flex items-center gap-1">View GitHub
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 5h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
</div>
<% } %>
<div class="mt-20">
<%- include('./Partials/OpenSourceCommitment', { blogPost: blogPost }) -%>
</div>
</article>
<!-- Sidebar -->
<aside class="hidden lg:block lg:col-span-4 xl:col-span-3">
<div class="sticky top-28 space-y-8">
<div class="rounded-2xl border border-gray-200 bg-white/70 backdrop-blur p-6 shadow-sm">
<h2 class="text-sm font-semibold tracking-wide text-gray-900 uppercase mb-4">On this page</h2>
<!-- Reserve initial space for TOC to reduce layout shift once populated -->
<nav id="toc" aria-label="Table of contents" class="text-sm leading-6 space-y-2 text-gray-700 min-h-[3rem]"></nav>
</div>
</div>
</aside>
</div>
</div>
</div>
</div>
@@ -120,6 +195,76 @@
<%- include('./Partials/BlogCta') -%>
<%- include('../footer') -%>
<script>
(function(){
// Reading time
try {
const container = document.querySelector('.blog-body');
if(container){
const text = container.textContent || '';
const words = text.trim().split(/\s+/).filter(Boolean).length;
const minutes = Math.max(1, Math.round(words / 200));
const rt = minutes + ' min read';
const el = document.getElementById('reading-time');
if(el){ el.insertAdjacentText('beforeend', rt); }
const el2 = document.getElementById('reading-time-inline');
if(el2){ el2.textContent = rt; }
}
} catch (e) {}
// TOC
try {
const container = document.querySelector('.blog-body');
if(!container){ return; }
const headings = container.querySelectorAll('h2, h3');
if(!headings.length){ return; }
const toc = document.getElementById('toc');
if(!toc){ return; }
headings.forEach(h => {
if(!h.id){
h.id = h.textContent.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
}
const a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent;
a.className = 'block hover:text-indigo-600 transition ' + (h.tagName === 'H3' ? 'pl-4 text-gray-500' : 'font-medium');
toc.appendChild(a);
});
} catch (e) {}
// Stabilize images inside blog body (add width/height if missing & lazy-load non-hero images)
try {
const imgs = document.querySelectorAll('.blog-body img');
imgs.forEach((img, index) => {
// If width/height already specified, skip dimension inference
if(!img.hasAttribute('width') || !img.hasAttribute('height')){
if(img.naturalWidth && img.naturalHeight){
img.setAttribute('width', img.naturalWidth);
img.setAttribute('height', img.naturalHeight);
} else {
// If not yet loaded, attach a one-time listener
img.addEventListener('load', function handler(){
if(!img.hasAttribute('width') && img.naturalWidth){ img.setAttribute('width', img.naturalWidth); }
if(!img.hasAttribute('height') && img.naturalHeight){ img.setAttribute('height', img.naturalHeight); }
img.removeEventListener('load', handler);
});
}
}
// Lazy-load images after the first one (often top/hero) for better LCP while reducing CLS
if(index > 0 && !img.hasAttribute('loading')){
img.setAttribute('loading', 'lazy');
}
if(!img.hasAttribute('decoding')){
img.setAttribute('decoding', 'async');
}
// Ensure display block for centered images without causing late shifts
if(!img.className.includes('inline') && !img.className.includes('block')){
img.classList.add('block');
}
});
} catch(e) {}
})();
</script>
</body>
</html>

View File

@@ -22,10 +22,10 @@
<div class="hidden sm:block">
<div class="border-b border-gray-200">
<nav class="-mb-px flex justify-center space-x-8" aria-label="Tabs">
<nav class="-mb-px flex justify-center space-x-8" aria-label="Product feature tabs" role="tablist">
<!-- Current: "border-indigo-500 text-indigo-600", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" -->
<a onclick="showTab(1)"
class="tab-1-button cursor-pointer border-indigo-500 text-indigo-600 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
<button type="button" onclick="showTab('status-pages')" id="tab-status-pages" role="tab" aria-selected="true" aria-controls="panel-status-pages"
class="tab-status-pages-button tab-1-button cursor-pointer border-indigo-500 text-indigo-600 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="0" aria-label="Status Pages - communicates service availability to users">
<!-- Current: "text-indigo-500", Default: "text-gray-400 group-hover:text-gray-500" -->
<svg class="icon-tab-1 text-gray-400 text-indigo-500 -ml-0.5 mr-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@@ -33,67 +33,60 @@
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Status Pages</span>
</a>
<a onclick="showTab(2)"
class="tab-2-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
</button>
<button type="button" onclick="showTab('monitoring')" id="tab-monitoring" role="tab" aria-selected="false" aria-controls="panel-monitoring"
class="tab-monitoring-button tab-2-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Monitoring - uptime, performance and synthetic checks">
<svg class="icon-tab-2 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span>Monitoring</span>
</a>
<a onclick="showTab(3)"
class="tab-3-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium"
aria-current="page">
</button>
<button type="button" onclick="showTab('incidents')" id="tab-incidents" role="tab" aria-selected="false" aria-controls="panel-incidents"
class="tab-incidents-button tab-3-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Incidents - detect, respond and learn from outages">
<svg xmlns="http://www.w3.org/2000/svg"
class="icon-tab-3 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<span>Incidents</span>
</a>
<a onclick="showTab(4)"
class="tab-4-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
</button>
<button type="button" onclick="showTab('on-call')" id="tab-on-call" role="tab" aria-selected="false" aria-controls="panel-on-call"
class="tab-on-call-button tab-4-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="On Call Alerts - intelligent alert routing and scheduling">
<svg class="icon-tab-4 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
</svg>
<span>On Call Alerts</span>
</a>
</button>
<a onclick="showTab(5)"
class="tab-5-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
<button type="button" onclick="showTab('logs')" id="tab-logs" role="tab" aria-selected="false" aria-controls="panel-logs"
class="tab-logs-button tab-5-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Logs - centralized log management and analytics">
<svg class="icon-tab-5 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" >
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
</svg>
<span>Logs</span>
</a>
</button>
<a onclick="showTab(6)"
class="tab-6-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
<button type="button" onclick="showTab('apm')" id="tab-apm" role="tab" aria-selected="false" aria-controls="panel-apm"
class="tab-apm-button tab-6-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="APM - metrics, traces and error tracking for applications">
<svg class="icon-tab-6 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
<span>APM</span>
</a>
</button>
<a onclick="showTab(7)"
class="tab-7-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
<button type="button" onclick="showTab('workflows')" id="tab-workflows" role="tab" aria-selected="false" aria-controls="panel-workflows"
class="tab-workflows-button tab-7-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Workflows - automate reliability operations and responses">
<svg class="icon-tab-7 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@@ -101,9 +94,8 @@
<path strokeLinecap="round" strokeLinejoin="round"
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />
</svg>
<span>Workflows</span>
</a>
</button>
</nav>
</div>
</div>
@@ -588,36 +580,97 @@
<script>
let currentActiveTab = 1;
// Semantic tab slugs in desired order
const TAB_ORDER = ['status-pages','monitoring','incidents','on-call','logs','apm','workflows'];
let currentActiveTab = 'status-pages';
function normalize(tab){
// allow old numeric references for backward compatibility
if(typeof tab === 'number' || /^\d+$/.test(tab)){ return TAB_ORDER[Number(tab)-1] || 'status-pages'; }
return tab;
}
function hideTab(tab) {
tab = normalize(tab);
const panel = document.querySelector(`.tab-${TAB_ORDER.indexOf(tab)+1}`) || document.querySelector(`.tab-${tab}`);
if (panel) { panel.style.display = 'none'; panel.setAttribute('aria-hidden','true'); }
document.querySelector(`.tab-${tab}`).style.display = 'none';
document.querySelector(`.tab-${tab}-button`).classList.remove('border-indigo-500', 'text-indigo-600');
document.querySelector(`.tab-${tab}-button`).classList.add('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
document.querySelector(`.icon-tab-${tab}`).classList.remove('text-indigo-500');
document.querySelector(`.icon-tab-${tab}`).classList.add('text-gray-400');
const button = document.querySelector(`.tab-${tab}-button`) || document.querySelector(`.tab-${TAB_ORDER.indexOf(tab)+1}-button`);
if (button) {
button.classList.remove('border-indigo-500', 'text-indigo-600');
button.classList.add('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
button.setAttribute('aria-selected','false');
button.setAttribute('tabindex','-1');
}
const icon = document.querySelector(`.icon-tab-${TAB_ORDER.indexOf(tab)+1}`) || document.querySelector(`.icon-tab-${tab}`);
if (icon) { icon.classList.remove('text-indigo-500'); icon.classList.add('text-gray-400'); }
}
function showTab(tab) {
tab = normalize(tab);
if (tab === currentActiveTab) { return; }
hideTab(currentActiveTab);
document.querySelector(`.tab-${tab}`).style.display = 'block';
const numericIndex = TAB_ORDER.indexOf(tab)+1;
const panel = document.querySelector(`.tab-${numericIndex}`) || document.querySelector(`.tab-${tab}`);
if (panel) { panel.style.display = 'block'; panel.setAttribute('aria-hidden','false'); }
document.querySelector(`.tab-${tab}-button`).classList.remove('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
document.querySelector(`.tab-${tab}-button`).classList.add('border-indigo-500', 'text-indigo-600');
const button = document.querySelector(`.tab-${tab}-button`) || document.querySelector(`.tab-${numericIndex}-button`);
if (button) {
button.classList.remove('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
button.classList.add('border-indigo-500', 'text-indigo-600');
button.setAttribute('aria-selected','true');
button.setAttribute('tabindex','0');
button.focus();
}
document.querySelector(`.icon-tab-${tab}`).classList.remove('text-gray-400');
document.querySelector(`.icon-tab-${tab}`).classList.add('text-indigo-500');
const icon = document.querySelector(`.icon-tab-${numericIndex}`) || document.querySelector(`.icon-tab-${tab}`);
if (icon) { icon.classList.remove('text-gray-400'); icon.classList.add('text-indigo-500'); }
currentActiveTab = tab;
if (history.replaceState) { history.replaceState(null, '', `#tab-${tab}`); }
}
// Initialize tabs accessibility + allow keyboard navigation (arrow keys)
document.addEventListener('DOMContentLoaded', () => {
// Mark all panels with role="tabpanel" and associate with buttons
TAB_ORDER.forEach((slug, idx) => {
const panel = document.querySelector(`.tab-${idx+1}`) || document.querySelector(`.tab-${slug}`);
if (panel) {
panel.setAttribute('role','tabpanel');
panel.setAttribute('id',`panel-${slug}`);
panel.setAttribute('aria-labelledby',`tab-${slug}`);
if (slug !== currentActiveTab) { panel.style.display='none'; panel.setAttribute('aria-hidden','true'); }
else { panel.setAttribute('aria-hidden','false'); }
}
});
// Support deep linking via hash like #tab-apm or legacy #tab-6
const hash = window.location.hash;
if (hash && /^#tab-/.test(hash)) {
const raw = hash.replace('#tab-','');
showTab(raw);
}
// Keyboard navigation
const tabButtons = Array.from(document.querySelectorAll('[role="tab"]'));
tabButtons.forEach(btn => {
btn.addEventListener('keydown', e => {
const idx = tabButtons.indexOf(e.currentTarget);
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = tabButtons[(idx+1)%tabButtons.length];
const ident = next.id.replace('tab-',''); showTab(ident);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = tabButtons[(idx-1+tabButtons.length)%tabButtons.length];
const ident = prev.id.replace('tab-',''); showTab(ident);
}
});
});
});
</script>

View File

@@ -1,5 +1,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Preconnect to external image/avatar domains to reduce layout shift & latency -->
<link rel="preconnect" href="https://avatars.githubusercontent.com" crossorigin>
<link rel="dns-prefetch" href="//avatars.githubusercontent.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet">
<style>
@@ -19,6 +22,8 @@
right: 0;
top: 0;
width: auto;
appearance: none; /* standard */
-webkit-appearance: none; /* vendor */
}
input[type="date"]::-webkit-calendar-picker-indicator {
@@ -32,6 +37,8 @@
right: 0;
top: 0;
width: auto;
appearance: none;
-webkit-appearance: none;
}
/*Chrome*/
@@ -161,5 +168,5 @@
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
<!-- Canonical and Manifest -->
<link rel="canonical" href="/">
<% /* Expect a homeUrl variable like https://oneuptime.com passed from server; fallback to production domain */ %>
<link rel="manifest" href="/manifest.json">

View File

@@ -41,7 +41,7 @@
</div>
</a>
</div>
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">Build reliable software.</h1>
<h1 class="text-6xl font-bold tracking-tight text-gray-900 sm:text-6xl mt-8 mb-8">Complete Monitoring <br/> &amp; Observability Platform</h1>
<p class="mt-6 text-xl sm:text-2xl leading-8 text-gray-600">Monitor, Observe, Debug, Resolve. Everything you
need to build reliable software in one open source platform.</p>
<div class="mt-10 flex items-center justify-center gap-x-6">
@@ -117,15 +117,6 @@
});
}
// Mobile redirect helper - if JS is available and we detect mobile, redirect to dashboard
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone|Opera Mini|IEMobile|Mobile Safari|Mobile/i.test(navigator.userAgent)) {
// Small delay to avoid redirect loops and let server handle first
setTimeout(() => {
if (window.location.pathname === '/' && !window.location.search.includes('no-mobile-redirect')) {
window.location.href = '/dashboard';
}
}, 100);
}
</script>
</body>

View File

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

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