mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
147 Commits
otel-queue
...
sp-scim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f517d8dcc | ||
|
|
cb5c4dce45 | ||
|
|
d9abeda60d | ||
|
|
15c4c89310 | ||
|
|
8c1d5652f4 | ||
|
|
fbf87cf8d4 | ||
|
|
1c12ad94dd | ||
|
|
aa09bab7c9 | ||
|
|
f7d1975ab0 | ||
|
|
99c9a591cb | ||
|
|
c956d01789 | ||
|
|
17c829869b | ||
|
|
d65e91a912 | ||
|
|
39710ba9b0 | ||
|
|
8c70a4dfae | ||
|
|
ff99055594 | ||
|
|
f01cc2fd71 | ||
|
|
49b43593b1 | ||
|
|
e293ffd0eb | ||
|
|
b62a5e7722 | ||
|
|
8f8ba0abb8 | ||
|
|
5525556b54 | ||
|
|
669066b70a | ||
|
|
76d2abed08 | ||
|
|
a6c18b3f21 | ||
|
|
955ea7bc31 | ||
|
|
45719d4656 | ||
|
|
796c94a261 | ||
|
|
d2fe822cb7 | ||
|
|
289a369eab | ||
|
|
6f07e3e119 | ||
|
|
8cdc1e9faf | ||
|
|
d4609a84ef | ||
|
|
eb4a91a598 | ||
|
|
5bea404d6c | ||
|
|
df3f8b6a74 | ||
|
|
0c9d2c821a | ||
|
|
ba49aaf0c3 | ||
|
|
6ea5ad7fe8 | ||
|
|
962866d109 | ||
|
|
115216561c | ||
|
|
f709c90cc4 | ||
|
|
d7f01b0189 | ||
|
|
c3eaa8995c | ||
|
|
53b482b9f3 | ||
|
|
d52670f39c | ||
|
|
fdc1332b9e | ||
|
|
a937416663 | ||
|
|
546d41da81 | ||
|
|
c4c6793b29 | ||
|
|
c894b112e6 | ||
|
|
304baf1bb4 | ||
|
|
9adea6b1ba | ||
|
|
5498521e02 | ||
|
|
9e97c6ddbc | ||
|
|
63272e09f8 | ||
|
|
327c28afdc | ||
|
|
896020b93b | ||
|
|
15a68472b0 | ||
|
|
0210480d97 | ||
|
|
72fdc06687 | ||
|
|
3710b81b9a | ||
|
|
9fcb3dc2e0 | ||
|
|
43e2ccf51a | ||
|
|
48c3d8603a | ||
|
|
9cfc912161 | ||
|
|
29e3ee57ab | ||
|
|
be7e849822 | ||
|
|
59d76b601a | ||
|
|
b77ef336b8 | ||
|
|
7df21fe8e5 | ||
|
|
f39e1943c7 | ||
|
|
966a903646 | ||
|
|
1d9d37c6d1 | ||
|
|
7edcc4dbce | ||
|
|
0939294d22 | ||
|
|
dbcbfe5f79 | ||
|
|
a638972817 | ||
|
|
37c6310465 | ||
|
|
a7d38389fd | ||
|
|
2f55336db7 | ||
|
|
f99a15b95b | ||
|
|
de5bff2ffe | ||
|
|
cef2764499 | ||
|
|
a7014ac3ff | ||
|
|
fa31dc670c | ||
|
|
4c2a12cf31 | ||
|
|
b4115e1529 | ||
|
|
3883790c50 | ||
|
|
1702558d73 | ||
|
|
cacdbff50e | ||
|
|
0bc6b432a2 | ||
|
|
eaa09d4a13 | ||
|
|
08c85dd31c | ||
|
|
42e82b6fb7 | ||
|
|
463a20f342 | ||
|
|
1b8a7e3261 | ||
|
|
8b27dd1f26 | ||
|
|
17c72f65e3 | ||
|
|
5eee900fd3 | ||
|
|
0a6cdd11af | ||
|
|
8514b6b82e | ||
|
|
dfa8f6cd24 | ||
|
|
61614227e1 | ||
|
|
f3d20eb544 | ||
|
|
a11ff57fda | ||
|
|
deb635bc80 | ||
|
|
c707830811 | ||
|
|
24ada68d1e | ||
|
|
ca23234ba9 | ||
|
|
ea40a955e9 | ||
|
|
a46ee07d70 | ||
|
|
5c5bab408d | ||
|
|
540d632baf | ||
|
|
74718017ad | ||
|
|
d16897db1b | ||
|
|
be3fc6f077 | ||
|
|
b7b577517c | ||
|
|
ccf7a96e43 | ||
|
|
892f3c052a | ||
|
|
00833a06f4 | ||
|
|
472adf610a | ||
|
|
976c36de9a | ||
|
|
6026c9c9af | ||
|
|
791aa1421b | ||
|
|
79dbc94f82 | ||
|
|
ded41fc7ec | ||
|
|
581c374745 | ||
|
|
64c0c8b4cb | ||
|
|
7d2241ba98 | ||
|
|
30bada5b7a | ||
|
|
61bfb37747 | ||
|
|
4686aa941a | ||
|
|
3c065c76b0 | ||
|
|
5dccd03ed4 | ||
|
|
a395a95997 | ||
|
|
89082b1232 | ||
|
|
7cb33de450 | ||
|
|
353ac875fb | ||
|
|
d6560fdb32 | ||
|
|
5115e21a7a | ||
|
|
0e6119ddce | ||
|
|
b842a49cfb | ||
|
|
9737e50467 | ||
|
|
91beb6091d | ||
|
|
68e610aa9f | ||
|
|
d673ef3a01 |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -209,22 +209,6 @@ jobs:
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Dashboard/Dockerfile .
|
||||
|
||||
docker-build-haraka:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
run: npm run prerun
|
||||
|
||||
# build images
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Haraka/Dockerfile .
|
||||
|
||||
|
||||
docker-build-probe:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
68
.github/workflows/release.yml
vendored
68
.github/workflows/release.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
|
||||
publish-mcp-server:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-build-number]
|
||||
needs: [generate-build-number, publish-npm-packages]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{ github.run_number }}
|
||||
NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
@@ -138,6 +138,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd MCP
|
||||
npm update @oneuptime/common
|
||||
npm install
|
||||
|
||||
- name: Build MCP server
|
||||
@@ -1052,67 +1053,6 @@ jobs:
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
|
||||
haraka-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/haraka
|
||||
ghcr.io/oneuptime/haraka
|
||||
tags: |
|
||||
type=raw,value=release,enable=true
|
||||
type=semver,value=7.0.${{needs.generate-build-number.outputs.build_number}},pattern={{version}},enable=true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy haraka.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: ./Haraka/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}}
|
||||
|
||||
admin-dashboard-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1838,7 +1778,7 @@ jobs:
|
||||
|
||||
test-e2e-release-saas:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, haraka-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
@@ -1891,7 +1831,7 @@ jobs:
|
||||
test-e2e-release-self-hosted:
|
||||
runs-on: ubuntu-latest
|
||||
# After all the jobs runs
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, publish-mcp-server, copilot-docker-image-deploy, incoming-request-ingest-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, haraka-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy]
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, publish-mcp-server, copilot-docker-image-deploy, incoming-request-ingest-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
||||
64
.github/workflows/test-release.yaml
vendored
64
.github/workflows/test-release.yaml
vendored
@@ -144,6 +144,7 @@ jobs:
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
cd MCP
|
||||
npm update @oneuptime/common
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
@@ -1146,67 +1147,6 @@ jobs:
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
haraka-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/haraka
|
||||
ghcr.io/oneuptime/haraka
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=semver,value=7.0.${{needs.generate-build-number.outputs.build_number}}-test,pattern={{version}},enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy haraka.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: ./Haraka/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}}
|
||||
|
||||
dashboard-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1768,7 +1708,7 @@ jobs:
|
||||
|
||||
test-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [infrastructure-agent-deploy, publish-mcp-server, llm-docker-image-deploy, publish-terraform-provider, open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, api-reference-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, probe-docker-image-deploy, haraka-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy, fluent-ingest-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
needs: [infrastructure-agent-deploy, publish-mcp-server, llm-docker-image-deploy, publish-terraform-provider, open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, api-reference-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, probe-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy, fluent-ingest-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -86,9 +86,6 @@ Backups/*.tar
|
||||
|
||||
.env
|
||||
|
||||
Haraka/dkim/keys/private_base64.txt
|
||||
Haraka/dkim/keys/public_base64.txt
|
||||
|
||||
.eslintcache
|
||||
|
||||
HelmChart/Values/*.values.yaml
|
||||
@@ -129,3 +126,4 @@ terraform-provider-example/**
|
||||
MCP/build/
|
||||
MCP/.env
|
||||
MCP/node_modules
|
||||
Dashboard/public/sw.js
|
||||
|
||||
4
APIReference/package-lock.json
generated
4
APIReference/package-lock.json
generated
@@ -55,6 +55,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -74,7 +75,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -118,6 +118,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -132,7 +133,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
4
Accounts/package-lock.json
generated
4
Accounts/package-lock.json
generated
@@ -59,6 +59,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -78,7 +79,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -122,6 +122,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -136,7 +137,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
4
AdminDashboard/package-lock.json
generated
4
AdminDashboard/package-lock.json
generated
@@ -58,6 +58,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -77,7 +78,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -121,6 +121,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -135,7 +136,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
@@ -21,7 +21,7 @@ import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
|
||||
const Settings: FunctionComponent = (): ReactElement => {
|
||||
const [emailServerType, setemailServerType] = React.useState<EmailServerType>(
|
||||
EmailServerType.Internal,
|
||||
EmailServerType.CustomSMTP,
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
@@ -43,7 +43,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (globalConfig) {
|
||||
setemailServerType(
|
||||
globalConfig.emailServerType || EmailServerType.Internal,
|
||||
globalConfig.emailServerType || EmailServerType.CustomSMTP,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
/>
|
||||
|
||||
<CardModelDetail
|
||||
name="Internal SMTP Settings"
|
||||
name="Email Server Settings"
|
||||
cardProps={{
|
||||
title: "Email Server Settings",
|
||||
description:
|
||||
@@ -172,7 +172,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
cardProps={{
|
||||
title: "Custom Email and SMTP Settings",
|
||||
description:
|
||||
"If you have not enabled Internal SMTP server to send emails. Please configure your SMTP server here.",
|
||||
"Please configure your SMTP server here to send emails.",
|
||||
}}
|
||||
isEditable={true}
|
||||
editButtonText="Edit SMTP Config"
|
||||
|
||||
@@ -583,6 +583,12 @@ import StatusPageAnnouncementTemplateService, {
|
||||
Service as StatusPageAnnouncementTemplateServiceType,
|
||||
} from "Common/Server/Services/StatusPageAnnouncementTemplateService";
|
||||
|
||||
// ProjectSCIM
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import ProjectSCIMService, {
|
||||
Service as ProjectSCIMServiceType,
|
||||
} from "Common/Server/Services/ProjectSCIMService";
|
||||
|
||||
// Open API Spec
|
||||
import OpenAPI from "Common/Server/API/OpenAPI";
|
||||
|
||||
@@ -618,6 +624,15 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
// Project SCIM
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<ProjectSCIM, ProjectSCIMServiceType>(
|
||||
ProjectSCIM,
|
||||
ProjectSCIMService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
// status page announcement templates
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
|
||||
663
App/FeatureSet/Identity/API/SCIM.ts
Normal file
663
App/FeatureSet/Identity/API/SCIM.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
|
||||
import UserService from "Common/Server/Services/UserService";
|
||||
import TeamMemberService from "Common/Server/Services/TeamMemberService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
OneUptimeRequest,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Email from "Common/Types/Email";
|
||||
import Name from "Common/Types/Name";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
|
||||
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import {
|
||||
parseNameFromSCIM,
|
||||
formatUserForSCIM,
|
||||
generateServiceProviderConfig,
|
||||
generateUsersListResponse,
|
||||
parseSCIMQueryParams,
|
||||
logSCIMOperation,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
const handleUserTeamOperations: (
|
||||
operation: "add" | "remove",
|
||||
projectId: ObjectID,
|
||||
userId: ObjectID,
|
||||
scimConfig: ProjectSCIM,
|
||||
) => Promise<void> = async (
|
||||
operation: "add" | "remove",
|
||||
projectId: ObjectID,
|
||||
userId: ObjectID,
|
||||
scimConfig: ProjectSCIM,
|
||||
): Promise<void> => {
|
||||
const teamsIds: Array<ObjectID> =
|
||||
scimConfig.teams?.map((team: any) => {
|
||||
return team.id;
|
||||
}) || [];
|
||||
|
||||
if (teamsIds.length === 0) {
|
||||
logger.debug(`SCIM Team operations - no teams configured for SCIM`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === "add") {
|
||||
logger.debug(
|
||||
`SCIM Team operations - adding user to ${teamsIds.length} configured teams`,
|
||||
);
|
||||
|
||||
for (const team of scimConfig.teams || []) {
|
||||
const existingMember: TeamMember | null =
|
||||
await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: userId,
|
||||
teamId: team.id!,
|
||||
},
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!existingMember) {
|
||||
logger.debug(`SCIM Team operations - adding user to team: ${team.id}`);
|
||||
const teamMember: TeamMember = new TeamMember();
|
||||
teamMember.projectId = projectId;
|
||||
teamMember.userId = userId;
|
||||
teamMember.teamId = team.id!;
|
||||
teamMember.hasAcceptedInvitation = true;
|
||||
teamMember.invitationAcceptedAt = OneUptimeDate.getCurrentDate();
|
||||
|
||||
await TeamMemberService.create({
|
||||
data: teamMember,
|
||||
props: {
|
||||
isRoot: true,
|
||||
ignoreHooks: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`SCIM Team operations - user already member of team: ${team.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (operation === "remove") {
|
||||
logger.debug(
|
||||
`SCIM Team operations - removing user from ${teamsIds.length} configured teams`,
|
||||
);
|
||||
|
||||
await TeamMemberService.deleteBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: userId,
|
||||
teamId: QueryHelper.any(teamsIds),
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// SCIM Service Provider Configuration - GET /scim/v2/ServiceProviderConfig
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/ServiceProviderConfig",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation("ServiceProviderConfig", "project", req.params["projectScimId"]!);
|
||||
|
||||
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
DocsClientUrl.toString()+"/identity/scim"
|
||||
);
|
||||
|
||||
logger.debug("Project SCIM ServiceProviderConfig response prepared successfully");
|
||||
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Basic Users endpoint - GET /scim/v2/Users
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
|
||||
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
|
||||
// Parse query parameters
|
||||
const { startIndex, count } = parseSCIMQueryParams(req);
|
||||
const filter: string = req.query["filter"] as string;
|
||||
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`
|
||||
);
|
||||
|
||||
// Build query for team members in this project
|
||||
const query: Query<ProjectUser> = {
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
// Handle SCIM filter for userName
|
||||
if (filter) {
|
||||
const emailMatch: RegExpMatchArray | null = filter.match(
|
||||
/userName eq "([^"]+)"/i,
|
||||
);
|
||||
if (emailMatch) {
|
||||
const email: string = emailMatch[1]!;
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!, `filter by email: ${email}`);
|
||||
|
||||
if (email) {
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
if (user && user.id) {
|
||||
query.userId = user.id;
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!, `found user with id: ${user.id}`);
|
||||
} else {
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!, `user not found for email: ${email}`);
|
||||
return Response.sendJsonObjectResponse(req, res, generateUsersListResponse([], startIndex, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!, `query built for projectId: ${projectId}`);
|
||||
|
||||
// Get team members
|
||||
const teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
|
||||
query: query,
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: { isRoot: true },
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// now get unique users.
|
||||
const usersInProjects: Array<JSONObject> = teamMembers
|
||||
.filter((tm: TeamMember) => {
|
||||
return tm.user && tm.user.id;
|
||||
})
|
||||
.map((tm: TeamMember) => {
|
||||
return formatUserForSCIM(tm.user!, req, req.params["projectScimId"]!, "project");
|
||||
});
|
||||
|
||||
// remove duplicates
|
||||
const uniqueUserIds: Set<string> = new Set<string>();
|
||||
const users: Array<JSONObject> = usersInProjects.filter(
|
||||
(user: JSONObject) => {
|
||||
if (uniqueUserIds.has(user["id"]?.toString() || "")) {
|
||||
return false;
|
||||
}
|
||||
uniqueUserIds.add(user["id"]?.toString() || "");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// now paginate the results
|
||||
const paginatedUsers: Array<JSONObject> = users.slice(
|
||||
(startIndex - 1) * count,
|
||||
startIndex * count,
|
||||
);
|
||||
|
||||
logger.debug(`SCIM Users response prepared with ${users.length} users`);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, generateUsersListResponse(paginatedUsers, startIndex, users.length));
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get Individual User - GET /scim/v2/Users/{id}
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Get individual user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Get user - projectId: ${projectId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and is part of the project
|
||||
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!projectUser || !projectUser.user) {
|
||||
logger.debug(
|
||||
`SCIM Get user - user not found or not part of project for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`SCIM Get user - found user: ${projectUser.user.id}`);
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(projectUser.user, req, req.params["projectScimId"]!, "project");
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update User - PUT /scim/v2/Users/{id}
|
||||
router.put(
|
||||
"/scim/v2/:projectScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Update user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - projectId: ${projectId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and is part of the project
|
||||
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!projectUser || !projectUser.user) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user not found or not part of project for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
// Update user information
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
const active: boolean = scimUser["active"] as boolean;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - email: ${email}, name: ${name}, active: ${active}`,
|
||||
);
|
||||
|
||||
// Handle user deactivation by removing from teams
|
||||
if (active === false) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user marked as inactive, removing from teams`,
|
||||
);
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
await handleUserTeamOperations(
|
||||
"remove",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
scimConfig,
|
||||
);
|
||||
logger.debug(
|
||||
`SCIM Update user - user successfully removed from teams due to deactivation`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle user activation by adding to teams
|
||||
if (active === true) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user marked as active, adding to teams`,
|
||||
);
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
await handleUserTeamOperations(
|
||||
"add",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
scimConfig,
|
||||
);
|
||||
logger.debug(
|
||||
`SCIM Update user - user successfully added to teams due to activation`,
|
||||
);
|
||||
}
|
||||
|
||||
if (email || name) {
|
||||
const updateData: any = {};
|
||||
if (email) {
|
||||
updateData.email = new Email(email);
|
||||
}
|
||||
if (name) {
|
||||
updateData.name = new Name(name);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
|
||||
);
|
||||
|
||||
await UserService.updateOneById({
|
||||
id: new ObjectID(userId),
|
||||
data: updateData,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(`SCIM Update user - user updated successfully`);
|
||||
|
||||
// Fetch updated user
|
||||
const updatedUser: User | null = await UserService.findOneById({
|
||||
id: new ObjectID(userId),
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const user: JSONObject = formatUserForSCIM(updatedUser, req, req.params["projectScimId"]!, "project");
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - no updates made, returning existing user`,
|
||||
);
|
||||
|
||||
// If no updates were made, return the existing user
|
||||
const user: JSONObject = formatUserForSCIM(projectUser.user, req, req.params["projectScimId"]!, "project");
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Groups endpoint - GET /scim/v2/Groups
|
||||
router.get(
|
||||
"/scim/v2/:projectScimId/Groups",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Groups list request for projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Groups - found ${scimConfig.teams?.length || 0} configured teams`,
|
||||
);
|
||||
|
||||
// Return configured teams as groups
|
||||
const groups: JSONObject[] = (scimConfig.teams || []).map((team: any) => {
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
id: team.id?.toString(),
|
||||
displayName: team.name?.toString(),
|
||||
members: [],
|
||||
meta: {
|
||||
resourceType: "Group",
|
||||
location: `${req.protocol}://${req.get("host")}/scim/v2/${req.params["projectScimId"]}/Groups/${team.id?.toString()}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: groups.length,
|
||||
startIndex: 1,
|
||||
itemsPerPage: groups.length,
|
||||
Resources: groups,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create User - POST /scim/v2/Users
|
||||
router.post(
|
||||
"/scim/v2/:projectScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Create user request for projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
|
||||
if (!scimConfig.autoProvisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-provisioning is disabled for this project",
|
||||
);
|
||||
}
|
||||
|
||||
const scimUser: JSONObject = req.body;
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
|
||||
logger.debug(`SCIM Create user - email: ${email}, name: ${name}`);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException("userName or email is required");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
// Create user if doesn't exist
|
||||
if (!user) {
|
||||
logger.debug(
|
||||
`SCIM Create user - creating new user for email: ${email}`,
|
||||
);
|
||||
user = await UserService.createByEmail({
|
||||
email: new Email(email),
|
||||
name: name ? new Name(name) : new Name("Unknown"),
|
||||
isEmailVerified: true,
|
||||
generateRandomPassword: true,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`SCIM Create user - user already exists with id: ${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add user to default teams if configured
|
||||
if (scimConfig.teams && scimConfig.teams.length > 0) {
|
||||
logger.debug(
|
||||
`SCIM Create user - adding user to ${scimConfig.teams.length} configured teams`,
|
||||
);
|
||||
await handleUserTeamOperations("add", projectId, user.id!, scimConfig);
|
||||
}
|
||||
|
||||
const createdUser: JSONObject = formatUserForSCIM(user, req, req.params["projectScimId"]!, "project");
|
||||
|
||||
logger.debug(
|
||||
`SCIM Create user - returning created user with id: ${user.id}`,
|
||||
);
|
||||
|
||||
res.status(201);
|
||||
return Response.sendJsonObjectResponse(req, res, createdUser);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete User - DELETE /scim/v2/Users/{id}
|
||||
router.delete(
|
||||
"/scim/v2/:projectScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`SCIM Delete user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
if (!scimConfig.autoDeprovisionUsers) {
|
||||
logger.debug("SCIM Delete user - auto-deprovisioning is disabled");
|
||||
throw new BadRequestException(
|
||||
"Auto-deprovisioning is disabled for this project",
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Delete user - removing user from all teams in project: ${projectId}`,
|
||||
);
|
||||
|
||||
// Remove user from teams the SCIM configured
|
||||
if (!scimConfig.teams || scimConfig.teams.length === 0) {
|
||||
logger.debug("SCIM Delete user - no teams configured for SCIM");
|
||||
throw new BadRequestException("No teams configured for SCIM");
|
||||
}
|
||||
|
||||
await handleUserTeamOperations(
|
||||
"remove",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
scimConfig,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Delete user - user successfully deprovisioned from project`,
|
||||
);
|
||||
|
||||
res.status(204);
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
message: "User deprovisioned",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
488
App/FeatureSet/Identity/API/StatusPageSCIM.ts
Normal file
488
App/FeatureSet/Identity/API/StatusPageSCIM.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
|
||||
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
OneUptimeRequest,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Email from "Common/Types/Email";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
|
||||
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import {
|
||||
formatUserForSCIM,
|
||||
generateServiceProviderConfig,
|
||||
logSCIMOperation,
|
||||
} from "../Utils/SCIMUtils";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// SCIM Service Provider Configuration - GET /status-page-scim/v2/ServiceProviderConfig
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/ServiceProviderConfig",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation("ServiceProviderConfig", "status-page", req.params["statusPageScimId"]!);
|
||||
|
||||
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page"
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Status Page Users endpoint - GET /status-page-scim/v2/Users
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Users list request for statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
|
||||
// Parse query parameters
|
||||
const startIndex: number = parseInt(req.query["startIndex"] as string) || 1;
|
||||
const count: number = Math.min(
|
||||
parseInt(req.query["count"] as string) || 100,
|
||||
LIMIT_PER_PROJECT,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
|
||||
);
|
||||
|
||||
// Get all private users for this status page
|
||||
const statusPageUsers: Array<StatusPagePrivateUser> =
|
||||
await StatusPagePrivateUserService.findBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_MAX,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - found ${statusPageUsers.length} users`,
|
||||
);
|
||||
|
||||
// Format users for SCIM
|
||||
const users: Array<JSONObject> = statusPageUsers.map(
|
||||
(user: StatusPagePrivateUser) => {
|
||||
return formatUserForSCIM(user, req, req.params["statusPageScimId"]!, "status-page");
|
||||
},
|
||||
);
|
||||
|
||||
// Paginate the results
|
||||
const paginatedUsers: Array<JSONObject> = users.slice(
|
||||
(startIndex - 1) * count,
|
||||
startIndex * count,
|
||||
);
|
||||
|
||||
logger.debug(`Status Page SCIM Users response prepared with ${users.length} users`);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: users.length,
|
||||
startIndex: startIndex,
|
||||
itemsPerPage: paginatedUsers.length,
|
||||
Resources: paginatedUsers,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get Individual Status Page User - GET /status-page-scim/v2/Users/{id}
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Get individual user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - user not found for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(statusPageUser, req, req.params["statusPageScimId"]!, "status-page");
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - returning user with id: ${statusPageUser.id}`,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create Status Page User - POST /status-page-scim/v2/Users
|
||||
router.post(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user request for statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
|
||||
if (!scimConfig.autoProvisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-provisioning is disabled for this status page",
|
||||
);
|
||||
}
|
||||
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - statusPageId: ${statusPageId}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for Status Page SCIM Create user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
// Extract user data from SCIM payload
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException("Email is required for user creation");
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - email: ${email}`,
|
||||
);
|
||||
|
||||
// Check if user already exists for this status page
|
||||
let user: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
email: new Email(email),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - creating new user with email: ${email}`,
|
||||
);
|
||||
|
||||
// Create new status page private user
|
||||
user = await StatusPagePrivateUserService.create({
|
||||
data: {
|
||||
statusPageId: statusPageId,
|
||||
email: new Email(email),
|
||||
} as any,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - user already exists with id: ${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const createdUser: JSONObject = formatUserForSCIM(user, req, req.params["statusPageScimId"]!, "status-page");
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - returning created user with id: ${user.id}`,
|
||||
);
|
||||
|
||||
res.status(201);
|
||||
return Response.sendJsonObjectResponse(req, res, createdUser);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update Status Page User - PUT /status-page-scim/v2/Users/{id}
|
||||
router.put(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for Status Page SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user not found for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
// Update user information
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const active: boolean = scimUser["active"] as boolean;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - email: ${email}, active: ${active}`,
|
||||
);
|
||||
|
||||
// Handle user deactivation by deleting from status page
|
||||
if (active === false) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user marked as inactive, removing from status page`,
|
||||
);
|
||||
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
if (scimConfig.autoDeprovisionUsers) {
|
||||
await StatusPagePrivateUserService.deleteOneById({
|
||||
id: new ObjectID(userId),
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user removed from status page`,
|
||||
);
|
||||
|
||||
// Return empty response for deleted user
|
||||
return Response.sendJsonObjectResponse(req, res, {});
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: {
|
||||
email?: Email;
|
||||
} = {};
|
||||
|
||||
if (email && email !== statusPageUser.email?.toString()) {
|
||||
updateData.email = new Email(email);
|
||||
}
|
||||
|
||||
// Only update if there are changes
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
|
||||
);
|
||||
|
||||
await StatusPagePrivateUserService.updateOneById({
|
||||
id: new ObjectID(userId),
|
||||
data: updateData,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(`Status Page SCIM Update user - user updated successfully`);
|
||||
|
||||
// Fetch updated user
|
||||
const updatedUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneById({
|
||||
id: new ObjectID(userId),
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const user: JSONObject = formatUserForSCIM(updatedUser, req, req.params["statusPageScimId"]!, "status-page");
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - no updates made, returning existing user`,
|
||||
);
|
||||
|
||||
// If no updates were made, return the existing user
|
||||
const user: JSONObject = formatUserForSCIM(statusPageUser, req, req.params["statusPageScimId"]!, "status-page");
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete Status Page User - DELETE /status-page-scim/v2/Users/{id}
|
||||
router.delete(
|
||||
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
const userId: string = req.params["userId"]!;
|
||||
|
||||
if (!scimConfig.autoDeprovisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-deprovisioning is disabled for this status page",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - user not found for userId: ${userId}`,
|
||||
);
|
||||
// SCIM spec says to return 404 for non-existent resources
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
|
||||
// Delete the user from status page
|
||||
await StatusPagePrivateUserService.deleteOneById({
|
||||
id: new ObjectID(userId),
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - user deleted successfully for userId: ${userId}`,
|
||||
);
|
||||
|
||||
// Return 204 No Content for successful deletion
|
||||
res.status(204);
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,8 +1,10 @@
|
||||
import AuthenticationAPI from "./API/Authentication";
|
||||
import ResellerAPI from "./API/Reseller";
|
||||
import SsoAPI from "./API/SSO";
|
||||
import SCIMAPI from "./API/SCIM";
|
||||
import StatusPageAuthenticationAPI from "./API/StatusPageAuthentication";
|
||||
import StatusPageSsoAPI from "./API/StatusPageSSO";
|
||||
import StatusPageSCIMAPI from "./API/StatusPageSCIM";
|
||||
import FeatureSet from "Common/Server/Types/FeatureSet";
|
||||
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import "ejs";
|
||||
@@ -19,6 +21,10 @@ const IdentityFeatureSet: FeatureSet = {
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], SsoAPI);
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], SCIMAPI);
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], StatusPageSCIMAPI);
|
||||
|
||||
app.use([`/${APP_NAME}`, "/"], StatusPageSsoAPI);
|
||||
|
||||
app.use(
|
||||
|
||||
262
App/FeatureSet/Identity/Utils/SCIMUtils.ts
Normal file
262
App/FeatureSet/Identity/Utils/SCIMUtils.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { ExpressRequest } from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import Email from "Common/Types/Email";
|
||||
import Name from "Common/Types/Name";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
|
||||
/**
|
||||
* Shared SCIM utility functions for both Project SCIM and Status Page SCIM
|
||||
*/
|
||||
|
||||
// Base interface for SCIM user-like objects - compatible with User model
|
||||
export interface SCIMUser {
|
||||
id?: ObjectID | null;
|
||||
email?: Email;
|
||||
name?: Name | string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse name information from SCIM user payload
|
||||
*/
|
||||
export const parseNameFromSCIM: (scimUser: JSONObject) => string = (
|
||||
scimUser: JSONObject,
|
||||
): string => {
|
||||
logger.debug(
|
||||
`SCIM - Parsing name from SCIM user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
const givenName: string =
|
||||
((scimUser["name"] as JSONObject)?.["givenName"] as string) || "";
|
||||
const familyName: string =
|
||||
((scimUser["name"] as JSONObject)?.["familyName"] as string) || "";
|
||||
const formattedName: string = (scimUser["name"] as JSONObject)?.[
|
||||
"formatted"
|
||||
] as string;
|
||||
|
||||
// Construct full name: prefer formatted, then combine given+family, then fallback to displayName
|
||||
if (formattedName) {
|
||||
return formattedName;
|
||||
} else if (givenName || familyName) {
|
||||
return `${givenName} ${familyName}`.trim();
|
||||
} else if (scimUser["displayName"]) {
|
||||
return scimUser["displayName"] as string;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse full name into SCIM name format
|
||||
*/
|
||||
export const parseNameToSCIMFormat: (fullName: string) => {
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
formatted: string;
|
||||
} = (
|
||||
fullName: string,
|
||||
): { givenName: string; familyName: string; formatted: string } => {
|
||||
const nameParts: string[] = fullName.trim().split(/\s+/);
|
||||
const givenName: string = nameParts[0] || "";
|
||||
const familyName: string = nameParts.slice(1).join(" ") || "";
|
||||
|
||||
return {
|
||||
givenName,
|
||||
familyName,
|
||||
formatted: fullName,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format user object for SCIM response
|
||||
*/
|
||||
export const formatUserForSCIM: (
|
||||
user: SCIMUser,
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
) => JSONObject = (
|
||||
user: SCIMUser,
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
): JSONObject => {
|
||||
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
|
||||
const userName: string = user.email?.toString() || "";
|
||||
const fullName: string = user.name?.toString() || userName.split("@")[0] || "Unknown User";
|
||||
|
||||
const nameData: { givenName: string; familyName: string; formatted: string } =
|
||||
parseNameToSCIMFormat(fullName);
|
||||
|
||||
// Determine the correct endpoint path based on SCIM type
|
||||
const endpointPath: string = scimType === "project"
|
||||
? `/scim/v2/${scimId}/Users/${user.id?.toString()}`
|
||||
: `/status-page-scim/v2/${scimId}/Users/${user.id?.toString()}`;
|
||||
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
id: user.id?.toString(),
|
||||
userName: userName,
|
||||
displayName: nameData.formatted,
|
||||
name: {
|
||||
formatted: nameData.formatted,
|
||||
familyName: nameData.familyName,
|
||||
givenName: nameData.givenName,
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
value: userName,
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
meta: {
|
||||
resourceType: "User",
|
||||
created: user.createdAt?.toISOString(),
|
||||
lastModified: user.updatedAt?.toISOString(),
|
||||
location: `${baseUrl}${endpointPath}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract email from SCIM user payload
|
||||
*/
|
||||
export const extractEmailFromSCIM: (scimUser: JSONObject) => string = (
|
||||
scimUser: JSONObject,
|
||||
): string => {
|
||||
return (
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string) ||
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract active status from SCIM user payload
|
||||
*/
|
||||
export const extractActiveFromSCIM: (scimUser: JSONObject) => boolean = (
|
||||
scimUser: JSONObject,
|
||||
): boolean => {
|
||||
return scimUser["active"] !== false; // Default to true if not specified
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate SCIM ServiceProviderConfig response
|
||||
*/
|
||||
export const generateServiceProviderConfig: (
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
documentationUrl?: string,
|
||||
) => JSONObject = (
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
documentationUrl: string = "https://oneuptime.com/docs/identity/scim",
|
||||
): JSONObject => {
|
||||
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
|
||||
const endpointPath: string = scimType === "project"
|
||||
? `/scim/v2/${scimId}`
|
||||
: `/status-page-scim/v2/${scimId}`;
|
||||
|
||||
return {
|
||||
schemas: [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
|
||||
],
|
||||
documentationUri: documentationUrl,
|
||||
patch: {
|
||||
supported: true,
|
||||
},
|
||||
bulk: {
|
||||
supported: true,
|
||||
maxOperations: 1000,
|
||||
maxPayloadSize: 1048576,
|
||||
},
|
||||
filter: {
|
||||
supported: true,
|
||||
maxResults: 200,
|
||||
},
|
||||
changePassword: {
|
||||
supported: false,
|
||||
},
|
||||
sort: {
|
||||
supported: true,
|
||||
},
|
||||
etag: {
|
||||
supported: false,
|
||||
},
|
||||
authenticationSchemes: [
|
||||
{
|
||||
type: "httpbearer",
|
||||
name: "HTTP Bearer",
|
||||
description: "Authentication scheme using HTTP Bearer Token",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
location: `${baseUrl}${endpointPath}/ServiceProviderConfig`,
|
||||
resourceType: "ServiceProviderConfig",
|
||||
created: "2023-01-01T00:00:00Z",
|
||||
lastModified: "2023-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate SCIM ListResponse for users
|
||||
*/
|
||||
export const generateUsersListResponse: (
|
||||
users: JSONObject[],
|
||||
startIndex: number,
|
||||
totalResults: number,
|
||||
) => JSONObject = (
|
||||
users: JSONObject[],
|
||||
startIndex: number,
|
||||
totalResults: number,
|
||||
): JSONObject => {
|
||||
return {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: totalResults,
|
||||
startIndex: startIndex,
|
||||
itemsPerPage: users.length,
|
||||
Resources: users,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse query parameters for SCIM list requests
|
||||
*/
|
||||
export const parseSCIMQueryParams: (req: ExpressRequest) => {
|
||||
startIndex: number;
|
||||
count: number;
|
||||
} = (req: ExpressRequest): { startIndex: number; count: number } => {
|
||||
const startIndex: number = parseInt(req.query["startIndex"] as string) || 1;
|
||||
const count: number = Math.min(
|
||||
parseInt(req.query["count"] as string) || 100,
|
||||
200, // SCIM recommended max
|
||||
);
|
||||
|
||||
return { startIndex, count };
|
||||
};
|
||||
|
||||
/**
|
||||
* Log SCIM operation with consistent format
|
||||
*/
|
||||
export const logSCIMOperation: (
|
||||
operation: string,
|
||||
scimType: "project" | "status-page",
|
||||
scimId: string,
|
||||
details?: string,
|
||||
) => void = (
|
||||
operation: string,
|
||||
scimType: "project" | "status-page",
|
||||
scimId: string,
|
||||
details?: string,
|
||||
): void => {
|
||||
const logPrefix: string = scimType === "project" ? "Project SCIM" : "Status Page SCIM";
|
||||
const message: string = `${logPrefix} ${operation} - scimId: ${scimId}${details ? `, ${details}` : ""}`;
|
||||
logger.debug(message);
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import Hostname from "Common/Types/API/Hostname";
|
||||
import TwilioConfig from "Common/Types/CallAndSMS/TwilioConfig";
|
||||
import Email from "Common/Types/Email";
|
||||
import EmailServer from "Common/Types/Email/EmailServer";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Port from "Common/Types/Port";
|
||||
import { AdminDashboardClientURL } from "Common/Server/EnvironmentConfig";
|
||||
import GlobalConfigService from "Common/Server/Services/GlobalConfigService";
|
||||
import GlobalConfig, {
|
||||
@@ -12,24 +10,6 @@ import GlobalConfig, {
|
||||
} from "Common/Models/DatabaseModels/GlobalConfig";
|
||||
import Phone from "Common/Types/Phone";
|
||||
|
||||
export const InternalSmtpPassword: string =
|
||||
process.env["INTERNAL_SMTP_PASSWORD"] || "";
|
||||
|
||||
export const InternalSmtpHost: Hostname = new Hostname(
|
||||
process.env["INTERNAL_SMTP_HOST"] || "haraka",
|
||||
);
|
||||
|
||||
export const InternalSmtpPort: Port = new Port(2525);
|
||||
|
||||
export const InternalSmtpSecure: boolean = false;
|
||||
|
||||
export const InternalSmtpEmail: Email = new Email(
|
||||
process.env["INTERNAL_SMTP_EMAIL"] || "noreply@oneuptime.com",
|
||||
);
|
||||
|
||||
export const InternalSmtpFromName: string =
|
||||
process.env["INTERNAL_SMTP_FROM_NAME"] || "OneUptime";
|
||||
|
||||
type GetGlobalSMTPConfig = () => Promise<EmailServer | null>;
|
||||
|
||||
export const getGlobalSMTPConfig: GetGlobalSMTPConfig =
|
||||
@@ -132,10 +112,10 @@ export const getEmailServerType: GetEmailServerTypeFunction =
|
||||
});
|
||||
|
||||
if (!globalConfig) {
|
||||
return EmailServerType.Internal;
|
||||
return EmailServerType.CustomSMTP;
|
||||
}
|
||||
|
||||
return globalConfig.emailServerType || EmailServerType.Internal;
|
||||
return globalConfig.emailServerType || EmailServerType.CustomSMTP;
|
||||
};
|
||||
|
||||
export interface SendGridConfig {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
InternalSmtpEmail,
|
||||
InternalSmtpFromName,
|
||||
InternalSmtpHost,
|
||||
InternalSmtpPassword,
|
||||
InternalSmtpPort,
|
||||
InternalSmtpSecure,
|
||||
SendGridConfig,
|
||||
getEmailServerType,
|
||||
getGlobalSMTPConfig,
|
||||
@@ -202,19 +196,6 @@ export default class MailService {
|
||||
};
|
||||
}
|
||||
|
||||
public static getInternalEmailServer(): EmailServer {
|
||||
return {
|
||||
id: undefined,
|
||||
username: InternalSmtpEmail.toString(),
|
||||
password: InternalSmtpPassword,
|
||||
host: InternalSmtpHost,
|
||||
port: InternalSmtpPort,
|
||||
fromEmail: InternalSmtpEmail,
|
||||
fromName: InternalSmtpFromName,
|
||||
secure: InternalSmtpSecure,
|
||||
};
|
||||
}
|
||||
|
||||
public static async getGlobalFromEmail(): Promise<Email> {
|
||||
const emailServer: EmailServer | null = await this.getGlobalSmtpSettings();
|
||||
|
||||
@@ -540,17 +521,6 @@ export default class MailService {
|
||||
options.emailServer = globalEmailServer;
|
||||
}
|
||||
|
||||
if (
|
||||
emailServerType === EmailServerType.Internal &&
|
||||
(!options || !options.emailServer)
|
||||
) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
options.emailServer = this.getInternalEmailServer();
|
||||
}
|
||||
|
||||
if (options && options.emailServer && emailLog) {
|
||||
emailLog.fromEmail = options.emailServer.fromEmail;
|
||||
}
|
||||
|
||||
4
App/package-lock.json
generated
4
App/package-lock.json
generated
@@ -65,6 +65,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -84,7 +85,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -128,6 +128,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -142,7 +143,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
@@ -64,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertOwnerTeam",
|
||||
})
|
||||
@Index(["alertId", "teamId", "projectId"])
|
||||
export default class AlertOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -63,6 +63,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertOwnerUser",
|
||||
})
|
||||
@Index(["alertId", "userId", "projectId"])
|
||||
export default class AlertOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertSeverity",
|
||||
})
|
||||
@Index(["projectId", "order"])
|
||||
export default class AlertSeverity extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertState",
|
||||
})
|
||||
@Index(["projectId", "isCreatedState"])
|
||||
@Index(["projectId", "isResolvedState"])
|
||||
@Index(["projectId", "isAcknowledgedState"])
|
||||
@Index(["projectId", "order"])
|
||||
export default class AlertState extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -60,6 +60,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertStateTimeline",
|
||||
})
|
||||
@Index(["alertId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "AlertStateTimeline",
|
||||
singularName: "Alert State Timeline",
|
||||
|
||||
@@ -17,7 +17,6 @@ import Port from "../../Types/Port";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
export enum EmailServerType {
|
||||
Internal = "Internal",
|
||||
Sendgrid = "Sendgrid",
|
||||
CustomSMTP = "Custom SMTP",
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentOwnerTeam",
|
||||
})
|
||||
@Index(["incidentId", "teamId", "projectId"])
|
||||
export default class IncidentOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -63,6 +63,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentOwnerUser",
|
||||
})
|
||||
@Index(["incidentId", "userId", "projectId"])
|
||||
export default class IncidentOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentSeverity",
|
||||
})
|
||||
@Index(["projectId", "order"])
|
||||
export default class IncidentSeverity extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentState",
|
||||
})
|
||||
@Index(["projectId", "isCreatedState"])
|
||||
@Index(["projectId", "isResolvedState"])
|
||||
@Index(["projectId", "order"])
|
||||
export default class IncidentState extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -24,6 +24,8 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@EnableDocumentation()
|
||||
@CanAccessIfCanReadOn("incident")
|
||||
@TenantColumn("projectId")
|
||||
@Index(["incidentId", "startsAt"]) // Composite index for efficient incident timeline queries
|
||||
@Index(["incidentId", "projectId", "startsAt"]) // Alternative composite index including project
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
@@ -60,6 +62,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentStateTimeline",
|
||||
})
|
||||
@Index(["incidentId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "IncidentStateTimeline",
|
||||
singularName: "Incident State Timeline",
|
||||
|
||||
@@ -114,6 +114,7 @@ import StatusPageOwnerTeam from "./StatusPageOwnerTeam";
|
||||
import StatusPageOwnerUser from "./StatusPageOwnerUser";
|
||||
import StatusPagePrivateUser from "./StatusPagePrivateUser";
|
||||
import StatusPageResource from "./StatusPageResource";
|
||||
import StatusPageSCIM from "./StatusPageSCIM";
|
||||
import StatusPageSSO from "./StatusPageSso";
|
||||
import StatusPageSubscriber from "./StatusPageSubscriber";
|
||||
// Team
|
||||
@@ -179,6 +180,7 @@ import ProjectUser from "./ProjectUser";
|
||||
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
|
||||
import MonitorFeed from "./MonitorFeed";
|
||||
import MetricType from "./MetricType";
|
||||
import ProjectSCIM from "./ProjectSCIM";
|
||||
|
||||
const AllModelTypes: Array<{
|
||||
new (): BaseModel;
|
||||
@@ -276,6 +278,7 @@ const AllModelTypes: Array<{
|
||||
|
||||
ProjectSSO,
|
||||
StatusPageSSO,
|
||||
StatusPageSCIM,
|
||||
|
||||
MonitorProbe,
|
||||
|
||||
@@ -380,6 +383,10 @@ const AllModelTypes: Array<{
|
||||
MetricType,
|
||||
|
||||
OnCallDutyPolicyTimeLog,
|
||||
|
||||
ProjectSCIM,
|
||||
|
||||
StatusPageSCIM
|
||||
];
|
||||
|
||||
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorOwnerTeam",
|
||||
})
|
||||
@Index(["monitorId", "teamId", "projectId"])
|
||||
export default class MonitorOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorOwnerUser",
|
||||
})
|
||||
@Index(["monitorId", "userId", "projectId"])
|
||||
export default class MonitorOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -24,6 +24,8 @@ export type MonitorStepProbeResponse = Dictionary<ProbeMonitorResponse>;
|
||||
|
||||
@EnableDocumentation()
|
||||
@TenantColumn("projectId")
|
||||
@Index(["monitorId", "probeId"]) // Composite index for efficient monitor-probe relationship queries
|
||||
@Index(["monitorId", "projectId"]) // Alternative index for monitor queries within project
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -76,6 +76,8 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorStatus",
|
||||
})
|
||||
@Index(["projectId", "isOperationalState"])
|
||||
@Index(["projectId", "isOfflineState"])
|
||||
export default class MonitorStatus extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@EnableDocumentation()
|
||||
@CanAccessIfCanReadOn("monitor")
|
||||
@TenantColumn("projectId")
|
||||
@Index(["monitorId", "projectId", "startsAt"]) // Composite index for efficient timeline queries
|
||||
@Index(["monitorId", "startsAt"]) // Alternative index for monitor-specific timeline queries
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
@@ -62,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorStatusTimeline",
|
||||
})
|
||||
@Index(["monitorId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "MonitorStatusTimeline",
|
||||
singularName: "Monitor Status Event",
|
||||
|
||||
@@ -51,6 +51,9 @@ import Alert from "./Alert";
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyExecutionLogTimeline",
|
||||
})
|
||||
@Index(["onCallDutyPolicyExecutionLogId", "createdAt"])
|
||||
@Index(["projectId", "createdAt"])
|
||||
@Index(["alertSentToUserId", "projectId"])
|
||||
@TableMetadata({
|
||||
tableName: "OnCallDutyPolicyExecutionLogTimeline",
|
||||
singularName: "On-Call Duty Execution Log Timeline",
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyOwnerTeam",
|
||||
})
|
||||
@Index(["onCallDutyPolicyId", "teamId", "projectId"])
|
||||
export default class OnCallDutyPolicyOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyOwnerUser",
|
||||
})
|
||||
@Index(["onCallDutyPolicyId", "userId", "projectId"])
|
||||
export default class OnCallDutyPolicyOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
451
Common/Models/DatabaseModels/ProjectSCIM.ts
Normal file
451
Common/Models/DatabaseModels/ProjectSCIM.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import Project from "./Project";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/project-scim"))
|
||||
@TableMetadata({
|
||||
tableName: "ProjectSCIM",
|
||||
singularName: "SCIM",
|
||||
pluralName: "SCIM",
|
||||
icon: IconProp.Lock,
|
||||
tableDescription: "Manage SCIM auto-provisioning for your project",
|
||||
})
|
||||
@Entity({
|
||||
name: "ProjectSCIM",
|
||||
})
|
||||
export default class ProjectSCIM extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Any friendly name for this SCIM configuration",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
@UniqueColumnBy("projectId")
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Description",
|
||||
description: "Friendly description to help you remember",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Bearer Token",
|
||||
description: "Bearer token for SCIM authentication. Keep this secure.",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public bearerToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Team,
|
||||
title: "Default Teams",
|
||||
description: "Default teams that new users will be added to via SCIM",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Team;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "ProjectScimTeam",
|
||||
inverseJoinColumn: {
|
||||
name: "teamId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "projectScimId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public teams?: Array<Team> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Provision Users",
|
||||
description: "Automatically create users when they are added via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoProvisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Deprovision Users",
|
||||
description: "Automatically remove users when they are removed via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoDeprovisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Deleted by User",
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceOwnerTeam",
|
||||
})
|
||||
@Index(["scheduledMaintenanceId", "teamId", "projectId"])
|
||||
export default class ScheduledMaintenanceOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -63,6 +63,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceOwnerUser",
|
||||
})
|
||||
@Index(["scheduledMaintenanceId", "userId", "projectId"])
|
||||
export default class ScheduledMaintenanceOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceState",
|
||||
})
|
||||
@Index(["projectId", "order"])
|
||||
@Index(["projectId", "isOngoingState"])
|
||||
@Index(["projectId", "isEndedState"])
|
||||
export default class ScheduledMaintenanceState extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -59,6 +59,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceStateTimeline",
|
||||
})
|
||||
@Index(["scheduledMaintenanceId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "ScheduledMaintenanceStateTimeline",
|
||||
icon: IconProp.List,
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "StatusPageOwnerTeam",
|
||||
})
|
||||
@Index(["statusPageId", "teamId", "projectId"])
|
||||
export default class StatusPageOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "StatusPageOwnerUser",
|
||||
})
|
||||
@Index(["statusPageId", "userId", "projectId"])
|
||||
export default class StatusPageOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
473
Common/Models/DatabaseModels/StatusPageSCIM.ts
Normal file
473
Common/Models/DatabaseModels/StatusPageSCIM.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import Project from "./Project";
|
||||
import StatusPage from "./StatusPage";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import CanAccessIfCanReadOn from "../../Types/Database/CanAccessIfCanReadOn";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@CanAccessIfCanReadOn("statusPage")
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/status-page-scim"))
|
||||
@TableMetadata({
|
||||
tableName: "StatusPageSCIM",
|
||||
singularName: "Status Page SCIM",
|
||||
pluralName: "Status Page SCIM",
|
||||
icon: IconProp.Lock,
|
||||
tableDescription: "Manage SCIM auto-provisioning for your status page",
|
||||
})
|
||||
@Entity({
|
||||
name: "StatusPageSCIM",
|
||||
})
|
||||
export default class StatusPageSCIM extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "statusPageId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: StatusPage,
|
||||
title: "Status Page",
|
||||
description:
|
||||
"Relation to Status Page Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return StatusPage;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "statusPageId" })
|
||||
public statusPage?: StatusPage = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "Status Page ID",
|
||||
description: "ID of your Status Page resource where this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public statusPageId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Any friendly name for this SCIM configuration",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
@UniqueColumnBy("statusPageId")
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Description",
|
||||
description: "Friendly description to help you remember",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongText,
|
||||
title: "Bearer Token",
|
||||
description: "Bearer token for SCIM authentication. Keep this secure.",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public bearerToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Provision Users",
|
||||
description: "Automatically create status page users when they are added via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoProvisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPageSSO,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Auto Deprovision Users",
|
||||
description: "Automatically remove status page users when they are removed via SCIM",
|
||||
defaultValue: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoDeprovisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPageSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Deleted by User",
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
@@ -52,6 +52,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "UserOnCallLogTimeline",
|
||||
})
|
||||
@Index(["userId", "createdAt"])
|
||||
@Index(["onCallDutyPolicyExecutionLogId", "status"])
|
||||
@Index(["projectId", "status"])
|
||||
@TableMetadata({
|
||||
tableName: "UserOnCallLogTimeline",
|
||||
singularName: "User On-Call Log Timeline",
|
||||
|
||||
144
Common/Scripts/generate-service-worker.js
Executable file
144
Common/Scripts/generate-service-worker.js
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Universal Service Worker Generator for OneUptime Services
|
||||
*
|
||||
* This script can be used by any OneUptime service to generate
|
||||
* a service worker from a template with dynamic versioning.
|
||||
*
|
||||
* Usage:
|
||||
* node generate-service-worker.js [template-path] [output-path]
|
||||
*
|
||||
* Example:
|
||||
* node generate-service-worker.js sw.js.template public/sw.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Default values
|
||||
const DEFAULT_APP_VERSION = '1.0.0';
|
||||
const DEFAULT_GIT_SHA = 'local';
|
||||
|
||||
/**
|
||||
* Get app version from environment or package.json
|
||||
*/
|
||||
function getAppVersion(packageJsonPath) {
|
||||
// First try environment variable (Docker build)
|
||||
if (process.env.APP_VERSION) {
|
||||
return process.env.APP_VERSION;
|
||||
}
|
||||
|
||||
// Fallback to package.json version
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
return packageJson.version || DEFAULT_APP_VERSION;
|
||||
} catch (error) {
|
||||
console.warn('Could not read package.json, using default version');
|
||||
return DEFAULT_APP_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git SHA from environment
|
||||
*/
|
||||
function getGitSha() {
|
||||
// Try environment variable first (Docker build)
|
||||
if (process.env.GIT_SHA) {
|
||||
return process.env.GIT_SHA.substring(0, 8); // Short SHA
|
||||
}
|
||||
|
||||
// Try to get from git command if available
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const gitSha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
return gitSha;
|
||||
} catch (error) {
|
||||
// Fallback to timestamp-based hash for local development
|
||||
const timestamp = Date.now().toString();
|
||||
const hash = crypto.createHash('md5').update(timestamp).digest('hex');
|
||||
return hash.substring(0, 8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate service worker from template
|
||||
*/
|
||||
function generateServiceWorker(templatePath, outputPath, serviceName = 'OneUptime') {
|
||||
// Check if template exists
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
console.error('❌ Service worker template not found:', templatePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read template
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
// Get version information
|
||||
const packageJsonPath = path.join(path.dirname(templatePath), 'package.json');
|
||||
const appVersion = getAppVersion(packageJsonPath);
|
||||
const gitSha = getGitSha();
|
||||
const buildTimestamp = new Date().toISOString();
|
||||
|
||||
console.log(`🔧 Generating service worker for ${serviceName}...`);
|
||||
console.log(` App Version: ${appVersion}`);
|
||||
console.log(` Git SHA: ${gitSha}`);
|
||||
console.log(` Build Time: ${buildTimestamp}`);
|
||||
|
||||
// Replace placeholders
|
||||
const generatedContent = template
|
||||
.replace(/\{\{APP_VERSION\}\}/g, appVersion)
|
||||
.replace(/\{\{GIT_SHA\}\}/g, gitSha)
|
||||
.replace(/\{\{BUILD_TIMESTAMP\}\}/g, buildTimestamp)
|
||||
.replace(/\{\{SERVICE_NAME\}\}/g, serviceName);
|
||||
|
||||
// Add generation comment at the top
|
||||
const header = `/*
|
||||
* Generated Service Worker for ${serviceName}
|
||||
*
|
||||
* Generated at: ${buildTimestamp}
|
||||
* App Version: ${appVersion}
|
||||
* Git SHA: ${gitSha}
|
||||
*
|
||||
* DO NOT EDIT THIS FILE DIRECTLY
|
||||
* Edit the template file instead and run the generator script
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
const finalContent = header + generatedContent;
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write generated service worker
|
||||
fs.writeFileSync(outputPath, finalContent, 'utf8');
|
||||
|
||||
console.log('✅ Service worker generated successfully:', outputPath);
|
||||
console.log(` Cache version: oneuptime-v${appVersion}-${gitSha}`);
|
||||
}
|
||||
|
||||
// Command line interface
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const templatePath = args[0] || 'sw.js.template';
|
||||
const outputPath = args[1] || 'public/sw.js';
|
||||
const serviceName = args[2] || path.basename(process.cwd());
|
||||
|
||||
try {
|
||||
// Resolve paths relative to current working directory
|
||||
const resolvedTemplatePath = path.resolve(templatePath);
|
||||
const resolvedOutputPath = path.resolve(outputPath);
|
||||
|
||||
generateServiceWorker(resolvedTemplatePath, resolvedOutputPath, serviceName);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate service worker:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateServiceWorker, getAppVersion, getGitSha };
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DashboardRoute,
|
||||
AppApiRoute,
|
||||
StatusPageApiRoute,
|
||||
DocsRoute,
|
||||
} from "../ServiceRoute";
|
||||
import BillingConfig from "./BillingConfig";
|
||||
import Protocol from "../Types/API/Protocol";
|
||||
@@ -150,6 +151,13 @@ export const AdminDashboardHostname: Hostname = Hostname.fromString(
|
||||
}`,
|
||||
);
|
||||
|
||||
|
||||
export const DocsHostname: Hostname = Hostname.fromString(
|
||||
`${process.env["SERVER_DOCS_HOSTNAME"] || "localhost"}:${
|
||||
process.env["DOCS_PORT"] || 80
|
||||
}`,
|
||||
);
|
||||
|
||||
export const Env: string = process.env["NODE_ENV"] || "production";
|
||||
|
||||
// Redis does not require password.
|
||||
@@ -318,6 +326,13 @@ export const AccountsClientUrl: URL = new URL(
|
||||
AccountsRoute,
|
||||
);
|
||||
|
||||
|
||||
export const DocsClientUrl: URL = new URL(
|
||||
HttpProtocol,
|
||||
Host,
|
||||
DocsRoute
|
||||
);
|
||||
|
||||
export const DisableTelemetry: boolean =
|
||||
process.env["DISABLE_TELEMETRY"] === "true";
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753343522987 implements MigrationInterface {
|
||||
public name = 'MigrationName1753343522987'
|
||||
public name = "MigrationName1753343522987";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "UserPush" ALTER COLUMN "deviceToken" TYPE text`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "UserPush" ALTER COLUMN "deviceToken" TYPE character varying(500)`);
|
||||
}
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ALTER COLUMN "deviceToken" TYPE text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ALTER COLUMN "deviceToken" TYPE character varying(500)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753377161288 implements MigrationInterface {
|
||||
public name = "MigrationName1753377161288";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_16db786b562f1db40c93d463c7" ON "IncidentStateTimeline" ("incidentId", "projectId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_410cf30b966f88c287d368aa48" ON "IncidentStateTimeline" ("incidentId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ac648c5f1961bc1d5ec1ba21bd" ON "MonitorProbe" ("monitorId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_bde10e600047b06718db90a636" ON "MonitorProbe" ("monitorId", "probeId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_570f164ca5b3559eb8555eb1b1" ON "MonitorStatusTimeline" ("monitorId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_466d392af405ccf2e8b552eb0e" ON "MonitorStatusTimeline" ("monitorId", "projectId", "startsAt") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_466d392af405ccf2e8b552eb0e"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_570f164ca5b3559eb8555eb1b1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_bde10e600047b06718db90a636"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ac648c5f1961bc1d5ec1ba21bd"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_410cf30b966f88c287d368aa48"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_16db786b562f1db40c93d463c7"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPerformanceIndexes1753378524062 implements MigrationInterface {
|
||||
public name = "AddPerformanceIndexes1753378524062";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3c2f8998deba67cedb958fc08f" ON "IncidentSeverity" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_2283c2d1aab23419b784db0d84" ON "IncidentState" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4ed23cf5e6614ee930972ab6b5" ON "IncidentState" ("projectId", "isResolvedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b231eb3cdc945e53947495cf76" ON "IncidentState" ("projectId", "isCreatedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_5c9760b0f7df9fe68efd52151d" ON "MonitorStatus" ("projectId", "isOfflineState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_9c64d2b5df8c5cac0ece90d899" ON "MonitorStatus" ("projectId", "isOperationalState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4490b10d3394a9be5f27f8fc3b" ON "IncidentOwnerTeam" ("incidentId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_1d8d2229e31e4ec13ec99c79ae" ON "IncidentOwnerUser" ("incidentId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7b7272644aab237d503ed3429a" ON "MonitorOwnerTeam" ("monitorId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6f6246149ab744fd62ada06ee5" ON "MonitorOwnerUser" ("monitorId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c98e7e9e31d674cf5c47b15f36" ON "AlertSeverity" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3bb6dc217814170a3b37e21bf5" ON "AlertState" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b20be7b2ca1a6dc602da305f8a" ON "AlertState" ("projectId", "isAcknowledgedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ae2854ea86740fdd56eaf2fea9" ON "AlertState" ("projectId", "isResolvedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_91ad158d170a9b51a2046fcc87" ON "AlertState" ("projectId", "isCreatedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_d640454e87b3dd4f24f9c527d2" ON "AlertStateTimeline" ("alertId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_dfbcaebaa02d06a556fd2e155c" ON "AlertOwnerTeam" ("alertId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_042a7841d65141fb940de9d881" ON "AlertOwnerUser" ("alertId", "userId", "projectId") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_042a7841d65141fb940de9d881"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_dfbcaebaa02d06a556fd2e155c"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_d640454e87b3dd4f24f9c527d2"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_91ad158d170a9b51a2046fcc87"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ae2854ea86740fdd56eaf2fea9"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b20be7b2ca1a6dc602da305f8a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_3bb6dc217814170a3b37e21bf5"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_c98e7e9e31d674cf5c47b15f36"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6f6246149ab744fd62ada06ee5"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7b7272644aab237d503ed3429a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_1d8d2229e31e4ec13ec99c79ae"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4490b10d3394a9be5f27f8fc3b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_9c64d2b5df8c5cac0ece90d899"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_5c9760b0f7df9fe68efd52151d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b231eb3cdc945e53947495cf76"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4ed23cf5e6614ee930972ab6b5"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_2283c2d1aab23419b784db0d84"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_3c2f8998deba67cedb958fc08f"`,
|
||||
);
|
||||
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}}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753383711511 implements MigrationInterface {
|
||||
public name = "MigrationName1753383711511";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b03e14b5a5fc9f5b8603283c88" ON "OnCallDutyPolicyExecutionLogTimeline" ("alertSentToUserId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_114e3f761691867aa919ab6b6e" ON "OnCallDutyPolicyExecutionLogTimeline" ("projectId", "createdAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f34e1244e487f705e7c6b25831" ON "OnCallDutyPolicyExecutionLogTimeline" ("onCallDutyPolicyExecutionLogId", "createdAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_34f21c8ae164fb90be806818a8" ON "OnCallDutyPolicyOwnerTeam" ("onCallDutyPolicyId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_1539db4bbd6ada58abb940b058" ON "OnCallDutyPolicyOwnerUser" ("onCallDutyPolicyId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_00439dd14338c3ee4e81d0714a" ON "ScheduledMaintenanceState" ("projectId", "isEndedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7addde4d27f13be56651000df9" ON "ScheduledMaintenanceState" ("projectId", "isOngoingState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_e84431ba010571147933477cff" ON "ScheduledMaintenanceState" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b737666365dbea2e4c914fc6d3" ON "ScheduledMaintenanceOwnerTeam" ("scheduledMaintenanceId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a4621b7155a01292b92569549f" ON "ScheduledMaintenanceOwnerUser" ("scheduledMaintenanceId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c4ac940ddb05242a166567edbb" ON "ScheduledMaintenanceStateTimeline" ("scheduledMaintenanceId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4873976169085f14bdc39e168d" ON "StatusPageOwnerTeam" ("statusPageId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a9f80dc4f648f0957ce695dc61" ON "StatusPageOwnerUser" ("statusPageId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_33ba145fe2826bb953e2ce9d3d" ON "UserOnCallLogTimeline" ("projectId", "status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_90363cc35c22e377df8fdc5dfb" ON "UserOnCallLogTimeline" ("onCallDutyPolicyExecutionLogId", "status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_89cccd6782b1ee84d20e9690d0" ON "UserOnCallLogTimeline" ("userId", "createdAt") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_89cccd6782b1ee84d20e9690d0"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_90363cc35c22e377df8fdc5dfb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_33ba145fe2826bb953e2ce9d3d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a9f80dc4f648f0957ce695dc61"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4873976169085f14bdc39e168d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_c4ac940ddb05242a166567edbb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a4621b7155a01292b92569549f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b737666365dbea2e4c914fc6d3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_e84431ba010571147933477cff"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7addde4d27f13be56651000df9"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_00439dd14338c3ee4e81d0714a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_1539db4bbd6ada58abb940b058"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_34f21c8ae164fb90be806818a8"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_f34e1244e487f705e7c6b25831"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_114e3f761691867aa919ab6b6e"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b03e14b5a5fc9f5b8603283c88"`,
|
||||
);
|
||||
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}}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754304193228 implements MigrationInterface {
|
||||
public name = "MigrationName1754304193228";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ProjectSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying(500), "bearerToken" character varying(500) NOT NULL, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT true, "isEnabled" boolean NOT NULL DEFAULT false, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_51e71d70211675a5c918aee4e68" PRIMARY KEY ("_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f916360335859c26c4d7051239" ON "ProjectSCIM" ("projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ProjectScimTeam" ("projectScimId" uuid NOT NULL, "teamId" uuid NOT NULL, CONSTRAINT "PK_db724b66b4fa8c880ce5ccf820b" PRIMARY KEY ("projectScimId", "teamId"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b9a28efd66600267f0e9de0731" ON "ProjectScimTeam" ("projectScimId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_bb0eda2ef0c773f975e9ad8448" ON "ProjectScimTeam" ("teamId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_f916360335859c26c4d7051239b" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_5d5d587984f156e5215d51daff7" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_b9a28efd66600267f0e9de0731b" FOREIGN KEY ("projectScimId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_b9a28efd66600267f0e9de0731b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_5d5d587984f156e5215d51daff7"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_f916360335859c26c4d7051239b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_bb0eda2ef0c773f975e9ad8448"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b9a28efd66600267f0e9de0731"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ProjectScimTeam"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_f916360335859c26c4d7051239"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ProjectSCIM"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754315774827 implements MigrationInterface {
|
||||
public name = "MigrationName1754315774827";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" DROP COLUMN "isEnabled"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "ProjectSCIM" ADD "isEnabled" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754384418632 implements MigrationInterface {
|
||||
public name = 'MigrationName1754384418632'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "StatusPageSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying(500), "bearerToken" character varying(500) NOT NULL, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT true, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_9d65d486be515b9608347cf66d4" PRIMARY KEY ("_id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_0a241118fe6b4a8665deef444b" ON "StatusPageSCIM" ("projectId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_7200e368657773fde2836c57eb" ON "StatusPageSCIM" ("statusPageId") `);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_0a241118fe6b4a8665deef444b2" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_7200e368657773fde2836c57ebe" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_adb05dd1cbe0e734a76b3dbdcf1" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" ADD CONSTRAINT "FK_2fded7c784a5c2f56ad2553cb80" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_2fded7c784a5c2f56ad2553cb80"`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_adb05dd1cbe0e734a76b3dbdcf1"`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_7200e368657773fde2836c57ebe"`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIM" DROP CONSTRAINT "FK_0a241118fe6b4a8665deef444b2"`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_7200e368657773fde2836c57eb"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_0a241118fe6b4a8665deef444b"`);
|
||||
await queryRunner.query(`DROP TABLE "StatusPageSCIM"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -143,6 +143,12 @@ import { MigrationName1752774923063 } from "./1752774923063-MigrationName";
|
||||
import { MigrationName1753109689244 } from "./1753109689244-MigrationName";
|
||||
import { AddEnableCustomSubscriberEmailNotificationFooterText1753131488925 } from "./1753131488925-AddEnableCustomSubscriberEmailNotificationFooterText";
|
||||
import { MigrationName1753343522987 } from "./1753343522987-MigrationName";
|
||||
import { MigrationName1753377161288 } from "./1753377161288-MigrationName";
|
||||
import { AddPerformanceIndexes1753378524062 } from "./1753378524062-AddPerformanceIndexes";
|
||||
import { MigrationName1753383711511 } from "./1753383711511-MigrationName";
|
||||
import { MigrationName1754304193228 } from "./1754304193228-MigrationName";
|
||||
import { MigrationName1754315774827 } from "./1754315774827-MigrationName";
|
||||
import { MigrationName1754384418632 } from "./1754384418632-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -289,5 +295,11 @@ export default [
|
||||
MigrationName1752774923063,
|
||||
MigrationName1753109689244,
|
||||
AddEnableCustomSubscriberEmailNotificationFooterText1753131488925,
|
||||
MigrationName1753343522987
|
||||
MigrationName1753343522987,
|
||||
MigrationName1753377161288,
|
||||
AddPerformanceIndexes1753378524062,
|
||||
MigrationName1753383711511,
|
||||
MigrationName1754304193228,
|
||||
MigrationName1754315774827,
|
||||
MigrationName1754384418632
|
||||
];
|
||||
|
||||
@@ -16,9 +16,11 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
export enum QueueName {
|
||||
Workflow = "Workflow",
|
||||
Worker = "Worker",
|
||||
OtelIngestTraces = "OtelIngestTraces",
|
||||
OtelIngestMetrics = "OtelIngestMetrics",
|
||||
OtelIngestLogs = "OtelIngestLogs",
|
||||
Telemetry = "Telemetry",
|
||||
FluentIngest = "FluentIngest",
|
||||
IncomingRequestIngest = "IncomingRequestIngest",
|
||||
ServerMonitorIngest = "ServerMonitorIngest",
|
||||
ProbeIngest = "ProbeIngest",
|
||||
}
|
||||
|
||||
export type QueueJob = Job;
|
||||
@@ -136,4 +138,97 @@ export default class Queue {
|
||||
|
||||
return jobAdded;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getQueueSize(queueName: QueueName): Promise<number> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const waitingCount: number = await queue.getWaitingCount();
|
||||
const activeCount: number = await queue.getActiveCount();
|
||||
const delayedCount: number = await queue.getDelayedCount();
|
||||
|
||||
return waitingCount + activeCount + delayedCount;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getQueueStats(queueName: QueueName): Promise<{
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
total: number;
|
||||
}> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const waitingCount: number = await queue.getWaitingCount();
|
||||
const activeCount: number = await queue.getActiveCount();
|
||||
const completedCount: number = await queue.getCompletedCount();
|
||||
const failedCount: number = await queue.getFailedCount();
|
||||
const delayedCount: number = await queue.getDelayedCount();
|
||||
|
||||
return {
|
||||
waiting: waitingCount,
|
||||
active: activeCount,
|
||||
completed: completedCount,
|
||||
failed: failedCount,
|
||||
delayed: delayedCount,
|
||||
total:
|
||||
waitingCount +
|
||||
activeCount +
|
||||
completedCount +
|
||||
failedCount +
|
||||
delayedCount,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getFailedJobs(
|
||||
queueName: QueueName,
|
||||
options?: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
},
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
data: JSONObject;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
}>
|
||||
> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const start: number = options?.start || 0;
|
||||
const end: number = options?.end || 100;
|
||||
const failed: Job[] = await queue.getFailed(start, end);
|
||||
|
||||
return failed.map((job: Job) => {
|
||||
const result: {
|
||||
id: string;
|
||||
name: string;
|
||||
data: JSONObject;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
} = {
|
||||
id: job.id || "unknown",
|
||||
name: job.name || "unknown",
|
||||
data: job.data as JSONObject,
|
||||
failedReason: job.failedReason || "No reason provided",
|
||||
processedOn: job.processedOn ? new Date(job.processedOn) : null,
|
||||
finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
|
||||
attemptsMade: job.attemptsMade || 0,
|
||||
};
|
||||
|
||||
if (job.stacktrace && job.stacktrace.length > 0) {
|
||||
result.stackTrace = job.stacktrace.join("\n");
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export default class ClusterKeyAuthorization {
|
||||
} else if (req.headers && req.headers["clusterkey"]) {
|
||||
// Header keys are automatically transformed to lowercase
|
||||
clusterKey = req.headers["clusterkey"] as string;
|
||||
} else if (req.headers && req.headers["x-clusterkey"]) {
|
||||
// KEDA TriggerAuthentication sends headers with X- prefix
|
||||
clusterKey = req.headers["x-clusterkey"] as string;
|
||||
} else if (req.body && req.body.clusterKey) {
|
||||
clusterKey = req.body.clusterKey;
|
||||
} else {
|
||||
|
||||
129
Common/Server/Middleware/SCIMAuthorization.ts
Normal file
129
Common/Server/Middleware/SCIMAuthorization.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import ProjectSCIMService from "../Services/ProjectSCIMService";
|
||||
import StatusPageSCIMService from "../Services/StatusPageSCIMService";
|
||||
import {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
NextFunction,
|
||||
OneUptimeRequest,
|
||||
} from "../Utils/Express";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import ProjectSCIM from "../../Models/DatabaseModels/ProjectSCIM";
|
||||
import StatusPageSCIM from "../../Models/DatabaseModels/StatusPageSCIM";
|
||||
import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
|
||||
import BadRequestException from "../../Types/Exception/BadRequestException";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import logger from "../Utils/Logger";
|
||||
|
||||
export default class SCIMMiddleware {
|
||||
@CaptureSpan()
|
||||
public static async isAuthorizedSCIMRequest(
|
||||
req: ExpressRequest,
|
||||
_res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
|
||||
// Extract SCIM ID from URL path (could be project or status page)
|
||||
const scimId: string | undefined = req.params["projectScimId"] || req.params["statusPageScimId"];
|
||||
if (!scimId) {
|
||||
throw new BadRequestException("SCIM ID is required");
|
||||
}
|
||||
|
||||
// Extract bearer token from Authorization header
|
||||
let bearerToken: string | undefined;
|
||||
if (req.headers?.["authorization"]) {
|
||||
const authHeader: string = req.headers["authorization"] as string;
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
bearerToken = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Authorization: scimId=${scimId}, bearerToken=${
|
||||
bearerToken ? "***" : "missing"
|
||||
}`,
|
||||
);
|
||||
|
||||
if (!bearerToken) {
|
||||
throw new NotAuthorizedException(
|
||||
"Bearer token is required for SCIM authentication",
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find Project SCIM configuration first
|
||||
const projectScimConfig: ProjectSCIM | null = await ProjectSCIMService.findOneBy(
|
||||
{
|
||||
query: {
|
||||
_id: new ObjectID(scimId),
|
||||
bearerToken: bearerToken,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
projectId: true,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
teams: {
|
||||
_id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (projectScimConfig) {
|
||||
// Store Project SCIM configuration
|
||||
oneuptimeRequest.bearerTokenData = {
|
||||
scimConfig: projectScimConfig,
|
||||
projectId: projectScimConfig.projectId,
|
||||
projectScimId: new ObjectID(scimId),
|
||||
type: "project-scim",
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// If not found, try Status Page SCIM configuration
|
||||
const statusPageScimConfig: StatusPageSCIM | null = await StatusPageSCIMService.findOneBy(
|
||||
{
|
||||
query: {
|
||||
_id: new ObjectID(scimId),
|
||||
bearerToken: bearerToken,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
projectId: true,
|
||||
statusPageId: true,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (statusPageScimConfig) {
|
||||
// Store Status Page SCIM configuration
|
||||
oneuptimeRequest.bearerTokenData = {
|
||||
scimConfig: statusPageScimConfig,
|
||||
projectId: statusPageScimConfig.projectId,
|
||||
statusPageId: statusPageScimConfig.statusPageId,
|
||||
statusPageScimId: new ObjectID(scimId),
|
||||
type: "status-page-scim",
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
// If neither found, throw error
|
||||
throw new NotAuthorizedException(
|
||||
"Invalid bearer token or SCIM configuration not found",
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { MessageBlocksByWorkspaceType } from "./WorkspaceNotificationRuleService
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import MetricType from "../../Models/DatabaseModels/MetricType";
|
||||
import Dictionary from "../../Types/Dictionary";
|
||||
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -272,6 +273,7 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("currentAlertStateId is required");
|
||||
}
|
||||
|
||||
// Get alert data for feed creation
|
||||
const alert: Model | null = await this.findOneById({
|
||||
id: createdItem.id,
|
||||
select: {
|
||||
@@ -304,147 +306,258 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("Alert not found");
|
||||
}
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await AlertWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id!,
|
||||
alertNumber: createdItem.alertNumber!,
|
||||
});
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(this.createAlertFeedAsync(alert, createdItem));
|
||||
|
||||
logger.debug("Alert created. Workspace result:");
|
||||
logger.debug(workspaceResult);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update alert with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Alert ${createdItem.alertNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
if (alert.currentAlertState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Alert State**: ${alert.currentAlertState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.alertSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${alert.alertSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.monitor) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
const monitor: Monitor = alert.monitor;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
const alertCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await AlertWorkspaceMessages.getAlertCreateMessageBlocks({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
});
|
||||
|
||||
await AlertFeedService.createAlertFeedItem({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
alertFeedEventType: AlertFeedEventType.AlertCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: alertCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.changeAlertState({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id,
|
||||
alertStateId: createdItem.currentAlertStateId,
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// add owners.
|
||||
// Handle state change asynchronously
|
||||
coreOperations.push(this.handleAlertStateChangeAsync(createdItem));
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
for (const policy of createdItem.onCallDutyPolicies) {
|
||||
await OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy._id as string),
|
||||
{
|
||||
triggeredByAlertId: createdItem.id!,
|
||||
userNotificationEventType: UserNotificationEventType.AlertCreated,
|
||||
},
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in AlertService.onCreateSuccess: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle on-call duty policies asynchronously
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
this.executeAlertOnCallDutyPoliciesAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleAlertWorkspaceOperationsAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in AlertService core operations: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleAlertWorkspaceOperationsAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for workspace operations",
|
||||
);
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await AlertWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id,
|
||||
alertNumber: createdItem.alertNumber!,
|
||||
});
|
||||
|
||||
logger.debug("Alert created. Workspace result:");
|
||||
logger.debug(workspaceResult);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update alert with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels:
|
||||
workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleAlertWorkspaceOperationsAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createAlertFeedAsync(
|
||||
alert: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Alert ${createdItem.alertNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
if (alert.currentAlertState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Alert State**: ${alert.currentAlertState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.alertSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${alert.alertSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.monitor) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
const monitor: Monitor = alert.monitor;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
const alertCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await AlertWorkspaceMessages.getAlertCreateMessageBlocks({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
});
|
||||
|
||||
await AlertFeedService.createAlertFeedItem({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
alertFeedEventType: AlertFeedEventType.AlertCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: alertCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in createAlertFeedAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleAlertStateChangeAsync(createdItem: Model): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for state change",
|
||||
);
|
||||
}
|
||||
|
||||
await this.changeAlertState({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id,
|
||||
alertStateId: createdItem.currentAlertStateId!,
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleAlertStateChangeAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async executeAlertOnCallDutyPoliciesAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
// Execute all on-call policies in parallel
|
||||
const policyPromises: Promise<void>[] =
|
||||
createdItem.onCallDutyPolicies.map((policy: OnCallDutyPolicy) => {
|
||||
return OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy["_id"] as string),
|
||||
{
|
||||
triggeredByAlertId: createdItem.id!,
|
||||
userNotificationEventType:
|
||||
UserNotificationEventType.AlertCreated,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(policyPromises);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in executeAlertOnCallDutyPoliciesAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getWorkspaceChannelForAlert(data: {
|
||||
alertId: ObjectID;
|
||||
|
||||
@@ -59,9 +59,10 @@ import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
||||
import { MessageBlocksByWorkspaceType } from "./WorkspaceNotificationRuleService";
|
||||
import NotificationRuleWorkspaceChannel from "../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import { Dictionary } from "lodash";
|
||||
import MetricType from "../../Models/DatabaseModels/MetricType";
|
||||
import UpdateBy from "../Types/Database/UpdateBy";
|
||||
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
|
||||
import Dictionary from "../../Types/Dictionary";
|
||||
|
||||
// key is incidentId for this dictionary.
|
||||
type UpdateCarryForward = Dictionary<{
|
||||
@@ -544,6 +545,7 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("id is required");
|
||||
}
|
||||
|
||||
// Get incident data for feed creation
|
||||
const incident: Model | null = await this.findOneById({
|
||||
id: createdItem.id,
|
||||
select: {
|
||||
@@ -576,202 +578,343 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("Incident not found");
|
||||
}
|
||||
|
||||
// release the mutex.
|
||||
if (onCreate.carryForward && onCreate.carryForward.mutex) {
|
||||
const mutex: SemaphoreMutex = onCreate.carryForward.mutex;
|
||||
const projectId: ObjectID = createdItem.projectId!;
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
try {
|
||||
await Semaphore.release(mutex);
|
||||
logger.debug(
|
||||
"Mutex released - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(this.createIncidentFeedAsync(incident, createdItem));
|
||||
|
||||
// Handle state change asynchronously
|
||||
coreOperations.push(this.handleIncidentStateChangeAsync(createdItem));
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle monitor status change and active monitoring asynchronously
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
coreOperations.push(
|
||||
this.handleMonitorStatusChangeAsync(createdItem, onCreate),
|
||||
);
|
||||
}
|
||||
|
||||
coreOperations.push(
|
||||
this.disableActiveMonitoringIfManualIncident(createdItem.id!),
|
||||
);
|
||||
|
||||
// Release mutex immediately
|
||||
this.releaseMutexAsync(onCreate, createdItem.projectId!);
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in IncidentService.onCreateSuccess: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle on-call duty policies asynchronously
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
this.executeOnCallDutyPoliciesAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleIncidentWorkspaceOperationsAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in IncidentService core operations: ${error}`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
"Mutex release failed - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleIncidentWorkspaceOperationsAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for workspace operations",
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await IncidentWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id,
|
||||
incidentNumber: createdItem.incidentNumber!,
|
||||
});
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update incident with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels:
|
||||
workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleIncidentWorkspaceOperationsAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
@CaptureSpan()
|
||||
private async createIncidentFeedAsync(
|
||||
incident: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await IncidentWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id!,
|
||||
incidentNumber: createdItem.incidentNumber!,
|
||||
});
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update incident with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Incident ${createdItem.incidentNumber?.toString()} Created:
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Incident ${createdItem.incidentNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
if (incident.currentIncidentState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Incident State**: ${incident.currentIncidentState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (incident.incidentSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${incident.incidentSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (incident.monitors && incident.monitors.length > 0) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of incident.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
if (incident.currentIncidentState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Incident State**: ${incident.currentIncidentState.name} \n\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
if (incident.incidentSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${incident.incidentSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
if (incident.monitors && incident.monitors.length > 0) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of incident.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const incidentCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await IncidentWorkspaceMessages.getIncidentCreateMessageBlocks({
|
||||
const incidentCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await IncidentWorkspaceMessages.getIncidentCreateMessageBlocks({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
});
|
||||
|
||||
await IncidentFeedService.createIncidentFeedItem({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
incidentFeedEventType: IncidentFeedEventType.IncidentCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: incidentCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
await IncidentFeedService.createIncidentFeedItem({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
incidentFeedEventType: IncidentFeedEventType.IncidentCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: incidentCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdItem.currentIncidentStateId) {
|
||||
throw new BadDataException("currentIncidentStateId is required");
|
||||
} catch (error) {
|
||||
logger.error(`Error in createIncidentFeedAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
// change status of all the monitors.
|
||||
await MonitorService.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
createdItem.monitors?.map((monitor: Monitor) => {
|
||||
return new ObjectID(monitor._id || "");
|
||||
}) || [],
|
||||
createdItem.changeMonitorStatusToId,
|
||||
true, // notifyMonitorOwners
|
||||
createdItem.rootCause ||
|
||||
"Status was changed because Incident #" +
|
||||
createdItem.incidentNumber?.toString() +
|
||||
" was created.",
|
||||
createdItem.createdStateLog,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
|
||||
await this.changeIncidentState({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id,
|
||||
incidentStateId: createdItem.currentIncidentStateId,
|
||||
shouldNotifyStatusPageSubscribers: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
),
|
||||
isSubscribersNotified: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
), // we dont want to notify subscribers when incident state changes because they are already notified when the incident is created.
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// add owners.
|
||||
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
for (const policy of createdItem.onCallDutyPolicies) {
|
||||
await OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy._id as string),
|
||||
{
|
||||
triggeredByIncidentId: createdItem.id!,
|
||||
userNotificationEventType:
|
||||
UserNotificationEventType.IncidentCreated,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// check if the incident is created manaull by a user and if thats the case, then disable active monitoting on that monitor.
|
||||
|
||||
await this.disableActiveMonitoringIfManualIncident(createdItem.id!);
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleIncidentStateChangeAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.currentIncidentStateId) {
|
||||
throw new BadDataException("currentIncidentStateId is required");
|
||||
}
|
||||
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for state change",
|
||||
);
|
||||
}
|
||||
|
||||
await this.changeIncidentState({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id,
|
||||
incidentStateId: createdItem.currentIncidentStateId,
|
||||
shouldNotifyStatusPageSubscribers: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
),
|
||||
isSubscribersNotified: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
), // we dont want to notify subscribers when incident state changes because they are already notified when the incident is created.
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleIncidentStateChangeAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async executeOnCallDutyPoliciesAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
// Execute all on-call policies in parallel
|
||||
const policyPromises: Promise<void>[] =
|
||||
createdItem.onCallDutyPolicies.map((policy: OnCallDutyPolicy) => {
|
||||
return OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy["_id"] as string),
|
||||
{
|
||||
triggeredByIncidentId: createdItem.id!,
|
||||
userNotificationEventType:
|
||||
UserNotificationEventType.IncidentCreated,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(policyPromises);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in executeOnCallDutyPoliciesAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleMonitorStatusChangeAsync(
|
||||
createdItem: Model,
|
||||
onCreate: OnCreate<Model>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
// change status of all the monitors.
|
||||
await MonitorService.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
createdItem.monitors?.map((monitor: Monitor) => {
|
||||
return new ObjectID(monitor._id || "");
|
||||
}) || [],
|
||||
createdItem.changeMonitorStatusToId,
|
||||
true, // notifyMonitorOwners
|
||||
createdItem.rootCause ||
|
||||
"Status was changed because Incident #" +
|
||||
createdItem.incidentNumber?.toString() +
|
||||
" was created.",
|
||||
createdItem.createdStateLog,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleMonitorStatusChangeAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private releaseMutexAsync(
|
||||
onCreate: OnCreate<Model>,
|
||||
projectId: ObjectID,
|
||||
): void {
|
||||
// Release mutex in background without blocking
|
||||
if (onCreate.carryForward && onCreate.carryForward.mutex) {
|
||||
const mutex: SemaphoreMutex = onCreate.carryForward.mutex;
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await Semaphore.release(mutex);
|
||||
logger.debug(
|
||||
"Mutex released - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
"Mutex release failed - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async disableActiveMonitoringIfManualIncident(
|
||||
incidentId: ObjectID,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -116,8 +116,9 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
throw new BadDataException("incidentStateId is null");
|
||||
}
|
||||
|
||||
const stateBeforeThis: IncidentStateTimeline | null =
|
||||
await this.findOneBy({
|
||||
// Execute queries for before and after states in parallel for better performance
|
||||
const [stateBeforeThis, stateAfterThis] = await Promise.all([
|
||||
this.findOneBy({
|
||||
query: {
|
||||
incidentId: createBy.data.incidentId,
|
||||
startsAt: QueryHelper.lessThanEqualTo(createBy.data.startsAt),
|
||||
@@ -138,7 +139,25 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
this.findOneBy({
|
||||
query: {
|
||||
incidentId: createBy.data.incidentId,
|
||||
startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
|
||||
},
|
||||
sort: {
|
||||
startsAt: SortOrder.Ascending,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
incidentStateId: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.debug("State Before this");
|
||||
logger.debug(stateBeforeThis);
|
||||
@@ -197,26 +216,6 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
}
|
||||
}
|
||||
|
||||
const stateAfterThis: IncidentStateTimeline | null = await this.findOneBy(
|
||||
{
|
||||
query: {
|
||||
incidentId: createBy.data.incidentId,
|
||||
startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
|
||||
},
|
||||
sort: {
|
||||
startsAt: SortOrder.Ascending,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
incidentStateId: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// compute ends at. It's the start of the next status.
|
||||
if (stateAfterThis && stateAfterThis.startsAt) {
|
||||
createBy.data.endsAt = stateAfterThis.startsAt;
|
||||
|
||||
@@ -502,20 +502,132 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
// Parallelize operations that don't depend on each other
|
||||
const parallelOperations: Array<Promise<any>> = [];
|
||||
|
||||
// 1. Essential monitor status operation (must complete first)
|
||||
await this.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
[createdItem.id],
|
||||
createdItem.currentMonitorStatusId,
|
||||
false, // notifyOwners = false
|
||||
"This status was created when the monitor was created.",
|
||||
undefined,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
|
||||
// 2. Start core operations in parallel that can run asynchronously (excluding workspace operations)
|
||||
|
||||
// Add default probes if needed (can be slow with many probes)
|
||||
if (
|
||||
createdItem.monitorType &&
|
||||
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
|
||||
) {
|
||||
parallelOperations.push(
|
||||
this.addDefaultProbesToMonitor(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in adding default probes");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to probe creation issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Billing operations
|
||||
if (IsBillingEnabled) {
|
||||
parallelOperations.push(
|
||||
ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
|
||||
createdItem.projectId,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in billing operations");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to billing issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Owner operations
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
parallelOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in adding owners");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to owner issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Probe status refresh (can be expensive with many probes)
|
||||
parallelOperations.push(
|
||||
this.refreshMonitorProbeStatus(createdItem.id).catch((error: Error) => {
|
||||
logger.error("Error in refreshing probe status");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to probe status issues
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for core operations to complete, then handle workspace operations
|
||||
Promise.allSettled(parallelOperations)
|
||||
.then(() => {
|
||||
// Handle workspace operations after core operations complete
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleWorkspaceOperationsAsync({
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: createdItem.id!,
|
||||
monitorName: createdItem.name!,
|
||||
feedInfoInMarkdown,
|
||||
createdByUserId,
|
||||
}).catch((error: Error) => {
|
||||
logger.error("Error in workspace operations");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to workspace issues
|
||||
});
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error("Error in parallel monitor creation operations");
|
||||
logger.error(error);
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleWorkspaceOperationsAsync(data: {
|
||||
projectId: ObjectID;
|
||||
monitorId: ObjectID;
|
||||
monitorName: string;
|
||||
feedInfoInMarkdown: string;
|
||||
createdByUserId: ObjectID | undefined | null;
|
||||
}): Promise<void> {
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await MonitorWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
monitorId: createdItem.id!,
|
||||
monitorName: createdItem.name!,
|
||||
projectId: data.projectId,
|
||||
monitorId: data.monitorId,
|
||||
monitorName: data.monitorName,
|
||||
});
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update incident with these channels.
|
||||
// update monitor with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
id: data.monitorId,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
@@ -527,72 +639,22 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
|
||||
const monitorCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await MonitorWorkspaceMessages.getMonitorCreateMessageBlocks({
|
||||
monitorId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: data.monitorId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
await MonitorFeedService.createMonitorFeedItem({
|
||||
monitorId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: data.monitorId,
|
||||
projectId: data.projectId,
|
||||
monitorFeedEventType: MonitorFeedEventType.MonitorCreated,
|
||||
displayColor: Green500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
feedInfoInMarkdown: data.feedInfoInMarkdown,
|
||||
userId: data.createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: monitorCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
[createdItem.id],
|
||||
createdItem.currentMonitorStatusId,
|
||||
false, // notifyOwners = false
|
||||
"This status was created when the monitor was created.",
|
||||
undefined,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
|
||||
if (
|
||||
createdItem.monitorType &&
|
||||
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
|
||||
) {
|
||||
await this.addDefaultProbesToMonitor(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (IsBillingEnabled) {
|
||||
await ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
|
||||
createdItem.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
// add owners.
|
||||
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
|
||||
// refresh probe status.
|
||||
await this.refreshMonitorProbeStatus(createdItem.id);
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -761,21 +823,32 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
|
||||
const totalProbes: Array<Probe> = [...globalProbes, ...projectProbes];
|
||||
|
||||
if (totalProbes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create all monitor probes in parallel for better performance
|
||||
const createPromises: Array<Promise<MonitorProbe>> = [];
|
||||
|
||||
for (const probe of totalProbes) {
|
||||
const monitorProbe: MonitorProbe = new MonitorProbe();
|
||||
|
||||
monitorProbe.monitorId = monitorId;
|
||||
monitorProbe.probeId = probe.id!;
|
||||
monitorProbe.projectId = projectId;
|
||||
monitorProbe.isEnabled = true;
|
||||
|
||||
await MonitorProbeService.create({
|
||||
data: monitorProbe,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
createPromises.push(
|
||||
MonitorProbeService.create({
|
||||
data: monitorProbe,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute all creates in parallel
|
||||
await Promise.all(createPromises);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
import {
|
||||
IsBillingEnabled,
|
||||
NotificationSlackWebhookOnSubscriptionUpdate,
|
||||
} from "../EnvironmentConfig";
|
||||
import logger from "../Utils/Logger";
|
||||
import BaseService from "./BaseService";
|
||||
import BillingService from "./BillingService";
|
||||
@@ -7,6 +10,9 @@ import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Project from "../../Models/DatabaseModels/Project";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import SlackUtil from "../Utils/Workspace/Slack/Slack";
|
||||
import URL from "../../Types/API/URL";
|
||||
import Exception from "../../Types/Exception/Exception";
|
||||
|
||||
export class NotificationService extends BaseService {
|
||||
public constructor() {
|
||||
@@ -105,6 +111,17 @@ export class NotificationService extends BaseService {
|
||||
} USD.`,
|
||||
);
|
||||
|
||||
// Send Slack notification for balance refill
|
||||
this.sendBalanceRefillSlackNotification({
|
||||
project: project,
|
||||
amountInUSD: amountInUSD,
|
||||
currentBalanceInUSD: updatedAmount / 100,
|
||||
}).catch((error: Exception) => {
|
||||
logger.error(
|
||||
"Error sending slack message for balance refill: " + error,
|
||||
);
|
||||
});
|
||||
|
||||
project.smsOrCallCurrentBalanceInUSDCents = updatedAmount;
|
||||
|
||||
return updatedAmount;
|
||||
@@ -194,6 +211,34 @@ export class NotificationService extends BaseService {
|
||||
|
||||
return project?.smsOrCallCurrentBalanceInUSDCents || 0;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async sendBalanceRefillSlackNotification(data: {
|
||||
project: Project;
|
||||
amountInUSD: number;
|
||||
currentBalanceInUSD: number;
|
||||
}): Promise<void> {
|
||||
const { project, amountInUSD, currentBalanceInUSD } = data;
|
||||
|
||||
if (NotificationSlackWebhookOnSubscriptionUpdate) {
|
||||
const slackMessage: string = `*SMS and Call Balance Refilled:*
|
||||
*Project Name:* ${project.name?.toString() || "N/A"}
|
||||
*Project ID:* ${project.id?.toString() || "N/A"}
|
||||
*Refill Amount:* $${amountInUSD} USD
|
||||
*Current Balance:* $${currentBalanceInUSD} USD
|
||||
|
||||
${project.createdOwnerName && project.createdOwnerEmail ? `*Project Created By:* ${project.createdOwnerName.toString()} (${project.createdOwnerEmail.toString()})` : ""}`;
|
||||
|
||||
SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(NotificationSlackWebhookOnSubscriptionUpdate),
|
||||
text: slackMessage,
|
||||
}).catch((error: Exception) => {
|
||||
logger.error(
|
||||
"Error sending slack message for balance refill: " + error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotificationService();
|
||||
|
||||
27
Common/Server/Services/ProjectSCIMService.ts
Normal file
27
Common/Server/Services/ProjectSCIMService.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate } from "../Types/Database/Hooks";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/ProjectSCIM";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<Model>,
|
||||
): Promise<OnCreate<Model>> {
|
||||
if (!createBy.data.bearerToken) {
|
||||
// Generate a secure bearer token if not provided
|
||||
createBy.data.bearerToken = ObjectID.generate().toString();
|
||||
}
|
||||
|
||||
return {
|
||||
createBy: createBy,
|
||||
carryForward: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -547,36 +547,7 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
onCreate: OnCreate<Model>,
|
||||
createdItem: Model,
|
||||
): Promise<Model> {
|
||||
// create new scheduled maintenance state timeline.
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await ScheduledMaintenanceWorkspaceMessages.createChannelsAndInviteUsersToChannels(
|
||||
{
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
scheduledMaintenanceNumber: createdItem.scheduledMaintenanceNumber!,
|
||||
},
|
||||
);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update scheduledMaintenance with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get scheduled maintenance data for feed creation
|
||||
const scheduledMaintenance: Model | null = await this.findOneById({
|
||||
id: createdItem.id!,
|
||||
select: {
|
||||
@@ -606,83 +577,23 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
throw new BadDataException("Scheduled Maintenance not found");
|
||||
}
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${createdItem.scheduledMaintenanceNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// add starts at and ends at.
|
||||
if (scheduledMaintenance.startsAt) {
|
||||
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.endsAt) {
|
||||
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
|
||||
feedInfoInMarkdown += `⏳ **Scheduled Maintenance State**: ${scheduledMaintenance.currentScheduledMaintenanceState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (
|
||||
scheduledMaintenance.monitors &&
|
||||
scheduledMaintenance.monitors.length > 0
|
||||
) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of scheduledMaintenance.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
|
||||
{
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
},
|
||||
);
|
||||
|
||||
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceFeedEventType:
|
||||
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: scheduledMaintenanceCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
const timeline: ScheduledMaintenanceStateTimeline =
|
||||
new ScheduledMaintenanceStateTimeline();
|
||||
timeline.projectId = createdItem.projectId!;
|
||||
timeline.scheduledMaintenanceId = createdItem.id!;
|
||||
timeline.isOwnerNotified = true; // ignore notifying owners because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(
|
||||
this.createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance,
|
||||
createdItem,
|
||||
),
|
||||
);
|
||||
timeline.isStatusPageSubscribersNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
); // ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.scheduledMaintenanceStateId =
|
||||
createdItem.currentScheduledMaintenanceStateId!;
|
||||
|
||||
await ScheduledMaintenanceStateTimelineService.create({
|
||||
data: timeline,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
// Create state timeline asynchronously
|
||||
coreOperations.push(
|
||||
this.createScheduledMaintenanceStateTimelineAsync(createdItem),
|
||||
);
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
createdItem.projectId &&
|
||||
createdItem.id &&
|
||||
@@ -690,21 +601,200 @@ ${createdItem.description || "No description provided."}
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in ScheduledMaintenanceService.onCreateSuccess: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleScheduledMaintenanceWorkspaceOperationsAsync(
|
||||
createdItem,
|
||||
).catch((error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in ScheduledMaintenanceService core operations: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleScheduledMaintenanceWorkspaceOperationsAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for workspace operations",
|
||||
);
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await ScheduledMaintenanceWorkspaceMessages.createChannelsAndInviteUsersToChannels(
|
||||
{
|
||||
projectId: createdItem.projectId,
|
||||
scheduledMaintenanceId: createdItem.id,
|
||||
scheduledMaintenanceNumber: createdItem.scheduledMaintenanceNumber!,
|
||||
},
|
||||
);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update scheduledMaintenance with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels:
|
||||
workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in handleScheduledMaintenanceWorkspaceOperationsAsync: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${createdItem.scheduledMaintenanceNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
// add starts at and ends at.
|
||||
if (scheduledMaintenance.startsAt) {
|
||||
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.endsAt) {
|
||||
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
|
||||
feedInfoInMarkdown += `⏳ **Scheduled Maintenance State**: ${scheduledMaintenance.currentScheduledMaintenanceState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (
|
||||
scheduledMaintenance.monitors &&
|
||||
scheduledMaintenance.monitors.length > 0
|
||||
) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of scheduledMaintenance.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
|
||||
{
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
},
|
||||
);
|
||||
|
||||
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceFeedEventType:
|
||||
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: scheduledMaintenanceCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in createScheduledMaintenanceFeedAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createScheduledMaintenanceStateTimelineAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const timeline: ScheduledMaintenanceStateTimeline =
|
||||
new ScheduledMaintenanceStateTimeline();
|
||||
timeline.projectId = createdItem.projectId!;
|
||||
timeline.scheduledMaintenanceId = createdItem.id!;
|
||||
timeline.isOwnerNotified = true; // ignore notifying owners because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
);
|
||||
timeline.isStatusPageSubscribersNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
); // ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.scheduledMaintenanceStateId =
|
||||
createdItem.currentScheduledMaintenanceStateId!;
|
||||
|
||||
await ScheduledMaintenanceStateTimelineService.create({
|
||||
data: timeline,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in createScheduledMaintenanceStateTimelineAsync: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async addOwners(
|
||||
projectId: ObjectID,
|
||||
|
||||
27
Common/Server/Services/StatusPageSCIMService.ts
Normal file
27
Common/Server/Services/StatusPageSCIMService.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate } from "../Types/Database/Hooks";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/StatusPageSCIM";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<Model>,
|
||||
): Promise<OnCreate<Model>> {
|
||||
if (!createBy.data.bearerToken) {
|
||||
// Generate a secure bearer token if not provided
|
||||
createBy.data.bearerToken = ObjectID.generate().toString();
|
||||
}
|
||||
|
||||
return {
|
||||
createBy: createBy,
|
||||
carryForward: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -194,8 +194,7 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
onCreate: OnCreate<StatusPage>,
|
||||
createdItem: StatusPage,
|
||||
): Promise<StatusPage> {
|
||||
// add owners.
|
||||
|
||||
// Execute owner assignment asynchronously
|
||||
if (
|
||||
createdItem.projectId &&
|
||||
createdItem.id &&
|
||||
@@ -203,16 +202,19 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
// Run owner assignment in background without blocking
|
||||
this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
(onCreate.createBy.miscDataProps!["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
(onCreate.createBy.miscDataProps!["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
).catch((error: Error) => {
|
||||
logger.error(`Error in StatusPageService owner assignment: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
return createdItem;
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class IncomingRequestCriteria {
|
||||
input.dataToProcess.monitorId.toString() +
|
||||
" is true",
|
||||
);
|
||||
return `Incoming request / heartbeat received in ${value} minutes.`;
|
||||
return `Incoming request / heartbeat received in ${value} minutes. It was received ${differenceInMinutes} minutes ago.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export default class IncomingRequestCriteria {
|
||||
input.dataToProcess.monitorId.toString() +
|
||||
" is true",
|
||||
);
|
||||
return `Incoming request / heartbeat not received in ${value} minutes.`;
|
||||
return `Incoming request / heartbeat not received in ${value} minutes. It was received ${differenceInMinutes} minutes ago.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -56,9 +56,13 @@ export default class ServerMonitorCriteria {
|
||||
const lastCheckTime: Date = (input.dataToProcess as ServerMonitorResponse)
|
||||
.requestReceivedAt;
|
||||
|
||||
const timeNow: Date =
|
||||
(input.dataToProcess as ServerMonitorResponse).timeNow ||
|
||||
OneUptimeDate.getCurrentDate();
|
||||
|
||||
const differenceInMinutes: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
lastCheckTime,
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
timeNow,
|
||||
);
|
||||
|
||||
let offlineIfNotCheckedInMinutes: number = 3;
|
||||
|
||||
@@ -228,6 +228,8 @@ export default class MonitorResourceUtil {
|
||||
await MonitorService.updateOneById({
|
||||
id: monitor.id!,
|
||||
data: {
|
||||
incomingRequestMonitorHeartbeatCheckedAt:
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
incomingMonitorRequest: {
|
||||
...dataToProcess,
|
||||
} as any,
|
||||
@@ -1372,7 +1374,7 @@ export default class MonitorResourceUtil {
|
||||
}
|
||||
|
||||
if (input.monitor.monitorType === MonitorType.SSLCertificate) {
|
||||
// check server monitor
|
||||
// check SSL monitor
|
||||
const sslMonitorResult: string | null =
|
||||
await SSLMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
||||
dataToProcess: input.dataToProcess,
|
||||
|
||||
@@ -89,6 +89,16 @@ app.set("view engine", "ejs");
|
||||
* https://stackoverflow.com/questions/19917401/error-request-entity-too-large
|
||||
*/
|
||||
|
||||
// Handle SCIM content type before JSON middleware
|
||||
app.use((req: ExpressRequest, _res: ExpressResponse, next: NextFunction) => {
|
||||
const contentType: string | undefined = req.headers["content-type"];
|
||||
if (contentType && contentType.includes("application/scim+json")) {
|
||||
// Set content type to application/json so express.json() can parse it
|
||||
req.headers["content-type"] = "application/json";
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req: OneUptimeRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
if (req.headers["content-encoding"] === "gzip") {
|
||||
const buffers: any = [];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Dictionary } from "lodash";
|
||||
import { JSONArray, JSONObject, JSONValue } from "../../../Types/JSON";
|
||||
import ObjectID from "../../../Types/ObjectID";
|
||||
import TelemetryType from "../../../Types/Telemetry/TelemetryType";
|
||||
@@ -9,6 +8,7 @@ import logger from "../Logger";
|
||||
import MetricType from "../../../Models/DatabaseModels/MetricType";
|
||||
import MetricTypeService from "../../Services/MetricTypeService";
|
||||
import TelemetryService from "../../../Models/DatabaseModels/TelemetryService";
|
||||
import Dictionary from "../../../Types/Dictionary";
|
||||
|
||||
export type AttributeType = string | number | boolean | null;
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import SlackActionType from "./ActionTypes";
|
||||
import WorkspaceProjectAuthTokenService from "../../../../Services/WorkspaceProjectAuthTokenService";
|
||||
import logger from "../../../Logger";
|
||||
import { JSONArray, JSONObject } from "../../../../../Types/JSON";
|
||||
import { Dictionary } from "lodash";
|
||||
import CaptureSpan from "../../../Telemetry/CaptureSpan";
|
||||
import Dictionary from "../../../../../Types/Dictionary";
|
||||
|
||||
export interface SlackAction {
|
||||
actionValue?: string | undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dictionary } from "lodash";
|
||||
import DatabaseProperty from "../Database/DatabaseProperty";
|
||||
import Dictionary from "../Dictionary";
|
||||
import BadDataException from "../Exception/BadDataException";
|
||||
import { JSONObject, ObjectType } from "../JSON";
|
||||
import { FindOperator } from "typeorm";
|
||||
|
||||
@@ -16,4 +16,5 @@ export default interface ServerMonitorResponse {
|
||||
onlyCheckRequestReceivedAt: boolean;
|
||||
processes?: ServerProcess[] | undefined;
|
||||
failureCause?: string | undefined;
|
||||
timeNow?: Date | undefined; // Time when the response was generated
|
||||
}
|
||||
|
||||
@@ -27,4 +27,5 @@ export default interface ProbeMonitorResponse {
|
||||
customCodeMonitorResponse?: CustomCodeMonitorResponse | undefined;
|
||||
monitoredAt: Date;
|
||||
isTimeout?: boolean | undefined;
|
||||
ingestedAt?: Date | undefined;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import OneUptimeDate from "../../../Types/Date";
|
||||
import Dictionary from "../../../Types/Dictionary";
|
||||
import BadDataException from "../../../Types/Exception/BadDataException";
|
||||
import GenericObject from "../../../Types/GenericObject";
|
||||
import get from "lodash/get";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
|
||||
export interface ComponentProps<T extends GenericObject> {
|
||||
@@ -144,6 +143,16 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
||||
|
||||
type GetFieldFunction = (field: Field<T>, index: number) => ReactElement;
|
||||
|
||||
// Helper function to get nested property values using dot notation
|
||||
const getNestedValue: (obj: any, path: string) => any = (
|
||||
obj: any,
|
||||
path: string,
|
||||
): any => {
|
||||
return path.split(".").reduce((current: any, key: string) => {
|
||||
return current?.[key];
|
||||
}, obj);
|
||||
};
|
||||
|
||||
const getField: GetFieldFunction = (
|
||||
field: Field<T>,
|
||||
index: number,
|
||||
@@ -160,8 +169,11 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
||||
|
||||
let data: string | ReactElement = "";
|
||||
|
||||
if (get(props.item, fieldKey)) {
|
||||
data = (get(props.item, fieldKey, "") as any) || "";
|
||||
// Use helper function for both simple and nested property access
|
||||
const fieldKeyStr: string = String(fieldKey);
|
||||
const value: any = getNestedValue(props.item, fieldKeyStr);
|
||||
if (value !== undefined && value !== null) {
|
||||
data = value;
|
||||
}
|
||||
|
||||
if (field.fieldType === FieldType.Date) {
|
||||
|
||||
@@ -24,11 +24,11 @@ import API from "../../Utils/API/API";
|
||||
import { APP_API_URL } from "../../Config";
|
||||
import PageLoader from "../Loader/PageLoader";
|
||||
import ErrorMessage from "../ErrorMessage/ErrorMessage";
|
||||
import { Dictionary } from "lodash";
|
||||
import TelemetryService from "../../../Models/DatabaseModels/TelemetryService";
|
||||
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
||||
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
|
||||
import ListResult from "../../../Types/BaseDatabase/ListResult";
|
||||
import Dictionary from "../../../Types/Dictionary";
|
||||
|
||||
export interface ComponentProps {
|
||||
logs: Array<Log>;
|
||||
|
||||
@@ -62,7 +62,7 @@ const Modal: FunctionComponent<ComponentProps> = (
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-end justify-center p-0 text-center md:items-center md:p-4">
|
||||
<div
|
||||
className={`relative transform bg-white text-left shadow-xl transition-all w-full h-full md:h-auto md:w-auto md:rounded-lg md:my-8 ${
|
||||
className={`relative transform bg-white text-left shadow-xl transition-all w-full h-full md:rounded-lg md:my-8 ${
|
||||
props.modalWidth && props.modalWidth === ModalWidth.Large
|
||||
? "md:max-w-7xl"
|
||||
: ""
|
||||
|
||||
@@ -64,7 +64,7 @@ const Page: FunctionComponent<ComponentProps> = (
|
||||
)}
|
||||
|
||||
{props.sideMenu && (
|
||||
<main className="mx-auto max-w-full pb-10 mr-5">
|
||||
<main className="mx-auto max-w-full pb-10">
|
||||
<div className="lg:grid lg:grid-cols-12 lg:gap-x-5">
|
||||
{props.sideMenu}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const Pagination: FunctionComponent<ComponentProps> = (
|
||||
className="flex items-center justify-between border-t border-gray-200 bg-white px-4"
|
||||
data-testid={props.dataTestId}
|
||||
>
|
||||
{/* Desktop layout: Description on left, all controls on right */}
|
||||
<div className="hidden md:block">
|
||||
<p className="text-sm text-gray-500">
|
||||
{!props.isLoading && (
|
||||
@@ -88,7 +89,9 @@ const Pagination: FunctionComponent<ComponentProps> = (
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
{/* Desktop layout: All controls together on right */}
|
||||
<div className="hidden md:flex">
|
||||
<nav className="inline-flex -space-x-px rounded-md shadow-sm">
|
||||
<div className="my-2">
|
||||
<Button
|
||||
@@ -165,6 +168,84 @@ const Pagination: FunctionComponent<ComponentProps> = (
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Mobile layout: Navigate button on left, pagination controls on right */}
|
||||
<div className="md:hidden my-2">
|
||||
<Button
|
||||
dataTestId="show-pagination-modal-button-mobile"
|
||||
className="my-2"
|
||||
buttonSize={ButtonSize.ExtraSmall}
|
||||
icon={IconProp.AdjustmentHorizontal}
|
||||
buttonStyle={ButtonStyleType.ICON_LIGHT}
|
||||
onClick={() => {
|
||||
setShowPaginationModel(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<nav className="inline-flex -space-x-px rounded-md shadow-sm">
|
||||
<ul>
|
||||
<li
|
||||
onClick={() => {
|
||||
let currentPageNumber: number = props.currentPageNumber;
|
||||
|
||||
if (typeof currentPageNumber === "string") {
|
||||
currentPageNumber = parseInt(currentPageNumber);
|
||||
}
|
||||
|
||||
if (props.onNavigateToPage && !isPreviousDisabled) {
|
||||
props.onNavigateToPage(
|
||||
currentPageNumber - 1,
|
||||
props.itemsOnPage,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={` inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 ${
|
||||
isPreviousDisabled
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<span className="page-link">Previous</span>
|
||||
</li>
|
||||
<li
|
||||
data-testid="current-page-link-mobile"
|
||||
className={` z-10 inline-flex items-center border border-x-0 border-gray-300 hover:bg-gray-50 px-4 py-2 text-sm font-medium text-text-600 cursor-pointer ${
|
||||
isCurrentPageButtonDisabled ? "bg-gray-100" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setShowPaginationModel(true);
|
||||
}}
|
||||
>
|
||||
<span>{props.currentPageNumber}</span>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
let currentPageNumber: number = props.currentPageNumber;
|
||||
|
||||
if (typeof currentPageNumber === "string") {
|
||||
currentPageNumber = parseInt(currentPageNumber);
|
||||
}
|
||||
|
||||
if (props.onNavigateToPage && !isNextDisabled) {
|
||||
props.onNavigateToPage(
|
||||
currentPageNumber + 1,
|
||||
props.itemsOnPage,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={` inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 ${
|
||||
isNextDisabled
|
||||
? "bg-gray-100"
|
||||
: " hover:bg-gray-50 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<span>Next</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{showPaginationModel && (
|
||||
<BasicFormModal<PaginationNavigationItem>
|
||||
data-testid="pagination-modal"
|
||||
|
||||
@@ -11,7 +11,6 @@ import Color from "../../../Types/Color";
|
||||
import OneUptimeDate from "../../../Types/Date";
|
||||
import GenericObject from "../../../Types/GenericObject";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
import get from "lodash/get";
|
||||
import React, { ReactElement, useState, useEffect } from "react";
|
||||
import { Draggable, DraggableProvided } from "react-beautiful-dnd";
|
||||
import LongTextViewer from "../LongText/LongTextViewer";
|
||||
@@ -42,6 +41,16 @@ type TableRowFunction = <T extends GenericObject>(
|
||||
const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
props: ComponentProps<T>,
|
||||
): ReactElement => {
|
||||
// Helper function to get nested property values using dot notation
|
||||
const getNestedValue: (obj: any, path: string) => any = (
|
||||
obj: any,
|
||||
path: string,
|
||||
): any => {
|
||||
return path.split(".").reduce((current: any, key: string) => {
|
||||
return current?.[key];
|
||||
}, obj);
|
||||
};
|
||||
|
||||
const [isButtonLoading, setIsButtonLoading] = useState<Array<boolean>>(
|
||||
props.actionButtons?.map(() => {
|
||||
return false;
|
||||
@@ -240,7 +249,10 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
get(props.item, column.key, "")?.toString() ||
|
||||
getNestedValue(
|
||||
props.item,
|
||||
String(column.key),
|
||||
)?.toString() ||
|
||||
column.noValueMessage ||
|
||||
""
|
||||
)
|
||||
@@ -413,7 +425,10 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
get(props.item, column.key, "")?.toString() ||
|
||||
getNestedValue(
|
||||
props.item,
|
||||
String(column.key),
|
||||
)?.toString() ||
|
||||
column.noValueMessage ||
|
||||
""
|
||||
)
|
||||
|
||||
9
Common/package-lock.json
generated
9
Common/package-lock.json
generated
@@ -59,7 +59,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -118,7 +117,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
@@ -4535,13 +4533,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz",
|
||||
"integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "3.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz",
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
@@ -95,7 +94,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
|
||||
5
Copilot/package-lock.json
generated
5
Copilot/package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -76,7 +77,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -120,6 +120,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -134,7 +135,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
@@ -3257,6 +3257,7 @@
|
||||
},
|
||||
"../Common/node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"../Common/node_modules/lodash.memoize": {
|
||||
|
||||
4
Dashboard/.gitignore
vendored
4
Dashboard/.gitignore
vendored
@@ -19,6 +19,10 @@ node_modules
|
||||
|
||||
env.js
|
||||
|
||||
# Note: public/sw.js is auto-generated from sw.js.template during build
|
||||
# but should be committed to ensure it exists for deployments
|
||||
# The file is regenerated with correct versions during Docker build
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
5
Dashboard/package-lock.json
generated
5
Dashboard/package-lock.json
generated
@@ -62,6 +62,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -81,7 +82,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -125,6 +125,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -139,7 +140,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
@@ -3262,6 +3262,7 @@
|
||||
},
|
||||
"../Common/node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"../Common/node_modules/lodash.memoize": {
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"dev-build": "NODE_ENV=development node esbuild.config.js",
|
||||
"generate-sw": "node scripts/generate-sw.js",
|
||||
"dev-build": "npm run generate-sw && NODE_ENV=development node esbuild.config.js",
|
||||
"dev": "npx nodemon",
|
||||
"build": "NODE_ENV=production node esbuild.config.js",
|
||||
"analyze": "analyze=true NODE_ENV=production node esbuild.config.js",
|
||||
"build": "npm run generate-sw && NODE_ENV=production node esbuild.config.js",
|
||||
"analyze": "npm run generate-sw && analyze=true NODE_ENV=production node esbuild.config.js",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "echo 'esbuild does not require eject'",
|
||||
"compile": "tsc",
|
||||
"compile": "npm run generate-sw && tsc",
|
||||
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",
|
||||
"start": "node --require ts-node/register Serve.ts",
|
||||
"audit": "npm audit --audit-level=low",
|
||||
|
||||
21
Dashboard/scripts/generate-sw-dev.sh
Executable file
21
Dashboard/scripts/generate-sw-dev.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Development Service Worker Generation Script
|
||||
#
|
||||
# This script can be used during local development to test
|
||||
# the service worker generation with sample environment variables
|
||||
|
||||
echo "🔧 Generating service worker for local development..."
|
||||
|
||||
# Set sample environment variables for testing
|
||||
export APP_VERSION="1.0.0-dev"
|
||||
export GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "local-dev")
|
||||
|
||||
echo "Using APP_VERSION: $APP_VERSION"
|
||||
echo "Using GIT_SHA: $GIT_SHA"
|
||||
|
||||
# Generate the service worker
|
||||
node scripts/generate-sw.js
|
||||
|
||||
echo "✅ Service worker generated for development"
|
||||
echo "🔍 Check public/sw.js to see the generated file"
|
||||
22
Dashboard/scripts/generate-sw.js
Executable file
22
Dashboard/scripts/generate-sw.js
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Dashboard Service Worker Generator
|
||||
*
|
||||
* This script generates the Dashboard service worker from a template,
|
||||
* using the universal generator from Common/Scripts.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { generateServiceWorker } = require('../../Common/Scripts/generate-service-worker');
|
||||
|
||||
// Generate Dashboard service worker
|
||||
const templatePath = path.join(__dirname, '..', 'sw.js.template');
|
||||
const outputPath = path.join(__dirname, '..', 'public', 'sw.js');
|
||||
|
||||
try {
|
||||
generateServiceWorker(templatePath, outputPath, 'OneUptime Dashboard');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate Dashboard service worker:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const ProbePicker: FunctionComponent<ComponentProps> = (
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="w-fit mr-2 flex h-full align-middle items-center">
|
||||
<div className="w-fit mr-2 flex h-full align-middle items-center mt-4">
|
||||
<FieldLabelElement title="Select Probe:" required={true} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
402
Dashboard/src/Pages/Settings/SCIM.tsx
Normal file
402
Dashboard/src/Pages/Settings/SCIM.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import Banner from "Common/UI/Components/Banner/Banner";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import HiddenText from "Common/UI/Components/HiddenText/HiddenText";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Route from "Common/Types/API/Route";
|
||||
|
||||
const SCIMPage: FunctionComponent<PageComponentProps> = (
|
||||
_props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const [showSCIMUrlId, setShowSCIMUrlId] = useState<string>("");
|
||||
const [currentSCIMConfig, setCurrentSCIMConfig] =
|
||||
useState<ProjectSCIM | null>(null);
|
||||
const [refresher, setRefresher] = useState<boolean>(false);
|
||||
const [resetSCIMId, setResetSCIMId] = useState<string>("");
|
||||
const [showResetModal, setShowResetModal] = useState<boolean>(false);
|
||||
const [isResetLoading, setIsResetLoading] = useState<boolean>(false);
|
||||
const [resetError, setResetError] = useState<string>("");
|
||||
const [showResetErrorModal, setShowResetErrorModal] =
|
||||
useState<boolean>(false);
|
||||
const [showResetSuccessModal, setShowResetSuccessModal] =
|
||||
useState<boolean>(false);
|
||||
const [newBearerToken, setNewBearerToken] = useState<string>("");
|
||||
|
||||
const resetBearerToken: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsResetLoading(true);
|
||||
try {
|
||||
const newToken: ObjectID = ObjectID.generate();
|
||||
await ModelAPI.updateById<ProjectSCIM>({
|
||||
modelType: ProjectSCIM,
|
||||
id: new ObjectID(resetSCIMId),
|
||||
data: {
|
||||
bearerToken: newToken.toString(),
|
||||
},
|
||||
});
|
||||
setNewBearerToken(newToken.toString());
|
||||
setShowResetModal(false);
|
||||
setShowResetSuccessModal(true);
|
||||
setRefresher(!refresher);
|
||||
} catch (err) {
|
||||
setResetError(API.getFriendlyMessage(err));
|
||||
setShowResetErrorModal(true);
|
||||
setShowResetModal(false);
|
||||
}
|
||||
setIsResetLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Banner
|
||||
openInNewTab={true}
|
||||
title="Need help with configuring SCIM?"
|
||||
description="Learn more about SCIM (System for Cross-domain Identity Management) setup and configuration"
|
||||
link={Route.fromString("/docs/identity/scim")}
|
||||
hideOnMobile={true}
|
||||
/>
|
||||
|
||||
<ModelTable<ProjectSCIM>
|
||||
key={refresher.toString()}
|
||||
modelType={ProjectSCIM}
|
||||
userPreferencesKey={"project-scim-table"}
|
||||
query={{
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
}}
|
||||
id="scim-table"
|
||||
name="Settings > Project SCIM"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "SCIM (System for Cross-domain Identity Management)",
|
||||
description:
|
||||
"SCIM is an open standard for automating the exchange of user identity information between identity domains, or IT systems. Use SCIM to automatically provision and deprovision users from your identity provider.",
|
||||
}}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
id: "configuration",
|
||||
},
|
||||
{
|
||||
title: "Teams",
|
||||
id: "teams",
|
||||
},
|
||||
]}
|
||||
noItemsMessage={"No SCIM configuration found."}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
description:
|
||||
"Friendly name to help you remember this SCIM configuration.",
|
||||
placeholder: "Okta SCIM",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
description: "Optional description for this SCIM configuration.",
|
||||
placeholder:
|
||||
"SCIM configuration for automatic user provisioning from Okta",
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically create users when they are added in your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically remove users from teams when they are removed from your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
teams: true,
|
||||
},
|
||||
title: "Default Teams",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
dropdownModal: {
|
||||
type: Team,
|
||||
labelField: "name",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: false,
|
||||
description:
|
||||
"New users will be automatically added to these teams.",
|
||||
stepId: "teams",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
selectMoreFields={{
|
||||
bearerToken: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
teams: {
|
||||
name: true,
|
||||
_id: true,
|
||||
},
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
]}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "View SCIM URLs",
|
||||
buttonStyleType: ButtonStyleType.NORMAL,
|
||||
onClick: async (
|
||||
item: ProjectSCIM,
|
||||
onCompleteAction: () => void,
|
||||
_onError: (error: Error) => void,
|
||||
) => {
|
||||
onCompleteAction();
|
||||
setCurrentSCIMConfig(item);
|
||||
setShowSCIMUrlId(item.id?.toString() || "");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reset Bearer Token",
|
||||
buttonStyleType: ButtonStyleType.OUTLINE,
|
||||
icon: IconProp.Refresh,
|
||||
onClick: async (
|
||||
item: ProjectSCIM,
|
||||
onCompleteAction: () => void,
|
||||
_onError: (error: Error) => void,
|
||||
) => {
|
||||
onCompleteAction();
|
||||
setResetSCIMId(item.id?.toString() || "");
|
||||
setShowResetModal(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showSCIMUrlId && currentSCIMConfig && (
|
||||
<ConfirmModal
|
||||
title={`SCIM Configuration URLs`}
|
||||
description={
|
||||
<div>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Use these URLs to configure SCIM in your identity provider:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
SCIM Base URL:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}
|
||||
</code>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use this as the SCIM endpoint URL in your identity
|
||||
provider
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Service Provider Config URL:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}
|
||||
/ServiceProviderConfig
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Users Endpoint:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}/Users
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Groups Endpoint:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
{IDENTITY_URL.toString()}/scim/v2/{showSCIMUrlId}/Groups
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Unique identifier field for users:
|
||||
</p>
|
||||
<code className="block p-2 bg-gray-100 rounded text-sm break-all">
|
||||
userName
|
||||
</code>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use this field as the unique identifier for users in your
|
||||
identity provider SCIM configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="font-medium text-gray-700 mb-1">
|
||||
Bearer Token:
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<HiddenText
|
||||
text={currentSCIMConfig.bearerToken || ""}
|
||||
isCopyable={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Use this bearer token for authentication in your identity
|
||||
provider SCIM configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowSCIMUrlId("");
|
||||
setCurrentSCIMConfig(null);
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Bearer Token Modals */}
|
||||
{showResetModal && (
|
||||
<ConfirmModal
|
||||
title="Reset Bearer Token"
|
||||
description="Are you sure you want to reset the Bearer Token? You will need to update your identity provider with the new token."
|
||||
onSubmit={async () => {
|
||||
await resetBearerToken();
|
||||
}}
|
||||
isLoading={isResetLoading}
|
||||
onClose={() => {
|
||||
setShowResetModal(false);
|
||||
setResetSCIMId("");
|
||||
}}
|
||||
submitButtonText="Reset"
|
||||
submitButtonType={ButtonStyleType.DANGER}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResetErrorModal && (
|
||||
<ConfirmModal
|
||||
title="Reset Error"
|
||||
description={resetError}
|
||||
onSubmit={() => {
|
||||
setShowResetErrorModal(false);
|
||||
setResetError("");
|
||||
setResetSCIMId("");
|
||||
}}
|
||||
submitButtonText="Close"
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResetSuccessModal && (
|
||||
<ConfirmModal
|
||||
title="New Bearer Token"
|
||||
description={
|
||||
<div>
|
||||
<p className="mb-3">
|
||||
Your new Bearer Token has been generated:
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<HiddenText text={newBearerToken} isCopyable={true} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Please update your identity provider with this new token.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onSubmit={() => {
|
||||
setShowResetSuccessModal(false);
|
||||
setNewBearerToken("");
|
||||
setResetSCIMId("");
|
||||
}}
|
||||
submitButtonText="Close"
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCIMPage;
|
||||
@@ -398,6 +398,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => {
|
||||
},
|
||||
icon: IconProp.Lock,
|
||||
},
|
||||
{
|
||||
link: {
|
||||
title: "SCIM",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SETTINGS_SCIM] as Route,
|
||||
),
|
||||
},
|
||||
icon: IconProp.Refresh,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
381
Dashboard/src/Pages/StatusPages/View/SCIM.tsx
Normal file
381
Dashboard/src/Pages/StatusPages/View/SCIM.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { VoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Banner from "Common/UI/Components/Banner/Banner";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import HiddenText from "Common/UI/Components/HiddenText/HiddenText";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
const SCIMPage: FunctionComponent<PageComponentProps> = (
|
||||
_props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [showSCIMUrlId, setShowSCIMUrlId] = useState<string>("");
|
||||
const [currentSCIMConfig, setCurrentSCIMConfig] =
|
||||
useState<StatusPageSCIM | null>(null);
|
||||
const [refresher, setRefresher] = useState<boolean>(false);
|
||||
const [resetSCIMId, setResetSCIMId] = useState<string>("");
|
||||
const [showResetModal, setShowResetModal] = useState<boolean>(false);
|
||||
const [isResetLoading, setIsResetLoading] = useState<boolean>(false);
|
||||
const [resetError, setResetError] = useState<string>("");
|
||||
const [showResetErrorModal, setShowResetErrorModal] =
|
||||
useState<boolean>(false);
|
||||
const [showResetSuccessModal, setShowResetSuccessModal] =
|
||||
useState<boolean>(false);
|
||||
const [newBearerToken, setNewBearerToken] = useState<string>("");
|
||||
|
||||
const resetBearerToken: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsResetLoading(true);
|
||||
try {
|
||||
const newToken: ObjectID = ObjectID.generate();
|
||||
await ModelAPI.updateById<StatusPageSCIM>({
|
||||
modelType: StatusPageSCIM,
|
||||
id: new ObjectID(resetSCIMId),
|
||||
data: {
|
||||
bearerToken: newToken.toString(),
|
||||
},
|
||||
});
|
||||
setNewBearerToken(newToken.toString());
|
||||
setShowResetModal(false);
|
||||
setShowResetSuccessModal(true);
|
||||
setRefresher(!refresher);
|
||||
} catch (err) {
|
||||
setResetError(API.getFriendlyMessage(err));
|
||||
setShowResetErrorModal(true);
|
||||
setShowResetModal(false);
|
||||
}
|
||||
setIsResetLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Banner
|
||||
openInNewTab={true}
|
||||
title="Need help with configuring SCIM?"
|
||||
description="Learn more about SCIM (System for Cross-domain Identity Management) setup and configuration for Status Pages"
|
||||
link={URL.fromString("https://oneuptime.com/docs/status-page-scim")}
|
||||
hideOnMobile={true}
|
||||
/>
|
||||
|
||||
<ModelTable<StatusPageSCIM>
|
||||
key={refresher.toString()}
|
||||
modelType={StatusPageSCIM}
|
||||
userPreferencesKey={"status-page-scim-table"}
|
||||
query={{
|
||||
statusPageId: modelId,
|
||||
}}
|
||||
id="status-page-scim-table"
|
||||
name="Status Page > SCIM"
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: "SCIM (System for Cross-domain Identity Management)",
|
||||
description:
|
||||
"SCIM is an open standard for automating the exchange of user identity information between identity domains, or IT systems. Use SCIM to automatically provision and deprovision users with access to your private Status Page.",
|
||||
}}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
id: "configuration",
|
||||
},
|
||||
]}
|
||||
noItemsMessage={"No SCIM configuration found."}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
description:
|
||||
"Friendly name to help you remember this SCIM configuration.",
|
||||
placeholder: "Okta SCIM for Status Page",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
description: "Optional description for this SCIM configuration.",
|
||||
placeholder:
|
||||
"SCIM configuration for automatic user provisioning to the Status Page from Okta",
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically create users when they are added in your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
description:
|
||||
"Automatically remove users when they are removed from your identity provider.",
|
||||
stepId: "configuration",
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
filters={[]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
title: "Auto Provision Users",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
title: "Auto Deprovision Users",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
bearerToken: true,
|
||||
},
|
||||
title: "Bearer Token",
|
||||
type: FieldType.HiddenText,
|
||||
getElement: (item: StatusPageSCIM): ReactElement => {
|
||||
return (
|
||||
<HiddenText
|
||||
text={item["bearerToken"] as string}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
createdAt: true,
|
||||
},
|
||||
title: "Created",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "Show SCIM Endpoint URLs",
|
||||
buttonStyleType: ButtonStyleType.NORMAL,
|
||||
icon: IconProp.List,
|
||||
onClick: async (
|
||||
item: StatusPageSCIM,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
try {
|
||||
setCurrentSCIMConfig(item);
|
||||
setShowSCIMUrlId(item["_id"] as string);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reset Bearer Token",
|
||||
buttonStyleType: ButtonStyleType.DANGER_OUTLINE,
|
||||
icon: IconProp.Refresh,
|
||||
onClick: async (
|
||||
item: StatusPageSCIM,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
try {
|
||||
setResetSCIMId(item["_id"] as string);
|
||||
setShowResetModal(true);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showSCIMUrlId && currentSCIMConfig ? (
|
||||
<ConfirmModal
|
||||
title={`SCIM URLs - ${currentSCIMConfig.name}`}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
Configure your identity provider with these SCIM endpoint
|
||||
URLs:
|
||||
</p>
|
||||
<br />
|
||||
<div>
|
||||
<strong>SCIM Base URL:</strong>
|
||||
<br />
|
||||
<code
|
||||
style={{
|
||||
backgroundColor: "#f4f4f4",
|
||||
padding: "5px",
|
||||
borderRadius: "3px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{IDENTITY_URL.toString()}/status-page-scim/v2/{showSCIMUrlId}
|
||||
</code>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<strong>Users Endpoint:</strong>
|
||||
<br />
|
||||
<code
|
||||
style={{
|
||||
backgroundColor: "#f4f4f4",
|
||||
padding: "5px",
|
||||
borderRadius: "3px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{IDENTITY_URL.toString()}/status-page-scim/v2/{showSCIMUrlId}/Users
|
||||
</code>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<strong>Bearer Token:</strong>
|
||||
<br />
|
||||
<HiddenText
|
||||
text={currentSCIMConfig.bearerToken as string}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<strong>Note:</strong> Make sure to use this bearer token in
|
||||
the Authorization header when making SCIM API requests.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowSCIMUrlId("");
|
||||
setCurrentSCIMConfig(null);
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showResetModal ? (
|
||||
<ConfirmModal
|
||||
title={"Reset Bearer Token"}
|
||||
description={
|
||||
"Are you sure you want to reset the bearer token? This will invalidate the current token and you will need to update your identity provider with the new token."
|
||||
}
|
||||
submitButtonText={"Reset"}
|
||||
onSubmit={resetBearerToken}
|
||||
isLoading={isResetLoading}
|
||||
submitButtonType={ButtonStyleType.DANGER}
|
||||
onClose={() => {
|
||||
setShowResetModal(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showResetErrorModal ? (
|
||||
<ConfirmModal
|
||||
title={"Error"}
|
||||
description={resetError}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowResetErrorModal(false);
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showResetSuccessModal ? (
|
||||
<ConfirmModal
|
||||
title={"Bearer Token Reset"}
|
||||
description={
|
||||
<div>
|
||||
<p>Bearer token has been reset successfully.</p>
|
||||
<br />
|
||||
<div>
|
||||
<strong>New Bearer Token:</strong>
|
||||
<br />
|
||||
<HiddenText text={newBearerToken} />
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<strong>Important:</strong> Make sure to update your identity
|
||||
provider with this new bearer token.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={() => {
|
||||
setShowResetSuccessModal(false);
|
||||
setNewBearerToken("");
|
||||
}}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default SCIMPage;
|
||||
@@ -230,6 +230,17 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
icon={IconProp.Lock}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "SCIM",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.STATUS_PAGE_VIEW_SCIM] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Team}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Authentication Settings",
|
||||
|
||||
@@ -196,6 +196,12 @@ const SettingsSSO: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SSO");
|
||||
});
|
||||
|
||||
const SettingsSCIM: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SCIM");
|
||||
});
|
||||
|
||||
const SettingsSmsLog: LazyExoticComponent<FunctionComponent<ComponentProps>> =
|
||||
lazy(() => {
|
||||
return import("../Pages/Settings/SmsLog");
|
||||
@@ -680,6 +686,18 @@ const SettingsRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.SETTINGS_SCIM)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<SettingsSCIM
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.SETTINGS_SCIM] as Route}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.SETTINGS_INCIDENTS_SEVERITY,
|
||||
|
||||
@@ -119,6 +119,11 @@ const StatusPageViewSSO: LazyExoticComponent<
|
||||
> = lazy(() => {
|
||||
return import("../Pages/StatusPages/View/SSO");
|
||||
});
|
||||
const StatusPageViewSCIM: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
return import("../Pages/StatusPages/View/SCIM");
|
||||
});
|
||||
const StatusPageViewPrivateUser: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
@@ -360,6 +365,18 @@ const StatusPagesRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.STATUS_PAGE_VIEW_SCIM)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<StatusPageViewSCIM
|
||||
{...props}
|
||||
pageRoute={RouteMap[PageMap.STATUS_PAGE_VIEW_SCIM] as Route}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS,
|
||||
|
||||
@@ -211,6 +211,7 @@ enum PageMap {
|
||||
STATUS_PAGE_VIEW_CUSTOM_FIELDS = "STATUS_PAGE_VIEW_CUSTOM_FIELDS",
|
||||
STATUS_PAGE_VIEW_REPORTS = "STATUS_PAGE_VIEW_REPORTS",
|
||||
STATUS_PAGE_VIEW_SSO = "STATUS_PAGE_VIEW_SSO",
|
||||
STATUS_PAGE_VIEW_SCIM = "STATUS_PAGE_VIEW_SCIM",
|
||||
STATUS_PAGE_VIEW_OWNERS = "STATUS_PAGE_VIEW_OWNERS",
|
||||
STATUS_PAGE_VIEW_SETTINGS = "STATUS_PAGE_VIEW_SETTINGS",
|
||||
|
||||
@@ -338,6 +339,9 @@ enum PageMap {
|
||||
// SSO.
|
||||
SETTINGS_SSO = "SETTINGS_SSO",
|
||||
|
||||
// SCIM.
|
||||
SETTINGS_SCIM = "SETTINGS_SCIM",
|
||||
|
||||
// Domains
|
||||
|
||||
SETTINGS_DOMAINS = "SETTINGS_DOMAINS",
|
||||
|
||||
@@ -136,6 +136,7 @@ export const StatusPagesRoutePath: Dictionary<string> = {
|
||||
[PageMap.STATUS_PAGE_VIEW_EMBEDDED]: `${RouteParams.ModelID}/embedded`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SUBSCRIBER_SETTINGS]: `${RouteParams.ModelID}/subscriber-settings`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SSO]: `${RouteParams.ModelID}/sso`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SCIM]: `${RouteParams.ModelID}/scim`,
|
||||
[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]: `${RouteParams.ModelID}/custom-code`,
|
||||
[PageMap.STATUS_PAGE_VIEW_RESOURCES]: `${RouteParams.ModelID}/resources`,
|
||||
[PageMap.STATUS_PAGE_VIEW_ADVANCED_OPTIONS]: `${RouteParams.ModelID}/advanced-options`,
|
||||
@@ -244,6 +245,7 @@ export const SettingsRoutePath: Dictionary<string> = {
|
||||
[PageMap.SETTINGS_DOMAINS]: "domains",
|
||||
[PageMap.SETTINGS_FEATURE_FLAGS]: "feature-flags",
|
||||
[PageMap.SETTINGS_SSO]: "sso",
|
||||
[PageMap.SETTINGS_SCIM]: "scim",
|
||||
[PageMap.SETTINGS_TEAMS]: "teams",
|
||||
[PageMap.SETTINGS_USERS]: "users",
|
||||
[PageMap.SETTINGS_USER_VIEW]: `users/${RouteParams.ModelID}`,
|
||||
@@ -1096,6 +1098,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.STATUS_PAGE_VIEW_SCIM]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/status-pages/${
|
||||
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_SCIM]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/status-pages/${
|
||||
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_CUSTOM_HTML_CSS]
|
||||
@@ -1702,6 +1710,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SETTINGS_SCIM]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/settings/${
|
||||
SettingsRoutePath[PageMap.SETTINGS_SCIM]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.SETTINGS_TEAMS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/settings/${
|
||||
SettingsRoutePath[PageMap.SETTINGS_TEAMS]
|
||||
|
||||
@@ -1,24 +1,215 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
// OneUptime Progressive Web App Service Worker
|
||||
// Handles push notifications only - no caching or offline functionality
|
||||
// Handles push notifications and caching for PWA functionality
|
||||
|
||||
console.log('[ServiceWorker] OneUptime PWA Service Worker Loaded');
|
||||
|
||||
// Install event - just skip waiting, no caching
|
||||
// Cache configuration - Updated dynamically during build
|
||||
// Version format: oneuptime-v{APP_VERSION}-{GIT_SHA}
|
||||
// This ensures cache invalidation on every deployment
|
||||
const CACHE_VERSION = 'oneuptime-v{{APP_VERSION}}-{{GIT_SHA}}'; // Auto-generated version
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`;
|
||||
|
||||
// Cache duration configuration (in milliseconds)
|
||||
const CACHE_DURATIONS = {
|
||||
static: 7 * 24 * 60 * 60 * 1000, // 7 days for static assets
|
||||
dynamic: 24 * 60 * 60 * 1000, // 1 day for dynamic content
|
||||
};
|
||||
|
||||
// Assets to cache immediately during install
|
||||
const STATIC_ASSETS = [
|
||||
'/dashboard/',
|
||||
'/dashboard/manifest.json',
|
||||
'/dashboard/offline.html',
|
||||
'/dashboard/assets/img/favicons/favicon.ico',
|
||||
'/dashboard/assets/img/favicons/android-chrome-192x192.png',
|
||||
'/dashboard/assets/img/favicons/android-chrome-512x512.png',
|
||||
// Add other critical assets as needed
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', function(event) {
|
||||
console.log('[ServiceWorker] Installing...');
|
||||
event.waitUntil(self.skipWaiting());
|
||||
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
// Cache static assets
|
||||
caches.open(STATIC_CACHE).then(function(cache) {
|
||||
console.log('[ServiceWorker] Pre-caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS.filter(url => url !== '/dashboard/'));
|
||||
}),
|
||||
|
||||
// Skip waiting to activate immediately
|
||||
self.skipWaiting()
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - claim clients, no cache cleanup needed
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', function(event) {
|
||||
console.log('[ServiceWorker] Activating...');
|
||||
event.waitUntil(self.clients.claim());
|
||||
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
// Clean up old caches
|
||||
caches.keys().then(function(cacheNames) {
|
||||
return Promise.all(
|
||||
cacheNames.map(function(cacheName) {
|
||||
if (cacheName.startsWith('oneuptime-') &&
|
||||
!cacheName.startsWith(CACHE_VERSION)) {
|
||||
console.log('[ServiceWorker] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}),
|
||||
|
||||
// Claim all clients
|
||||
self.clients.claim()
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
// No fetch event handling - let all requests go to network
|
||||
// PWA will work entirely online without any caching
|
||||
// Fetch event - implement caching strategies
|
||||
self.addEventListener('fetch', function(event) {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip chrome-extension and other non-http(s) requests
|
||||
if (!url.protocol.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(handleRequest(request));
|
||||
});
|
||||
|
||||
// Request handling with different caching strategies
|
||||
async function handleRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
try {
|
||||
// Strategy 1: Network First for HTML pages (with fallback)
|
||||
if (pathname.endsWith('/') || pathname.endsWith('.html') ||
|
||||
pathname === '/dashboard' || pathname.startsWith('/dashboard/') && !pathname.includes('.')) {
|
||||
return await networkFirstWithFallback(request, DYNAMIC_CACHE);
|
||||
}
|
||||
|
||||
// Strategy 2: Cache First for JavaScript, CSS, and other static assets
|
||||
if (pathname.includes('/dist/') || pathname.match(/\.(js|css|woff|woff2|ttf|otf|eot)$/)) {
|
||||
return await cacheFirstWithUpdate(request, STATIC_CACHE);
|
||||
}
|
||||
|
||||
// Strategy 3: Cache First for images and other media
|
||||
if (pathname.match(/\.(png|jpe?g|gif|svg|ico|webp|avif)$/)) {
|
||||
return await cacheFirstWithUpdate(request, STATIC_CACHE);
|
||||
}
|
||||
|
||||
|
||||
// Strategy 5: Network First for everything else
|
||||
return await networkFirstWithFallback(request, DYNAMIC_CACHE);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Request handling error:', error);
|
||||
|
||||
// Return offline page for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
const offlineResponse = await caches.match('/dashboard/offline.html');
|
||||
if (offlineResponse) {
|
||||
return offlineResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a basic offline response
|
||||
return new Response('Offline - Please check your internet connection', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Caching Strategy 1: Network First with Fallback (for HTML)
|
||||
async function networkFirstWithFallback(request, cacheName) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse.ok) {
|
||||
// Cache successful responses
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.log('[ServiceWorker] Network failed, trying cache:', request.url);
|
||||
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Caching Strategy 2: Cache First with Background Update (for static assets)
|
||||
async function cacheFirstWithUpdate(request, cacheName) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
|
||||
if (cachedResponse) {
|
||||
// Return cached version immediately
|
||||
|
||||
// Background update if cache is old
|
||||
const cacheDate = new Date(cachedResponse.headers.get('date') || 0);
|
||||
const now = new Date();
|
||||
const age = now.getTime() - cacheDate.getTime();
|
||||
|
||||
if (age > CACHE_DURATIONS.static) {
|
||||
// Background update - don't await
|
||||
updateCacheInBackground(request, cacheName);
|
||||
}
|
||||
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Not in cache, fetch from network
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Failed to fetch asset:', request.url, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Background cache update
|
||||
async function updateCacheInBackground(request, cacheName) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(request, networkResponse);
|
||||
console.log('[ServiceWorker] Background cache update:', request.url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[ServiceWorker] Background update failed:', request.url, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle push subscription changes
|
||||
self.addEventListener('pushsubscriptionchange', function(event) {
|
||||
@@ -174,7 +365,7 @@ self.addEventListener('message', function(event) {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
} else if (event.data && event.data.type === 'GET_VERSION') {
|
||||
event.ports[0].postMessage({ version: 'oneuptime-pwa-no-cache' });
|
||||
event.ports[0].postMessage({ version: CACHE_VERSION });
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user