mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
315 Commits
vs-code-co
...
cert-manag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3938637b84 | ||
|
|
3ed9e21271 | ||
|
|
63e1266e2b | ||
|
|
a552812711 | ||
|
|
ad07ab75fe | ||
|
|
c8deffebb0 | ||
|
|
67a3ea5109 | ||
|
|
6728cc0458 | ||
|
|
f84ab2474f | ||
|
|
5c8ce04eed | ||
|
|
3064aa0364 | ||
|
|
9625f1381c | ||
|
|
6ecd3ad166 | ||
|
|
8e54cac86e | ||
|
|
cc52bb76d1 | ||
|
|
4c037f54f4 | ||
|
|
b869628d4a | ||
|
|
0fbeb503ad | ||
|
|
a302e4dc6c | ||
|
|
00c8783137 | ||
|
|
11211f4a62 | ||
|
|
d29750d66e | ||
|
|
7dc590dab4 | ||
|
|
1d0ed64c1a | ||
|
|
0cf3884be4 | ||
|
|
165f5608e6 | ||
|
|
f2b8cfbffb | ||
|
|
6084e15f20 | ||
|
|
b1db4187de | ||
|
|
20ce8a8c74 | ||
|
|
39200249d1 | ||
|
|
27533125e4 | ||
|
|
99dd421329 | ||
|
|
4184894f27 | ||
|
|
a7a00dc0fa | ||
|
|
9340f69789 | ||
|
|
ba33bc0c23 | ||
|
|
b8cac60c6e | ||
|
|
3a5d5253d0 | ||
|
|
64010b0348 | ||
|
|
84ca2ff311 | ||
|
|
c0becebadc | ||
|
|
6ef99fd890 | ||
|
|
a55f2f7842 | ||
|
|
0aae7877c7 | ||
|
|
8d6cc37f7a | ||
|
|
1a0f7eb1e7 | ||
|
|
6ed65ed3ef | ||
|
|
2ac342e26a | ||
|
|
fe80d6b1ff | ||
|
|
a68254be6d | ||
|
|
49a9e355fe | ||
|
|
7091e35393 | ||
|
|
34cc8a43ab | ||
|
|
75333ef36c | ||
|
|
d4b3f1b60b | ||
|
|
318d20a5a5 | ||
|
|
44b9c33e5c | ||
|
|
317a17cbab | ||
|
|
6d2cb53760 | ||
|
|
7ddc4be319 | ||
|
|
604776551b | ||
|
|
26b085030d | ||
|
|
e1046d2424 | ||
|
|
cf2a7b9dfa | ||
|
|
55f4c0b65d | ||
|
|
5100fbda52 | ||
|
|
9e36188975 | ||
|
|
26c2d41dfa | ||
|
|
a511a433b1 | ||
|
|
cc581e91b5 | ||
|
|
3fd95fe8aa | ||
|
|
6f2455c265 | ||
|
|
de5b32a609 | ||
|
|
155b0d90f1 | ||
|
|
3da5e12a0d | ||
|
|
8accdc6bd4 | ||
|
|
22a10702ac | ||
|
|
a013c86fae | ||
|
|
361626d21f | ||
|
|
6615ac63d7 | ||
|
|
655611b28d | ||
|
|
9bd45ecd14 | ||
|
|
53c9babb83 | ||
|
|
ccc0b0142b | ||
|
|
4a2f7f68cb | ||
|
|
de994e10de | ||
|
|
2ff22ca079 | ||
|
|
e6b8f60977 | ||
|
|
b823b5924a | ||
|
|
a58ddd94d5 | ||
|
|
275e15ce96 | ||
|
|
1e2a30823c | ||
|
|
a326e7084e | ||
|
|
2c1d20f680 | ||
|
|
95bd2db0dd | ||
|
|
3ca875254c | ||
|
|
7f1f78dad6 | ||
|
|
a0d6468aee | ||
|
|
914c9bc58e | ||
|
|
b38031e9f7 | ||
|
|
487ca71f84 | ||
|
|
67cd8e7db6 | ||
|
|
f44017d710 | ||
|
|
78240b906b | ||
|
|
2ef0b3be27 | ||
|
|
0792d8367a | ||
|
|
920397cead | ||
|
|
42c18e94ab | ||
|
|
533f7eb238 | ||
|
|
e2f16e85f1 | ||
|
|
c98e6b8471 | ||
|
|
c16c13fd89 | ||
|
|
c8ce0e8819 | ||
|
|
9e98f6acdb | ||
|
|
a7a5b15dde | ||
|
|
3ebb5217a2 | ||
|
|
f570ffe1e3 | ||
|
|
ae94bf6d7c | ||
|
|
d9a6e465bb | ||
|
|
020b171b77 | ||
|
|
afc4932c28 | ||
|
|
324851c57e | ||
|
|
380ecfa096 | ||
|
|
5f9f73ceaa | ||
|
|
038ca4a920 | ||
|
|
d15629da0f | ||
|
|
363bbf9dea | ||
|
|
6f0a0c8e38 | ||
|
|
a75a62c708 | ||
|
|
db76d716b9 | ||
|
|
b0abbf64b4 | ||
|
|
3a432cf8e6 | ||
|
|
5c7d18e3ed | ||
|
|
2590850ffa | ||
|
|
0eeb80e16e | ||
|
|
e1cfe24a24 | ||
|
|
4e4f3a889d | ||
|
|
ede7ae103d | ||
|
|
075c0fb6bd | ||
|
|
5ebdb1ef7d | ||
|
|
387dbf332e | ||
|
|
9681e1dc88 | ||
|
|
fb29014480 | ||
|
|
1a5c2efc59 | ||
|
|
3e31e44ed5 | ||
|
|
9e69d69429 | ||
|
|
a108deac0f | ||
|
|
c767f14bf1 | ||
|
|
d69485c436 | ||
|
|
67a5bdb7b8 | ||
|
|
6504731025 | ||
|
|
773692081c | ||
|
|
51c6234966 | ||
|
|
fac6e9a1fe | ||
|
|
86e5d85d55 | ||
|
|
1c592435e9 | ||
|
|
02fed5bd6e | ||
|
|
dd724fcc6e | ||
|
|
6ba26bcb82 | ||
|
|
799ab3220d | ||
|
|
f73f2fb732 | ||
|
|
43d6ead92c | ||
|
|
4c053b3f31 | ||
|
|
c026e411cf | ||
|
|
65a9e32db1 | ||
|
|
0b15e97e08 | ||
|
|
bd74b96596 | ||
|
|
990d3ea750 | ||
|
|
30665a1907 | ||
|
|
04db4289fa | ||
|
|
01f4c030a7 | ||
|
|
d060ed8b64 | ||
|
|
413240733e | ||
|
|
b0799093dd | ||
|
|
0ecdc775db | ||
|
|
82065c20b1 | ||
|
|
3dcd1ee604 | ||
|
|
f63c69e6a6 | ||
|
|
6ba793e871 | ||
|
|
7afd243992 | ||
|
|
4f58155719 | ||
|
|
553adc4aef | ||
|
|
f668a626d7 | ||
|
|
e3bd534295 | ||
|
|
6aa5c3b314 | ||
|
|
3bc4f7267d | ||
|
|
a7021cf045 | ||
|
|
2709e1d976 | ||
|
|
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 | ||
|
|
4869172648 | ||
|
|
1abd323b00 | ||
|
|
fa196a55cd | ||
|
|
29b4417aca |
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
|
||||
814
.github/workflows/release.yml
vendored
814
.github/workflows/release.yml
vendored
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
772
.github/workflows/test-release.yaml
vendored
772
.github/workflows/test-release.yaml
vendored
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
|
||||
import { ViewsPath } from "../Utils/Config";
|
||||
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
// Retrieve resources documentation
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
|
||||
@@ -16,7 +17,7 @@ export default class ServiceHandler {
|
||||
|
||||
// Extract page parameter from request
|
||||
const page: string | undefined = req.params["page"];
|
||||
const pageData: any = {};
|
||||
const pageData: Dictionary<unknown> = {};
|
||||
|
||||
// Set default page title and description for the authentication page
|
||||
pageTitle = "Authentication";
|
||||
|
||||
@@ -4,6 +4,7 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import LocalCache from "Common/Server/Infrastructure/LocalCache";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
|
||||
|
||||
@@ -12,9 +13,9 @@ export default class ServiceHandler {
|
||||
_req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<void> {
|
||||
const pageData: any = {};
|
||||
const pageData: Dictionary<unknown> = {};
|
||||
|
||||
pageData.selectCode = await LocalCache.getOrSetString(
|
||||
pageData["selectCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"select",
|
||||
async () => {
|
||||
@@ -22,7 +23,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.sortCode = await LocalCache.getOrSetString(
|
||||
pageData["sortCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"sort",
|
||||
async () => {
|
||||
@@ -30,7 +31,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.equalToCode = await LocalCache.getOrSetString(
|
||||
pageData["equalToCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"equal-to",
|
||||
async () => {
|
||||
@@ -38,7 +39,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.equalToOrNullCode = await LocalCache.getOrSetString(
|
||||
pageData["equalToOrNullCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"equal-to-or-null",
|
||||
async () => {
|
||||
@@ -48,7 +49,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.greaterThanCode = await LocalCache.getOrSetString(
|
||||
pageData["greaterThanCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"greater-than",
|
||||
async () => {
|
||||
@@ -58,7 +59,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.greaterThanOrEqualCode = await LocalCache.getOrSetString(
|
||||
pageData["greaterThanOrEqualCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"greater-than-or-equal",
|
||||
async () => {
|
||||
@@ -68,7 +69,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.lessThanCode = await LocalCache.getOrSetString(
|
||||
pageData["lessThanCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"less-than",
|
||||
async () => {
|
||||
@@ -78,7 +79,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.lessThanOrEqualCode = await LocalCache.getOrSetString(
|
||||
pageData["lessThanOrEqualCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"less-than-or-equal",
|
||||
async () => {
|
||||
@@ -88,7 +89,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.includesCode = await LocalCache.getOrSetString(
|
||||
pageData["includesCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"includes",
|
||||
async () => {
|
||||
@@ -98,7 +99,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.lessThanOrNullCode = await LocalCache.getOrSetString(
|
||||
pageData["lessThanOrNullCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"less-than-or-equal",
|
||||
async () => {
|
||||
@@ -108,7 +109,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.greaterThanOrNullCode = await LocalCache.getOrSetString(
|
||||
pageData["greaterThanOrNullCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"less-than-or-equal",
|
||||
async () => {
|
||||
@@ -118,7 +119,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.isNullCode = await LocalCache.getOrSetString(
|
||||
pageData["isNullCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"is-null",
|
||||
async () => {
|
||||
@@ -126,7 +127,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.notNullCode = await LocalCache.getOrSetString(
|
||||
pageData["notNullCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"not-null",
|
||||
async () => {
|
||||
@@ -134,7 +135,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.notEqualToCode = await LocalCache.getOrSetString(
|
||||
pageData["notEqualToCode"] = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"not-equals",
|
||||
async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
|
||||
import { ViewsPath } from "../Utils/Config";
|
||||
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
// Fetch a list of resources used in the application
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
|
||||
@@ -17,7 +18,7 @@ export default class ServiceHandler {
|
||||
|
||||
// Get the 'page' parameter from the request
|
||||
const page: string | undefined = req.params["page"];
|
||||
const pageData: any = {};
|
||||
const pageData: Dictionary<unknown> = {};
|
||||
|
||||
// Set the default page title and description
|
||||
pageTitle = "Errors";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
|
||||
import { ViewsPath } from "../Utils/Config";
|
||||
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
// Get all resources and featured resources from ResourceUtil
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
|
||||
@@ -20,10 +21,10 @@ export default class ServiceHandler {
|
||||
|
||||
// Get the requested page from the URL parameters
|
||||
const page: string | undefined = req.params["page"];
|
||||
const pageData: any = {};
|
||||
const pageData: Dictionary<unknown> = {};
|
||||
|
||||
// Set featured resources for the page
|
||||
pageData.featuredResources = FeaturedResources;
|
||||
pageData["featuredResources"] = FeaturedResources;
|
||||
|
||||
// Set page title and description
|
||||
pageTitle = "Introduction";
|
||||
|
||||
@@ -3,7 +3,10 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import PageNotFoundServiceHandler from "./PageNotFound";
|
||||
import { AppApiRoute } from "Common/ServiceRoute";
|
||||
import { ColumnAccessControl } from "Common/Types/BaseDatabase/AccessControl";
|
||||
import { getTableColumns } from "Common/Types/Database/TableColumn";
|
||||
import {
|
||||
getTableColumns,
|
||||
TableColumnMetadata,
|
||||
} from "Common/Types/Database/TableColumn";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Permission, {
|
||||
@@ -33,7 +36,7 @@ export default class ServiceHandler {
|
||||
let pageTitle: string = "";
|
||||
let pageDescription: string = "";
|
||||
let page: string | undefined = req.params["page"];
|
||||
const pageData: any = {};
|
||||
const pageData: Dictionary<unknown> = {};
|
||||
|
||||
// Check if page is provided
|
||||
if (!page) {
|
||||
@@ -56,7 +59,9 @@ export default class ServiceHandler {
|
||||
page = "model";
|
||||
|
||||
// Get table columns for current resource
|
||||
const tableColumns: any = getTableColumns(currentResource.model);
|
||||
const tableColumns: Dictionary<TableColumnMetadata> = getTableColumns(
|
||||
currentResource.model,
|
||||
);
|
||||
|
||||
// Filter out columns with no access
|
||||
for (const key in tableColumns) {
|
||||
@@ -77,12 +82,14 @@ export default class ServiceHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tableColumns[key].hideColumnInDocumentation) {
|
||||
if (tableColumns[key] && tableColumns[key]!.hideColumnInDocumentation) {
|
||||
delete tableColumns[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
tableColumns[key].permissions = accessControl;
|
||||
if (tableColumns[key]) {
|
||||
(tableColumns[key] as any).permissions = accessControl;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove unnecessary columns
|
||||
@@ -92,11 +99,11 @@ export default class ServiceHandler {
|
||||
delete tableColumns["version"];
|
||||
|
||||
// Set page data
|
||||
pageData.title = currentResource.model.singularName;
|
||||
pageData.description = currentResource.model.tableDescription;
|
||||
pageData.columns = tableColumns;
|
||||
pageData["title"] = currentResource.model.singularName;
|
||||
pageData["description"] = currentResource.model.tableDescription;
|
||||
pageData["columns"] = tableColumns;
|
||||
|
||||
pageData.tablePermissions = {
|
||||
pageData["tablePermissions"] = {
|
||||
read: currentResource.model.readRecordPermissions.map(
|
||||
(permission: Permission) => {
|
||||
return PermissionDictionary[permission];
|
||||
@@ -120,7 +127,7 @@ export default class ServiceHandler {
|
||||
};
|
||||
|
||||
// Cache the list request data
|
||||
pageData.listRequest = await LocalCache.getOrSetString(
|
||||
pageData["listRequest"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"list-request",
|
||||
async () => {
|
||||
@@ -130,7 +137,7 @@ export default class ServiceHandler {
|
||||
);
|
||||
|
||||
// Cache the item request data
|
||||
pageData.itemRequest = await LocalCache.getOrSetString(
|
||||
pageData["itemRequest"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"item-request",
|
||||
async () => {
|
||||
@@ -140,7 +147,7 @@ export default class ServiceHandler {
|
||||
);
|
||||
|
||||
// Cache the item response data
|
||||
pageData.itemResponse = await LocalCache.getOrSetString(
|
||||
pageData["itemResponse"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"item-response",
|
||||
async () => {
|
||||
@@ -152,7 +159,7 @@ export default class ServiceHandler {
|
||||
);
|
||||
|
||||
// Cache the count request data
|
||||
pageData.countRequest = await LocalCache.getOrSetString(
|
||||
pageData["countRequest"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"count-request",
|
||||
async () => {
|
||||
@@ -164,7 +171,7 @@ export default class ServiceHandler {
|
||||
);
|
||||
|
||||
// Cache the count response data
|
||||
pageData.countResponse = await LocalCache.getOrSetString(
|
||||
pageData["countResponse"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"count-response",
|
||||
async () => {
|
||||
@@ -175,7 +182,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.updateRequest = await LocalCache.getOrSetString(
|
||||
pageData["updateRequest"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"update-request",
|
||||
async () => {
|
||||
@@ -186,7 +193,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.updateResponse = await LocalCache.getOrSetString(
|
||||
pageData["updateResponse"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"update-response",
|
||||
async () => {
|
||||
@@ -197,7 +204,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.createRequest = await LocalCache.getOrSetString(
|
||||
pageData["createRequest"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"create-request",
|
||||
async () => {
|
||||
@@ -208,7 +215,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.createResponse = await LocalCache.getOrSetString(
|
||||
pageData["createResponse"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"create-response",
|
||||
async () => {
|
||||
@@ -219,7 +226,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.deleteRequest = await LocalCache.getOrSetString(
|
||||
pageData["deleteRequest"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"delete-request",
|
||||
async () => {
|
||||
@@ -230,7 +237,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.deleteResponse = await LocalCache.getOrSetString(
|
||||
pageData["deleteResponse"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"delete-response",
|
||||
async () => {
|
||||
@@ -242,7 +249,7 @@ export default class ServiceHandler {
|
||||
);
|
||||
|
||||
// Get list response from cache or set it if it's not available
|
||||
pageData.listResponse = await LocalCache.getOrSetString(
|
||||
pageData["listResponse"] = await LocalCache.getOrSetString(
|
||||
"model",
|
||||
"list-response",
|
||||
async () => {
|
||||
@@ -254,14 +261,15 @@ export default class ServiceHandler {
|
||||
);
|
||||
|
||||
// Generate a unique ID for the example object
|
||||
pageData.exampleObjectID = ObjectID.generate();
|
||||
pageData["exampleObjectID"] = ObjectID.generate();
|
||||
|
||||
// Construct the API path for the current resource
|
||||
pageData.apiPath =
|
||||
pageData["apiPath"] =
|
||||
AppApiRoute.toString() + currentResource.model.crudApiPath?.toString();
|
||||
|
||||
// Check if the current resource is a master admin API
|
||||
pageData.isMasterAdminApiDocs = currentResource.model.isMasterAdminApiDocs;
|
||||
pageData["isMasterAdminApiDocs"] =
|
||||
currentResource.model.isMasterAdminApiDocs;
|
||||
|
||||
// Render the index page with the required data
|
||||
return res.render(`${ViewsPath}/pages/index`, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ViewsPath } from "../Utils/Config";
|
||||
import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
// Fetch a list of resources used in the application
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
|
||||
@@ -22,7 +23,7 @@ export default class ServiceHandler {
|
||||
|
||||
// Get the 'page' parameter from the request
|
||||
const page: string | undefined = req.params["page"];
|
||||
const pageData: any = {
|
||||
const pageData: Dictionary<unknown> = {
|
||||
hostUrl: new URL(HttpProtocol, Host).toString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import LocalCache from "Common/Server/Infrastructure/LocalCache";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources(); // Get all resources from ResourceUtil
|
||||
|
||||
@@ -15,14 +16,14 @@ export default class ServiceHandler {
|
||||
let pageTitle: string = ""; // Initialize page title
|
||||
let pageDescription: string = ""; // Initialize page description
|
||||
const page: string | undefined = req.params["page"]; // Get the page parameter from the request
|
||||
const pageData: any = {}; // Initialize page data object
|
||||
const pageData: Dictionary<unknown> = {}; // Initialize page data object
|
||||
|
||||
// Set page title and description
|
||||
pageTitle = "Pagination";
|
||||
pageDescription = "Learn how to paginate requests with OneUptime API";
|
||||
|
||||
// Get response and request code from LocalCache or LocalFile
|
||||
pageData.responseCode = await LocalCache.getOrSetString(
|
||||
pageData["responseCode"] = await LocalCache.getOrSetString(
|
||||
"pagination",
|
||||
"response",
|
||||
async () => {
|
||||
@@ -33,7 +34,7 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.requestCode = await LocalCache.getOrSetString(
|
||||
pageData["requestCode"] = await LocalCache.getOrSetString(
|
||||
"pagination",
|
||||
"request",
|
||||
async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import ResourceUtil, { ModelDocumentation } from "../Utils/Resources";
|
||||
import { PermissionHelper, PermissionProps } from "Common/Types/Permission";
|
||||
import { ExpressRequest, ExpressResponse } from "Common/Server/Utils/Express";
|
||||
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
const Resources: Array<ModelDocumentation> = ResourceUtil.getResources();
|
||||
|
||||
@@ -17,14 +18,14 @@ export default class ServiceHandler {
|
||||
|
||||
// Get the requested page
|
||||
const page: string | undefined = req.params["page"];
|
||||
const pageData: any = {};
|
||||
const pageData: Dictionary<unknown> = {};
|
||||
|
||||
// Set page title and description
|
||||
pageTitle = "Permissions";
|
||||
pageDescription = "Learn how permissions work with OneUptime";
|
||||
|
||||
// Filter permissions to only include those assignable to tenants
|
||||
pageData.permissions = PermissionHelper.getAllPermissionProps().filter(
|
||||
pageData["permissions"] = PermissionHelper.getAllPermissionProps().filter(
|
||||
(i: PermissionProps) => {
|
||||
return i.isAssignableToTenant;
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
1
APIReference/package-lock.json
generated
1
APIReference/package-lock.json
generated
@@ -66,6 +66,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
|
||||
@@ -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">
|
||||
|
||||
1
Accounts/package-lock.json
generated
1
Accounts/package-lock.json
generated
@@ -70,6 +70,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
|
||||
1
AdminDashboard/package-lock.json
generated
1
AdminDashboard/package-lock.json
generated
@@ -69,6 +69,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -20,7 +20,6 @@ import NotFoundException from "Common/Types/Exception/NotFoundException";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import ProjectUser from "Common/Models/DatabaseModels/ProjectUser";
|
||||
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import {
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
generateServiceProviderConfig,
|
||||
generateUsersListResponse,
|
||||
parseSCIMQueryParams,
|
||||
logSCIMOperation,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import { DocsClientUrl } from "Common/Server/EnvironmentConfig";
|
||||
|
||||
@@ -89,6 +87,8 @@ const handleUserTeamOperations: (
|
||||
ignoreHooks: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`SCIM Team operations - user added to team: ${team.id}`);
|
||||
} else {
|
||||
logger.debug(
|
||||
`SCIM Team operations - user already member of team: ${team.id}`,
|
||||
@@ -119,10 +119,8 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation(
|
||||
"ServiceProviderConfig",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
logger.debug(
|
||||
`Project SCIM ServiceProviderConfig - scimId: ${req.params["projectScimId"]!}`,
|
||||
);
|
||||
|
||||
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
|
||||
@@ -149,7 +147,9 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation("Users list", "project", req.params["projectScimId"]!);
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}`,
|
||||
);
|
||||
|
||||
const oneuptimeRequest: OneUptimeRequest = req as OneUptimeRequest;
|
||||
const bearerData: JSONObject =
|
||||
@@ -160,15 +160,12 @@ router.get(
|
||||
const { startIndex, count } = parseSCIMQueryParams(req);
|
||||
const filter: string = req.query["filter"] as string;
|
||||
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
|
||||
);
|
||||
|
||||
// Build query for team members in this project
|
||||
const query: Query<ProjectUser> = {
|
||||
const query: Query<TeamMember> = {
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
@@ -179,33 +176,35 @@ router.get(
|
||||
);
|
||||
if (emailMatch) {
|
||||
const email: string = emailMatch[1]!;
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`filter by email: ${email}`,
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, filter by email: ${email}`,
|
||||
);
|
||||
|
||||
if (email) {
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
if (user && user.id) {
|
||||
query.userId = user.id;
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`found user with id: ${user.id}`,
|
||||
);
|
||||
if (Email.isValid(email)) {
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: { email: new Email(email) },
|
||||
select: { _id: true },
|
||||
props: { isRoot: true },
|
||||
});
|
||||
if (user && user.id) {
|
||||
query.userId = user.id;
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, found user with id: ${user.id}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, user not found for email: ${email}`,
|
||||
);
|
||||
return Response.sendJsonObjectResponse(
|
||||
req,
|
||||
res,
|
||||
generateUsersListResponse([], startIndex, 0),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`user not found for email: ${email}`,
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, invalid email format in filter: ${email}`,
|
||||
);
|
||||
return Response.sendJsonObjectResponse(
|
||||
req,
|
||||
@@ -217,11 +216,8 @@ router.get(
|
||||
}
|
||||
}
|
||||
|
||||
logSCIMOperation(
|
||||
"Users list",
|
||||
"project",
|
||||
req.params["projectScimId"]!,
|
||||
`query built for projectId: ${projectId}`,
|
||||
logger.debug(
|
||||
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}, query built for projectId: ${projectId}`,
|
||||
);
|
||||
|
||||
// Get team members
|
||||
|
||||
@@ -19,7 +19,6 @@ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import {
|
||||
formatUserForSCIM,
|
||||
generateServiceProviderConfig,
|
||||
logSCIMOperation,
|
||||
} from "../Utils/SCIMUtils";
|
||||
import Text from "Common/Types/Text";
|
||||
import HashedString from "Common/Types/HashedString";
|
||||
@@ -32,10 +31,8 @@ router.get(
|
||||
SCIMMiddleware.isAuthorizedSCIMRequest,
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
logSCIMOperation(
|
||||
"ServiceProviderConfig",
|
||||
"status-page",
|
||||
req.params["statusPageScimId"]!,
|
||||
logger.debug(
|
||||
`Status Page SCIM ServiceProviderConfig - scimId: ${req.params["statusPageScimId"]!}`,
|
||||
);
|
||||
|
||||
const serviceProviderConfig: JSONObject = generateServiceProviderConfig(
|
||||
@@ -73,17 +70,54 @@ router.get(
|
||||
parseInt(req.query["count"] as string) || 100,
|
||||
LIMIT_PER_PROJECT,
|
||||
);
|
||||
const filter: string = req.query["filter"] as string;
|
||||
|
||||
logger.debug(
|
||||
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}`,
|
||||
`Status Page SCIM Users - statusPageId: ${statusPageId}, startIndex: ${startIndex}, count: ${count}, filter: ${filter || "none"}`,
|
||||
);
|
||||
|
||||
// Build query for status page users
|
||||
const query: any = {
|
||||
statusPageId: statusPageId,
|
||||
};
|
||||
|
||||
// Handle SCIM filter for userName
|
||||
if (filter) {
|
||||
const emailMatch: RegExpMatchArray | null = filter.match(
|
||||
/userName eq "([^"]+)"/i,
|
||||
);
|
||||
if (emailMatch) {
|
||||
const email: string = emailMatch[1]!;
|
||||
logger.debug(
|
||||
`Status Page SCIM Users list - statusPageScimId: ${req.params["statusPageScimId"]!}, filter by email: ${email}`,
|
||||
);
|
||||
|
||||
if (email) {
|
||||
if (Email.isValid(email)) {
|
||||
query.email = new Email(email);
|
||||
logger.debug(
|
||||
`Status Page SCIM Users list - statusPageScimId: ${req.params["statusPageScimId"]!}, filtering by email: ${email}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Status Page SCIM Users list - statusPageScimId: ${req.params["statusPageScimId"]!}, invalid email format in filter: ${email}`,
|
||||
);
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
totalResults: 0,
|
||||
startIndex: startIndex,
|
||||
itemsPerPage: 0,
|
||||
Resources: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all private users for this status page
|
||||
const statusPageUsers: Array<StatusPagePrivateUser> =
|
||||
await StatusPagePrivateUserService.findBy({
|
||||
query: {
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
query: query,
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
|
||||
@@ -242,23 +242,3 @@ export const parseSCIMQueryParams: (req: ExpressRequest) => {
|
||||
|
||||
return { startIndex, count };
|
||||
};
|
||||
|
||||
/**
|
||||
* Log SCIM operation with consistent format
|
||||
*/
|
||||
export const logSCIMOperation: (
|
||||
operation: string,
|
||||
scimType: "project" | "status-page",
|
||||
scimId: string,
|
||||
details?: string,
|
||||
) => void = (
|
||||
operation: string,
|
||||
scimType: "project" | "status-page",
|
||||
scimId: string,
|
||||
details?: string,
|
||||
): void => {
|
||||
const logPrefix: string =
|
||||
scimType === "project" ? "Project SCIM" : "Status Page SCIM";
|
||||
const message: string = `${logPrefix} ${operation} - scimId: ${scimId}${details ? `, ${details}` : ""}`;
|
||||
logger.debug(message);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
1
App/package-lock.json
generated
1
App/package-lock.json
generated
@@ -76,6 +76,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
|
||||
@@ -716,6 +716,77 @@ export default class IncidentTemplate extends BaseModel {
|
||||
})
|
||||
public changeMonitorStatusToId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentTemplate,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentTemplate,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "initialIncidentStateId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: IncidentState,
|
||||
title: "Initial Incident State",
|
||||
description:
|
||||
"Relation to Incident State Object. Incidents created from this template will start in this state.",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return IncidentState;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "initialIncidentStateId" })
|
||||
public initialIncidentState?: IncidentState = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateIncidentTemplate,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadIncidentTemplate,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditIncidentTemplate,
|
||||
],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: false,
|
||||
title: "Initial Incident State ID",
|
||||
description:
|
||||
"Relation to Incident State Object ID. Incidents created from this template will start in this state.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public initialIncidentStateId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -178,7 +178,7 @@ import WorkspaceUserAuthToken from "./WorkspaceUserAuthToken";
|
||||
import WorkspaceProjectAuthToken from "./WorkspaceProjectAuthToken";
|
||||
import WorkspaceSetting from "./WorkspaceSetting";
|
||||
import WorkspaceNotificationRule from "./WorkspaceNotificationRule";
|
||||
import ProjectUser from "./ProjectUser";
|
||||
|
||||
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
|
||||
import MonitorFeed from "./MonitorFeed";
|
||||
import MetricType from "./MetricType";
|
||||
@@ -380,8 +380,6 @@ const AllModelTypes: Array<{
|
||||
WorkspaceSetting,
|
||||
WorkspaceNotificationRule,
|
||||
|
||||
ProjectUser,
|
||||
|
||||
MonitorFeed,
|
||||
|
||||
MetricType,
|
||||
|
||||
@@ -46,7 +46,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
Permission.EditProjectOnCallDutyPolicyEscalationRule,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule"))
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule"))
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyEscalationRule",
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
Permission.EditProjectOnCallDutyPolicyEscalationRuleSchedule,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule-schedule"))
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule-schedule"))
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyEscalationRuleSchedule",
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
Permission.EditProjectOnCallDutyPolicyEscalationRuleTeam,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule-team"))
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule-team"))
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyEscalationRuleTeam",
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
Permission.EditProjectOnCallDutyPolicyEscalationRuleUser,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-esclation-rule-user"))
|
||||
@CrudApiEndpoint(new Route("/on-call-duty-policy-escalation-rule-user"))
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyEscalationRuleUser",
|
||||
})
|
||||
|
||||
@@ -248,6 +248,84 @@ export default class Project extends TenantModel {
|
||||
})
|
||||
public paymentProviderCustomerId?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.LongText,
|
||||
title: "Business Details / Billing Address",
|
||||
description:
|
||||
"Business legal name, address and any tax information to appear on invoices.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.LongText,
|
||||
length: ColumnLength.LongText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public businessDetails?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Business Country (ISO Alpha-2)",
|
||||
description:
|
||||
"Two-letter ISO country code for billing address (e.g., US, GB, DE).",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public businessDetailsCountry?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.Email,
|
||||
title: "Finance / Accounting Email",
|
||||
description:
|
||||
"Invoices, receipts and billing related notifications will be sent to this email in addition to project owner.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Email,
|
||||
length: ColumnLength.Email,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public financeAccountingEmail?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
@@ -1213,4 +1291,37 @@ export default class Project extends TenantModel {
|
||||
type: ColumnType.Boolean,
|
||||
})
|
||||
public letCustomerSupportAccessProject?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProject,
|
||||
Permission.UnAuthorizedSsoUser,
|
||||
Permission.ProjectUser,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProject,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.Boolean,
|
||||
isDefaultValueColumn: false,
|
||||
title: "Do NOT auto-add Global Probes to new monitors",
|
||||
description:
|
||||
"If enabled, global probes will NOT be automatically added to new monitors. Enable this only if you are using ONLY custom probes to monitor your resources.",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
default: false,
|
||||
})
|
||||
public doNotAddGlobalProbesByDefaultOnNewMonitors?: boolean = undefined;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Monitor from "./Monitor";
|
||||
import Project from "./Project";
|
||||
import StatusPage from "./StatusPage";
|
||||
import User from "./User";
|
||||
@@ -198,6 +199,53 @@ export default class StatusPageAnnouncement extends BaseModel {
|
||||
})
|
||||
public statusPages?: Array<StatusPage> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageAnnouncement,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageAnnouncement,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditStatusPageAnnouncement,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Monitor,
|
||||
title: "Monitors",
|
||||
description:
|
||||
"List of monitors affected by this announcement. If none are selected, all subscribers will be notified.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Monitor;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "AnnouncementMonitor",
|
||||
inverseJoinColumn: {
|
||||
name: "monitorId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "announcementId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public monitors?: Array<Monitor> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Monitor from "./Monitor";
|
||||
import Project from "./Project";
|
||||
import StatusPage from "./StatusPage";
|
||||
import User from "./User";
|
||||
@@ -328,6 +329,53 @@ export default class StatusPageAnnouncementTemplate extends BaseModel {
|
||||
})
|
||||
public statusPages?: Array<StatusPage> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateStatusPageAnnouncementTemplate,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadStatusPageAnnouncementTemplate,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditStatusPageAnnouncementTemplate,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Monitor,
|
||||
title: "Monitors",
|
||||
description:
|
||||
"List of monitors affected by this announcement template. If none are selected, all subscribers will be notified.",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Monitor;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "AnnouncementTemplateMonitor",
|
||||
inverseJoinColumn: {
|
||||
name: "monitorId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "announcementTemplateId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public monitors?: Array<Monitor> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,13 +18,20 @@ import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
||||
import Permission from "../../Types/Permission";
|
||||
|
||||
export interface MiscData {
|
||||
[key: string]: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface SlackMiscData extends MiscData {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
botUserId: string;
|
||||
channelCache?: {
|
||||
[channelName: string]: {
|
||||
id: string;
|
||||
name: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@TenantColumn("projectId")
|
||||
|
||||
@@ -235,13 +235,25 @@ export default class BaseAPI<
|
||||
): Promise<void> {
|
||||
await this.onBeforeList(req, res);
|
||||
|
||||
const skip: PositiveNumber = req.query["skip"]
|
||||
? new PositiveNumber(req.query["skip"] as string)
|
||||
: new PositiveNumber(0);
|
||||
// Extract pagination parameters from query or body (for POST requests)
|
||||
// Support both 'skip' and 'offset' parameters (offset is alias for skip)
|
||||
let skipValue: number = 0;
|
||||
let limitValue: number = DEFAULT_LIMIT;
|
||||
|
||||
const limit: PositiveNumber = req.query["limit"]
|
||||
? new PositiveNumber(req.query["limit"] as string)
|
||||
: new PositiveNumber(DEFAULT_LIMIT);
|
||||
if (req.query["skip"]) {
|
||||
skipValue = parseInt(req.query["skip"] as string, 10) || 0;
|
||||
} else if (req.body && req.body["skip"] !== undefined) {
|
||||
skipValue = parseInt(req.body["skip"] as string, 10) || 0;
|
||||
}
|
||||
|
||||
if (req.query["limit"]) {
|
||||
limitValue = parseInt(req.query["limit"] as string, 10) || DEFAULT_LIMIT;
|
||||
} else if (req.body && req.body["limit"] !== undefined) {
|
||||
limitValue = parseInt(req.body["limit"] as string, 10) || DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
const skip: PositiveNumber = new PositiveNumber(skipValue);
|
||||
const limit: PositiveNumber = new PositiveNumber(limitValue);
|
||||
|
||||
if (limit.toNumber() > LIMIT_PER_PROJECT) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -502,6 +502,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
footerHTML: true,
|
||||
enableEmailSubscribers: true,
|
||||
enableSlackSubscribers: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
enableSmsSubscribers: true,
|
||||
isPublicStatusPage: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
@@ -2075,6 +2076,10 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
_id: true,
|
||||
showAnnouncementAt: true,
|
||||
endAnnouncementAt: true,
|
||||
monitors: {
|
||||
_id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
@@ -2095,6 +2100,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
displayTooltip: true,
|
||||
displayDescription: true,
|
||||
displayName: true,
|
||||
monitorGroupId: true,
|
||||
monitor: {
|
||||
_id: true,
|
||||
currentMonitorStatusId: true,
|
||||
@@ -2108,6 +2114,65 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
},
|
||||
});
|
||||
|
||||
const monitorGroupIds: Array<ObjectID> = statusPageResources
|
||||
.map((resource: StatusPageResource) => {
|
||||
return resource.monitorGroupId!;
|
||||
})
|
||||
.filter((id: ObjectID) => {
|
||||
return Boolean(id); // remove nulls
|
||||
});
|
||||
|
||||
// get monitors in the group.
|
||||
const monitorsInGroup: Dictionary<Array<ObjectID>> = {};
|
||||
|
||||
// get monitor status charts.
|
||||
const monitorsOnStatusPage: Array<ObjectID> = statusPageResources
|
||||
.map((monitor: StatusPageResource) => {
|
||||
return monitor.monitorId!;
|
||||
})
|
||||
.filter((id: ObjectID) => {
|
||||
return Boolean(id); // remove nulls
|
||||
});
|
||||
|
||||
for (const monitorGroupId of monitorGroupIds) {
|
||||
// get monitors in the group.
|
||||
|
||||
const groupResources: Array<MonitorGroupResource> =
|
||||
await MonitorGroupResourceService.findBy({
|
||||
query: {
|
||||
monitorGroupId: monitorGroupId,
|
||||
},
|
||||
select: {
|
||||
monitorId: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
const monitorsInGroupIds: Array<ObjectID> = groupResources
|
||||
.map((resource: MonitorGroupResource) => {
|
||||
return resource.monitorId!;
|
||||
})
|
||||
.filter((id: ObjectID) => {
|
||||
return Boolean(id); // remove nulls
|
||||
});
|
||||
|
||||
for (const monitorId of monitorsInGroupIds) {
|
||||
if (
|
||||
!monitorsOnStatusPage.find((item: ObjectID) => {
|
||||
return item.toString() === monitorId.toString();
|
||||
})
|
||||
) {
|
||||
monitorsOnStatusPage.push(monitorId);
|
||||
}
|
||||
}
|
||||
|
||||
monitorsInGroup[monitorGroupId.toString()] = monitorsInGroupIds;
|
||||
}
|
||||
|
||||
const response: JSONObject = {
|
||||
announcements: BaseModel.toJSONArray(
|
||||
announcements,
|
||||
@@ -2117,6 +2182,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
statusPageResources,
|
||||
StatusPageResource,
|
||||
),
|
||||
monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
|
||||
};
|
||||
|
||||
return response;
|
||||
@@ -2146,6 +2212,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
projectId: true,
|
||||
enableEmailSubscribers: true,
|
||||
enableSlackSubscribers: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
enableSmsSubscribers: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
allowSubscribersToChooseEventTypes: true,
|
||||
@@ -2419,6 +2486,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
enableEmailSubscribers: true,
|
||||
enableSmsSubscribers: true,
|
||||
enableSlackSubscribers: true,
|
||||
enableMicrosoftTeamsSubscribers: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
allowSubscribersToChooseEventTypes: true,
|
||||
showSubscriberPageOnStatusPage: true,
|
||||
@@ -2480,15 +2548,28 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
}
|
||||
|
||||
if (
|
||||
!req.body.data["subscriberEmail"] &&
|
||||
!req.body.data["subscriberPhone"] &&
|
||||
!req.body.data["slackWorkspaceName"]
|
||||
req.body.data["microsoftTeamsWorkspaceName"] &&
|
||||
!statusPage.enableMicrosoftTeamsSubscribers
|
||||
) {
|
||||
logger.debug(
|
||||
`No email, phone, or slack workspace name provided for subscription to status page with ID: ${objectId}`,
|
||||
`Microsoft Teams subscribers not enabled for status page with ID: ${objectId}`,
|
||||
);
|
||||
throw new BadDataException(
|
||||
"Email, phone or slack workspace name is required to subscribe to this status page.",
|
||||
"Microsoft Teams subscribers not enabled for this status page.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!req.body.data["subscriberEmail"] &&
|
||||
!req.body.data["subscriberPhone"] &&
|
||||
!req.body.data["slackWorkspaceName"] &&
|
||||
!req.body.data["microsoftTeamsWorkspaceName"]
|
||||
) {
|
||||
logger.debug(
|
||||
`No email, phone, slack workspace name, or Microsoft Teams workspace name provided for subscription to status page with ID: ${objectId}`,
|
||||
);
|
||||
throw new BadDataException(
|
||||
"Email, phone, slack workspace name, or Microsoft Teams workspace name is required to subscribe to this status page.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2512,6 +2593,18 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
? (req.body.data["slackWorkspaceName"] as string)
|
||||
: undefined;
|
||||
|
||||
const microsoftTeamsIncomingWebhookUrl: string | undefined = req.body.data[
|
||||
"microsoftTeamsIncomingWebhookUrl"
|
||||
]
|
||||
? (req.body.data["microsoftTeamsIncomingWebhookUrl"] as string)
|
||||
: undefined;
|
||||
|
||||
const microsoftTeamsWorkspaceName: string | undefined = req.body.data[
|
||||
"microsoftTeamsWorkspaceName"
|
||||
]
|
||||
? (req.body.data["microsoftTeamsWorkspaceName"] as string)
|
||||
: undefined;
|
||||
|
||||
let statusPageSubscriber: StatusPageSubscriber | null = null;
|
||||
|
||||
let isUpdate: boolean = false;
|
||||
@@ -2570,6 +2663,23 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
statusPageSubscriber.slackWorkspaceName = slackWorkspaceName;
|
||||
}
|
||||
|
||||
if (microsoftTeamsIncomingWebhookUrl) {
|
||||
logger.debug(
|
||||
`Setting subscriber Microsoft Teams webhook: ${microsoftTeamsIncomingWebhookUrl}`,
|
||||
);
|
||||
statusPageSubscriber.microsoftTeamsIncomingWebhookUrl = URL.fromString(
|
||||
microsoftTeamsIncomingWebhookUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (microsoftTeamsWorkspaceName) {
|
||||
logger.debug(
|
||||
`Setting subscriber Microsoft Teams workspace name: ${microsoftTeamsWorkspaceName}`,
|
||||
);
|
||||
statusPageSubscriber.microsoftTeamsWorkspaceName =
|
||||
microsoftTeamsWorkspaceName;
|
||||
}
|
||||
|
||||
if (
|
||||
req.body.data["statusPageResources"] &&
|
||||
!statusPage.allowSubscribersToChooseResources
|
||||
|
||||
@@ -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,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1756821449686 implements MigrationInterface {
|
||||
public name = "MigrationName1756821449686";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" ADD "doNotAddGlobalProbesByDefaultOnNewMonitors" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "Project" DROP COLUMN "doNotAddGlobalProbesByDefaultOnNewMonitors"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1757416939595 implements MigrationInterface {
|
||||
public name = "MigrationName1757416939595";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentTemplate" ADD "initialIncidentStateId" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_36317c99429a40d3344d838223" ON "IncidentTemplate" ("initialIncidentStateId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentTemplate" ADD CONSTRAINT "FK_36317c99429a40d3344d838223f" FOREIGN KEY ("initialIncidentStateId") REFERENCES "IncidentState"("_id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentTemplate" DROP CONSTRAINT "FK_36317c99429a40d3344d838223f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_36317c99429a40d3344d838223"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "IncidentTemplate" DROP COLUMN "initialIncidentStateId"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1757423505855 implements MigrationInterface {
|
||||
public name = "MigrationName1757423505855";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "AnnouncementMonitor" ("announcementId" uuid NOT NULL, "monitorId" uuid NOT NULL, CONSTRAINT "PK_7acb54ddede76e67b5e2eb84519" PRIMARY KEY ("announcementId", "monitorId"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b43baa07f7be40b5cfb61153fd" ON "AnnouncementMonitor" ("announcementId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_751be8c61cfeb7e1a0af9fcc3a" ON "AnnouncementMonitor" ("monitorId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "AnnouncementTemplateMonitor" ("announcementTemplateId" uuid NOT NULL, "monitorId" uuid NOT NULL, CONSTRAINT "PK_ad19f2b65c1b6b77e7a8d80c028" PRIMARY KEY ("announcementTemplateId", "monitorId"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_46bee9106e631ebe9f6c95ff15" ON "AnnouncementTemplateMonitor" ("announcementTemplateId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_0d979cb538fde87c7441d7bc93" ON "AnnouncementTemplateMonitor" ("monitorId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementMonitor" ADD CONSTRAINT "FK_b43baa07f7be40b5cfb61153fd3" FOREIGN KEY ("announcementId") REFERENCES "StatusPageAnnouncement"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementMonitor" ADD CONSTRAINT "FK_751be8c61cfeb7e1a0af9fcc3a0" FOREIGN KEY ("monitorId") REFERENCES "Monitor"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementTemplateMonitor" ADD CONSTRAINT "FK_46bee9106e631ebe9f6c95ff153" FOREIGN KEY ("announcementTemplateId") REFERENCES "StatusPageAnnouncementTemplate"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementTemplateMonitor" ADD CONSTRAINT "FK_0d979cb538fde87c7441d7bc936" FOREIGN KEY ("monitorId") REFERENCES "Monitor"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementTemplateMonitor" DROP CONSTRAINT "FK_0d979cb538fde87c7441d7bc936"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementTemplateMonitor" DROP CONSTRAINT "FK_46bee9106e631ebe9f6c95ff153"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementMonitor" DROP CONSTRAINT "FK_751be8c61cfeb7e1a0af9fcc3a0"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "AnnouncementMonitor" DROP CONSTRAINT "FK_b43baa07f7be40b5cfb61153fd3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_0d979cb538fde87c7441d7bc93"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_46bee9106e631ebe9f6c95ff15"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "AnnouncementTemplateMonitor"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_751be8c61cfeb7e1a0af9fcc3a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b43baa07f7be40b5cfb61153fd"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "AnnouncementMonitor"`);
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,15 @@ import { MigrationName1755088852971 } from "./1755088852971-MigrationName";
|
||||
import { MigrationName1755093133870 } from "./1755093133870-MigrationName";
|
||||
import { MigrationName1755109893911 } from "./1755109893911-MigrationName";
|
||||
import { MigrationName1755110936888 } from "./1755110936888-MigrationName";
|
||||
import { MigrationName1755775040650 } from "./1755775040650-MigrationName";
|
||||
import { MigrationName1755778495455 } from "./1755778495455-MigrationName";
|
||||
import { MigrationName1755778934927 } from "./1755778934927-MigrationName";
|
||||
import { MigrationName1756293325324 } from "./1756293325324-MigrationName";
|
||||
import { MigrationName1756296282627 } from "./1756296282627-MigrationName";
|
||||
import { MigrationName1756300358095 } from "./1756300358095-MigrationName";
|
||||
import { MigrationName1756821449686 } from "./1756821449686-MigrationName";
|
||||
import { MigrationName1757416939595 } from "./1757416939595-MigrationName";
|
||||
import { MigrationName1757423505855 } from "./1757423505855-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -320,4 +329,13 @@ export default [
|
||||
MigrationName1755093133870,
|
||||
MigrationName1755109893911,
|
||||
MigrationName1755110936888,
|
||||
MigrationName1755775040650,
|
||||
MigrationName1755778495455,
|
||||
MigrationName1755778934927,
|
||||
MigrationName1756293325324,
|
||||
MigrationName1756296282627,
|
||||
MigrationName1756300358095,
|
||||
MigrationName1756821449686,
|
||||
MigrationName1757416939595,
|
||||
MigrationName1757423505855,
|
||||
];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -273,109 +273,88 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("currentAlertStateId is required");
|
||||
}
|
||||
|
||||
// Get alert data for feed creation
|
||||
const alert: Model | null = await this.findOneById({
|
||||
id: createdItem.id,
|
||||
select: {
|
||||
projectId: true,
|
||||
alertNumber: true,
|
||||
title: true,
|
||||
description: true,
|
||||
alertSeverity: {
|
||||
name: true,
|
||||
},
|
||||
rootCause: true,
|
||||
remediationNotes: true,
|
||||
currentAlertState: {
|
||||
name: true,
|
||||
},
|
||||
labels: {
|
||||
name: true,
|
||||
},
|
||||
monitor: {
|
||||
name: true,
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
throw new BadDataException("Alert not found");
|
||||
}
|
||||
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(this.createAlertFeedAsync(alert, createdItem));
|
||||
|
||||
// Handle state change asynchronously
|
||||
coreOperations.push(this.handleAlertStateChangeAsync(createdItem));
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
// Execute operations sequentially with error handling
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
try {
|
||||
return await this.handleAlertWorkspaceOperationsAsync(createdItem);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in AlertService.onCreateSuccess: ${result.reason}`,
|
||||
`Workspace operations failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.createAlertFeedAsync(createdItem.id!);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Create alert feed failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve(); // Continue chain even on error
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.handleAlertStateChangeAsync(createdItem);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Handle alert state change failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve(); // Continue chain even on error
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
return await this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps![
|
||||
"ownerUsers"
|
||||
] as Array<ObjectID>) || [],
|
||||
(onCreate.createBy.miscDataProps![
|
||||
"ownerTeams"
|
||||
] as Array<ObjectID>) || [],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle on-call duty policies asynchronously
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Add owners failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve(); // Continue chain even on error
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
this.executeAlertOnCallDutyPoliciesAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleAlertWorkspaceOperationsAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await this.executeAlertOnCallDutyPoliciesAsync(createdItem);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in AlertService core operations: ${error}`,
|
||||
`Critical error in AlertService sequential operations: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -426,21 +405,57 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createAlertFeedAsync(
|
||||
alert: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
private async createAlertFeedAsync(alertId: ObjectID): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
// Get alert data for feed creation
|
||||
const alert: Model | null = await this.findOneById({
|
||||
id: alertId,
|
||||
select: {
|
||||
projectId: true,
|
||||
alertNumber: true,
|
||||
title: true,
|
||||
description: true,
|
||||
alertSeverity: {
|
||||
name: true,
|
||||
},
|
||||
rootCause: true,
|
||||
createdByUserId: true,
|
||||
createdByUser: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
remediationNotes: true,
|
||||
currentAlertState: {
|
||||
name: true,
|
||||
},
|
||||
labels: {
|
||||
name: true,
|
||||
},
|
||||
monitor: {
|
||||
name: true,
|
||||
_id: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Alert ${createdItem.alertNumber?.toString()} Created:
|
||||
if (!alert) {
|
||||
throw new BadDataException("Alert not found");
|
||||
}
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
alert.createdByUserId || alert.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Alert ${alert.alertNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
**${alert.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
${alert.description || "No description provided."}
|
||||
|
||||
`;
|
||||
`;
|
||||
|
||||
if (alert.currentAlertState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Alert State**: ${alert.currentAlertState.name} \n\n`;
|
||||
@@ -454,25 +469,25 @@ ${createdItem.description || "No description provided."}
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
const monitor: Monitor = alert.monitor;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(alert.projectId!, monitor.id!)).toString()})\n`;
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
if (alert.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
${alert.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
if (alert.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
${alert.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
@@ -480,13 +495,13 @@ ${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
const alertCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await AlertWorkspaceMessages.getAlertCreateMessageBlocks({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
alertId: alert.id!,
|
||||
projectId: alert.projectId!,
|
||||
});
|
||||
|
||||
await AlertFeedService.createAlertFeedItem({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
alertId: alert.id!,
|
||||
projectId: alert.projectId!,
|
||||
alertFeedEventType: AlertFeedEventType.AlertCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -64,6 +64,8 @@ import MetricType from "../../Models/DatabaseModels/MetricType";
|
||||
import UpdateBy from "../Types/Database/UpdateBy";
|
||||
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
|
||||
import Dictionary from "../../Types/Dictionary";
|
||||
import IncidentTemplateService from "./IncidentTemplateService";
|
||||
import IncidentTemplate from "../../Models/DatabaseModels/IncidentTemplate";
|
||||
|
||||
// key is incidentId for this dictionary.
|
||||
type UpdateCarryForward = Dictionary<{
|
||||
@@ -466,24 +468,97 @@ export class Service extends DatabaseService<Model> {
|
||||
const projectId: ObjectID =
|
||||
createBy.props.tenantId || createBy.data.projectId!;
|
||||
|
||||
const incidentState: IncidentState | null =
|
||||
await IncidentStateService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
isCreatedState: true,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
// Determine the initial incident state
|
||||
let initialIncidentStateId: ObjectID | undefined = undefined;
|
||||
|
||||
if (!incidentState || !incidentState.id) {
|
||||
throw new BadDataException(
|
||||
"Created incident state not found for this project. Please add created incident state from settings.",
|
||||
);
|
||||
// If currentIncidentStateId is already provided (manual selection), use it
|
||||
if (createBy.data.currentIncidentStateId) {
|
||||
initialIncidentStateId = createBy.data.currentIncidentStateId;
|
||||
|
||||
// Validate that the provided state exists and belongs to the project
|
||||
const providedState: IncidentState | null =
|
||||
await IncidentStateService.findOneBy({
|
||||
query: {
|
||||
_id: initialIncidentStateId.toString(),
|
||||
projectId: projectId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!providedState) {
|
||||
throw new BadDataException(
|
||||
"Invalid incident state provided. The state does not exist or does not belong to this project.",
|
||||
);
|
||||
}
|
||||
} else if (createBy.data.createdIncidentTemplateId) {
|
||||
// If created from a template, check if template has a custom initial state
|
||||
const incidentTemplate: IncidentTemplate | null =
|
||||
await IncidentTemplateService.findOneBy({
|
||||
query: {
|
||||
_id: createBy.data.createdIncidentTemplateId.toString(),
|
||||
projectId: projectId,
|
||||
},
|
||||
select: {
|
||||
initialIncidentStateId: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (incidentTemplate?.initialIncidentStateId) {
|
||||
initialIncidentStateId = incidentTemplate.initialIncidentStateId;
|
||||
|
||||
// Validate that the template's state exists and belongs to the project
|
||||
const templateState: IncidentState | null =
|
||||
await IncidentStateService.findOneBy({
|
||||
query: {
|
||||
_id: initialIncidentStateId.toString(),
|
||||
projectId: projectId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!templateState) {
|
||||
// Fall back to default if template state is invalid
|
||||
initialIncidentStateId = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no custom state is provided or found, fall back to default created state
|
||||
if (!initialIncidentStateId) {
|
||||
const incidentState: IncidentState | null =
|
||||
await IncidentStateService.findOneBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
isCreatedState: true,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!incidentState || !incidentState.id) {
|
||||
throw new BadDataException(
|
||||
"Created incident state not found for this project. Please add created incident state from settings.",
|
||||
);
|
||||
}
|
||||
|
||||
initialIncidentStateId = incidentState.id;
|
||||
}
|
||||
|
||||
let mutex: SemaphoreMutex | null = null;
|
||||
@@ -517,7 +592,7 @@ export class Service extends DatabaseService<Model> {
|
||||
projectId: projectId,
|
||||
})) + 1;
|
||||
|
||||
createBy.data.currentIncidentStateId = incidentState.id;
|
||||
createBy.data.currentIncidentStateId = initialIncidentStateId;
|
||||
createBy.data.incidentNumber = incidentNumberForThisIncident;
|
||||
|
||||
if (
|
||||
@@ -597,6 +672,12 @@ export class Service extends DatabaseService<Model> {
|
||||
name: true,
|
||||
},
|
||||
rootCause: true,
|
||||
createdByUserId: true,
|
||||
createdByUser: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
remediationNotes: true,
|
||||
currentIncidentState: {
|
||||
name: true,
|
||||
@@ -618,90 +699,121 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("Incident not found");
|
||||
}
|
||||
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(this.createIncidentFeedAsync(incident, createdItem));
|
||||
|
||||
// Handle state change asynchronously
|
||||
coreOperations.push(this.handleIncidentStateChangeAsync(createdItem));
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle monitor status change and active monitoring asynchronously
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
coreOperations.push(
|
||||
this.handleMonitorStatusChangeAsync(createdItem, onCreate),
|
||||
);
|
||||
}
|
||||
|
||||
coreOperations.push(
|
||||
this.disableActiveMonitoringIfManualIncident(createdItem.id!),
|
||||
);
|
||||
|
||||
// Release mutex immediately
|
||||
this.releaseMutexAsync(onCreate, createdItem.projectId!);
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in IncidentService.onCreateSuccess: ${result.reason}`,
|
||||
// Execute operations sequentially with error handling
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
try {
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
return await this.handleIncidentWorkspaceOperationsAsync(
|
||||
createdItem,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle on-call duty policies asynchronously
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
this.executeOnCallDutyPoliciesAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Workspace operations failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleIncidentWorkspaceOperationsAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.createIncidentFeedAsync(incident);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Create incident feed failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.handleIncidentStateChangeAsync(createdItem);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Handle incident state change failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
return await this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps[
|
||||
"ownerUsers"
|
||||
] as Array<ObjectID>) || [],
|
||||
(onCreate.createBy.miscDataProps[
|
||||
"ownerTeams"
|
||||
] as Array<ObjectID>) || [],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Add owners failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
return await this.handleMonitorStatusChangeAsync(
|
||||
createdItem,
|
||||
onCreate,
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Monitor status change failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.disableActiveMonitoringIfManualIncident(
|
||||
createdItem.id!,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Disable active monitoring failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
return await this.executeOnCallDutyPoliciesAsync(createdItem);
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in IncidentService core operations: ${error}`,
|
||||
`Critical error in IncidentService sequential operations: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -749,19 +861,16 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createIncidentFeedAsync(
|
||||
incident: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
private async createIncidentFeedAsync(incident: Model): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
incident.createdByUserId || incident.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Incident ${createdItem.incidentNumber?.toString()} Created:
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Incident ${incident.incidentNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
**${incident.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
${incident.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
@@ -777,26 +886,26 @@ ${createdItem.description || "No description provided."}
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of incident.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(incident.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
if (incident.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
${incident.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
if (incident.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
${incident.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
@@ -804,13 +913,13 @@ ${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
const incidentCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await IncidentWorkspaceMessages.getIncidentCreateMessageBlocks({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
incidentId: incident.id!,
|
||||
projectId: incident.projectId!,
|
||||
});
|
||||
|
||||
await IncidentFeedService.createIncidentFeedItem({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
incidentId: incident.id!,
|
||||
projectId: incident.projectId!,
|
||||
incidentFeedEventType: IncidentFeedEventType.IncidentCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
|
||||
@@ -67,6 +67,8 @@ import QueryOperator from "../../Types/BaseDatabase/QueryOperator";
|
||||
import { FindWhere } from "../../Types/BaseDatabase/Query";
|
||||
import logger from "../Utils/Logger";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
|
||||
import Project from "../../Models/DatabaseModels/Project";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -162,11 +164,11 @@ export class Service extends DatabaseService<Model> {
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
throw new BadDataException("Monitor not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
if (!monitor.id) {
|
||||
throw new BadDataException("Monitor id not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
projectId = monitor.projectId!;
|
||||
@@ -502,105 +504,122 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
// Parallelize operations that don't depend on each other
|
||||
const parallelOperations: Array<Promise<any>> = [];
|
||||
|
||||
// 1. Essential monitor status operation (must complete first)
|
||||
await this.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
[createdItem.id],
|
||||
createdItem.currentMonitorStatusId,
|
||||
false, // notifyOwners = false
|
||||
"This status was created when the monitor was created.",
|
||||
undefined,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
|
||||
// 2. Start core operations in parallel that can run asynchronously (excluding workspace operations)
|
||||
|
||||
// Add default probes if needed (can be slow with many probes)
|
||||
if (
|
||||
createdItem.monitorType &&
|
||||
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
|
||||
) {
|
||||
parallelOperations.push(
|
||||
this.addDefaultProbesToMonitor(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in adding default probes");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to probe creation issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Billing operations
|
||||
if (IsBillingEnabled) {
|
||||
parallelOperations.push(
|
||||
ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
|
||||
createdItem.projectId,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in billing operations");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to billing issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Owner operations
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
parallelOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in adding owners");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to owner issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Probe status refresh (can be expensive with many probes)
|
||||
parallelOperations.push(
|
||||
this.refreshMonitorProbeStatus(createdItem.id).catch((error: Error) => {
|
||||
logger.error("Error in refreshing probe status");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to probe status issues
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for core operations to complete, then handle workspace operations
|
||||
Promise.allSettled(parallelOperations)
|
||||
.then(() => {
|
||||
// Handle workspace operations after core operations complete
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleWorkspaceOperationsAsync({
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: createdItem.id!,
|
||||
monitorName: createdItem.name!,
|
||||
feedInfoInMarkdown,
|
||||
createdByUserId,
|
||||
}).catch((error: Error) => {
|
||||
logger.error("Error in workspace operations");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to workspace issues
|
||||
});
|
||||
// Execute operations sequentially with error handling (workspace first)
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.handleWorkspaceOperationsAsync({
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: createdItem.id!,
|
||||
monitorName: createdItem.name!,
|
||||
feedInfoInMarkdown,
|
||||
createdByUserId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Workspace operations failed in MonitorService.onCreateSuccess",
|
||||
);
|
||||
logger.error(error as Error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.changeMonitorStatus(
|
||||
createdItem.projectId!,
|
||||
[createdItem.id!],
|
||||
createdItem.currentMonitorStatusId!,
|
||||
false, // notifyOwners = false
|
||||
"This status was created when the monitor was created.",
|
||||
undefined,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Change monitor status failed in MonitorService.onCreateSuccess",
|
||||
);
|
||||
logger.error(error as Error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
createdItem.monitorType &&
|
||||
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
|
||||
) {
|
||||
return await this.addDefaultProbesToMonitor(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Add default probes failed in MonitorService.onCreateSuccess",
|
||||
);
|
||||
logger.error(error as Error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (IsBillingEnabled) {
|
||||
return await ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
|
||||
createdItem.projectId!,
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Billing operations failed in MonitorService.onCreateSuccess",
|
||||
);
|
||||
logger.error(error as Error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
return await this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps[
|
||||
"ownerUsers"
|
||||
] as Array<ObjectID>) || [],
|
||||
(onCreate.createBy.miscDataProps[
|
||||
"ownerTeams"
|
||||
] as Array<ObjectID>) || [],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error("Add owners failed in MonitorService.onCreateSuccess");
|
||||
logger.error(error as Error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.refreshMonitorProbeStatus(createdItem.id!);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Refresh probe status failed in MonitorService.onCreateSuccess",
|
||||
);
|
||||
logger.error(error as Error);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error("Error in parallel monitor creation operations");
|
||||
logger.error(error);
|
||||
logger.error(
|
||||
`Critical error in MonitorService sequential operations: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
@@ -790,21 +809,40 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
projectId: ObjectID,
|
||||
monitorId: ObjectID,
|
||||
): Promise<void> {
|
||||
const globalProbes: Array<Probe> = await ProbeService.findBy({
|
||||
query: {
|
||||
isGlobalProbe: true,
|
||||
shouldAutoEnableProbeOnNewMonitors: true,
|
||||
},
|
||||
// Fetch project to see if global probes should be added automatically.
|
||||
const project: Project | null = await ProjectService.findOneById({
|
||||
id: projectId,
|
||||
select: {
|
||||
_id: true,
|
||||
doNotAddGlobalProbesByDefaultOnNewMonitors: true,
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const shouldSkipGlobalProbes: boolean =
|
||||
project?.doNotAddGlobalProbesByDefaultOnNewMonitors === true;
|
||||
|
||||
let globalProbes: Array<Probe> = [];
|
||||
|
||||
if (!shouldSkipGlobalProbes) {
|
||||
globalProbes = await ProbeService.findBy({
|
||||
query: {
|
||||
isGlobalProbe: true,
|
||||
shouldAutoEnableProbeOnNewMonitors: true,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const projectProbes: Array<Probe> = await ProbeService.findBy({
|
||||
query: {
|
||||
isGlobalProbe: false,
|
||||
@@ -1389,7 +1427,7 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
throw new BadDataException("Monitor not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
return (monitor.postUpdatesToWorkspaceChannels || []).filter(
|
||||
@@ -1419,7 +1457,7 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
});
|
||||
|
||||
if (!monitor) {
|
||||
throw new BadDataException("Monitor not found.");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
return monitor.name || "";
|
||||
|
||||
@@ -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();
|
||||
@@ -261,7 +261,7 @@ export class Service extends DatabaseService<Model> {
|
||||
if (subscriber.slackIncomingWebhookUrl) {
|
||||
const slackMessage: string = `## 🔧 Scheduled Maintenance - ${event.title || ""}
|
||||
|
||||
**Scheduled Date:** ${OneUptimeDate.getDateAsFormattedString(event.startsAt!)}
|
||||
**Scheduled Date:** ${OneUptimeDate.getDateAsUserFriendlyFormattedString(event.startsAt!)}
|
||||
|
||||
${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
|
||||
@@ -305,6 +305,7 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
OneUptimeDate.getDateAsFormattedHTMLInMultipleTimezones({
|
||||
date: event.startsAt!,
|
||||
timezones: statuspage.subscriberTimezones || [],
|
||||
use12HourFormat: true,
|
||||
}),
|
||||
eventTitle: event.title || "",
|
||||
eventDescription: await Markdown.convertToHTML(
|
||||
@@ -610,6 +611,12 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
labels: {
|
||||
name: true,
|
||||
},
|
||||
createdByUserId: true,
|
||||
createdByUser: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
@@ -620,71 +627,80 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
throw new BadDataException("Scheduled Maintenance not found");
|
||||
}
|
||||
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(
|
||||
this.createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance,
|
||||
createdItem,
|
||||
),
|
||||
);
|
||||
|
||||
// Create state timeline asynchronously
|
||||
coreOperations.push(
|
||||
this.createScheduledMaintenanceStateTimelineAsync(createdItem),
|
||||
);
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
createdItem.projectId &&
|
||||
createdItem.id &&
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in ScheduledMaintenanceService.onCreateSuccess: ${result.reason}`,
|
||||
// Execute operations sequentially with error handling
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
try {
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
return await this.handleScheduledMaintenanceWorkspaceOperationsAsync(
|
||||
createdItem,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleScheduledMaintenanceWorkspaceOperationsAsync(
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Create scheduled maintenance feed failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
return await this.createScheduledMaintenanceStateTimelineAsync(
|
||||
createdItem,
|
||||
).catch((error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Create scheduled maintenance state timeline failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (
|
||||
createdItem.projectId &&
|
||||
createdItem.id &&
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
return await this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps[
|
||||
"ownerUsers"
|
||||
] as Array<ObjectID>) || [],
|
||||
(onCreate.createBy.miscDataProps[
|
||||
"ownerTeams"
|
||||
] as Array<ObjectID>) || [],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Add owners failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in ScheduledMaintenanceService core operations: ${error}`,
|
||||
`Critical error in ScheduledMaintenanceService sequential operations: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -738,27 +754,27 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
@CaptureSpan()
|
||||
private async createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
scheduledMaintenance.createdByUserId ||
|
||||
scheduledMaintenance.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${createdItem.scheduledMaintenanceNumber?.toString()} Created:
|
||||
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${scheduledMaintenance.scheduledMaintenanceNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
**${scheduledMaintenance.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
${scheduledMaintenance.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
// add starts at and ends at.
|
||||
if (scheduledMaintenance.startsAt) {
|
||||
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
||||
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.endsAt) {
|
||||
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
||||
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
|
||||
@@ -772,7 +788,7 @@ ${createdItem.description || "No description provided."}
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of scheduledMaintenance.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(scheduledMaintenance.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
@@ -781,14 +797,14 @@ ${createdItem.description || "No description provided."}
|
||||
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
|
||||
{
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceId: scheduledMaintenance.id!,
|
||||
projectId: scheduledMaintenance.projectId!,
|
||||
},
|
||||
);
|
||||
|
||||
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceId: scheduledMaintenance.id!,
|
||||
projectId: scheduledMaintenance.projectId!,
|
||||
scheduledMaintenanceFeedEventType:
|
||||
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
|
||||
displayColor: Red500,
|
||||
@@ -1049,7 +1065,7 @@ ${onUpdate.updateBy.data.title || "No title provided."}
|
||||
// add scheduledMaintenance feed.
|
||||
|
||||
feedInfoInMarkdown += `\n\n**Starts At**:
|
||||
${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
|
||||
${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.startsAt as Date) || "No title provided."}
|
||||
`;
|
||||
shouldAddScheduledMaintenanceFeed = true;
|
||||
}
|
||||
@@ -1058,7 +1074,7 @@ ${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.startsAt as
|
||||
// add scheduledMaintenance feed.
|
||||
|
||||
feedInfoInMarkdown += `\n\n**Ends At**:
|
||||
${OneUptimeDate.getDateAsLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
|
||||
${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(onUpdate.updateBy.data.endsAt as Date) || "No title provided."}
|
||||
`;
|
||||
shouldAddScheduledMaintenanceFeed = true;
|
||||
}
|
||||
|
||||
@@ -340,10 +340,26 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
projectId: ObjectID,
|
||||
statusPageId: ObjectID,
|
||||
): Promise<URL> {
|
||||
const dahboardUrl: URL = await DatabaseConfig.getDashboardUrl();
|
||||
if (!projectId) {
|
||||
throw new BadDataException(
|
||||
"projectId is required to build status page dashboard link",
|
||||
);
|
||||
}
|
||||
|
||||
return URL.fromString(dahboardUrl.toString()).addRoute(
|
||||
`/${projectId.toString()}/status-pages/${statusPageId.toString()}`,
|
||||
if (!statusPageId) {
|
||||
throw new BadDataException(
|
||||
"statusPageId is required to build status page dashboard link",
|
||||
);
|
||||
}
|
||||
|
||||
const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
|
||||
|
||||
// Defensive: ensure objects have toString
|
||||
const projectIdStr: string = projectId.toString();
|
||||
const statusPageIdStr: string = statusPageId.toString();
|
||||
|
||||
return URL.fromString(dashboardUrl.toString()).addRoute(
|
||||
`/${projectIdStr}/status-pages/${statusPageIdStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -861,7 +877,7 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
|
||||
const endDate: Date = OneUptimeDate.getCurrentDate();
|
||||
const startDate: Date = OneUptimeDate.getSomeDaysAgo(numberOfDays);
|
||||
const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsLocalFormattedString(endDate, true)})`;
|
||||
const startAndEndDate: string = `${numberOfDays} days (${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(startDate, true)} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(endDate, true)})`;
|
||||
|
||||
if (statusPageResources.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,7 @@ import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
|
||||
import NumberUtil from "../../Utils/Number";
|
||||
import SlackUtil from "../Utils/Workspace/Slack/Slack";
|
||||
import MicrosoftTeamsUtil from "../Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -221,6 +222,23 @@ export class Service extends DatabaseService<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Microsoft Teams webhook URL if provided
|
||||
if (data.data.microsoftTeamsIncomingWebhookUrl) {
|
||||
logger.debug(
|
||||
`Microsoft Teams Incoming Webhook URL: ${data.data.microsoftTeamsIncomingWebhookUrl}`,
|
||||
);
|
||||
if (
|
||||
!MicrosoftTeamsUtil.isValidMicrosoftTeamsIncomingWebhookUrl(
|
||||
data.data.microsoftTeamsIncomingWebhookUrl,
|
||||
)
|
||||
) {
|
||||
logger.debug("Invalid Microsoft Teams Incoming Webhook URL.");
|
||||
throw new BadDataException(
|
||||
"Invalid Microsoft Teams Incoming Webhook URL.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
data.data.subscriptionConfirmationToken = NumberUtil.getRandomNumber(
|
||||
100000,
|
||||
999999,
|
||||
@@ -370,16 +388,55 @@ Stay informed about service availability! 🚀`;
|
||||
|
||||
logger.debug(`Slack Message: ${slackMessage}`);
|
||||
|
||||
try {
|
||||
await SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(createdItem.slackIncomingWebhookUrl.toString()),
|
||||
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
|
||||
SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(createdItem.slackIncomingWebhookUrl.toString()),
|
||||
text: SlackUtil.convertMarkdownToSlackRichText(slackMessage),
|
||||
})
|
||||
.then(() => {
|
||||
logger.debug("Slack notification sent successfully.");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger.error("Error sending Slack notification:");
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// if Microsoft Teams incoming webhook is provided and sendYouHaveSubscribedMessage is true, then send a message to the Teams channel.
|
||||
if (
|
||||
createdItem.microsoftTeamsIncomingWebhookUrl &&
|
||||
createdItem.sendYouHaveSubscribedMessage
|
||||
) {
|
||||
logger.debug("Sending Microsoft Teams notification for new subscriber.");
|
||||
const teamsMessage: string = `## 📢 New Subscription to ${statusPageName}
|
||||
|
||||
**You have successfully subscribed to receive status updates!**
|
||||
|
||||
🔗 **Status Page:** [${statusPageName}](${statusPageURL})
|
||||
📧 **Manage Subscription:** [Update preferences or unsubscribe](${unsubscribeLink})
|
||||
|
||||
You will receive real-time notifications for:
|
||||
• Incidents and outages
|
||||
• Scheduled maintenance events
|
||||
• Service announcements
|
||||
• Status updates
|
||||
|
||||
Stay informed about service availability! 🚀`;
|
||||
|
||||
logger.debug(`Teams Message: ${teamsMessage}`);
|
||||
|
||||
MicrosoftTeamsUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(
|
||||
createdItem.microsoftTeamsIncomingWebhookUrl.toString(),
|
||||
),
|
||||
text: teamsMessage,
|
||||
})
|
||||
.then(() => {
|
||||
logger.debug("Microsoft Teams notification sent successfully.");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger.error("Error sending Microsoft Teams notification:");
|
||||
logger.error(err);
|
||||
});
|
||||
logger.debug("Slack notification sent successfully.");
|
||||
} catch (error) {
|
||||
logger.error("Error sending Slack notification:");
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("onCreateSuccess completed.");
|
||||
@@ -691,6 +748,7 @@ Stay informed about service availability! 🚀`;
|
||||
subscriberPhone: true,
|
||||
subscriberWebhook: true,
|
||||
slackIncomingWebhookUrl: true,
|
||||
microsoftTeamsIncomingWebhookUrl: true,
|
||||
isSubscribedToAllResources: true,
|
||||
statusPageResources: true,
|
||||
isSubscribedToAllEventTypes: true,
|
||||
@@ -746,7 +804,10 @@ Stay informed about service availability! 🚀`;
|
||||
if (
|
||||
data.statusPage.allowSubscribersToChooseResources &&
|
||||
!data.subscriber.isSubscribedToAllResources &&
|
||||
data.eventType !== StatusPageEventType.Announcement // announcements dont have resources
|
||||
!(
|
||||
data.eventType === StatusPageEventType.Announcement &&
|
||||
data.statusPageResources.length === 0
|
||||
) // announcements with no monitors don't use resource filtering
|
||||
) {
|
||||
logger.debug(
|
||||
"Subscriber can choose resources and is not subscribed to all resources.",
|
||||
|
||||
@@ -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;
|
||||
@@ -213,6 +214,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
).doesChannelExist({
|
||||
authToken: projectAuthToken,
|
||||
channelName: channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
if (!channelExists) {
|
||||
@@ -458,6 +460,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
workspaceType: workspaceType,
|
||||
}),
|
||||
sendMessageBeforeArchiving: data.sendMessageBeforeArchiving,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -610,6 +613,9 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
notificationFor: NotificationFor;
|
||||
workspaceType: WorkspaceType;
|
||||
}): Promise<Array<WorkspaceChannel>> {
|
||||
logger.debug("getWorkspaceChannelsByNotificationFor called with data:");
|
||||
logger.debug(JSON.stringify(data, null, 2));
|
||||
|
||||
let monitorChannels: Array<WorkspaceChannel> = [];
|
||||
|
||||
if (data.notificationFor.monitorId) {
|
||||
@@ -653,6 +659,10 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Workspace channels found:");
|
||||
logger.debug(monitorChannels);
|
||||
|
||||
return monitorChannels;
|
||||
}
|
||||
|
||||
@@ -757,112 +767,132 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
}): Promise<{
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null> {
|
||||
logger.debug(
|
||||
"WorkspaceNotificationRuleService.createInviteAndPostToChannelsBasedOnRules",
|
||||
);
|
||||
logger.debug(data);
|
||||
try {
|
||||
logger.debug(
|
||||
"WorkspaceNotificationRuleService.createInviteAndPostToChannelsBasedOnRules",
|
||||
);
|
||||
logger.debug(data);
|
||||
|
||||
const channelsCreated: Array<NotificationRuleWorkspaceChannel> = [];
|
||||
const channelsCreated: Array<NotificationRuleWorkspaceChannel> = [];
|
||||
|
||||
const projectAuths: Array<WorkspaceProjectAuthToken> =
|
||||
await WorkspaceProjectAuthTokenService.getProjectAuths({
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
logger.debug("projectAuths");
|
||||
logger.debug(projectAuths);
|
||||
|
||||
if (!projectAuths || projectAuths.length === 0) {
|
||||
// do nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const projectAuth of projectAuths) {
|
||||
if (!projectAuth.authToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!projectAuth.workspaceType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authToken: string = projectAuth.authToken;
|
||||
const workspaceType: WorkspaceType = projectAuth.workspaceType;
|
||||
|
||||
const notificationRules: Array<WorkspaceNotificationRule> =
|
||||
await this.getMatchingNotificationRules({
|
||||
const projectAuths: Array<WorkspaceProjectAuthToken> =
|
||||
await WorkspaceProjectAuthTokenService.getProjectAuths({
|
||||
projectId: data.projectId,
|
||||
workspaceType: workspaceType,
|
||||
notificationRuleEventType: data.notificationRuleEventType,
|
||||
notificationFor: data.notificationFor,
|
||||
});
|
||||
|
||||
logger.debug("notificationRules");
|
||||
logger.debug(notificationRules);
|
||||
logger.debug("projectAuths");
|
||||
logger.debug(projectAuths);
|
||||
|
||||
if (!notificationRules || notificationRules.length === 0) {
|
||||
if (!projectAuths || projectAuths.length === 0) {
|
||||
// do nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug("Creating channels based on rules");
|
||||
const createdWorkspaceChannels: Array<NotificationRuleWorkspaceChannel> =
|
||||
await this.createChannelsBasedOnRules({
|
||||
projectId: data.projectId,
|
||||
projectOrUserAuthTokenForWorkspace: authToken,
|
||||
workspaceType: workspaceType,
|
||||
notificationRules: notificationRules,
|
||||
channelNameSiffix: data.channelNameSiffix,
|
||||
notificationEventType: data.notificationRuleEventType,
|
||||
notificationFor: data.notificationFor,
|
||||
});
|
||||
for (const projectAuth of projectAuths) {
|
||||
try {
|
||||
if (!projectAuth.authToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug("createdWorkspaceChannels");
|
||||
logger.debug(createdWorkspaceChannels);
|
||||
if (!projectAuth.workspaceType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug("Inviting users and teams to channels based on rules");
|
||||
await this.inviteUsersAndTeamsToChannelsBasedOnRules({
|
||||
projectId: data.projectId,
|
||||
projectAuth: projectAuth,
|
||||
workspaceType: workspaceType,
|
||||
notificationRules: notificationRules,
|
||||
notificationChannels: createdWorkspaceChannels,
|
||||
});
|
||||
const authToken: string = projectAuth.authToken;
|
||||
const workspaceType: WorkspaceType = projectAuth.workspaceType;
|
||||
|
||||
logger.debug("Getting existing channel names from notification rules");
|
||||
const existingChannelNames: Array<string> =
|
||||
this.getExistingChannelNamesFromNotificationRules({
|
||||
notificationRules: notificationRules.map(
|
||||
(rule: WorkspaceNotificationRule) => {
|
||||
return rule.notificationRule as BaseNotificationRule;
|
||||
},
|
||||
),
|
||||
}) || [];
|
||||
const notificationRules: Array<WorkspaceNotificationRule> =
|
||||
await this.getMatchingNotificationRules({
|
||||
projectId: data.projectId,
|
||||
workspaceType: workspaceType,
|
||||
notificationRuleEventType: data.notificationRuleEventType,
|
||||
notificationFor: data.notificationFor,
|
||||
});
|
||||
|
||||
logger.debug("Existing channel names:");
|
||||
logger.debug(existingChannelNames);
|
||||
logger.debug("notificationRules");
|
||||
logger.debug(notificationRules);
|
||||
|
||||
logger.debug("Adding created channel names to existing channel names");
|
||||
for (const channel of createdWorkspaceChannels) {
|
||||
if (!existingChannelNames.includes(channel.name)) {
|
||||
existingChannelNames.push(channel.name);
|
||||
if (!notificationRules || notificationRules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug("Creating channels based on rules");
|
||||
const createdWorkspaceChannels: Array<NotificationRuleWorkspaceChannel> =
|
||||
await this.createChannelsBasedOnRules({
|
||||
projectId: data.projectId,
|
||||
projectOrUserAuthTokenForWorkspace: authToken,
|
||||
workspaceType: workspaceType,
|
||||
notificationRules: notificationRules,
|
||||
channelNameSiffix: data.channelNameSiffix,
|
||||
notificationEventType: data.notificationRuleEventType,
|
||||
notificationFor: data.notificationFor,
|
||||
});
|
||||
|
||||
logger.debug("createdWorkspaceChannels");
|
||||
logger.debug(createdWorkspaceChannels);
|
||||
|
||||
logger.debug("Inviting users and teams to channels based on rules");
|
||||
await this.inviteUsersAndTeamsToChannelsBasedOnRules({
|
||||
projectId: data.projectId,
|
||||
projectAuth: projectAuth,
|
||||
workspaceType: workspaceType,
|
||||
notificationRules: notificationRules,
|
||||
notificationChannels: createdWorkspaceChannels,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
"Getting existing channel names from notification rules",
|
||||
);
|
||||
const existingChannelNames: Array<string> =
|
||||
this.getExistingChannelNamesFromNotificationRules({
|
||||
notificationRules: notificationRules.map(
|
||||
(rule: WorkspaceNotificationRule) => {
|
||||
return rule.notificationRule as BaseNotificationRule;
|
||||
},
|
||||
),
|
||||
}) || [];
|
||||
|
||||
logger.debug("Existing channel names:");
|
||||
logger.debug(existingChannelNames);
|
||||
|
||||
logger.debug(
|
||||
"Adding created channel names to existing channel names",
|
||||
);
|
||||
for (const channel of createdWorkspaceChannels) {
|
||||
if (!existingChannelNames.includes(channel.name)) {
|
||||
existingChannelNames.push(channel.name);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Final list of channel names to post messages to:");
|
||||
logger.debug(existingChannelNames);
|
||||
|
||||
logger.debug("Posting messages to workspace channels");
|
||||
|
||||
logger.debug("Channels created:");
|
||||
logger.debug(createdWorkspaceChannels);
|
||||
|
||||
channelsCreated.push(...createdWorkspaceChannels);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Error in creating channels and inviting users to channels for workspace type " +
|
||||
projectAuth.workspaceType,
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Final list of channel names to post messages to:");
|
||||
logger.debug(existingChannelNames);
|
||||
|
||||
logger.debug("Posting messages to workspace channels");
|
||||
|
||||
logger.debug("Channels created:");
|
||||
logger.debug(createdWorkspaceChannels);
|
||||
|
||||
channelsCreated.push(...createdWorkspaceChannels);
|
||||
logger.debug("Returning created channels");
|
||||
return {
|
||||
channelsCreated: channelsCreated,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Error in createChannelsAndInviteUsersToChannelsBasedOnRules:",
|
||||
);
|
||||
logger.error(err);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug("Returning created channels");
|
||||
return {
|
||||
channelsCreated: channelsCreated,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -1004,6 +1034,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
} as WorkspacePayloadMarkdown,
|
||||
],
|
||||
},
|
||||
projectId: data.projectId,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Error in sending message to channel");
|
||||
@@ -1031,6 +1062,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
}),
|
||||
workspaceUserIds: workspaceUserIds,
|
||||
},
|
||||
projectId: data.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1159,6 +1191,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
} as WorkspacePayloadMarkdown,
|
||||
],
|
||||
},
|
||||
projectId: data.projectId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -1188,6 +1221,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
channelNames: channelNames,
|
||||
workspaceUserIds: workspaceUserIds,
|
||||
},
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
// Log user invitations
|
||||
@@ -1345,6 +1379,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
).createChannel({
|
||||
authToken: data.projectOrUserAuthTokenForWorkspace,
|
||||
channelName: notificationChannel.channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
const notificationWorkspaceChannel: NotificationRuleWorkspaceChannel = {
|
||||
@@ -1911,7 +1946,7 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
|
||||
if (!monitor) {
|
||||
logger.debug("Monitor not found for ID:");
|
||||
logger.debug(data.notificationFor.monitorId);
|
||||
throw new BadDataException("Monitor ID not found");
|
||||
throw new BadDataException(ExceptionMessages.MonitorNotFound);
|
||||
}
|
||||
|
||||
const monitorLabels: Array<Label> = monitor?.labels || [];
|
||||
|
||||
@@ -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><code class="language-${language}">${code}</code></pre>`;
|
||||
};
|
||||
|
||||
renderer.heading = function (text, level) {
|
||||
@@ -96,6 +95,11 @@ export default class Markdown {
|
||||
return `<h6 class="my-5 tracking-tight font-bold text-gray-800">${text}</h6>`;
|
||||
};
|
||||
|
||||
// Inline code
|
||||
renderer.codespan = function (code) {
|
||||
return `<code class="rounded-md bg-slate-100 px-1.5 py-0.5 text-sm text-slate-700 font-mono">${code}</code>`;
|
||||
};
|
||||
|
||||
this.docsRenderer = renderer;
|
||||
|
||||
return renderer;
|
||||
@@ -137,6 +141,102 @@ export default class Markdown {
|
||||
return `<h6 class="my-5 tracking-tight font-bold text-gray-800">${text}</h6>`;
|
||||
};
|
||||
|
||||
// Lists
|
||||
renderer.list = function (body, ordered, start) {
|
||||
const tag: string = ordered ? "ol" : "ul";
|
||||
const cls: string = ordered
|
||||
? "list-decimal pl-6 my-6 space-y-2 text-gray-700"
|
||||
: "list-disc pl-6 my-6 space-y-2 text-gray-700";
|
||||
const startAttr: string =
|
||||
ordered && start !== 1 ? ` start="${start}"` : "";
|
||||
return `<${tag}${startAttr} class="${cls}">${body}</${tag}>`;
|
||||
};
|
||||
renderer.listitem = function (text) {
|
||||
return `<li class="leading-7">${text}</li>`;
|
||||
};
|
||||
|
||||
// Tables
|
||||
renderer.table = function (header, body) {
|
||||
return `<div class="overflow-x-auto my-8"><table class="min-w-full border border-gray-200 text-sm text-left">
|
||||
${header}${body}
|
||||
</table></div>`;
|
||||
};
|
||||
renderer.tablerow = function (content) {
|
||||
return `<tr class="border-b last:border-b-0">${content}</tr>`;
|
||||
};
|
||||
renderer.tablecell = function (content, flags) {
|
||||
const type: string = flags.header ? "th" : "td";
|
||||
const base: string = "px-4 py-2 border-r last:border-r-0 border-gray-200";
|
||||
const align: string = flags.align ? ` text-${flags.align}` : "";
|
||||
const weight: string = flags.header ? " font-semibold bg-gray-50" : "";
|
||||
return `<${type} class="${base}${align}${weight}">${content}</${type}>`;
|
||||
};
|
||||
|
||||
// Inline code
|
||||
renderer.codespan = function (code) {
|
||||
return `<code class="rounded-md bg-gray-100 px-1.5 py-0.5 text-sm text-pink-600">${code}</code>`;
|
||||
};
|
||||
|
||||
// Horizontal rule
|
||||
renderer.hr = function () {
|
||||
return '<hr class="my-12 border-t border-gray-200" />';
|
||||
};
|
||||
|
||||
// Emphasis / Strong / Strikethrough
|
||||
renderer.strong = function (text) {
|
||||
return `<strong class="font-semibold text-gray-800">${text}</strong>`;
|
||||
};
|
||||
renderer.em = function (text) {
|
||||
return `<em class="italic text-gray-700">${text}</em>`;
|
||||
};
|
||||
renderer.del = function (text) {
|
||||
return `<del class="line-through text-gray-400">${text}</del>`;
|
||||
};
|
||||
|
||||
// Images
|
||||
renderer.image = function (href, _title, text) {
|
||||
return `<figure class="my-8"><img src="${href}" alt="${text}" class="rounded-xl shadow-sm border border-gray-200" loading="lazy"/><figcaption class="mt-2 text-center text-sm text-gray-500">${text || ""}</figcaption></figure>`;
|
||||
};
|
||||
|
||||
// Links
|
||||
// We explicitly add underline + color classes because Tailwind Typography (prose-*)
|
||||
// styles may get overridden by surrounding utility classes or global resets.
|
||||
// External links open in a new tab with proper rel attributes; internal links stay in-page.
|
||||
renderer.link = function (href, title, text) {
|
||||
// Guard: if no href, just return the text.
|
||||
if (!href) {
|
||||
return text as string;
|
||||
}
|
||||
|
||||
const isHash: boolean = href.startsWith("#");
|
||||
const isMailTo: boolean = href.startsWith("mailto:");
|
||||
const isTel: boolean = href.startsWith("tel:");
|
||||
const isInternal: boolean =
|
||||
href.startsWith("/") ||
|
||||
href.includes("oneuptime.com") ||
|
||||
isHash ||
|
||||
isMailTo ||
|
||||
isTel;
|
||||
|
||||
const baseClasses: string = [
|
||||
"font-semibold",
|
||||
"text-indigo-600",
|
||||
"underline",
|
||||
"underline-offset-2",
|
||||
"decoration-indigo-300",
|
||||
"hover:decoration-indigo-500",
|
||||
"hover:text-indigo-500",
|
||||
"transition-colors",
|
||||
].join(" ");
|
||||
|
||||
const titleAttr: string = title ? ` title="${title}"` : "";
|
||||
const externalAttrs: string = isInternal
|
||||
? ""
|
||||
: ' target="_blank" rel="noopener noreferrer"';
|
||||
|
||||
return `<a href="${href}"${titleAttr} class="${baseClasses}"${externalAttrs}>${text}</a>`;
|
||||
};
|
||||
|
||||
this.blogRenderer = renderer;
|
||||
|
||||
return renderer;
|
||||
|
||||
@@ -19,6 +19,8 @@ import AlertStateTimelineService from "../../Services/AlertStateTimelineService"
|
||||
import logger from "../Logger";
|
||||
import CaptureSpan from "../Telemetry/CaptureSpan";
|
||||
import DataToProcess from "./DataToProcess";
|
||||
import MonitorTemplateUtil from "./MonitorTemplateUtil";
|
||||
import { JSONObject } from "../../../Types/JSON";
|
||||
|
||||
export default class MonitorAlert {
|
||||
@CaptureSpan()
|
||||
@@ -130,9 +132,20 @@ export default class MonitorAlert {
|
||||
logger.debug(`${input.monitor.id?.toString()} - Create alert.`);
|
||||
|
||||
const alert: Alert = new Alert();
|
||||
const storageMap: JSONObject =
|
||||
MonitorTemplateUtil.buildTemplateStorageMap({
|
||||
monitorType: input.monitor.monitorType!,
|
||||
dataToProcess: input.dataToProcess,
|
||||
});
|
||||
|
||||
alert.title = criteriaAlert.title;
|
||||
alert.description = criteriaAlert.description;
|
||||
alert.title = MonitorTemplateUtil.processTemplateString({
|
||||
value: criteriaAlert.title,
|
||||
storageMap,
|
||||
});
|
||||
alert.description = MonitorTemplateUtil.processTemplateString({
|
||||
value: criteriaAlert.description,
|
||||
storageMap,
|
||||
});
|
||||
|
||||
if (!criteriaAlert.alertSeverityId) {
|
||||
// pick the critical criteria.
|
||||
@@ -194,7 +207,10 @@ export default class MonitorAlert {
|
||||
}
|
||||
|
||||
if (criteriaAlert.remediationNotes) {
|
||||
alert.remediationNotes = criteriaAlert.remediationNotes;
|
||||
alert.remediationNotes = MonitorTemplateUtil.processTemplateString({
|
||||
value: criteriaAlert.remediationNotes,
|
||||
storageMap,
|
||||
});
|
||||
}
|
||||
|
||||
if (DisableAutomaticAlertCreation) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import IncidentStateTimelineService from "../../Services/IncidentStateTimelineSe
|
||||
import logger from "../Logger";
|
||||
import CaptureSpan from "../Telemetry/CaptureSpan";
|
||||
import DataToProcess from "./DataToProcess";
|
||||
import MonitorTemplateUtil from "./MonitorTemplateUtil";
|
||||
import { JSONObject } from "../../../Types/JSON";
|
||||
|
||||
export default class MonitorIncident {
|
||||
@CaptureSpan()
|
||||
@@ -34,7 +36,7 @@ export default class MonitorIncident {
|
||||
// check active incidents and if there are open incidents, do not cretae anothr incident.
|
||||
const openIncidents: Array<Incident> = await IncidentService.findBy({
|
||||
query: {
|
||||
monitors: [input.monitorId] as any,
|
||||
monitors: [input.monitorId],
|
||||
currentIncidentState: {
|
||||
isResolvedState: false,
|
||||
},
|
||||
@@ -136,9 +138,20 @@ export default class MonitorIncident {
|
||||
logger.debug(`${input.monitor.id?.toString()} - Create incident.`);
|
||||
|
||||
const incident: Incident = new Incident();
|
||||
const storageMap: JSONObject =
|
||||
MonitorTemplateUtil.buildTemplateStorageMap({
|
||||
monitorType: input.monitor.monitorType!,
|
||||
dataToProcess: input.dataToProcess,
|
||||
});
|
||||
|
||||
incident.title = criteriaIncident.title;
|
||||
incident.description = criteriaIncident.description;
|
||||
incident.title = MonitorTemplateUtil.processTemplateString({
|
||||
value: criteriaIncident.title,
|
||||
storageMap,
|
||||
});
|
||||
incident.description = MonitorTemplateUtil.processTemplateString({
|
||||
value: criteriaIncident.description,
|
||||
storageMap,
|
||||
});
|
||||
|
||||
if (!criteriaIncident.incidentSeverityId) {
|
||||
// pick the critical criteria.
|
||||
@@ -204,7 +217,12 @@ export default class MonitorIncident {
|
||||
}
|
||||
|
||||
if (criteriaIncident.remediationNotes) {
|
||||
incident.remediationNotes = criteriaIncident.remediationNotes;
|
||||
incident.remediationNotes = MonitorTemplateUtil.processTemplateString(
|
||||
{
|
||||
value: criteriaIncident.remediationNotes,
|
||||
storageMap,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (DisableAutomaticIncidentCreation) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
246
Common/Server/Utils/Monitor/MonitorTemplateUtil.ts
Normal file
246
Common/Server/Utils/Monitor/MonitorTemplateUtil.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import MonitorType from "../../../Types/Monitor/MonitorType";
|
||||
import { JSONObject } from "../../../Types/JSON";
|
||||
import ProbeMonitorResponse from "../../../Types/Probe/ProbeMonitorResponse";
|
||||
import IncomingMonitorRequest from "../../../Types/Monitor/IncomingMonitor/IncomingMonitorRequest";
|
||||
import ServerMonitorResponse, {
|
||||
ServerProcess,
|
||||
} from "../../../Types/Monitor/ServerMonitor/ServerMonitorResponse";
|
||||
import BasicInfrastructureMetrics, {
|
||||
BasicDiskMetrics,
|
||||
} from "../../../Types/Infrastructure/BasicMetrics";
|
||||
import SslMonitorResponse from "../../../Types/Monitor/SSLMonitor/SslMonitorResponse";
|
||||
import CustomCodeMonitorResponse from "../../../Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse";
|
||||
import SyntheticMonitorResponse from "../../../Types/Monitor/SyntheticMonitors/SyntheticMonitorResponse";
|
||||
import Typeof from "../../../Types/Typeof";
|
||||
import VMUtil from "../VM/VMAPI";
|
||||
import DataToProcess from "./DataToProcess";
|
||||
import logger from "../Logger";
|
||||
|
||||
/**
|
||||
* Utility for building template variable storage map and processing dynamic placeholders
|
||||
* shared between Incident and Alert auto-creation.
|
||||
*/
|
||||
export default class MonitorTemplateUtil {
|
||||
/**
|
||||
* Build a storage map of variables available for templating based on monitor type.
|
||||
*/
|
||||
public static buildTemplateStorageMap(data: {
|
||||
monitorType: MonitorType;
|
||||
dataToProcess: DataToProcess;
|
||||
}): JSONObject {
|
||||
let storageMap: JSONObject = {};
|
||||
|
||||
try {
|
||||
if (
|
||||
data.monitorType === MonitorType.API ||
|
||||
data.monitorType === MonitorType.Website
|
||||
) {
|
||||
let responseBody: JSONObject | null = null;
|
||||
try {
|
||||
responseBody = JSON.parse(
|
||||
((data.dataToProcess as ProbeMonitorResponse)
|
||||
.responseBody as string) || "{}",
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
responseBody = (data.dataToProcess as ProbeMonitorResponse)
|
||||
.responseBody as JSONObject;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof responseBody === Typeof.String &&
|
||||
responseBody?.toString() === ""
|
||||
) {
|
||||
responseBody = {};
|
||||
}
|
||||
|
||||
storageMap = {
|
||||
responseBody: responseBody,
|
||||
responseHeaders: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.responseHeaders,
|
||||
responseStatusCode: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.responseCode,
|
||||
responseTimeInMs: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.responseTimeInMs,
|
||||
isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
if (data.monitorType === MonitorType.IncomingRequest) {
|
||||
storageMap = {
|
||||
requestBody: (data.dataToProcess as IncomingMonitorRequest)
|
||||
.requestBody,
|
||||
requestHeaders: (data.dataToProcess as IncomingMonitorRequest)
|
||||
.requestHeaders,
|
||||
requestMethod: (data.dataToProcess as IncomingMonitorRequest)
|
||||
.requestMethod,
|
||||
incomingRequestReceivedAt: (
|
||||
data.dataToProcess as IncomingMonitorRequest
|
||||
).incomingRequestReceivedAt,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
if (
|
||||
data.monitorType === MonitorType.Ping ||
|
||||
data.monitorType === MonitorType.IP ||
|
||||
data.monitorType === MonitorType.Port
|
||||
) {
|
||||
storageMap = {
|
||||
isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline,
|
||||
responseTimeInMs: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.responseTimeInMs,
|
||||
failureCause: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.failureCause,
|
||||
isTimeout: (data.dataToProcess as ProbeMonitorResponse).isTimeout,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
if (data.monitorType === MonitorType.SSLCertificate) {
|
||||
const sslResponse: SslMonitorResponse | undefined = (
|
||||
data.dataToProcess as ProbeMonitorResponse
|
||||
).sslResponse;
|
||||
storageMap = {
|
||||
isOnline: (data.dataToProcess as ProbeMonitorResponse).isOnline,
|
||||
isSelfSigned: sslResponse?.isSelfSigned,
|
||||
createdAt: sslResponse?.createdAt,
|
||||
expiresAt: sslResponse?.expiresAt,
|
||||
commonName: sslResponse?.commonName,
|
||||
organizationalUnit: sslResponse?.organizationalUnit,
|
||||
organization: sslResponse?.organization,
|
||||
locality: sslResponse?.locality,
|
||||
state: sslResponse?.state,
|
||||
country: sslResponse?.country,
|
||||
serialNumber: sslResponse?.serialNumber,
|
||||
fingerprint: sslResponse?.fingerprint,
|
||||
fingerprint256: sslResponse?.fingerprint256,
|
||||
failureCause: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.failureCause,
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
if (data.monitorType === MonitorType.Server) {
|
||||
const serverResponse: ServerMonitorResponse =
|
||||
data.dataToProcess as ServerMonitorResponse;
|
||||
const infraMetrics: BasicInfrastructureMetrics | undefined =
|
||||
serverResponse.basicInfrastructureMetrics;
|
||||
|
||||
storageMap = {
|
||||
hostname: serverResponse.hostname,
|
||||
requestReceivedAt: serverResponse.requestReceivedAt,
|
||||
failureCause: serverResponse.failureCause,
|
||||
} as JSONObject;
|
||||
|
||||
// Add CPU metrics if available
|
||||
if (infraMetrics?.cpuMetrics) {
|
||||
storageMap["cpuUsagePercent"] = infraMetrics.cpuMetrics.percentUsed;
|
||||
storageMap["cpuCores"] = infraMetrics.cpuMetrics.cores;
|
||||
}
|
||||
|
||||
// Add memory metrics if available
|
||||
if (infraMetrics?.memoryMetrics) {
|
||||
storageMap["memoryUsagePercent"] =
|
||||
infraMetrics.memoryMetrics.percentUsed;
|
||||
storageMap["memoryFreePercent"] =
|
||||
infraMetrics.memoryMetrics.percentFree;
|
||||
storageMap["memoryTotalBytes"] = infraMetrics.memoryMetrics.total;
|
||||
}
|
||||
|
||||
// Add disk metrics if available
|
||||
if (infraMetrics?.diskMetrics) {
|
||||
storageMap["diskMetrics"] = infraMetrics.diskMetrics.map(
|
||||
(disk: BasicDiskMetrics) => {
|
||||
return {
|
||||
diskPath: disk.diskPath,
|
||||
usagePercent: disk.percentUsed,
|
||||
freePercent: disk.percentFree,
|
||||
totalBytes: disk.total,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Add processes if available
|
||||
if (serverResponse.processes) {
|
||||
storageMap["processes"] = serverResponse.processes.map(
|
||||
(process: ServerProcess) => {
|
||||
return {
|
||||
pid: process.pid,
|
||||
name: process.name,
|
||||
command: process.command,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.monitorType === MonitorType.SyntheticMonitor ||
|
||||
data.monitorType === MonitorType.CustomJavaScriptCode
|
||||
) {
|
||||
const customCodeResponse: CustomCodeMonitorResponse | undefined = (
|
||||
data.dataToProcess as ProbeMonitorResponse
|
||||
).customCodeMonitorResponse;
|
||||
const syntheticResponse: SyntheticMonitorResponse[] | undefined = (
|
||||
data.dataToProcess as ProbeMonitorResponse
|
||||
).syntheticMonitorResponse;
|
||||
|
||||
storageMap = {
|
||||
executionTimeInMs: customCodeResponse?.executionTimeInMS,
|
||||
result: customCodeResponse?.result,
|
||||
scriptError: customCodeResponse?.scriptError,
|
||||
logMessages: customCodeResponse?.logMessages || [],
|
||||
failureCause: (data.dataToProcess as ProbeMonitorResponse)
|
||||
.failureCause,
|
||||
} as JSONObject;
|
||||
|
||||
// Add synthetic monitor specific fields if available
|
||||
if (syntheticResponse && syntheticResponse.length > 0) {
|
||||
const firstResponse: SyntheticMonitorResponse = syntheticResponse[0]!;
|
||||
if (firstResponse) {
|
||||
storageMap["screenshots"] = firstResponse.screenshots;
|
||||
storageMap["browserType"] = firstResponse.browserType;
|
||||
storageMap["screenSizeType"] = firstResponse.screenSizeType;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
logger.debug(`Storage Map: ${JSON.stringify(storageMap, null, 2)}`);
|
||||
|
||||
return storageMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace {{var}} placeholders in the given string with values from the storage map.
|
||||
*/
|
||||
public static processTemplateString(data: {
|
||||
value: string | undefined;
|
||||
storageMap: JSONObject;
|
||||
}): string {
|
||||
try {
|
||||
const { value, storageMap } = data;
|
||||
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let replaced: string = VMUtil.replaceValueInPlace(
|
||||
storageMap,
|
||||
value,
|
||||
false,
|
||||
);
|
||||
replaced =
|
||||
replaced !== undefined && replaced !== null ? `${replaced}` : "";
|
||||
|
||||
logger.debug(`Original Value: ${data.value}`);
|
||||
logger.debug(`Replaced Value: ${replaced}`);
|
||||
|
||||
return replaced;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return data.value || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,10 +82,19 @@ export default class VMUtil {
|
||||
}
|
||||
|
||||
for (const variable of variablesInArgument) {
|
||||
const valueToReplaceInPlace: string = VMUtil.deepFind(
|
||||
const foundValue: JSONValue = VMUtil.deepFind(
|
||||
storageMap as any,
|
||||
variable as any,
|
||||
) as string;
|
||||
);
|
||||
|
||||
let valueToReplaceInPlace: string;
|
||||
|
||||
// Properly serialize objects to JSON strings
|
||||
if (typeof foundValue === "object" && foundValue !== null) {
|
||||
valueToReplaceInPlace = JSON.stringify(foundValue, null, 2);
|
||||
} else {
|
||||
valueToReplaceInPlace = foundValue as string;
|
||||
}
|
||||
|
||||
if (valueToReplaceInPlaceCopy.trim() === "{{" + variable + "}}") {
|
||||
valueToReplaceInPlaceCopy = valueToReplaceInPlace;
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ export default class SlackIncidentActions {
|
||||
await SlackUtil.sendMessage({
|
||||
authToken: projectAuthToken,
|
||||
userId: botUserId,
|
||||
projectId: slackRequest.projectId!,
|
||||
workspaceMessagePayload: {
|
||||
_type: "WorkspaceMessagePayload",
|
||||
channelIds: [slackChannelId],
|
||||
|
||||
@@ -280,6 +280,7 @@ export default class SlackScheduledMaintenanceActions {
|
||||
await SlackUtil.sendMessage({
|
||||
authToken: projectAuthToken,
|
||||
userId: botUserId,
|
||||
projectId: slackRequest.projectId!,
|
||||
workspaceMessagePayload: {
|
||||
_type: "WorkspaceMessagePayload",
|
||||
channelIds: [slackChannelId],
|
||||
|
||||
@@ -31,6 +31,8 @@ import { DropdownOption } from "../../../../UI/Components/Dropdown/Dropdown";
|
||||
import OneUptimeDate from "../../../../Types/Date";
|
||||
import CaptureSpan from "../../Telemetry/CaptureSpan";
|
||||
import BadDataException from "../../../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../../../Types/ObjectID";
|
||||
import WorkspaceProjectAuthTokenService from "../../../Services/WorkspaceProjectAuthTokenService";
|
||||
|
||||
export default class SlackUtil extends WorkspaceBase {
|
||||
public static isValidSlackIncomingWebhookUrl(
|
||||
@@ -181,6 +183,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
channelIds: Array<string>;
|
||||
authToken: string;
|
||||
sendMessageBeforeArchiving: WorkspacePayloadMarkdown;
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
if (data.sendMessageBeforeArchiving) {
|
||||
await this.sendMessage({
|
||||
@@ -193,6 +196,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
},
|
||||
authToken: data.authToken,
|
||||
userId: data.userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -349,6 +353,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
workspaceUserId: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
if (data.channelName && data.channelName.startsWith("#")) {
|
||||
// trim # from channel name
|
||||
@@ -362,6 +367,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
await this.getWorkspaceChannelFromChannelName({
|
||||
authToken: data.authToken,
|
||||
channelName: data.channelName,
|
||||
projectId: data.projectId,
|
||||
})
|
||||
).id;
|
||||
|
||||
@@ -376,34 +382,32 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
public static override async createChannelsIfDoesNotExist(data: {
|
||||
authToken: string;
|
||||
channelNames: Array<string>;
|
||||
projectId: ObjectID;
|
||||
}): Promise<Array<WorkspaceChannel>> {
|
||||
logger.debug("Creating channels if they do not exist with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const workspaceChannels: Array<WorkspaceChannel> = [];
|
||||
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
});
|
||||
|
||||
logger.debug("Existing workspace channels:");
|
||||
logger.debug(existingWorkspaceChannels);
|
||||
|
||||
for (let channelName of data.channelNames) {
|
||||
// if channel name starts with #, remove it
|
||||
// Normalize channel name
|
||||
if (channelName && channelName.startsWith("#")) {
|
||||
channelName = channelName.substring(1);
|
||||
}
|
||||
|
||||
// convert channel name to lowercase
|
||||
channelName = channelName.toLowerCase();
|
||||
|
||||
// replace spaces with hyphens
|
||||
channelName = channelName.replace(/\s+/g, "-");
|
||||
|
||||
if (existingWorkspaceChannels[channelName]) {
|
||||
// Check if channel exists using optimized method
|
||||
const existingChannel: WorkspaceChannel | null =
|
||||
await this.getWorkspaceChannelByName({
|
||||
authToken: data.authToken,
|
||||
channelName: channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
if (existingChannel) {
|
||||
logger.debug(`Channel ${channelName} already exists.`);
|
||||
workspaceChannels.push(existingWorkspaceChannels[channelName]!);
|
||||
workspaceChannels.push(existingChannel);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -411,6 +415,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
const channel: WorkspaceChannel = await this.createChannel({
|
||||
authToken: data.authToken,
|
||||
channelName: channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
if (channel) {
|
||||
@@ -428,27 +433,27 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
public static override async getWorkspaceChannelFromChannelName(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceChannel> {
|
||||
logger.debug("Getting workspace channel ID from channel name with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const channels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
const channel: WorkspaceChannel | null =
|
||||
await this.getWorkspaceChannelByName({
|
||||
authToken: data.authToken,
|
||||
channelName: data.channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
logger.debug("All workspace channels:");
|
||||
logger.debug(channels);
|
||||
|
||||
if (!channels[data.channelName]) {
|
||||
if (!channel) {
|
||||
logger.error("Channel not found.");
|
||||
throw new BadDataException("Channel not found.");
|
||||
}
|
||||
|
||||
logger.debug("Workspace channel ID obtained:");
|
||||
logger.debug(channels[data.channelName]!.id);
|
||||
logger.debug("Workspace channel obtained:");
|
||||
logger.debug(channel);
|
||||
|
||||
return channels[data.channelName]!;
|
||||
return channel;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -549,9 +554,6 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug("Response from Slack API for getting all channels:");
|
||||
logger.debug(JSON.stringify(response, null, 2));
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error("Error response from Slack API:");
|
||||
logger.error(response);
|
||||
@@ -588,10 +590,219 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
} while (cursor);
|
||||
|
||||
logger.debug("All workspace channels obtained:");
|
||||
logger.debug(channels);
|
||||
return channels;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getChannelFromCache(data: {
|
||||
projectId: ObjectID;
|
||||
channelName: string;
|
||||
}): Promise<WorkspaceChannel | null> {
|
||||
logger.debug("Getting channel from cache with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const projectAuth: any =
|
||||
await WorkspaceProjectAuthTokenService.getProjectAuth({
|
||||
projectId: data.projectId,
|
||||
workspaceType: WorkspaceType.Slack,
|
||||
});
|
||||
|
||||
if (!projectAuth || !projectAuth.miscData) {
|
||||
logger.debug("No project auth found or no misc data");
|
||||
return null;
|
||||
}
|
||||
|
||||
const miscData: any = projectAuth.miscData;
|
||||
const channelCache: any = miscData.channelCache;
|
||||
|
||||
if (!channelCache || !channelCache[data.channelName]) {
|
||||
logger.debug("Channel not found in cache");
|
||||
return null;
|
||||
}
|
||||
|
||||
const cachedChannelData: WorkspaceChannel = channelCache[
|
||||
data.channelName
|
||||
] as WorkspaceChannel;
|
||||
const channel: WorkspaceChannel = {
|
||||
id: cachedChannelData.id,
|
||||
name: cachedChannelData.name,
|
||||
workspaceType: WorkspaceType.Slack,
|
||||
};
|
||||
|
||||
logger.debug("Channel found in cache:");
|
||||
logger.debug(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async updateChannelCache(data: {
|
||||
projectId: ObjectID;
|
||||
channelName: string;
|
||||
channel: WorkspaceChannel;
|
||||
}): Promise<void> {
|
||||
logger.debug("Updating channel cache with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const projectAuth: any =
|
||||
await WorkspaceProjectAuthTokenService.getProjectAuth({
|
||||
projectId: data.projectId,
|
||||
workspaceType: WorkspaceType.Slack,
|
||||
});
|
||||
|
||||
if (!projectAuth) {
|
||||
logger.debug("No project auth found, cannot update cache");
|
||||
return;
|
||||
}
|
||||
|
||||
const miscData: any = projectAuth.miscData || {};
|
||||
const channelCache: any = miscData.channelCache || {};
|
||||
|
||||
// Update the cache
|
||||
channelCache[data.channelName] = {
|
||||
id: data.channel.id,
|
||||
name: data.channel.name,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update miscData
|
||||
miscData.channelCache = channelCache;
|
||||
|
||||
// Save back to database
|
||||
await WorkspaceProjectAuthTokenService.refreshAuthToken({
|
||||
projectId: data.projectId,
|
||||
workspaceType: WorkspaceType.Slack,
|
||||
authToken: projectAuth.authToken,
|
||||
workspaceProjectId: projectAuth.workspaceProjectId,
|
||||
miscData: miscData,
|
||||
});
|
||||
|
||||
logger.debug("Channel cache updated successfully");
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getWorkspaceChannelByName(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceChannel | null> {
|
||||
logger.debug("Getting workspace channel by name with data:");
|
||||
logger.debug(data);
|
||||
|
||||
// Normalize channel name
|
||||
let normalizedChannelName: string = data.channelName;
|
||||
if (normalizedChannelName && normalizedChannelName.startsWith("#")) {
|
||||
normalizedChannelName = normalizedChannelName.substring(1);
|
||||
}
|
||||
normalizedChannelName = normalizedChannelName.toLowerCase();
|
||||
|
||||
// Try to get from cache first
|
||||
try {
|
||||
const cachedChannel: WorkspaceChannel | null =
|
||||
await this.getChannelFromCache({
|
||||
projectId: data.projectId,
|
||||
channelName: normalizedChannelName,
|
||||
});
|
||||
if (cachedChannel) {
|
||||
logger.debug("Channel found in cache:");
|
||||
logger.debug(cachedChannel);
|
||||
return cachedChannel;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error getting channel from cache, falling back to API:");
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
let cursor: string | undefined = undefined;
|
||||
const maxPages: number = 10; // Limit search to prevent excessive API calls
|
||||
let pageCount: number = 0;
|
||||
|
||||
do {
|
||||
const requestBody: JSONObject = {
|
||||
limit: 200, // Use smaller limit for faster searches
|
||||
types: "public_channel,private_channel",
|
||||
};
|
||||
|
||||
if (cursor) {
|
||||
requestBody["cursor"] = cursor;
|
||||
}
|
||||
|
||||
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.post<JSONObject>(
|
||||
URL.fromString("https://slack.com/api/conversations.list"),
|
||||
requestBody,
|
||||
{
|
||||
Authorization: `Bearer ${data.authToken}`,
|
||||
["Content-Type"]: "application/x-www-form-urlencoded",
|
||||
},
|
||||
{
|
||||
retries: 3,
|
||||
exponentialBackoff: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error("Error response from Slack API:");
|
||||
logger.error(response);
|
||||
throw response;
|
||||
}
|
||||
|
||||
// check for ok response
|
||||
if ((response.jsonData as JSONObject)?.["ok"] !== true) {
|
||||
logger.error("Invalid response from Slack API:");
|
||||
logger.error(response.jsonData);
|
||||
const messageFromSlack: string = (response.jsonData as JSONObject)?.[
|
||||
"error"
|
||||
] as string;
|
||||
throw new BadRequestException("Error from Slack " + messageFromSlack);
|
||||
}
|
||||
|
||||
for (const channel of (response.jsonData as JSONObject)[
|
||||
"channels"
|
||||
] as Array<JSONObject>) {
|
||||
if (!channel["id"] || !channel["name"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channelName: string = (channel["name"] as string).toLowerCase();
|
||||
if (channelName === normalizedChannelName) {
|
||||
logger.debug("Channel found:");
|
||||
logger.debug(channel);
|
||||
|
||||
const foundChannel: WorkspaceChannel = {
|
||||
id: channel["id"] as string,
|
||||
name: channel["name"] as string,
|
||||
workspaceType: WorkspaceType.Slack,
|
||||
};
|
||||
|
||||
// Update cache if projectId is provided
|
||||
if (data.projectId) {
|
||||
try {
|
||||
await this.updateChannelCache({
|
||||
projectId: data.projectId,
|
||||
channelName: normalizedChannelName,
|
||||
channel: foundChannel,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error updating channel cache:");
|
||||
logger.error(error);
|
||||
// Don't fail the request if cache update fails
|
||||
}
|
||||
}
|
||||
|
||||
return foundChannel;
|
||||
}
|
||||
}
|
||||
|
||||
cursor = (
|
||||
(response.jsonData as JSONObject)["response_metadata"] as JSONObject
|
||||
)?.["next_cursor"] as string;
|
||||
pageCount++;
|
||||
} while (cursor && pageCount < maxPages);
|
||||
|
||||
logger.debug("Channel not found:");
|
||||
return null;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static override getDividerBlock(): JSONObject {
|
||||
return {
|
||||
@@ -659,6 +870,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
public static override async doesChannelExist(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<boolean> {
|
||||
// if channel name starts with #, remove it
|
||||
if (data.channelName && data.channelName.startsWith("#")) {
|
||||
@@ -668,18 +880,15 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
// convert channel name to lowercase
|
||||
data.channelName = data.channelName.toLowerCase();
|
||||
|
||||
// get channel id from channel name
|
||||
const channels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
// Check if channel exists using optimized method
|
||||
const channel: WorkspaceChannel | null =
|
||||
await this.getWorkspaceChannelByName({
|
||||
authToken: data.authToken,
|
||||
channelName: data.channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
// if this channel exists
|
||||
if (channels[data.channelName]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return channel !== null;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -687,6 +896,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
workspaceMessagePayload: WorkspaceMessagePayload;
|
||||
authToken: string; // which auth token should we use to send.
|
||||
userId: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceSendMessageResponse> {
|
||||
logger.debug("Sending message to Slack with data:");
|
||||
logger.debug(data);
|
||||
@@ -698,27 +908,21 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
logger.debug("Blocks generated from workspace message payload:");
|
||||
logger.debug(blocks);
|
||||
|
||||
const existingWorkspaceChannels: Dictionary<WorkspaceChannel> =
|
||||
await this.getAllWorkspaceChannels({
|
||||
authToken: data.authToken,
|
||||
});
|
||||
|
||||
logger.debug("Existing workspace channels:");
|
||||
logger.debug(existingWorkspaceChannels);
|
||||
|
||||
const workspaceChannelsToPostTo: Array<WorkspaceChannel> = [];
|
||||
|
||||
// Resolve channel names efficiently
|
||||
for (let channelName of data.workspaceMessagePayload.channelNames) {
|
||||
if (channelName && channelName.startsWith("#")) {
|
||||
// trim # from channel name
|
||||
channelName = channelName.substring(1);
|
||||
}
|
||||
|
||||
let channel: WorkspaceChannel | null = null;
|
||||
|
||||
if (existingWorkspaceChannels[channelName]) {
|
||||
channel = existingWorkspaceChannels[channelName]!;
|
||||
}
|
||||
const channel: WorkspaceChannel | null =
|
||||
await this.getWorkspaceChannelByName({
|
||||
authToken: data.authToken,
|
||||
channelName: channelName,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
if (channel) {
|
||||
workspaceChannelsToPostTo.push(channel);
|
||||
@@ -879,6 +1083,7 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
public static override async createChannel(data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceChannel> {
|
||||
if (data.channelName && data.channelName.startsWith("#")) {
|
||||
data.channelName = data.channelName.substring(1);
|
||||
@@ -946,6 +1151,20 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
|
||||
logger.debug("Channel created successfully:");
|
||||
logger.debug(channel);
|
||||
|
||||
// Cache the created channel
|
||||
try {
|
||||
await this.updateChannelCache({
|
||||
projectId: data.projectId,
|
||||
channelName: data.channelName,
|
||||
channel: channel,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error caching created channel:");
|
||||
logger.error(error);
|
||||
// Don't fail the creation if caching fails
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export default class WorkspaceUtil {
|
||||
messagePayloadsByWorkspace: Array<WorkspaceMessagePayload>;
|
||||
}): Promise<Array<WorkspaceSendMessageResponse>> {
|
||||
logger.debug("postToWorkspaceChannels called with data:");
|
||||
logger.debug(data);
|
||||
logger.debug(JSON.stringify(data, null, 2));
|
||||
|
||||
const responses: Array<WorkspaceSendMessageResponse> = [];
|
||||
|
||||
@@ -194,6 +194,7 @@ export default class WorkspaceUtil {
|
||||
await WorkspaceUtil.getWorkspaceTypeUtil(workspaceType).sendMessage({
|
||||
userId: botUserId,
|
||||
authToken: projectAuthToken.authToken,
|
||||
projectId: data.projectId,
|
||||
workspaceMessagePayload: messagePayloadByWorkspace,
|
||||
});
|
||||
|
||||
@@ -202,7 +203,7 @@ export default class WorkspaceUtil {
|
||||
|
||||
logger.debug("Message posted to workspace channels successfully");
|
||||
logger.debug("Returning thread IDs");
|
||||
logger.debug(responses);
|
||||
logger.debug(JSON.stringify(responses, null, 2));
|
||||
|
||||
return responses;
|
||||
}
|
||||
@@ -213,6 +214,7 @@ export default class WorkspaceUtil {
|
||||
projectOrUserAuthTokenForWorkspace: string;
|
||||
workspaceType: WorkspaceType;
|
||||
workspaceMessagePayload: WorkspaceMessagePayload;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceSendMessageResponse> {
|
||||
logger.debug("postToWorkspaceChannels called with data:");
|
||||
logger.debug(data);
|
||||
@@ -222,6 +224,7 @@ export default class WorkspaceUtil {
|
||||
userId: data.workspaceUserId,
|
||||
workspaceMessagePayload: data.workspaceMessagePayload,
|
||||
authToken: data.projectOrUserAuthTokenForWorkspace,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
logger.debug("Message posted to workspace channels successfully");
|
||||
|
||||
@@ -3,6 +3,7 @@ import HTTPResponse from "../../../Types/API/HTTPResponse";
|
||||
import Dictionary from "../../../Types/Dictionary";
|
||||
import NotImplementedException from "../../../Types/Exception/NotImplementedException";
|
||||
import { JSONObject } from "../../../Types/JSON";
|
||||
import ObjectID from "../../../Types/ObjectID";
|
||||
import WorkspaceChannelInvitationPayload from "../../../Types/Workspace/WorkspaceChannelInvitationPayload";
|
||||
import WorkspaceMessagePayload, {
|
||||
WorkspaceCheckboxBlock,
|
||||
@@ -53,6 +54,7 @@ export default class WorkspaceBase {
|
||||
public static async doesChannelExist(_data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<boolean> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -63,6 +65,7 @@ export default class WorkspaceBase {
|
||||
authToken: string;
|
||||
userId: string;
|
||||
sendMessageBeforeArchiving: WorkspacePayloadMarkdown;
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -121,6 +124,7 @@ export default class WorkspaceBase {
|
||||
public static async inviteUsersToChannels(data: {
|
||||
authToken: string;
|
||||
workspaceChannelInvitationPayload: WorkspaceChannelInvitationPayload;
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
for (const channelName of data.workspaceChannelInvitationPayload
|
||||
.channelNames) {
|
||||
@@ -129,6 +133,7 @@ export default class WorkspaceBase {
|
||||
channelName: channelName,
|
||||
workspaceUserIds:
|
||||
data.workspaceChannelInvitationPayload.workspaceUserIds,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,12 +143,14 @@ export default class WorkspaceBase {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
workspaceUserIds: Array<string>;
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
for (const userId of data.workspaceUserIds) {
|
||||
await this.inviteUserToChannelByChannelName({
|
||||
authToken: data.authToken,
|
||||
channelName: data.channelName,
|
||||
workspaceUserId: userId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -153,6 +160,7 @@ export default class WorkspaceBase {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
workspaceUserId: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<void> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -175,6 +183,7 @@ export default class WorkspaceBase {
|
||||
public static async createChannelsIfDoesNotExist(_data: {
|
||||
authToken: string;
|
||||
channelNames: Array<string>;
|
||||
projectId: ObjectID;
|
||||
}): Promise<Array<WorkspaceChannel>> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -192,6 +201,7 @@ export default class WorkspaceBase {
|
||||
workspaceMessagePayload: WorkspaceMessagePayload;
|
||||
authToken: string; // which auth token should we use to send.
|
||||
userId: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceSendMessageResponse> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -207,6 +217,7 @@ export default class WorkspaceBase {
|
||||
public static async getWorkspaceChannelFromChannelName(_data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceChannel> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
@@ -215,6 +226,7 @@ export default class WorkspaceBase {
|
||||
public static async createChannel(_data: {
|
||||
authToken: string;
|
||||
channelName: string;
|
||||
projectId: ObjectID;
|
||||
}): Promise<WorkspaceChannel> {
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
import React from "react";
|
||||
import MarkdownEditor from "../../../UI/Components/Markdown.tsx/MarkdownEditor";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, test } from "@jest/globals";
|
||||
|
||||
describe("MarkdownEditor with SpellCheck", () => {
|
||||
describe("MarkdownEditor", () => {
|
||||
test("should render with toolbar buttons", () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
initialValue="This is a test"
|
||||
placeholder="Enter markdown here..."
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check for toolbar buttons
|
||||
expect(screen.getByTitle("Bold (Ctrl+B)")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Italic (Ctrl+I)")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Underline")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Strikethrough")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Heading 1")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Heading 2")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Heading 3")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Bullet List")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Numbered List")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Task List")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Link")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Image")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Table")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Code")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Quote")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("Horizontal Rule")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should toggle preview mode", () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
initialValue="**bold text**"
|
||||
placeholder="Enter markdown here..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const previewButton: HTMLElement = screen.getByText("Preview");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Should show preview
|
||||
expect(screen.getByText("Write")).toBeInTheDocument();
|
||||
|
||||
// Click to go back to write mode
|
||||
fireEvent.click(screen.getByText("Write"));
|
||||
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should enable spell check by default", () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
@@ -18,6 +64,21 @@ describe("MarkdownEditor with SpellCheck", () => {
|
||||
expect(textarea.spellcheck).toBe(true);
|
||||
});
|
||||
|
||||
test("should enable spell check when disableSpellCheck is undefined", () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
initialValue="This is a test with spelling errors"
|
||||
placeholder="Enter markdown here..."
|
||||
disableSpellCheck={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea: HTMLTextAreaElement = screen.getByRole(
|
||||
"textbox",
|
||||
) as HTMLTextAreaElement;
|
||||
expect(textarea.spellcheck).toBe(true);
|
||||
});
|
||||
|
||||
test("should disable spell check when disableSpellCheck is true", () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
@@ -58,4 +119,28 @@ describe("MarkdownEditor with SpellCheck", () => {
|
||||
textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.spellcheck).toBe(false);
|
||||
});
|
||||
|
||||
test("should show help text", () => {
|
||||
render(
|
||||
<MarkdownEditor initialValue="" placeholder="Enter markdown here..." />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Markdown help")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should handle onChange callback", () => {
|
||||
const mockOnChange: jest.Mock = jest.fn();
|
||||
render(
|
||||
<MarkdownEditor
|
||||
initialValue=""
|
||||
placeholder="Enter markdown here..."
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea: HTMLElement = screen.getByRole("textbox");
|
||||
fireEvent.change(textarea, { target: { value: "new text" } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("new text");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,12 +201,19 @@ export default class OneUptimeDate {
|
||||
return this.secondsToFormattedFriendlyTimeString(seconds);
|
||||
}
|
||||
|
||||
public static toTimeString(date: Date | string): string {
|
||||
public static toTimeString(
|
||||
date: Date | string,
|
||||
use12HourFormat?: boolean,
|
||||
): string {
|
||||
if (typeof date === "string") {
|
||||
date = this.fromString(date);
|
||||
}
|
||||
|
||||
return moment(date).format("HH:mm");
|
||||
const format: "hh:mm A" | "HH:mm" =
|
||||
use12HourFormat || this.getUserPrefers12HourFormat()
|
||||
? "hh:mm A"
|
||||
: "HH:mm";
|
||||
return moment(date).format(format);
|
||||
}
|
||||
|
||||
public static isSame(date1: Date, date2: Date): boolean {
|
||||
@@ -879,23 +886,72 @@ export default class OneUptimeDate {
|
||||
public static getCurrentDateAsFormattedString(options?: {
|
||||
onlyShowDate?: boolean;
|
||||
showSeconds?: boolean;
|
||||
use12HourFormat?: boolean;
|
||||
}): string {
|
||||
return this.getDateAsFormattedString(new Date(), options);
|
||||
}
|
||||
|
||||
public static getUserPrefers12HourFormat(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
// Server-side: default to 12-hour format for user-friendly display
|
||||
return true;
|
||||
}
|
||||
|
||||
// Client-side: detect user's preferred time format from browser locale
|
||||
const testDate: Date = new Date();
|
||||
const timeString: string = testDate.toLocaleTimeString();
|
||||
return (
|
||||
timeString.toLowerCase().includes("am") ||
|
||||
timeString.toLowerCase().includes("pm")
|
||||
);
|
||||
}
|
||||
|
||||
public static getDateAsUserFriendlyFormattedString(
|
||||
date: string | Date,
|
||||
options?: {
|
||||
onlyShowDate?: boolean;
|
||||
showSeconds?: boolean;
|
||||
},
|
||||
): string {
|
||||
return this.getDateAsFormattedString(date, {
|
||||
...options,
|
||||
use12HourFormat: this.getUserPrefers12HourFormat(),
|
||||
});
|
||||
}
|
||||
|
||||
public static getDateAsUserFriendlyLocalFormattedString(
|
||||
date: string | Date,
|
||||
onlyShowDate?: boolean,
|
||||
): string {
|
||||
return this.getDateAsLocalFormattedString(
|
||||
date,
|
||||
onlyShowDate,
|
||||
this.getUserPrefers12HourFormat(),
|
||||
);
|
||||
}
|
||||
|
||||
public static getDateAsFormattedString(
|
||||
date: string | Date,
|
||||
options?: {
|
||||
onlyShowDate?: boolean;
|
||||
showSeconds?: boolean;
|
||||
use12HourFormat?: boolean;
|
||||
},
|
||||
): string {
|
||||
date = this.fromString(date);
|
||||
|
||||
let formatstring: string = "MMM DD YYYY, HH:mm";
|
||||
|
||||
if (options?.use12HourFormat) {
|
||||
formatstring = "MMM DD YYYY, hh:mm A";
|
||||
}
|
||||
|
||||
if (options?.showSeconds) {
|
||||
formatstring = "MMM DD YYYY, HH:mm:ss";
|
||||
if (options?.use12HourFormat) {
|
||||
formatstring = "MMM DD YYYY, hh:mm:ss A";
|
||||
} else {
|
||||
formatstring = "MMM DD YYYY, HH:mm:ss";
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.onlyShowDate) {
|
||||
@@ -1061,15 +1117,22 @@ export default class OneUptimeDate {
|
||||
date: string | Date;
|
||||
onlyShowDate?: boolean | undefined;
|
||||
timezones?: Array<Timezone> | undefined;
|
||||
use12HourFormat?: boolean | undefined;
|
||||
}): Array<string> {
|
||||
let date: string | Date = data.date;
|
||||
const onlyShowDate: boolean | undefined = data.onlyShowDate;
|
||||
let timezones: Array<Timezone> | undefined = data.timezones;
|
||||
const use12HourFormat: boolean =
|
||||
data.use12HourFormat ?? this.getUserPrefers12HourFormat();
|
||||
|
||||
date = this.fromString(date);
|
||||
|
||||
let formatstring: string = "MMM DD YYYY, HH:mm";
|
||||
|
||||
if (use12HourFormat) {
|
||||
formatstring = "MMM DD YYYY, hh:mm A";
|
||||
}
|
||||
|
||||
if (onlyShowDate) {
|
||||
formatstring = "MMM DD, YYYY";
|
||||
}
|
||||
@@ -1106,15 +1169,18 @@ export default class OneUptimeDate {
|
||||
date: string | Date;
|
||||
onlyShowDate?: boolean;
|
||||
timezones?: Array<Timezone> | undefined; // if this is skipped, then it will show the default timezones in the order of UTC, EST, PST, IST, ACT
|
||||
use12HourFormat?: boolean | undefined;
|
||||
}): string {
|
||||
const date: string | Date = data.date;
|
||||
const onlyShowDate: boolean | undefined = data.onlyShowDate;
|
||||
const timezones: Array<Timezone> | undefined = data.timezones;
|
||||
const use12HourFormat: boolean | undefined = data.use12HourFormat;
|
||||
|
||||
return this.getDateAsFormattedArrayInMultipleTimezones({
|
||||
date,
|
||||
onlyShowDate,
|
||||
timezones,
|
||||
use12HourFormat,
|
||||
}).join("<br/>");
|
||||
}
|
||||
|
||||
@@ -1122,26 +1188,34 @@ export default class OneUptimeDate {
|
||||
date: string | Date;
|
||||
onlyShowDate?: boolean | undefined;
|
||||
timezones?: Array<Timezone> | undefined; // if this is skipped, then it will show the default timezones in the order of UTC, EST, PST, IST, ACT
|
||||
use12HourFormat?: boolean | undefined;
|
||||
}): string {
|
||||
const date: string | Date = data.date;
|
||||
const onlyShowDate: boolean | undefined = data.onlyShowDate;
|
||||
const timezones: Array<Timezone> | undefined = data.timezones;
|
||||
const use12HourFormat: boolean | undefined = data.use12HourFormat;
|
||||
|
||||
return this.getDateAsFormattedArrayInMultipleTimezones({
|
||||
date,
|
||||
onlyShowDate,
|
||||
timezones,
|
||||
use12HourFormat,
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
public static getDateAsLocalFormattedString(
|
||||
date: string | Date,
|
||||
onlyShowDate?: boolean,
|
||||
use12HourFormat?: boolean,
|
||||
): string {
|
||||
date = this.fromString(date);
|
||||
|
||||
let formatstring: string = "MMM DD YYYY, HH:mm";
|
||||
|
||||
if (use12HourFormat) {
|
||||
formatstring = "MMM DD YYYY, hh:mm A";
|
||||
}
|
||||
|
||||
if (onlyShowDate) {
|
||||
formatstring = "MMM DD, YYYY";
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -4,6 +4,8 @@ import URL from "./API/URL";
|
||||
import Dictionary from "./Dictionary";
|
||||
import HTML from "./Html";
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import type { Agent as HttpAgent } from "http";
|
||||
import type { Agent as HttpsAgent } from "https";
|
||||
|
||||
export interface WebsiteResponse {
|
||||
url: URL;
|
||||
@@ -22,6 +24,8 @@ export default class WebsiteRequest {
|
||||
timeout?: number | undefined;
|
||||
isHeadRequest?: boolean | undefined;
|
||||
doNotFollowRedirects?: boolean | undefined;
|
||||
httpAgent?: HttpAgent | undefined; // per-request HTTP proxy agent
|
||||
httpsAgent?: HttpsAgent | undefined; // per-request HTTPS proxy agent
|
||||
},
|
||||
): Promise<WebsiteResponse> {
|
||||
const axiosOptions: AxiosRequestConfig = {
|
||||
@@ -41,6 +45,13 @@ export default class WebsiteRequest {
|
||||
axiosOptions.maxRedirects = 0;
|
||||
}
|
||||
|
||||
if (options.httpAgent) {
|
||||
(axiosOptions as AxiosRequestConfig).httpAgent = options.httpAgent;
|
||||
}
|
||||
if (options.httpsAgent) {
|
||||
(axiosOptions as AxiosRequestConfig).httpsAgent = options.httpsAgent;
|
||||
}
|
||||
|
||||
// use axios to fetch an HTML page
|
||||
let response: AxiosResponse | null = null;
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ const DashboardStartAndEndDateView: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const getContent: GetReactElementFunction = (): ReactElement => {
|
||||
const title: string = isCustomRange
|
||||
? `${OneUptimeDate.getDateAsLocalFormattedString(
|
||||
? `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.dashboardStartAndEndDate.startAndEndDate?.startValue ||
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
)} - ${OneUptimeDate.getDateAsLocalFormattedString(
|
||||
)} - ${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.dashboardStartAndEndDate.startAndEndDate?.endValue ||
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
)}`
|
||||
|
||||
@@ -178,7 +178,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
||||
|
||||
if (field.fieldType === FieldType.Date) {
|
||||
if (data) {
|
||||
data = OneUptimeDate.getDateAsLocalFormattedString(
|
||||
data = OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
data as string,
|
||||
true,
|
||||
);
|
||||
@@ -197,7 +197,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
||||
|
||||
if (field.fieldType === FieldType.DateTime) {
|
||||
if (data) {
|
||||
data = OneUptimeDate.getDateAsLocalFormattedString(
|
||||
data = OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
data as string,
|
||||
false,
|
||||
);
|
||||
@@ -420,9 +420,8 @@ const Detail: DetailFunction = <T extends GenericObject>(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(data === null || data === undefined) && field.placeholder && (
|
||||
<PlaceholderText text={field.placeholder} />
|
||||
)}
|
||||
{(data === null || data === undefined || data === "") &&
|
||||
field.placeholder && <PlaceholderText text={field.placeholder} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,7 +55,10 @@ const EventHistoryDayList: FunctionComponent<ComponentProps> = (
|
||||
width: isMobile ? "100%" : "15%",
|
||||
}}
|
||||
>
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(props.date, true)}
|
||||
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.date,
|
||||
true,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -224,7 +224,7 @@ const EventItem: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm leading-8 text-gray-500 whitespace-nowrap">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
item.date,
|
||||
)}
|
||||
</span>
|
||||
@@ -269,7 +269,7 @@ const EventItem: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-500">
|
||||
posted on{" "}
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
item.date,
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -117,7 +117,7 @@ const FeedItem: FunctionComponent<ComponentProps> = (
|
||||
)}
|
||||
<div className="mt-0.5 text-sm text-gray-500 w-fit">
|
||||
<Tooltip
|
||||
text={OneUptimeDate.getDateAsLocalFormattedString(
|
||||
text={OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.itemDateTime,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -208,11 +208,11 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
|
||||
const shouldOnlyShowDate: boolean = data.filter.type === FieldType.Date;
|
||||
|
||||
if (
|
||||
OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
startAndEndDates.startValue as Date,
|
||||
shouldOnlyShowDate,
|
||||
) ===
|
||||
OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
startAndEndDates.endValue as Date,
|
||||
shouldOnlyShowDate,
|
||||
)
|
||||
@@ -222,7 +222,7 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
|
||||
{" "}
|
||||
<span className="font-medium">{data.filter.title}</span> at{" "}
|
||||
<span className="font-medium">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
startAndEndDates.startValue as Date,
|
||||
data.filter.type === FieldType.Date,
|
||||
)}
|
||||
@@ -235,14 +235,14 @@ const FilterComponent: FilterComponentFunction = <T extends GenericObject>(
|
||||
{" "}
|
||||
<span className="font-medium">{data.filter.title}</span> is in between{" "}
|
||||
<span className="font-medium">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
startAndEndDates.startValue as Date,
|
||||
shouldOnlyShowDate,
|
||||
)}
|
||||
</span>{" "}
|
||||
and{" "}
|
||||
<span className="font-medium">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
{OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
startAndEndDates.endValue as Date,
|
||||
shouldOnlyShowDate,
|
||||
)}
|
||||
|
||||
@@ -34,6 +34,7 @@ import React, { ReactElement, useEffect } from "react";
|
||||
import Radio, { RadioValue } from "../../Radio/Radio";
|
||||
import { BasicRadioButtonOption } from "../../RadioButtons/BasicRadioButtons";
|
||||
import HorizontalRule from "../../HorizontalRule/HorizontalRule";
|
||||
import MarkdownEditor from "../../Markdown.tsx/MarkdownEditor";
|
||||
|
||||
export interface ComponentProps<T extends GenericObject> {
|
||||
field: Field<T>;
|
||||
@@ -473,11 +474,10 @@ const FormField: <T extends GenericObject>(
|
||||
)}
|
||||
|
||||
{props.field.fieldType === FormFieldSchemaType.Markdown && (
|
||||
<CodeEditor
|
||||
<MarkdownEditor
|
||||
error={props.touched && props.error ? props.error : undefined}
|
||||
dataTestId={props.field.dataTestId}
|
||||
tabIndex={index}
|
||||
type={CodeType.Markdown}
|
||||
disableSpellCheck={props.field.disableSpellCheck}
|
||||
onChange={async (value: string) => {
|
||||
onChange(value);
|
||||
|
||||
@@ -115,6 +115,7 @@ export default interface Field<TEntity> {
|
||||
hideOptionalLabel?: boolean | undefined;
|
||||
|
||||
// Spell check configuration (primarily for Markdown and text fields)
|
||||
// Default: false (spell check enabled). Set to true to disable spell check.
|
||||
disableSpellCheck?: boolean | undefined;
|
||||
|
||||
getSummaryElement?: (item: FormValues<TEntity>) => ReactElement | undefined;
|
||||
|
||||
@@ -55,7 +55,7 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
|
||||
dayNumber,
|
||||
);
|
||||
|
||||
let toolTipText: string = `${OneUptimeDate.getDateAsLocalFormattedString(
|
||||
let toolTipText: string = `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
todaysDay,
|
||||
true,
|
||||
)}`;
|
||||
@@ -189,7 +189,7 @@ const DayUptimeGraph: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (todaysEvents.length === 1) {
|
||||
hasEvents = true;
|
||||
toolTipText = `${OneUptimeDate.getDateAsLocalFormattedString(
|
||||
toolTipText = `${OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
todaysDay,
|
||||
true,
|
||||
)} - 100% ${todaysEvents[0]?.label || "Operational"}.`;
|
||||
|
||||
@@ -14,9 +14,14 @@ import ReactFlow, {
|
||||
Position,
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import type { ElkExtendedEdge, ElkNode } from "elkjs";
|
||||
import type { ElkExtendedEdge, ElkNode, LayoutOptions } from "elkjs";
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
|
||||
// Minimal interface for the ELK layout engine we rely on.
|
||||
interface ElkLayoutEngine {
|
||||
layout: (graph: ElkNode) => Promise<ElkNode>;
|
||||
}
|
||||
|
||||
export interface ServiceNodeData {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -96,7 +101,7 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
|
||||
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
||||
|
||||
useEffect((): void => {
|
||||
const elk: any = new ELK();
|
||||
const elk: ElkLayoutEngine = new ELK() as unknown as ElkLayoutEngine;
|
||||
// fixed node dimensions for layout (px)
|
||||
const NODE_WIDTH: number = 220;
|
||||
const NODE_HEIGHT: number = 56;
|
||||
@@ -123,7 +128,7 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": "120",
|
||||
"elk.spacing.nodeNode": "60",
|
||||
"elk.edgeRouting": "POLYLINE",
|
||||
},
|
||||
} as LayoutOptions,
|
||||
children: sortedServices.map((svc: ServiceNodeData): ElkNode => {
|
||||
return {
|
||||
id: svc.id,
|
||||
@@ -142,9 +147,9 @@ const ServiceDependencyGraph: FunctionComponent<ServiceDependencyGraphProps> = (
|
||||
|
||||
const layout: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
const res: any = await elk.layout(elkGraph as any);
|
||||
const res: ElkNode = (await elk.layout(elkGraph)) as ElkNode; // casting to bundled ElkNode shape
|
||||
const placedNodes: Node[] = (res.children || []).map(
|
||||
(child: any): Node => {
|
||||
(child: ElkNode): Node => {
|
||||
const svc: ServiceNodeData | undefined = sortedServices.find(
|
||||
(s: ServiceNodeData): boolean => {
|
||||
return s.id === child.id;
|
||||
|
||||
@@ -64,7 +64,8 @@ const Input: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const [value, setValue] = useState<string | Date>("");
|
||||
const [displayValue, setDisplayValue] = useState<string>("");
|
||||
const ref: any = useRef<any>(null);
|
||||
const ref: React.MutableRefObject<HTMLInputElement | null> =
|
||||
useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -120,9 +121,9 @@ const Input: FunctionComponent<ComponentProps> = (
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const input: any = ref.current;
|
||||
const input: HTMLInputElement | null = ref.current;
|
||||
if (input) {
|
||||
(input as any).value = displayValue;
|
||||
input.value = displayValue;
|
||||
}
|
||||
}, [ref, displayValue]);
|
||||
|
||||
@@ -195,7 +196,7 @@ const Input: FunctionComponent<ComponentProps> = (
|
||||
tabIndex={props.tabIndex}
|
||||
onKeyDown={
|
||||
props.onEnterPress
|
||||
? (event: any) => {
|
||||
? (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
props.onEnterPress?.();
|
||||
}
|
||||
|
||||
@@ -144,7 +144,8 @@ const LogItem: FunctionComponent<ComponentProps> = (
|
||||
DATE TIME:
|
||||
</div>
|
||||
<div className="text-slate-500 courier-prime">
|
||||
{OneUptimeDate.getDateAsFormattedString(props.log.time)} {" "}
|
||||
{OneUptimeDate.getDateAsUserFriendlyFormattedString(props.log.time)}{" "}
|
||||
{" "}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import TextArea from "../TextArea/TextArea";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Icon from "../Icon/Icon";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
initialValue?: undefined | string;
|
||||
@@ -10,24 +17,656 @@ export interface ComponentProps {
|
||||
onBlur?: (() => void) | undefined;
|
||||
tabIndex?: number | undefined;
|
||||
error?: string | undefined;
|
||||
// Default: false (spell check enabled). Set to true to disable spell check.
|
||||
disableSpellCheck?: boolean | undefined;
|
||||
dataTestId?: string | undefined;
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: IconProp;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const ToolbarButton: FunctionComponent<ToolbarButtonProps> = ({
|
||||
icon,
|
||||
title,
|
||||
onClick,
|
||||
isActive = false,
|
||||
}: ToolbarButtonProps): ReactElement => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`p-2 rounded-md transition-colors duration-200 ${
|
||||
isActive
|
||||
? "bg-indigo-100 text-indigo-700"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
} focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2`}
|
||||
>
|
||||
<Icon icon={icon} className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownEditor: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [text, setText] = useState<string>(props.initialValue || "");
|
||||
const [showPreview, setShowPreview] = useState<boolean>(false);
|
||||
const textareaRef: React.RefObject<HTMLTextAreaElement> =
|
||||
useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.initialValue !== undefined) {
|
||||
setText(props.initialValue);
|
||||
}
|
||||
}, [props.initialValue]);
|
||||
|
||||
const handleChange: (value: string) => void = (value: string): void => {
|
||||
setText(value);
|
||||
if (props.onChange) {
|
||||
props.onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const insertText: (
|
||||
before: string,
|
||||
after?: string,
|
||||
placeholder?: string,
|
||||
) => void = (
|
||||
before: string,
|
||||
after: string = "",
|
||||
placeholder: string = "",
|
||||
): void => {
|
||||
const textarea: HTMLTextAreaElement | null = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start: number = textarea.selectionStart;
|
||||
const end: number = textarea.selectionEnd;
|
||||
const selectedText: string = text.substring(start, end);
|
||||
const textToInsert: string = selectedText || placeholder;
|
||||
|
||||
const newText: string =
|
||||
text.substring(0, start) +
|
||||
before +
|
||||
textToInsert +
|
||||
after +
|
||||
text.substring(end);
|
||||
|
||||
handleChange(newText);
|
||||
|
||||
// Set cursor position after insertion
|
||||
setTimeout(() => {
|
||||
if (selectedText) {
|
||||
textarea.setSelectionRange(
|
||||
start + before.length,
|
||||
start + before.length + textToInsert.length,
|
||||
);
|
||||
} else {
|
||||
textarea.setSelectionRange(
|
||||
start + before.length,
|
||||
start + before.length + placeholder.length,
|
||||
);
|
||||
}
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const insertAtLineStart: (prefix: string) => void = (
|
||||
prefix: string,
|
||||
): void => {
|
||||
const textarea: HTMLTextAreaElement | null = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start: number = textarea.selectionStart;
|
||||
const lineStart: number = text.lastIndexOf("\n", start - 1) + 1;
|
||||
const lineEnd: number = text.indexOf("\n", start);
|
||||
const actualLineEnd: number = lineEnd === -1 ? text.length : lineEnd;
|
||||
|
||||
const currentLine: string = text.substring(lineStart, actualLineEnd);
|
||||
|
||||
// Special handling for headings - replace existing heading levels
|
||||
if (prefix.startsWith("#")) {
|
||||
// Remove any existing heading markers
|
||||
const cleanLine: string = currentLine.replace(/^#+\s*/, "");
|
||||
|
||||
if (currentLine.startsWith(prefix)) {
|
||||
// Same heading level - remove it
|
||||
const newText: string =
|
||||
text.substring(0, lineStart) +
|
||||
cleanLine +
|
||||
text.substring(actualLineEnd);
|
||||
handleChange(newText);
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(
|
||||
start - prefix.length,
|
||||
start - prefix.length,
|
||||
);
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
// Different heading level or no heading - apply new heading
|
||||
const newText: string =
|
||||
text.substring(0, lineStart) +
|
||||
prefix +
|
||||
cleanLine +
|
||||
text.substring(actualLineEnd);
|
||||
handleChange(newText);
|
||||
setTimeout(() => {
|
||||
const adjustment: number =
|
||||
prefix.length - (currentLine.length - cleanLine.length);
|
||||
textarea.setSelectionRange(start + adjustment, start + adjustment);
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
}
|
||||
} else if (currentLine.startsWith(prefix)) {
|
||||
// Non-heading prefixes (lists, quotes) - remove prefix
|
||||
const newText: string =
|
||||
text.substring(0, lineStart) +
|
||||
currentLine.substring(prefix.length) +
|
||||
text.substring(actualLineEnd);
|
||||
handleChange(newText);
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(
|
||||
start - prefix.length,
|
||||
start - prefix.length,
|
||||
);
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
// Add prefix
|
||||
const newText: string =
|
||||
text.substring(0, lineStart) +
|
||||
prefix +
|
||||
currentLine +
|
||||
text.substring(actualLineEnd);
|
||||
handleChange(newText);
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(
|
||||
start + prefix.length,
|
||||
start + prefix.length,
|
||||
);
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const formatActions: {
|
||||
bold: () => void;
|
||||
italic: () => void;
|
||||
underline: () => void;
|
||||
strikethrough: () => void;
|
||||
heading1: () => void;
|
||||
heading2: () => void;
|
||||
heading3: () => void;
|
||||
unorderedList: () => void;
|
||||
orderedList: () => void;
|
||||
taskList: () => void;
|
||||
link: () => void;
|
||||
image: () => void;
|
||||
code: () => void;
|
||||
codeBlock: () => void;
|
||||
quote: () => void;
|
||||
horizontalRule: () => void;
|
||||
table: () => void;
|
||||
} = {
|
||||
bold: () => {
|
||||
return insertText("**", "**", "bold text");
|
||||
},
|
||||
italic: () => {
|
||||
return insertText("*", "*", "italic text");
|
||||
},
|
||||
underline: () => {
|
||||
return insertText("<u>", "</u>", "underlined text");
|
||||
},
|
||||
strikethrough: () => {
|
||||
return insertText("~~", "~~", "strikethrough text");
|
||||
},
|
||||
heading1: () => {
|
||||
return insertAtLineStart("# ");
|
||||
},
|
||||
heading2: () => {
|
||||
return insertAtLineStart("## ");
|
||||
},
|
||||
heading3: () => {
|
||||
return insertAtLineStart("### ");
|
||||
},
|
||||
unorderedList: () => {
|
||||
return insertAtLineStart("- ");
|
||||
},
|
||||
orderedList: () => {
|
||||
return insertAtLineStart("1. ");
|
||||
},
|
||||
taskList: () => {
|
||||
return insertAtLineStart("- [ ] ");
|
||||
},
|
||||
link: () => {
|
||||
return insertText("[", "](url)", "link text");
|
||||
},
|
||||
image: () => {
|
||||
return insertText("", "alt text");
|
||||
},
|
||||
code: () => {
|
||||
return insertText("`", "`", "code");
|
||||
},
|
||||
codeBlock: () => {
|
||||
return insertText("```\n", "\n```", "code block");
|
||||
},
|
||||
quote: () => {
|
||||
return insertAtLineStart("> ");
|
||||
},
|
||||
horizontalRule: () => {
|
||||
return insertText("\n---\n", "", "");
|
||||
},
|
||||
table: () => {
|
||||
return insertText(
|
||||
"\n| Header 1 | Header 2 | Header 3 |\n|----------|----------|----------|\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |\n",
|
||||
"",
|
||||
"",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
let className: string = "";
|
||||
if (!props.className) {
|
||||
className =
|
||||
"block w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-3 text-sm placeholder-gray-500 focus:border-indigo-500 focus:text-gray-900 focus:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 resize-none";
|
||||
} else {
|
||||
className = props.className;
|
||||
}
|
||||
|
||||
if (props.error) {
|
||||
className +=
|
||||
" border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500";
|
||||
}
|
||||
|
||||
const renderPreview: () => ReactElement = (): ReactElement => {
|
||||
// Enhanced markdown preview with proper code block handling
|
||||
let htmlContent: string = text;
|
||||
|
||||
// Handle code blocks first (before inline code)
|
||||
htmlContent = htmlContent.replace(
|
||||
/```([^`]*?)```/g,
|
||||
(_match: string, code: string) => {
|
||||
const escapedCode: string = code
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
return `<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4"><code class="text-sm font-mono whitespace-pre">${escapedCode}</code></pre>`;
|
||||
},
|
||||
);
|
||||
|
||||
// Handle inline code (after code blocks to avoid conflicts)
|
||||
htmlContent = htmlContent.replace(
|
||||
/`([^`]+)`/g,
|
||||
'<code class="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono">$1</code>',
|
||||
);
|
||||
|
||||
// Handle other markdown elements
|
||||
htmlContent = htmlContent
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/<u>(.*?)<\/u>/g, '<u class="underline">$1</u>')
|
||||
.replace(/~~(.*?)~~/g, '<s class="line-through">$1</s>')
|
||||
.replace(
|
||||
/^# (.*$)/gm,
|
||||
'<h1 class="text-3xl font-bold mb-4 mt-6 first:mt-0">$1</h1>',
|
||||
)
|
||||
.replace(
|
||||
/^## (.*$)/gm,
|
||||
'<h2 class="text-2xl font-bold mb-3 mt-5 first:mt-0">$1</h2>',
|
||||
)
|
||||
.replace(
|
||||
/^### (.*$)/gm,
|
||||
'<h3 class="text-xl font-bold mb-2 mt-4 first:mt-0">$1</h3>',
|
||||
)
|
||||
.replace(
|
||||
/^#### (.*$)/gm,
|
||||
'<h4 class="text-lg font-bold mb-2 mt-3 first:mt-0">$1</h4>',
|
||||
)
|
||||
.replace(
|
||||
/^- \[ \] (.*$)/gm,
|
||||
'<li class="ml-6 mb-1 flex items-center"><input type="checkbox" class="mr-2" disabled> $1</li>',
|
||||
)
|
||||
.replace(
|
||||
/^- \[x\] (.*$)/gm,
|
||||
'<li class="ml-6 mb-1 flex items-center"><input type="checkbox" class="mr-2" checked disabled> $1</li>',
|
||||
)
|
||||
.replace(
|
||||
/^- (.*$)/gm,
|
||||
'<li class="ml-6 mb-1 list-disc list-inside">$1</li>',
|
||||
)
|
||||
.replace(
|
||||
/^\d+\. (.*$)/gm,
|
||||
'<li class="ml-6 mb-1 list-decimal list-inside">$1</li>',
|
||||
)
|
||||
.replace(
|
||||
/^> (.*$)/gm,
|
||||
'<blockquote class="border-l-4 border-blue-400 pl-4 py-2 mb-4 bg-blue-50 italic text-gray-700">$1</blockquote>',
|
||||
)
|
||||
.replace(/^---$/gm, '<hr class="border-t-2 border-gray-300 my-6">')
|
||||
.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
'<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg shadow-sm my-4">',
|
||||
)
|
||||
.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" class="text-blue-600 hover:text-blue-800 underline font-medium" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
);
|
||||
|
||||
// Handle tables
|
||||
htmlContent = htmlContent.replace(
|
||||
/^\|(.+)\|\n\|(-+\|)+\n((?:\|.+\|\n?)*)/gm,
|
||||
(
|
||||
_match: string,
|
||||
headerRow: string,
|
||||
_separatorRow: string,
|
||||
bodyRows: string,
|
||||
) => {
|
||||
const headers: string = headerRow
|
||||
.split("|")
|
||||
.filter((cell: string) => {
|
||||
return cell.trim();
|
||||
})
|
||||
.map((cell: string) => {
|
||||
return `<th class="px-4 py-2 bg-gray-50 font-semibold text-left border-b border-gray-300">${cell.trim()}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const rows: string = bodyRows
|
||||
.split("\n")
|
||||
.filter((row: string) => {
|
||||
return row.trim();
|
||||
})
|
||||
.map((row: string) => {
|
||||
const cells: string = row
|
||||
.split("|")
|
||||
.filter((cell: string) => {
|
||||
return cell.trim();
|
||||
})
|
||||
.map((cell: string) => {
|
||||
return `<td class="px-4 py-2 border-b border-gray-200">${cell.trim()}</td>`;
|
||||
})
|
||||
.join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<table class="w-full border-collapse border border-gray-300 my-4 rounded-lg overflow-hidden"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
},
|
||||
);
|
||||
|
||||
// Handle line breaks (convert \n to <br> but avoid double breaks)
|
||||
htmlContent = htmlContent
|
||||
.replace(/\n\n/g, '</p><p class="mb-4">')
|
||||
.replace(/\n/g, "<br>");
|
||||
|
||||
// Wrap in paragraphs if there's content
|
||||
if (htmlContent.trim()) {
|
||||
htmlContent = `<p class="mb-4">${htmlContent}</p>`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 min-h-32 bg-white prose prose-sm max-w-none">
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
tabIndex={props.tabIndex}
|
||||
className={props.className}
|
||||
initialValue={props.initialValue || ""}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange ? props.onChange : () => {}}
|
||||
onFocus={props.onFocus ? props.onFocus : () => {}}
|
||||
onBlur={props.onBlur ? props.onBlur : () => {}}
|
||||
error={props.error}
|
||||
disableSpellCheck={props.disableSpellCheck}
|
||||
/>
|
||||
<div className="relative" data-testid={props.dataTestId}>
|
||||
{/* Toolbar */}
|
||||
<div className="p-2 bg-gray-50 border border-gray-300 rounded-t-md border-b-0">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{/* Text Formatting */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={IconProp.Bold}
|
||||
title="Bold (Ctrl+B)"
|
||||
onClick={formatActions.bold}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.Italic}
|
||||
title="Italic (Ctrl+I)"
|
||||
onClick={formatActions.italic}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.Underline}
|
||||
title="Underline"
|
||||
onClick={formatActions.underline}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.Minus}
|
||||
title="Strikethrough"
|
||||
onClick={formatActions.strikethrough}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300" />
|
||||
|
||||
{/* Headings */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatActions.heading1}
|
||||
title="Heading 1"
|
||||
className="px-2 py-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="text-sm font-bold">H1</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatActions.heading2}
|
||||
title="Heading 2"
|
||||
className="px-2 py-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="text-sm font-bold">H2</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatActions.heading3}
|
||||
title="Heading 3"
|
||||
className="px-2 py-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="text-sm font-bold">H3</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300" />
|
||||
|
||||
{/* Lists */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={IconProp.ListBullet}
|
||||
title="Bullet List"
|
||||
onClick={formatActions.unorderedList}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.List}
|
||||
title="Numbered List"
|
||||
onClick={formatActions.orderedList}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.Check}
|
||||
title="Task List"
|
||||
onClick={formatActions.taskList}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300" />
|
||||
|
||||
{/* Links and Media */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={IconProp.Link}
|
||||
title="Link"
|
||||
onClick={formatActions.link}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.Image}
|
||||
title="Image"
|
||||
onClick={formatActions.image}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={IconProp.Code}
|
||||
title="Code"
|
||||
onClick={formatActions.code}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300" />
|
||||
|
||||
{/* Advanced */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={IconProp.TableCells}
|
||||
title="Table"
|
||||
onClick={formatActions.table}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatActions.horizontalRule}
|
||||
title="Horizontal Rule"
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="font-bold text-sm">—</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatActions.quote}
|
||||
title="Quote"
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="font-bold text-sm">"</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatActions.codeBlock}
|
||||
title="Code Block"
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="font-mono text-xs font-bold">{"{}"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300" />
|
||||
|
||||
{/* Preview Toggle */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
return setShowPreview(!showPreview);
|
||||
}}
|
||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||||
showPreview
|
||||
? "bg-indigo-100 text-indigo-700"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
} focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2`}
|
||||
>
|
||||
{showPreview ? "Write" : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor/Preview Area */}
|
||||
<div className="relative">
|
||||
{showPreview ? (
|
||||
<div
|
||||
className={`min-h-32 border border-gray-300 bg-white rounded-b-md ${props.error ? "border-red-300" : ""}`}
|
||||
>
|
||||
{text.trim() ? (
|
||||
renderPreview()
|
||||
) : (
|
||||
<div className="p-3 text-gray-500 italic">Nothing to preview</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
autoFocus={false}
|
||||
placeholder={props.placeholder || "Type your markdown here..."}
|
||||
className={`${className} rounded-t-none min-h-32`}
|
||||
value={text}
|
||||
spellCheck={props.disableSpellCheck !== true}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
handleChange(e.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (props.onFocus) {
|
||||
props.onFocus();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (props.onBlur) {
|
||||
props.onBlur();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle keyboard shortcuts
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case "b":
|
||||
e.preventDefault();
|
||||
formatActions.bold();
|
||||
break;
|
||||
case "i":
|
||||
e.preventDefault();
|
||||
formatActions.italic();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
tabIndex={props.tabIndex}
|
||||
rows={6}
|
||||
/>
|
||||
{props.error && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<Icon
|
||||
icon={IconProp.ErrorSolid}
|
||||
className="h-5 w-5 text-red-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{props.error && (
|
||||
<p className="mt-1 text-sm text-red-400">{props.error}</p>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<details className="cursor-pointer">
|
||||
<summary className="hover:text-gray-700">Markdown help</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div>
|
||||
<strong>**bold**</strong> or <em>*italic*</em>
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-gray-100 px-1 rounded">`code`</code> or
|
||||
```code block```
|
||||
</div>
|
||||
<div># Heading 1, ## Heading 2, ### Heading 3</div>
|
||||
<div>- Bullet list or 1. Numbered list</div>
|
||||
<div>[Link text](url) or > Quote</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-none p-3">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// because tailwind does not supply <h1 ... /> styles https://tailwindcss.com/docs/preflight#headings-are-unstyled
|
||||
h1: ({ ...props }: any) => {
|
||||
return (
|
||||
<h1
|
||||
className="text-3xl mt-5 border border-gray-200 border-r-0 border-l-0 border-t-0 pb-1 mb-8 text-gray-800 font-medium"
|
||||
className="text-4xl mt-8 mb-6 border-b-2 border-blue-500 pb-2 text-gray-900 font-bold"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -29,7 +29,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
h2: ({ ...props }: any) => {
|
||||
return (
|
||||
<h2
|
||||
className="text-2xl mt-4 border border-gray-200 border-r-0 border-l-0 border-t-0 pb-1 mb-8 text-gray-800 font-medium"
|
||||
className="text-3xl mt-6 mb-4 border-b border-gray-300 pb-1 text-gray-900 font-semibold"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -37,7 +37,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
h3: ({ ...props }: any) => {
|
||||
return (
|
||||
<h3
|
||||
className="text-xl mt-12 mb-3 text-gray-800 font-medium"
|
||||
className="text-2xl mt-6 mb-3 text-gray-900 font-semibold"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -45,7 +45,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
h4: ({ ...props }: any) => {
|
||||
return (
|
||||
<h4
|
||||
className="text-lg mt-8 mb-3 text-gray-800 font-medium"
|
||||
className="text-xl mt-5 mb-3 text-gray-900 font-medium"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -53,7 +53,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
h5: ({ ...props }: any) => {
|
||||
return (
|
||||
<h5
|
||||
className="text-lg mt-5 mb-1 text-gray-800 font-medium"
|
||||
className="text-lg mt-4 mb-2 text-gray-900 font-medium"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -61,51 +61,103 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
h6: ({ ...props }: any) => {
|
||||
return (
|
||||
<h6
|
||||
className="text-base mt-3 text-gray-800 font-medium"
|
||||
className="text-base mt-3 mb-2 text-gray-900 font-medium"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
p: ({ ...props }: any) => {
|
||||
return <p className="text-sm mt-2 mb-3 text-gray-500" {...props} />;
|
||||
return <p className="text-base mt-3 mb-4 text-gray-700 leading-relaxed" {...props} />;
|
||||
},
|
||||
a: ({ ...props }: any) => {
|
||||
return (
|
||||
<a className="underline text-blue-500 font-medium" {...props} />
|
||||
<a className="underline text-blue-600 hover:text-blue-800 font-medium transition-colors" {...props} />
|
||||
);
|
||||
},
|
||||
|
||||
pre: ({ ...props }: any) => {
|
||||
pre: ({ children, ...rest }: any) => {
|
||||
// Avoid double borders when SyntaxHighlighter is already styling the block.
|
||||
const isSyntaxHighlighter: boolean =
|
||||
React.isValidElement(children) &&
|
||||
// name can be 'SyntaxHighlighter' or wrapped/minified; fall back to presence of 'children' prop with 'react-syntax-highlighter' data attribute.
|
||||
(((children as any).type &&
|
||||
((children as any).type.name === "SyntaxHighlighter" ||
|
||||
(children as any).type.displayName === "SyntaxHighlighter")) ||
|
||||
(children as any).props?.className?.includes("syntax-highlighter"));
|
||||
|
||||
const baseClass: string = isSyntaxHighlighter
|
||||
? "mt-4 mb-4 rounded-lg overflow-hidden"
|
||||
: "bg-gray-900 text-gray-100 mt-4 mb-4 p-4 rounded-lg text-sm overflow-x-auto border border-gray-700";
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="text-gray-600 mt-4 mb-2 rounded text-sm text-sm overflow-x-auto "
|
||||
{...props}
|
||||
/>
|
||||
<pre className={baseClass} {...rest}>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
strong: ({ ...props }: any) => {
|
||||
return (
|
||||
<strong
|
||||
className="text-sm mt-2 text-gray-900 font-medium"
|
||||
className="text-base font-semibold text-gray-900"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
li: ({ ...props }: any) => {
|
||||
return (
|
||||
<li className="text-sm mt-2 text-gray-500 list-disc" {...props} />
|
||||
<li className="text-base mt-2 mb-1 text-gray-700 leading-relaxed" {...props} />
|
||||
);
|
||||
},
|
||||
ul: ({ ...props }: any) => {
|
||||
return <ul className="list-disc px-6 m-1" {...props} />;
|
||||
return <ul className="list-disc pl-8 mt-2 mb-4" {...props} />;
|
||||
},
|
||||
ol: ({ ...props }: any) => {
|
||||
return <ol className="list-decimal pl-8 mt-2 mb-4" {...props} />;
|
||||
},
|
||||
blockquote: ({ ...props }: any) => {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600 bg-gray-50 py-2 my-4" {...props} />
|
||||
);
|
||||
},
|
||||
table: ({ ...props }: any) => {
|
||||
return (
|
||||
<table className="min-w-full table-auto border-collapse border border-gray-300 mt-4 mb-4" {...props} />
|
||||
);
|
||||
},
|
||||
thead: ({ ...props }: any) => {
|
||||
return (
|
||||
<thead className="bg-gray-100" {...props} />
|
||||
);
|
||||
},
|
||||
tbody: ({ ...props }: any) => {
|
||||
return (
|
||||
<tbody {...props} />
|
||||
);
|
||||
},
|
||||
tr: ({ ...props }: any) => {
|
||||
return (
|
||||
<tr className="border-b border-gray-200" {...props} />
|
||||
);
|
||||
},
|
||||
th: ({ ...props }: any) => {
|
||||
return (
|
||||
<th className="px-4 py-2 text-left text-sm font-semibold text-gray-900 border border-gray-300" {...props} />
|
||||
);
|
||||
},
|
||||
td: ({ ...props }: any) => {
|
||||
return (
|
||||
<td className="px-4 py-2 text-sm text-gray-700 border border-gray-300" {...props} />
|
||||
);
|
||||
},
|
||||
hr: ({ ...props }: any) => {
|
||||
return <hr className="border-gray-300 my-6" {...props} />;
|
||||
},
|
||||
code: (props: any) => {
|
||||
const { children, className, ...rest } = props;
|
||||
|
||||
// eslint-disable-next-line wrap-regex
|
||||
const match: RegExpExecArray | null = /language-(\w+)/.exec(
|
||||
className || "",
|
||||
);
|
||||
const match: RegExpExecArray | null = new RegExp(
|
||||
"language-(\\w+)",
|
||||
).exec(className || "");
|
||||
|
||||
const content: string = String(children as string).replace(
|
||||
/\n$/,
|
||||
@@ -119,7 +171,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
return item.includes("language-");
|
||||
}).length > 0)
|
||||
? ""
|
||||
: "text-sm p-1 bg-gray-100 rounded text-gray-900 pl-2 pr-2 text-xs";
|
||||
: "text-sm px-2 py-1 bg-gray-200 rounded text-gray-900 font-mono";
|
||||
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
@@ -129,6 +181,8 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
|
||||
children={content}
|
||||
language={match[1]}
|
||||
style={a11yDark}
|
||||
className="rounded-lg mt-4 mb-4 !bg-gray-900 !p-4 text-sm"
|
||||
codeTagProps={{ className: "font-mono" }}
|
||||
/>
|
||||
) : (
|
||||
<code className={codeClassName} {...rest}>
|
||||
|
||||
@@ -192,7 +192,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
column.key && !column.getElement ? (
|
||||
column.type === FieldType.Date ? (
|
||||
props.item[column.key] ? (
|
||||
OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.item[column.key] as string,
|
||||
true,
|
||||
)
|
||||
@@ -201,7 +201,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
)
|
||||
) : column.type === FieldType.DateTime ? (
|
||||
props.item[column.key] ? (
|
||||
OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.item[column.key] as string,
|
||||
false,
|
||||
)
|
||||
@@ -367,7 +367,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
{column.key && !column.getElement ? (
|
||||
column.type === FieldType.Date ? (
|
||||
props.item[column.key] ? (
|
||||
OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.item[column.key] as string,
|
||||
true,
|
||||
)
|
||||
@@ -376,7 +376,7 @@ const TableRow: TableRowFunction = <T extends GenericObject>(
|
||||
)
|
||||
) : column.type === FieldType.DateTime ? (
|
||||
props.item[column.key] ? (
|
||||
OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
|
||||
props.item[column.key] as string,
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,9 @@ export interface ComponentProps {
|
||||
const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const formRef: any = useRef<FormProps<FormValues<JSONObject>>>(null);
|
||||
const formRef: React.MutableRefObject<FormProps<
|
||||
FormValues<JSONObject>
|
||||
> | null> = useRef<FormProps<FormValues<JSONObject>> | null>(null);
|
||||
const [component, setComponent] = useState<NodeDataProp>(props.component);
|
||||
const [showVariableModal, setShowVariableModal] = useState<boolean>(false);
|
||||
const [showComponentPickerModal, setShowComponentPickerModal] =
|
||||
@@ -143,7 +145,7 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
onSave={(variableId: string) => {
|
||||
setShowVariableModal(false);
|
||||
formRef.current.setFieldValue(
|
||||
formRef.current?.setFieldValue(
|
||||
selectedArgId,
|
||||
(component.arguments && component.arguments[selectedArgId]
|
||||
? component.arguments[selectedArgId]
|
||||
@@ -161,7 +163,7 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
onSave={(returnValuePath: string) => {
|
||||
setShowComponentPickerModal(false);
|
||||
formRef.current.setFieldValue(
|
||||
formRef.current?.setFieldValue(
|
||||
selectedArgId,
|
||||
(component.arguments && component.arguments[selectedArgId]
|
||||
? component.arguments[selectedArgId]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DropdownOption } from "../Dropdown/Dropdown";
|
||||
import FormFieldSchemaType from "../Forms/Types/FormFieldSchemaType";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
import Typeof from "../../../Types/Typeof";
|
||||
import ComponentMetadata, {
|
||||
ComponentCategory,
|
||||
ComponentInputType,
|
||||
@@ -44,7 +43,7 @@ export const loadComponentsAndCategories: LoadComponentsAndCategoriesFunction =
|
||||
|
||||
type ComponentInputTypeToFormFieldTypeFunction = (
|
||||
componentInputType: ComponentInputType,
|
||||
argValue: any,
|
||||
argValue: unknown,
|
||||
) => {
|
||||
fieldType: FormFieldSchemaType;
|
||||
dropdownOptions?: Array<DropdownOption> | undefined;
|
||||
@@ -53,7 +52,7 @@ type ComponentInputTypeToFormFieldTypeFunction = (
|
||||
export const componentInputTypeToFormFieldType: ComponentInputTypeToFormFieldTypeFunction =
|
||||
(
|
||||
componentInputType: ComponentInputType,
|
||||
argValue: any,
|
||||
argValue: unknown,
|
||||
): {
|
||||
fieldType: FormFieldSchemaType;
|
||||
dropdownOptions?: Array<DropdownOption> | undefined;
|
||||
@@ -122,11 +121,7 @@ export const componentInputTypeToFormFieldType: ComponentInputTypeToFormFieldTyp
|
||||
|
||||
// Second priority.
|
||||
|
||||
if (
|
||||
argValue &&
|
||||
typeof argValue === Typeof.String &&
|
||||
argValue.toString().includes("{{")
|
||||
) {
|
||||
if (typeof argValue === "string" && argValue.includes("{{")) {
|
||||
return {
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
dropdownOptions: [],
|
||||
|
||||
@@ -91,6 +91,10 @@ class BaseAPI extends API {
|
||||
return defaultHeaders;
|
||||
}
|
||||
|
||||
protected static logoutUser(): void {
|
||||
return User.logout();
|
||||
}
|
||||
|
||||
protected static override handleError(
|
||||
error: HTTPErrorResponse | APIException,
|
||||
): HTTPErrorResponse | APIException {
|
||||
@@ -103,7 +107,7 @@ class BaseAPI extends API {
|
||||
) {
|
||||
const loginRoute: Route = this.getLoginRoute();
|
||||
|
||||
User.logout();
|
||||
this.logoutUser();
|
||||
|
||||
if (Navigation.getQueryStringByName("token")) {
|
||||
Navigation.navigate(loginRoute.addRouteParam("sso", "true"), {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user