mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
129 Commits
copilot-pl
...
scim-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c17328ee3 | ||
|
|
3eeb2a9eca | ||
|
|
51fa5705b1 | ||
|
|
6f952d0a5b | ||
|
|
ade98cf1ed | ||
|
|
a65e480bb6 | ||
|
|
c777a935c3 | ||
|
|
8ec9d2a930 | ||
|
|
224c225789 | ||
|
|
85dae7a307 | ||
|
|
332a479c22 | ||
|
|
d708fbbb52 | ||
|
|
03bceb959e | ||
|
|
efa411206e | ||
|
|
27fd99f2e8 | ||
|
|
07361bfeb7 | ||
|
|
bc8a5be0fa | ||
|
|
518768078a | ||
|
|
86e95f99ff | ||
|
|
ea48f56097 | ||
|
|
b8b9dd859a | ||
|
|
d28c14ef24 | ||
|
|
670bec2a12 | ||
|
|
aff24845a8 | ||
|
|
f280e97c1b | ||
|
|
62facf62dd | ||
|
|
db0387d81a | ||
|
|
5c4b19ab3d | ||
|
|
463755fa4d | ||
|
|
85888572de | ||
|
|
475bb25b2d | ||
|
|
badd200aed | ||
|
|
b40d87cbc9 | ||
|
|
36d0066b3a | ||
|
|
a49a0b2cba | ||
|
|
bada97d474 | ||
|
|
a1699f2d55 | ||
|
|
a11e054291 | ||
|
|
47cf7ba763 | ||
|
|
4e0dfb3664 | ||
|
|
250cb9e547 | ||
|
|
541257e3c6 | ||
|
|
ed43686736 | ||
|
|
9ca45f23e3 | ||
|
|
e3573a9b77 | ||
|
|
c9e78044e6 | ||
|
|
813581dec5 | ||
|
|
e528decf73 | ||
|
|
42ef41ede8 | ||
|
|
af26472db4 | ||
|
|
44b5c8b668 | ||
|
|
d821b88ed7 | ||
|
|
1df43e21ff | ||
|
|
76ca6ee7e1 | ||
|
|
dac731a57b | ||
|
|
0f4b248598 | ||
|
|
b2c14e0380 | ||
|
|
3ab9705bbe | ||
|
|
40812c8749 | ||
|
|
45ae1501f2 | ||
|
|
13d9f19606 | ||
|
|
ad3221310a | ||
|
|
659042fcfb | ||
|
|
d65b9c7b29 | ||
|
|
dc77206e6f | ||
|
|
9c1910d3f1 | ||
|
|
afe8f8e6f4 | ||
|
|
015bd0f870 | ||
|
|
383c145186 | ||
|
|
f155795e6b | ||
|
|
757f5b5721 | ||
|
|
694215df06 | ||
|
|
0eb6022f1d | ||
|
|
3109006828 | ||
|
|
272695bd11 | ||
|
|
330e3bc106 | ||
|
|
c7876bf3a3 | ||
|
|
345ada5404 | ||
|
|
4f97b1b460 | ||
|
|
e35ef1809f | ||
|
|
c2926f3542 | ||
|
|
9495b4bd47 | ||
|
|
ad3f36fdf5 | ||
|
|
f2221b0a40 | ||
|
|
62fbc1f4be | ||
|
|
054a2bc8f5 | ||
|
|
896787109c | ||
|
|
3a55fcc872 | ||
|
|
2945a48d05 | ||
|
|
da3a7ddb2e | ||
|
|
04a0bfedaa | ||
|
|
fa5c7b1e73 | ||
|
|
a1c2918cd7 | ||
|
|
91b11b12c1 | ||
|
|
778a34d631 | ||
|
|
6dbd838ca4 | ||
|
|
e09634dc6f | ||
|
|
af60715de2 | ||
|
|
3b4c54876e | ||
|
|
e357100e46 | ||
|
|
7f3a50076d | ||
|
|
9d182b6d55 | ||
|
|
33fce0b53c | ||
|
|
3db8419349 | ||
|
|
dfc324b099 | ||
|
|
36521ef37c | ||
|
|
a6f336340e | ||
|
|
c36f782192 | ||
|
|
5219f1cfc0 | ||
|
|
7f84d50baa | ||
|
|
cd2ce3f1a8 | ||
|
|
01b0e01ca8 | ||
|
|
73dc6bb5db | ||
|
|
ab7fc1c244 | ||
|
|
3927bea29c | ||
|
|
6060d66c2b | ||
|
|
9f4869b05f | ||
|
|
17bdfee012 | ||
|
|
4988b9fc7a | ||
|
|
9edc6b9f18 | ||
|
|
525e19faa6 | ||
|
|
588e8976d2 | ||
|
|
d5e28e98fb | ||
|
|
0e84bc9c40 | ||
|
|
39e8b1da6b | ||
|
|
66c4badd94 | ||
|
|
a245fabc34 | ||
|
|
fa9fce2774 | ||
|
|
a256f4be54 |
126
.github/workflows/build.yml
vendored
126
.github/workflows/build.yml
vendored
@@ -23,7 +23,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Accounts/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Accounts/Dockerfile .
|
||||
|
||||
docker-build-isolated-vm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -38,7 +42,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./IsolatedVM/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./IsolatedVM/Dockerfile .
|
||||
|
||||
docker-build-home:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -53,7 +61,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Home/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Home/Dockerfile .
|
||||
|
||||
docker-build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -68,7 +80,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Worker/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Worker/Dockerfile .
|
||||
|
||||
docker-build-workflow:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -83,7 +99,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Workflow/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Workflow/Dockerfile .
|
||||
|
||||
docker-build-api-reference:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -98,7 +118,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./APIReference/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./APIReference/Dockerfile .
|
||||
|
||||
docker-build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -113,7 +137,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Docs/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Docs/Dockerfile .
|
||||
|
||||
|
||||
docker-build-otel-collector:
|
||||
@@ -129,7 +157,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./OTelCollector/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./OTelCollector/Dockerfile .
|
||||
|
||||
docker-build-app:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -145,7 +177,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./App/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./App/Dockerfile .
|
||||
|
||||
|
||||
docker-build-copilot:
|
||||
@@ -161,7 +197,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Copilot/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Copilot/Dockerfile .
|
||||
|
||||
docker-build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -177,7 +217,11 @@ jobs:
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./E2E/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./E2E/Dockerfile .
|
||||
|
||||
docker-build-admin-dashboard:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -192,7 +236,11 @@ jobs:
|
||||
|
||||
# build image for home
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./AdminDashboard/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./AdminDashboard/Dockerfile .
|
||||
|
||||
docker-build-dashboard:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -207,7 +255,11 @@ jobs:
|
||||
|
||||
# build image for home
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Dashboard/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Dashboard/Dockerfile .
|
||||
|
||||
docker-build-probe:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -222,7 +274,11 @@ jobs:
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Probe/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./Probe/Dockerfile .
|
||||
|
||||
docker-build-probe-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -237,7 +293,11 @@ jobs:
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./ProbeIngest/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./ProbeIngest/Dockerfile .
|
||||
|
||||
docker-build-server-monitor-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -252,7 +312,11 @@ jobs:
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./ServerMonitorIngest/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./ServerMonitorIngest/Dockerfile .
|
||||
|
||||
docker-build-open-telemetry-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -267,7 +331,11 @@ jobs:
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./OpenTelemetryIngest/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./OpenTelemetryIngest/Dockerfile .
|
||||
|
||||
docker-build-incoming-request-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -282,7 +350,11 @@ jobs:
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./IncomingRequestIngest/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./IncomingRequestIngest/Dockerfile .
|
||||
|
||||
docker-build-fluent-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -297,7 +369,11 @@ jobs:
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./FluentIngest/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./FluentIngest/Dockerfile .
|
||||
|
||||
docker-build-status-page:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -312,7 +388,11 @@ jobs:
|
||||
|
||||
# build image for home
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./StatusPage/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./StatusPage/Dockerfile .
|
||||
|
||||
docker-build-test-server:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -327,4 +407,8 @@ jobs:
|
||||
|
||||
# build image for mail service
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./TestServer/Dockerfile .
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build -f ./TestServer/Dockerfile .
|
||||
|
||||
168
.github/workflows/compile.yml
vendored
168
.github/workflows/compile.yml
vendored
@@ -20,7 +20,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Accounts && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Accounts
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Accounts && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-isolated-vm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,7 +37,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd IsolatedVM && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile IsolatedVM
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd IsolatedVM && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-common:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -43,7 +53,12 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Common
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Common && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-app:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -55,7 +70,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd App && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile App
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd App && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-home:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -67,7 +87,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Home && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Home
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Home && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-worker:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -79,7 +104,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Worker && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Worker
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Worker && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-workflow:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -91,7 +121,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Workflow && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Workflow
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Workflow && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-api-reference:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -103,7 +138,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd APIReference && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile API Reference
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd APIReference && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-docs-reference:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -115,7 +155,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Docs && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Docs Reference
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Docs && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-copilot:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -127,7 +172,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Copilot && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Copilot
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Copilot && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-nginx:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -140,7 +190,12 @@ jobs:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
|
||||
- run: cd Nginx && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Nginx
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Nginx && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-infrastructure-agent:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -150,7 +205,12 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
# Setup Go
|
||||
- uses: actions/setup-go@v5
|
||||
- run: cd InfrastructureAgent && go build .
|
||||
- name: Compile Infrastructure Agent
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd InfrastructureAgent && go build .
|
||||
|
||||
|
||||
compile-admin-dashboard:
|
||||
@@ -164,7 +224,12 @@ jobs:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
|
||||
- run: cd AdminDashboard && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Admin Dashboard
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd AdminDashboard && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-dashboard:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -177,7 +242,12 @@ jobs:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
|
||||
- run: cd Dashboard && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Dashboard
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Dashboard && npm install && npm run compile && npm run dep-check
|
||||
|
||||
|
||||
compile-e2e:
|
||||
@@ -191,7 +261,12 @@ jobs:
|
||||
node-version: latest
|
||||
- run: sudo apt-get update
|
||||
- run: cd Common && npm install
|
||||
- run: cd E2E && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile E2E
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd E2E && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-probe:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -203,7 +278,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Probe && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Probe
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Probe && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-probe-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -215,7 +295,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd ProbeIngest && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Probe Ingest
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd ProbeIngest && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-server-monitor-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -227,7 +312,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd ServerMonitorIngest && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Server Monitor Ingest
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd ServerMonitorIngest && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-open-telemetry-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -239,7 +329,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd OpenTelemetryIngest && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Open Telemetry Ingest
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd OpenTelemetryIngest && npm install && npm run compile && npm run dep-check
|
||||
|
||||
|
||||
compile-incoming-request-ingest:
|
||||
@@ -252,7 +347,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd IncomingRequestIngest && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Incoming Request Ingest
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd IncomingRequestIngest && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-fluent-ingest:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -264,7 +364,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd FluentIngest && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Fluent Ingest
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd FluentIngest && npm install && npm run compile && npm run dep-check
|
||||
|
||||
|
||||
compile-status-page:
|
||||
@@ -278,7 +383,12 @@ jobs:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
|
||||
- run: cd StatusPage && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Status Page
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd StatusPage && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-test-server:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -290,7 +400,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd TestServer && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile Test Server
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd TestServer && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -302,4 +417,9 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd MCP && npm install && npm run compile && npm run dep-check
|
||||
- name: Compile MCP
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd MCP && npm install && npm run compile && npm run dep-check
|
||||
667
.github/workflows/release.yml
vendored
667
.github/workflows/release.yml
vendored
@@ -184,18 +184,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file ./MCP/Dockerfile.tpl \
|
||||
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag oneuptime/mcp-server:release \
|
||||
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag ghcr.io/oneuptime/mcp-server:release \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
|
||||
--push .
|
||||
echo "✅ Pushed Docker images to Docker Hub and GitHub Container Registry"
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file ./MCP/Dockerfile.tpl \
|
||||
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag oneuptime/mcp-server:release \
|
||||
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag ghcr.io/oneuptime/mcp-server:release \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
|
||||
--push .
|
||||
echo "✅ Pushed Docker images to Docker Hub and GitHub Container Registry"
|
||||
|
||||
- name: Upload MCP server artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -252,17 +256,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Nginx/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Nginx/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/nginx:release \
|
||||
--tag oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/nginx:release \
|
||||
--tag ghcr.io/oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
e2e-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -312,17 +321,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./E2E/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./E2E/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/e2e:release \
|
||||
--tag oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/e2e:release \
|
||||
--tag ghcr.io/oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
isolated-vm-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -372,17 +386,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./IsolatedVM/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./IsolatedVM/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/isolated-vm:release \
|
||||
--tag oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/isolated-vm:release \
|
||||
--tag ghcr.io/oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
home-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -432,17 +451,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Home/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Home/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/home:release \
|
||||
--tag oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/home:release \
|
||||
--tag ghcr.io/oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -495,17 +519,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./TestServer/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./TestServer/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/test-server:release \
|
||||
--tag oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/test-server:release \
|
||||
--tag ghcr.io/oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
otel-collector-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -555,17 +584,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./OTelCollector/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./OTelCollector/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/otel-collector:release \
|
||||
--tag oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/otel-collector:release \
|
||||
--tag ghcr.io/oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -617,17 +651,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./StatusPage/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./StatusPage/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/status-page:release \
|
||||
--tag oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/status-page:release \
|
||||
--tag ghcr.io/oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
test-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -677,17 +716,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Tests/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Tests/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/test:release \
|
||||
--tag oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/test:release \
|
||||
--tag ghcr.io/oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
probe-ingest-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -737,17 +781,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./ProbeIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./ProbeIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/probe-ingest:release \
|
||||
--tag oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/probe-ingest:release \
|
||||
--tag ghcr.io/oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
server-monitor-ingest-docker-image-deploy:
|
||||
@@ -798,17 +847,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./ServerMonitorIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./ServerMonitorIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/server-monitor-ingest:release \
|
||||
--tag oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/server-monitor-ingest:release \
|
||||
--tag ghcr.io/oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -860,17 +914,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./OpenTelemetryIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./OpenTelemetryIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/open-telemetry-ingest:release \
|
||||
--tag oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/open-telemetry-ingest:release \
|
||||
--tag ghcr.io/oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
incoming-request-ingest-docker-image-deploy:
|
||||
@@ -921,17 +980,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./IncomingRequestIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./IncomingRequestIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/incoming-request-ingest:release \
|
||||
--tag oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/incoming-request-ingest:release \
|
||||
--tag ghcr.io/oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
fluent-ingest-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -981,17 +1045,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./FluentIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./FluentIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/fluent-ingest:release \
|
||||
--tag oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/fluent-ingest:release \
|
||||
--tag ghcr.io/oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
probe-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -1041,17 +1110,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Probe/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Probe/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/probe:release \
|
||||
--tag oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/probe:release \
|
||||
--tag ghcr.io/oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
admin-dashboard-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -1101,17 +1175,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./AdminDashboard/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./AdminDashboard/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/admin-dashboard:release \
|
||||
--tag oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/admin-dashboard:release \
|
||||
--tag ghcr.io/oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
dashboard-docker-image-deploy:
|
||||
@@ -1162,17 +1241,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Dashboard/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Dashboard/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/dashboard:release \
|
||||
--tag oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/dashboard:release \
|
||||
--tag ghcr.io/oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
app-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -1222,17 +1306,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./App/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./App/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/app:release \
|
||||
--tag oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/app:release \
|
||||
--tag ghcr.io/oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
copilot-docker-image-deploy:
|
||||
@@ -1283,17 +1372,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Copilot/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Copilot/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/copilot:release \
|
||||
--tag oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/copilot:release \
|
||||
--tag ghcr.io/oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
accounts-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
@@ -1343,17 +1437,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Accounts/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Accounts/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/accounts:release \
|
||||
--tag oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/accounts:release \
|
||||
--tag ghcr.io/oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
publish-npm-packages:
|
||||
@@ -1443,17 +1542,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./LLM/Dockerfile
|
||||
context: ./LLM
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./LLM/Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
--tag oneuptime/llm:release \
|
||||
--tag oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/llm:release \
|
||||
--tag ghcr.io/oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
./LLM
|
||||
|
||||
docs-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1505,17 +1609,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Docs/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Docs/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/docs:release \
|
||||
--tag oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/docs:release \
|
||||
--tag ghcr.io/oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -1570,17 +1679,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Worker/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Worker/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/worker:release \
|
||||
--tag oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/worker:release \
|
||||
--tag ghcr.io/oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -1635,17 +1749,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Workflow/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Workflow/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/workflow:release \
|
||||
--tag oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/workflow:release \
|
||||
--tag ghcr.io/oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -1761,17 +1880,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./APIReference/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./APIReference/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/api-reference:release \
|
||||
--tag oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--tag ghcr.io/oneuptime/api-reference:release \
|
||||
--tag ghcr.io/oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -1898,12 +2022,49 @@ jobs:
|
||||
configuration: "./Scripts/Release/ChangelogConfig.json"
|
||||
- run: echo "Changelog:"
|
||||
- run: echo "${{steps.build_changelog.outputs.changelog}}"
|
||||
- name: Fallback to commit messages if changelog empty
|
||||
id: fallback_changelog
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CHANGELOG_CONTENT="${{steps.build_changelog.outputs.changelog}}"
|
||||
OLD_PLACEHOLDER="No significant changes were made. We have just fixed minor bugs for this release. You can find the detailed information in the commit history."
|
||||
NEW_PLACEHOLDER="(auto) No categorized pull requests. Fallback will list raw commit messages."
|
||||
if echo "$CHANGELOG_CONTENT" | grep -Fq "$OLD_PLACEHOLDER" || echo "$CHANGELOG_CONTENT" | grep -Fq "$NEW_PLACEHOLDER"; then
|
||||
echo "Detected empty placeholder changelog. Building commit list fallback."
|
||||
# Find previous tag (skip the most recent tag which might be for an older release). If none, include all commits.
|
||||
if prev_tag=$(git describe --tags --abbrev=0 $(git rev-list --tags --skip=1 --max-count=1) 2>/dev/null); then
|
||||
echo "Previous tag: $prev_tag"
|
||||
commits=$(git log --pretty=format:'- %s (%h)' "$prev_tag"..HEAD)
|
||||
else
|
||||
echo "No previous tag found; using full commit history on this branch."
|
||||
commits=$(git log --pretty=format:'- %s (%h)')
|
||||
fi
|
||||
# If still empty (e.g., no commits), keep placeholder to avoid empty body.
|
||||
if [ -z "$commits" ]; then
|
||||
commits="(no commits found)"
|
||||
fi
|
||||
{
|
||||
echo "changelog<<EOF"
|
||||
echo "## Commit Messages"
|
||||
echo ""
|
||||
echo "$commits"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Pass through original changelog
|
||||
{
|
||||
echo "changelog<<EOF"
|
||||
echo "$CHANGELOG_CONTENT"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: "7.0.${{needs.generate-build-number.outputs.build_number}}"
|
||||
artifactErrorsFailBuild: true
|
||||
body: |
|
||||
${{steps.build_changelog.outputs.changelog}}
|
||||
${{steps.fallback_changelog.outputs.changelog}}
|
||||
|
||||
|
||||
infrastructure-agent-deploy:
|
||||
|
||||
@@ -77,17 +77,21 @@ jobs:
|
||||
ls -la "$PROVIDER_DIR" || true
|
||||
|
||||
- name: Test Go build
|
||||
run: |
|
||||
PROVIDER_DIR="./Terraform"
|
||||
if [ -d "$PROVIDER_DIR" ] && [ -f "$PROVIDER_DIR/go.mod" ]; then
|
||||
cd "$PROVIDER_DIR"
|
||||
echo "🔨 Testing Go build..."
|
||||
go mod tidy
|
||||
go build -v ./...
|
||||
echo "✅ Go build successful"
|
||||
else
|
||||
echo "⚠️ Cannot test build - missing go.mod or provider directory"
|
||||
fi
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
PROVIDER_DIR="./Terraform"
|
||||
if [ -d "$PROVIDER_DIR" ] && [ -f "$PROVIDER_DIR/go.mod" ]; then
|
||||
cd "$PROVIDER_DIR"
|
||||
echo "🔨 Testing Go build..."
|
||||
go mod tidy
|
||||
go build -v ./...
|
||||
echo "✅ Go build successful"
|
||||
else
|
||||
echo "⚠️ Cannot test build - missing go.mod or provider directory"
|
||||
fi
|
||||
|
||||
- name: Upload Terraform provider as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
629
.github/workflows/test-release.yaml
vendored
629
.github/workflows/test-release.yaml
vendored
@@ -176,18 +176,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker images (test)
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file ./MCP/Dockerfile.tpl \
|
||||
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag oneuptime/mcp-server:test \
|
||||
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag ghcr.io/oneuptime/mcp-server:test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
|
||||
--push .
|
||||
echo "✅ Pushed test Docker images to Docker Hub and GitHub Container Registry"
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file ./MCP/Dockerfile.tpl \
|
||||
--tag oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag oneuptime/mcp-server:test \
|
||||
--tag ghcr.io/oneuptime/mcp-server:${{ steps.version.outputs.version }} \
|
||||
--tag ghcr.io/oneuptime/mcp-server:test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=${{ steps.version.outputs.version }} \
|
||||
--push .
|
||||
echo "✅ Pushed test Docker images to Docker Hub and GitHub Container Registry"
|
||||
|
||||
- name: Upload MCP server artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -269,18 +273,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./LLM/Dockerfile
|
||||
context: ./LLM
|
||||
# arm64 is not supported by the base image of the LLM
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./LLM/Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
--tag oneuptime/llm:test \
|
||||
--tag oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/llm:test \
|
||||
--tag ghcr.io/oneuptime/llm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
./LLM
|
||||
|
||||
|
||||
nginx-docker-image-deploy:
|
||||
@@ -332,17 +340,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Nginx/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Nginx/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/nginx:test \
|
||||
--tag oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/nginx:test \
|
||||
--tag ghcr.io/oneuptime/nginx:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
e2e-docker-image-deploy:
|
||||
@@ -394,17 +407,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./E2E/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./E2E/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/e2e:test \
|
||||
--tag oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/e2e:test \
|
||||
--tag ghcr.io/oneuptime/e2e:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
test-server-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -455,17 +473,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./TestServer/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./TestServer/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/test-server:test \
|
||||
--tag oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/test-server:test \
|
||||
--tag ghcr.io/oneuptime/test-server:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
otel-collector-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -516,17 +539,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./OTelCollector/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./OTelCollector/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/otel-collector:test \
|
||||
--tag oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/otel-collector:test \
|
||||
--tag ghcr.io/oneuptime/otel-collector:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
isolated-vm-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -577,17 +605,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./IsolatedVM/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./IsolatedVM/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/isolated-vm:test \
|
||||
--tag oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/isolated-vm:test \
|
||||
--tag ghcr.io/oneuptime/isolated-vm:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
home-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -638,17 +671,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Home/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Home/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/home:test \
|
||||
--tag oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/home:test \
|
||||
--tag ghcr.io/oneuptime/home:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -701,17 +739,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./StatusPage/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./StatusPage/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/status-page:test \
|
||||
--tag oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/status-page:test \
|
||||
--tag ghcr.io/oneuptime/status-page:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -764,17 +807,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Tests/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Tests/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/test:test \
|
||||
--tag oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/test:test \
|
||||
--tag ghcr.io/oneuptime/test:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
probe-ingest-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -825,17 +873,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./ProbeIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./ProbeIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/probe-ingest:test \
|
||||
--tag oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/probe-ingest:test \
|
||||
--tag ghcr.io/oneuptime/probe-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -888,17 +941,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./ServerMonitorIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./ServerMonitorIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/server-monitor-ingest:test \
|
||||
--tag oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/server-monitor-ingest:test \
|
||||
--tag ghcr.io/oneuptime/server-monitor-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -952,17 +1010,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./IncomingRequestIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./IncomingRequestIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/incoming-request-ingest:test \
|
||||
--tag oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/incoming-request-ingest:test \
|
||||
--tag ghcr.io/oneuptime/incoming-request-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
open-telemetry-ingest-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1013,17 +1076,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./OpenTelemetryIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./OpenTelemetryIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/open-telemetry-ingest:test \
|
||||
--tag oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/open-telemetry-ingest:test \
|
||||
--tag ghcr.io/oneuptime/open-telemetry-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
fluent-ingest-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1074,17 +1142,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./FluentIngest/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./FluentIngest/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/fluent-ingest:test \
|
||||
--tag oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/fluent-ingest:test \
|
||||
--tag ghcr.io/oneuptime/fluent-ingest:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
probe-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1135,17 +1208,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Probe/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Probe/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/probe:test \
|
||||
--tag oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/probe:test \
|
||||
--tag ghcr.io/oneuptime/probe:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
dashboard-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1196,17 +1274,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Dashboard/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Dashboard/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/dashboard:test \
|
||||
--tag oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/dashboard:test \
|
||||
--tag ghcr.io/oneuptime/dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
admin-dashboard-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1257,17 +1340,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./AdminDashboard/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./AdminDashboard/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/admin-dashboard:test \
|
||||
--tag oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/admin-dashboard:test \
|
||||
--tag ghcr.io/oneuptime/admin-dashboard:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
app-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1318,17 +1406,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./App/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./App/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/app:test \
|
||||
--tag oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/app:test \
|
||||
--tag ghcr.io/oneuptime/app:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -1382,17 +1475,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./APIReference/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./APIReference/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/api-reference:test \
|
||||
--tag oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/api-reference:test \
|
||||
--tag ghcr.io/oneuptime/api-reference:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
@@ -1445,17 +1543,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Accounts/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Accounts/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/accounts:test \
|
||||
--tag oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/accounts:test \
|
||||
--tag ghcr.io/oneuptime/accounts:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
worker-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1506,17 +1609,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Worker/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.copilot-docker-image-deploybuild_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Worker/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/worker:test \
|
||||
--tag oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/worker:test \
|
||||
--tag ghcr.io/oneuptime/worker:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
copilot-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
@@ -1567,17 +1675,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Copilot/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Copilot/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/copilot:test \
|
||||
--tag oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/copilot:test \
|
||||
--tag ghcr.io/oneuptime/copilot:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
workflow-docker-image-deploy:
|
||||
@@ -1629,17 +1742,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Workflow/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Workflow/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/workflow:test \
|
||||
--tag oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/workflow:test \
|
||||
--tag ghcr.io/oneuptime/workflow:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
docs-docker-image-deploy:
|
||||
@@ -1691,17 +1809,22 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
file: ./Docs/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
docker buildx build \
|
||||
--file ./Docs/Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag oneuptime/docs:test \
|
||||
--tag oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--tag ghcr.io/oneuptime/docs:test \
|
||||
--tag ghcr.io/oneuptime/docs:7.0.${{needs.generate-build-number.outputs.build_number}}-test \
|
||||
--build-arg GIT_SHA=${{ github.sha }} \
|
||||
--build-arg APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}} \
|
||||
.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"ignore": [
|
||||
"greenlock.d/*"
|
||||
],
|
||||
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
|
||||
"watchOptions": {"useFsEvents": false, "interval": 500},
|
||||
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
|
||||
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
|
||||
}
|
||||
@@ -135,7 +135,6 @@
|
||||
<link rel="apple-touch-icon-precomposed" href="/img/ou-wb.svg">
|
||||
<link rel="icon" href="/img/ou-wb.svg">
|
||||
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
|
||||
<link rel="canonical" href="/">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta property="og:title" content="OneUptime - One Complete Observability platform.">
|
||||
<meta property="og:url" content="https://oneuptime.com">
|
||||
|
||||
@@ -538,11 +538,6 @@ import WorkspaceSettingService, {
|
||||
Service as WorkspaceSettingServiceType,
|
||||
} from "Common/Server/Services/WorkspaceSettingService";
|
||||
|
||||
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
|
||||
import ProjectUserService, {
|
||||
Service as ProjectUserServiceType,
|
||||
} from "Common/Server/Services/ProjectUserService";
|
||||
|
||||
import MonitorFeed from "Common/Models/DatabaseModels/MonitorFeed";
|
||||
import MonitorFeedService, {
|
||||
Service as MonitorFeedServiceType,
|
||||
@@ -736,14 +731,6 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<ProjectUser, ProjectUserServiceType>(
|
||||
ProjectUser,
|
||||
ProjectUserService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
//service provider setting
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
|
||||
import UserService from "Common/Server/Services/UserService";
|
||||
import TeamMemberService from "Common/Server/Services/TeamMemberService";
|
||||
import SCIMUserService from "Common/Server/Services/SCIMUserService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
@@ -15,12 +16,12 @@ import Name from "Common/Types/Name";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import ProjectSCIM from "Common/Models/DatabaseModels/ProjectSCIM";
|
||||
import SCIMUser from "Common/Models/DatabaseModels/SCIMUser";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
|
||||
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import {
|
||||
@@ -30,11 +31,240 @@ import {
|
||||
generateUsersListResponse,
|
||||
parseSCIMQueryParams,
|
||||
logSCIMOperation,
|
||||
extractEmailFromSCIM,
|
||||
extractExternalIdFromSCIM,
|
||||
isUserNameEmail,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// Helper function to find user by external ID or email
|
||||
const findUserByExternalIdOrEmail: (
|
||||
userName: string,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<User | null> = async (
|
||||
userName: string,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<User | null> => {
|
||||
// First check if userName is an external ID (not an email)
|
||||
if (!isUserNameEmail(userName)) {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Looking for external ID: ${userName}`,
|
||||
);
|
||||
|
||||
// Look up by external ID
|
||||
const scimUser: SCIMUser | null = await SCIMUserService.findOneBy({
|
||||
query: {
|
||||
externalId: userName,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (scimUser && scimUser.user) {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Found user by external ID: ${scimUser.user.id}`,
|
||||
);
|
||||
return scimUser.user;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to email lookup
|
||||
try {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Looking for email: ${userName}`,
|
||||
);
|
||||
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(userName) },
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Found user by email: ${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
// If email validation fails, userName is likely an external ID but no mapping exists
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Email validation failed for: ${userName}, treating as external ID with no mapping`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to create or update SCIM user mapping
|
||||
const createOrUpdateSCIMUserMapping: (
|
||||
user: User,
|
||||
externalId: string,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<void> = async (
|
||||
user: User,
|
||||
externalId: string,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<void> => {
|
||||
// Check if mapping already exists
|
||||
const existingMapping: SCIMUser | null = await SCIMUserService.findOneBy({
|
||||
query: {
|
||||
userId: user.id!,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: { _id: true, externalId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
// Update existing mapping if external ID changed
|
||||
if (existingMapping.externalId !== externalId) {
|
||||
await SCIMUserService.updateOneById({
|
||||
id: existingMapping.id!,
|
||||
data: { externalId: externalId },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logSCIMOperation(
|
||||
"SCIM mapping",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Updated external ID mapping for user ${user.id} from ${existingMapping.externalId} to ${externalId}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Create new mapping
|
||||
const scimUser: SCIMUser = new SCIMUser();
|
||||
scimUser.projectId = projectId;
|
||||
scimUser.scimConfigId = scimConfigId;
|
||||
scimUser.userId = user.id!;
|
||||
scimUser.externalId = externalId;
|
||||
|
||||
await SCIMUserService.create({
|
||||
data: scimUser,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logSCIMOperation(
|
||||
"SCIM mapping",
|
||||
"project",
|
||||
scimConfigId.toString(),
|
||||
`Created external ID mapping for user ${user.id} with external ID ${externalId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to resolve user ID (could be internal ID or external ID)
|
||||
const resolveUserId: (
|
||||
userIdParam: string,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<ObjectID | null> = async (
|
||||
userIdParam: string,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<ObjectID | null> => {
|
||||
// First try to parse as ObjectID (internal user ID)
|
||||
try {
|
||||
const objectId: ObjectID = new ObjectID(userIdParam);
|
||||
|
||||
// Verify this user exists in the project
|
||||
const teamMember: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: objectId,
|
||||
},
|
||||
select: { userId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (teamMember) {
|
||||
return objectId;
|
||||
}
|
||||
} catch (error) {
|
||||
// Not a valid ObjectID, continue to external ID lookup
|
||||
}
|
||||
|
||||
// Try to find by external ID
|
||||
const scimUser: SCIMUser | null = await SCIMUserService.findOneBy({
|
||||
query: {
|
||||
externalId: userIdParam,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: { userId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (scimUser && scimUser.userId) {
|
||||
return scimUser.userId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get external ID for a user
|
||||
const getExternalIdForUser: (
|
||||
userId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<string | null> = async (
|
||||
userId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<string | null> => {
|
||||
const scimUser: SCIMUser | null = await SCIMUserService.findOneBy({
|
||||
query: {
|
||||
userId: userId,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: { externalId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
return scimUser?.externalId || null;
|
||||
};
|
||||
|
||||
const handleUserTeamOperations: (
|
||||
operation: "add" | "remove",
|
||||
projectId: ObjectID,
|
||||
@@ -89,6 +319,8 @@ const handleUserTeamOperations: (
|
||||
ignoreHooks: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`SCIM Team operations - user added to team: ${team.id}`);
|
||||
} else {
|
||||
logger.debug(
|
||||
`SCIM Team operations - user already member of team: ${team.id}`,
|
||||
@@ -119,6 +351,10 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: GET ServiceProviderConfig - projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
|
||||
logSCIMOperation(
|
||||
"ServiceProviderConfig",
|
||||
"project",
|
||||
@@ -149,6 +385,10 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: GET Users List - projectScimId: ${req.params["projectScimId"]}, query: ${JSON.stringify(req.query)}`,
|
||||
);
|
||||
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
|
||||
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
@@ -168,7 +408,7 @@ router.get(
|
||||
);
|
||||
|
||||
// Build query for team members in this project
|
||||
const query: Query<ProjectUser> = {
|
||||
const query: Query<TeamMember> = {
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
@@ -178,20 +418,21 @@ router.get(
|
||||
/userName eq "([^"]+)"/i,
|
||||
);
|
||||
if (emailMatch) {
|
||||
const email: string = emailMatch[1]!;
|
||||
const userName: string = emailMatch[1]!;
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`filter by email: ${email}`,
|
||||
`filter by userName: ${userName}`,
|
||||
);
|
||||
|
||||
if (email) {
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
if (userName) {
|
||||
const user: User | null = await findUserByExternalIdOrEmail(
|
||||
userName,
|
||||
projectId,
|
||||
new ObjectID(req.params["projectScimId"]!),
|
||||
);
|
||||
|
||||
if (user && user.id) {
|
||||
query.userId = user.id;
|
||||
logSCIMOperation(
|
||||
@@ -205,7 +446,7 @@ router.get(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`user not found for email: ${email}`,
|
||||
`user not found for userName: ${userName}`,
|
||||
);
|
||||
return Response.sendJsonObjectResponse(
|
||||
req,
|
||||
@@ -243,18 +484,28 @@ router.get(
|
||||
});
|
||||
|
||||
// now get unique users.
|
||||
const usersInProjects: Array<JSONObject> = teamMembers
|
||||
.filter((tm: TeamMember) => {
|
||||
return tm.user && tm.user.id;
|
||||
})
|
||||
.map((tm: TeamMember) => {
|
||||
return formatUserForSCIM(
|
||||
tm.user!,
|
||||
const usersInProjects: Array<JSONObject> = [];
|
||||
|
||||
for (const tm of teamMembers) {
|
||||
if (tm.user && tm.user.id) {
|
||||
// Get external ID for this user if it exists
|
||||
const externalId: string | null = await getExternalIdForUser(
|
||||
tm.user.id,
|
||||
projectId,
|
||||
new ObjectID(req.params["projectScimId"]!),
|
||||
);
|
||||
|
||||
const userFormatted: JSONObject = formatUserForSCIM(
|
||||
tm.user,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
externalId,
|
||||
);
|
||||
});
|
||||
|
||||
usersInProjects.push(userFormatted);
|
||||
}
|
||||
}
|
||||
|
||||
// remove duplicates
|
||||
const uniqueUserIds: Set<string> = new Set<string>();
|
||||
@@ -294,6 +545,10 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: GET Individual User - projectScimId: ${req.params["projectScimId"]}, userId: ${req.params["userId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Get individual user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
@@ -301,21 +556,38 @@ router.get(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
const userIdParam: string = req.params["userId"]!;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Get user - projectId: ${projectId}, userId: ${userId}`,
|
||||
`SCIM Get user - projectId: ${projectId}, userIdParam: ${userIdParam}`,
|
||||
);
|
||||
|
||||
if (!userIdParam) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Resolve user ID (could be internal ID or external ID)
|
||||
const userId: ObjectID | null = await resolveUserId(
|
||||
userIdParam,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
logger.debug(
|
||||
`SCIM Get user - could not resolve user ID for param: ${userIdParam}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists and is part of the project
|
||||
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: new ObjectID(userId),
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
@@ -332,7 +604,7 @@ router.get(
|
||||
|
||||
if (!projectUser || !projectUser.user) {
|
||||
logger.debug(
|
||||
`SCIM Get user - user not found or not part of project for userId: ${userId}`,
|
||||
`SCIM Get user - user not found or not part of project for resolved userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
@@ -341,11 +613,19 @@ router.get(
|
||||
|
||||
logger.debug(`SCIM Get user - found user: ${projectUser.user.id}`);
|
||||
|
||||
// Get external ID for this user if it exists
|
||||
const externalId: string | null = await getExternalIdForUser(
|
||||
projectUser.user.id!,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
projectUser.user,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
externalId,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
@@ -362,6 +642,10 @@ router.put(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: PUT Update User - projectScimId: ${req.params["projectScimId"]}, userId: ${req.params["userId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
@@ -369,70 +653,167 @@ router.put(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
const userIdParam: string = req.params["userId"]!;
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - projectId: ${projectId}, userId: ${userId}`,
|
||||
`SCIM Update user - projectId: ${projectId}, userIdParam: ${userIdParam}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
if (!userIdParam) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Check if user exists and is part of the project
|
||||
const projectUser: TeamMember | null = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: new ObjectID(userId),
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (!projectUser || !projectUser.user) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user not found or not part of project for userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
// Update user information
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
// Extract user data from SCIM request
|
||||
const userName: string = extractEmailFromSCIM(scimUser);
|
||||
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
const active: boolean = scimUser["active"] as boolean;
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - email: ${email}, name: ${name}, active: ${active}`,
|
||||
`SCIM Update user - userName: ${userName}, externalId: ${externalId}, name: ${name}, active: ${active}`,
|
||||
);
|
||||
|
||||
// Extract email from emails array if userName is not an email
|
||||
let email: string = "";
|
||||
if (isUserNameEmail(userName)) {
|
||||
email = userName;
|
||||
} else {
|
||||
// Look for email in the emails array
|
||||
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
|
||||
if (emailsArray && emailsArray.length > 0) {
|
||||
email = emailsArray[0]?.["value"] as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve user ID (could be internal ID or external ID)
|
||||
const userId: ObjectID | null = await resolveUserId(
|
||||
userIdParam,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
let projectUser: TeamMember | null = null;
|
||||
let user: User | null = null;
|
||||
let isNewUser: boolean = false;
|
||||
|
||||
if (userId) {
|
||||
// Check if user exists and is part of the project
|
||||
projectUser = await TeamMemberService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (projectUser && projectUser.user) {
|
||||
user = projectUser.user;
|
||||
}
|
||||
}
|
||||
|
||||
// If user not found, create a new user (SCIM PUT should create if not exists)
|
||||
if (!user) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user not found for param: ${userIdParam}, creating new user`,
|
||||
);
|
||||
|
||||
if (!scimConfig.autoProvisionUsers) {
|
||||
throw new BadRequestException(
|
||||
"Auto-provisioning is disabled for this project and user not found",
|
||||
);
|
||||
}
|
||||
|
||||
if (!email && !externalId) {
|
||||
throw new BadRequestException(
|
||||
"Either a valid email address or external ID is required to create user",
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find existing user by email if we have one
|
||||
if (email) {
|
||||
try {
|
||||
user = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} catch (error) {
|
||||
// Email validation failed, continue without email lookup
|
||||
logger.debug(`SCIM Update user - email validation failed for: ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new user if still not found
|
||||
if (!user) {
|
||||
if (!email) {
|
||||
throw new BadRequestException(
|
||||
"A valid email address is required to create a new user",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - creating new user for email: ${email}`,
|
||||
);
|
||||
user = await UserService.createByEmail({
|
||||
email: new Email(email),
|
||||
name: name ? new Name(name) : new Name("Unknown"),
|
||||
isEmailVerified: true,
|
||||
generateRandomPassword: true,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
isNewUser = true;
|
||||
}
|
||||
|
||||
// Add user to default teams if configured
|
||||
if (scimConfig.teams && scimConfig.teams.length > 0) {
|
||||
logger.debug(
|
||||
`SCIM Update user - adding new user to ${scimConfig.teams.length} configured teams`,
|
||||
);
|
||||
await handleUserTeamOperations("add", projectId, user.id!, scimConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update SCIM user mapping if we have an external ID
|
||||
if (externalId && user && user.id) {
|
||||
await createOrUpdateSCIMUserMapping(
|
||||
user,
|
||||
externalId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle user deactivation by removing from teams
|
||||
if (active === false) {
|
||||
if (active === false && user && user.id) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user marked as inactive, removing from teams`,
|
||||
);
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
await handleUserTeamOperations(
|
||||
"remove",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
user.id,
|
||||
scimConfig,
|
||||
);
|
||||
logger.debug(
|
||||
@@ -441,15 +822,14 @@ router.put(
|
||||
}
|
||||
|
||||
// Handle user activation by adding to teams
|
||||
if (active === true) {
|
||||
if (active === true && user && user.id) {
|
||||
logger.debug(
|
||||
`SCIM Update user - user marked as active, adding to teams`,
|
||||
);
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
await handleUserTeamOperations(
|
||||
"add",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
user.id,
|
||||
scimConfig,
|
||||
);
|
||||
logger.debug(
|
||||
@@ -457,7 +837,8 @@ router.put(
|
||||
);
|
||||
}
|
||||
|
||||
if (email || name) {
|
||||
// Update user information if needed and not a new user
|
||||
if (!isNewUser && user && user.id && (email || name)) {
|
||||
const updateData: any = {};
|
||||
if (email) {
|
||||
updateData.email = new Email(email);
|
||||
@@ -467,11 +848,11 @@ router.put(
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
|
||||
`SCIM Update user - updating existing user with data: ${JSON.stringify(updateData)}`,
|
||||
);
|
||||
|
||||
await UserService.updateOneById({
|
||||
id: new ObjectID(userId),
|
||||
id: user.id,
|
||||
data: updateData,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
@@ -480,7 +861,7 @@ router.put(
|
||||
|
||||
// Fetch updated user
|
||||
const updatedUser: User | null = await UserService.findOneById({
|
||||
id: new ObjectID(userId),
|
||||
id: user.id,
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
@@ -492,29 +873,32 @@ router.put(
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
updatedUser,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
);
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
user = updatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Update user - no updates made, returning existing user`,
|
||||
);
|
||||
|
||||
// If no updates were made, return the existing user
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
projectUser.user,
|
||||
// Get external ID for response
|
||||
const userExternalId: string | null = user && user.id ?
|
||||
await getExternalIdForUser(user.id, projectId, scimConfig.id!) :
|
||||
externalId;
|
||||
|
||||
const responseUser: JSONObject = formatUserForSCIM(
|
||||
user!,
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
userExternalId,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
// Set status code based on whether user was created or updated
|
||||
if (isNewUser) {
|
||||
res.status(201);
|
||||
logger.debug(`SCIM Update user - returning newly created user with id: ${user!.id}`);
|
||||
} else {
|
||||
logger.debug(`SCIM Update user - returning updated user with id: ${user!.id}`);
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, responseUser);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
@@ -528,6 +912,10 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: GET Groups - projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Groups list request for projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
@@ -574,6 +962,10 @@ router.post(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: POST Create User - projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Create user request for projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
@@ -590,32 +982,68 @@ router.post(
|
||||
}
|
||||
|
||||
const scimUser: JSONObject = req.body;
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const userName: string = extractEmailFromSCIM(scimUser);
|
||||
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
|
||||
logger.debug(`SCIM Create user - email: ${email}, name: ${name}`);
|
||||
logger.debug(`SCIM Create user - userName: ${userName}, externalId: ${externalId}, name: ${name}`);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException("userName or email is required");
|
||||
// Extract email from emails array if userName is not an email
|
||||
let email: string = "";
|
||||
if (isUserNameEmail(userName)) {
|
||||
email = userName;
|
||||
} else {
|
||||
// Look for email in the emails array
|
||||
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
|
||||
if (emailsArray && emailsArray.length > 0) {
|
||||
email = emailsArray[0]?.["value"] as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
if (!email && !externalId) {
|
||||
throw new BadRequestException(
|
||||
"Either a valid email address or external ID is required",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists (by external ID first, then email)
|
||||
let user: User | null = null;
|
||||
|
||||
if (externalId) {
|
||||
user = await findUserByExternalIdOrEmail(
|
||||
externalId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && email) {
|
||||
try {
|
||||
user = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} catch (error) {
|
||||
// Email validation failed, continue without email lookup
|
||||
logger.debug(`SCIM Create user - email validation failed for: ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user if doesn't exist
|
||||
if (!user) {
|
||||
if (!email) {
|
||||
throw new BadRequestException(
|
||||
"A valid email address is required to create a new user",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Create user - creating new user for email: ${email}`,
|
||||
);
|
||||
@@ -632,6 +1060,16 @@ router.post(
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update SCIM user mapping if we have an external ID
|
||||
if (externalId && user.id) {
|
||||
await createOrUpdateSCIMUserMapping(
|
||||
user,
|
||||
externalId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
}
|
||||
|
||||
// Add user to default teams if configured
|
||||
if (scimConfig.teams && scimConfig.teams.length > 0) {
|
||||
logger.debug(
|
||||
@@ -645,6 +1083,7 @@ router.post(
|
||||
req,
|
||||
req.params["projectScimId"]!,
|
||||
"project",
|
||||
externalId,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
@@ -666,6 +1105,10 @@ router.delete(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 PROJECT SCIM API HIT: DELETE User - projectScimId: ${req.params["projectScimId"]}, userId: ${req.params["userId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`SCIM Delete user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
|
||||
);
|
||||
@@ -674,7 +1117,7 @@ router.delete(
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: ProjectSCIM = bearerData["scimConfig"] as ProjectSCIM;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const userIdParam: string = req.params["userId"]!;
|
||||
|
||||
if (!scimConfig.autoDeprovisionUsers) {
|
||||
logger.debug("SCIM Delete user - auto-deprovisioning is disabled");
|
||||
@@ -683,10 +1126,26 @@ router.delete(
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
if (!userIdParam) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Resolve user ID (could be internal ID or external ID)
|
||||
const userId: ObjectID | null = await resolveUserId(
|
||||
userIdParam,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
logger.debug(
|
||||
`SCIM Delete user - could not resolve user ID for param: ${userIdParam}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this project",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`SCIM Delete user - removing user from all teams in project: ${projectId}`,
|
||||
);
|
||||
@@ -700,7 +1159,7 @@ router.delete(
|
||||
await handleUserTeamOperations(
|
||||
"remove",
|
||||
projectId,
|
||||
new ObjectID(userId),
|
||||
userId,
|
||||
scimConfig,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SCIMMiddleware from "Common/Server/Middleware/SCIMAuthorization";
|
||||
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
|
||||
import StatusPageSCIMUserService from "Common/Server/Services/StatusPageSCIMUserService";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
@@ -13,25 +14,276 @@ import Email from "Common/Types/Email";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
|
||||
import StatusPageSCIM from "Common/Models/DatabaseModels/StatusPageSCIM";
|
||||
import StatusPageSCIMUser from "Common/Models/DatabaseModels/StatusPageSCIMUser";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import LIMIT_MAX from "Common/Types/Database/LimitMax";
|
||||
import {
|
||||
parseNameFromSCIM,
|
||||
formatUserForSCIM,
|
||||
generateServiceProviderConfig,
|
||||
generateUsersListResponse,
|
||||
parseSCIMQueryParams,
|
||||
logSCIMOperation,
|
||||
extractEmailFromSCIM,
|
||||
extractExternalIdFromSCIM,
|
||||
isUserNameEmail,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import Text from "Common/Types/Text";
|
||||
import HashedString from "Common/Types/HashedString";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// Helper function to find user by external ID or email
|
||||
const findUserByExternalIdOrEmail: (
|
||||
userName: string,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<StatusPagePrivateUser | null> = async (
|
||||
userName: string,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<StatusPagePrivateUser | null> => {
|
||||
// First check if userName is an external ID (not an email)
|
||||
if (!isUserNameEmail(userName)) {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Looking for external ID: ${userName}`,
|
||||
);
|
||||
|
||||
// Look up by external ID
|
||||
const scimUser: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
|
||||
query: {
|
||||
externalId: userName,
|
||||
statusPageId: statusPageId,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: {
|
||||
statusPagePrivateUserId: true,
|
||||
statusPagePrivateUser: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (scimUser && scimUser.statusPagePrivateUser) {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Found user by external ID: ${scimUser.statusPagePrivateUser.id}`,
|
||||
);
|
||||
return scimUser.statusPagePrivateUser;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to email lookup
|
||||
try {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Looking for email: ${userName}`,
|
||||
);
|
||||
|
||||
const user: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
email: new Email(userName),
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Found user by email: ${user.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
// If email validation fails, userName is likely an external ID but no mapping exists
|
||||
logSCIMOperation(
|
||||
"User lookup",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Email validation failed for: ${userName}, treating as external ID with no mapping`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to create or update SCIM user mapping
|
||||
const createOrUpdateSCIMUserMapping: (
|
||||
user: StatusPagePrivateUser,
|
||||
externalId: string,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<void> = async (
|
||||
user: StatusPagePrivateUser,
|
||||
externalId: string,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<void> => {
|
||||
// Check if mapping already exists
|
||||
const existingMapping: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
|
||||
query: {
|
||||
statusPagePrivateUserId: user.id!,
|
||||
statusPageId: statusPageId,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: { _id: true, externalId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
// Update existing mapping if external ID changed
|
||||
if (existingMapping.externalId !== externalId) {
|
||||
await StatusPageSCIMUserService.updateOneById({
|
||||
id: existingMapping.id!,
|
||||
data: { externalId: externalId },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logSCIMOperation(
|
||||
"SCIM mapping",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Updated external ID mapping for user ${user.id} from ${existingMapping.externalId} to ${externalId}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Create new mapping
|
||||
const scimUser: StatusPageSCIMUser = new StatusPageSCIMUser();
|
||||
scimUser.statusPageId = statusPageId;
|
||||
scimUser.projectId = projectId;
|
||||
scimUser.scimConfigId = scimConfigId;
|
||||
scimUser.statusPagePrivateUserId = user.id!;
|
||||
scimUser.externalId = externalId;
|
||||
|
||||
await StatusPageSCIMUserService.create({
|
||||
data: scimUser,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logSCIMOperation(
|
||||
"SCIM mapping",
|
||||
"status-page",
|
||||
scimConfigId.toString(),
|
||||
`Created external ID mapping for user ${user.id} with external ID ${externalId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to resolve user ID (could be internal ID or external ID)
|
||||
const resolveUserId: (
|
||||
userIdParam: string,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<ObjectID | null> = async (
|
||||
userIdParam: string,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<ObjectID | null> => {
|
||||
// First try to parse as ObjectID (internal user ID)
|
||||
try {
|
||||
const objectId: ObjectID = new ObjectID(userIdParam);
|
||||
|
||||
// Verify this user exists in the status page
|
||||
const statusPageUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
_id: objectId,
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (statusPageUser) {
|
||||
return objectId;
|
||||
}
|
||||
} catch (error) {
|
||||
// Not a valid ObjectID, continue to external ID lookup
|
||||
}
|
||||
|
||||
// Try to find by external ID
|
||||
const scimUser: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
|
||||
query: {
|
||||
externalId: userIdParam,
|
||||
statusPageId: statusPageId,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: { statusPagePrivateUserId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (scimUser && scimUser.statusPagePrivateUserId) {
|
||||
return scimUser.statusPagePrivateUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get external ID for a user
|
||||
const getExternalIdForUser: (
|
||||
userId: ObjectID,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
) => Promise<string | null> = async (
|
||||
userId: ObjectID,
|
||||
statusPageId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
scimConfigId: ObjectID,
|
||||
): Promise<string | null> => {
|
||||
const scimUser: StatusPageSCIMUser | null = await StatusPageSCIMUserService.findOneBy({
|
||||
query: {
|
||||
statusPagePrivateUserId: userId,
|
||||
statusPageId: statusPageId,
|
||||
projectId: projectId,
|
||||
scimConfigId: scimConfigId,
|
||||
},
|
||||
select: { externalId: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
return scimUser?.externalId || null;
|
||||
};
|
||||
|
||||
// SCIM Service Provider Configuration - GET /status-page-scim/v2/ServiceProviderConfig
|
||||
router.get(
|
||||
"/status-page-scim/v2/:statusPageScimId/ServiceProviderConfig",
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 STATUS PAGE SCIM API HIT: GET ServiceProviderConfig - statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
|
||||
logSCIMOperation(
|
||||
"ServiceProviderConfig",
|
||||
"status-page",
|
||||
@@ -58,6 +310,10 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 STATUS PAGE SCIM API HIT: GET Users List - statusPageScimId: ${req.params["statusPageScimId"]}, query: ${JSON.stringify(req.query)}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users list request for statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
@@ -65,22 +321,66 @@ router.get(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
|
||||
// Parse query parameters
|
||||
const startIndex: number =
|
||||
parseInt(req.query["startIndex"] as string) || 1;
|
||||
const count: number = Math.min(
|
||||
parseInt(req.query["count"] as string) || 100,
|
||||
LIMIT_PER_PROJECT,
|
||||
const { startIndex, count } = parseSCIMQueryParams(req);
|
||||
const filter: string = req.query["filter"] as string;
|
||||
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"status-page",
|
||||
req.params["statusPageScimId"]!,
|
||||
`statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
|
||||
);
|
||||
let users: Array<StatusPagePrivateUser> = [];
|
||||
|
||||
// Get all private users for this status page
|
||||
const statusPageUsers: Array<StatusPagePrivateUser> =
|
||||
await StatusPagePrivateUserService.findBy({
|
||||
// Handle SCIM filter for userName
|
||||
if (filter) {
|
||||
const emailMatch: RegExpMatchArray | null = filter.match(
|
||||
/userName eq "([^"]+)"/i,
|
||||
);
|
||||
if (emailMatch) {
|
||||
const userName: string = emailMatch[1]!;
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"status-page",
|
||||
req.params["statusPageScimId"]!,
|
||||
`filter by userName: ${userName}`,
|
||||
);
|
||||
|
||||
if (userName) {
|
||||
const user: StatusPagePrivateUser | null = await findUserByExternalIdOrEmail(
|
||||
userName,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
if (user) {
|
||||
users = [user];
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"status-page",
|
||||
req.params["statusPageScimId"]!,
|
||||
`found user with id: ${user.id}`,
|
||||
);
|
||||
} else {
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"status-page",
|
||||
req.params["statusPageScimId"]!,
|
||||
`user not found for userName: ${userName}`,
|
||||
);
|
||||
users = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get all private users for this status page
|
||||
users = await StatusPagePrivateUserService.findBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
@@ -94,40 +394,50 @@ router.get(
|
||||
limit: LIMIT_MAX,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - found ${statusPageUsers.length} users`,
|
||||
`Status Page SCIM Users - found ${users.length} users`,
|
||||
);
|
||||
|
||||
// Format users for SCIM
|
||||
const users: Array<JSONObject> = statusPageUsers.map(
|
||||
(user: StatusPagePrivateUser) => {
|
||||
return formatUserForSCIM(
|
||||
user,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
);
|
||||
},
|
||||
);
|
||||
// Format users for SCIM with external IDs
|
||||
const formattedUsers: Array<JSONObject> = [];
|
||||
|
||||
for (const user of users) {
|
||||
// Get external ID for this user if it exists
|
||||
const externalId: string | null = await getExternalIdForUser(
|
||||
user.id!,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
const userFormatted: JSONObject = formatUserForSCIM(
|
||||
user,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
externalId,
|
||||
);
|
||||
|
||||
formattedUsers.push(userFormatted);
|
||||
}
|
||||
|
||||
// Paginate the results
|
||||
const paginatedUsers: Array<JSONObject> = users.slice(
|
||||
const paginatedUsers: Array<JSONObject> = formattedUsers.slice(
|
||||
(startIndex - 1) * count,
|
||||
startIndex * count,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users response prepared with ${users.length} users`,
|
||||
`Status Page SCIM Users response prepared with ${formattedUsers.length} users`,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: users.length,
|
||||
startIndex: startIndex,
|
||||
itemsPerPage: paginatedUsers.length,
|
||||
Resources: paginatedUsers,
|
||||
});
|
||||
return Response.sendJsonObjectResponse(
|
||||
req,
|
||||
res,
|
||||
generateUsersListResponse(paginatedUsers, startIndex, formattedUsers.length),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(req, res, err as BadRequestException);
|
||||
@@ -141,6 +451,10 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 STATUS PAGE SCIM API HIT: GET Individual User - statusPageScimId: ${req.params["statusPageScimId"]}, userId: ${req.params["userId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Get individual user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
@@ -148,14 +462,33 @@ router.get(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
const userIdParam: string = req.params["userId"]!;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
`Status Page SCIM Get user - statusPageId: ${statusPageId}, userIdParam: ${userIdParam}`,
|
||||
);
|
||||
|
||||
if (!userIdParam) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Resolve user ID (could be internal ID or external ID)
|
||||
const userId: ObjectID | null = await resolveUserId(
|
||||
userIdParam,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - could not resolve user ID for param: ${userIdParam}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
@@ -163,7 +496,7 @@ router.get(
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
_id: userId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
@@ -176,18 +509,27 @@ router.get(
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Get user - user not found for userId: ${userId}`,
|
||||
`Status Page SCIM Get user - user not found for resolved userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
// Get external ID for this user if it exists
|
||||
const externalId: string | null = await getExternalIdForUser(
|
||||
statusPageUser.id!,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
statusPageUser,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
externalId,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
@@ -208,6 +550,10 @@ router.post(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 STATUS PAGE SCIM API HIT: POST Create User - statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user request for statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
@@ -215,6 +561,7 @@ router.post(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData[
|
||||
"scimConfig"
|
||||
] as StatusPageSCIM;
|
||||
@@ -226,6 +573,9 @@ router.post(
|
||||
}
|
||||
|
||||
const scimUser: JSONObject = req.body;
|
||||
const userName: string = extractEmailFromSCIM(scimUser);
|
||||
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
|
||||
const name: string = parseNameFromSCIM(scimUser);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - statusPageId: ${statusPageId}`,
|
||||
@@ -235,34 +585,67 @@ router.post(
|
||||
`Request body for Status Page SCIM Create user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
// Extract user data from SCIM payload
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
logger.debug(`Status Page SCIM Create user - userName: ${userName}, externalId: ${externalId}, name: ${name}`);
|
||||
|
||||
if (!email) {
|
||||
throw new BadRequestException("Email is required for user creation");
|
||||
// Extract email from emails array if userName is not an email
|
||||
let email: string = "";
|
||||
if (isUserNameEmail(userName)) {
|
||||
email = userName;
|
||||
} else {
|
||||
// Look for email in the emails array
|
||||
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
|
||||
if (emailsArray && emailsArray.length > 0) {
|
||||
email = emailsArray[0]?.["value"] as string;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Status Page SCIM Create user - email: ${email}`);
|
||||
if (!email && !externalId) {
|
||||
throw new BadRequestException(
|
||||
"Either a valid email address or external ID is required",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists for this status page
|
||||
let user: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
email: new Email(email),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
// Check if user already exists (by external ID first, then email)
|
||||
let user: StatusPagePrivateUser | null = null;
|
||||
|
||||
if (externalId) {
|
||||
user = await findUserByExternalIdOrEmail(
|
||||
externalId,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && email) {
|
||||
try {
|
||||
user = await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
email: new Email(email),
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
} catch (error) {
|
||||
// Email validation failed, continue without email lookup
|
||||
logger.debug(`Status Page SCIM Create user - email validation failed for: ${email}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user if doesn't exist
|
||||
if (!user) {
|
||||
if (!email) {
|
||||
throw new BadRequestException(
|
||||
"A valid email address is required to create a new user",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Create user - creating new user with email: ${email}`,
|
||||
);
|
||||
@@ -271,7 +654,7 @@ router.post(
|
||||
privateUser.statusPageId = statusPageId;
|
||||
privateUser.email = new Email(email);
|
||||
privateUser.password = new HashedString(Text.generateRandomText(32));
|
||||
privateUser.projectId = bearerData["projectId"] as ObjectID;
|
||||
privateUser.projectId = projectId;
|
||||
|
||||
// Create new status page private user
|
||||
user = await StatusPagePrivateUserService.create({
|
||||
@@ -284,11 +667,23 @@ router.post(
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update SCIM user mapping if we have an external ID
|
||||
if (externalId && user.id) {
|
||||
await createOrUpdateSCIMUserMapping(
|
||||
user,
|
||||
externalId,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
}
|
||||
|
||||
const createdUser: JSONObject = formatUserForSCIM(
|
||||
user,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
externalId,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
@@ -310,6 +705,10 @@ router.put(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 STATUS PAGE SCIM API HIT: PUT Update User - statusPageScimId: ${req.params["statusPageScimId"]}, userId: ${req.params["userId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
@@ -317,27 +716,46 @@ router.put(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
const userIdParam: string = req.params["userId"]!;
|
||||
const scimUser: JSONObject = req.body;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
`Status Page SCIM Update user - statusPageId: ${statusPageId}, userIdParam: ${userIdParam}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Request body for Status Page SCIM Update user: ${JSON.stringify(scimUser, null, 2)}`,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
if (!userIdParam) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Resolve user ID (could be internal ID or external ID)
|
||||
const userId: ObjectID | null = await resolveUserId(
|
||||
userIdParam,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - could not resolve user ID for param: ${userIdParam}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
const statusPageUser: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
_id: userId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
@@ -350,7 +768,7 @@ router.put(
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user not found for userId: ${userId}`,
|
||||
`Status Page SCIM Update user - user not found for resolved userId: ${userId}`,
|
||||
);
|
||||
throw new NotFoundException(
|
||||
"User not found or not part of this status page",
|
||||
@@ -358,27 +776,46 @@ router.put(
|
||||
}
|
||||
|
||||
// Update user information
|
||||
const email: string =
|
||||
(scimUser["userName"] as string) ||
|
||||
((scimUser["emails"] as JSONObject[])?.[0]?.["value"] as string);
|
||||
const userName: string = extractEmailFromSCIM(scimUser);
|
||||
const externalId: string | null = extractExternalIdFromSCIM(scimUser);
|
||||
const active: boolean = scimUser["active"] as boolean;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - email: ${email}, active: ${active}`,
|
||||
`Status Page SCIM Update user - userName: ${userName}, externalId: ${externalId}, active: ${active}`,
|
||||
);
|
||||
|
||||
// Extract email from emails array if userName is not an email
|
||||
let email: string = "";
|
||||
if (isUserNameEmail(userName)) {
|
||||
email = userName;
|
||||
} else {
|
||||
// Look for email in the emails array
|
||||
const emailsArray: JSONObject[] = scimUser["emails"] as JSONObject[];
|
||||
if (emailsArray && emailsArray.length > 0) {
|
||||
email = emailsArray[0]?.["value"] as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Create or update SCIM user mapping if we have an external ID
|
||||
if (externalId) {
|
||||
await createOrUpdateSCIMUserMapping(
|
||||
statusPageUser,
|
||||
externalId,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle user deactivation by deleting from status page
|
||||
if (active === false) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user marked as inactive, removing from status page`,
|
||||
);
|
||||
|
||||
const scimConfig: StatusPageSCIM = bearerData[
|
||||
"scimConfig"
|
||||
] as StatusPageSCIM;
|
||||
if (scimConfig.autoDeprovisionUsers) {
|
||||
await StatusPagePrivateUserService.deleteOneById({
|
||||
id: new ObjectID(userId),
|
||||
id: userId,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
@@ -387,54 +824,51 @@ router.put(
|
||||
);
|
||||
|
||||
// Return empty response for deleted user
|
||||
return Response.sendJsonObjectResponse(req, res, {});
|
||||
res.status(204);
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
message: "User deprovisioned",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: {
|
||||
email?: Email;
|
||||
} = {};
|
||||
|
||||
// Update email if provided and changed
|
||||
if (email && email !== statusPageUser.email?.toString()) {
|
||||
updateData.email = new Email(email);
|
||||
}
|
||||
|
||||
// Only update if there are changes
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - updating user with data: ${JSON.stringify(updateData)}`,
|
||||
`Status Page SCIM Update user - updating email from ${statusPageUser.email?.toString()} to ${email}`,
|
||||
);
|
||||
|
||||
await StatusPagePrivateUserService.updateOneById({
|
||||
id: new ObjectID(userId),
|
||||
data: updateData,
|
||||
id: userId,
|
||||
data: { email: new Email(email) },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Update user - user updated successfully`,
|
||||
);
|
||||
|
||||
// Fetch updated user
|
||||
const updatedUser: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneById({
|
||||
id: new ObjectID(userId),
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
const updatedUser: StatusPagePrivateUser | null = await StatusPagePrivateUserService.findOneById({
|
||||
id: userId,
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const userExternalId: string | null = await getExternalIdForUser(
|
||||
updatedUser.id!,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
updatedUser,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
userExternalId,
|
||||
);
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
}
|
||||
@@ -445,11 +879,19 @@ router.put(
|
||||
);
|
||||
|
||||
// If no updates were made, return the existing user
|
||||
const userExternalId: string | null = await getExternalIdForUser(
|
||||
statusPageUser.id!,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
const user: JSONObject = formatUserForSCIM(
|
||||
statusPageUser,
|
||||
req,
|
||||
req.params["statusPageScimId"]!,
|
||||
"status-page",
|
||||
userExternalId,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, user);
|
||||
@@ -466,6 +908,10 @@ router.delete(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logger.debug(
|
||||
`🔗 STATUS PAGE SCIM API HIT: DELETE User - statusPageScimId: ${req.params["statusPageScimId"]}, userId: ${req.params["userId"]}`,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
|
||||
);
|
||||
@@ -473,10 +919,9 @@ router.delete(
|
||||
const bearerData: JSONObject =
|
||||
oneuptimeRequest.bearerTokenData as JSONObject;
|
||||
const statusPageId: ObjectID = bearerData["statusPageId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData[
|
||||
"scimConfig"
|
||||
] as StatusPageSCIM;
|
||||
const userId: string = req.params["userId"]!;
|
||||
const projectId: ObjectID = bearerData["projectId"] as ObjectID;
|
||||
const scimConfig: StatusPageSCIM = bearerData["scimConfig"] as StatusPageSCIM;
|
||||
const userIdParam: string = req.params["userId"]!;
|
||||
|
||||
if (!scimConfig.autoDeprovisionUsers) {
|
||||
throw new BadRequestException(
|
||||
@@ -485,11 +930,26 @@ router.delete(
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userId: ${userId}`,
|
||||
`Status Page SCIM Delete user - statusPageId: ${statusPageId}, userIdParam: ${userIdParam}`,
|
||||
);
|
||||
|
||||
if (!userIdParam) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
}
|
||||
|
||||
// Resolve user ID (could be internal ID or external ID)
|
||||
const userId: ObjectID | null = await resolveUserId(
|
||||
userIdParam,
|
||||
statusPageId,
|
||||
projectId,
|
||||
scimConfig.id!,
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new BadRequestException("User ID is required");
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - could not resolve user ID for param: ${userIdParam}`,
|
||||
);
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
|
||||
// Check if user exists and belongs to this status page
|
||||
@@ -497,7 +957,7 @@ router.delete(
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
_id: new ObjectID(userId),
|
||||
_id: userId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
@@ -507,7 +967,7 @@ router.delete(
|
||||
|
||||
if (!statusPageUser) {
|
||||
logger.debug(
|
||||
`Status Page SCIM Delete user - user not found for userId: ${userId}`,
|
||||
`Status Page SCIM Delete user - user not found for resolved userId: ${userId}`,
|
||||
);
|
||||
// SCIM spec says to return 404 for non-existent resources
|
||||
throw new NotFoundException("User not found");
|
||||
@@ -515,7 +975,7 @@ router.delete(
|
||||
|
||||
// Delete the user from status page
|
||||
await StatusPagePrivateUserService.deleteOneById({
|
||||
id: new ObjectID(userId),
|
||||
id: userId,
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -76,16 +76,19 @@ export const formatUserForSCIM: (
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
externalId?: string | null,
|
||||
) => JSONObject = (
|
||||
user: SCIMUser,
|
||||
req: ExpressRequest,
|
||||
scimId: string,
|
||||
scimType: "project" | "status-page",
|
||||
externalId?: string | null,
|
||||
): JSONObject => {
|
||||
const baseUrl: string = `${req.protocol}://${req.get("host")}`;
|
||||
const userName: string = user.email?.toString() || "";
|
||||
const userName: string = externalId || user.email?.toString() || "";
|
||||
const email: string = user.email?.toString() || "";
|
||||
const fullName: string =
|
||||
user.name?.toString() || userName.split("@")[0] || "Unknown User";
|
||||
user.name?.toString() || email.split("@")[0] || "Unknown User";
|
||||
|
||||
const nameData: { givenName: string; familyName: string; formatted: string } =
|
||||
parseNameToSCIMFormat(fullName);
|
||||
@@ -108,7 +111,7 @@ export const formatUserForSCIM: (
|
||||
},
|
||||
emails: [
|
||||
{
|
||||
value: userName,
|
||||
value: email,
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
@@ -136,6 +139,40 @@ export const extractEmailFromSCIM: (scimUser: JSONObject) => string = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract external ID from SCIM user payload (for non-email userNames)
|
||||
*/
|
||||
export const extractExternalIdFromSCIM: (scimUser: JSONObject) => string | null = (
|
||||
scimUser: JSONObject,
|
||||
): string | null => {
|
||||
const userName: string = scimUser["userName"] as string;
|
||||
if (!userName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if userName is not an email - if it's not a valid email format, treat it as external ID
|
||||
const emailRegex: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(userName)) {
|
||||
return userName;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a userName field contains an email or external ID
|
||||
*/
|
||||
export const isUserNameEmail: (userName: string) => boolean = (
|
||||
userName: string,
|
||||
): boolean => {
|
||||
if (!userName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailRegex: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(userName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract active status from SCIM user payload
|
||||
*/
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
<link rel="apple-touch-icon-precomposed" href="/img/ou-wb.svg">
|
||||
<link rel="icon" href="/img/ou-wb.svg">
|
||||
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
|
||||
<link rel="canonical" href="/">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta property="og:title" content="OneUptime - One Complete Observability platform.">
|
||||
<meta property="og:url" content="https://oneuptime.com">
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
"ignore": [
|
||||
"greenlock.d/*"
|
||||
],
|
||||
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
|
||||
"watchOptions": {
|
||||
"useFsEvents": false,
|
||||
"interval": 500
|
||||
},
|
||||
"env": {
|
||||
"TS_NODE_TRANSPILE_ONLY": "1",
|
||||
"TS_NODE_FILES": "false"
|
||||
},
|
||||
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
|
||||
}
|
||||
@@ -178,11 +178,13 @@ import WorkspaceUserAuthToken from "./WorkspaceUserAuthToken";
|
||||
import WorkspaceProjectAuthToken from "./WorkspaceProjectAuthToken";
|
||||
import WorkspaceSetting from "./WorkspaceSetting";
|
||||
import WorkspaceNotificationRule from "./WorkspaceNotificationRule";
|
||||
import ProjectUser from "./ProjectUser";
|
||||
|
||||
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
|
||||
import MonitorFeed from "./MonitorFeed";
|
||||
import MetricType from "./MetricType";
|
||||
import ProjectSCIM from "./ProjectSCIM";
|
||||
import SCIMUser from "./SCIMUser";
|
||||
import StatusPageSCIMUser from "./StatusPageSCIMUser";
|
||||
|
||||
const AllModelTypes: Array<{
|
||||
new (): BaseModel;
|
||||
@@ -380,8 +382,6 @@ const AllModelTypes: Array<{
|
||||
WorkspaceSetting,
|
||||
WorkspaceNotificationRule,
|
||||
|
||||
ProjectUser,
|
||||
|
||||
MonitorFeed,
|
||||
|
||||
MetricType,
|
||||
@@ -389,6 +389,9 @@ const AllModelTypes: Array<{
|
||||
OnCallDutyPolicyTimeLog,
|
||||
|
||||
ProjectSCIM,
|
||||
SCIMUser,
|
||||
|
||||
StatusPageSCIMUser
|
||||
];
|
||||
|
||||
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};
|
||||
|
||||
@@ -248,6 +248,84 @@ export default class Project extends TenantModel {
|
||||
})
|
||||
public paymentProviderCustomerId?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.LongText,
|
||||
title: "Business Details / Billing Address",
|
||||
description:
|
||||
"Business legal name, address and any tax information to appear on invoices.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public businessDetails?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Business Country (ISO Alpha-2)",
|
||||
description:
|
||||
"Two-letter ISO country code for billing address (e.g., US, GB, DE).",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public businessDetailsCountry?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Email,
|
||||
title: "Finance / Accounting Email",
|
||||
description:
|
||||
"Invoices, receipts and billing related notifications will be sent to this email in addition to project owner.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Email,
|
||||
length: ColumnLength.Email,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public financeAccountingEmail?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
import Project from "./Project";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import AllowUserQueryWithoutTenant from "../../Types/Database/AllowUserQueryWithoutTenant";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy";
|
||||
import MultiTenentQueryAllowed from "../../Types/Database/MultiTenentQueryAllowed";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.CurrentUser,
|
||||
],
|
||||
delete: [],
|
||||
update: [],
|
||||
})
|
||||
@MultiTenentQueryAllowed(true)
|
||||
@AllowUserQueryWithoutTenant(true)
|
||||
@CurrentUserCanAccessRecordBy("userId")
|
||||
@TenantColumn("projectId")
|
||||
@CrudApiEndpoint(new Route("/project-user"))
|
||||
@Entity({
|
||||
name: "ProjectUser",
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "ProjectUser",
|
||||
singularName: "Project User",
|
||||
pluralName: "Project Users",
|
||||
icon: IconProp.User,
|
||||
tableDescription:
|
||||
"This model connects users and teams. This is an internal table. Its a view on TeamMembers table.",
|
||||
})
|
||||
export default class ProjectUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.CurrentUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Team,
|
||||
title: "Teams",
|
||||
description: "Teams to which this user belongs.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Team;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "ProjectUserAcceptedTeams",
|
||||
inverseJoinColumn: {
|
||||
name: "teamId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "projectUserId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public acceptedTeams?: Array<Team> = undefined; // user is accepted to these teams. This is a view on TeamMembers table.
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.CurrentUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Team,
|
||||
title: "Teams",
|
||||
description: "Teams to which this user belongs.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Team;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "ProjectUserInvitedTeams",
|
||||
inverseJoinColumn: {
|
||||
name: "teamId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "projectUserId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public invitedTeams?: Array<Team> = undefined; // user is invited to these teams.
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.CurrentUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.CurrentUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.ProjectMember,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "userId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "User",
|
||||
description: "User who belongs to this team.",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "userId" })
|
||||
public user?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectTeam,
|
||||
Permission.CurrentUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "User ID",
|
||||
description: "ID of User who belongs to this team",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public userId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
}
|
||||
287
Common/Models/DatabaseModels/SCIMUser.ts
Normal file
287
Common/Models/DatabaseModels/SCIMUser.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import Project from "./Project";
|
||||
import ProjectSCIM from "./ProjectSCIM";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteProjectSSO,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSSO,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/scim-user"))
|
||||
@Entity({
|
||||
name: "SCIMUser",
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "SCIMUser",
|
||||
singularName: "SCIM User",
|
||||
pluralName: "SCIM Users",
|
||||
icon: IconProp.User,
|
||||
tableDescription: "SCIM User mapping to store external provider user IDs",
|
||||
})
|
||||
export default class SCIMUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "scimConfigId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: ProjectSCIM,
|
||||
title: "SCIM Config",
|
||||
description: "Relation to Project SCIM Config Resource",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return ProjectSCIM;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "scimConfigId" })
|
||||
public scimConfig?: ProjectSCIM = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "SCIM Config ID",
|
||||
description: "ID of the SCIM Config this user mapping belongs to",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public scimConfigId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "userId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "User",
|
||||
description: "Relation to User Resource",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "userId" })
|
||||
public user?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "User ID",
|
||||
description: "ID of the OneUptime User this external ID maps to",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public userId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSSO,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSSO,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.LongText,
|
||||
required: true,
|
||||
title: "External ID",
|
||||
description: "External user ID from SCIM provider (e.g., Azure AD user ID)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.LongText,
|
||||
nullable: false,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public externalId?: string = undefined;
|
||||
}
|
||||
@@ -1179,6 +1179,45 @@ export default class StatusPage extends BaseModel {
|
||||
})
|
||||
public enableSlackSubscribers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectStatusPage,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectStatusPage,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectStatusPage,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Enable Microsoft Teams Subscribers",
|
||||
description:
|
||||
"Can Microsoft Teams subscribers subscribe to this Status Page?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
@ColumnBillingAccessControl({
|
||||
read: PlanType.Free,
|
||||
update: PlanType.Scale,
|
||||
create: PlanType.Free,
|
||||
})
|
||||
public enableMicrosoftTeamsSubscribers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
352
Common/Models/DatabaseModels/StatusPageSCIMUser.ts
Normal file
352
Common/Models/DatabaseModels/StatusPageSCIMUser.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import Project from "./Project";
|
||||
import StatusPage from "./StatusPage";
|
||||
import StatusPageSCIM from "./StatusPageSCIM";
|
||||
import StatusPagePrivateUser from "./StatusPagePrivateUser";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteStatusPagePrivateUser,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditStatusPagePrivateUser,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/status-page-scim-user"))
|
||||
@Entity({
|
||||
name: "StatusPageSCIMUser",
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "StatusPageSCIMUser",
|
||||
singularName: "Status Page SCIM User",
|
||||
pluralName: "Status Page SCIM Users",
|
||||
icon: IconProp.User,
|
||||
tableDescription: "Status Page SCIM User mapping to store external provider user IDs",
|
||||
})
|
||||
export default class StatusPageSCIMUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "statusPageId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: StatusPage,
|
||||
title: "Status Page",
|
||||
description: "Relation to Status Page Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return StatusPage;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "statusPageId" })
|
||||
public statusPage?: StatusPage = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Status Page ID",
|
||||
description: "ID of your Status Page in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public statusPageId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "scimConfigId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: StatusPageSCIM,
|
||||
title: "SCIM Config",
|
||||
description: "Relation to Status Page SCIM Config Resource",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return StatusPageSCIM;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "scimConfigId" })
|
||||
public scimConfig?: StatusPageSCIM = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "SCIM Config ID",
|
||||
description: "ID of the Status Page SCIM Config this user mapping belongs to",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public scimConfigId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "statusPagePrivateUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: StatusPagePrivateUser,
|
||||
title: "Status Page Private User",
|
||||
description: "Relation to Status Page Private User Resource",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return StatusPagePrivateUser;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: false,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "delete",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "statusPagePrivateUserId" })
|
||||
public statusPagePrivateUser?: StatusPagePrivateUser = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "Status Page Private User ID",
|
||||
description: "ID of the Status Page Private User this external ID maps to",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public statusPagePrivateUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateStatusPagePrivateUser,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPagePrivateUser,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.LongText,
|
||||
required: true,
|
||||
title: "External ID",
|
||||
description: "External user ID from SCIM provider (e.g., Azure AD user ID)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.LongText,
|
||||
nullable: false,
|
||||
length: ColumnLength.LongText,
|
||||
})
|
||||
public externalId?: string = undefined;
|
||||
}
|
||||
@@ -379,6 +379,65 @@ export default class StatusPageSubscriber extends BaseModel {
|
||||
})
|
||||
public slackWorkspaceName?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageSubscriber,
|
||||
Permission.Public,
|
||||
],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.LongURL,
|
||||
title: "Microsoft Teams Incoming Webhook URL",
|
||||
description:
|
||||
"Microsoft Teams incoming webhook URL to send notifications to Teams channel",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.LongURL,
|
||||
transformer: URL.getDatabaseTransformer(),
|
||||
})
|
||||
public microsoftTeamsIncomingWebhookUrl?: URL = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageSubscriber,
|
||||
Permission.Public,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageSubscriber,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditStatusPageSubscriber,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "Microsoft Teams Workspace Name",
|
||||
description:
|
||||
"Name of the Microsoft Teams workspace for validation and identification",
|
||||
})
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: ColumnType.VeryLongText,
|
||||
})
|
||||
public microsoftTeamsWorkspaceName?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -502,6 +502,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
footerHTML: true,
|
||||
enableEmailSubscribers: true,
|
||||
enableSlackSubscribers: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
enableSmsSubscribers: true,
|
||||
isPublicStatusPage: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
@@ -2146,6 +2147,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
projectId: true,
|
||||
enableEmailSubscribers: true,
|
||||
enableSlackSubscribers: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
enableSmsSubscribers: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
allowSubscribersToChooseEventTypes: true,
|
||||
@@ -2419,6 +2421,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
enableEmailSubscribers: true,
|
||||
enableSmsSubscribers: true,
|
||||
enableSlackSubscribers: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
allowSubscribersToChooseEventTypes: true,
|
||||
showSubscriberPageOnStatusPage: true,
|
||||
@@ -2480,15 +2483,28 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
}
|
||||
|
||||
if (
|
||||
!req.body.data["subscriberEmail"] &&
|
||||
!req.body.data["subscriberPhone"] &&
|
||||
!req.body.data["slackWorkspaceName"]
|
||||
req.body.data["microsoftTeamsWorkspaceName"] &&
|
||||
!statusPage.enableMicrosoftTeamsSubscribers
|
||||
) {
|
||||
logger.debug(
|
||||
`No email, phone, or slack workspace name provided for subscription to status page with ID: ${objectId}`,
|
||||
`Microsoft Teams subscribers not enabled for status page with ID: ${objectId}`,
|
||||
);
|
||||
throw new BadDataException(
|
||||
"Email, phone or slack workspace name is required to subscribe to this status page.",
|
||||
"Microsoft Teams subscribers not enabled for this status page.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!req.body.data["subscriberEmail"] &&
|
||||
!req.body.data["subscriberPhone"] &&
|
||||
!req.body.data["slackWorkspaceName"] &&
|
||||
!req.body.data["microsoftTeamsWorkspaceName"]
|
||||
) {
|
||||
logger.debug(
|
||||
`No email, phone, slack workspace name, or Microsoft Teams workspace name provided for subscription to status page with ID: ${objectId}`,
|
||||
);
|
||||
throw new BadDataException(
|
||||
"Email, phone, slack workspace name, or Microsoft Teams workspace name is required to subscribe to this status page.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2512,6 +2528,18 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
? (req.body.data["slackWorkspaceName"] as string)
|
||||
: undefined;
|
||||
|
||||
const microsoftTeamsIncomingWebhookUrl: string | undefined = req.body.data[
|
||||
"microsoftTeamsIncomingWebhookUrl"
|
||||
]
|
||||
? (req.body.data["microsoftTeamsIncomingWebhookUrl"] as string)
|
||||
: undefined;
|
||||
|
||||
const microsoftTeamsWorkspaceName: string | undefined = req.body.data[
|
||||
"microsoftTeamsWorkspaceName"
|
||||
]
|
||||
? (req.body.data["microsoftTeamsWorkspaceName"] as string)
|
||||
: undefined;
|
||||
|
||||
let statusPageSubscriber: StatusPageSubscriber | null = null;
|
||||
|
||||
let isUpdate: boolean = false;
|
||||
@@ -2570,6 +2598,23 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
statusPageSubscriber.slackWorkspaceName = slackWorkspaceName;
|
||||
}
|
||||
|
||||
if (microsoftTeamsIncomingWebhookUrl) {
|
||||
logger.debug(
|
||||
`Setting subscriber Microsoft Teams webhook: ${microsoftTeamsIncomingWebhookUrl}`,
|
||||
);
|
||||
statusPageSubscriber.microsoftTeamsIncomingWebhookUrl = URL.fromString(
|
||||
microsoftTeamsIncomingWebhookUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (microsoftTeamsWorkspaceName) {
|
||||
logger.debug(
|
||||
`Setting subscriber Microsoft Teams workspace name: ${microsoftTeamsWorkspaceName}`,
|
||||
);
|
||||
statusPageSubscriber.microsoftTeamsWorkspaceName =
|
||||
microsoftTeamsWorkspaceName;
|
||||
}
|
||||
|
||||
if (
|
||||
req.body.data["statusPageResources"] &&
|
||||
!statusPage.allowSubscribersToChooseResources
|
||||
|
||||
@@ -68,6 +68,28 @@ export class MigrationName1754671483948 implements MigrationInterface {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageAnnouncement" ADD "subscriberNotificationStatusMessage" text`,
|
||||
);
|
||||
// Set all existing rows' subscriber notification statuses to 'Success' since they were previously considered notified
|
||||
await queryRunner.query(
|
||||
`UPDATE "Incident" SET "subscriberNotificationStatusOnIncidentCreated"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentPublicNote" SET "subscriberNotificationStatusOnNoteCreated"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "IncidentStateTimeline" SET "subscriberNotificationStatus"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenance" SET "subscriberNotificationStatusOnEventScheduled"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenancePublicNote" SET "subscriberNotificationStatusOnNoteCreated"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "ScheduledMaintenanceStateTimeline" SET "subscriberNotificationStatus"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "StatusPageAnnouncement" SET "subscriberNotificationStatus"='Success'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1755775040650 implements MigrationInterface {
|
||||
public name = "MigrationName1755775040650";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPage" ADD "enableMicrosoftTeamsSubscribers" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsIncomingWebhookUrl" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsWorkspaceName" character varying(100)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsWorkspaceName"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsIncomingWebhookUrl"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPage" DROP COLUMN "enableMicrosoftTeamsSubscribers"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1755778495455 implements MigrationInterface {
|
||||
public name = "MigrationName1755778495455";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsWorkspaceName"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsWorkspaceName" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsWorkspaceName"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsWorkspaceName" character varying(100)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1755778934927 implements MigrationInterface {
|
||||
public name = "MigrationName1755778934927";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" DROP COLUMN "microsoftTeamsIncomingWebhookUrl"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPageSubscriber" ADD "microsoftTeamsIncomingWebhookUrl" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1756293325324 implements MigrationInterface {
|
||||
public name = "MigrationName1756293325324";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" ADD "businessDetails" character varying(500)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" DROP COLUMN "businessDetails"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1756296282627 implements MigrationInterface {
|
||||
public name = "MigrationName1756296282627";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" ADD "businessDetailsCountry" character varying(100)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" DROP COLUMN "businessDetailsCountry"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1756300358095 implements MigrationInterface {
|
||||
public name = "MigrationName1756300358095";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" ADD "financeAccountingEmail" character varying(100)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" DROP COLUMN "financeAccountingEmail"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1756740910798 implements MigrationInterface {
|
||||
public name = 'MigrationName1756740910798'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "SCIMUser" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "scimConfigId" uuid NOT NULL, "userId" uuid NOT NULL, "externalId" character varying(500) NOT NULL, CONSTRAINT "PK_161711d359ba1935520b5aa313e" PRIMARY KEY ("_id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_7561dd17a97f143cdffe341184" ON "SCIMUser" ("projectId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ca31718fa40f6a1ac4aa63b5d8" ON "SCIMUser" ("scimConfigId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_c0bebe6a5b38293c297a6e2b1c" ON "SCIMUser" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_3593cbfcd05e591bfe131bf58a" ON "SCIMUser" ("externalId") `);
|
||||
await queryRunner.query(`CREATE TABLE "StatusPageSCIMUser" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "scimConfigId" uuid NOT NULL, "statusPagePrivateUserId" uuid NOT NULL, "externalId" character varying(500) NOT NULL, CONSTRAINT "PK_e2fb21d6da5fc881f7adf2310f6" PRIMARY KEY ("_id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_e0f38e455921c08948b9402e8f" ON "StatusPageSCIMUser" ("projectId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_4282ed65830c3301d7b91297b3" ON "StatusPageSCIMUser" ("statusPageId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_43712b2bba1e0f13970353bee6" ON "StatusPageSCIMUser" ("scimConfigId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_8e7127bd5155fd551b218076e0" ON "StatusPageSCIMUser" ("statusPagePrivateUserId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_3cbb3996ed387428369f45b3cb" ON "StatusPageSCIMUser" ("externalId") `);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "SCIMUser" ADD CONSTRAINT "FK_7561dd17a97f143cdffe341184f" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "SCIMUser" ADD CONSTRAINT "FK_ca31718fa40f6a1ac4aa63b5d8f" FOREIGN KEY ("scimConfigId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "SCIMUser" ADD CONSTRAINT "FK_c0bebe6a5b38293c297a6e2b1c7" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_e0f38e455921c08948b9402e8ff" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_4282ed65830c3301d7b91297b3f" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_43712b2bba1e0f13970353bee64" FOREIGN KEY ("scimConfigId") REFERENCES "StatusPageSCIM"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" ADD CONSTRAINT "FK_8e7127bd5155fd551b218076e0e" FOREIGN KEY ("statusPagePrivateUserId") REFERENCES "StatusPagePrivateUser"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_8e7127bd5155fd551b218076e0e"`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_43712b2bba1e0f13970353bee64"`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_4282ed65830c3301d7b91297b3f"`);
|
||||
await queryRunner.query(`ALTER TABLE "StatusPageSCIMUser" DROP CONSTRAINT "FK_e0f38e455921c08948b9402e8ff"`);
|
||||
await queryRunner.query(`ALTER TABLE "SCIMUser" DROP CONSTRAINT "FK_c0bebe6a5b38293c297a6e2b1c7"`);
|
||||
await queryRunner.query(`ALTER TABLE "SCIMUser" DROP CONSTRAINT "FK_ca31718fa40f6a1ac4aa63b5d8f"`);
|
||||
await queryRunner.query(`ALTER TABLE "SCIMUser" DROP CONSTRAINT "FK_7561dd17a97f143cdffe341184f"`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_3cbb3996ed387428369f45b3cb"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_8e7127bd5155fd551b218076e0"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_43712b2bba1e0f13970353bee6"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_4282ed65830c3301d7b91297b3"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_e0f38e455921c08948b9402e8f"`);
|
||||
await queryRunner.query(`DROP TABLE "StatusPageSCIMUser"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_3593cbfcd05e591bfe131bf58a"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_c0bebe6a5b38293c297a6e2b1c"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ca31718fa40f6a1ac4aa63b5d8"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_7561dd17a97f143cdffe341184"`);
|
||||
await queryRunner.query(`DROP TABLE "SCIMUser"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -158,6 +158,13 @@ import { MigrationName1755088852971 } from "./1755088852971-MigrationName";
|
||||
import { MigrationName1755093133870 } from "./1755093133870-MigrationName";
|
||||
import { MigrationName1755109893911 } from "./1755109893911-MigrationName";
|
||||
import { MigrationName1755110936888 } from "./1755110936888-MigrationName";
|
||||
import { MigrationName1755775040650 } from "./1755775040650-MigrationName";
|
||||
import { MigrationName1755778495455 } from "./1755778495455-MigrationName";
|
||||
import { MigrationName1755778934927 } from "./1755778934927-MigrationName";
|
||||
import { MigrationName1756293325324 } from "./1756293325324-MigrationName";
|
||||
import { MigrationName1756296282627 } from "./1756296282627-MigrationName";
|
||||
import { MigrationName1756300358095 } from "./1756300358095-MigrationName";
|
||||
import { MigrationName1756740910798 } from "./1756740910798-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -320,4 +327,11 @@ export default [
|
||||
MigrationName1755093133870,
|
||||
MigrationName1755109893911,
|
||||
MigrationName1755110936888,
|
||||
MigrationName1755775040650,
|
||||
MigrationName1755778495455,
|
||||
MigrationName1755778934927,
|
||||
MigrationName1756293325324,
|
||||
MigrationName1756296282627,
|
||||
MigrationName1756300358095,
|
||||
MigrationName1756740910798
|
||||
];
|
||||
|
||||
@@ -14,7 +14,20 @@ export default class QueueWorker {
|
||||
public static getWorker(
|
||||
queueName: QueueName,
|
||||
onJobInQueue: (job: QueueJob) => Promise<void>,
|
||||
options: { concurrency: number },
|
||||
options: {
|
||||
concurrency: number;
|
||||
/**
|
||||
* How long (in ms) the worker will hold a lock on the job before it's considered stalled
|
||||
* if the event loop is blocked and the lock cannot be extended in time.
|
||||
* Defaults to BullMQ default (30s) if not provided.
|
||||
*/
|
||||
lockDuration?: number;
|
||||
/**
|
||||
* Maximum number of times a job can be re-processed due to stall detection
|
||||
* before being moved to failed. Defaults to BullMQ default (1) if not provided.
|
||||
*/
|
||||
maxStalledCount?: number;
|
||||
},
|
||||
): Worker {
|
||||
const worker: Worker = new Worker(queueName, onJobInQueue, {
|
||||
connection: {
|
||||
@@ -23,6 +36,11 @@ export default class QueueWorker {
|
||||
password: RedisPassword,
|
||||
},
|
||||
concurrency: options.concurrency,
|
||||
// Only set these values if provided so we do not override BullMQ defaults
|
||||
...(options.lockDuration ? { lockDuration: options.lockDuration } : {}),
|
||||
...(options.maxStalledCount !== undefined
|
||||
? { maxStalledCount: options.maxStalledCount }
|
||||
: {}),
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
|
||||
@@ -80,6 +80,94 @@ export class BillingService extends BaseService {
|
||||
await this.stripe.customers.update(id, { name: newName });
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async updateCustomerBusinessDetails(
|
||||
id: string,
|
||||
businessDetails: string,
|
||||
countryCode?: string | null,
|
||||
financeAccountingEmail?: string | null,
|
||||
): Promise<void> {
|
||||
if (!this.isBillingEnabled()) {
|
||||
throw new BadDataException(Errors.BillingService.BILLING_NOT_ENABLED);
|
||||
}
|
||||
// Goal: Update Stripe Customer "Billing details" (address fields) rather than invoice footer.
|
||||
// We only have a single free-form textarea. We'll map:
|
||||
// First non-empty line -> address.line1
|
||||
// Second non-empty line (if any) and remaining (joined, truncated) -> address.line2
|
||||
// We also persist full text in metadata so we can reconstruct or improve parsing later.
|
||||
// NOTE: Because Stripe requires structured address, any city/state/postal/country detection
|
||||
// would be heuristic; we keep it simple unless we later add structured fields.
|
||||
|
||||
const lines: Array<string> = businessDetails
|
||||
.split(/\r?\n/)
|
||||
.map((l: string) => {
|
||||
return l.trim();
|
||||
})
|
||||
.filter((l: string) => {
|
||||
return l.length > 0;
|
||||
});
|
||||
|
||||
let line1: string | undefined = undefined;
|
||||
let line2: string | undefined = undefined;
|
||||
|
||||
if (lines && lines.length > 0) {
|
||||
const first: string = lines[0]!; // non-null
|
||||
line1 = first.substring(0, 200); // Stripe typical limit safeguard.
|
||||
}
|
||||
if (lines && lines.length > 1) {
|
||||
const rest: string = lines.slice(1).join(", ");
|
||||
line2 = rest.substring(0, 200);
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {
|
||||
business_details_full: businessDetails.substring(0, 5000),
|
||||
};
|
||||
if (financeAccountingEmail) {
|
||||
metadata["finance_accounting_email"] = financeAccountingEmail.substring(
|
||||
0,
|
||||
200,
|
||||
);
|
||||
} else {
|
||||
// Remove if cleared
|
||||
metadata["finance_accounting_email"] = "";
|
||||
}
|
||||
|
||||
const updateParams: Stripe.CustomerUpdateParams = {
|
||||
metadata,
|
||||
address: {},
|
||||
};
|
||||
|
||||
// If finance / accounting email provided, set it as the customer email so Stripe sends
|
||||
// invoices / receipts there. (Stripe only supports a single email via API currently.)
|
||||
if (financeAccountingEmail && financeAccountingEmail.trim().length > 0) {
|
||||
updateParams.email = financeAccountingEmail.trim();
|
||||
}
|
||||
|
||||
if (line1) {
|
||||
updateParams.address = updateParams.address || {};
|
||||
updateParams.address.line1 = line1;
|
||||
}
|
||||
if (line2) {
|
||||
updateParams.address = updateParams.address || {};
|
||||
updateParams.address.line2 = line2;
|
||||
}
|
||||
if (countryCode) {
|
||||
updateParams.address = updateParams.address || {};
|
||||
// Stripe expects uppercase 2-letter ISO code
|
||||
updateParams.address.country = countryCode.toUpperCase();
|
||||
}
|
||||
|
||||
if (!line1 && !line2 && !countryCode) {
|
||||
// Clear address if empty details submitted.
|
||||
updateParams.address = {
|
||||
line1: "",
|
||||
line2: "",
|
||||
} as any;
|
||||
}
|
||||
|
||||
await this.stripe.customers.update(id, updateParams);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async deleteCustomer(id: string): Promise<void> {
|
||||
if (!this.isBillingEnabled()) {
|
||||
|
||||
@@ -67,6 +67,7 @@ import QueryOperator from "../../Types/BaseDatabase/QueryOperator";
|
||||
import { FindWhere } from "../../Types/BaseDatabase/Query";
|
||||
import logger from "../Utils/Logger";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -162,11 +163,11 @@ export class Service extends DatabaseService<Model> {
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
throw new BadDataException("Monitor not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
if (!monitor.id) {
|
||||
throw new BadDataException("Monitor id not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
projectId = monitor.projectId!;
|
||||
@@ -1389,7 +1390,7 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
throw new BadDataException("Monitor not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
return (monitor.postUpdatesToWorkspaceChannels || []).filter(
|
||||
@@ -1419,7 +1420,7 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
throw new BadDataException("Monitor not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
return monitor.name || "";
|
||||
|
||||
@@ -128,18 +128,50 @@ export default class OTelIngestService {
|
||||
Metric,
|
||||
) as Metric;
|
||||
|
||||
// Handle start timestamp safely
|
||||
if (datapoint["startTimeUnixNano"]) {
|
||||
newDbMetric.startTimeUnixNano = datapoint["startTimeUnixNano"] as number;
|
||||
newDbMetric.startTime = OneUptimeDate.fromUnixNano(
|
||||
datapoint["startTimeUnixNano"] as number,
|
||||
);
|
||||
try {
|
||||
let startTimeUnixNano: number;
|
||||
if (typeof datapoint["startTimeUnixNano"] === "string") {
|
||||
startTimeUnixNano = parseFloat(datapoint["startTimeUnixNano"]);
|
||||
if (isNaN(startTimeUnixNano)) {
|
||||
startTimeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
|
||||
}
|
||||
} else {
|
||||
startTimeUnixNano =
|
||||
(datapoint["startTimeUnixNano"] as number) ||
|
||||
OneUptimeDate.getCurrentDateAsUnixNano();
|
||||
}
|
||||
newDbMetric.startTimeUnixNano = startTimeUnixNano;
|
||||
newDbMetric.startTime = OneUptimeDate.fromUnixNano(startTimeUnixNano);
|
||||
} catch {
|
||||
const currentNano: number = OneUptimeDate.getCurrentDateAsUnixNano();
|
||||
newDbMetric.startTimeUnixNano = currentNano;
|
||||
newDbMetric.startTime = OneUptimeDate.getCurrentDate();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle end timestamp safely
|
||||
if (datapoint["timeUnixNano"]) {
|
||||
newDbMetric.timeUnixNano = datapoint["timeUnixNano"] as number;
|
||||
newDbMetric.time = OneUptimeDate.fromUnixNano(
|
||||
datapoint["timeUnixNano"] as number,
|
||||
);
|
||||
try {
|
||||
let timeUnixNano: number;
|
||||
if (typeof datapoint["timeUnixNano"] === "string") {
|
||||
timeUnixNano = parseFloat(datapoint["timeUnixNano"]);
|
||||
if (isNaN(timeUnixNano)) {
|
||||
timeUnixNano = OneUptimeDate.getCurrentDateAsUnixNano();
|
||||
}
|
||||
} else {
|
||||
timeUnixNano =
|
||||
(datapoint["timeUnixNano"] as number) ||
|
||||
OneUptimeDate.getCurrentDateAsUnixNano();
|
||||
}
|
||||
newDbMetric.timeUnixNano = timeUnixNano;
|
||||
newDbMetric.time = OneUptimeDate.fromUnixNano(timeUnixNano);
|
||||
} catch {
|
||||
const currentNano: number = OneUptimeDate.getCurrentDateAsUnixNano();
|
||||
newDbMetric.timeUnixNano = currentNano;
|
||||
newDbMetric.time = OneUptimeDate.getCurrentDate();
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(datapoint).includes("asInt")) {
|
||||
@@ -174,7 +206,7 @@ export default class OTelIngestService {
|
||||
serviceName: data.telemetryServiceName,
|
||||
}),
|
||||
...TelemetryUtil.getAttributes({
|
||||
items: datapoint["attributes"] as JSONArray,
|
||||
items: (datapoint["attributes"] as JSONArray) || [],
|
||||
prefixKeysWithString: "metricAttributes",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -273,6 +273,38 @@ export class ProjectService extends DatabaseService<Model> {
|
||||
updateBy: UpdateBy<Model>,
|
||||
): Promise<OnUpdate<Model>> {
|
||||
if (IsBillingEnabled) {
|
||||
if (
|
||||
updateBy.data.businessDetails ||
|
||||
updateBy.data.businessDetailsCountry ||
|
||||
updateBy.data.financeAccountingEmail
|
||||
) {
|
||||
// Sync to Stripe.
|
||||
const project: Model | null = await this.findOneById({
|
||||
id: new ObjectID(updateBy.query._id! as string),
|
||||
select: {
|
||||
paymentProviderCustomerId: true,
|
||||
financeAccountingEmail: true,
|
||||
},
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
if (project?.paymentProviderCustomerId) {
|
||||
try {
|
||||
await BillingService.updateCustomerBusinessDetails(
|
||||
project.paymentProviderCustomerId,
|
||||
(updateBy.data.businessDetails as string) || "",
|
||||
(updateBy.data.businessDetailsCountry as string) || null,
|
||||
(updateBy.data.financeAccountingEmail as string) ||
|
||||
(project as any).financeAccountingEmail ||
|
||||
null,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Failed to update Stripe customer business details: " + err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updateBy.data.enableAutoRechargeSmsOrCallBalance) {
|
||||
await NotificationService.rechargeIfBalanceIsLow(
|
||||
new ObjectID(updateBy.query._id! as string),
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import TeamMember from "../../Models/DatabaseModels/TeamMember";
|
||||
import LIMIT_MAX from "../../Types/Database/LimitMax";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/ProjectUser";
|
||||
import TeamMemberService from "./TeamMemberService";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async refreshProjectUsersByProject(data: {
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
// get all team members by user
|
||||
|
||||
// first delete all project users by project id.
|
||||
await this.deleteBy({
|
||||
query: {
|
||||
projectId: data.projectId,
|
||||
},
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// get all team members by project.
|
||||
const teamMembers: Array<TeamMember> = await TeamMemberService.findBy({
|
||||
query: {
|
||||
projectId: data.projectId,
|
||||
},
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
teamId: true,
|
||||
team: {
|
||||
_id: true,
|
||||
},
|
||||
hasAcceptedInvitation: true,
|
||||
},
|
||||
});
|
||||
|
||||
// create project users by team members.
|
||||
|
||||
const projectUsersToCreate: Array<Model> = [];
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
// check if the user already exists in the project users.
|
||||
|
||||
// if yes then add the team to the project user acceptedTeams, if the invitation is accepted.
|
||||
|
||||
// if no then create a new project user.
|
||||
|
||||
// if the user is not accepted the invitation then add the team to invitedTeams of the project user.
|
||||
|
||||
// if the user is accepted the invitation then add the team to acceptedTeams of the project user.
|
||||
|
||||
let doesProjectUserExist: boolean = false;
|
||||
|
||||
for (const item of projectUsersToCreate) {
|
||||
if (item.userId?.toString() === teamMember.userId?.toString()) {
|
||||
doesProjectUserExist = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (doesProjectUserExist) {
|
||||
// add the team to the project user acceptedTeams, if the invitation is accepted.
|
||||
if (teamMember.hasAcceptedInvitation) {
|
||||
for (const projectUser of projectUsersToCreate) {
|
||||
if (
|
||||
projectUser.userId?.toString() === teamMember.userId?.toString()
|
||||
) {
|
||||
if (!projectUser.acceptedTeams) {
|
||||
projectUser.acceptedTeams = [];
|
||||
}
|
||||
|
||||
projectUser.acceptedTeams?.push(teamMember.team!);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const projectUser of projectUsersToCreate) {
|
||||
if (
|
||||
projectUser.userId?.toString() === teamMember.userId?.toString()
|
||||
) {
|
||||
if (!projectUser.invitedTeams) {
|
||||
projectUser.invitedTeams = [];
|
||||
}
|
||||
|
||||
projectUser.invitedTeams?.push(teamMember.team!);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// create a new project user.
|
||||
const projectUser: Model = new Model();
|
||||
projectUser.userId = teamMember.userId!;
|
||||
projectUser.projectId = data.projectId;
|
||||
|
||||
if (teamMember.hasAcceptedInvitation) {
|
||||
projectUser.acceptedTeams = [teamMember.team!];
|
||||
} else {
|
||||
projectUser.invitedTeams = [teamMember.team!];
|
||||
}
|
||||
|
||||
projectUsersToCreate.push(projectUser);
|
||||
}
|
||||
}
|
||||
|
||||
// now create the project users.
|
||||
|
||||
for (const projectUser of projectUsersToCreate) {
|
||||
await this.create({
|
||||
data: projectUser,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
10
Common/Server/Services/SCIMUserService.ts
Normal file
10
Common/Server/Services/SCIMUserService.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Model from "../../Models/DatabaseModels/SCIMUser";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
10
Common/Server/Services/StatusPageSCIMUserService.ts
Normal file
10
Common/Server/Services/StatusPageSCIMUserService.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Model from "../../Models/DatabaseModels/StatusPageSCIMUser";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -340,10 +340,26 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
projectId: ObjectID,
|
||||
statusPageId: ObjectID,
|
||||
): Promise<URL> {
|
||||
const dahboardUrl: URL = await DatabaseConfig.getDashboardUrl();
|
||||
if (!projectId) {
|
||||
throw new BadDataException(
|
||||
"projectId is required to build status page dashboard link",
|
||||
);
|
||||
}
|
||||
|
||||
return URL.fromString(dahboardUrl.toString()).addRoute(
|
||||
`/${projectId.toString()}/status-pages/${statusPageId.toString()}`,
|
||||
if (!statusPageId) {
|
||||
throw new BadDataException(
|
||||
"statusPageId is required to build status page dashboard link",
|
||||
);
|
||||
}
|
||||
|
||||
const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
|
||||
|
||||
// Defensive: ensure objects have toString
|
||||
const projectIdStr: string = projectId.toString();
|
||||
const statusPageIdStr: string = statusPageId.toString();
|
||||
|
||||
return URL.fromString(dashboardUrl.toString()).addRoute(
|
||||
`/${projectIdStr}/status-pages/${statusPageIdStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
|
||||
import NumberUtil from "../../Utils/Number";
|
||||
import SlackUtil from "../Utils/Workspace/Slack/Slack";
|
||||
import MicrosoftTeamsUtil from "../Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -221,6 +222,23 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Microsoft Teams webhook URL if provided
|
||||
if (data.data.microsoftTeamsIncomingWebhookUrl) {
|
||||
logger.debug(
|
||||
`Microsoft Teams Incoming Webhook URL: ${data.data.microsoftTeamsIncomingWebhookUrl}`,
|
||||
);
|
||||
if (
|
||||
!MicrosoftTeamsUtil.isValidMicrosoftTeamsIncomingWebhookUrl(
|
||||
data.data.microsoftTeamsIncomingWebhookUrl,
|
||||
)
|
||||
) {
|
||||
logger.debug("Invalid Microsoft Teams Incoming Webhook URL.");
|
||||
throw new BadDataException(
|
||||
"Invalid Microsoft Teams Incoming Webhook URL.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
data.data.subscriptionConfirmationToken = NumberUtil.getRandomNumber(
|
||||
100000,
|
||||
999999,
|
||||
@@ -370,16 +388,55 @@ Stay informed about service availability! 🚀`;
|
||||
|
||||
logger.debug(`Slack Message: ${slackMessage}`);
|
||||
|
||||
try {
|
||||
await SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(createdItem.slackIncomingWebhookUrl.toString()),
|
||||
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
|
||||
SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(createdItem.slackIncomingWebhookUrl.toString()),
|
||||
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
|
||||
})
|
||||
.then(() => {
|
||||
logger.debug("Slack notification sent successfully.");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger.error("Error sending Slack notification:");
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// if Microsoft Teams incoming webhook is provided and sendYouHaveSubscribedMessage is true, then send a message to the Teams channel.
|
||||
if (
|
||||
createdItem.microsoftTeamsIncomingWebhookUrl &&
|
||||
createdItem.sendYouHaveSubscribedMessage
|
||||
) {
|
||||
logger.debug("Sending Microsoft Teams notification for new subscriber.");
|
||||
const teamsMessage: string = `## 📢 New Subscription to ${statusPageName}
|
||||
|
||||
**You have successfully subscribed to receive status updates!**
|
||||
|
||||
🔗 **Status Page:** [${statusPageName}](${statusPageURL})
|
||||
📧 **Manage Subscription:** [Update preferences or unsubscribe](${unsubscribeLink})
|
||||
|
||||
You will receive real-time notifications for:
|
||||
• Incidents and outages
|
||||
• Scheduled maintenance events
|
||||
• Service announcements
|
||||
• Status updates
|
||||
|
||||
Stay informed about service availability! 🚀`;
|
||||
|
||||
logger.debug(`Teams Message: ${teamsMessage}`);
|
||||
|
||||
MicrosoftTeamsUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(
|
||||
createdItem.microsoftTeamsIncomingWebhookUrl.toString(),
|
||||
),
|
||||
text: teamsMessage,
|
||||
})
|
||||
.then(() => {
|
||||
logger.debug("Microsoft Teams notification sent successfully.");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger.error("Error sending Microsoft Teams notification:");
|
||||
logger.error(err);
|
||||
});
|
||||
logger.debug("Slack notification sent successfully.");
|
||||
} catch (error) {
|
||||
logger.error("Error sending Slack notification:");
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("onCreateSuccess completed.");
|
||||
@@ -691,6 +748,7 @@ Stay informed about service availability! 🚀`;
|
||||
subscriberPhone: true,
|
||||
subscriberWebhook: true,
|
||||
slackIncomingWebhookUrl: true,
|
||||
microsoftTeamsIncomingWebhookUrl: true,
|
||||
isSubscribedToAllResources: true,
|
||||
statusPageResources: true,
|
||||
isSubscribedToAllEventTypes: true,
|
||||
|
||||
@@ -33,7 +33,6 @@ import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import Project from "../../Models/DatabaseModels/Project";
|
||||
import TeamMember from "../../Models/DatabaseModels/TeamMember";
|
||||
import User from "../../Models/DatabaseModels/User";
|
||||
import ProjectUserService from "./ProjectUserService";
|
||||
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
|
||||
import OneUptimeDate from "../../Types/Date";
|
||||
|
||||
@@ -198,12 +197,6 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
|
||||
onCreate.createBy.data.projectId!,
|
||||
);
|
||||
|
||||
ProjectUserService.refreshProjectUsersByProject({
|
||||
projectId: onCreate.createBy.data.projectId!,
|
||||
}).catch((err: Error) => {
|
||||
logger.error(err);
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@@ -247,12 +240,6 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
|
||||
item.user?.email as Email,
|
||||
);
|
||||
}
|
||||
|
||||
ProjectUserService.refreshProjectUsersByProject({
|
||||
projectId: item.projectId!,
|
||||
}).catch((err: Error) => {
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
return { updateBy, carryForward: onUpdate.carryForward };
|
||||
@@ -335,13 +322,6 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
|
||||
item.userId!,
|
||||
item.projectId!,
|
||||
);
|
||||
|
||||
// refresh project users.
|
||||
ProjectUserService.refreshProjectUsersByProject({
|
||||
projectId: item.projectId!,
|
||||
}).catch((err: Error) => {
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
return onDelete;
|
||||
|
||||
@@ -49,6 +49,7 @@ import WorkspaceNotificationLog from "../../Models/DatabaseModels/WorkspaceNotif
|
||||
import WorkspaceNotificationLogService from "./WorkspaceNotificationLogService";
|
||||
import WorkspaceNotificationStatus from "../../Types/Workspace/WorkspaceNotificationStatus";
|
||||
import WorkspaceNotificationActionType from "../../Types/Workspace/WorkspaceNotificationActionType";
|
||||
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
|
||||
|
||||
export interface MessageBlocksByWorkspaceType {
|
||||
workspaceType: WorkspaceType;
|
||||
@@ -1911,7 +1912,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
if (!monitor) {
|
||||
logger.debug("Monitor not found for ID:");
|
||||
logger.debug(data.notificationFor.monitorId);
|
||||
throw new BadDataException("Monitor ID not found");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
const monitorLabels: Array<Label> = monitor?.labels || [];
|
||||
|
||||
@@ -19,9 +19,8 @@ export default class Markdown {
|
||||
markdown: string,
|
||||
contentType: MarkdownContentType,
|
||||
): Promise<string> {
|
||||
// convert tags > and < to > and <
|
||||
markdown = markdown.replace(/</g, "<");
|
||||
markdown = markdown.replace(/>/g, ">");
|
||||
// Basic sanitization: neutralize script tags but preserve markdown syntax like '>' for blockquotes.
|
||||
markdown = markdown.replace(/<script/gi, "<script");
|
||||
|
||||
let renderer: Renderer | null = null;
|
||||
|
||||
@@ -78,7 +77,7 @@ export default class Markdown {
|
||||
};
|
||||
|
||||
renderer.code = function (code, language) {
|
||||
return `<pre class="language-${language} rounded-md"><code class="language-${language} rounded-md">${code}</code></pre>`;
|
||||
return `<pre class="language-${language} rounded-xl bg-slate-900/95 text-slate-100 p-5 overflow-x-auto text-sm shadow-md ring-1 ring-slate-900/10"><code class="language-${language}">${code}</code></pre>`;
|
||||
};
|
||||
|
||||
renderer.heading = function (text, level) {
|
||||
@@ -137,6 +136,102 @@ export default class Markdown {
|
||||
return `<h6 class="my-5 tracking-tight font-bold text-gray-800">${text}</h6>`;
|
||||
};
|
||||
|
||||
// Lists
|
||||
renderer.list = function (body, ordered, start) {
|
||||
const tag: string = ordered ? "ol" : "ul";
|
||||
const cls: string = ordered
|
||||
? "list-decimal pl-6 my-6 space-y-2 text-gray-700"
|
||||
: "list-disc pl-6 my-6 space-y-2 text-gray-700";
|
||||
const startAttr: string =
|
||||
ordered && start !== 1 ? ` start="${start}"` : "";
|
||||
return `<${tag}${startAttr} class="${cls}">${body}</${tag}>`;
|
||||
};
|
||||
renderer.listitem = function (text) {
|
||||
return `<li class="leading-7">${text}</li>`;
|
||||
};
|
||||
|
||||
// Tables
|
||||
renderer.table = function (header, body) {
|
||||
return `<div class="overflow-x-auto my-8"><table class="min-w-full border border-gray-200 text-sm text-left">
|
||||
${header}${body}
|
||||
</table></div>`;
|
||||
};
|
||||
renderer.tablerow = function (content) {
|
||||
return `<tr class="border-b last:border-b-0">${content}</tr>`;
|
||||
};
|
||||
renderer.tablecell = function (content, flags) {
|
||||
const type: string = flags.header ? "th" : "td";
|
||||
const base: string = "px-4 py-2 border-r last:border-r-0 border-gray-200";
|
||||
const align: string = flags.align ? ` text-${flags.align}` : "";
|
||||
const weight: string = flags.header ? " font-semibold bg-gray-50" : "";
|
||||
return `<${type} class="${base}${align}${weight}">${content}</${type}>`;
|
||||
};
|
||||
|
||||
// Inline code
|
||||
renderer.codespan = function (code) {
|
||||
return `<code class="rounded-md bg-gray-100 px-1.5 py-0.5 text-sm text-pink-600">${code}</code>`;
|
||||
};
|
||||
|
||||
// Horizontal rule
|
||||
renderer.hr = function () {
|
||||
return '<hr class="my-12 border-t border-gray-200" />';
|
||||
};
|
||||
|
||||
// Emphasis / Strong / Strikethrough
|
||||
renderer.strong = function (text) {
|
||||
return `<strong class="font-semibold text-gray-800">${text}</strong>`;
|
||||
};
|
||||
renderer.em = function (text) {
|
||||
return `<em class="italic text-gray-700">${text}</em>`;
|
||||
};
|
||||
renderer.del = function (text) {
|
||||
return `<del class="line-through text-gray-400">${text}</del>`;
|
||||
};
|
||||
|
||||
// Images
|
||||
renderer.image = function (href, _title, text) {
|
||||
return `<figure class="my-8"><img src="${href}" alt="${text}" class="rounded-xl shadow-sm border border-gray-200" loading="lazy"/><figcaption class="mt-2 text-center text-sm text-gray-500">${text || ""}</figcaption></figure>`;
|
||||
};
|
||||
|
||||
// Links
|
||||
// We explicitly add underline + color classes because Tailwind Typography (prose-*)
|
||||
// styles may get overridden by surrounding utility classes or global resets.
|
||||
// External links open in a new tab with proper rel attributes; internal links stay in-page.
|
||||
renderer.link = function (href, title, text) {
|
||||
// Guard: if no href, just return the text.
|
||||
if (!href) {
|
||||
return text as string;
|
||||
}
|
||||
|
||||
const isHash: boolean = href.startsWith("#");
|
||||
const isMailTo: boolean = href.startsWith("mailto:");
|
||||
const isTel: boolean = href.startsWith("tel:");
|
||||
const isInternal: boolean =
|
||||
href.startsWith("/") ||
|
||||
href.includes("oneuptime.com") ||
|
||||
isHash ||
|
||||
isMailTo ||
|
||||
isTel;
|
||||
|
||||
const baseClasses: string = [
|
||||
"font-semibold",
|
||||
"text-indigo-600",
|
||||
"underline",
|
||||
"underline-offset-2",
|
||||
"decoration-indigo-300",
|
||||
"hover:decoration-indigo-500",
|
||||
"hover:text-indigo-500",
|
||||
"transition-colors",
|
||||
].join(" ");
|
||||
|
||||
const titleAttr: string = title ? ` title="${title}"` : "";
|
||||
const externalAttrs: string = isInternal
|
||||
? ""
|
||||
: ' target="_blank" rel="noopener noreferrer"';
|
||||
|
||||
return `<a href="${href}"${titleAttr} class="${baseClasses}"${externalAttrs}>${text}</a>`;
|
||||
};
|
||||
|
||||
this.blogRenderer = renderer;
|
||||
|
||||
return renderer;
|
||||
|
||||
@@ -58,6 +58,7 @@ import CaptureSpan from "../Telemetry/CaptureSpan";
|
||||
import MetricType from "../../../Models/DatabaseModels/MetricType";
|
||||
import MonitorLog from "../../../Models/AnalyticsModels/MonitorLog";
|
||||
import MonitorLogService from "../../Services/MonitorLogService";
|
||||
import ExceptionMessages from "../../../Types/Exception/ExceptionMessages";
|
||||
|
||||
export default class MonitorResourceUtil {
|
||||
@CaptureSpan()
|
||||
@@ -109,7 +110,7 @@ export default class MonitorResourceUtil {
|
||||
|
||||
if (!monitor) {
|
||||
logger.debug(`${dataToProcess.monitorId.toString()} Monitor not found`);
|
||||
throw new BadDataException("Monitor not found");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
if (!monitor.projectId) {
|
||||
@@ -126,9 +127,7 @@ export default class MonitorResourceUtil {
|
||||
`${dataToProcess.monitorId.toString()} Monitor is disabled. Please enable it to start monitoring again.`,
|
||||
);
|
||||
|
||||
throw new BadDataException(
|
||||
"Monitor is disabled. Please enable it to start monitoring again.",
|
||||
);
|
||||
throw new BadDataException(ExceptionMessages.MonitorDisabled);
|
||||
}
|
||||
|
||||
if (monitor.disableActiveMonitoringBecauseOfManualIncident) {
|
||||
|
||||
@@ -3,414 +3,154 @@ import HTTPResponse from "../../../../Types/API/HTTPResponse";
|
||||
import URL from "../../../../Types/API/URL";
|
||||
import { JSONObject } from "../../../../Types/JSON";
|
||||
import API from "../../../../Utils/API";
|
||||
import WorkspaceMessagePayload from "../../../../Types/Workspace/WorkspaceMessagePayload";
|
||||
import logger from "../../Logger";
|
||||
import Dictionary from "../../../../Types/Dictionary";
|
||||
import WorkspaceBase, {
|
||||
WorkspaceChannel,
|
||||
WorkspaceSendMessageResponse,
|
||||
WorkspaceThread,
|
||||
} from "../WorkspaceBase";
|
||||
import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
|
||||
import OneUptimeDate from "../../../../Types/Date";
|
||||
import WorkspaceBase from "../WorkspaceBase";
|
||||
import CaptureSpan from "../../Telemetry/CaptureSpan";
|
||||
import BadDataException from "../../../../Types/Exception/BadDataException";
|
||||
|
||||
export default class MicrosoftTeams extends WorkspaceBase {
|
||||
@CaptureSpan()
|
||||
public static override async getAllWorkspaceChannels(data: {
|
||||
authToken: string;
|
||||
}): Promise<Dictionary<WorkspaceChannel>> {
|
||||
logger.debug("Getting all workspace channels with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const channels: Dictionary<WorkspaceChannel> = {};
|
||||
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get<JSONObject>(
|
||||
URL.fromString("https://graph.microsoft.com/v1.0/me/joinedTeams"),
|
||||
{
|
||||
Authorization: `Bearer ${data.authToken}`,
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug("Response from Microsoft Graph API for getting all channels:");
|
||||
logger.debug(JSON.stringify(response, null, 2));
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error("Error response from Microsoft Graph API:");
|
||||
logger.error(response);
|
||||
throw response;
|
||||
}
|
||||
|
||||
for (const team of (response.jsonData as JSONObject)?.[
|
||||
"value"
|
||||
] as Array<JSONObject>) {
|
||||
if (!team["id"] || !team["displayName"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels[team["displayName"].toString()] = {
|
||||
id: team["id"] as string,
|
||||
name: team["displayName"] as string,
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("All workspace channels obtained:");
|
||||
logger.debug(channels);
|
||||
return channels;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override getDividerBlock(): JSONObject {
|
||||
return {
|
||||
type: "divider",
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static getValuesFromView(data: {
|
||||
view: JSONObject;
|
||||
}): Dictionary<string | number | Array<string | number> | Date> {
|
||||
logger.debug("Getting values from view with data:");
|
||||
logger.debug(JSON.stringify(data, null, 2));
|
||||
|
||||
const teamsView: JSONObject = data.view;
|
||||
const values: Dictionary<string | number | Array<string | number> | Date> =
|
||||
{};
|
||||
|
||||
if (!teamsView["state"] || !(teamsView["state"] as JSONObject)["values"]) {
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const valueId in (teamsView["state"] as JSONObject)[
|
||||
"values"
|
||||
] as JSONObject) {
|
||||
for (const blockId in (
|
||||
(teamsView["state"] as JSONObject)["values"] as JSONObject
|
||||
)[valueId] as JSONObject) {
|
||||
const valueObject: JSONObject = (
|
||||
(teamsView["state"] as JSONObject)["values"] as JSONObject
|
||||
)[valueId] as JSONObject;
|
||||
const value: JSONObject = valueObject[blockId] as JSONObject;
|
||||
values[blockId] = value["value"] as string | number;
|
||||
|
||||
if ((value["selected_option"] as JSONObject)?.["value"]) {
|
||||
values[blockId] = (value["selected_option"] as JSONObject)?.[
|
||||
"value"
|
||||
] as string;
|
||||
}
|
||||
|
||||
if (Array.isArray(value["selected_options"])) {
|
||||
values[blockId] = (
|
||||
value["selected_options"] as Array<JSONObject>
|
||||
).map((option: JSONObject) => {
|
||||
return option["value"] as string | number;
|
||||
});
|
||||
}
|
||||
|
||||
// if date picker
|
||||
if (value["selected_date_time"]) {
|
||||
values[blockId] = OneUptimeDate.fromUnixTimestamp(
|
||||
value["selected_date_time"] as number,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Values obtained from view:");
|
||||
logger.debug(values);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async inviteUserToChannelByChannelName(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
workspaceUserId: string;
|
||||
}): Promise<void> {
|
||||
logger.debug("Inviting user to channel with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const channelId: string = (
|
||||
await this.getWorkspaceChannelFromChannelName({
|
||||
authToken: data.authToken,
|
||||
channelName: data.channelName,
|
||||
private static buildMessageCardFromMarkdown(markdown: string): JSONObject {
|
||||
// Teams MessageCard has limited markdown support. Headings like '##' are not supported
|
||||
// and single newlines can collapse. Convert common patterns to a structured card.
|
||||
const lines: Array<string> = markdown
|
||||
.split("\n")
|
||||
.map((l: string) => {
|
||||
return l.trim();
|
||||
})
|
||||
).id;
|
||||
|
||||
return this.inviteUserToChannelByChannelId({
|
||||
authToken: data.authToken,
|
||||
channelId: channelId,
|
||||
workspaceUserId: data.workspaceUserId,
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async createChannelsIfDoesNotExist(data: {
|
||||
authToken: string;
|
||||
channelNames: Array<string>;
|
||||
}): Promise<Array<WorkspaceChannel>> {
|
||||
logger.debug("Creating channels if they do not exist with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const workspaceChannels: Array<WorkspaceChannel> = [];
|
||||
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
.filter((l: string) => {
|
||||
return l.length > 0;
|
||||
});
|
||||
|
||||
logger.debug("Existing workspace channels:");
|
||||
logger.debug(existingWorkspaceChannels);
|
||||
let title: string = "";
|
||||
const facts: Array<JSONObject> = [];
|
||||
const actions: Array<JSONObject> = [];
|
||||
const bodyTextParts: Array<string> = [];
|
||||
|
||||
for (let channelName of data.channelNames) {
|
||||
// if channel name starts with #, remove it
|
||||
if (channelName && channelName.startsWith("#")) {
|
||||
channelName = channelName.substring(1);
|
||||
}
|
||||
|
||||
// convert channel name to lowercase
|
||||
channelName = channelName.toLowerCase();
|
||||
|
||||
// replace spaces with hyphens
|
||||
channelName = channelName.replace(/\s+/g, "-");
|
||||
|
||||
if (existingWorkspaceChannels[channelName]) {
|
||||
logger.debug(`Channel ${channelName} already exists.`);
|
||||
workspaceChannels.push(existingWorkspaceChannels[channelName]!);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(`Channel ${channelName} does not exist. Creating channel.`);
|
||||
const channel: WorkspaceChannel = await this.createChannel({
|
||||
authToken: data.authToken,
|
||||
channelName: channelName,
|
||||
});
|
||||
|
||||
if (channel) {
|
||||
logger.debug(`Channel ${channelName} created successfully.`);
|
||||
workspaceChannels.push(channel);
|
||||
}
|
||||
// Extract title from the first non-empty line and strip markdown heading markers
|
||||
if (lines.length > 0) {
|
||||
const firstLine: string = lines[0] ?? "";
|
||||
title = firstLine
|
||||
.replace(/^#+\s*/, "") // remove leading markdown headers like ##
|
||||
.replace(/^\*\*|\*\*$/g, "") // remove stray bold markers if any
|
||||
.trim();
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
logger.debug("Channels created or found:");
|
||||
logger.debug(workspaceChannels);
|
||||
return workspaceChannels;
|
||||
}
|
||||
const linkRegex: RegExp = /\[([^\]]+)\]\(([^)]+)\)/g; // [text](url)
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async getWorkspaceChannelFromChannelName(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
}): Promise<WorkspaceChannel> {
|
||||
logger.debug("Getting workspace channel ID from channel name with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const channels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
});
|
||||
|
||||
logger.debug("All workspace channels:");
|
||||
logger.debug(channels);
|
||||
|
||||
if (!channels[data.channelName]) {
|
||||
logger.error("Channel not found.");
|
||||
throw new BadDataException("Channel not found.");
|
||||
}
|
||||
|
||||
logger.debug("Workspace channel ID obtained:");
|
||||
logger.debug(channels[data.channelName]!.id);
|
||||
|
||||
return channels[data.channelName]!;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async getWorkspaceChannelFromChannelId(data: {
|
||||
authToken: string;
|
||||
channelId: string;
|
||||
}): Promise<WorkspaceChannel> {
|
||||
logger.debug("Getting workspace channel from channel ID with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get<JSONObject>(
|
||||
URL.fromString(
|
||||
`https://graph.microsoft.com/v1.0/teams/${data.channelId}`,
|
||||
),
|
||||
{
|
||||
Authorization: `Bearer ${data.authToken}`,
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug("Response from Microsoft Graph API for getting channel info:");
|
||||
logger.debug(response);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error("Error response from Microsoft Graph API:");
|
||||
logger.error(response);
|
||||
throw response;
|
||||
}
|
||||
|
||||
if (!(response.jsonData as JSONObject)?.["displayName"]) {
|
||||
logger.error("Invalid response from Microsoft Graph API:");
|
||||
logger.error(response.jsonData);
|
||||
throw new Error("Invalid response");
|
||||
}
|
||||
|
||||
const channel: WorkspaceChannel = {
|
||||
name: (response.jsonData as JSONObject)["displayName"] as string,
|
||||
id: data.channelId,
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
};
|
||||
|
||||
logger.debug("Workspace channel obtained:");
|
||||
logger.debug(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async doesChannelExist(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
}): Promise<boolean> {
|
||||
// if channel name starts with #, remove it
|
||||
if (data.channelName && data.channelName.startsWith("#")) {
|
||||
data.channelName = data.channelName.substring(1);
|
||||
}
|
||||
|
||||
// convert channel name to lowercase
|
||||
data.channelName = data.channelName.toLowerCase();
|
||||
|
||||
// get channel id from channel name
|
||||
const channels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
});
|
||||
|
||||
// if this channel exists
|
||||
if (channels[data.channelName]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async sendMessage(data: {
|
||||
workspaceMessagePayload: WorkspaceMessagePayload;
|
||||
authToken: string; // which auth token should we use to send.
|
||||
userId: string;
|
||||
}): Promise<WorkspaceSendMessageResponse> {
|
||||
logger.debug("Sending message to Microsoft Teams with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const blocks: Array<JSONObject> = this.getBlocksFromWorkspaceMessagePayload(
|
||||
data.workspaceMessagePayload,
|
||||
);
|
||||
|
||||
logger.debug("Blocks generated from workspace message payload:");
|
||||
logger.debug(blocks);
|
||||
|
||||
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
});
|
||||
|
||||
logger.debug("Existing workspace channels:");
|
||||
logger.debug(existingWorkspaceChannels);
|
||||
|
||||
const workspaceChannelsToPostTo: Array<WorkspaceChannel> = [];
|
||||
|
||||
for (let channelName of data.workspaceMessagePayload.channelNames) {
|
||||
if (channelName && channelName.startsWith("#")) {
|
||||
// trim # from channel name
|
||||
channelName = channelName.substring(1);
|
||||
}
|
||||
|
||||
let channel: WorkspaceChannel | null = null;
|
||||
|
||||
if (existingWorkspaceChannels[channelName]) {
|
||||
channel = existingWorkspaceChannels[channelName]!;
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
workspaceChannelsToPostTo.push(channel);
|
||||
} else {
|
||||
logger.debug(`Channel ${channelName} does not exist.`);
|
||||
}
|
||||
}
|
||||
|
||||
// add channel ids.
|
||||
for (const channelId of data.workspaceMessagePayload.channelIds) {
|
||||
try {
|
||||
// Get the channel info including name from channel ID
|
||||
const channel: WorkspaceChannel =
|
||||
await this.getWorkspaceChannelFromChannelId({
|
||||
authToken: data.authToken,
|
||||
channelId: channelId,
|
||||
});
|
||||
|
||||
workspaceChannelsToPostTo.push(channel);
|
||||
} catch (err) {
|
||||
logger.error(`Error getting channel info for channel ID ${channelId}:`);
|
||||
logger.error(err);
|
||||
|
||||
// Fallback: create channel object with empty name if API call fails
|
||||
const channel: WorkspaceChannel = {
|
||||
id: channelId,
|
||||
name: channelId,
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
};
|
||||
|
||||
workspaceChannelsToPostTo.push(channel);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Channel IDs to post to:");
|
||||
logger.debug(workspaceChannelsToPostTo);
|
||||
|
||||
const workspaceMessageResponse: WorkspaceSendMessageResponse = {
|
||||
threads: [],
|
||||
workspaceType: WorkspaceType.MicrosoftTeams,
|
||||
};
|
||||
|
||||
for (const channel of workspaceChannelsToPostTo) {
|
||||
try {
|
||||
// check if the user is in the channel.
|
||||
const isUserInChannel: boolean = await this.isUserInChannel({
|
||||
authToken: data.authToken,
|
||||
channelId: channel.id,
|
||||
userId: data.userId,
|
||||
for (const line of lines) {
|
||||
// Extract links to actions and strip them from text
|
||||
let lineWithoutLinks: string = line;
|
||||
let match: RegExpExecArray | null = null;
|
||||
while ((match = linkRegex.exec(line))) {
|
||||
const name: string = match[1] ?? "";
|
||||
const url: string = match[2] ?? "";
|
||||
actions.push({
|
||||
["@type"]: "OpenUri",
|
||||
name: name,
|
||||
targets: [
|
||||
{
|
||||
os: "default",
|
||||
uri: url,
|
||||
},
|
||||
],
|
||||
});
|
||||
lineWithoutLinks = lineWithoutLinks.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
if (!isUserInChannel) {
|
||||
// add user to the channel
|
||||
await this.joinChannel({
|
||||
authToken: data.authToken,
|
||||
channelId: channel.id,
|
||||
});
|
||||
// Parse facts of the form **Label:** value
|
||||
const factMatch: RegExpExecArray | null = new RegExp(
|
||||
"\\*\\*(.*?):\\*\\*\\s*(.*)",
|
||||
).exec(lineWithoutLinks);
|
||||
|
||||
if (factMatch) {
|
||||
const name: string = (factMatch[1] ?? "").trim();
|
||||
const value: string = (factMatch[2] ?? "").trim();
|
||||
if (
|
||||
name.toLowerCase() === "description" ||
|
||||
name.toLowerCase() === "note"
|
||||
) {
|
||||
bodyTextParts.push(`**${name}:** ${value}`);
|
||||
} else {
|
||||
facts.push({ name: name, value: value });
|
||||
}
|
||||
|
||||
const thread: WorkspaceThread = await this.sendPayloadBlocksToChannel({
|
||||
authToken: data.authToken,
|
||||
workspaceChannel: channel,
|
||||
blocks: blocks,
|
||||
});
|
||||
|
||||
workspaceMessageResponse.threads.push(thread);
|
||||
|
||||
logger.debug(`Message sent to channel ID ${channel.id} successfully.`);
|
||||
} catch (e) {
|
||||
logger.error(`Error sending message to channel ID ${channel.id}:`);
|
||||
logger.error(e);
|
||||
} else if (lineWithoutLinks) {
|
||||
bodyTextParts.push(lineWithoutLinks);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Message sent successfully.");
|
||||
logger.debug(workspaceMessageResponse);
|
||||
const payload: JSONObject = {
|
||||
["@type"]: "MessageCard",
|
||||
["@context"]: "https://schema.org/extensions",
|
||||
title: title,
|
||||
summary: title,
|
||||
};
|
||||
|
||||
return workspaceMessageResponse;
|
||||
if (bodyTextParts.length > 0) {
|
||||
payload["text"] = bodyTextParts.join("\n\n");
|
||||
}
|
||||
|
||||
if (facts.length > 0) {
|
||||
payload["sections"] = [
|
||||
{
|
||||
facts: facts,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
payload["potentialAction"] = actions;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override async sendMessageToChannelViaIncomingWebhook(data: {
|
||||
url: URL;
|
||||
text: string;
|
||||
}): Promise<HTTPResponse<JSONObject> | HTTPErrorResponse> {
|
||||
logger.debug("Sending message to Teams channel via incoming webhook:");
|
||||
logger.debug(data);
|
||||
|
||||
// Build a structured MessageCard from markdown for better rendering in Teams
|
||||
const payload: JSONObject = this.buildMessageCardFromMarkdown(data.text);
|
||||
|
||||
const apiResult: HTTPResponse<JSONObject> | HTTPErrorResponse | null =
|
||||
await API.post(data.url, payload);
|
||||
|
||||
if (!apiResult) {
|
||||
logger.error(
|
||||
"Could not send message to Teams channel via incoming webhook.",
|
||||
);
|
||||
throw new Error(
|
||||
"Could not send message to Teams channel via incoming webhook.",
|
||||
);
|
||||
}
|
||||
|
||||
if (apiResult instanceof HTTPErrorResponse) {
|
||||
logger.error(
|
||||
"Error sending message to Teams channel via incoming webhook:",
|
||||
);
|
||||
logger.error(apiResult);
|
||||
throw apiResult;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Message sent to Teams channel via incoming webhook successfully:",
|
||||
);
|
||||
logger.debug(apiResult);
|
||||
|
||||
return apiResult;
|
||||
}
|
||||
|
||||
public static isValidMicrosoftTeamsIncomingWebhookUrl(
|
||||
incomingWebhookUrl: URL,
|
||||
): boolean {
|
||||
// Check if the URL contains outlook.office.com or office.com webhook pattern
|
||||
const urlString: string = incomingWebhookUrl.toString();
|
||||
return (
|
||||
urlString.includes("outlook.office.com") ||
|
||||
urlString.includes("office.com")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
<link rel="apple-touch-icon-precomposed" href="/img/ou-wb.svg">
|
||||
<link rel="icon" href="/img/ou-wb.svg">
|
||||
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
|
||||
<link rel="canonical" href="/">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta property="og:title" content="OneUptime - One Complete Observability platform.">
|
||||
<meta property="og:url" content="https://oneuptime.com">
|
||||
|
||||
@@ -28,21 +28,10 @@ import EmptyResponseData from "../../../Types/API/EmptyResponse";
|
||||
|
||||
jest.setTimeout(60000); // Increase test timeout to 60 seconds becuase GitHub runners are slow
|
||||
|
||||
jest.mock("../../../Server/Services/ProjectUserService", () => {
|
||||
return {
|
||||
refreshProjectUsersByProject: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
// Get the mocked module to access the mock functions
|
||||
import ProjectUserService from "../../../Server/Services/ProjectUserService";
|
||||
const mockProjectUserService = ProjectUserService as jest.Mocked<typeof ProjectUserService>;
|
||||
|
||||
describe("TeamMemberService", () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
// Re-setup the mock after resetAllMocks
|
||||
mockProjectUserService.refreshProjectUsersByProject.mockResolvedValue(undefined);
|
||||
await TestDatabaseMock.connectDbMock();
|
||||
});
|
||||
|
||||
|
||||
6
Common/Types/Exception/ExceptionMessages.ts
Normal file
6
Common/Types/Exception/ExceptionMessages.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
enum ExceptionMessages {
|
||||
MonitorNotFound = "Monitor not found.",
|
||||
MonitorDisabled = "Monitor is disabled. Please enable it to start monitoring again.",
|
||||
}
|
||||
|
||||
export default ExceptionMessages;
|
||||
@@ -420,9 +420,8 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(data === null || data === undefined) && field.placeholder && (
|
||||
<PlaceholderText text={field.placeholder} />
|
||||
)}
|
||||
{(data === null || data === undefined || data === "") &&
|
||||
field.placeholder && <PlaceholderText text={field.placeholder} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -102,10 +102,9 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
code: (props: any) => {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
// eslint-disable-next-line wrap-regex
|
||||
const match: RegExpExecArray | null = /language-(\w+)/.exec(
|
||||
className || "",
|
||||
);
|
||||
const match: RegExpExecArray | null = new RegExp(
|
||||
"language-(\\w+)",
|
||||
).exec(className || "");
|
||||
|
||||
const content: string = String(children as string).replace(
|
||||
/\n$/,
|
||||
|
||||
@@ -91,6 +91,10 @@ class BaseAPI extends API {
|
||||
return defaultHeaders;
|
||||
}
|
||||
|
||||
protected static logoutUser(): void {
|
||||
return User.logout();
|
||||
}
|
||||
|
||||
protected static override handleError(
|
||||
error: HTTPErrorResponse | APIException,
|
||||
): HTTPErrorResponse | APIException {
|
||||
@@ -103,7 +107,7 @@ class BaseAPI extends API {
|
||||
) {
|
||||
const loginRoute: Route = this.getLoginRoute();
|
||||
|
||||
User.logout();
|
||||
this.logoutUser();
|
||||
|
||||
if (Navigation.getQueryStringByName("token")) {
|
||||
Navigation.navigate(loginRoute.addRouteParam("sso", "true"), {
|
||||
|
||||
259
Common/UI/Utils/Countries.ts
Normal file
259
Common/UI/Utils/Countries.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// ISO 3166-1 alpha-2 country codes and names.
|
||||
// Limited to widely recognized sovereign states and territories supported by Stripe.
|
||||
// If needed, expand or adjust for specific business logic.
|
||||
export interface CountryOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const Countries: Array<CountryOption> = [
|
||||
{ value: "AF", label: "Afghanistan" },
|
||||
{ value: "AL", label: "Albania" },
|
||||
{ value: "DZ", label: "Algeria" },
|
||||
{ value: "AS", label: "American Samoa" },
|
||||
{ value: "AD", label: "Andorra" },
|
||||
{ value: "AO", label: "Angola" },
|
||||
{ value: "AI", label: "Anguilla" },
|
||||
{ value: "AQ", label: "Antarctica" },
|
||||
{ value: "AG", label: "Antigua and Barbuda" },
|
||||
{ value: "AR", label: "Argentina" },
|
||||
{ value: "AM", label: "Armenia" },
|
||||
{ value: "AW", label: "Aruba" },
|
||||
{ value: "AU", label: "Australia" },
|
||||
{ value: "AT", label: "Austria" },
|
||||
{ value: "AZ", label: "Azerbaijan" },
|
||||
{ value: "BS", label: "Bahamas" },
|
||||
{ value: "BH", label: "Bahrain" },
|
||||
{ value: "BD", label: "Bangladesh" },
|
||||
{ value: "BB", label: "Barbados" },
|
||||
{ value: "BY", label: "Belarus" },
|
||||
{ value: "BE", label: "Belgium" },
|
||||
{ value: "BZ", label: "Belize" },
|
||||
{ value: "BJ", label: "Benin" },
|
||||
{ value: "BM", label: "Bermuda" },
|
||||
{ value: "BT", label: "Bhutan" },
|
||||
{ value: "BO", label: "Bolivia" },
|
||||
{ value: "BQ", label: "Bonaire, Sint Eustatius and Saba" },
|
||||
{ value: "BA", label: "Bosnia and Herzegovina" },
|
||||
{ value: "BW", label: "Botswana" },
|
||||
{ value: "BV", label: "Bouvet Island" },
|
||||
{ value: "BR", label: "Brazil" },
|
||||
{ value: "IO", label: "British Indian Ocean Territory" },
|
||||
{ value: "BN", label: "Brunei Darussalam" },
|
||||
{ value: "BG", label: "Bulgaria" },
|
||||
{ value: "BF", label: "Burkina Faso" },
|
||||
{ value: "BI", label: "Burundi" },
|
||||
{ value: "KH", label: "Cambodia" },
|
||||
{ value: "CM", label: "Cameroon" },
|
||||
{ value: "CA", label: "Canada" },
|
||||
{ value: "CV", label: "Cape Verde" },
|
||||
{ value: "KY", label: "Cayman Islands" },
|
||||
{ value: "CF", label: "Central African Republic" },
|
||||
{ value: "TD", label: "Chad" },
|
||||
{ value: "CL", label: "Chile" },
|
||||
{ value: "CN", label: "China" },
|
||||
{ value: "CX", label: "Christmas Island" },
|
||||
{ value: "CC", label: "Cocos (Keeling) Islands" },
|
||||
{ value: "CO", label: "Colombia" },
|
||||
{ value: "KM", label: "Comoros" },
|
||||
{ value: "CG", label: "Congo" },
|
||||
{ value: "CD", label: "Congo, the Democratic Republic of the" },
|
||||
{ value: "CK", label: "Cook Islands" },
|
||||
{ value: "CR", label: "Costa Rica" },
|
||||
{ value: "CI", label: "Côte d'Ivoire" },
|
||||
{ value: "HR", label: "Croatia" },
|
||||
{ value: "CU", label: "Cuba" },
|
||||
{ value: "CW", label: "Curaçao" },
|
||||
{ value: "CY", label: "Cyprus" },
|
||||
{ value: "CZ", label: "Czech Republic" },
|
||||
{ value: "DK", label: "Denmark" },
|
||||
{ value: "DJ", label: "Djibouti" },
|
||||
{ value: "DM", label: "Dominica" },
|
||||
{ value: "DO", label: "Dominican Republic" },
|
||||
{ value: "EC", label: "Ecuador" },
|
||||
{ value: "EG", label: "Egypt" },
|
||||
{ value: "SV", label: "El Salvador" },
|
||||
{ value: "GQ", label: "Equatorial Guinea" },
|
||||
{ value: "ER", label: "Eritrea" },
|
||||
{ value: "EE", label: "Estonia" },
|
||||
{ value: "ET", label: "Ethiopia" },
|
||||
{ value: "FK", label: "Falkland Islands (Malvinas)" },
|
||||
{ value: "FO", label: "Faroe Islands" },
|
||||
{ value: "FJ", label: "Fiji" },
|
||||
{ value: "FI", label: "Finland" },
|
||||
{ value: "FR", label: "France" },
|
||||
{ value: "GF", label: "French Guiana" },
|
||||
{ value: "PF", label: "French Polynesia" },
|
||||
{ value: "TF", label: "French Southern Territories" },
|
||||
{ value: "GA", label: "Gabon" },
|
||||
{ value: "GM", label: "Gambia" },
|
||||
{ value: "GE", label: "Georgia" },
|
||||
{ value: "DE", label: "Germany" },
|
||||
{ value: "GH", label: "Ghana" },
|
||||
{ value: "GI", label: "Gibraltar" },
|
||||
{ value: "GR", label: "Greece" },
|
||||
{ value: "GL", label: "Greenland" },
|
||||
{ value: "GD", label: "Grenada" },
|
||||
{ value: "GP", label: "Guadeloupe" },
|
||||
{ value: "GU", label: "Guam" },
|
||||
{ value: "GT", label: "Guatemala" },
|
||||
{ value: "GG", label: "Guernsey" },
|
||||
{ value: "GN", label: "Guinea" },
|
||||
{ value: "GW", label: "Guinea-Bissau" },
|
||||
{ value: "GY", label: "Guyana" },
|
||||
{ value: "HT", label: "Haiti" },
|
||||
{ value: "HM", label: "Heard Island and McDonald Islands" },
|
||||
{ value: "HN", label: "Honduras" },
|
||||
{ value: "HK", label: "Hong Kong" },
|
||||
{ value: "HU", label: "Hungary" },
|
||||
{ value: "IS", label: "Iceland" },
|
||||
{ value: "IN", label: "India" },
|
||||
{ value: "ID", label: "Indonesia" },
|
||||
{ value: "IR", label: "Iran, Islamic Republic of" },
|
||||
{ value: "IQ", label: "Iraq" },
|
||||
{ value: "IE", label: "Ireland" },
|
||||
{ value: "IM", label: "Isle of Man" },
|
||||
{ value: "IL", label: "Israel" },
|
||||
{ value: "IT", label: "Italy" },
|
||||
{ value: "JM", label: "Jamaica" },
|
||||
{ value: "JP", label: "Japan" },
|
||||
{ value: "JE", label: "Jersey" },
|
||||
{ value: "JO", label: "Jordan" },
|
||||
{ value: "KZ", label: "Kazakhstan" },
|
||||
{ value: "KE", label: "Kenya" },
|
||||
{ value: "KI", label: "Kiribati" },
|
||||
{ value: "KP", label: "Korea, Democratic People's Republic of" },
|
||||
{ value: "KR", label: "Korea, Republic of" },
|
||||
{ value: "KW", label: "Kuwait" },
|
||||
{ value: "KG", label: "Kyrgyzstan" },
|
||||
{ value: "LA", label: "Lao People's Democratic Republic" },
|
||||
{ value: "LV", label: "Latvia" },
|
||||
{ value: "LB", label: "Lebanon" },
|
||||
{ value: "LS", label: "Lesotho" },
|
||||
{ value: "LR", label: "Liberia" },
|
||||
{ value: "LY", label: "Libya" },
|
||||
{ value: "LI", label: "Liechtenstein" },
|
||||
{ value: "LT", label: "Lithuania" },
|
||||
{ value: "LU", label: "Luxembourg" },
|
||||
{ value: "MO", label: "Macao" },
|
||||
{ value: "MG", label: "Madagascar" },
|
||||
{ value: "MW", label: "Malawi" },
|
||||
{ value: "MY", label: "Malaysia" },
|
||||
{ value: "MV", label: "Maldives" },
|
||||
{ value: "ML", label: "Mali" },
|
||||
{ value: "MT", label: "Malta" },
|
||||
{ value: "MH", label: "Marshall Islands" },
|
||||
{ value: "MQ", label: "Martinique" },
|
||||
{ value: "MR", label: "Mauritania" },
|
||||
{ value: "MU", label: "Mauritius" },
|
||||
{ value: "YT", label: "Mayotte" },
|
||||
{ value: "MX", label: "Mexico" },
|
||||
{ value: "FM", label: "Micronesia, Federated States of" },
|
||||
{ value: "MD", label: "Moldova, Republic of" },
|
||||
{ value: "MC", label: "Monaco" },
|
||||
{ value: "MN", label: "Mongolia" },
|
||||
{ value: "ME", label: "Montenegro" },
|
||||
{ value: "MS", label: "Montserrat" },
|
||||
{ value: "MA", label: "Morocco" },
|
||||
{ value: "MZ", label: "Mozambique" },
|
||||
{ value: "MM", label: "Myanmar" },
|
||||
{ value: "NA", label: "Namibia" },
|
||||
{ value: "NR", label: "Nauru" },
|
||||
{ value: "NP", label: "Nepal" },
|
||||
{ value: "NL", label: "Netherlands" },
|
||||
{ value: "NC", label: "New Caledonia" },
|
||||
{ value: "NZ", label: "New Zealand" },
|
||||
{ value: "NI", label: "Nicaragua" },
|
||||
{ value: "NE", label: "Niger" },
|
||||
{ value: "NG", label: "Nigeria" },
|
||||
{ value: "NU", label: "Niue" },
|
||||
{ value: "NF", label: "Norfolk Island" },
|
||||
{ value: "MK", label: "North Macedonia" },
|
||||
{ value: "MP", label: "Northern Mariana Islands" },
|
||||
{ value: "NO", label: "Norway" },
|
||||
{ value: "OM", label: "Oman" },
|
||||
{ value: "PK", label: "Pakistan" },
|
||||
{ value: "PW", label: "Palau" },
|
||||
{ value: "PS", label: "Palestine, State of" },
|
||||
{ value: "PA", label: "Panama" },
|
||||
{ value: "PG", label: "Papua New Guinea" },
|
||||
{ value: "PY", label: "Paraguay" },
|
||||
{ value: "PE", label: "Peru" },
|
||||
{ value: "PH", label: "Philippines" },
|
||||
{ value: "PN", label: "Pitcairn" },
|
||||
{ value: "PL", label: "Poland" },
|
||||
{ value: "PT", label: "Portugal" },
|
||||
{ value: "PR", label: "Puerto Rico" },
|
||||
{ value: "QA", label: "Qatar" },
|
||||
{ value: "RE", label: "Réunion" },
|
||||
{ value: "RO", label: "Romania" },
|
||||
{ value: "RU", label: "Russian Federation" },
|
||||
{ value: "RW", label: "Rwanda" },
|
||||
{ value: "BL", label: "Saint Barthélemy" },
|
||||
{ value: "SH", label: "Saint Helena, Ascension and Tristan da Cunha" },
|
||||
{ value: "KN", label: "Saint Kitts and Nevis" },
|
||||
{ value: "LC", label: "Saint Lucia" },
|
||||
{ value: "MF", label: "Saint Martin (French part)" },
|
||||
{ value: "PM", label: "Saint Pierre and Miquelon" },
|
||||
{ value: "VC", label: "Saint Vincent and the Grenadines" },
|
||||
{ value: "WS", label: "Samoa" },
|
||||
{ value: "SM", label: "San Marino" },
|
||||
{ value: "ST", label: "Sao Tome and Principe" },
|
||||
{ value: "SA", label: "Saudi Arabia" },
|
||||
{ value: "SN", label: "Senegal" },
|
||||
{ value: "RS", label: "Serbia" },
|
||||
{ value: "SC", label: "Seychelles" },
|
||||
{ value: "SL", label: "Sierra Leone" },
|
||||
{ value: "SG", label: "Singapore" },
|
||||
{ value: "SX", label: "Sint Maarten (Dutch part)" },
|
||||
{ value: "SK", label: "Slovakia" },
|
||||
{ value: "SI", label: "Slovenia" },
|
||||
{ value: "SB", label: "Solomon Islands" },
|
||||
{ value: "SO", label: "Somalia" },
|
||||
{ value: "ZA", label: "South Africa" },
|
||||
{ value: "GS", label: "South Georgia and the South Sandwich Islands" },
|
||||
{ value: "SS", label: "South Sudan" },
|
||||
{ value: "ES", label: "Spain" },
|
||||
{ value: "LK", label: "Sri Lanka" },
|
||||
{ value: "SD", label: "Sudan" },
|
||||
{ value: "SR", label: "Suriname" },
|
||||
{ value: "SJ", label: "Svalbard and Jan Mayen" },
|
||||
{ value: "SZ", label: "Swaziland" },
|
||||
{ value: "SE", label: "Sweden" },
|
||||
{ value: "CH", label: "Switzerland" },
|
||||
{ value: "SY", label: "Syrian Arab Republic" },
|
||||
{ value: "TW", label: "Taiwan, Province of China" },
|
||||
{ value: "TJ", label: "Tajikistan" },
|
||||
{ value: "TZ", label: "Tanzania, United Republic of" },
|
||||
{ value: "TH", label: "Thailand" },
|
||||
{ value: "TL", label: "Timor-Leste" },
|
||||
{ value: "TG", label: "Togo" },
|
||||
{ value: "TK", label: "Tokelau" },
|
||||
{ value: "TO", label: "Tonga" },
|
||||
{ value: "TT", label: "Trinidad and Tobago" },
|
||||
{ value: "TN", label: "Tunisia" },
|
||||
{ value: "TR", label: "Turkey" },
|
||||
{ value: "TM", label: "Turkmenistan" },
|
||||
{ value: "TC", label: "Turks and Caicos Islands" },
|
||||
{ value: "TV", label: "Tuvalu" },
|
||||
{ value: "UG", label: "Uganda" },
|
||||
{ value: "UA", label: "Ukraine" },
|
||||
{ value: "AE", label: "United Arab Emirates" },
|
||||
{ value: "GB", label: "United Kingdom" },
|
||||
{ value: "US", label: "United States" },
|
||||
{ value: "UM", label: "United States Minor Outlying Islands" },
|
||||
{ value: "UY", label: "Uruguay" },
|
||||
{ value: "UZ", label: "Uzbekistan" },
|
||||
{ value: "VU", label: "Vanuatu" },
|
||||
{ value: "VE", label: "Venezuela, Bolivarian Republic of" },
|
||||
{ value: "VN", label: "Viet Nam" },
|
||||
{ value: "VG", label: "Virgin Islands, British" },
|
||||
{ value: "VI", label: "Virgin Islands, U.S." },
|
||||
{ value: "WF", label: "Wallis and Futuna" },
|
||||
{ value: "EH", label: "Western Sahara" },
|
||||
{ value: "YE", label: "Yemen" },
|
||||
{ value: "ZM", label: "Zambia" },
|
||||
{ value: "ZW", label: "Zimbabwe" },
|
||||
];
|
||||
|
||||
export default Countries;
|
||||
@@ -4,5 +4,7 @@
|
||||
"../Common"
|
||||
],
|
||||
"ext": "ts,json,tsx,env,js,jsx,hbs",
|
||||
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
|
||||
"watchOptions": {"useFsEvents": false, "interval": 500},
|
||||
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
|
||||
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import React, {
|
||||
} from "react";
|
||||
import MonitorStatusElement from "./MonitorStatusElement";
|
||||
import Loader, { LoaderType } from "Common/UI/Components/Loader/Loader";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
@@ -45,7 +46,7 @@ const GetMonitorStatusElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (!monitor) {
|
||||
setIsLoading(false);
|
||||
setError("Monitor not found");
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,33 @@ const CustomProbeDocumentation: FunctionComponent<ComponentProps> = (
|
||||
docker run --name oneuptime-probe --network host -e PROBE_KEY=${props.probeKey.toString()} -e PROBE_ID=${props.probeId.toString()} -e ONEUPTIME_URL=${host.toString()} -d oneuptime/probe:release
|
||||
`}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||
With Proxy Configuration (Optional)
|
||||
</h4>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`
|
||||
# With HTTP/HTTPS proxy
|
||||
docker run --name oneuptime-probe --network host \\
|
||||
-e PROBE_KEY=${props.probeKey.toString()} \\
|
||||
-e PROBE_ID=${props.probeId.toString()} \\
|
||||
-e ONEUPTIME_URL=${host.toString()} \\
|
||||
-e HTTP_PROXY_URL=http://proxy.example.com:8080 \\
|
||||
-e HTTPS_PROXY_URL=http://proxy.example.com:8080 \\
|
||||
-d oneuptime/probe:release
|
||||
|
||||
# With proxy authentication
|
||||
docker run --name oneuptime-probe --network host \\
|
||||
-e PROBE_KEY=${props.probeKey.toString()} \\
|
||||
-e PROBE_ID=${props.probeId.toString()} \\
|
||||
-e ONEUPTIME_URL=${host.toString()} \\
|
||||
-e HTTP_PROXY_URL=http://username:password@proxy.example.com:8080 \\
|
||||
-e HTTPS_PROXY_URL=http://username:password@proxy.example.com:8080 \\
|
||||
-d oneuptime/probe:release
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,7 @@ import React, {
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import { useAsyncEffect } from "use-async-effect";
|
||||
import MonitorTestForm from "../../../Components/Form/Monitor/MonitorTest";
|
||||
import Probe from "Common/Models/DatabaseModels/Probe";
|
||||
@@ -61,7 +62,7 @@ const MonitorCriteria: FunctionComponent<
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, {
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
|
||||
const MonitorDocumentation: FunctionComponent<
|
||||
@@ -55,7 +56,7 @@ const MonitorDocumentation: FunctionComponent<
|
||||
setMonitor(item);
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import React, {
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
|
||||
import PageMap from "../../../Utils/PageMap";
|
||||
@@ -215,7 +216,7 @@ const MonitorView: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import React, {
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
|
||||
const MonitorCriteria: FunctionComponent<
|
||||
@@ -48,7 +49,7 @@ const MonitorCriteria: FunctionComponent<
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
|
||||
@@ -43,7 +44,7 @@ const MonitorViewLayout: FunctionComponent = (): ReactElement => {
|
||||
setMonitor(item);
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import React, {
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
import AnalyticsModelTable from "Common/UI/Components/ModelTable/AnalyticsModelTable";
|
||||
import SummaryInfo from "../../../Components/Monitor/SummaryView/SummaryInfo";
|
||||
@@ -55,7 +56,7 @@ const MonitorLogs: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import React, {
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
import SummaryInfo from "../../../Components/Monitor/SummaryView/SummaryInfo";
|
||||
import ProbeMonitorResponse from "Common/Types/Probe/ProbeMonitorResponse";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
|
||||
const MonitorProbes: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -61,7 +62,7 @@ const MonitorProbes: FunctionComponent<
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import React, {
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import ExceptionMessages from "Common/Types/Exception/ExceptionMessages";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
@@ -61,7 +62,7 @@ const MonitorCriteria: FunctionComponent<
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
setError(`Monitor not found`);
|
||||
setError(ExceptionMessages.MonitorNotFound);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
import Countries from "Common/UI/Utils/Countries";
|
||||
|
||||
export type ComponentProps = PageComponentProps;
|
||||
|
||||
@@ -523,6 +524,89 @@ const Settings: FunctionComponent<ComponentProps> = (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<CardModelDetail<Project>
|
||||
name="Business Details"
|
||||
cardProps={{
|
||||
title: "Business Details / Billing Address",
|
||||
description:
|
||||
"Enter your business legal name, address and optional tax info. This will appear on your invoices.",
|
||||
}}
|
||||
isEditable={true}
|
||||
editButtonText={"Update"}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
businessDetails: true,
|
||||
},
|
||||
title: "Business Details / Billing Address",
|
||||
description:
|
||||
"This information will appear on invoices. Include company legal name, address, and tax / VAT ID if applicable.",
|
||||
required: false,
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
validation: {
|
||||
maxLength: 10000,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
businessDetailsCountry: true,
|
||||
},
|
||||
title: "Country",
|
||||
description: "Required by Stripe. Select your billing country.",
|
||||
required: false,
|
||||
placeholder: "Select Country",
|
||||
fieldType: FormFieldSchemaType.Dropdown,
|
||||
dropdownOptions: Countries,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
financeAccountingEmail: true,
|
||||
},
|
||||
title: "Finance / Accounting Email",
|
||||
description:
|
||||
"Invoices, receipts and billing notifications will be sent here (optional).",
|
||||
required: false,
|
||||
placeholder: "finance@yourcompany.com",
|
||||
fieldType: FormFieldSchemaType.Email,
|
||||
validation: {
|
||||
minLength: 3,
|
||||
maxLength: 200,
|
||||
},
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
modelType: Project,
|
||||
id: "model-detail-project-business-details",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
businessDetails: true,
|
||||
},
|
||||
title: "Business Details / Billing Address",
|
||||
placeholder: "No business details added yet.",
|
||||
fieldType: FieldType.LongText,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
businessDetailsCountry: true,
|
||||
},
|
||||
title: "Country",
|
||||
placeholder: "No country details added yet.",
|
||||
fieldType: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
financeAccountingEmail: true,
|
||||
},
|
||||
title: "Finance / Accounting Email",
|
||||
placeholder: "No finance / accounting email added yet.",
|
||||
fieldType: FieldType.Email,
|
||||
},
|
||||
],
|
||||
modelId: ProjectUtil.getCurrentProjectId()!,
|
||||
}}
|
||||
/>
|
||||
|
||||
{!reseller && (
|
||||
<Card
|
||||
title={`Cancel Plan`}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import PageComponentProps from "../PageComponentProps";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import UserElement from "../../Components/User/User";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import TeamsElement from "../../Components/Team/TeamsElement";
|
||||
import TeamElement from "../../Components/Team/Team";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import { RouteUtil } from "../../Utils/RouteMap";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
@@ -16,6 +15,8 @@ import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import { FormType } from "Common/UI/Components/Forms/ModelForm";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Pill from "Common/UI/Components/Pill/Pill";
|
||||
import { Green, Yellow } from "Common/Types/BrandColors";
|
||||
|
||||
const Teams: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
@@ -26,21 +27,22 @@ const Teams: FunctionComponent<PageComponentProps> = (
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ModelTable<ProjectUser>
|
||||
modelType={ProjectUser}
|
||||
<ModelTable<TeamMember>
|
||||
modelType={TeamMember}
|
||||
id="teams-table"
|
||||
name="Settings > Users"
|
||||
userPreferencesKey="users-table"
|
||||
isDeleteable={false}
|
||||
isDeleteable={true}
|
||||
isEditable={false}
|
||||
isCreateable={true}
|
||||
isCreateable={false}
|
||||
onFilterApplied={(isApplied: boolean) => {
|
||||
setIsFilterApplied(isApplied);
|
||||
}}
|
||||
isViewable={true}
|
||||
cardProps={{
|
||||
title: "Users",
|
||||
description: "Here is a list of all the users in this project.",
|
||||
description:
|
||||
"Here is a list of all the team members in this project.",
|
||||
buttons: [
|
||||
{
|
||||
title: "Invite User",
|
||||
@@ -61,7 +63,7 @@ const Teams: FunctionComponent<PageComponentProps> = (
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
}}
|
||||
showRefreshButton={true}
|
||||
onViewPage={(item: ProjectUser) => {
|
||||
onViewPage={(item: TeamMember) => {
|
||||
const viewPageRoute: string =
|
||||
RouteUtil.populateRouteParams(props.pageRoute).toString() +
|
||||
"/" +
|
||||
@@ -72,12 +74,12 @@ const Teams: FunctionComponent<PageComponentProps> = (
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
acceptedTeams: {
|
||||
team: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
title: "Teams member of",
|
||||
type: FieldType.EntityArray,
|
||||
title: "Team",
|
||||
type: FieldType.Entity,
|
||||
filterEntityType: Team,
|
||||
filterQuery: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -89,20 +91,10 @@ const Teams: FunctionComponent<PageComponentProps> = (
|
||||
},
|
||||
{
|
||||
field: {
|
||||
invitedTeams: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
title: "Teams invited to",
|
||||
type: FieldType.EntityArray,
|
||||
filterEntityType: Team,
|
||||
filterQuery: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
filterDropdownField: {
|
||||
label: "name",
|
||||
value: "_id",
|
||||
hasAcceptedInvitation: true,
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
@@ -116,7 +108,7 @@ const Teams: FunctionComponent<PageComponentProps> = (
|
||||
},
|
||||
title: "User",
|
||||
type: FieldType.Element,
|
||||
getElement: (item: ProjectUser) => {
|
||||
getElement: (item: TeamMember) => {
|
||||
if (!item.user) {
|
||||
return <p>User not found</p>;
|
||||
}
|
||||
@@ -125,26 +117,31 @@ const Teams: FunctionComponent<PageComponentProps> = (
|
||||
},
|
||||
{
|
||||
field: {
|
||||
acceptedTeams: {
|
||||
team: {
|
||||
name: true,
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
title: "Teams member of",
|
||||
title: "Team",
|
||||
type: FieldType.Element,
|
||||
getElement: (item: ProjectUser) => {
|
||||
return <TeamsElement teams={item.acceptedTeams || []} />;
|
||||
getElement: (item: TeamMember) => {
|
||||
if (!item.team) {
|
||||
return <p>No team assigned</p>;
|
||||
}
|
||||
return <TeamElement team={item.team!} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
invitedTeams: {
|
||||
name: true,
|
||||
},
|
||||
hasAcceptedInvitation: true,
|
||||
},
|
||||
title: "Teams invited to",
|
||||
title: "Status",
|
||||
type: FieldType.Element,
|
||||
getElement: (item: ProjectUser) => {
|
||||
return <TeamsElement teams={item.invitedTeams || []} />;
|
||||
getElement: (item: TeamMember) => {
|
||||
if (item.hasAcceptedInvitation) {
|
||||
return <Pill text="Member" color={Green} />;
|
||||
}
|
||||
return <Pill text="Invitation Sent" color={Yellow} />;
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import NotNull from "Common/Types/BaseDatabase/NotNull";
|
||||
import { Green, Red } from "Common/Types/BrandColors";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
|
||||
import { CategoryCheckboxOptionsAndCategories } from "Common/UI/Components/CategoryCheckbox/Index";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { ModelField } from "Common/UI/Components/Forms/ModelForm";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import Pill from "Common/UI/Components/Pill/Pill";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import SubscriberUtil from "Common/UI/Utils/StatusPage";
|
||||
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
|
||||
import StatusPageSubscriber from "Common/Models/DatabaseModels/StatusPageSubscriber";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
|
||||
const StatusPageMicrosoftTeamsSubscribers: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (props: PageComponentProps): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
const [
|
||||
allowSubscribersToChooseResources,
|
||||
setAllowSubscribersToChooseResources,
|
||||
] = React.useState<boolean>(false);
|
||||
|
||||
const [
|
||||
allowSubscribersToChooseEventTypes,
|
||||
setAllowSubscribersToChooseEventTypes,
|
||||
] = React.useState<boolean>(false);
|
||||
|
||||
const [
|
||||
isMicrosoftTeamsSubscribersEnabled,
|
||||
setIsMicrosoftTeamsSubscribersEnabled,
|
||||
] = React.useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string>("");
|
||||
const [
|
||||
categoryCheckboxOptionsAndCategories,
|
||||
setCategoryCheckboxOptionsAndCategories,
|
||||
] = useState<CategoryCheckboxOptionsAndCategories>({
|
||||
categories: [],
|
||||
options: [],
|
||||
});
|
||||
|
||||
const fetchCheckboxOptionsAndCategories: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
const result: CategoryCheckboxOptionsAndCategories =
|
||||
await SubscriberUtil.getCategoryCheckboxPropsBasedOnResources(modelId);
|
||||
|
||||
setCategoryCheckboxOptionsAndCategories(result);
|
||||
};
|
||||
|
||||
const fetchStatusPage: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const statusPage: StatusPage | null = await ModelAPI.getItem({
|
||||
modelType: StatusPage,
|
||||
id: modelId,
|
||||
select: {
|
||||
allowSubscribersToChooseResources: true,
|
||||
allowSubscribersToChooseEventTypes: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (statusPage && statusPage.allowSubscribersToChooseResources) {
|
||||
setAllowSubscribersToChooseResources(
|
||||
statusPage.allowSubscribersToChooseResources,
|
||||
);
|
||||
await fetchCheckboxOptionsAndCategories();
|
||||
}
|
||||
|
||||
if (statusPage && statusPage.allowSubscribersToChooseEventTypes) {
|
||||
setAllowSubscribersToChooseEventTypes(
|
||||
statusPage.allowSubscribersToChooseEventTypes,
|
||||
);
|
||||
}
|
||||
|
||||
if (statusPage && statusPage.enableMicrosoftTeamsSubscribers) {
|
||||
setIsMicrosoftTeamsSubscribersEnabled(
|
||||
statusPage.enableMicrosoftTeamsSubscribers,
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatusPage().catch((err: Error) => {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [formFields, setFormFields] = React.useState<
|
||||
Array<ModelField<StatusPageSubscriber>>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return; // don't do anything if loading
|
||||
}
|
||||
|
||||
const formFields: Array<ModelField<StatusPageSubscriber>> = [
|
||||
{
|
||||
field: {
|
||||
microsoftTeamsWorkspaceName: true,
|
||||
},
|
||||
stepId: "subscriber-info",
|
||||
title: "Microsoft Teams Workspace Name",
|
||||
description:
|
||||
"Name of the Microsoft Teams workspace for identification.",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
placeholder: "my-company-workspace",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
microsoftTeamsIncomingWebhookUrl: true,
|
||||
},
|
||||
stepId: "subscriber-info",
|
||||
title: "Microsoft Teams Incoming Webhook URL",
|
||||
description: "Status page updates will be sent to this Teams channel.",
|
||||
fieldType: FormFieldSchemaType.URL,
|
||||
required: true,
|
||||
placeholder: "https://xxxxx.office.com/webhook/...",
|
||||
disableSpellCheck: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
sendYouHaveSubscribedMessage: true,
|
||||
},
|
||||
title: "Send Subscription Notification",
|
||||
stepId: "subscriber-info",
|
||||
description:
|
||||
"Send a notification to the Teams channel confirming the subscription.",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
doNotShowWhenEditing: true,
|
||||
},
|
||||
|
||||
{
|
||||
field: {
|
||||
isUnsubscribed: true,
|
||||
},
|
||||
title: "Unsubscribe",
|
||||
stepId: "subscriber-info",
|
||||
description: "Unsubscribe this Teams channel from the status page.",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
doNotShowWhenCreating: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (allowSubscribersToChooseResources) {
|
||||
formFields.push({
|
||||
field: {
|
||||
isSubscribedToAllResources: true,
|
||||
},
|
||||
title: "Subscribe to All Resources",
|
||||
stepId: "subscriber-info",
|
||||
description: "Send notifications for all resources.",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
formFields.push({
|
||||
field: {
|
||||
statusPageResources: true,
|
||||
},
|
||||
title: "Select Resources to Subscribe",
|
||||
description: "Please select the resources you want to subscribe to.",
|
||||
stepId: "subscriber-info",
|
||||
fieldType: FormFieldSchemaType.CategoryCheckbox,
|
||||
required: false,
|
||||
categoryCheckboxProps: categoryCheckboxOptionsAndCategories,
|
||||
showIf: (model: FormValues<StatusPageSubscriber>) => {
|
||||
return !model || !model.isSubscribedToAllResources;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (allowSubscribersToChooseEventTypes) {
|
||||
formFields.push({
|
||||
field: {
|
||||
isSubscribedToAllEventTypes: true,
|
||||
},
|
||||
title: "Subscribe to All Event Types",
|
||||
stepId: "subscriber-info",
|
||||
description:
|
||||
"Select this option if you want to subscribe to all event types.",
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
formFields.push({
|
||||
field: {
|
||||
statusPageEventTypes: true,
|
||||
},
|
||||
title: "Select Event Types to Subscribe",
|
||||
stepId: "subscriber-info",
|
||||
description: "Please select the event types you want to subscribe to.",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
required: false,
|
||||
dropdownOptions: SubscriberUtil.getDropdownPropsBasedOnEventTypes(),
|
||||
showIf: (model: FormValues<StatusPageSubscriber>) => {
|
||||
return !model || !model.isSubscribedToAllEventTypes;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// add internal note field
|
||||
formFields.push({
|
||||
field: {
|
||||
internalNote: true,
|
||||
},
|
||||
title: "Internal Note",
|
||||
stepId: "internal-info",
|
||||
description:
|
||||
"Internal note for the subscriber. This is for internal use only and is visible only to the team members.",
|
||||
fieldType: FormFieldSchemaType.Markdown,
|
||||
required: false,
|
||||
});
|
||||
|
||||
setFormFields(formFields);
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{isLoading ? <PageLoader isVisible={true} /> : <></>}
|
||||
|
||||
{error ? <ErrorMessage message={error} /> : <></>}
|
||||
|
||||
{!error && !isLoading ? (
|
||||
<>
|
||||
{!isMicrosoftTeamsSubscribersEnabled && (
|
||||
<Alert
|
||||
type={AlertType.DANGER}
|
||||
title="Microsoft Teams subscribers are not enabled for this status page. Please enable it in Subscriber Settings"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelTable<StatusPageSubscriber>
|
||||
modelType={StatusPageSubscriber}
|
||||
id="table-microsoft-teams-subscriber"
|
||||
name="Status Page > Microsoft Teams Subscribers"
|
||||
userPreferencesKey="status-page-microsoft-teams-subscribers-table"
|
||||
isDeleteable={true}
|
||||
showViewIdButton={true}
|
||||
isCreateable={true}
|
||||
isEditable={true}
|
||||
isViewable={false}
|
||||
selectMoreFields={{
|
||||
isSubscriptionConfirmed: true,
|
||||
}}
|
||||
query={{
|
||||
statusPageId: modelId,
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
microsoftTeamsWorkspaceName: new NotNull(),
|
||||
}}
|
||||
onBeforeCreate={(
|
||||
item: StatusPageSubscriber,
|
||||
): Promise<StatusPageSubscriber> => {
|
||||
if (!props.currentProject || !props.currentProject._id) {
|
||||
throw new BadDataException("Project ID cannot be null");
|
||||
}
|
||||
|
||||
item.statusPageId = modelId;
|
||||
item.projectId = new ObjectID(props.currentProject._id);
|
||||
return Promise.resolve(item);
|
||||
}}
|
||||
cardProps={{
|
||||
title: "Microsoft Teams Subscribers",
|
||||
description:
|
||||
"Here are the list of Microsoft Teams channels that have subscribed to the status page.",
|
||||
}}
|
||||
noItemsMessage={"No Microsoft Teams subscribers found."}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Subscriber Info",
|
||||
id: "subscriber-info",
|
||||
},
|
||||
{
|
||||
title: "Internal Info",
|
||||
id: "internal-info",
|
||||
},
|
||||
]}
|
||||
formFields={formFields}
|
||||
showRefreshButton={true}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
microsoftTeamsWorkspaceName: true,
|
||||
},
|
||||
title: "Teams Workspace Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isUnsubscribed: true,
|
||||
},
|
||||
title: "Is Unsubscribed",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isSubscriptionConfirmed: true,
|
||||
},
|
||||
title: "Subscription Confirmed?",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
createdAt: true,
|
||||
},
|
||||
title: "Subscribed At",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
microsoftTeamsWorkspaceName: true,
|
||||
},
|
||||
title: "Workspace Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isUnsubscribed: true,
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Text,
|
||||
getElement: (item: StatusPageSubscriber): ReactElement => {
|
||||
if (item["isUnsubscribed"]) {
|
||||
return <Pill color={Red} text={"Unsubscribed"} />;
|
||||
}
|
||||
|
||||
if (!item["isSubscriptionConfirmed"]) {
|
||||
return (
|
||||
<Pill
|
||||
color={Red}
|
||||
text={"Awaiting Confirmation"}
|
||||
tooltip="Subscription not yet confirmed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Pill color={Green} text={"Subscribed"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
createdAt: true,
|
||||
},
|
||||
title: "Subscribed At",
|
||||
type: FieldType.DateTime,
|
||||
hideOnMobile: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusPageMicrosoftTeamsSubscribers;
|
||||
@@ -111,6 +111,18 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
icon={IconProp.Slack}
|
||||
/>
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "MS Teams Subscribers",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS
|
||||
] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.MicrosoftTeams}
|
||||
/>
|
||||
|
||||
{/* <SideMenuItem
|
||||
link={{
|
||||
|
||||
@@ -127,6 +127,43 @@ const StatusPageDelete: FunctionComponent<
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<StatusPage>
|
||||
name="Status Page > Branding > Subscriber > Microsoft Teams"
|
||||
cardProps={{
|
||||
title: "Microsoft Teams Subscribers",
|
||||
description:
|
||||
"Microsoft Teams subscriber settings for this status page.",
|
||||
}}
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
},
|
||||
title: "Enable Microsoft Teams Subscribers",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
placeholder:
|
||||
"Can Microsoft Teams subscribers subscribe to this status page?",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: StatusPage,
|
||||
id: "model-detail-microsoft-teams-subscribers",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
},
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Enable Microsoft Teams Subscribers",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<StatusPage>
|
||||
name="Status Page > Branding > Subscriber > Advanced"
|
||||
cardProps={{
|
||||
|
||||
@@ -49,6 +49,11 @@ const StatusPagesViewSlackSubscribers: LazyExoticComponent<
|
||||
> = lazy(() => {
|
||||
return import("../Pages/StatusPages/View/SlackSubscribers");
|
||||
});
|
||||
const StatusPagesViewMicrosoftTeamsSubscribers: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
return import("../Pages/StatusPages/View/MicrosoftTeamsSubscribers");
|
||||
});
|
||||
const StatusPagesViewWebhookSubscribers: LazyExoticComponent<
|
||||
FunctionComponent<ComponentProps>
|
||||
> = lazy(() => {
|
||||
@@ -640,6 +645,24 @@ const StatusPagesRoutes: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS,
|
||||
)}
|
||||
element={
|
||||
<Suspense fallback={Loader}>
|
||||
<StatusPagesViewMicrosoftTeamsSubscribers
|
||||
{...props}
|
||||
pageRoute={
|
||||
RouteMap[
|
||||
PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS
|
||||
] as Route
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(PageMap.STATUS_PAGE_VIEW_EMBEDDED)}
|
||||
element={
|
||||
|
||||
@@ -209,6 +209,7 @@ enum PageMap {
|
||||
STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS = "STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS",
|
||||
STATUS_PAGE_VIEW_SMS_SUBSCRIBERS = "STATUS_PAGE_VIEW_SMS_SUBSCRIBERS",
|
||||
STATUS_PAGE_VIEW_SLACK_SUBSCRIBERS = "STATUS_PAGE_VIEW_SLACK_SUBSCRIBERS",
|
||||
STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS = "STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS",
|
||||
STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS = "STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS",
|
||||
STATUS_PAGE_VIEW_RESOURCES = "STATUS_PAGE_VIEW_RESOURCES",
|
||||
STATUS_PAGE_VIEW_ADVANCED_OPTIONS = "STATUS_PAGE_VIEW_ADVANCED_OPTIONS",
|
||||
|
||||
@@ -128,6 +128,7 @@ export const StatusPagesRoutePath: Dictionary<string> = {
|
||||
[PageMap.STATUS_PAGE_VIEW_EMAIL_SUBSCRIBERS]: `${RouteParams.ModelID}/email-subscribers`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SMS_SUBSCRIBERS]: `${RouteParams.ModelID}/sms-subscribers`,
|
||||
[PageMap.STATUS_PAGE_VIEW_SLACK_SUBSCRIBERS]: `${RouteParams.ModelID}/slack-subscribers`,
|
||||
[PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS]: `${RouteParams.ModelID}/microsoft-teams-subscribers`,
|
||||
[PageMap.STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS]: `${RouteParams.ModelID}/webhook-subscribers`,
|
||||
[PageMap.STATUS_PAGE_VIEW_HEADER_STYLE]: `${RouteParams.ModelID}/header-style`,
|
||||
[PageMap.STATUS_PAGE_VIEW_FOOTER_STYLE]: `${RouteParams.ModelID}/footer-style`,
|
||||
@@ -1081,6 +1082,12 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/status-pages/${
|
||||
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_MICROSOFT_TEAMS_SUBSCRIBERS]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/status-pages/${
|
||||
StatusPagesRoutePath[PageMap.STATUS_PAGE_VIEW_WEBHOOK_SUBSCRIBERS]
|
||||
|
||||
@@ -92,7 +92,7 @@ The following example shows how to use a JavaScript expression to monitor an inc
|
||||
|
||||
// you can combine multiple expressions using logical operators
|
||||
|
||||
"{{requestBody.item}"} === "hello" && "{{requestHeaders.contentType}}" === "text/html"
|
||||
"{{requestBody.item}}" === "hello" && "{{requestHeaders.contentType}}" === "text/html"
|
||||
|
||||
// you can use the following for arrays
|
||||
|
||||
|
||||
@@ -16,6 +16,37 @@ docker run --name oneuptime-probe --network host -e PROBE_KEY=<probe-key> -e PRO
|
||||
|
||||
If you are self hosting OneUptime, you can change `ONEUPTIME_URL` to your custom self hosted instance.
|
||||
|
||||
##### Proxy Configuration
|
||||
|
||||
If your probe needs to go through a proxy server to reach OneUptime or monitor external resources, you can configure proxy settings using these environment variables:
|
||||
|
||||
```
|
||||
# For HTTP proxy
|
||||
docker run --name oneuptime-probe --network host \
|
||||
-e PROBE_KEY=<probe-key> \
|
||||
-e PROBE_ID=<probe-id> \
|
||||
-e ONEUPTIME_URL=https://oneuptime.com \
|
||||
-e HTTP_PROXY_URL=http://proxy.example.com:8080 \
|
||||
-d oneuptime/probe:release
|
||||
|
||||
# For HTTPS proxy
|
||||
docker run --name oneuptime-probe --network host \
|
||||
-e PROBE_KEY=<probe-key> \
|
||||
-e PROBE_ID=<probe-id> \
|
||||
-e ONEUPTIME_URL=https://oneuptime.com \
|
||||
-e HTTPS_PROXY_URL=http://proxy.example.com:8080 \
|
||||
-d oneuptime/probe:release
|
||||
|
||||
# With proxy authentication
|
||||
docker run --name oneuptime-probe --network host \
|
||||
-e PROBE_KEY=<probe-key> \
|
||||
-e PROBE_ID=<probe-id> \
|
||||
-e ONEUPTIME_URL=https://oneuptime.com \
|
||||
-e HTTP_PROXY_URL=http://username:password@proxy.example.com:8080 \
|
||||
-e HTTPS_PROXY_URL=http://username:password@proxy.example.com:8080 \
|
||||
-d oneuptime/probe:release
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
You can also run the probe using docker-compose. Create a `docker-compose.yml` file with the following content:
|
||||
@@ -35,6 +66,31 @@ services:
|
||||
restart: always
|
||||
```
|
||||
|
||||
##### With Proxy Configuration
|
||||
|
||||
If you need to use a proxy server, you can add proxy environment variables:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
oneuptime-probe:
|
||||
image: oneuptime/probe:release
|
||||
container_name: oneuptime-probe
|
||||
environment:
|
||||
- PROBE_KEY=<probe-key>
|
||||
- PROBE_ID=<probe-id>
|
||||
- ONEUPTIME_URL=https://oneuptime.com
|
||||
# Proxy configuration (optional)
|
||||
- HTTP_PROXY_URL=http://proxy.example.com:8080
|
||||
- HTTPS_PROXY_URL=http://proxy.example.com:8080
|
||||
# For proxy with authentication:
|
||||
# - HTTP_PROXY_URL=http://username:password@proxy.example.com:8080
|
||||
# - HTTPS_PROXY_URL=http://username:password@proxy.example.com:8080
|
||||
network_mode: host
|
||||
restart: always
|
||||
```
|
||||
|
||||
Then run the following command:
|
||||
|
||||
```
|
||||
@@ -56,20 +112,61 @@ spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: oneuptime-probe
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: oneuptime-probe
|
||||
spec:
|
||||
containers:
|
||||
image: oneuptime/probe:release
|
||||
env:
|
||||
- name: PROBE_KEY
|
||||
value: "<probe-key>"
|
||||
- name: PROBE_ID
|
||||
value: "<probe-id>"
|
||||
- name: ONEUPTIME_URL
|
||||
value: "https://oneuptime.com"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: oneuptime-probe
|
||||
spec:
|
||||
containers:
|
||||
- name: oneuptime-probe
|
||||
image: oneuptime/probe:release
|
||||
env:
|
||||
- name: PROBE_KEY
|
||||
value: "<probe-key>"
|
||||
- name: PROBE_ID
|
||||
value: "<probe-id>"
|
||||
- name: ONEUPTIME_URL
|
||||
value: "https://oneuptime.com"
|
||||
```
|
||||
|
||||
##### With Proxy Configuration
|
||||
|
||||
If you need to use a proxy server, you can add proxy environment variables:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: oneuptime-probe
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: oneuptime-probe
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: oneuptime-probe
|
||||
spec:
|
||||
containers:
|
||||
- name: oneuptime-probe
|
||||
image: oneuptime/probe:release
|
||||
env:
|
||||
- name: PROBE_KEY
|
||||
value: "<probe-key>"
|
||||
- name: PROBE_ID
|
||||
value: "<probe-id>"
|
||||
- name: ONEUPTIME_URL
|
||||
value: "https://oneuptime.com"
|
||||
# Proxy configuration (optional)
|
||||
- name: HTTP_PROXY_URL
|
||||
value: "http://proxy.example.com:8080"
|
||||
- name: HTTPS_PROXY_URL
|
||||
value: "http://proxy.example.com:8080"
|
||||
# For proxy with authentication, use:
|
||||
# - name: HTTP_PROXY_URL
|
||||
# value: "http://username:password@proxy.example.com:8080"
|
||||
# - name: HTTPS_PROXY_URL
|
||||
# value: "http://username:password@proxy.example.com:8080"
|
||||
```
|
||||
|
||||
Then run the following command:
|
||||
@@ -80,6 +177,46 @@ kubectl apply -f oneuptime-probe.yaml
|
||||
|
||||
If you are self hosting OneUptime, you can change `ONEUPTIME_URL` to your custom self hosted instance.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The probe supports the following environment variables:
|
||||
|
||||
#### Required Variables
|
||||
- `PROBE_KEY` - The probe key from your OneUptime dashboard
|
||||
- `PROBE_ID` - The probe ID from your OneUptime dashboard
|
||||
- `ONEUPTIME_URL` - The URL of your OneUptime instance (default: https://oneuptime.com)
|
||||
|
||||
#### Optional Variables
|
||||
- `HTTP_PROXY_URL` - HTTP proxy server URL for HTTP requests
|
||||
- `HTTPS_PROXY_URL` - HTTP proxy server URL for HTTPS requests
|
||||
- `PROBE_NAME` - Custom name for the probe
|
||||
- `PROBE_DESCRIPTION` - Description for the probe
|
||||
- `PROBE_MONITORING_WORKERS` - Number of monitoring workers (default: 1)
|
||||
- `PROBE_MONITOR_FETCH_LIMIT` - Number of monitors to fetch at once (default: 10)
|
||||
- `PROBE_MONITOR_RETRY_LIMIT` - Number of retries for failed monitors (default: 3)
|
||||
- `PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS` - Timeout for synthetic monitor scripts in milliseconds (default: 60000)
|
||||
- `PROBE_CUSTOM_CODE_MONITOR_SCRIPT_TIMEOUT_IN_MS` - Timeout for custom code monitor scripts in milliseconds (default: 60000)
|
||||
|
||||
#### Proxy Configuration
|
||||
|
||||
The probe supports both HTTP and HTTPS proxy servers. When configured, the probe will route all monitoring traffic through the specified proxy servers.
|
||||
|
||||
**Proxy URL Format:**
|
||||
```
|
||||
http://[username:password@]proxy.server.com:port
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- Basic proxy: `http://proxy.example.com:8080`
|
||||
- With authentication: `http://username:password@proxy.example.com:8080`
|
||||
|
||||
**Supported Features:**
|
||||
- HTTP and HTTPS proxy support
|
||||
- Proxy authentication (username/password)
|
||||
- Automatic fallback between HTTP and HTTPS proxies
|
||||
- Works with all monitor types (Website, API, SSL, Synthetic, etc.)
|
||||
|
||||
**Note:** Both standard environment variables (`HTTP_PROXY_URL`, `HTTPS_PROXY_URL`) and lowercase variants (`http_proxy`, `https_proxy`) are supported for compatibility.
|
||||
|
||||
### Verify
|
||||
|
||||
|
||||
@@ -7,30 +7,79 @@
|
||||
}) %>
|
||||
</head>
|
||||
|
||||
<body class="flex min-h-full bg-white ">
|
||||
|
||||
<body class="flex min-h-full bg-white">
|
||||
<div class="flex w-full flex-col">
|
||||
<%- include('./Partials/Header.ejs') %>
|
||||
|
||||
<!-- Mobile top bar with menu button -->
|
||||
<div class="flex items-center justify-between lg:hidden border-b border-slate-200 px-4 py-2 gap-2">
|
||||
<button aria-label="Open navigation" data-mobile-menu-open class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-600 shadow-sm active:scale-[.97]">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
Menu
|
||||
</button>
|
||||
<h1 class="text-sm font-semibold text-slate-700 truncate"><%- link.title %></h1>
|
||||
</div>
|
||||
<!-- Mobile nav overlay -->
|
||||
<div data-mobile-menu-overlay class="fixed inset-0 z-40 bg-slate-900/40 lg:hidden hidden"></div>
|
||||
<aside data-mobile-menu-drawer class="fixed inset-y-0 left-0 z-50 w-72 overflow-y-auto bg-white px-6 py-6 shadow-lg ring-1 ring-slate-900/10 lg:hidden transform transition ease-out duration-200 -translate-x-full hidden">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm font-semibold text-slate-700">Documentation</span>
|
||||
<button aria-label="Close navigation" data-mobile-menu-close class="rounded-md p-2 text-slate-500 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"><svg class="h-5 w-5" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<%- include('./Partials/Nav.ejs') %>
|
||||
</aside>
|
||||
<div class="relative mx-auto flex w-full max-w-8xl flex-auto justify-center sm:px-2 lg:px-8 xl:px-12">
|
||||
<div class="hidden lg:relative lg:block lg:flex-none">
|
||||
<div class="absolute inset-y-0 right-0 w-[50vw] bg-slate-50 "></div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 top-16 hidden h-12 w-px bg-gradient-to-t from-slate-800 ">
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 top-28 hidden w-px bg-slate-800 "></div>
|
||||
<div
|
||||
class="sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64 overflow-y-auto overflow-x-hidden py-16 pl-0.5 pr-8 xl:w-72 xl:pr-16">
|
||||
<%- include('./Partials/Nav.ejs') %>
|
||||
<div class="absolute inset-y-0 right-0 w-[50vw] bg-slate-50"></div>
|
||||
<div class="absolute bottom-0 right-0 top-16 hidden h-12 w-px bg-gradient-to-t from-slate-800"></div>
|
||||
<div class="absolute bottom-0 right-0 top-28 hidden w-px bg-slate-800"></div>
|
||||
<div class="sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64 overflow-y-auto overflow-x-hidden py-16 pl-0.5 pr-8 xl:w-72 xl:pr-16">
|
||||
<%- include('./Partials/Nav.ejs') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 max-w-2xl flex-auto px-4 py-16 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16">
|
||||
<div class="min-w-0 max-w-2xl flex-auto px-4 py-8 md:py-12 lg:py-16 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16">
|
||||
<%- include('./Partials/Content.ejs', { category: category, link: link, content: content }) %>
|
||||
<%- include('./Partials/OpenSourceCommitment.ejs', { githubPath: githubPath }) %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile menu toggle (vanilla JS) -->
|
||||
<script>
|
||||
(function(){
|
||||
const openBtn = document.querySelector('[data-mobile-menu-open]');
|
||||
const closeBtn = document.querySelector('[data-mobile-menu-close]');
|
||||
const overlay = document.querySelector('[data-mobile-menu-overlay]');
|
||||
const drawer = document.querySelector('[data-mobile-menu-drawer]');
|
||||
if(!openBtn || !overlay || !drawer){ return; }
|
||||
function openMenu(){
|
||||
overlay.classList.remove('hidden');
|
||||
drawer.classList.remove('hidden');
|
||||
// trigger slide in
|
||||
requestAnimationFrame(()=>{
|
||||
drawer.classList.remove('-translate-x-full');
|
||||
});
|
||||
document.body.classList.add('overflow-hidden');
|
||||
}
|
||||
function closeMenu(){
|
||||
drawer.classList.add('-translate-x-full');
|
||||
overlay.classList.add('hidden');
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
// after transition hide drawer
|
||||
drawer.addEventListener('transitionend', function handler(e){
|
||||
if(e.propertyName === 'transform'){
|
||||
if(drawer.classList.contains('-translate-x-full')){
|
||||
drawer.classList.add('hidden');
|
||||
}
|
||||
drawer.removeEventListener('transitionend', handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
openBtn.addEventListener('click', openMenu);
|
||||
overlay.addEventListener('click', closeMenu);
|
||||
closeBtn && closeBtn.addEventListener('click', closeMenu);
|
||||
// Close on escape
|
||||
document.addEventListener('keydown', (e)=>{ if(e.key==='Escape'){ closeMenu(); }});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -2,17 +2,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="preload" href="/docs/static/fonts/f1.woff2" as="font" crossorigin="" type="font/woff2">
|
||||
<link rel="preload" href="/docs/static/fonts/f2.woff2" as="font" crossorigin="" type="font/woff2">
|
||||
<link rel="stylesheet" href="/docs/static/css/style.css" crossorigin="" data-precedence="next">
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="preconnect" href="https://cdn.tailwindcss.com" crossorigin>
|
||||
<link rel="stylesheet" href="/docs/static/css/style.css" crossorigin data-precedence="next">
|
||||
<title>OneUptime Documentation</title>
|
||||
<meta name="description"
|
||||
content="Cache every single thing your app could ever do ahead of time, so your code never even has to run at all.">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16">
|
||||
<meta name="next-size-adjust">
|
||||
<link rel="stylesheet" href="/docs/static/css/style.css" crossorigin="" data-precedence="next" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/a11y-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script>hljs.highlightAll();</script>
|
||||
<script src="https://cdn.tailwindcss.com" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/a11y-dark.min.css" crossorigin>
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" onload="hljs.highlightAll();"></script>
|
||||
|
||||
<% if(typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false){ %>
|
||||
<!-- Google Tag Manager -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<header
|
||||
class="sticky top-0 z-50 flex flex-none flex-wrap items-center justify-between bg-white px-4 py-5 shadow-md shadow-slate-900/5 transition duration-500 sm:px-6 lg:px-8 ">
|
||||
class="sticky top-0 z-50 flex flex-none flex-wrap items-center justify-between bg-white px-4 py-4 sm:py-5 border-b border-slate-200 sm:px-6 lg:px-8">
|
||||
|
||||
<div class="relative flex flex-grow basis-0 items-center"><a aria-label="Home page" href="/">
|
||||
<img class="h-8 w-auto" src="/img/3-transparent.svg" alt="">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<nav class="text-base lg:text-sm">
|
||||
<nav class="text-base lg:text-sm" style="width:16rem;flex:0 0 16rem;" aria-label="Documentation navigation">
|
||||
<ul role="list" class="space-y-9">
|
||||
<% for(var i=0; i<nav.length; i++) {%>
|
||||
<li>
|
||||
|
||||
50
E2E/Tests/Home/Sitemap.spec.ts
Normal file
50
E2E/Tests/Home/Sitemap.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BASE_URL, IS_BILLING_ENABLED } from "../../Config";
|
||||
import { Page, expect, test, Response } from "@playwright/test";
|
||||
import URL from "Common/Types/API/URL";
|
||||
|
||||
// Helper to fetch sitemap XML via a normal page navigation.
|
||||
async function fetchSitemap(page: Page): Promise<string> {
|
||||
const response: Response | null = await page.goto(
|
||||
URL.fromString(`${BASE_URL.toString()}sitemap.xml`).toString(),
|
||||
{ waitUntil: "networkidle" },
|
||||
);
|
||||
expect(response, "sitemap.xml should respond").toBeTruthy();
|
||||
expect(response?.status(), "sitemap.xml should return 200").toBe(200);
|
||||
|
||||
// Raw content (Playwright wraps XML in HTML view sometimes); extract text.
|
||||
const body: Awaited<ReturnType<typeof page.$>> = await page.$("body");
|
||||
const xml: string = (await body?.innerText()) || (await page.content());
|
||||
return xml;
|
||||
}
|
||||
|
||||
function extractLocs(xml: string): string[] {
|
||||
const regex: RegExp = /<loc>(.*?)<\/loc>/g;
|
||||
const locs: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
if (match[1]) {
|
||||
locs.push(match[1]);
|
||||
}
|
||||
}
|
||||
return locs;
|
||||
}
|
||||
|
||||
test.describe("Home: Sitemap", () => {
|
||||
test("sitemap loads and has home first", async ({ page }: { page: Page }) => {
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
return; // mirror existing pattern
|
||||
}
|
||||
|
||||
page.setDefaultNavigationTimeout(120000);
|
||||
|
||||
const xml: string = await fetchSitemap(page);
|
||||
expect(xml.includes("<urlset")).toBeTruthy();
|
||||
|
||||
const locs: string[] = extractLocs(xml);
|
||||
expect(locs.length).toBeGreaterThan(0);
|
||||
|
||||
const first: string | undefined = locs[0];
|
||||
expect(first, "First <loc> should exist").toBeTruthy();
|
||||
expect(first!.endsWith("/")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,9 @@ const init: PromiseVoidFunction = async (): Promise<void> => {
|
||||
serviceName: APP_NAME,
|
||||
});
|
||||
|
||||
logger.info(`FluentIngest Service - Queue concurrency: ${FLUENT_INGEST_CONCURRENCY}`);
|
||||
logger.info(
|
||||
`FluentIngest Service - Queue concurrency: ${FLUENT_INGEST_CONCURRENCY}`,
|
||||
);
|
||||
|
||||
// init the app
|
||||
await App.init({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"watch": ["./","../Common/Server", "../Common/Types", "../Common/Utils", "../Common/Models"],
|
||||
"ext": "ts,json,tsx,env,js,jsx,hbs",
|
||||
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
|
||||
"watchOptions": {"useFsEvents": false, "interval": 500},
|
||||
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
|
||||
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
|
||||
}
|
||||
@@ -103,6 +103,8 @@ The following table lists the configurable parameters of the OneUptime chart and
|
||||
| `probes.<key>.monitorFetchLimit` | Number of resources to be monitored in parallel | `10` | |
|
||||
| `probes.<key>.syntheticMonitorScriptTimeoutInMs` | Timeout for synthetic monitor script | `60000` | |
|
||||
| `probes.<key>.customCodeMonitorScriptTimeoutInMs` | Timeout for custom code monitor script | `60000` | |
|
||||
| `probes.<key>.proxy.httpProxyUrl` | HTTP proxy URL for HTTP requests made by the probe (optional) | `nil` | |
|
||||
| `probes.<key>.proxy.httpsProxyUrl` | HTTPS proxy URL for HTTPS requests made by the probe (optional) | `nil` | |
|
||||
| `probes.<key>.additionalContainers` | Additional containers to add to the probe pod | `nil` | |
|
||||
| `probes.<key>.resources` | Pod resources (limits, requests) | `nil` | |
|
||||
| `statusPage.cnameRecord` | CNAME record for the status page | `nil` | |
|
||||
|
||||
@@ -103,6 +103,14 @@ spec:
|
||||
- name: DISABLE_TELEMETRY
|
||||
value: {{ $val.disableTelemetryCollection | quote }}
|
||||
{{- end }}
|
||||
{{- if and $val.proxy $val.proxy.httpProxyUrl }}
|
||||
- name: HTTP_PROXY_URL
|
||||
value: {{ $val.proxy.httpProxyUrl | squote }}
|
||||
{{- end }}
|
||||
{{- if and $val.proxy $val.proxy.httpsProxyUrl }}
|
||||
- name: HTTPS_PROXY_URL
|
||||
value: {{ $val.proxy.httpsProxyUrl | squote }}
|
||||
{{- end }}
|
||||
{{- include "oneuptime.env.oneuptimeSecret" $ | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: {{ if and $val.ports $val.ports.http }}{{ $val.ports.http }}{{ else }}3874{{ end }}
|
||||
|
||||
@@ -208,6 +208,18 @@ probes:
|
||||
disableAutoscaler: false
|
||||
ports:
|
||||
http: 3874
|
||||
# Proxy configuration for probe connections
|
||||
proxy:
|
||||
# HTTP proxy URL for HTTP requests (optional)
|
||||
# Format: http://[username:password@]proxy.server.com:port
|
||||
# Example: http://proxy.example.com:8080
|
||||
# Example with auth: http://username:password@proxy.example.com:8080
|
||||
httpProxyUrl:
|
||||
# HTTPS proxy URL for HTTPS requests (optional)
|
||||
# Format: http://[username:password@]proxy.server.com:port
|
||||
# Example: http://proxy.example.com:8080
|
||||
# Example with auth: http://username:password@proxy.example.com:8080
|
||||
httpsProxyUrl:
|
||||
# KEDA autoscaling configuration based on monitor queue metrics
|
||||
keda:
|
||||
enabled: false
|
||||
@@ -234,6 +246,12 @@ probes:
|
||||
# customCodeMonitorScriptTimeoutInMs: 60000
|
||||
# disableTelemetryCollection: false
|
||||
# disableAutoscaler: false
|
||||
# # Proxy configuration for probe connections
|
||||
# proxy:
|
||||
# # HTTP proxy URL for HTTP requests (optional)
|
||||
# httpProxyUrl:
|
||||
# # HTTPS proxy URL for HTTPS requests (optional)
|
||||
# httpsProxyUrl:
|
||||
# resources:
|
||||
# additionalContainers:
|
||||
# KEDA autoscaling configuration based on monitor queue metrics
|
||||
|
||||
@@ -93,9 +93,40 @@ app.get(
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const tagName: string = req.params["tagName"] as string;
|
||||
const tagSlug: string = tagName; // original slug
|
||||
|
||||
const blogPosts: Array<BlogPostHeader> =
|
||||
// Pagination params
|
||||
const pageParam: string | undefined = req.query["page"] as
|
||||
| string
|
||||
| undefined;
|
||||
const pageSizeParam: string | undefined = req.query["pageSize"] as
|
||||
| string
|
||||
| undefined;
|
||||
let page: number = pageParam ? parseInt(pageParam, 10) : 1;
|
||||
let pageSize: number = pageSizeParam ? parseInt(pageSizeParam, 10) : 24;
|
||||
if (isNaN(page) || page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (isNaN(pageSize) || pageSize < 1) {
|
||||
pageSize = 24;
|
||||
}
|
||||
if (pageSize > 100) {
|
||||
pageSize = 100;
|
||||
}
|
||||
|
||||
const allPosts: Array<BlogPostHeader> =
|
||||
await BlogPostUtil.getBlogPostList(tagName);
|
||||
const totalPosts: number = allPosts.length;
|
||||
const totalPages: number = Math.ceil(totalPosts / pageSize) || 1;
|
||||
if (page > totalPages) {
|
||||
page = totalPages;
|
||||
}
|
||||
const start: number = (page - 1) * pageSize;
|
||||
const paginatedPosts: Array<BlogPostHeader> = allPosts.slice(
|
||||
start,
|
||||
start + pageSize,
|
||||
);
|
||||
const allTags: Array<string> = await BlogPostUtil.getTags();
|
||||
|
||||
res.render(`${ViewsPath}/Blog/ListByTag`, {
|
||||
support: false,
|
||||
@@ -103,8 +134,15 @@ app.get(
|
||||
cta: true,
|
||||
blackLogo: false,
|
||||
requestDemoCta: false,
|
||||
blogPosts: blogPosts,
|
||||
blogPosts: paginatedPosts,
|
||||
tagName: Text.fromDashesToPascalCase(tagName),
|
||||
tagSlug: tagSlug,
|
||||
allTags: allTags,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
totalPages: totalPages,
|
||||
totalPosts: totalPosts,
|
||||
basePath: `/blog/tag/${tagSlug}`,
|
||||
enableGoogleTagManager: IsBillingEnabled,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -117,8 +155,38 @@ app.get(
|
||||
// main blog page
|
||||
app.get("/blog", async (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const blogPosts: Array<BlogPostHeader> =
|
||||
const req: ExpressRequest = _req; // alias for clarity
|
||||
const pageParam: string | undefined = req.query["page"] as
|
||||
| string
|
||||
| undefined;
|
||||
const pageSizeParam: string | undefined = req.query["pageSize"] as
|
||||
| string
|
||||
| undefined;
|
||||
let page: number = pageParam ? parseInt(pageParam, 10) : 1;
|
||||
let pageSize: number = pageSizeParam ? parseInt(pageSizeParam, 10) : 24;
|
||||
if (isNaN(page) || page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (isNaN(pageSize) || pageSize < 1) {
|
||||
pageSize = 24;
|
||||
}
|
||||
if (pageSize > 100) {
|
||||
pageSize = 100;
|
||||
}
|
||||
|
||||
const allPosts: Array<BlogPostHeader> =
|
||||
await BlogPostUtil.getBlogPostList();
|
||||
const totalPosts: number = allPosts.length;
|
||||
const totalPages: number = Math.ceil(totalPosts / pageSize) || 1;
|
||||
if (page > totalPages) {
|
||||
page = totalPages;
|
||||
}
|
||||
const start: number = (page - 1) * pageSize;
|
||||
const paginatedPosts: Array<BlogPostHeader> = allPosts.slice(
|
||||
start,
|
||||
start + pageSize,
|
||||
);
|
||||
const allTags: Array<string> = await BlogPostUtil.getTags();
|
||||
|
||||
res.render(`${ViewsPath}/Blog/List`, {
|
||||
support: false,
|
||||
@@ -126,7 +194,13 @@ app.get("/blog", async (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
cta: true,
|
||||
blackLogo: false,
|
||||
requestDemoCta: false,
|
||||
blogPosts: blogPosts,
|
||||
blogPosts: paginatedPosts,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
totalPages: totalPages,
|
||||
totalPosts: totalPosts,
|
||||
basePath: `/blog`,
|
||||
allTags: allTags,
|
||||
enableGoogleTagManager: IsBillingEnabled,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StaticPath, ViewsPath } from "./Utils/Config";
|
||||
import NotFoundUtil from "./Utils/NotFound";
|
||||
import ProductCompare, { Product } from "./Utils/ProductCompare";
|
||||
import generateSitemapXml from "./Utils/Sitemap";
|
||||
import DatabaseConfig from "Common/Server/DatabaseConfig";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
@@ -25,12 +26,41 @@ import Reviews from "./Utils/Reviews";
|
||||
// import jobs.
|
||||
import "./Jobs/UpdateBlog";
|
||||
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
|
||||
import LocalCache from "Common/Server/Infrastructure/LocalCache";
|
||||
|
||||
const HomeFeatureSet: FeatureSet = {
|
||||
init: async (): Promise<void> => {
|
||||
const app: ExpressApplication = Express.getExpressApp();
|
||||
|
||||
//Routes
|
||||
// Middleware to inject baseUrl for templates (used for canonical links)
|
||||
app.use(
|
||||
async (_req: ExpressRequest, res: ExpressResponse, next: () => void) => {
|
||||
if (!res.locals["homeUrl"]) {
|
||||
try {
|
||||
// Try to get cached home URL first.
|
||||
let homeUrl: string | undefined = LocalCache.getString(
|
||||
"home",
|
||||
"url",
|
||||
);
|
||||
|
||||
if (!homeUrl) {
|
||||
homeUrl = (await DatabaseConfig.getHomeUrl())
|
||||
.toString()
|
||||
.replace(/\/$/, "");
|
||||
LocalCache.setString("home", "url", homeUrl);
|
||||
}
|
||||
|
||||
res.locals["homeUrl"] = homeUrl;
|
||||
} catch {
|
||||
// Fallback hard-coded production domain if env misconfigured
|
||||
res.locals["homeUrl"] = "https://oneuptime.com";
|
||||
}
|
||||
}
|
||||
next();
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/", (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
const { reviewsList1, reviewsList2, reviewsList3 } = Reviews;
|
||||
|
||||
@@ -1331,18 +1361,21 @@ const HomeFeatureSet: FeatureSet = {
|
||||
);
|
||||
|
||||
// Dynamic Sitemap
|
||||
app.get("/sitemap.xml", async (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const xml: string = await generateSitemapXml();
|
||||
res.setHeader("Content-Type", "text/xml");
|
||||
res.send(xml);
|
||||
} catch (err) {
|
||||
// Fallback minimal static sitemap if dynamic generation fails
|
||||
const fallback: string = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://oneuptime.com/</loc></url>\n</urlset>`;
|
||||
res.setHeader("Content-Type", "text/xml");
|
||||
res.status(200).send(fallback);
|
||||
}
|
||||
});
|
||||
app.get(
|
||||
"/sitemap.xml",
|
||||
async (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const xml: string = await generateSitemapXml();
|
||||
res.setHeader("Content-Type", "text/xml");
|
||||
res.send(xml);
|
||||
} catch {
|
||||
// Fallback minimal static sitemap if dynamic generation fails
|
||||
const fallback: string = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://oneuptime.com/</loc></url>\n</urlset>`;
|
||||
res.setHeader("Content-Type", "text/xml");
|
||||
res.status(200).send(fallback);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/*
|
||||
* Cache policy for static contents
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import AnalyticsBaseModel from "Common/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
|
||||
import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import { JSONArray, JSONObject, JSONObjectOrArray } from "Common/Types/JSON";
|
||||
import { JSONArray, JSONObject } from "Common/Types/JSON";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import Text from "Common/Types/Text";
|
||||
import API from "Common/Utils/API";
|
||||
import Markdown, { MarkdownContentType } from "Common/Server/Types/Markdown";
|
||||
import { BlogRootPath } from "./Config";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
@@ -19,6 +14,7 @@ export interface BlogPostAuthor {
|
||||
githubUrl: string;
|
||||
profileImageUrl: string;
|
||||
name: string;
|
||||
bio?: string | undefined; // optional bio from Authors.json
|
||||
}
|
||||
|
||||
export interface BlogPostBaseProps {
|
||||
@@ -44,6 +40,45 @@ export interface BlogPost extends BlogPostBaseProps {
|
||||
}
|
||||
|
||||
export default class BlogPostUtil {
|
||||
// Cache Blogs.json contents to avoid repeated disk reads and external calls.
|
||||
private static blogsMetaCache: Array<JSONObject> | null = null;
|
||||
// Cache Authors.json (keyed by github username)
|
||||
private static authorsMetaCache: JSONObject | null = null;
|
||||
private static async getBlogsMeta(): Promise<Array<JSONObject>> {
|
||||
if (this.blogsMetaCache) {
|
||||
return this.blogsMetaCache;
|
||||
}
|
||||
|
||||
const filePath: string = `${BlogRootPath}/Blogs.json`;
|
||||
let jsonContent: string | JSONArray = await LocalFile.read(filePath);
|
||||
if (typeof jsonContent === "string") {
|
||||
jsonContent = JSONFunctions.parseJSONArray(jsonContent);
|
||||
}
|
||||
const blogs: Array<JSONObject> = JSONFunctions.deserializeArray(
|
||||
jsonContent as Array<JSONObject>,
|
||||
);
|
||||
this.blogsMetaCache = blogs;
|
||||
return blogs;
|
||||
}
|
||||
|
||||
private static async getAuthorsMeta(): Promise<JSONObject> {
|
||||
if (this.authorsMetaCache) {
|
||||
return this.authorsMetaCache;
|
||||
}
|
||||
const filePath: string = `${BlogRootPath}/Authors.json`;
|
||||
try {
|
||||
let jsonContent: string | JSONObject = await LocalFile.read(filePath);
|
||||
if (typeof jsonContent === "string") {
|
||||
jsonContent = JSONFunctions.parse(jsonContent) as JSONObject;
|
||||
}
|
||||
this.authorsMetaCache = jsonContent as JSONObject;
|
||||
return this.authorsMetaCache || ({} as JSONObject);
|
||||
} catch {
|
||||
this.authorsMetaCache = {} as JSONObject;
|
||||
return this.authorsMetaCache;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getBlogPostList(
|
||||
tagName?: string | undefined,
|
||||
): Promise<BlogPostHeader[]> {
|
||||
@@ -98,30 +133,6 @@ export default class BlogPostUtil {
|
||||
return blogPost;
|
||||
}
|
||||
|
||||
public static async getNameOfGitHubUser(username: string): Promise<string> {
|
||||
const fileUrl: URL = URL.fromString(
|
||||
`https://api.github.com/users/${username}`,
|
||||
);
|
||||
|
||||
const fileData:
|
||||
| HTTPResponse<
|
||||
| JSONObjectOrArray
|
||||
| BaseModel
|
||||
| BaseModel[]
|
||||
| AnalyticsBaseModel
|
||||
| AnalyticsBaseModel[]
|
||||
>
|
||||
| HTTPErrorResponse = await API.get(fileUrl);
|
||||
|
||||
if (fileData.isFailure()) {
|
||||
throw fileData as HTTPErrorResponse;
|
||||
}
|
||||
|
||||
const name: string =
|
||||
(fileData.data as JSONObject)?.["name"]?.toString() || "";
|
||||
return name;
|
||||
}
|
||||
|
||||
public static async getTags(): Promise<string[]> {
|
||||
// check if tags are in cache
|
||||
|
||||
@@ -168,8 +179,43 @@ export default class BlogPostUtil {
|
||||
|
||||
let markdownContent: string = await LocalFile.read(filePath);
|
||||
|
||||
const blogPostAuthor: BlogPostAuthor | null =
|
||||
await this.getAuthorFromFileContent(markdownContent);
|
||||
// Resolve author WITHOUT hitting GitHub API. Use Blogs.json to get username, Authors.json for name/bio.
|
||||
let blogPostAuthor: BlogPostAuthor | null = null;
|
||||
try {
|
||||
const blogsMeta: Array<JSONObject> = await this.getBlogsMeta();
|
||||
const blogMeta: JSONObject | undefined = blogsMeta.find(
|
||||
(b: JSONObject) => {
|
||||
return (b["post"] as string) === fileName;
|
||||
},
|
||||
);
|
||||
const username: string | undefined = blogMeta?.[
|
||||
"authorGitHubUsername"
|
||||
] as string | undefined;
|
||||
if (username) {
|
||||
const authorsMeta: JSONObject = await this.getAuthorsMeta();
|
||||
const authorMeta: JSONObject | undefined = authorsMeta[username] as
|
||||
| JSONObject
|
||||
| undefined;
|
||||
const authorName: string | undefined =
|
||||
(authorMeta?.["authorName"] as string) || undefined;
|
||||
const authorBio: string | undefined =
|
||||
(authorMeta?.["authorBio"] as string) || undefined;
|
||||
blogPostAuthor = {
|
||||
username,
|
||||
githubUrl: `https://github.com/${username}`,
|
||||
profileImageUrl: `https://avatars.githubusercontent.com/${username}`,
|
||||
name: authorName || username,
|
||||
bio: authorBio,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback
|
||||
}
|
||||
|
||||
// Fallback to parsing markdown (no network) if metadata missing.
|
||||
if (!blogPostAuthor) {
|
||||
blogPostAuthor = await this.getAuthorFromFileContent(markdownContent);
|
||||
}
|
||||
|
||||
const title: string = this.getTitleFromFileContent(markdownContent);
|
||||
const description: string =
|
||||
@@ -346,7 +392,8 @@ export default class BlogPostUtil {
|
||||
username: authorUsername,
|
||||
githubUrl: authorGitHubUrl,
|
||||
profileImageUrl: authorProfileImageUrl,
|
||||
name: await this.getNameOfGitHubUser(authorUsername),
|
||||
// Do NOT call GitHub; use username as name placeholder.
|
||||
name: authorUsername,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,130 +15,171 @@ interface CachedSitemap {
|
||||
const TTL_MS: number = 10 * 60 * 1000;
|
||||
let cache: CachedSitemap | null = null;
|
||||
|
||||
export const generateSitemapXml = async (): Promise<string> => {
|
||||
const now: number = OneUptimeDate.getCurrentDate().getTime();
|
||||
if (cache && now - cache.generatedAt < TTL_MS) {
|
||||
return cache.xml;
|
||||
}
|
||||
export const generateSitemapXml: () => Promise<string> =
|
||||
async (): Promise<string> => {
|
||||
const now: number = OneUptimeDate.getCurrentDate().getTime();
|
||||
if (cache && now - cache.generatedAt < TTL_MS) {
|
||||
return cache.xml;
|
||||
}
|
||||
|
||||
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
||||
const baseUrl: URL = await BlogPostUtil.getHomeUrl();
|
||||
|
||||
// Discover static (non-parameterized) routes from Express stack
|
||||
const discoveredStaticPaths: Set<string> = new Set();
|
||||
try {
|
||||
const app: ExpressApplication = Express.getExpressApp();
|
||||
const stack: any[] = (app as any)?._router?.stack || [];
|
||||
for (const layer of stack) {
|
||||
if (!layer) { continue; }
|
||||
const route = layer.route || layer?.handle?.route;
|
||||
if (!route) { continue; }
|
||||
// Only include GET handlers
|
||||
const methods: any = route.methods || {};
|
||||
if (!methods.get) { continue; }
|
||||
const path: string | string[] | undefined = route.path || route?.route?.path;
|
||||
const rawPaths: Array<string | undefined> = Array.isArray(path) ? path : [path];
|
||||
const paths: string[] = rawPaths.filter((p): p is string => !!p);
|
||||
for (let p of paths) {
|
||||
if (!p || typeof p !== "string") { continue; }
|
||||
// Filters: skip parameterized, wildcard, api or file-serving or sitemap itself
|
||||
if (p.includes(":") || p.includes("*") || p.includes("sitemap")) { continue; }
|
||||
// Exclude script or installer endpoints
|
||||
if (p.endsWith(".sh")) { continue; }
|
||||
if (p.startsWith("/api") || p.startsWith("/blog/post")) { continue; }
|
||||
// We'll add compare pages separately with real slugs; skip base compare param route
|
||||
if (p.startsWith("/compare")) { continue; }
|
||||
// Normalize slash
|
||||
if (!p.startsWith("/")) { p = `/${p}`; }
|
||||
discoveredStaticPaths.add(p);
|
||||
// Discover static (non-parameterized) routes from Express stack
|
||||
const discoveredStaticPaths: Set<string> = new Set();
|
||||
try {
|
||||
const app: ExpressApplication = Express.getExpressApp();
|
||||
const stack: any[] = (app as any)?._router?.stack || [];
|
||||
for (const layer of stack) {
|
||||
if (!layer) {
|
||||
continue;
|
||||
}
|
||||
const route: any = layer.route || layer?.handle?.route;
|
||||
if (!route) {
|
||||
continue;
|
||||
}
|
||||
// Only include GET handlers
|
||||
const methods: any = route.methods || {};
|
||||
if (!methods.get) {
|
||||
continue;
|
||||
}
|
||||
const path: string | string[] | undefined =
|
||||
route.path || route?.route?.path;
|
||||
const rawPaths: Array<string | undefined> = Array.isArray(path)
|
||||
? path
|
||||
: [path];
|
||||
const paths: string[] = rawPaths.filter(
|
||||
(p: string | undefined): p is string => {
|
||||
return Boolean(p);
|
||||
},
|
||||
);
|
||||
for (let p of paths) {
|
||||
if (!p || typeof p !== "string") {
|
||||
continue;
|
||||
}
|
||||
// Filters: skip parameterized, wildcard, api or file-serving or sitemap itself
|
||||
if (p.includes(":") || p.includes("*") || p.includes("sitemap")) {
|
||||
continue;
|
||||
}
|
||||
// Exclude script or installer endpoints
|
||||
if (p.endsWith(".sh")) {
|
||||
continue;
|
||||
}
|
||||
if (p.startsWith("/api") || p.startsWith("/blog/post")) {
|
||||
continue;
|
||||
}
|
||||
// We'll add compare pages separately with real slugs; skip base compare param route
|
||||
if (p.startsWith("/compare")) {
|
||||
continue;
|
||||
}
|
||||
// Normalize slash
|
||||
if (!p.startsWith("/")) {
|
||||
p = `/${p}`;
|
||||
}
|
||||
discoveredStaticPaths.add(p);
|
||||
}
|
||||
}
|
||||
// Ensure root present
|
||||
discoveredStaticPaths.add("/");
|
||||
// Ensure docs main landing page present (may be served statically and not discoverable)
|
||||
discoveredStaticPaths.add("/docs");
|
||||
// add /reference
|
||||
discoveredStaticPaths.add("/reference");
|
||||
} catch {
|
||||
// If introspection fails, fall back to minimal set
|
||||
discoveredStaticPaths.add("/");
|
||||
discoveredStaticPaths.add("/blog");
|
||||
}
|
||||
// Ensure root present
|
||||
discoveredStaticPaths.add("/");
|
||||
} catch {
|
||||
// If introspection fails, fall back to minimal set
|
||||
discoveredStaticPaths.add("/");
|
||||
discoveredStaticPaths.add("/blog");
|
||||
}
|
||||
|
||||
const staticPaths: string[] = Array.from(discoveredStaticPaths);
|
||||
const staticPaths: string[] = Array.from(discoveredStaticPaths);
|
||||
|
||||
// Product compare pages
|
||||
const productComparePaths: string[] = getProductCompareSlugs().map(
|
||||
(slug: string) => `/compare/${slug}`,
|
||||
);
|
||||
// Product compare pages
|
||||
const productComparePaths: string[] = getProductCompareSlugs().map(
|
||||
(slug: string) => {
|
||||
return `/compare/${slug}`;
|
||||
},
|
||||
);
|
||||
|
||||
// Blog posts
|
||||
const blogPosts: Array<BlogPostHeader> = await BlogPostUtil.getBlogPostList();
|
||||
const blogPostEntries = blogPosts.map((post: BlogPostHeader) => {
|
||||
// post.blogUrl already contains /blog/post/<slug>/view relative or absolute? In BlogPostUtil it's relative (starts with /blog...), so ensure absolute.
|
||||
const loc: string = post.blogUrl.startsWith("http")
|
||||
? post.blogUrl
|
||||
: `${baseUrl.toString()}${post.blogUrl.replace(/^\//, "")}`;
|
||||
return {
|
||||
loc,
|
||||
lastmod: new Date(post.postDate).toISOString(),
|
||||
};
|
||||
});
|
||||
// Blog posts
|
||||
const blogPosts: Array<BlogPostHeader> =
|
||||
await BlogPostUtil.getBlogPostList();
|
||||
const blogPostEntries: any[] = blogPosts.map((post: BlogPostHeader) => {
|
||||
// post.blogUrl already contains /blog/post/<slug>/view relative or absolute? In BlogPostUtil it's relative (starts with /blog...), so ensure absolute.
|
||||
const loc: string = post.blogUrl.startsWith("http")
|
||||
? post.blogUrl
|
||||
: `${baseUrl.toString()}${post.blogUrl.replace(/^\//, "")}`;
|
||||
return {
|
||||
loc,
|
||||
lastmod: new Date(post.postDate).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
// Blog tags
|
||||
const tags: string[] = await BlogPostUtil.getTags();
|
||||
const tagEntries = tags.map((tag: string) => {
|
||||
const tagSlug: string = tag
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.trim();
|
||||
return {
|
||||
loc: `${baseUrl.toString()}blog/tag/${tagSlug}`,
|
||||
lastmod: OneUptimeDate.getCurrentDate().toISOString(),
|
||||
};
|
||||
});
|
||||
// Blog tags
|
||||
const tags: string[] = await BlogPostUtil.getTags();
|
||||
const tagEntries: any[] = tags.map((tag: string) => {
|
||||
const tagSlug: string = tag.toLowerCase().replace(/\s+/g, "-").trim();
|
||||
return {
|
||||
loc: `${baseUrl.toString()}blog/tag/${tagSlug}`,
|
||||
lastmod: OneUptimeDate.getCurrentDate().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
|
||||
const timestamp: string = OneUptimeDate.getCurrentDate().toISOString();
|
||||
|
||||
interface Entry { loc: string; lastmod: string }
|
||||
const entries: Entry[] = [
|
||||
...staticPaths.map((p: string) => ({
|
||||
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
|
||||
lastmod: timestamp,
|
||||
})),
|
||||
...productComparePaths.map((p: string) => ({
|
||||
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
|
||||
lastmod: timestamp,
|
||||
})),
|
||||
...blogPostEntries,
|
||||
...tagEntries,
|
||||
];
|
||||
|
||||
// Remove duplicates (possible if overlap)
|
||||
const dedupMap: Map<string, Entry> = new Map();
|
||||
entries.forEach((e: Entry) => {
|
||||
if (!dedupMap.has(e.loc)) {
|
||||
dedupMap.set(e.loc, e);
|
||||
interface Entry {
|
||||
loc: string;
|
||||
lastmod: string;
|
||||
}
|
||||
});
|
||||
const entries: Entry[] = [
|
||||
...staticPaths.map((p: string) => {
|
||||
return {
|
||||
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
|
||||
lastmod: timestamp,
|
||||
};
|
||||
}),
|
||||
...productComparePaths.map((p: string) => {
|
||||
return {
|
||||
loc: `${baseUrl.toString()}${p.replace(/^\//, "")}`,
|
||||
lastmod: timestamp,
|
||||
};
|
||||
}),
|
||||
...blogPostEntries,
|
||||
...tagEntries,
|
||||
];
|
||||
|
||||
const urlset: XMLBuilder = create().ele("urlset");
|
||||
urlset.att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
|
||||
// Remove duplicates (possible if overlap)
|
||||
const dedupMap: Map<string, Entry> = new Map();
|
||||
entries.forEach((e: Entry) => {
|
||||
if (!dedupMap.has(e.loc)) {
|
||||
dedupMap.set(e.loc, e);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure home URL is first
|
||||
const baseUrlString: string = baseUrl.toString();
|
||||
const orderedEntries = Array.from(dedupMap.values());
|
||||
orderedEntries.sort((a, b) => {
|
||||
if (a.loc === baseUrlString) { return -1; }
|
||||
if (b.loc === baseUrlString) { return 1; }
|
||||
return 0; // preserve relative order otherwise
|
||||
});
|
||||
const urlset: XMLBuilder = create().ele("urlset");
|
||||
urlset.att("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9");
|
||||
|
||||
for (const entry of orderedEntries) {
|
||||
const urlEle: XMLBuilder = urlset.ele("url");
|
||||
urlEle.ele("loc").txt(entry.loc);
|
||||
urlEle.ele("lastmod").txt(entry.lastmod);
|
||||
}
|
||||
// Ensure home URL is first
|
||||
const baseUrlString: string = baseUrl.toString();
|
||||
const orderedEntries: any[] = Array.from(dedupMap.values());
|
||||
orderedEntries.sort((a: any, b: any) => {
|
||||
if (a.loc === baseUrlString) {
|
||||
return -1;
|
||||
}
|
||||
if (b.loc === baseUrlString) {
|
||||
return 1;
|
||||
}
|
||||
return 0; // preserve relative order otherwise
|
||||
});
|
||||
|
||||
const xml: string = urlset.end({ prettyPrint: true });
|
||||
for (const entry of orderedEntries) {
|
||||
const urlEle: XMLBuilder = urlset.ele("url");
|
||||
urlEle.ele("loc").txt(entry.loc);
|
||||
urlEle.ele("lastmod").txt(entry.lastmod);
|
||||
}
|
||||
|
||||
cache = { xml, generatedAt: now };
|
||||
return xml;
|
||||
};
|
||||
const xml: string = urlset.end({ prettyPrint: true });
|
||||
|
||||
cache = { xml, generatedAt: now };
|
||||
return xml;
|
||||
};
|
||||
|
||||
export default generateSitemapXml;
|
||||
|
||||
@@ -20,31 +20,55 @@
|
||||
<div class="relative isolate overflow-hidden bg-white">
|
||||
<div class="py-24 sm:py-32">
|
||||
|
||||
<%- include('./Partials/BlogTitleAndDescription', { title: 'Engineering Uptime', smallTitle: '- Blog by OneUptime', description: 'Latest posts on Observability, Monitoring, Reliability and more.' }) -%>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
|
||||
<div>
|
||||
<%- include('./Partials/BlogTitleAndDescription', { title: 'Engineering Uptime', smallTitle: '- Blog by OneUptime', description: 'Latest posts on Observability, Monitoring, Reliability and more.' }) -%>
|
||||
|
||||
<div class="mt-4">
|
||||
<% if(blogPosts.length> 0){ %>
|
||||
<ul role="list" class="divide-y divide-gray-100 list-none">
|
||||
|
||||
<% for(var i=0; i<blogPosts.length; i++) {%>
|
||||
|
||||
|
||||
|
||||
<%- include('./Partials/ListItem', { blogPost: blogPosts[i] }) -%>
|
||||
|
||||
<% } %>
|
||||
|
||||
</ul>
|
||||
<% } %>
|
||||
<% const featured = blogPosts[0]; const rest = blogPosts.slice(1); %>
|
||||
<!-- Featured Post -->
|
||||
<div class="relative mb-14 rounded-3xl overflow-hidden border border-indigo-100 bg-gradient-to-br from-indigo-50 via-white to-indigo-100/40 p-8 md:p-12 shadow-sm ring-1 ring-indigo-100/60">
|
||||
<div class="grid md:grid-cols-5 gap-10 items-center">
|
||||
<div class="md:col-span-3">
|
||||
<a href="<%- featured.blogUrl %>" class="group no-underline">
|
||||
<p class="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-indigo-700 bg-indigo-100/70 px-3 py-1 rounded-full ring-1 ring-indigo-200">Featured</p>
|
||||
<h2 class="mt-5 text-3xl md:text-4xl font-bold tracking-tight text-gray-900 group-hover:text-indigo-700 transition-colors"><%- featured.title %></h2>
|
||||
<p class="mt-5 text-base md:text-lg leading-relaxed text-gray-600 line-clamp-5"><%- featured.description %></p>
|
||||
<div class="mt-6 text-xs text-gray-500 flex flex-wrap items-center gap-2">
|
||||
<img class="h-8 w-8 rounded-full ring-2 ring-white shadow" src="https://avatars.githubusercontent.com/<%- featured.authorGitHubUsername -%>?s=80" alt="@<%- featured.authorGitHubUsername -%>" width="32" height="32" loading="lazy" decoding="async">
|
||||
<span>@<%- featured.authorGitHubUsername -%></span>
|
||||
<span class="select-none">•</span>
|
||||
<span><%- featured.formattedPostDate -%></span>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<%- include('./Partials/Tags', { blogPost: featured }) -%>
|
||||
</div>
|
||||
<div class="mt-8 inline-flex items-center gap-2 text-sm font-semibold text-indigo-600 group-hover:text-indigo-500">Read article <svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" /></svg></div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="md:col-span-2 relative">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(99,102,241,0.15),transparent_70%)] pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid -->
|
||||
<div id="blog-posts-grid" class="grid grid-cols-1 gap-10 sm:grid-cols-2 xl:grid-cols-3 items-stretch">
|
||||
<% for(var i=0; i<rest.length; i++) { const post = rest[i]; %>
|
||||
<div data-blog-card class="h-full" data-search="<%- (post.title + ' ' + post.description + ' ' + post.tags.join(' ')).toLowerCase() -%>">
|
||||
<%- include('./Partials/ListItem', { blogPost: post }) -%>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div id="no-search-results" class="hidden text-center text-gray-500 text-sm py-16">No posts match your search.</div>
|
||||
<%- include('./Partials/Pagination', { page: page, pageSize: pageSize, totalPages: totalPages, totalPosts: totalPosts, basePath: basePath }) -%>
|
||||
<% } else { %>
|
||||
<div class="text-center text-gray-600 text-lg py-12">No blog posts found.</div>
|
||||
<% } %>
|
||||
<div class="mt-24">
|
||||
<%- include('./Partials/OpenSourceCommitment', {blogPost: null}) -%>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,30 +20,23 @@
|
||||
<div class="relative isolate overflow-hidden bg-white">
|
||||
<div class="py-24 sm:py-32">
|
||||
|
||||
<%- include('./Partials/BlogTitleAndDescription', { title: 'Latest posts on '+tagName, smallTitle: "", description: 'Here are some of the latest posts on '+tagName }) -%>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
|
||||
<div>
|
||||
|
||||
<%- include('./Partials/BlogTitleAndDescription', { title: 'Latest posts on '+tagName, smallTitle: "", description: 'Here are some of the latest posts on '+tagName }) -%>
|
||||
<div class="mt-16">
|
||||
<% if(blogPosts.length> 0){ %>
|
||||
<ul role="list" class="divide-y divide-gray-100 list-none">
|
||||
|
||||
<% for(var i=0; i<blogPosts.length; i++) {%>
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 gap-10 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<% for(var i=0; i<blogPosts.length; i++) { %>
|
||||
<%- include('./Partials/ListItem', { blogPost: blogPosts[i] }) -%>
|
||||
|
||||
<% } %>
|
||||
|
||||
</ul>
|
||||
<% } %>
|
||||
|
||||
<% } %>
|
||||
</div>
|
||||
<%- include('./Partials/Pagination', { page: page, pageSize: pageSize, totalPages: totalPages, totalPosts: totalPosts, basePath: basePath }) -%>
|
||||
<% } else { %>
|
||||
<div class="text-center text-gray-600 text-lg py-12">No posts found for this tag.</div>
|
||||
<% } %>
|
||||
<div class="mt-20">
|
||||
<%- include('./Partials/OpenSourceCommitment', {blogPost: null}) -%>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('./Partials/OpenSourceCommitment', {blogPost: null}) -%>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
78
Home/Views/Blog/Partials/AllTagsFilter.ejs
Normal file
78
Home/Views/Blog/Partials/AllTagsFilter.ejs
Normal file
@@ -0,0 +1,78 @@
|
||||
<% if(allTags && allTags.length){ %>
|
||||
<div class="mb-14" id="tag-filter-root">
|
||||
<div class="mb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-semibold text-gray-700 flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M4 6a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0012.414 6H18a2 2 0 012 2v2M4 8v8a2 2 0 002 2h2m12-8v4a2 2 0 01-2 2h-4m-6 0h6m-6 0a2 2 0 01-2-2v-2"/></svg> Tags</span>
|
||||
<span class="hidden md:inline text-xs rounded-full bg-indigo-50 text-indigo-700 px-2 py-0.5 font-medium"> <%- allTags.length %> </span>
|
||||
</div>
|
||||
<div class="relative w-full md:w-72">
|
||||
<input id="tag-filter-search" type="text" placeholder="Search tags..." class="w-full rounded-md border border-gray-200 bg-white py-2 pl-9 pr-3 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" aria-label="Search tags" />
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div id="tag-filter-container" class="flex gap-2 overflow-x-auto pb-2 scrollbar-thin snap-x snap-mandatory" style="scrollbar-width: none; -ms-overflow-style: none;">
|
||||
<style>
|
||||
#tag-filter-container::-webkit-scrollbar{display:none;}
|
||||
</style>
|
||||
<a href="/blog" data-tag="all" class="tag-chip <%= !tagName ? 'tag-chip-active' : '' %>" aria-label="Show all posts">All</a>
|
||||
<% for(var i=0;i<allTags.length;i++){ const t = allTags[i]; const slug = t.replaceAll(' ','-').toLowerCase(); const active = (tagName && tagName.toLowerCase()===t.toLowerCase()); %>
|
||||
<a href="/blog/tag/<%- slug -%>" data-tag="<%- slug %>" class="tag-chip <%= active ? 'tag-chip-active' : '' %>" aria-label="Filter by <%- t %>"><%- t %></a>
|
||||
<% } %>
|
||||
<div id="no-tag-results" class="hidden px-3 py-1.5 text-xs text-gray-500">No tags match.</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute top-0 right-0 h-full w-10 bg-gradient-to-l from-white to-transparent hidden md:block"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between text-[11px] text-gray-500">
|
||||
<div id="tag-count-info">Showing <%- allTags.length %> tags</div>
|
||||
<button id="toggle-more-tags" type="button" class="hidden text-indigo-600 hover:text-indigo-500 font-medium">Show more</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
if(window.__TAG_FILTER_INITIALIZED__) { return; }
|
||||
window.__TAG_FILTER_INITIALIZED__ = true;
|
||||
const searchInput = document.getElementById('tag-filter-search');
|
||||
const container = document.getElementById('tag-filter-container');
|
||||
const info = document.getElementById('tag-count-info');
|
||||
const noResults = document.getElementById('no-tag-results');
|
||||
if(!searchInput || !container) { return; }
|
||||
const chips = Array.from(container.querySelectorAll('.tag-chip'));
|
||||
function normalize(s){ return (s||'').toLowerCase(); }
|
||||
function filter(){
|
||||
const q = normalize(searchInput.value);
|
||||
let visible = 0;
|
||||
chips.forEach(chip => {
|
||||
if(chip.dataset.tag === 'all'){ // always show All
|
||||
chip.classList.remove('hidden');
|
||||
return; }
|
||||
const text = normalize(chip.textContent);
|
||||
if(!q || text.includes(q)) { chip.classList.remove('hidden'); visible++; }
|
||||
else { chip.classList.add('hidden'); }
|
||||
});
|
||||
if(visible === 0 && q){ noResults.classList.remove('hidden'); }
|
||||
else { noResults.classList.add('hidden'); }
|
||||
info && (info.textContent = q ? `Matched ${visible} tag${visible!==1?'s':''}` : `Showing ${chips.length-1} tags`);
|
||||
}
|
||||
searchInput.addEventListener('input', filter);
|
||||
// Keyboard horizontal scroll convenience
|
||||
searchInput.addEventListener('keydown', function(e){
|
||||
if(e.key === 'ArrowRight'){ container.scrollBy({left:120,behavior:'smooth'}); }
|
||||
if(e.key === 'ArrowLeft'){ container.scrollBy({left:-120,behavior:'smooth'}); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Inline fallback styling since Tailwind @apply isn't processed in EJS runtime */
|
||||
.tag-chip { font-size: 0.75rem; font-weight:500; padding:0.375rem 0.75rem; border-radius:9999px; border:1px solid #e5e7eb; background:#ffffff; color:#4b5563; display:inline-flex; align-items:center; transition:all .15s; white-space:nowrap; text-decoration:none; }
|
||||
.tag-chip:hover { color:#4f46e5; border-color:#818cf8; background:#eef2ff; }
|
||||
.tag-chip-active { background:#4f46e5; border-color:#4f46e5; color:#ffffff; }
|
||||
.tag-chip-active:hover { background:#4f46e5; color:#ffffff; }
|
||||
</style>
|
||||
<% } %>
|
||||
@@ -1,17 +1,17 @@
|
||||
<div class="mb-24">
|
||||
<div class="mx-auto flex justify-center text-center ">
|
||||
<h1 class="max-w-5xl text-center text-6xl font-bold text-gray-900 leading-tight">
|
||||
<div class="relative mb-16 md:mb-20">
|
||||
<div class="mx-auto flex justify-center text-center">
|
||||
<div>
|
||||
<h1 class="max-w-5xl text-5xl md:text-6xl font-extrabold tracking-tight text-gray-900 leading-tight bg-clip-text">
|
||||
<%= title %>
|
||||
</h1>
|
||||
</h1>
|
||||
<% if(smallTitle){ %>
|
||||
<h3 class="max-w-5xl mt-4 text-2xl font-semibold text-indigo-600/80 leading-tight">
|
||||
<%= smallTitle %>
|
||||
</h3>
|
||||
<% } %>
|
||||
<p class="max-w-3xl mx-auto text-lg md:text-2xl leading-8 text-gray-600 mt-6">
|
||||
<%= description %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-auto flex justify-center text-center ">
|
||||
<% if(smallTitle){ %>
|
||||
<h3 class="max-w-5xl mt-5 text-center text-2xl font-medium text-gray-900 leading-tight">
|
||||
<%= smallTitle %>
|
||||
</h3>
|
||||
<% } %>
|
||||
</div>
|
||||
<p class="text-2xl text-center leading-8 text-gray-600 mt-8 leading-normal">
|
||||
<%= description %>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,33 +1,30 @@
|
||||
<li class="py-5">
|
||||
<div class="min-w-0">
|
||||
<a href="<%= blogPost.blogUrl %>">
|
||||
|
||||
<div class="min-w-0 flex-auto">
|
||||
<p class="font-semibold text-2xl leading-6 text-gray-900">
|
||||
<%= blogPost.title %>
|
||||
</p>
|
||||
<p class="mt-2 leading-5 text-gray-600">
|
||||
<%= blogPost.description %>
|
||||
</p>
|
||||
<div class="group relative flex flex-col h-full rounded-2xl border border-gray-200/80 bg-white/70 backdrop-blur-sm p-6 shadow-sm ring-1 ring-gray-200/60 hover:shadow-lg hover:border-indigo-300 hover:ring-indigo-200 transition-all duration-300 focus-within:shadow-lg" tabindex="0" aria-label="Blog post card: <%- blogPost.title.replace(/\"/g,'') -%>">
|
||||
<div class="absolute -inset-px rounded-2xl opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition pointer-events-none bg-gradient-to-br from-indigo-500/5 via-transparent to-indigo-400/10"></div>
|
||||
<div class="flex-1 flex flex-col relative z-10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1">
|
||||
<a href="<%= blogPost.blogUrl %>" class="no-underline focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-md">
|
||||
<h2 class="text-xl font-semibold tracking-tight text-gray-900 group-hover:text-indigo-600 transition-colors line-clamp-2">
|
||||
<%= blogPost.title %>
|
||||
</h2>
|
||||
</a>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600 line-clamp-4"><%= blogPost.description %></p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="-mt-5">
|
||||
<div class="mt-4">
|
||||
<%- include('./Tags', { blogPost: blogPost }) -%>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="">
|
||||
|
||||
<a href="https://github.com/<%- blogPost.authorGitHubUsername -%>" target="_blank">
|
||||
<div class="flex items-center gap-x-6">
|
||||
<p class="text-sm font-medium leading-7 tracking-tight text-gray-600">
|
||||
<span class="text-gray-500">By</span> @<%- blogPost.authorGitHubUsername -%> <span
|
||||
class="text-gray-500"> on
|
||||
<%- blogPost.formattedPostDate -%></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<a href="https://github.com/<%- blogPost.authorGitHubUsername -%>" target="_blank" class="flex items-center gap-3 group/author">
|
||||
<img loading="lazy" src="https://avatars.githubusercontent.com/<%- blogPost.authorGitHubUsername -%>?s=64" alt="@<%- blogPost.authorGitHubUsername -%>" class="h-10 w-10 rounded-full ring-2 ring-white shadow-sm group-hover/author:ring-indigo-200 transition" width="40" height="40" decoding="async">
|
||||
<div class="text-[11px] leading-4 text-gray-600">
|
||||
<span class="text-gray-500">By</span> @<%- blogPost.authorGitHubUsername -%><br/>
|
||||
<span class="text-gray-400"><%- blogPost.formattedPostDate -%></span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="<%= blogPost.blogUrl %>" class="shrink-0 text-sm font-medium text-indigo-600 hover:text-indigo-500 inline-flex items-center gap-1 group/cta">Read
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 transition-transform group-hover/cta:translate-x-0.5"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
37
Home/Views/Blog/Partials/Pagination.ejs
Normal file
37
Home/Views/Blog/Partials/Pagination.ejs
Normal file
@@ -0,0 +1,37 @@
|
||||
<% if(totalPages && totalPages > 1){ %>
|
||||
<nav class="mt-16 flex items-center justify-between" aria-label="Pagination">
|
||||
<div>
|
||||
<% if(page > 1){ %>
|
||||
<a href="<%- basePath %>?page=<%- page-1 %>&pageSize=<%- pageSize %>" class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-700 hover:bg-gray-50">Previous</a>
|
||||
<% } else { %>
|
||||
<span class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-200 bg-gray-50 text-gray-300 cursor-not-allowed">Previous</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
<% const windowSize = 5; let start = Math.max(1, page - Math.floor(windowSize/2)); let end = Math.min(totalPages, start + windowSize -1); if(end-start+1 < windowSize){ start = Math.max(1, end-windowSize+1);} %>
|
||||
<% if(start > 1){ %>
|
||||
<a href="<%- basePath %>?page=1&pageSize=<%- pageSize %>" class="px-3 py-2 text-sm font-medium rounded-md border border-gray-200 bg-white hover:bg-gray-50">1</a>
|
||||
<% if(start > 2){ %><span class="px-2 text-gray-400">…</span><% } %>
|
||||
<% } %>
|
||||
<% for(let p = start; p<=end; p++){ %>
|
||||
<% if(p === page){ %>
|
||||
<span class="px-3 py-2 text-sm font-semibold rounded-md bg-indigo-600 text-white border border-indigo-600"><%- p %></span>
|
||||
<% } else { %>
|
||||
<a href="<%- basePath %>?page=<%- p %>&pageSize=<%- pageSize %>" class="px-3 py-2 text-sm font-medium rounded-md border border-gray-200 bg-white hover:bg-gray-50"><%- p %></a>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if(end < totalPages){ %>
|
||||
<% if(end < totalPages-1){ %><span class="px-2 text-gray-400">…</span><% } %>
|
||||
<a href="<%- basePath %>?page=<%- totalPages %>&pageSize=<%- pageSize %>" class="px-3 py-2 text-sm font-medium rounded-md border border-gray-200 bg-white hover:bg-gray-50"><%- totalPages %></a>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if(page < totalPages){ %>
|
||||
<a href="<%- basePath %>?page=<%- page+1 %>&pageSize=<%- pageSize %>" class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-300 bg-white text-gray-700 hover:bg-gray-50">Next</a>
|
||||
<% } else { %>
|
||||
<span class="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium border border-gray-200 bg-gray-50 text-gray-300 cursor-not-allowed">Next</span>
|
||||
<% } %>
|
||||
</div>
|
||||
</nav>
|
||||
<p class="mt-4 text-center text-xs text-gray-500">Showing <%- ((page-1)*pageSize)+1 %> - <%- Math.min(page*pageSize, totalPosts) %> of <%- totalPosts %> posts</p>
|
||||
<% } %>
|
||||
@@ -1,22 +1,12 @@
|
||||
<% if(blogPost.tags.length> 0){ %>
|
||||
<div class="flex mt-10">
|
||||
<div class="space-x-1">
|
||||
<!-- Loop over blogPost.tags and show them here-->
|
||||
<% for(var i=0; i<blogPost.tags.length; i++) {%>
|
||||
<a href="/blog/tag/<%- blogPost.tags[i].replaceAll(' ','-').toLowerCase() -%>">
|
||||
<div
|
||||
class="relative inline-flex items-center rounded-full border border-gray-300 px-3 py-0.5 text-sm">
|
||||
<div class="absolute flex flex-shrink-0 items-center justify-center">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-indigo-500" aria-hidden="true">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3.5 font-medium text-gray-900">
|
||||
<%- blogPost.tags[i] -%>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% for(var i=0; i<blogPost.tags.length; i++) { %>
|
||||
<a href="/blog/tag/<%- blogPost.tags[i].replaceAll(' ','-').toLowerCase() -%>" class="group/tag">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-700 shadow-sm hover:border-indigo-300 hover:bg-indigo-50 hover:text-indigo-700 transition">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-indigo-500 group-hover/tag:bg-indigo-600"></span>
|
||||
<%- blogPost.tags[i] -%>
|
||||
</span>
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
@@ -9,7 +9,7 @@
|
||||
</title>
|
||||
<meta name="description" content="<%= blogPost.description %>">
|
||||
<%- include('../head-basic') -%>
|
||||
<link rel="canonical" href="https://oneuptime.com/blog/post/<%= blogPost.fileName %>" />
|
||||
<link rel="canonical" href="https://oneuptime.com/blog/post/<%= blogPost.fileName %>/view" />
|
||||
<meta property="og:site_name" content="OneUptime | One Complete Observability platform.">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:title" content="<%= blogPost.title %>">
|
||||
@@ -63,56 +63,131 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script>hljs.highlightAll();</script>
|
||||
|
||||
<style>
|
||||
/* Custom styles for code blocks in blog posts */
|
||||
.blog-body pre {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.blog-body pre code {
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
line-height: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
}
|
||||
|
||||
/* Ensure highlight.js doesn't override our font settings */
|
||||
.blog-body .hljs {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
|
||||
<body>
|
||||
<%- include('../nav') -%>
|
||||
|
||||
<div class="relative isolate overflow-hidden bg-white">
|
||||
<div class="py-24 sm:py-32">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
|
||||
<%- include('./Partials/BlogTitleAndDescription', { title: blogPost.title , smallTitle: "", description:
|
||||
blogPost.description }) -%>
|
||||
|
||||
|
||||
<div class="blog-body">
|
||||
<%- blogPost.htmlBody -%>
|
||||
</div>
|
||||
|
||||
<div class="relative isolate overflow-hidden bg-gradient-to-b from-white via-white to-indigo-50/40">
|
||||
<!-- Hero / Title Section -->
|
||||
<div class="pt-20 pb-12 sm:pt-28 sm:pb-20">
|
||||
<div class="mx-auto max-w-4xl px-6 lg:px-8 text-center">
|
||||
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight text-gray-900 leading-tight">
|
||||
<%= blogPost.title %>
|
||||
</h1>
|
||||
<p class="mt-6 text-xl text-gray-600 leading-relaxed max-w-3xl mx-auto">
|
||||
<%= blogPost.description %>
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-gray-500">
|
||||
<% if(blogPost.author){ %>
|
||||
<a href="<%- blogPost.author.githubUrl -%>" target="_blank" class="flex items-center space-x-2 group">
|
||||
<!-- Added explicit width & height to stabilize layout (CLS) -->
|
||||
<img class="h-8 w-8 rounded-full ring-2 ring-indigo-200 group-hover:ring-indigo-400 transition" src="<%- blogPost.author.profileImageUrl -%>" alt="<%- blogPost.author.name -%>" width="32" height="32" decoding="async">
|
||||
<span class="font-medium text-gray-700 group-hover:text-gray-900 transition">@<%- blogPost.author.username -%></span>
|
||||
</a>
|
||||
<span class="hidden sm:inline select-none">•</span>
|
||||
<span><%- blogPost.formattedPostDate -%></span>
|
||||
<span class="hidden sm:inline select-none">•</span>
|
||||
<% } %>
|
||||
<span id="reading-time" class="inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="sr-only">Reading time</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<%- include('./Partials/Tags', { blogPost: blogPost }) -%>
|
||||
|
||||
<div class="bg-white my-10 mt-20">
|
||||
<div class="">
|
||||
<ul role="list" class="">
|
||||
<li>
|
||||
<a href="<%- blogPost.author.githubUrl -%>" target="_blank">
|
||||
<div class="flex items-center gap-x-6">
|
||||
<img class="h-12 w-12 rounded-full"
|
||||
src="<%- blogPost.author.profileImageUrl -%>" alt="">
|
||||
<div class="-ml-4">
|
||||
<h3
|
||||
class="text-base font-medium leading-7 tracking-tight text-gray-600">
|
||||
<span class="text-gray-500">By</span> <%-
|
||||
blogPost.author.name -%> <span class="text-gray-500"> on
|
||||
<%- blogPost.formattedPostDate -%></span>
|
||||
</h3>
|
||||
<p class="text-sm font-medium leading-6 text-indigo-500 -mt-1">
|
||||
@<%- blogPost.author.username -%></p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('./Partials/OpenSourceCommitment', { blogPost: blogPost }) -%>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content + Sidebar -->
|
||||
<div class="pb-24">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||
<!-- Main content -->
|
||||
<article class="lg:col-span-8 xl:col-span-9">
|
||||
<div class="blog-body prose prose-slate max-w-none prose-headings:scroll-mt-28 prose-headings:font-display prose-headings:font-semibold lg:prose-headings:scroll-mt-[8.5rem] prose-a:font-semibold prose-a:text-indigo-600 hover:prose-a:text-indigo-500 prose-img:rounded-xl prose-pre:rounded-xl prose-code:text-indigo-600">
|
||||
<%- blogPost.htmlBody -%>
|
||||
</div>
|
||||
|
||||
<!-- Share -->
|
||||
<div class="mt-14 border-t border-gray-200/70 pt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-6">
|
||||
<div class="text-sm font-medium text-gray-600">Share this article</div>
|
||||
<div class="flex gap-3">
|
||||
<a title="Share on X" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-indigo-400 hover:bg-indigo-50 transition" href="https://twitter.com/intent/tweet?text=<%- encodeURIComponent(blogPost.title) -%>&url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
|
||||
<svg class="w-4 h-4 text-gray-500 group-hover:text-indigo-600" viewBox="0 0 24 24" fill="currentColor"><path d="M17.53 3h3.77l-8.26 9.45L23 21h-6.17l-4.8-6.01L6.4 21H2.62l8.63-9.87L1 3h6.32l4.33 5.41L17.53 3Zm-1.33 15.62h2.09L7.94 4.29H5.71l10.49 14.33Z"/></svg>
|
||||
</a>
|
||||
<a title="Share on LinkedIn" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-indigo-400 hover:bg-indigo-50 transition" href="https://www.linkedin.com/sharing/share-offsite/?url=<%- encodeURIComponent(blogPost.blogUrl) -%>">
|
||||
<svg class="w-4 h-4 text-gray-500 group-hover:text-indigo-600" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.049c.476-.9 1.637-1.85 3.37-1.85 3.601 0 4.266 2.37 4.266 5.455v6.286ZM5.337 7.433a2.062 2.062 0 1 1 0-4.124 2.062 2.062 0 0 1 0 4.124ZM7.119 20.452H3.553V9h3.566v11.452ZM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003Z"/></svg>
|
||||
</a>
|
||||
<a title="Discuss on Hacker News" target="_blank" rel="noopener" class="group inline-flex items-center justify-center h-9 w-9 rounded-lg border border-gray-200 hover:border-indigo-400 hover:bg-indigo-50 transition" href="https://news.ycombinator.com/submitlink?u=<%- encodeURIComponent(blogPost.blogUrl) -%>&t=<%- encodeURIComponent(blogPost.title) -%>">
|
||||
<svg class="w-4 h-4 text-gray-500 group-hover:text-indigo-600" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3v18h18V3H3Zm9.774 10.876L17 6h-2.117l-2.742 5.274L9.4 6H7l4.226 7.876V18h2.548v-4.124Z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<% if(blogPost.author){ %>
|
||||
<div class="mt-16 p-6 rounded-2xl bg-white/60 backdrop-blur border border-gray-200 shadow-sm flex gap-6 items-start">
|
||||
<!-- Added explicit width/height + lazy loading for below-the-fold author bio image -->
|
||||
<img class="h-16 w-16 rounded-full ring-2 ring-indigo-200" src="<%- blogPost.author.profileImageUrl -%>" alt="<%- blogPost.author.name -%>" width="64" height="64" loading="lazy" decoding="async">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900"><%- blogPost.author.name -%></h3>
|
||||
<p class="text-sm text-gray-600 mb-2">@<%- blogPost.author.username -%> • <%- blogPost.formattedPostDate -%> • <span id="reading-time-inline"></span></p>
|
||||
<div class="text-sm text-gray-700"><%- blogPost.author.bio || 'Building reliable software at OneUptime. Follow along for more on observability & reliability.' -%></div>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<a href="<%- blogPost.author.githubUrl -%>" target="_blank" class="text-xs font-medium text-indigo-600 hover:text-indigo-500 inline-flex items-center gap-1">View GitHub
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 5h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="mt-20">
|
||||
<%- include('./Partials/OpenSourceCommitment', { blogPost: blogPost }) -%>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="hidden lg:block lg:col-span-4 xl:col-span-3">
|
||||
<div class="sticky top-28 space-y-8">
|
||||
<div class="rounded-2xl border border-gray-200 bg-white/70 backdrop-blur p-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-gray-900 uppercase mb-4">On this page</h2>
|
||||
<!-- Reserve initial space for TOC to reduce layout shift once populated -->
|
||||
<nav id="toc" aria-label="Table of contents" class="text-sm leading-6 space-y-2 text-gray-700 min-h-[3rem]"></nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,6 +195,76 @@
|
||||
|
||||
<%- include('./Partials/BlogCta') -%>
|
||||
<%- include('../footer') -%>
|
||||
<script>
|
||||
(function(){
|
||||
// Reading time
|
||||
try {
|
||||
const container = document.querySelector('.blog-body');
|
||||
if(container){
|
||||
const text = container.textContent || '';
|
||||
const words = text.trim().split(/\s+/).filter(Boolean).length;
|
||||
const minutes = Math.max(1, Math.round(words / 200));
|
||||
const rt = minutes + ' min read';
|
||||
const el = document.getElementById('reading-time');
|
||||
if(el){ el.insertAdjacentText('beforeend', rt); }
|
||||
const el2 = document.getElementById('reading-time-inline');
|
||||
if(el2){ el2.textContent = rt; }
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// TOC
|
||||
try {
|
||||
const container = document.querySelector('.blog-body');
|
||||
if(!container){ return; }
|
||||
const headings = container.querySelectorAll('h2, h3');
|
||||
if(!headings.length){ return; }
|
||||
const toc = document.getElementById('toc');
|
||||
if(!toc){ return; }
|
||||
headings.forEach(h => {
|
||||
if(!h.id){
|
||||
h.id = h.textContent.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = '#' + h.id;
|
||||
a.textContent = h.textContent;
|
||||
a.className = 'block hover:text-indigo-600 transition ' + (h.tagName === 'H3' ? 'pl-4 text-gray-500' : 'font-medium');
|
||||
toc.appendChild(a);
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// Stabilize images inside blog body (add width/height if missing & lazy-load non-hero images)
|
||||
try {
|
||||
const imgs = document.querySelectorAll('.blog-body img');
|
||||
imgs.forEach((img, index) => {
|
||||
// If width/height already specified, skip dimension inference
|
||||
if(!img.hasAttribute('width') || !img.hasAttribute('height')){
|
||||
if(img.naturalWidth && img.naturalHeight){
|
||||
img.setAttribute('width', img.naturalWidth);
|
||||
img.setAttribute('height', img.naturalHeight);
|
||||
} else {
|
||||
// If not yet loaded, attach a one-time listener
|
||||
img.addEventListener('load', function handler(){
|
||||
if(!img.hasAttribute('width') && img.naturalWidth){ img.setAttribute('width', img.naturalWidth); }
|
||||
if(!img.hasAttribute('height') && img.naturalHeight){ img.setAttribute('height', img.naturalHeight); }
|
||||
img.removeEventListener('load', handler);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Lazy-load images after the first one (often top/hero) for better LCP while reducing CLS
|
||||
if(index > 0 && !img.hasAttribute('loading')){
|
||||
img.setAttribute('loading', 'lazy');
|
||||
}
|
||||
if(!img.hasAttribute('decoding')){
|
||||
img.setAttribute('decoding', 'async');
|
||||
}
|
||||
// Ensure display block for centered images without causing late shifts
|
||||
if(!img.className.includes('inline') && !img.className.includes('block')){
|
||||
img.classList.add('block');
|
||||
}
|
||||
});
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -22,10 +22,10 @@
|
||||
|
||||
<div class="hidden sm:block">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex justify-center space-x-8" aria-label="Tabs">
|
||||
<nav class="-mb-px flex justify-center space-x-8" aria-label="Product feature tabs" role="tablist">
|
||||
<!-- Current: "border-indigo-500 text-indigo-600", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" -->
|
||||
<a onclick="showTab(1)"
|
||||
class="tab-1-button cursor-pointer border-indigo-500 text-indigo-600 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
|
||||
<button type="button" onclick="showTab('status-pages')" id="tab-status-pages" role="tab" aria-selected="true" aria-controls="panel-status-pages"
|
||||
class="tab-status-pages-button tab-1-button cursor-pointer border-indigo-500 text-indigo-600 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="0" aria-label="Status Pages - communicates service availability to users">
|
||||
<!-- Current: "text-indigo-500", Default: "text-gray-400 group-hover:text-gray-500" -->
|
||||
<svg class="icon-tab-1 text-gray-400 text-indigo-500 -ml-0.5 mr-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
@@ -33,67 +33,60 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
|
||||
<span>Status Pages</span>
|
||||
</a>
|
||||
<a onclick="showTab(2)"
|
||||
class="tab-2-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
|
||||
</button>
|
||||
<button type="button" onclick="showTab('monitoring')" id="tab-monitoring" role="tab" aria-selected="false" aria-controls="panel-monitoring"
|
||||
class="tab-monitoring-button tab-2-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Monitoring - uptime, performance and synthetic checks">
|
||||
<svg class="icon-tab-2 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
|
||||
<span>Monitoring</span>
|
||||
</a>
|
||||
<a onclick="showTab(3)"
|
||||
class="tab-3-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium"
|
||||
aria-current="page">
|
||||
</button>
|
||||
<button type="button" onclick="showTab('incidents')" id="tab-incidents" role="tab" aria-selected="false" aria-controls="panel-incidents"
|
||||
class="tab-incidents-button tab-3-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Incidents - detect, respond and learn from outages">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon-tab-3 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
|
||||
<span>Incidents</span>
|
||||
</a>
|
||||
<a onclick="showTab(4)"
|
||||
class="tab-4-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
|
||||
</button>
|
||||
<button type="button" onclick="showTab('on-call')" id="tab-on-call" role="tab" aria-selected="false" aria-controls="panel-on-call"
|
||||
class="tab-on-call-button tab-4-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="On Call Alerts - intelligent alert routing and scheduling">
|
||||
<svg class="icon-tab-4 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 01-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z" />
|
||||
</svg>
|
||||
|
||||
<span>On Call Alerts</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<a onclick="showTab(5)"
|
||||
class="tab-5-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
|
||||
<button type="button" onclick="showTab('logs')" id="tab-logs" role="tab" aria-selected="false" aria-controls="panel-logs"
|
||||
class="tab-logs-button tab-5-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Logs - centralized log management and analytics">
|
||||
<svg class="icon-tab-5 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" >
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
|
||||
</svg>
|
||||
|
||||
<span>Logs</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<a onclick="showTab(6)"
|
||||
class="tab-6-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
|
||||
<button type="button" onclick="showTab('apm')" id="tab-apm" role="tab" aria-selected="false" aria-controls="panel-apm"
|
||||
class="tab-apm-button tab-6-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="APM - metrics, traces and error tracking for applications">
|
||||
|
||||
<svg class="icon-tab-6 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
|
||||
</svg>
|
||||
|
||||
<span>APM</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<a onclick="showTab(7)"
|
||||
class="tab-7-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium">
|
||||
<button type="button" onclick="showTab('workflows')" id="tab-workflows" role="tab" aria-selected="false" aria-controls="panel-workflows"
|
||||
class="tab-workflows-button tab-7-button cursor-pointer border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 group inline-flex items-center border-b-2 py-4 px-1 font-medium focus:outline-none" tabindex="-1" aria-label="Workflows - automate reliability operations and responses">
|
||||
|
||||
<svg class="icon-tab-7 text-gray-400 group-hover:text-gray-500 -ml-0.5 mr-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
@@ -101,9 +94,8 @@
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />
|
||||
</svg>
|
||||
|
||||
<span>Workflows</span>
|
||||
</a>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,36 +580,97 @@
|
||||
|
||||
|
||||
<script>
|
||||
let currentActiveTab = 1;
|
||||
// Semantic tab slugs in desired order
|
||||
const TAB_ORDER = ['status-pages','monitoring','incidents','on-call','logs','apm','workflows'];
|
||||
let currentActiveTab = 'status-pages';
|
||||
|
||||
|
||||
function normalize(tab){
|
||||
// allow old numeric references for backward compatibility
|
||||
if(typeof tab === 'number' || /^\d+$/.test(tab)){ return TAB_ORDER[Number(tab)-1] || 'status-pages'; }
|
||||
return tab;
|
||||
}
|
||||
|
||||
function hideTab(tab) {
|
||||
tab = normalize(tab);
|
||||
const panel = document.querySelector(`.tab-${TAB_ORDER.indexOf(tab)+1}`) || document.querySelector(`.tab-${tab}`);
|
||||
if (panel) { panel.style.display = 'none'; panel.setAttribute('aria-hidden','true'); }
|
||||
|
||||
document.querySelector(`.tab-${tab}`).style.display = 'none';
|
||||
|
||||
document.querySelector(`.tab-${tab}-button`).classList.remove('border-indigo-500', 'text-indigo-600');
|
||||
document.querySelector(`.tab-${tab}-button`).classList.add('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
|
||||
|
||||
document.querySelector(`.icon-tab-${tab}`).classList.remove('text-indigo-500');
|
||||
document.querySelector(`.icon-tab-${tab}`).classList.add('text-gray-400');
|
||||
const button = document.querySelector(`.tab-${tab}-button`) || document.querySelector(`.tab-${TAB_ORDER.indexOf(tab)+1}-button`);
|
||||
if (button) {
|
||||
button.classList.remove('border-indigo-500', 'text-indigo-600');
|
||||
button.classList.add('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
|
||||
button.setAttribute('aria-selected','false');
|
||||
button.setAttribute('tabindex','-1');
|
||||
}
|
||||
const icon = document.querySelector(`.icon-tab-${TAB_ORDER.indexOf(tab)+1}`) || document.querySelector(`.icon-tab-${tab}`);
|
||||
if (icon) { icon.classList.remove('text-indigo-500'); icon.classList.add('text-gray-400'); }
|
||||
}
|
||||
|
||||
function showTab(tab) {
|
||||
|
||||
tab = normalize(tab);
|
||||
if (tab === currentActiveTab) { return; }
|
||||
hideTab(currentActiveTab);
|
||||
|
||||
document.querySelector(`.tab-${tab}`).style.display = 'block';
|
||||
const numericIndex = TAB_ORDER.indexOf(tab)+1;
|
||||
const panel = document.querySelector(`.tab-${numericIndex}`) || document.querySelector(`.tab-${tab}`);
|
||||
if (panel) { panel.style.display = 'block'; panel.setAttribute('aria-hidden','false'); }
|
||||
|
||||
document.querySelector(`.tab-${tab}-button`).classList.remove('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
|
||||
document.querySelector(`.tab-${tab}-button`).classList.add('border-indigo-500', 'text-indigo-600');
|
||||
const button = document.querySelector(`.tab-${tab}-button`) || document.querySelector(`.tab-${numericIndex}-button`);
|
||||
if (button) {
|
||||
button.classList.remove('border-transparent', 'text-gray-500', 'hover:border-gray-300', 'hover:text-gray-700');
|
||||
button.classList.add('border-indigo-500', 'text-indigo-600');
|
||||
button.setAttribute('aria-selected','true');
|
||||
button.setAttribute('tabindex','0');
|
||||
button.focus();
|
||||
}
|
||||
|
||||
document.querySelector(`.icon-tab-${tab}`).classList.remove('text-gray-400');
|
||||
document.querySelector(`.icon-tab-${tab}`).classList.add('text-indigo-500');
|
||||
const icon = document.querySelector(`.icon-tab-${numericIndex}`) || document.querySelector(`.icon-tab-${tab}`);
|
||||
if (icon) { icon.classList.remove('text-gray-400'); icon.classList.add('text-indigo-500'); }
|
||||
|
||||
currentActiveTab = tab;
|
||||
|
||||
if (history.replaceState) { history.replaceState(null, '', `#tab-${tab}`); }
|
||||
}
|
||||
|
||||
// Initialize tabs accessibility + allow keyboard navigation (arrow keys)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Mark all panels with role="tabpanel" and associate with buttons
|
||||
TAB_ORDER.forEach((slug, idx) => {
|
||||
const panel = document.querySelector(`.tab-${idx+1}`) || document.querySelector(`.tab-${slug}`);
|
||||
if (panel) {
|
||||
panel.setAttribute('role','tabpanel');
|
||||
panel.setAttribute('id',`panel-${slug}`);
|
||||
panel.setAttribute('aria-labelledby',`tab-${slug}`);
|
||||
if (slug !== currentActiveTab) { panel.style.display='none'; panel.setAttribute('aria-hidden','true'); }
|
||||
else { panel.setAttribute('aria-hidden','false'); }
|
||||
}
|
||||
});
|
||||
|
||||
// Support deep linking via hash like #tab-apm or legacy #tab-6
|
||||
const hash = window.location.hash;
|
||||
if (hash && /^#tab-/.test(hash)) {
|
||||
const raw = hash.replace('#tab-','');
|
||||
showTab(raw);
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const tabButtons = Array.from(document.querySelectorAll('[role="tab"]'));
|
||||
tabButtons.forEach(btn => {
|
||||
btn.addEventListener('keydown', e => {
|
||||
const idx = tabButtons.indexOf(e.currentTarget);
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const next = tabButtons[(idx+1)%tabButtons.length];
|
||||
const ident = next.id.replace('tab-',''); showTab(ident);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const prev = tabButtons[(idx-1+tabButtons.length)%tabButtons.length];
|
||||
const ident = prev.id.replace('tab-',''); showTab(ident);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<!-- Preconnect to external image/avatar domains to reduce layout shift & latency -->
|
||||
<link rel="preconnect" href="https://avatars.githubusercontent.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="//avatars.githubusercontent.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||
rel="stylesheet">
|
||||
<style>
|
||||
@@ -19,6 +22,8 @@
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: auto;
|
||||
appearance: none; /* standard */
|
||||
-webkit-appearance: none; /* vendor */
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
@@ -32,6 +37,8 @@
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: auto;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*Chrome*/
|
||||
@@ -161,5 +168,5 @@
|
||||
<link rel="image_src" type="image/png" href="/img/hou-wb.svg">
|
||||
|
||||
<!-- Canonical and Manifest -->
|
||||
<link rel="canonical" href="/">
|
||||
<% /* Expect a homeUrl variable like https://oneuptime.com passed from server; fallback to production domain */ %>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">Build reliable software.</h1>
|
||||
<h1 class="text-6xl font-bold tracking-tight text-gray-900 sm:text-6xl mt-8 mb-8">Complete Monitoring <br/> & Observability Platform</h1>
|
||||
<p class="mt-6 text-xl sm:text-2xl leading-8 text-gray-600">Monitor, Observe, Debug, Resolve. Everything you
|
||||
need to build reliable software in one open source platform.</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
@@ -117,15 +117,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile redirect helper - if JS is available and we detect mobile, redirect to dashboard
|
||||
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone|Opera Mini|IEMobile|Mobile Safari|Mobile/i.test(navigator.userAgent)) {
|
||||
// Small delay to avoid redirect loops and let server handle first
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname === '/' && !window.location.search.includes('no-mobile-redirect')) {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"ignore": [
|
||||
"greenlock.d/*"
|
||||
],
|
||||
"exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts"
|
||||
"watchOptions": {"useFsEvents": false, "interval": 500},
|
||||
"env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"},
|
||||
"exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user