mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
546 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd37b8a05e | ||
|
|
23f5ffc840 | ||
|
|
875dbccad3 | ||
|
|
fb8fa899b0 | ||
|
|
4bad603db2 | ||
|
|
720399c8b8 | ||
|
|
37e4f28e57 | ||
|
|
0502eb5ebe | ||
|
|
191569eb3d | ||
|
|
2770f9a515 | ||
|
|
788eeae500 | ||
|
|
a8497c497c | ||
|
|
b7a4214fa4 | ||
|
|
5e9034dd76 | ||
|
|
26bcc69fa2 | ||
|
|
577d8d2fba | ||
|
|
2b9aaa9929 | ||
|
|
cf166da6de | ||
|
|
92a48f1e17 | ||
|
|
f0d0d81a9b | ||
|
|
a2dc9bf1c8 | ||
|
|
263d745d0a | ||
|
|
d108cd484e | ||
|
|
148813786a | ||
|
|
8101f4a459 | ||
|
|
46a698b4be | ||
|
|
8d07271aa1 | ||
|
|
f5ef80e544 | ||
|
|
292a37397d | ||
|
|
abb3942c44 | ||
|
|
10d09ac4af | ||
|
|
64c31e9e7a | ||
|
|
d64194c18e | ||
|
|
2d13a52287 | ||
|
|
a54234609f | ||
|
|
214c9e013c | ||
|
|
b0c9de4d82 | ||
|
|
e98b424168 | ||
|
|
7521fe218d | ||
|
|
1f3d85d7a1 | ||
|
|
058c52f79d | ||
|
|
8af6e48d70 | ||
|
|
7569a50c56 | ||
|
|
20f314512d | ||
|
|
cdbbcdfe27 | ||
|
|
4e2ca87752 | ||
|
|
54a79a8100 | ||
|
|
eb4010dfa5 | ||
|
|
407d4e3687 | ||
|
|
6f7907102b | ||
|
|
5f398bdb31 | ||
|
|
69c6b332c1 | ||
|
|
e15a934b3f | ||
|
|
3a62729c03 | ||
|
|
23da31b50c | ||
|
|
4e33cd7c1b | ||
|
|
d97f17b1cf | ||
|
|
4bdf9943e4 | ||
|
|
a4c5be8665 | ||
|
|
ea71c8bd75 | ||
|
|
043707d0cb | ||
|
|
991916b2de | ||
|
|
5d3885c8a5 | ||
|
|
da44cd34f8 | ||
|
|
ffa2d3f008 | ||
|
|
d8aea2627b | ||
|
|
9756f5a117 | ||
|
|
c8cd97437e | ||
|
|
249241dfd4 | ||
|
|
16e2c2cb39 | ||
|
|
ecbca3208f | ||
|
|
505c143ddf | ||
|
|
c4aab31056 | ||
|
|
cdb63031d8 | ||
|
|
464455eff3 | ||
|
|
c7cfd7aa67 | ||
|
|
832b87e6d5 | ||
|
|
678e9614bf | ||
|
|
ac6c53ad85 | ||
|
|
22bf4de6fd | ||
|
|
dacf71a75d | ||
|
|
213c755f97 | ||
|
|
ac39602ef6 | ||
|
|
848fd2c30b | ||
|
|
63dd84339e | ||
|
|
e3ca08c69f | ||
|
|
3276ab3641 | ||
|
|
675cfa4682 | ||
|
|
f28306ce68 | ||
|
|
9b9ac62c77 | ||
|
|
574cac7d64 | ||
|
|
414f7cebc7 | ||
|
|
e30f2587e8 | ||
|
|
d7a339b9aa | ||
|
|
fe5329a1aa | ||
|
|
043ddebc6c | ||
|
|
67b9d245ec | ||
|
|
856e1f4715 | ||
|
|
72da710326 | ||
|
|
9fc6871a1f | ||
|
|
7add10642f | ||
|
|
34b6c198cb | ||
|
|
3dda45d2cc | ||
|
|
2fd7ede52f | ||
|
|
599e8dda1d | ||
|
|
21062dab44 | ||
|
|
3477593e11 | ||
|
|
d8ec86adb3 | ||
|
|
64f21ac8b1 | ||
|
|
e953b33703 | ||
|
|
ffafada55b | ||
|
|
4caed413a3 | ||
|
|
594c5a7fc3 | ||
|
|
2845177743 | ||
|
|
75b2d63353 | ||
|
|
b5a5cf8b40 | ||
|
|
cc68ea4539 | ||
|
|
02c0c02760 | ||
|
|
ae230589c5 | ||
|
|
a0577b0175 | ||
|
|
472ebed3be | ||
|
|
796c52da4d | ||
|
|
3a19e600d5 | ||
|
|
b847d3a0b9 | ||
|
|
9f09eacf25 | ||
|
|
809a85c91d | ||
|
|
38ff1ae0c7 | ||
|
|
194bb87b45 | ||
|
|
26c402928e | ||
|
|
e0fe6e9827 | ||
|
|
0269593326 | ||
|
|
13d33b6df3 | ||
|
|
2c7a560aee | ||
|
|
b8e0f0de91 | ||
|
|
27ad3d6b99 | ||
|
|
e655385c4d | ||
|
|
9adbd04538 | ||
|
|
6ef8cc6db6 | ||
|
|
1c12f516ff | ||
|
|
9e9c7743f4 | ||
|
|
5bfd6ebd3d | ||
|
|
78d608a6cf | ||
|
|
5155858f67 | ||
|
|
862682388e | ||
|
|
308bade79e | ||
|
|
a41dfa8980 | ||
|
|
1a8fee15b8 | ||
|
|
7e4efeaeaa | ||
|
|
2d007b8676 | ||
|
|
0ba3a70a4b | ||
|
|
8672f442db | ||
|
|
e0f1da768b | ||
|
|
71b8891232 | ||
|
|
a48e8a2710 | ||
|
|
465cc798ec | ||
|
|
0130a850ca | ||
|
|
526eb756b1 | ||
|
|
59a9636870 | ||
|
|
a994c7b7b8 | ||
|
|
dc44e92867 | ||
|
|
4a0151243f | ||
|
|
e06b9a95ce | ||
|
|
3fd22cd3fb | ||
|
|
3c8dc1eee1 | ||
|
|
c91c653d9c | ||
|
|
086f01617c | ||
|
|
1d78ec8922 | ||
|
|
5ecf8ce881 | ||
|
|
147ff47aa2 | ||
|
|
a1122ed241 | ||
|
|
72a796c03d | ||
|
|
bec1c760ca | ||
|
|
b939b4ebf0 | ||
|
|
50717e5167 | ||
|
|
4b339f07ec | ||
|
|
e9be1c0898 | ||
|
|
b4dc6f1f02 | ||
|
|
ad6ac1a480 | ||
|
|
af3004394e | ||
|
|
028212731f | ||
|
|
7419ff4437 | ||
|
|
5b579fa55c | ||
|
|
f0ed6ae29f | ||
|
|
16e1d5ccf3 | ||
|
|
98efe09cc1 | ||
|
|
b1c2fdc33f | ||
|
|
d9c7259356 | ||
|
|
b5bf1d6dd1 | ||
|
|
c92e259978 | ||
|
|
5601cc5591 | ||
|
|
c0fe56f54d | ||
|
|
d316c1dceb | ||
|
|
7cb70278d5 | ||
|
|
f4e9bfbca1 | ||
|
|
4be33e6a0a | ||
|
|
2da9ea272f | ||
|
|
f4fb951757 | ||
|
|
969d435447 | ||
|
|
0edc50ae9f | ||
|
|
87e34b0abf | ||
|
|
1caff6844e | ||
|
|
2c12fcaa0f | ||
|
|
a62ba231be | ||
|
|
46c150f6df | ||
|
|
9f8891de88 | ||
|
|
8e671a9a41 | ||
|
|
02e7506f89 | ||
|
|
7fac485049 | ||
|
|
467921e899 | ||
|
|
31e1290ecb | ||
|
|
4cc78175ef | ||
|
|
feb86357e8 | ||
|
|
fb3c767056 | ||
|
|
be90693ad8 | ||
|
|
d0ef353993 | ||
|
|
f2c0b8461f | ||
|
|
4fe8aea655 | ||
|
|
923e020e6e | ||
|
|
178fa45dd8 | ||
|
|
19b5bc8348 | ||
|
|
7f30600c71 | ||
|
|
93291858f9 | ||
|
|
14a925e98c | ||
|
|
d5a136a662 | ||
|
|
e0ae2701ba | ||
|
|
fa68e3961e | ||
|
|
2e9118e123 | ||
|
|
f2f53b7cea | ||
|
|
5cb48400a2 | ||
|
|
006e54535a | ||
|
|
845b45cceb | ||
|
|
208e8b1fdb | ||
|
|
d03056be2d | ||
|
|
5a6193c4e0 | ||
|
|
4e30a863b2 | ||
|
|
2561117445 | ||
|
|
27e65caef2 | ||
|
|
0eb096ca8f | ||
|
|
25cde457a5 | ||
|
|
cd36071311 | ||
|
|
b4ce1e0c55 | ||
|
|
fc582bc547 | ||
|
|
5a0cf0f988 | ||
|
|
d283be898f | ||
|
|
d2385a83cf | ||
|
|
6b7b27be00 | ||
|
|
a1aceec9ec | ||
|
|
a8988346f7 | ||
|
|
d3865d94a6 | ||
|
|
c470d66725 | ||
|
|
e12e3cfc08 | ||
|
|
24db673926 | ||
|
|
3a65405401 | ||
|
|
caf533d0c0 | ||
|
|
323d9993bf | ||
|
|
cbcda7a36f | ||
|
|
b434f1fef8 | ||
|
|
20d21a40b7 | ||
|
|
633ffde611 | ||
|
|
fe668d808d | ||
|
|
c10cefb4e1 | ||
|
|
f93964b71b | ||
|
|
66a43d322c | ||
|
|
6c4d283761 | ||
|
|
7695c08d1a | ||
|
|
7d39a36526 | ||
|
|
9806425721 | ||
|
|
29c2bbbf57 | ||
|
|
296dc9c81e | ||
|
|
758b6c0b5b | ||
|
|
8ed94c0172 | ||
|
|
660d4cb155 | ||
|
|
442622c2be | ||
|
|
6d15cc8e8b | ||
|
|
61ea40a23a | ||
|
|
910d7d0066 | ||
|
|
5656ad2e62 | ||
|
|
e383a32e6e | ||
|
|
da5cc1877d | ||
|
|
d421caff2e | ||
|
|
4f8b4593a7 | ||
|
|
16c6776675 | ||
|
|
4d7bbb323e | ||
|
|
fa771f73f5 | ||
|
|
d62da19308 | ||
|
|
d47b43f7b2 | ||
|
|
01d2b7d0a3 | ||
|
|
582b464623 | ||
|
|
f2d138d0d7 | ||
|
|
70e6924cdd | ||
|
|
81eb16c1b7 | ||
|
|
c6534fb515 | ||
|
|
ac3d169eef | ||
|
|
a854db7564 | ||
|
|
e54c3effd1 | ||
|
|
2278843667 | ||
|
|
3d0c9ff1be | ||
|
|
2ef7988598 | ||
|
|
f97c753a72 | ||
|
|
4f67228eaf | ||
|
|
b10d2f458e | ||
|
|
8a54e2beac | ||
|
|
a0f5a5bc5a | ||
|
|
cef15e5938 | ||
|
|
ff0a2e9c91 | ||
|
|
db1ce405f5 | ||
|
|
b721c1ba80 | ||
|
|
7e98e6d7ae | ||
|
|
fb8126d5d6 | ||
|
|
17e786f88e | ||
|
|
1d186c2f49 | ||
|
|
537ac1eb2e | ||
|
|
6fba944b11 | ||
|
|
a4787121b3 | ||
|
|
2aab01bde6 | ||
|
|
e8e4ee3ff0 | ||
|
|
8b0926413e | ||
|
|
3efacce002 | ||
|
|
f84df20610 | ||
|
|
36041cef6a | ||
|
|
e814027048 | ||
|
|
0161bac994 | ||
|
|
dc3db1ec47 | ||
|
|
139aa83fe4 | ||
|
|
50d5514fea | ||
|
|
220cfa2d28 | ||
|
|
10d0237747 | ||
|
|
7708d791b1 | ||
|
|
6eb7b98002 | ||
|
|
6860033586 | ||
|
|
27b94fdbaf | ||
|
|
c4903e5d1c | ||
|
|
a2c8022442 | ||
|
|
7cc6e81fe6 | ||
|
|
758aab5f17 | ||
|
|
6af7f24d1b | ||
|
|
3ddd5658a1 | ||
|
|
5a87333275 | ||
|
|
5b5b0e8d54 | ||
|
|
a5d74ae76a | ||
|
|
0e0829bdd7 | ||
|
|
e8fe9a0f0d | ||
|
|
deb873c832 | ||
|
|
305fa4a476 | ||
|
|
65a4132081 | ||
|
|
5e15bf1bdc | ||
|
|
4375e1c8fd | ||
|
|
2f76fd3bcd | ||
|
|
38e617432f | ||
|
|
bfbe3fe050 | ||
|
|
c3ffc681bd | ||
|
|
f9b22fa0cd | ||
|
|
34d1f0a04c | ||
|
|
e0b530a323 | ||
|
|
17839a819f | ||
|
|
0446f55a9c | ||
|
|
ad999313c3 | ||
|
|
ffc49d83eb | ||
|
|
3df5640463 | ||
|
|
e4a76117b1 | ||
|
|
4cba330605 | ||
|
|
a8d1c90b33 | ||
|
|
3c2847ed10 | ||
|
|
ef7ce703dd | ||
|
|
2d56a56650 | ||
|
|
a2ac43baab | ||
|
|
a26532538a | ||
|
|
1bc82ef3b9 | ||
|
|
7b978f1885 | ||
|
|
e6861a2abe | ||
|
|
6d98893b8e | ||
|
|
6026f5cb81 | ||
|
|
103a49facc | ||
|
|
5d13911026 | ||
|
|
d873306b33 | ||
|
|
af7a3a9286 | ||
|
|
bc9949abe4 | ||
|
|
9049533338 | ||
|
|
da6c749d96 | ||
|
|
b0ab4ef199 | ||
|
|
85243e6b56 | ||
|
|
ddf2df4206 | ||
|
|
80d4ccbd7d | ||
|
|
3a1e75515c | ||
|
|
d097738f13 | ||
|
|
b6fb2d761e | ||
|
|
57974f0895 | ||
|
|
2adc399d74 | ||
|
|
bcb1e92cab | ||
|
|
7984e5d1ab | ||
|
|
9b380d424d | ||
|
|
7741bebe31 | ||
|
|
39a4c7e8f1 | ||
|
|
fe76e946c0 | ||
|
|
b16c997f20 | ||
|
|
66e5f43c5d | ||
|
|
5cf660a44e | ||
|
|
a7c9618a64 | ||
|
|
a02018aeb2 | ||
|
|
9f1389ce87 | ||
|
|
11e5c2778d | ||
|
|
9619090b98 | ||
|
|
eacf106d10 | ||
|
|
92c00e4fc8 | ||
|
|
da3bbca1bb | ||
|
|
5f52af2aa8 | ||
|
|
d08d6fde48 | ||
|
|
82fd2ffef6 | ||
|
|
b0c07038b5 | ||
|
|
777e9612a4 | ||
|
|
7ff5d9dba6 | ||
|
|
8b97807991 | ||
|
|
dff3c50a97 | ||
|
|
d00d7cb19f | ||
|
|
8cb25d9917 | ||
|
|
ea58fbdc0d | ||
|
|
5f660bae02 | ||
|
|
d327edb165 | ||
|
|
de7990c41e | ||
|
|
8f9e5a46fa | ||
|
|
046482a2a8 | ||
|
|
3568d766ea | ||
|
|
41a8287975 | ||
|
|
6629cc9023 | ||
|
|
85d927f291 | ||
|
|
1edda202be | ||
|
|
9a47b02a0c | ||
|
|
3ccd089d4f | ||
|
|
2c7486714f | ||
|
|
4781c6a532 | ||
|
|
1a58481265 | ||
|
|
58024398cf | ||
|
|
dde1e89c34 | ||
|
|
4b89add3b8 | ||
|
|
c7b8d13b49 | ||
|
|
7622367d5d | ||
|
|
916666fba5 | ||
|
|
8683e7a880 | ||
|
|
1e8f8da91d | ||
|
|
564f69d91a | ||
|
|
7da10c48a8 | ||
|
|
59904cb843 | ||
|
|
43c7954dbe | ||
|
|
9c361b4150 | ||
|
|
6dfc364cea | ||
|
|
bb48f530bc | ||
|
|
8c2931b8c9 | ||
|
|
202b8b3845 | ||
|
|
53a77ed47a | ||
|
|
34dffaa710 | ||
|
|
d8bbe4b2cd | ||
|
|
0b922f4dbf | ||
|
|
526cc21b9c | ||
|
|
d7ac8dbb1e | ||
|
|
b4fecdad21 | ||
|
|
df613292df | ||
|
|
7088e8073f | ||
|
|
f3fc6904bd | ||
|
|
af79613637 | ||
|
|
b7289c918c | ||
|
|
1b65ca934e | ||
|
|
d968666535 | ||
|
|
aaa5414248 | ||
|
|
60ae745fb1 | ||
|
|
2081f9030a | ||
|
|
735024216b | ||
|
|
4f84966326 | ||
|
|
bcac918edd | ||
|
|
b64c66a7c2 | ||
|
|
2d88e4fe0e | ||
|
|
ec0c9c8c56 | ||
|
|
71fb8e7bc9 | ||
|
|
cae34e080e | ||
|
|
0282ac3977 | ||
|
|
df35775f3f | ||
|
|
da26755cbf | ||
|
|
48cdfee319 | ||
|
|
0e93929a3f | ||
|
|
fd1ee0c248 | ||
|
|
8e3e6769ed | ||
|
|
417ae4473e | ||
|
|
810fcd4740 | ||
|
|
374de9cf82 | ||
|
|
b9eff113ac | ||
|
|
de0d923c63 | ||
|
|
d394c96eb9 | ||
|
|
03c38be19c | ||
|
|
a94e48501e | ||
|
|
35e973ebd9 | ||
|
|
8b6bfc3ef7 | ||
|
|
ad25241d1c | ||
|
|
954281c3d5 | ||
|
|
fca0816d6b | ||
|
|
d67d5315e0 | ||
|
|
2177f8d56c | ||
|
|
5602536058 | ||
|
|
abdc4b1d07 | ||
|
|
6c7f3b5090 | ||
|
|
7e9fc625c4 | ||
|
|
1382758f12 | ||
|
|
7442270e08 | ||
|
|
295e191456 | ||
|
|
8baaa587e2 | ||
|
|
66034a2473 | ||
|
|
d972c565e3 | ||
|
|
45d2c4aa2a | ||
|
|
144826ff84 | ||
|
|
4c620a2600 | ||
|
|
9786e46a2b | ||
|
|
22c89b7579 | ||
|
|
9a08c945bd | ||
|
|
d79588e02c | ||
|
|
9ae8dc2266 | ||
|
|
4d5bb32ad6 | ||
|
|
375b2e67d0 | ||
|
|
7e48bbb206 | ||
|
|
737f053347 | ||
|
|
20594be771 | ||
|
|
890e0a836c | ||
|
|
6fb4e24808 | ||
|
|
3ba5af0f95 | ||
|
|
a6058c6e65 | ||
|
|
dfc2755551 | ||
|
|
1ff774684c | ||
|
|
28c4eeb6ff | ||
|
|
4a566c7369 | ||
|
|
5ff9a8b75b | ||
|
|
46c9f36578 | ||
|
|
981f609b84 | ||
|
|
fa7bde4aca | ||
|
|
a8a9022ea2 | ||
|
|
2e903d82e5 | ||
|
|
27ecf76254 | ||
|
|
a77c59a9bd | ||
|
|
d48e40efac | ||
|
|
b7412813fd | ||
|
|
b93f4a9d7f | ||
|
|
9df8513954 | ||
|
|
bbd6bdac12 | ||
|
|
484dbabc3c | ||
|
|
989de0c811 | ||
|
|
818f6a3788 | ||
|
|
02e2a247c3 | ||
|
|
599b7fdf7b | ||
|
|
812e6ab6df | ||
|
|
dd4effa449 |
46
.github/workflows/build.yml
vendored
46
.github/workflows/build.yml
vendored
@@ -33,29 +33,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Home/Dockerfile .
|
||||
|
||||
docker-build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Worker/Dockerfile .
|
||||
|
||||
|
||||
docker-build-app:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -129,29 +106,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Probe/Dockerfile .
|
||||
|
||||
docker-build-telemetry:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# build image probe api
|
||||
- name: build docker image
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Telemetry/Dockerfile .
|
||||
|
||||
docker-build-test-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
3
.github/workflows/common-jobs.yaml
vendored
3
.github/workflows/common-jobs.yaml
vendored
@@ -18,9 +18,10 @@ jobs:
|
||||
- name: Install Helm
|
||||
run: |
|
||||
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
- name: Lint Helm Chart
|
||||
- name: Lint Helm Chart
|
||||
run: |
|
||||
helm lint ./HelmChart/Public/oneuptime
|
||||
helm lint ./HelmChart/Public/kubernetes-agent
|
||||
|
||||
js-lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
35
.github/workflows/compile.yml
vendored
35
.github/workflows/compile.yml
vendored
@@ -77,23 +77,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: cd Home && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-worker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- 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-nginx:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -201,24 +184,6 @@ jobs:
|
||||
max_attempts: 3
|
||||
command: cd Probe && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-telemetry:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- name: Compile Telemetry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Telemetry && npm install && npm run compile && npm run dep-check
|
||||
|
||||
|
||||
compile-status-page:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
722
.github/workflows/release.yml
vendored
722
.github/workflows/release.yml
vendored
File diff suppressed because it is too large
Load Diff
590
.github/workflows/test-release.yaml
vendored
590
.github/workflows/test-release.yaml
vendored
@@ -8,6 +8,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
generate-build-number:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -86,13 +91,21 @@ jobs:
|
||||
echo "patch=${target_patch}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using version base: ${new_version}"
|
||||
|
||||
nginx-docker-image-deploy:
|
||||
# ─── Docker image build jobs (per-arch matrix) ───────────────────────
|
||||
|
||||
nginx-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -102,17 +115,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/nginx
|
||||
ghcr.io/oneuptime/nginx
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -122,20 +124,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy nginx.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -151,19 +145,52 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Nginx/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
|
||||
e2e-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
nginx-docker-image-merge:
|
||||
needs: [nginx-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image nginx \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
|
||||
e2e-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -173,17 +200,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/e2e
|
||||
ghcr.io/oneuptime/e2e
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -193,20 +209,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy e2e.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -222,18 +230,51 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./E2E/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
test-server-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
e2e-docker-image-merge:
|
||||
needs: [e2e-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image e2e \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
test-server-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -243,17 +284,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/test-server
|
||||
ghcr.io/oneuptime/test-server
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -263,20 +293,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy test-server.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -292,18 +314,51 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./TestServer/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
home-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
test-server-docker-image-merge:
|
||||
needs: [test-server-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image test-server \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
home-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -313,17 +368,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/home
|
||||
ghcr.io/oneuptime/home
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -333,20 +377,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy home.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -362,20 +398,51 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Home/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
|
||||
|
||||
test-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
home-docker-image-merge:
|
||||
needs: [home-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image home \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
test-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -385,17 +452,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/test
|
||||
ghcr.io/oneuptime/test
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -405,20 +461,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy test.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -434,62 +482,22 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Tests/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
|
||||
telemetry-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
test-docker-image-merge:
|
||||
needs: [test-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/telemetry
|
||||
ghcr.io/oneuptime/telemetry
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy telemetry.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -498,25 +506,27 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image telemetry \
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Telemetry/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image test \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
probe-docker-image-deploy:
|
||||
probe-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -526,17 +536,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/probe
|
||||
ghcr.io/oneuptime/probe
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -546,20 +545,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy probe.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -575,18 +566,51 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Probe/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
app-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
probe-docker-image-merge:
|
||||
needs: [probe-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image probe \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
app-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -596,17 +620,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/app
|
||||
ghcr.io/oneuptime/app
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -616,20 +629,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy app.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -645,21 +650,51 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./App/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
|
||||
|
||||
|
||||
ai-agent-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
app-docker-image-merge:
|
||||
needs: [app-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image app \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
ai-agent-docker-image-build:
|
||||
needs: [read-version, generate-build-number]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
@@ -669,17 +704,6 @@ jobs:
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/ai-agent
|
||||
ghcr.io/oneuptime/ai-agent
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -689,19 +713,12 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy ai-agent.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -717,61 +734,22 @@ jobs:
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./AIAgent/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--platforms ${{ matrix.platform }} \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
worker-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
ai-agent-docker-image-merge:
|
||||
needs: [ai-agent-docker-image-build, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QEMU_CPU: max
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/worker
|
||||
ghcr.io/oneuptime/worker
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy accounts.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
@@ -780,18 +758,15 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image worker \
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Worker/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
SANITIZED_VERSION="${VERSION//+/-}"
|
||||
bash ./Scripts/GHA/merge_docker_manifests.sh \
|
||||
--image ai-agent \
|
||||
--tags "${SANITIZED_VERSION},test,enterprise-${SANITIZED_VERSION},enterprise-test"
|
||||
|
||||
# ─── Non-Docker jobs (unchanged) ─────────────────────────────────────
|
||||
|
||||
publish-terraform-provider:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -805,11 +780,10 @@ jobs:
|
||||
VERSION="${{needs.read-version.outputs.major_minor}}-test"
|
||||
echo "Skipping Terraform provider publish for test release $VERSION"
|
||||
|
||||
|
||||
|
||||
test-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [infrastructure-agent-deploy, publish-terraform-provider, telemetry-docker-image-deploy, worker-docker-image-deploy, home-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-docker-image-deploy, app-docker-image-deploy, ai-agent-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy]
|
||||
needs: [infrastructure-agent-deploy, publish-terraform-provider, home-docker-image-merge, test-server-docker-image-merge, test-docker-image-merge, probe-docker-image-merge, app-docker-image-merge, ai-agent-docker-image-merge, nginx-docker-image-merge, e2e-docker-image-merge]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
@@ -910,7 +884,7 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
|
||||
test-e2e-test-self-hosted:
|
||||
test-e2e-test-self-hosted:
|
||||
runs-on: ubuntu-latest
|
||||
# After all the jobs runs
|
||||
needs: [test-helm-chart, generate-build-number, read-version]
|
||||
@@ -1003,7 +977,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
|
||||
@@ -1027,7 +1001,7 @@ jobs:
|
||||
|
||||
- name: Release MSI Images
|
||||
run: cd InfrastructureAgent && bash build-msi.sh ${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
|
||||
|
||||
- name: Upload Release Binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -1036,13 +1010,13 @@ jobs:
|
||||
# Name of the artifact to upload.
|
||||
# Optional. Default is 'artifact'
|
||||
name: binaries
|
||||
|
||||
|
||||
# A file, directory or wildcard pattern that describes what to upload
|
||||
# Required.
|
||||
path: |
|
||||
./InfrastructureAgent/dist
|
||||
|
||||
|
||||
|
||||
|
||||
# Duration after which artifact will expire in days. 0 means using default retention.
|
||||
# Minimum 1 day.
|
||||
# Maximum 90 days unless changed from the repository settings page.
|
||||
|
||||
22
.github/workflows/test.telemetry.yaml
vendored
22
.github/workflows/test.telemetry.yaml
vendored
@@ -1,22 +0,0 @@
|
||||
name: Telemetry Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'hotfix-*' # excludes hotfix branches
|
||||
- 'release'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- run: cd Telemetry && npm install && npm run test
|
||||
|
||||
11
.github/workflows/test.yaml
vendored
11
.github/workflows/test.yaml
vendored
@@ -29,14 +29,3 @@ jobs:
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Home && npm install && npm run test
|
||||
|
||||
test-worker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Worker && npm install && npm run test
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -141,3 +141,5 @@ terraform.tfstate.backup
|
||||
.terraform.lock.hcl
|
||||
.claude/worktrees/**
|
||||
App/FeatureSet/Dashboard/public/sw.js
|
||||
|
||||
RFP/
|
||||
|
||||
1
AGENTS.md
Normal file
1
AGENTS.md
Normal file
@@ -0,0 +1 @@
|
||||
This is a local development server hosted at HOST env variable (please read config.env file). This project is hosted on docker compose for local development. When you make any changes to the codebase the container hot-reloads. Please make sure you wait for it to restart to test. If you need access to the database during development, credentials are in config.env file.
|
||||
@@ -11,6 +11,7 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import App from "Common/Server/Utils/StartServer";
|
||||
import Telemetry from "Common/Server/Utils/Telemetry";
|
||||
import Profiling from "Common/Server/Utils/Profiling";
|
||||
import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import "ejs";
|
||||
|
||||
@@ -23,6 +24,11 @@ const init: PromiseVoidFunction = async (): Promise<void> => {
|
||||
serviceName: APP_NAME,
|
||||
});
|
||||
|
||||
// Initialize profiling (opt-in via ENABLE_PROFILING env var)
|
||||
Profiling.init({
|
||||
serviceName: APP_NAME,
|
||||
});
|
||||
|
||||
logger.info("AI Agent Service - Starting...");
|
||||
|
||||
// init the app
|
||||
|
||||
37
AIAgent/package-lock.json
generated
37
AIAgent/package-lock.json
generated
@@ -48,11 +48,13 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -69,6 +71,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.8.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
@@ -78,7 +81,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -87,7 +90,7 @@
|
||||
"mermaid": "^11.12.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otpauth": "^9.3.1",
|
||||
@@ -1486,9 +1489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -2223,9 +2226,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3751,9 +3754,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3984,9 +3987,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4438,9 +4441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
35
App/API/Metrics.ts
Normal file
35
App/API/Metrics.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
NextFunction,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import AppQueueService from "../Services/Queue/AppQueueService";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
/**
|
||||
* JSON metrics endpoint for KEDA autoscaling
|
||||
* Returns combined queue size (worker + workflow + telemetry) as JSON for KEDA metrics-api scaler
|
||||
*/
|
||||
router.get(
|
||||
"/metrics/queue-size",
|
||||
async (
|
||||
_req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const queueSize: number = await AppQueueService.getQueueSize();
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.status(200).json({
|
||||
queueSize: queueSize,
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -72,6 +72,10 @@ WORKDIR /usr/src/app/FeatureSet/StatusPage
|
||||
COPY ./App/FeatureSet/StatusPage/package*.json /usr/src/app/FeatureSet/StatusPage/
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /usr/src/app/FeatureSet/PublicDashboard
|
||||
COPY ./App/FeatureSet/PublicDashboard/package*.json /usr/src/app/FeatureSet/PublicDashboard/
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Expose ports.
|
||||
@@ -89,6 +93,7 @@ COPY ./App/FeatureSet/Accounts /usr/src/app/FeatureSet/Accounts
|
||||
COPY ./App/FeatureSet/Dashboard /usr/src/app/FeatureSet/Dashboard
|
||||
COPY ./App/FeatureSet/AdminDashboard /usr/src/app/FeatureSet/AdminDashboard
|
||||
COPY ./App/FeatureSet/StatusPage /usr/src/app/FeatureSet/StatusPage
|
||||
COPY ./App/FeatureSet/PublicDashboard /usr/src/app/FeatureSet/PublicDashboard
|
||||
# Bundle frontend source
|
||||
RUN npm run build-frontends:prod
|
||||
# Bundle app source
|
||||
|
||||
26
App/FeatureSet/Accounts/package-lock.json
generated
26
App/FeatureSet/Accounts/package-lock.json
generated
@@ -52,11 +52,13 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -73,6 +75,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.8.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
@@ -82,7 +85,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -91,7 +94,7 @@
|
||||
"mermaid": "^11.12.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otpauth": "^9.3.1",
|
||||
@@ -800,9 +803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -928,9 +931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -1177,10 +1180,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
|
||||
26
App/FeatureSet/AdminDashboard/package-lock.json
generated
26
App/FeatureSet/AdminDashboard/package-lock.json
generated
@@ -51,11 +51,13 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -72,6 +74,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.8.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
@@ -81,7 +84,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -90,7 +93,7 @@
|
||||
"mermaid": "^11.12.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otpauth": "^9.3.1",
|
||||
@@ -784,9 +787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -912,9 +915,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -1161,10 +1164,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ import SettingsEmail from "./Pages/Settings/Email/Index";
|
||||
import SettingsProbes from "./Pages/Settings/Probes/Index";
|
||||
import SettingsAIAgents from "./Pages/Settings/AIAgents/Index";
|
||||
import SettingsLlmProviders from "./Pages/Settings/LlmProviders/Index";
|
||||
import SendEmail from "./Pages/SendEmail/Index";
|
||||
import MoreEmail from "./Pages/More/Email";
|
||||
import Users from "./Pages/Users/Index";
|
||||
import PageMap from "./Utils/PageMap";
|
||||
import RouteMap from "./Utils/RouteMap";
|
||||
@@ -149,6 +151,16 @@ const App: () => JSX.Element = () => {
|
||||
path={RouteMap[PageMap.SETTINGS_DATA_RETENTION]?.toString() || ""}
|
||||
element={<SettingsDataRetention />}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.SEND_EMAIL]?.toString() || ""}
|
||||
element={<SendEmail />}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.MORE_EMAIL]?.toString() || ""}
|
||||
element={<MoreEmail />}
|
||||
/>
|
||||
</Routes>
|
||||
</MasterPage>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,14 @@ const DashboardNavbar: FunctionComponent = (): ReactElement => {
|
||||
icon: IconProp.Folder,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.PROJECTS] as Route),
|
||||
},
|
||||
{
|
||||
id: "more-nav-bar-item",
|
||||
title: "More",
|
||||
icon: IconProp.More,
|
||||
route: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.MORE_EMAIL] as Route,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "settings-nav-bar-item",
|
||||
title: "Settings",
|
||||
|
||||
305
App/FeatureSet/AdminDashboard/src/Pages/More/Email.tsx
Normal file
305
App/FeatureSet/AdminDashboard/src/Pages/More/Email.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import MoreSideMenu from "./SideMenu";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Page from "Common/UI/Components/Page/Page";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import BasicForm from "Common/UI/Components/Forms/BasicForm";
|
||||
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import Modal from "Common/UI/Components/Modal/Modal";
|
||||
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
|
||||
const MoreEmail: FunctionComponent = (): ReactElement => {
|
||||
const [isSendingTest, setIsSendingTest] = useState<boolean>(false);
|
||||
const [isSendingAll, setIsSendingAll] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [success, setSuccess] = useState<string>("");
|
||||
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
|
||||
const [showTestModal, setShowTestModal] = useState<boolean>(false);
|
||||
const [testEmail, setTestEmail] = useState<string>("");
|
||||
const [testError, setTestError] = useState<string>("");
|
||||
const [testSuccess, setTestSuccess] = useState<string>("");
|
||||
const [pendingSubject, setPendingSubject] = useState<string>("");
|
||||
const [pendingMessage, setPendingMessage] = useState<string>("");
|
||||
const [currentFormValues, setCurrentFormValues] = useState<JSONObject>({
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const sendTestEmail: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsSendingTest(true);
|
||||
setTestError("");
|
||||
setTestSuccess("");
|
||||
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/admin/email/send-test",
|
||||
),
|
||||
data: {
|
||||
subject: pendingSubject,
|
||||
message: pendingMessage,
|
||||
testEmail: testEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw new Error("Failed to send test email.");
|
||||
}
|
||||
|
||||
setTestSuccess("Test email sent successfully. Please check your inbox.");
|
||||
} catch (err) {
|
||||
setTestError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSendingTest(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendToAllUsers: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsSendingAll(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/admin/email/send-to-all-users",
|
||||
),
|
||||
data: {
|
||||
subject: pendingSubject,
|
||||
message: pendingMessage,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw new Error("Failed to send emails.");
|
||||
}
|
||||
|
||||
setSuccess(
|
||||
"Broadcast email job has been started. Emails will be sent in the background.",
|
||||
);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSendingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
title={"Send Announcement Email"}
|
||||
breadcrumbLinks={[
|
||||
{
|
||||
title: "Admin Dashboard",
|
||||
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
|
||||
},
|
||||
{
|
||||
title: "More",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.MORE_EMAIL] as Route,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Send Email",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.MORE_EMAIL] as Route,
|
||||
),
|
||||
},
|
||||
]}
|
||||
sideMenu={<MoreSideMenu />}
|
||||
>
|
||||
<Card
|
||||
title="Send Announcement Email"
|
||||
description="Compose an announcement email to send to all registered users. You can send a test email first to preview how it looks."
|
||||
>
|
||||
{success ? (
|
||||
<Alert type={AlertType.SUCCESS} title={success} className="mb-4" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<BasicForm
|
||||
id="send-email-form"
|
||||
name="Send Announcement Email"
|
||||
isLoading={isSendingAll}
|
||||
error={error || ""}
|
||||
hideSubmitButton={true}
|
||||
onChange={(values: JSONObject) => {
|
||||
setCurrentFormValues(values as JSONObject);
|
||||
}}
|
||||
initialValues={{
|
||||
subject: "",
|
||||
message: "",
|
||||
}}
|
||||
fields={[
|
||||
{
|
||||
field: {
|
||||
subject: true,
|
||||
},
|
||||
title: "Subject",
|
||||
description: "The subject line of the announcement email.",
|
||||
placeholder: "Enter email subject",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
message: true,
|
||||
},
|
||||
title: "Message",
|
||||
description:
|
||||
"The body of the announcement email. This will be displayed in a branded OneUptime email template. You can use Markdown formatting.",
|
||||
placeholder: "Enter your announcement message here...",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Markdown,
|
||||
},
|
||||
]}
|
||||
onSubmit={async () => {}}
|
||||
footer={
|
||||
<div className="flex w-full justify-end mt-3 space-x-3">
|
||||
<Button
|
||||
title="Send Test Email"
|
||||
buttonStyle={ButtonStyleType.NORMAL}
|
||||
onClick={() => {
|
||||
const subject: string = String(
|
||||
currentFormValues["subject"] || "",
|
||||
).trim();
|
||||
const message: string = String(
|
||||
currentFormValues["message"] || "",
|
||||
).trim();
|
||||
|
||||
if (!subject || !message) {
|
||||
setError(
|
||||
"Please fill in subject and message before sending a test.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setPendingSubject(subject);
|
||||
setPendingMessage(message);
|
||||
setTestEmail("");
|
||||
setTestError("");
|
||||
setTestSuccess("");
|
||||
setShowTestModal(true);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title="Send to All Users"
|
||||
buttonStyle={ButtonStyleType.PRIMARY}
|
||||
isLoading={isSendingAll}
|
||||
onClick={() => {
|
||||
const subject: string = String(
|
||||
currentFormValues["subject"] || "",
|
||||
).trim();
|
||||
const message: string = String(
|
||||
currentFormValues["message"] || "",
|
||||
).trim();
|
||||
|
||||
if (!subject || !message) {
|
||||
setSuccess("");
|
||||
setError("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setPendingSubject(subject);
|
||||
setPendingMessage(message);
|
||||
setShowConfirmModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{showTestModal ? (
|
||||
<Modal
|
||||
title="Send Test Email"
|
||||
description="Enter an email address to send a test of this announcement."
|
||||
onClose={() => {
|
||||
setShowTestModal(false);
|
||||
}}
|
||||
submitButtonText="Send Test"
|
||||
isLoading={isSendingTest}
|
||||
onSubmit={() => {
|
||||
if (!testEmail.trim()) {
|
||||
setTestError("Please enter a test email address.");
|
||||
return;
|
||||
}
|
||||
sendTestEmail().catch(() => {});
|
||||
}}
|
||||
error={testError}
|
||||
>
|
||||
{testSuccess ? (
|
||||
<Alert
|
||||
type={AlertType.SUCCESS}
|
||||
title={testSuccess}
|
||||
className="mb-4"
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="test-email-input"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Test Email Address
|
||||
</label>
|
||||
<input
|
||||
id="test-email-input"
|
||||
type="email"
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
|
||||
placeholder="test@example.com"
|
||||
value={testEmail}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTestEmail(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showConfirmModal ? (
|
||||
<ConfirmModal
|
||||
title="Confirm Send to All Users"
|
||||
description="Are you sure you want to send this announcement email to all registered users? This action cannot be undone."
|
||||
submitButtonText="Yes, Send to All Users"
|
||||
onSubmit={async () => {
|
||||
setShowConfirmModal(false);
|
||||
await sendToAllUsers();
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowConfirmModal(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoreEmail;
|
||||
28
App/FeatureSet/AdminDashboard/src/Pages/More/SideMenu.tsx
Normal file
28
App/FeatureSet/AdminDashboard/src/Pages/More/SideMenu.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import SideMenu from "Common/UI/Components/SideMenu/SideMenu";
|
||||
import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem";
|
||||
import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
const MoreSideMenu: () => JSX.Element = (): ReactElement => {
|
||||
return (
|
||||
<SideMenu>
|
||||
<SideMenuSection title="Communication">
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Send Email",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.MORE_EMAIL] as Route,
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Email}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
</SideMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoreSideMenu;
|
||||
281
App/FeatureSet/AdminDashboard/src/Pages/SendEmail/Index.tsx
Normal file
281
App/FeatureSet/AdminDashboard/src/Pages/SendEmail/Index.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Page from "Common/UI/Components/Page/Page";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import BasicForm from "Common/UI/Components/Forms/BasicForm";
|
||||
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
|
||||
const SendEmail: FunctionComponent = (): ReactElement => {
|
||||
const [isSendingTest, setIsSendingTest] = useState<boolean>(false);
|
||||
const [isSendingAll, setIsSendingAll] = useState<boolean>(false);
|
||||
const [testError, setTestError] = useState<string>("");
|
||||
const [testSuccess, setTestSuccess] = useState<string>("");
|
||||
const [sendAllError, setSendAllError] = useState<string>("");
|
||||
const [sendAllSuccess, setSendAllSuccess] = useState<string>("");
|
||||
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
|
||||
const [pendingSubject, setPendingSubject] = useState<string>("");
|
||||
const [pendingMessage, setPendingMessage] = useState<string>("");
|
||||
|
||||
const sendToAllUsers: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsSendingAll(true);
|
||||
setSendAllError("");
|
||||
setSendAllSuccess("");
|
||||
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/admin/email/send-to-all-users",
|
||||
),
|
||||
data: {
|
||||
subject: pendingSubject,
|
||||
message: pendingMessage,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw new Error("Failed to send emails.");
|
||||
}
|
||||
|
||||
setSendAllSuccess(
|
||||
"Broadcast email job has been started. Emails will be sent in the background.",
|
||||
);
|
||||
} catch (err) {
|
||||
setSendAllError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSendingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page
|
||||
title={"Send Announcement Email"}
|
||||
breadcrumbLinks={[
|
||||
{
|
||||
title: "Admin Dashboard",
|
||||
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
|
||||
},
|
||||
{
|
||||
title: "Send Email",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SEND_EMAIL] as Route,
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Card
|
||||
title="Send Test Email"
|
||||
description="Send a test announcement email to a single email address to preview how it looks before sending to all users."
|
||||
>
|
||||
{testSuccess ? (
|
||||
<Alert
|
||||
type={AlertType.SUCCESS}
|
||||
title={testSuccess}
|
||||
className="mb-4"
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<BasicForm
|
||||
id="send-test-email-form"
|
||||
name="Send Test Announcement Email"
|
||||
isLoading={isSendingTest}
|
||||
error={testError || ""}
|
||||
submitButtonText="Send Test Email"
|
||||
maxPrimaryButtonWidth={true}
|
||||
initialValues={{
|
||||
subject: "",
|
||||
message: "",
|
||||
testEmail: "",
|
||||
}}
|
||||
fields={[
|
||||
{
|
||||
field: {
|
||||
subject: true,
|
||||
},
|
||||
title: "Subject",
|
||||
description: "The subject line of the announcement email.",
|
||||
placeholder: "Enter email subject",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
message: true,
|
||||
},
|
||||
title: "Message",
|
||||
description:
|
||||
"The body of the announcement email. This will be displayed in a branded OneUptime email template. You can use Markdown formatting.",
|
||||
placeholder: "Enter your announcement message here...",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Markdown,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
testEmail: true,
|
||||
},
|
||||
title: "Test Email Address",
|
||||
description:
|
||||
"The email address where the test email will be sent.",
|
||||
placeholder: "test@example.com",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Email,
|
||||
disableSpellCheck: true,
|
||||
},
|
||||
]}
|
||||
onSubmit={async (
|
||||
values: JSONObject,
|
||||
onSubmitSuccessful?: () => void,
|
||||
) => {
|
||||
const subject: string = String(values["subject"] || "").trim();
|
||||
const message: string = String(values["message"] || "").trim();
|
||||
const testEmail: string = String(values["testEmail"] || "").trim();
|
||||
|
||||
if (!subject || !message || !testEmail) {
|
||||
setTestSuccess("");
|
||||
setTestError("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingTest(true);
|
||||
setTestError("");
|
||||
setTestSuccess("");
|
||||
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/admin/email/send-test",
|
||||
),
|
||||
data: {
|
||||
subject,
|
||||
message,
|
||||
testEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw new Error("Failed to send test email.");
|
||||
}
|
||||
|
||||
setTestSuccess(
|
||||
"Test email sent successfully. Please check your inbox.",
|
||||
);
|
||||
|
||||
if (onSubmitSuccessful) {
|
||||
onSubmitSuccessful();
|
||||
}
|
||||
} catch (err) {
|
||||
setTestError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSendingTest(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Send Email to All Users"
|
||||
description="Send an announcement email to all registered users. Please send a test email first to verify the content."
|
||||
>
|
||||
{sendAllSuccess ? (
|
||||
<Alert
|
||||
type={AlertType.SUCCESS}
|
||||
title={sendAllSuccess}
|
||||
className="mb-4"
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<BasicForm
|
||||
id="send-all-email-form"
|
||||
name="Send Announcement to All Users"
|
||||
isLoading={isSendingAll}
|
||||
error={sendAllError || ""}
|
||||
submitButtonText="Send to All Users"
|
||||
maxPrimaryButtonWidth={true}
|
||||
initialValues={{
|
||||
subject: "",
|
||||
message: "",
|
||||
}}
|
||||
fields={[
|
||||
{
|
||||
field: {
|
||||
subject: true,
|
||||
},
|
||||
title: "Subject",
|
||||
description: "The subject line of the announcement email.",
|
||||
placeholder: "Enter email subject",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
message: true,
|
||||
},
|
||||
title: "Message",
|
||||
description:
|
||||
"The body of the announcement email. This will be sent to all registered users. You can use Markdown formatting.",
|
||||
placeholder: "Enter your announcement message here...",
|
||||
required: true,
|
||||
fieldType: FormFieldSchemaType.Markdown,
|
||||
},
|
||||
]}
|
||||
onSubmit={async (values: JSONObject) => {
|
||||
const subject: string = String(values["subject"] || "").trim();
|
||||
const message: string = String(values["message"] || "").trim();
|
||||
|
||||
if (!subject || !message) {
|
||||
setSendAllSuccess("");
|
||||
setSendAllError("Please fill in all fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingSubject(subject);
|
||||
setPendingMessage(message);
|
||||
setShowConfirmModal(true);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{showConfirmModal ? (
|
||||
<ConfirmModal
|
||||
title="Confirm Send to All Users"
|
||||
description="Are you sure you want to send this announcement email to all registered users? This action cannot be undone."
|
||||
submitButtonText="Yes, Send to All Users"
|
||||
onSubmit={async () => {
|
||||
setShowConfirmModal(false);
|
||||
await sendToAllUsers();
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowConfirmModal(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendEmail;
|
||||
@@ -78,6 +78,51 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
modelId: ObjectID.getZeroObjectID(),
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail
|
||||
name="Monitor Metric Retention Settings"
|
||||
cardProps={{
|
||||
title: "Monitor Metric Retention",
|
||||
description:
|
||||
"Configure how long monitor metrics are retained before being automatically deleted.",
|
||||
}}
|
||||
isEditable={true}
|
||||
editButtonText="Edit Settings"
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
monitorMetricRetentionInDays: true,
|
||||
},
|
||||
title: "Monitor Metric Retention (Days)",
|
||||
fieldType: FormFieldSchemaType.PositiveNumber,
|
||||
required: false,
|
||||
description:
|
||||
"Number of days to retain monitor metrics. Monitor metrics older than this will be automatically deleted. Default is 1 day if not set. Minimum: 1 day, Maximum: 365 days.",
|
||||
validation: {
|
||||
minValue: 1,
|
||||
maxValue: 365,
|
||||
},
|
||||
placeholder: "1",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
modelType: GlobalConfig,
|
||||
id: "model-detail-global-config-monitor-metric-retention",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
monitorMetricRetentionInDays: true,
|
||||
},
|
||||
fieldType: FieldType.Number,
|
||||
title: "Monitor Metric Retention (Days)",
|
||||
placeholder: "1 (default)",
|
||||
description:
|
||||
"Number of days to retain monitor metrics. Monitor metrics older than this will be automatically deleted.",
|
||||
},
|
||||
],
|
||||
modelId: ObjectID.getZeroObjectID(),
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,10 @@ enum PageMap {
|
||||
SETTINGS_AUTHENTICATION = "SETTINGS_AUTHENTICATION",
|
||||
SETTINGS_API_KEY = "SETTINGS_API_KEY",
|
||||
SETTINGS_DATA_RETENTION = "SETTINGS_DATA_RETENTION",
|
||||
|
||||
SEND_EMAIL = "SEND_EMAIL",
|
||||
|
||||
MORE_EMAIL = "MORE_EMAIL",
|
||||
}
|
||||
|
||||
export default PageMap;
|
||||
|
||||
@@ -39,6 +39,10 @@ const RouteMap: Dictionary<Route> = {
|
||||
[PageMap.SETTINGS_DATA_RETENTION]: new Route(
|
||||
`/admin/settings/data-retention`,
|
||||
),
|
||||
|
||||
[PageMap.SEND_EMAIL]: new Route(`/admin/send-email`),
|
||||
|
||||
[PageMap.MORE_EMAIL]: new Route(`/admin/more/email`),
|
||||
};
|
||||
|
||||
export class RouteUtil {
|
||||
|
||||
@@ -28,6 +28,9 @@ import MonitorAPI from "Common/Server/API/MonitorAPI";
|
||||
import ShortLinkAPI from "Common/Server/API/ShortLinkAPI";
|
||||
import StatusPageAPI from "Common/Server/API/StatusPageAPI";
|
||||
import WorkspaceNotificationRuleAPI from "Common/Server/API/WorkspaceNotificationRuleAPI";
|
||||
import WorkspaceNotificationSummaryAPI from "Common/Server/API/WorkspaceNotificationSummaryAPI";
|
||||
import DashboardAPI from "Common/Server/API/DashboardAPI";
|
||||
import DashboardDomainAPI from "Common/Server/API/DashboardDomainAPI";
|
||||
import StatusPageDomainAPI from "Common/Server/API/StatusPageDomainAPI";
|
||||
import StatusPageSubscriberAPI from "Common/Server/API/StatusPageSubscriberAPI";
|
||||
import UserCallAPI from "Common/Server/API/UserCallAPI";
|
||||
@@ -185,6 +188,21 @@ import IncidentPostmortemTemplateService, {
|
||||
import TableViewService, {
|
||||
Service as TableViewServiceType,
|
||||
} from "Common/Server/Services/TableViewService";
|
||||
import LogSavedViewService, {
|
||||
Service as LogSavedViewServiceType,
|
||||
} from "Common/Server/Services/LogSavedViewService";
|
||||
import LogPipelineService, {
|
||||
Service as LogPipelineServiceType,
|
||||
} from "Common/Server/Services/LogPipelineService";
|
||||
import LogPipelineProcessorService, {
|
||||
Service as LogPipelineProcessorServiceType,
|
||||
} from "Common/Server/Services/LogPipelineProcessorService";
|
||||
import LogDropFilterService, {
|
||||
Service as LogDropFilterServiceType,
|
||||
} from "Common/Server/Services/LogDropFilterService";
|
||||
import LogScrubRuleService, {
|
||||
Service as LogScrubRuleServiceType,
|
||||
} from "Common/Server/Services/LogScrubRuleService";
|
||||
import IncidentOwnerTeamService, {
|
||||
Service as IncidentOwnerTeamServiceType,
|
||||
} from "Common/Server/Services/IncidentOwnerTeamService";
|
||||
@@ -218,6 +236,9 @@ import IncidentTemplateOwnerUserService, {
|
||||
import IncidentTemplateService, {
|
||||
Service as IncidentTemplateServiceType,
|
||||
} from "Common/Server/Services/IncidentTemplateService";
|
||||
import KubernetesClusterService, {
|
||||
Service as KubernetesClusterServiceType,
|
||||
} from "Common/Server/Services/KubernetesClusterService";
|
||||
import LabelService, {
|
||||
Service as LabelServiceType,
|
||||
} from "Common/Server/Services/LabelService";
|
||||
@@ -378,6 +399,12 @@ import PushNotificationLogService, {
|
||||
import SpanService, {
|
||||
SpanService as SpanServiceType,
|
||||
} from "Common/Server/Services/SpanService";
|
||||
import ProfileService, {
|
||||
ProfileService as ProfileServiceType,
|
||||
} from "Common/Server/Services/ProfileService";
|
||||
import ProfileSampleService, {
|
||||
ProfileSampleService as ProfileSampleServiceType,
|
||||
} from "Common/Server/Services/ProfileSampleService";
|
||||
import StatusPageAnnouncementAPI from "Common/Server/API/StatusPageAnnouncementAPI";
|
||||
import StatusPageCustomFieldService, {
|
||||
Service as StatusPageCustomFieldServiceType,
|
||||
@@ -481,6 +508,8 @@ import Express, { ExpressApplication } from "Common/Server/Utils/Express";
|
||||
import Log from "Common/Models/AnalyticsModels/Log";
|
||||
import Metric from "Common/Models/AnalyticsModels/Metric";
|
||||
import Span from "Common/Models/AnalyticsModels/Span";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import ApiKey from "Common/Models/DatabaseModels/ApiKey";
|
||||
import ApiKeyPermission from "Common/Models/DatabaseModels/ApiKeyPermission";
|
||||
import CallLog from "Common/Models/DatabaseModels/CallLog";
|
||||
@@ -537,6 +566,7 @@ import IncidentTemplate from "Common/Models/DatabaseModels/IncidentTemplate";
|
||||
import IncidentTemplateOwnerTeam from "Common/Models/DatabaseModels/IncidentTemplateOwnerTeam";
|
||||
import IncidentTemplateOwnerUser from "Common/Models/DatabaseModels/IncidentTemplateOwnerUser";
|
||||
|
||||
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
import MonitorCustomField from "Common/Models/DatabaseModels/MonitorCustomField";
|
||||
import MonitorGroupOwnerTeam from "Common/Models/DatabaseModels/MonitorGroupOwnerTeam";
|
||||
@@ -630,6 +660,11 @@ import ScheduledMaintenanceTemplateOwnerUserService, {
|
||||
Service as ScheduledMaintenanceTemplateOwnerUserServiceType,
|
||||
} from "Common/Server/Services/ScheduledMaintenanceTemplateOwnerUserService";
|
||||
import TableView from "Common/Models/DatabaseModels/TableView";
|
||||
import LogSavedView from "Common/Models/DatabaseModels/LogSavedView";
|
||||
import LogPipeline from "Common/Models/DatabaseModels/LogPipeline";
|
||||
import LogPipelineProcessor from "Common/Models/DatabaseModels/LogPipelineProcessor";
|
||||
import LogDropFilter from "Common/Models/DatabaseModels/LogDropFilter";
|
||||
import LogScrubRule from "Common/Models/DatabaseModels/LogScrubRule";
|
||||
|
||||
import IncidentFeed from "Common/Models/DatabaseModels/IncidentFeed";
|
||||
import AlertFeed from "Common/Models/DatabaseModels/AlertFeed";
|
||||
@@ -1259,6 +1294,22 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAnalyticsAPI<Profile, ProfileServiceType>(
|
||||
Profile,
|
||||
ProfileService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAnalyticsAPI<ProfileSample, ProfileSampleServiceType>(
|
||||
ProfileSample,
|
||||
ProfileSampleService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<TelemetryUsageBilling, TelemetryUsageBillingServiceType>(
|
||||
@@ -1494,6 +1545,46 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<LogSavedView, LogSavedViewServiceType>(
|
||||
LogSavedView,
|
||||
LogSavedViewService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<LogPipeline, LogPipelineServiceType>(
|
||||
LogPipeline,
|
||||
LogPipelineService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<LogPipelineProcessor, LogPipelineProcessorServiceType>(
|
||||
LogPipelineProcessor,
|
||||
LogPipelineProcessorService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<LogDropFilter, LogDropFilterServiceType>(
|
||||
LogDropFilter,
|
||||
LogDropFilterService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<LogScrubRule, LogScrubRuleServiceType>(
|
||||
LogScrubRule,
|
||||
LogScrubRuleService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<IncidentState, IncidentStateServiceType>(
|
||||
@@ -1799,6 +1890,14 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
new BaseAPI<Label, LabelServiceType>(Label, LabelService).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<KubernetesCluster, KubernetesClusterServiceType>(
|
||||
KubernetesCluster,
|
||||
KubernetesClusterService,
|
||||
).getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new BaseAPI<EmailVerificationToken, EmailVerificationTokenServiceType>(
|
||||
@@ -1985,6 +2084,10 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new WorkspaceNotificationRuleAPI().getRouter(),
|
||||
);
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new WorkspaceNotificationSummaryAPI().getRouter(),
|
||||
);
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new FileAPI().getRouter());
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
@@ -1996,6 +2099,13 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
new StatusPageDomainAPI().getRouter(),
|
||||
);
|
||||
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new DashboardAPI().getRouter());
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new DashboardDomainAPI().getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ProjectSsoAPI().getRouter(),
|
||||
|
||||
23
App/FeatureSet/Dashboard/package-lock.json
generated
23
App/FeatureSet/Dashboard/package-lock.json
generated
@@ -55,11 +55,13 @@
|
||||
"@opentelemetry/sdk-node": "^0.207.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.37.0",
|
||||
"@pyroscope/nodejs": "^0.4.11",
|
||||
"@remixicon/react": "^4.2.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -76,6 +78,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^4.8.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
@@ -85,7 +88,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"isolated-vm": "^6.1.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -94,7 +97,7 @@
|
||||
"mermaid": "^11.12.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otpauth": "^9.3.1",
|
||||
@@ -1081,9 +1084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1378,9 +1381,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -1718,7 +1721,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -92,6 +92,15 @@ const ExceptionsRoutes: React.LazyExoticComponent<
|
||||
};
|
||||
});
|
||||
});
|
||||
const ProfilesRoutes: React.LazyExoticComponent<
|
||||
AllRoutesModule["ProfilesRoutes"]
|
||||
> = lazy(() => {
|
||||
return import("./Routes/AllRoutes").then((m: AllRoutesModule) => {
|
||||
return {
|
||||
default: m.ProfilesRoutes,
|
||||
};
|
||||
});
|
||||
});
|
||||
const IncidentsRoutes: React.LazyExoticComponent<
|
||||
AllRoutesModule["IncidentsRoutes"]
|
||||
> = lazy(() => {
|
||||
@@ -181,6 +190,15 @@ const ServiceRoutes: React.LazyExoticComponent<
|
||||
};
|
||||
});
|
||||
});
|
||||
const KubernetesRoutes: React.LazyExoticComponent<
|
||||
AllRoutesModule["KubernetesRoutes"]
|
||||
> = lazy(() => {
|
||||
return import("./Routes/AllRoutes").then((m: AllRoutesModule) => {
|
||||
return {
|
||||
default: m.KubernetesRoutes,
|
||||
};
|
||||
});
|
||||
});
|
||||
const CodeRepositoryRoutes: React.LazyExoticComponent<
|
||||
AllRoutesModule["CodeRepositoryRoutes"]
|
||||
> = lazy(() => {
|
||||
@@ -498,6 +516,12 @@ const App: () => JSX.Element = () => {
|
||||
element={<TracesRoutes {...commonPageProps} />}
|
||||
/>
|
||||
|
||||
{/* Profiles */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.PROFILES_ROOT]?.toString() || ""}
|
||||
element={<ProfilesRoutes {...commonPageProps} />}
|
||||
/>
|
||||
|
||||
{/* Monitors */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.MONITORS_ROOT]?.toString() || ""}
|
||||
@@ -528,6 +552,12 @@ const App: () => JSX.Element = () => {
|
||||
element={<ServiceRoutes {...commonPageProps} />}
|
||||
/>
|
||||
|
||||
{/* Kubernetes */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.KUBERNETES_ROOT]?.toString() || ""}
|
||||
element={<KubernetesRoutes {...commonPageProps} />}
|
||||
/>
|
||||
|
||||
{/* Code Repository */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.CODE_REPOSITORY_ROOT]?.toString() || ""}
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
ComponentArgument,
|
||||
ComponentArgumentSection,
|
||||
ComponentInputType,
|
||||
} from "Common/Types/Dashboard/DashboardComponents/ComponentArgument";
|
||||
import DashboardComponentsUtil from "Common/Utils/Dashboard/Components/Index";
|
||||
@@ -21,8 +22,13 @@ import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentTyp
|
||||
import MetricQueryConfig from "../../Metrics/MetricQueryConfig";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import CollapsibleSection from "Common/UI/Components/CollapsibleSection/CollapsibleSection";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
export interface ComponentProps {
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
@@ -37,16 +43,30 @@ export interface ComponentProps {
|
||||
onFormChange: (component: DashboardBaseComponent) => void;
|
||||
}
|
||||
|
||||
interface SectionGroup {
|
||||
section: ComponentArgumentSection;
|
||||
args: Array<ComponentArgument<DashboardBaseComponent>>;
|
||||
}
|
||||
|
||||
const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const formRef: any = useRef<FormProps<FormValues<JSONObject>>>(null);
|
||||
const formRefs: React.MutableRefObject<
|
||||
Record<string, FormProps<FormValues<JSONObject>> | null>
|
||||
> = useRef({});
|
||||
const [component, setComponent] = useState<DashboardBaseComponent>(
|
||||
props.component,
|
||||
);
|
||||
const [hasFormValidationErrors, setHasFormValidationErrors] = useState<
|
||||
Dictionary<boolean>
|
||||
>({});
|
||||
const [multiQueryConfigs, setMultiQueryConfigs] = useState<
|
||||
Array<MetricQueryConfigData>
|
||||
>(
|
||||
((props.component?.arguments as JSONObject)?.[
|
||||
"metricQueryConfigs"
|
||||
] as unknown as Array<MetricQueryConfigData>) || [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.onHasFormValidationErrors) {
|
||||
@@ -66,6 +86,57 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
const componentArguments: Array<ComponentArgument<DashboardBaseComponent>> =
|
||||
DashboardComponentsUtil.getComponentSettingsArguments(componentType);
|
||||
|
||||
// Group arguments by section
|
||||
const groupArgumentsBySections: () => Array<SectionGroup> =
|
||||
(): Array<SectionGroup> => {
|
||||
const sectionMap: Map<string, SectionGroup> = new Map();
|
||||
const unsectionedArgs: Array<ComponentArgument<DashboardBaseComponent>> =
|
||||
[];
|
||||
|
||||
for (const arg of componentArguments) {
|
||||
// Skip MetricsQueryConfigs - we render it as a custom multi-query UI
|
||||
if (arg.type === ComponentInputType.MetricsQueryConfigs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.section) {
|
||||
const key: string = arg.section.name;
|
||||
if (!sectionMap.has(key)) {
|
||||
sectionMap.set(key, {
|
||||
section: arg.section,
|
||||
args: [],
|
||||
});
|
||||
}
|
||||
sectionMap.get(key)!.args.push(arg);
|
||||
} else {
|
||||
unsectionedArgs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: Array<SectionGroup> = [];
|
||||
|
||||
// Add unsectioned args as a "General" section if they exist
|
||||
if (unsectionedArgs.length > 0) {
|
||||
groups.push({
|
||||
section: {
|
||||
name: "General",
|
||||
order: 0,
|
||||
},
|
||||
args: unsectionedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort sections by order
|
||||
const sortedSections: Array<SectionGroup> = Array.from(
|
||||
sectionMap.values(),
|
||||
).sort((a: SectionGroup, b: SectionGroup) => {
|
||||
return a.section.order - b.section.order;
|
||||
});
|
||||
groups.push(...sortedSections);
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
type GetMetricsQueryConfigFormFunction = (
|
||||
arg: ComponentArgument<DashboardBaseComponent>,
|
||||
) => (
|
||||
@@ -85,13 +156,20 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
componentProps: CustomElementProps,
|
||||
) => {
|
||||
return (
|
||||
<MetricQueryConfig
|
||||
{...componentProps}
|
||||
data={value[arg.id] as MetricQueryConfigData}
|
||||
metricTypes={props.metrics.metricTypes}
|
||||
telemetryAttributes={props.metrics.telemetryAttributes}
|
||||
hideCard={true}
|
||||
/>
|
||||
<div className="p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Query 1
|
||||
</span>
|
||||
</div>
|
||||
<MetricQueryConfig
|
||||
{...componentProps}
|
||||
data={value[arg.id] as MetricQueryConfigData}
|
||||
metricTypes={props.metrics.metricTypes}
|
||||
telemetryAttributes={props.metrics.telemetryAttributes}
|
||||
hideCard={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -105,7 +183,7 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
) => ReactElement)
|
||||
| undefined;
|
||||
|
||||
const getCustomElememnt: GetCustomElementFunction = (
|
||||
const getCustomElement: GetCustomElementFunction = (
|
||||
arg: ComponentArgument<DashboardBaseComponent>,
|
||||
):
|
||||
| ((
|
||||
@@ -119,11 +197,17 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getForm: GetReactElementFunction = (): ReactElement => {
|
||||
const renderSectionForm: (sectionGroup: SectionGroup) => ReactElement = (
|
||||
sectionGroup: SectionGroup,
|
||||
): ReactElement => {
|
||||
const sectionKey: string = sectionGroup.section.name;
|
||||
|
||||
return (
|
||||
<BasicForm
|
||||
hideSubmitButton={true}
|
||||
ref={formRef}
|
||||
ref={(ref: FormProps<FormValues<JSONObject>> | null) => {
|
||||
formRefs.current[sectionKey] = ref;
|
||||
}}
|
||||
values={{
|
||||
...(component?.arguments || {}),
|
||||
}}
|
||||
@@ -137,54 +221,190 @@ const ArgumentsForm: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}}
|
||||
onFormValidationErrorChanged={(hasError: boolean) => {
|
||||
if (hasFormValidationErrors["id"] !== hasError) {
|
||||
if (hasFormValidationErrors[sectionKey] !== hasError) {
|
||||
setHasFormValidationErrors({
|
||||
...hasFormValidationErrors,
|
||||
id: hasError,
|
||||
[sectionKey]: hasError,
|
||||
});
|
||||
}
|
||||
}}
|
||||
fields={
|
||||
componentArguments &&
|
||||
componentArguments.map(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return {
|
||||
title: `${arg.name}`,
|
||||
description: `${
|
||||
arg.required ? "Required" : "Optional"
|
||||
}. ${arg.description}`,
|
||||
field: {
|
||||
[arg.id]: true,
|
||||
},
|
||||
required: arg.required,
|
||||
placeholder: arg.placeholder,
|
||||
...ComponentInputTypeToFormFieldType.getFormFieldTypeByComponentInputType(
|
||||
arg.type,
|
||||
arg.dropdownOptions,
|
||||
),
|
||||
getCustomElement: getCustomElememnt(arg),
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
fields={sectionGroup.args.map(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return {
|
||||
title: arg.name,
|
||||
description: arg.description,
|
||||
field: {
|
||||
[arg.id]: true,
|
||||
},
|
||||
required: arg.required,
|
||||
placeholder: arg.placeholder,
|
||||
...ComponentInputTypeToFormFieldType.getFormFieldTypeByComponentInputType(
|
||||
arg.type,
|
||||
arg.dropdownOptions,
|
||||
),
|
||||
getCustomElement: getCustomElement(arg),
|
||||
};
|
||||
},
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 mt-3">
|
||||
<div className="mt-5 mb-5">
|
||||
<h2 className="text-base font-medium text-gray-500">Arguments</h2>
|
||||
<p className="text-sm font-medium text-gray-400 mb-5">
|
||||
Arguments for this component
|
||||
</p>
|
||||
{componentArguments && componentArguments.length === 0 && (
|
||||
<ErrorMessage
|
||||
message={"This component does not take any arguments."}
|
||||
// Check if this component has a MetricsQueryConfigs argument
|
||||
const hasMultiQueryArg: boolean = componentArguments.some(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return arg.type === ComponentInputType.MetricsQueryConfigs;
|
||||
},
|
||||
);
|
||||
|
||||
const multiQueryArg: ComponentArgument<DashboardBaseComponent> | undefined =
|
||||
componentArguments.find(
|
||||
(arg: ComponentArgument<DashboardBaseComponent>) => {
|
||||
return arg.type === ComponentInputType.MetricsQueryConfigs;
|
||||
},
|
||||
);
|
||||
|
||||
const renderMultiQuerySection: () => ReactElement | null =
|
||||
(): ReactElement | null => {
|
||||
if (!hasMultiQueryArg || !multiQueryArg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{multiQueryConfigs.map(
|
||||
(queryConfig: MetricQueryConfigData, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-4 p-3 border border-gray-200 rounded-lg bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Query {index + 2}
|
||||
</span>
|
||||
<Button
|
||||
title="Remove"
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
icon={IconProp.Trash}
|
||||
onClick={() => {
|
||||
const updated: Array<MetricQueryConfigData> = [
|
||||
...multiQueryConfigs,
|
||||
];
|
||||
updated.splice(index, 1);
|
||||
setMultiQueryConfigs(updated);
|
||||
setComponent({
|
||||
...component,
|
||||
arguments: {
|
||||
...((component.arguments as JSONObject) || {}),
|
||||
metricQueryConfigs: updated as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<MetricQueryConfig
|
||||
data={queryConfig}
|
||||
metricTypes={props.metrics.metricTypes}
|
||||
telemetryAttributes={props.metrics.telemetryAttributes}
|
||||
hideCard={true}
|
||||
onChange={(data: MetricQueryConfigData) => {
|
||||
const updated: Array<MetricQueryConfigData> = [
|
||||
...multiQueryConfigs,
|
||||
];
|
||||
updated[index] = data;
|
||||
setMultiQueryConfigs(updated);
|
||||
setComponent({
|
||||
...component,
|
||||
arguments: {
|
||||
...((component.arguments as JSONObject) || {}),
|
||||
metricQueryConfigs: updated as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
<Button
|
||||
title="Add Query"
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
const variableIndex: number = multiQueryConfigs.length + 1; // +1 because primary query is "a"
|
||||
const variableLetter: string = String.fromCharCode(
|
||||
97 + variableIndex,
|
||||
); // b, c, d, ...
|
||||
const newQuery: MetricQueryConfigData = {
|
||||
metricAliasData: {
|
||||
metricVariable: variableLetter,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
legend: undefined,
|
||||
legendUnit: undefined,
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {},
|
||||
groupBy: undefined,
|
||||
},
|
||||
};
|
||||
const updated: Array<MetricQueryConfigData> = [
|
||||
...multiQueryConfigs,
|
||||
newQuery,
|
||||
];
|
||||
setMultiQueryConfigs(updated);
|
||||
setComponent({
|
||||
...component,
|
||||
arguments: {
|
||||
...((component.arguments as JSONObject) || {}),
|
||||
metricQueryConfigs: updated as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{componentArguments && componentArguments.length > 0 && getForm()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const sectionGroups: Array<SectionGroup> = groupArgumentsBySections();
|
||||
|
||||
return (
|
||||
<div className="mb-3 mt-1">
|
||||
{componentArguments && componentArguments.length === 0 && (
|
||||
<ErrorMessage message={"This component does not take any arguments."} />
|
||||
)}
|
||||
{sectionGroups.map((sectionGroup: SectionGroup, index: number) => {
|
||||
const isFirstSection: boolean = index === 0;
|
||||
const shouldCollapse: boolean =
|
||||
!isFirstSection && (sectionGroup.section.defaultCollapsed ?? false);
|
||||
|
||||
return (
|
||||
<div key={sectionGroup.section.name} className="mt-3">
|
||||
<CollapsibleSection
|
||||
title={sectionGroup.section.name}
|
||||
description={sectionGroup.section.description}
|
||||
variant="bordered"
|
||||
defaultCollapsed={shouldCollapse}
|
||||
>
|
||||
<div>
|
||||
{renderSectionForm(sectionGroup)}
|
||||
{/* Render multi-query UI inside the Data Source section */}
|
||||
{sectionGroup.section.name === "Data Source" &&
|
||||
renderMultiQuerySection()}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* If no Data Source section exists, render multi-query at end */}
|
||||
{!sectionGroups.some((g: SectionGroup) => {
|
||||
return g.section.name === "Data Source";
|
||||
}) && renderMultiQuerySection()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize";
|
||||
import DefaultDashboardSize, {
|
||||
GetDashboardUnitWidthInPx,
|
||||
SpaceBetweenUnitsInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import BlankRowElement from "./BlankRow";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
|
||||
@@ -21,22 +24,57 @@ const BlankCanvasElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (!props.isEditMode && props.dashboardViewConfig.components.length === 0) {
|
||||
return (
|
||||
<div className="ml-1 mr-1 rounded p-10 border-2 border-gray-100 text-sm text-gray-400 text-center pt-24 pb-24">
|
||||
No components added to this dashboard. Please add one to get started.
|
||||
<div
|
||||
className="mx-3 mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50/50 text-center py-20 px-10"
|
||||
style={{ boxShadow: "0 2px 8px -2px rgba(0, 0, 0, 0.06)" }}
|
||||
>
|
||||
<div
|
||||
className="mx-auto w-14 h-14 rounded-full bg-white border border-gray-200 flex items-center justify-center mb-4"
|
||||
style={{ boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.04)" }}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
No widgets yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 max-w-sm mx-auto">
|
||||
This dashboard does not have any widgets.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// have a grid with width cols and height rows
|
||||
const gap: number = SpaceBetweenUnitsInPx;
|
||||
const unitSize: number = GetDashboardUnitWidthInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-${width}`}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${width}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
gridAutoRows: `${unitSize}px`,
|
||||
borderRadius: "16px",
|
||||
}}
|
||||
>
|
||||
{Array.from(Array(height).keys()).map((_: number, index: number) => {
|
||||
return (
|
||||
<BlankRowElement
|
||||
key={index}
|
||||
totalCurrentDashboardWidthInPx={
|
||||
props.totalCurrentDashboardWidthInPx
|
||||
}
|
||||
isEditMode={props.isEditMode}
|
||||
rowNumber={index}
|
||||
onClick={(top: number, left: number) => {
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
import {
|
||||
GetDashboardUnitHeightInPx,
|
||||
MarginForEachUnitInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
isEditMode: boolean;
|
||||
onClick: () => void;
|
||||
currentTotalDashboardWidthInPx: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const BlankDashboardUnitElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const heightOfUnitInPx: number = GetDashboardUnitHeightInPx(
|
||||
props.currentTotalDashboardWidthInPx,
|
||||
);
|
||||
|
||||
const widthOfUnitInPx: number = heightOfUnitInPx; // its a square
|
||||
|
||||
let className: string = "";
|
||||
|
||||
if (props.isEditMode) {
|
||||
className +=
|
||||
"border-2 border-gray-100 rounded hover:border-gray-300 hover:bg-gray-100 cursor-pointer";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
className={className}
|
||||
className={
|
||||
props.isEditMode
|
||||
? "rounded-md cursor-pointer transition-all duration-150"
|
||||
: "transition-all duration-150"
|
||||
}
|
||||
onClick={() => {
|
||||
props.onClick();
|
||||
}}
|
||||
style={{
|
||||
width: widthOfUnitInPx + "px",
|
||||
height: heightOfUnitInPx + "px",
|
||||
margin: MarginForEachUnitInPx + "px",
|
||||
border: props.isEditMode
|
||||
? "1px solid rgba(203, 213, 225, 0.4)"
|
||||
: "none",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
></div>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface ComponentProps {
|
||||
rowNumber: number;
|
||||
onClick: (top: number, left: number) => void;
|
||||
isEditMode: boolean;
|
||||
totalCurrentDashboardWidthInPx: number;
|
||||
}
|
||||
|
||||
const BlankRowElement: FunctionComponent<ComponentProps> = (
|
||||
@@ -20,9 +19,6 @@ const BlankRowElement: FunctionComponent<ComponentProps> = (
|
||||
(_: number, index: number) => {
|
||||
return (
|
||||
<BlankDashboardUnitElement
|
||||
currentTotalDashboardWidthInPx={
|
||||
props.totalCurrentDashboardWidthInPx
|
||||
}
|
||||
key={props.rowNumber + "-" + index}
|
||||
isEditMode={props.isEditMode}
|
||||
onClick={() => {
|
||||
|
||||
@@ -53,6 +53,12 @@ export default class ComponentInputTypeToFormFieldType {
|
||||
};
|
||||
}
|
||||
|
||||
if (componentInputType === ComponentInputType.MetricsQueryConfigs) {
|
||||
return {
|
||||
fieldType: FormFieldSchemaType.CustomComponent,
|
||||
};
|
||||
}
|
||||
|
||||
if (componentInputType === ComponentInputType.Dropdown) {
|
||||
return {
|
||||
fieldType: FormFieldSchemaType.Dropdown,
|
||||
|
||||
@@ -2,7 +2,6 @@ import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Divider from "Common/UI/Components/Divider/Divider";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import SideOver from "Common/UI/Components/SideOver/SideOver";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
@@ -55,7 +54,7 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
leftFooterElement={
|
||||
<Button
|
||||
title={`Delete Component`}
|
||||
title={`Delete Widget`}
|
||||
icon={IconProp.Trash}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
onClick={() => {
|
||||
@@ -67,12 +66,12 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
<>
|
||||
{showDeleteConfirmation && (
|
||||
<ConfirmModal
|
||||
title={`Delete?`}
|
||||
description={`Are you sure you want to delete this component? This action is not recoverable.`}
|
||||
title={`Delete Widget?`}
|
||||
description={`Are you sure you want to delete this widget? This action cannot be undone.`}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirmation(false);
|
||||
}}
|
||||
submitButtonText={"Delete"}
|
||||
submitButtonText={"Delete Widget"}
|
||||
onSubmit={() => {
|
||||
props.onComponentDelete(component);
|
||||
setShowDeleteConfirmation(false);
|
||||
@@ -82,7 +81,16 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
{/* Widget type and size info */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold bg-indigo-50 text-indigo-700 capitalize">
|
||||
{component.componentType}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{component.widthInDashboardUnits} x{" "}
|
||||
{component.heightInDashboardUnits} units
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ArgumentsForm
|
||||
component={component}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import BlankCanvasElement from "./BlankCanvas";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize";
|
||||
import DefaultDashboardSize, {
|
||||
GetDashboardUnitWidthInPx,
|
||||
SpaceBetweenUnitsInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import BlankDashboardUnitElement from "./BlankDashboardUnit";
|
||||
import DashboardBaseComponentElement from "../Components/DashboardBaseComponent";
|
||||
@@ -25,6 +28,7 @@ export interface ComponentProps {
|
||||
telemetryAttributes: string[];
|
||||
};
|
||||
dashboardStartAndEndDate: RangeStartAndEndDateTime;
|
||||
refreshTick?: number | undefined;
|
||||
}
|
||||
|
||||
const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
@@ -33,6 +37,11 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
const dashboardCanvasRef: React.RefObject<HTMLDivElement> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const gap: number = SpaceBetweenUnitsInPx;
|
||||
const unitSize: number = GetDashboardUnitWidthInPx(
|
||||
props.currentTotalDashboardWidthInPx,
|
||||
);
|
||||
|
||||
const renderComponents: GetReactElementFunction = (): ReactElement => {
|
||||
const canvasHeight: number =
|
||||
props.dashboardViewConfig.heightInDashboardUnits ||
|
||||
@@ -51,7 +60,7 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
grid[row] = new Array(canvasWidth).fill(null);
|
||||
}
|
||||
|
||||
let maxHeightInDashboardUnits: number = 0; // max height of the grid
|
||||
let maxHeightInDashboardUnits: number = 0;
|
||||
|
||||
// Place components in the grid
|
||||
allComponents.forEach((component: DashboardBaseComponent) => {
|
||||
@@ -105,16 +114,11 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (!component) {
|
||||
if (!props.isEditMode && i >= maxHeightInDashboardUnits) {
|
||||
// if we are not in edit mode, we should not render blank units
|
||||
continue;
|
||||
}
|
||||
|
||||
// render a blank unit
|
||||
renderedComponents.push(
|
||||
<BlankDashboardUnitElement
|
||||
currentTotalDashboardWidthInPx={
|
||||
props.currentTotalDashboardWidthInPx
|
||||
}
|
||||
isEditMode={props.isEditMode}
|
||||
key={`blank-unit-${i}-${j}`}
|
||||
onClick={() => {
|
||||
@@ -127,8 +131,6 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}
|
||||
|
||||
// remove nulls from the renderedComponents array
|
||||
|
||||
const finalRenderedComponents: Array<ReactElement> =
|
||||
renderedComponents.filter(
|
||||
(component: ReactElement | null): component is ReactElement => {
|
||||
@@ -136,10 +138,18 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
);
|
||||
|
||||
const width: number = DefaultDashboardSize.widthInDashboardUnits;
|
||||
|
||||
return (
|
||||
<div ref={dashboardCanvasRef} className={`grid grid-cols-${width}`}>
|
||||
<div
|
||||
ref={dashboardCanvasRef}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${canvasWidth}, 1fr)`,
|
||||
gap: `${gap}px`,
|
||||
gridAutoRows: `${unitSize}px`,
|
||||
borderRadius: "16px",
|
||||
padding: "8px",
|
||||
}}
|
||||
>
|
||||
{finalRenderedComponents}
|
||||
</div>
|
||||
);
|
||||
@@ -191,14 +201,13 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
return c.componentId.toString() === componentId.toString();
|
||||
});
|
||||
|
||||
const currentUnitSizeInPx: number =
|
||||
props.currentTotalDashboardWidthInPx / 12;
|
||||
const w: number = component?.widthInDashboardUnits || 0;
|
||||
const h: number = component?.heightInDashboardUnits || 0;
|
||||
|
||||
const heightOfComponentInPx: number =
|
||||
currentUnitSizeInPx * (component?.heightInDashboardUnits || 0);
|
||||
// Compute pixel dimensions for child component rendering (charts, etc.)
|
||||
const widthOfComponentInPx: number = unitSize * w + gap * (w - 1);
|
||||
|
||||
const widthOfComponentInPx: number =
|
||||
currentUnitSizeInPx * (component?.widthInDashboardUnits || 0);
|
||||
const heightOfComponentInPx: number = unitSize * h + gap * (h - 1);
|
||||
|
||||
return (
|
||||
<DashboardBaseComponentElement
|
||||
@@ -221,8 +230,8 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
updateComponent(updatedComponent);
|
||||
}}
|
||||
isSelected={isSelected}
|
||||
refreshTick={props.refreshTick}
|
||||
onClick={() => {
|
||||
// component is selected
|
||||
props.onComponentSelected(componentId);
|
||||
}}
|
||||
/>
|
||||
@@ -252,7 +261,6 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
description="Edit the settings of this component"
|
||||
dashboardViewConfig={props.dashboardViewConfig}
|
||||
onClose={() => {
|
||||
// unselect this component.
|
||||
props.onComponentUnselected();
|
||||
}}
|
||||
onComponentDelete={() => {
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardTextComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent";
|
||||
import DashboardChartComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardChartComponent";
|
||||
import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
import DashboardTableComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent";
|
||||
import DashboardGaugeComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent";
|
||||
import DashboardLogStreamComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardLogStreamComponent";
|
||||
import DashboardTraceListComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTraceListComponent";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import DashboardChartComponent from "./DashboardChartComponent";
|
||||
import DashboardValueComponent from "./DashboardValueComponent";
|
||||
import DashboardTextComponent from "./DashboardTextComponent";
|
||||
import DashboardTableComponent from "./DashboardTableComponent";
|
||||
import DashboardGaugeComponent from "./DashboardGaugeComponent";
|
||||
import DashboardLogStreamComponent from "./DashboardLogStreamComponent";
|
||||
import DashboardTraceListComponent from "./DashboardTraceListComponent";
|
||||
import DefaultDashboardSize, {
|
||||
GetDashboardComponentHeightInDashboardUnits,
|
||||
GetDashboardComponentWidthInDashboardUnits,
|
||||
GetDashboardUnitHeightInPx,
|
||||
GetDashboardUnitWidthInPx,
|
||||
MarginForEachUnitInPx,
|
||||
SpaceBetweenUnitsInPx,
|
||||
} from "Common/Types/Dashboard/DashboardSize";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
@@ -37,324 +50,437 @@ export interface DashboardBaseComponentProps {
|
||||
dashboardViewConfig: DashboardViewConfig;
|
||||
dashboardStartAndEndDate: RangeStartAndEndDateTime;
|
||||
metricTypes: Array<MetricType>;
|
||||
refreshTick?: number | undefined;
|
||||
}
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* ────────────────────────────────────────────────────────────
|
||||
* All mutable drag/resize state lives here, outside React.
|
||||
* Nothing in this struct triggers a re-render.
|
||||
* ────────────────────────────────────────────────────────────
|
||||
*/
|
||||
interface DragSession {
|
||||
mode: "move" | "resize-w" | "resize-h" | "resize-corner";
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
// Snapped values at the START of the gesture (dashboard units)
|
||||
originTop: number;
|
||||
originLeft: number;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
// Live snapped values (updated every mousemove, used on commit)
|
||||
liveTop: number;
|
||||
liveLeft: number;
|
||||
liveWidth: number;
|
||||
liveHeight: number;
|
||||
}
|
||||
|
||||
const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
// ── Derived data ──────────────────────────────────────────
|
||||
const component: DashboardBaseComponent =
|
||||
props.dashboardViewConfig.components.find(
|
||||
(component: DashboardBaseComponent) => {
|
||||
return (
|
||||
component.componentId.toString() === props.componentId.toString()
|
||||
);
|
||||
},
|
||||
) as DashboardBaseComponent;
|
||||
props.dashboardViewConfig.components.find((c: DashboardBaseComponent) => {
|
||||
return c.componentId.toString() === props.componentId.toString();
|
||||
}) as DashboardBaseComponent;
|
||||
|
||||
const widthOfComponent: number = component.widthInDashboardUnits;
|
||||
const heightOfComponent: number = component.heightInDashboardUnits;
|
||||
|
||||
const [topInPx, setTopInPx] = React.useState<number>(0);
|
||||
const [leftInPx, setLeftInPx] = React.useState<number>(0);
|
||||
// ── Minimal React state (only for hover gating) ───────────
|
||||
const [isHovered, setIsHovered] = useState<boolean>(false);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
|
||||
let className: string = `relative rounded-md col-span-${widthOfComponent} row-span-${heightOfComponent} p-2 bg-white border-2 border-solid border-gray-100`;
|
||||
// ── Refs ──────────────────────────────────────────────────
|
||||
const elRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef: React.RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
const sessionRef: React.MutableRefObject<DragSession | null> =
|
||||
useRef<DragSession | null>(null);
|
||||
const overlayRef: React.MutableRefObject<HTMLDivElement | null> =
|
||||
useRef<HTMLDivElement | null>(null);
|
||||
const latestProps: React.MutableRefObject<ComponentProps> =
|
||||
useRef<ComponentProps>(props);
|
||||
const latestComponent: React.MutableRefObject<DashboardBaseComponent> =
|
||||
useRef<DashboardBaseComponent>(component);
|
||||
latestProps.current = props;
|
||||
latestComponent.current = component;
|
||||
|
||||
if (props.isEditMode) {
|
||||
className += " cursor-pointer";
|
||||
// ── Core imperative handlers (stable — no deps) ──────────
|
||||
|
||||
function updateTooltip(session: DragSession): void {
|
||||
if (!tooltipRef.current) {
|
||||
return;
|
||||
}
|
||||
if (session.mode === "move") {
|
||||
tooltipRef.current.textContent = `${session.liveLeft}, ${session.liveTop}`;
|
||||
} else {
|
||||
tooltipRef.current.textContent = `${session.liveWidth} \u00d7 ${session.liveHeight}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.isSelected && props.isEditMode) {
|
||||
className += " border-2 border-blue-300";
|
||||
}
|
||||
|
||||
const dashboardComponentRef: React.RefObject<HTMLDivElement> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const refreshTopAndLeftInPx: () => void = () => {
|
||||
if (dashboardComponentRef.current === null) {
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
const s: DragSession | null = sessionRef.current;
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
|
||||
const topInPx: number =
|
||||
dashboardComponentRef.current.getBoundingClientRect().top;
|
||||
const leftInPx: number =
|
||||
dashboardComponentRef.current.getBoundingClientRect().left;
|
||||
const p: ComponentProps = latestProps.current;
|
||||
const c: DashboardBaseComponent = latestComponent.current;
|
||||
const uW: number = GetDashboardUnitWidthInPx(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
const uH: number = GetDashboardUnitHeightInPx(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
const g: number = SpaceBetweenUnitsInPx;
|
||||
|
||||
setTopInPx(topInPx);
|
||||
setLeftInPx(leftInPx);
|
||||
};
|
||||
const dxPx: number = e.clientX - s.startMouseX;
|
||||
const dyPx: number = e.clientY - s.startMouseY;
|
||||
|
||||
const el: HTMLDivElement | null = elRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.mode === "move") {
|
||||
el.style.transform = `translate(${dxPx}px, ${dyPx}px) scale(1.01)`;
|
||||
el.style.zIndex = "100";
|
||||
|
||||
const dxUnits: number = Math.round(dxPx / uW);
|
||||
const dyUnits: number = Math.round(dyPx / uH);
|
||||
|
||||
let newLeft: number = s.originLeft + dxUnits;
|
||||
let newTop: number = s.originTop + dyUnits;
|
||||
const maxLeft: number =
|
||||
DefaultDashboardSize.widthInDashboardUnits - c.widthInDashboardUnits;
|
||||
const maxTop: number =
|
||||
p.dashboardViewConfig.heightInDashboardUnits - c.heightInDashboardUnits;
|
||||
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
|
||||
newTop = Math.max(0, Math.min(newTop, maxTop));
|
||||
|
||||
s.liveLeft = newLeft;
|
||||
s.liveTop = newTop;
|
||||
|
||||
updateTooltip(s);
|
||||
} else {
|
||||
const rect: DOMRect = el.getBoundingClientRect();
|
||||
|
||||
if (s.mode === "resize-w" || s.mode === "resize-corner") {
|
||||
const wPx: number = Math.max(
|
||||
uW,
|
||||
e.pageX - (window.scrollX + rect.left),
|
||||
);
|
||||
let wUnits: number = GetDashboardComponentWidthInDashboardUnits(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
wPx,
|
||||
);
|
||||
wUnits = Math.max(c.minWidthInDashboardUnits, wUnits);
|
||||
wUnits = Math.min(DefaultDashboardSize.widthInDashboardUnits, wUnits);
|
||||
s.liveWidth = wUnits;
|
||||
|
||||
const newWidthPx: number = uW * wUnits + g * (wUnits - 1);
|
||||
el.style.width = `${newWidthPx}px`;
|
||||
}
|
||||
|
||||
if (s.mode === "resize-h" || s.mode === "resize-corner") {
|
||||
const hPx: number = Math.max(uH, e.pageY - (window.scrollY + rect.top));
|
||||
let hUnits: number = GetDashboardComponentHeightInDashboardUnits(
|
||||
p.totalCurrentDashboardWidthInPx,
|
||||
hPx,
|
||||
);
|
||||
hUnits = Math.max(c.minHeightInDashboardUnits, hUnits);
|
||||
s.liveHeight = hUnits;
|
||||
|
||||
const newHeightPx: number = uH * hUnits + g * (hUnits - 1);
|
||||
el.style.height = `${newHeightPx}px`;
|
||||
}
|
||||
|
||||
updateTooltip(s);
|
||||
}
|
||||
}
|
||||
|
||||
function removeOverlay(): void {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.remove();
|
||||
overlayRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createOverlay(cursor: string): void {
|
||||
removeOverlay();
|
||||
const overlay: HTMLDivElement = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.inset = "0";
|
||||
overlay.style.zIndex = "9999";
|
||||
overlay.style.cursor = cursor;
|
||||
overlay.style.background = "transparent";
|
||||
document.body.appendChild(overlay);
|
||||
overlayRef.current = overlay;
|
||||
}
|
||||
|
||||
function onMouseUp(): void {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
removeOverlay();
|
||||
|
||||
const s: DragSession | null = sessionRef.current;
|
||||
const el: HTMLDivElement | null = elRef.current;
|
||||
|
||||
if (el) {
|
||||
el.style.transform = "";
|
||||
el.style.zIndex = "";
|
||||
el.style.width = "";
|
||||
el.style.height = "";
|
||||
}
|
||||
|
||||
sessionRef.current = null;
|
||||
setIsDragging(false);
|
||||
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c: DashboardBaseComponent = latestComponent.current;
|
||||
const p: ComponentProps = latestProps.current;
|
||||
|
||||
const updated: DashboardBaseComponent = { ...c };
|
||||
let changed: boolean = false;
|
||||
|
||||
if (s.mode === "move") {
|
||||
if (
|
||||
s.liveTop !== c.topInDashboardUnits ||
|
||||
s.liveLeft !== c.leftInDashboardUnits
|
||||
) {
|
||||
updated.topInDashboardUnits = s.liveTop;
|
||||
updated.leftInDashboardUnits = s.liveLeft;
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
if (s.liveWidth !== c.widthInDashboardUnits) {
|
||||
updated.widthInDashboardUnits = s.liveWidth;
|
||||
changed = true;
|
||||
}
|
||||
if (s.liveHeight !== c.heightInDashboardUnits) {
|
||||
updated.heightInDashboardUnits = s.liveHeight;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
p.onComponentUpdate(updated);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshTopAndLeftInPx();
|
||||
}, [props.dashboardViewConfig]);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
removeOverlay();
|
||||
};
|
||||
}, []);
|
||||
|
||||
type MoveComponentFunction = (mouseEvent: MouseEvent) => void;
|
||||
// ── Start a drag / resize session ─────────────────────────
|
||||
function startSession(e: React.MouseEvent, mode: DragSession["mode"]): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const moveComponent: MoveComponentFunction = (
|
||||
mouseEvent: MouseEvent,
|
||||
): void => {
|
||||
const dashboardComponentOldTopInPx: number = topInPx;
|
||||
const dashboardComponentOldLeftInPx: number = leftInPx;
|
||||
const c: DashboardBaseComponent = latestComponent.current;
|
||||
|
||||
const newMoveToTop: number = mouseEvent.clientY;
|
||||
const newMoveToLeft: number = mouseEvent.clientX;
|
||||
|
||||
const deltaXInPx: number = newMoveToLeft - dashboardComponentOldLeftInPx;
|
||||
const deltaYInPx: number = newMoveToTop - dashboardComponentOldTopInPx;
|
||||
|
||||
const eachDashboardUnitInPx: number = GetDashboardUnitWidthInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
|
||||
const deltaXInDashboardUnits: number = Math.round(
|
||||
deltaXInPx / eachDashboardUnitInPx,
|
||||
);
|
||||
const deltaYInDashboardUnits: number = Math.round(
|
||||
deltaYInPx / eachDashboardUnitInPx,
|
||||
);
|
||||
|
||||
let newTopInDashboardUnits: number =
|
||||
component.topInDashboardUnits + deltaYInDashboardUnits;
|
||||
let newLeftInDashboardUnits: number =
|
||||
component.leftInDashboardUnits + deltaXInDashboardUnits;
|
||||
|
||||
// now make sure these are within the bounds of the dashboard inch component width and height in dashbosrd units
|
||||
|
||||
const dahsboardTotalWidthInDashboardUnits: number =
|
||||
DefaultDashboardSize.widthInDashboardUnits; // width does not change
|
||||
const dashboardTotalHeightInDashboardUnits: number =
|
||||
props.dashboardViewConfig.heightInDashboardUnits;
|
||||
|
||||
const heightOfTheComponntInDashboardUnits: number =
|
||||
component.heightInDashboardUnits;
|
||||
|
||||
const widthOfTheComponentInDashboardUnits: number =
|
||||
component.widthInDashboardUnits;
|
||||
|
||||
// if it goes outside the bounds then max it out to the bounds
|
||||
|
||||
if (
|
||||
newTopInDashboardUnits + heightOfTheComponntInDashboardUnits >
|
||||
dashboardTotalHeightInDashboardUnits
|
||||
) {
|
||||
newTopInDashboardUnits =
|
||||
dashboardTotalHeightInDashboardUnits -
|
||||
heightOfTheComponntInDashboardUnits;
|
||||
}
|
||||
|
||||
if (
|
||||
newLeftInDashboardUnits + widthOfTheComponentInDashboardUnits >
|
||||
dahsboardTotalWidthInDashboardUnits
|
||||
) {
|
||||
newLeftInDashboardUnits =
|
||||
dahsboardTotalWidthInDashboardUnits -
|
||||
widthOfTheComponentInDashboardUnits;
|
||||
}
|
||||
|
||||
// make sure they are not negative
|
||||
|
||||
if (newTopInDashboardUnits < 0) {
|
||||
newTopInDashboardUnits = 0;
|
||||
}
|
||||
|
||||
if (newLeftInDashboardUnits < 0) {
|
||||
newLeftInDashboardUnits = 0;
|
||||
}
|
||||
|
||||
// update the component
|
||||
const newComponentProps: DashboardBaseComponent = {
|
||||
...component,
|
||||
topInDashboardUnits: newTopInDashboardUnits,
|
||||
leftInDashboardUnits: newLeftInDashboardUnits,
|
||||
const session: DragSession = {
|
||||
mode,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
originTop: c.topInDashboardUnits,
|
||||
originLeft: c.leftInDashboardUnits,
|
||||
originWidth: c.widthInDashboardUnits,
|
||||
originHeight: c.heightInDashboardUnits,
|
||||
liveTop: c.topInDashboardUnits,
|
||||
liveLeft: c.leftInDashboardUnits,
|
||||
liveWidth: c.widthInDashboardUnits,
|
||||
liveHeight: c.heightInDashboardUnits,
|
||||
};
|
||||
|
||||
props.onComponentUpdate(newComponentProps);
|
||||
};
|
||||
sessionRef.current = session;
|
||||
setIsDragging(true);
|
||||
|
||||
const resizeWidth: (event: MouseEvent) => void = (event: MouseEvent) => {
|
||||
if (dashboardComponentRef.current === null) {
|
||||
return;
|
||||
updateTooltip(session);
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
let cursor: string = "grabbing";
|
||||
if (mode === "resize-w") {
|
||||
cursor = "ew-resize";
|
||||
} else if (mode === "resize-h") {
|
||||
cursor = "ns-resize";
|
||||
} else if (mode === "resize-corner") {
|
||||
cursor = "nwse-resize";
|
||||
}
|
||||
|
||||
let newDashboardComponentwidthInPx: number =
|
||||
event.pageX -
|
||||
(window.scrollX +
|
||||
dashboardComponentRef.current.getBoundingClientRect().left);
|
||||
if (
|
||||
GetDashboardUnitWidthInPx(props.totalCurrentDashboardWidthInPx) >
|
||||
newDashboardComponentwidthInPx
|
||||
) {
|
||||
newDashboardComponentwidthInPx = GetDashboardUnitWidthInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
}
|
||||
document.body.style.cursor = cursor;
|
||||
createOverlay(cursor);
|
||||
}
|
||||
|
||||
// get this in dashboard units.,
|
||||
let widthInDashboardUnits: number =
|
||||
GetDashboardComponentWidthInDashboardUnits(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
newDashboardComponentwidthInPx,
|
||||
);
|
||||
// ── Styling ───────────────────────────────────────────────
|
||||
const showHandles: boolean =
|
||||
props.isEditMode && (props.isSelected || isHovered || isDragging);
|
||||
|
||||
// if this width is less than the min width then set it to min width
|
||||
let borderClass: string = "border-gray-200";
|
||||
let extraClass: string = "";
|
||||
|
||||
if (widthInDashboardUnits < component.minWidthInDashboardUnits) {
|
||||
widthInDashboardUnits = component.minWidthInDashboardUnits;
|
||||
}
|
||||
if (isDragging) {
|
||||
borderClass = "border-blue-400";
|
||||
extraClass = "ring-2 ring-blue-400/40 shadow-2xl";
|
||||
} else if (props.isSelected && props.isEditMode) {
|
||||
borderClass = "border-blue-400";
|
||||
extraClass = "ring-2 ring-blue-100 shadow-lg z-10";
|
||||
} else if (props.isEditMode && isHovered) {
|
||||
borderClass = "border-blue-300";
|
||||
extraClass = "shadow-md z-10 cursor-pointer";
|
||||
} else if (props.isEditMode) {
|
||||
extraClass =
|
||||
"hover:border-blue-300 hover:shadow-md cursor-pointer transition-all duration-200";
|
||||
} else {
|
||||
extraClass = "hover:shadow-md transition-shadow duration-200";
|
||||
}
|
||||
|
||||
// if its more than the max width of dashboard.
|
||||
if (widthInDashboardUnits > DefaultDashboardSize.widthInDashboardUnits) {
|
||||
widthInDashboardUnits = DefaultDashboardSize.widthInDashboardUnits;
|
||||
}
|
||||
const className: string = [
|
||||
"relative rounded-xl bg-white border overflow-hidden",
|
||||
borderClass,
|
||||
extraClass,
|
||||
].join(" ");
|
||||
|
||||
// update the component
|
||||
const newComponentProps: DashboardBaseComponent = {
|
||||
...component,
|
||||
widthInDashboardUnits: widthInDashboardUnits,
|
||||
};
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
|
||||
props.onComponentUpdate(newComponentProps);
|
||||
};
|
||||
|
||||
const resizeHeight: (event: MouseEvent) => void = (event: MouseEvent) => {
|
||||
if (dashboardComponentRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newDashboardComponentHeightInPx: number =
|
||||
event.pageY -
|
||||
(window.scrollY +
|
||||
dashboardComponentRef.current.getBoundingClientRect().top);
|
||||
|
||||
if (
|
||||
GetDashboardUnitHeightInPx(props.totalCurrentDashboardWidthInPx) >
|
||||
newDashboardComponentHeightInPx
|
||||
) {
|
||||
newDashboardComponentHeightInPx = GetDashboardUnitHeightInPx(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
);
|
||||
}
|
||||
|
||||
// get this in dashboard units
|
||||
let heightInDashboardUnits: number =
|
||||
GetDashboardComponentHeightInDashboardUnits(
|
||||
props.totalCurrentDashboardWidthInPx,
|
||||
newDashboardComponentHeightInPx,
|
||||
);
|
||||
|
||||
// if this height is less tan the min height then set it to min height
|
||||
|
||||
if (heightInDashboardUnits < component.minHeightInDashboardUnits) {
|
||||
heightInDashboardUnits = component.minHeightInDashboardUnits;
|
||||
}
|
||||
|
||||
// update the component
|
||||
const newComponentProps: DashboardBaseComponent = {
|
||||
...component,
|
||||
heightInDashboardUnits: heightInDashboardUnits,
|
||||
};
|
||||
|
||||
props.onComponentUpdate(newComponentProps);
|
||||
};
|
||||
|
||||
const stopResizeAndMove: () => void = () => {
|
||||
window.removeEventListener("mousemove", resizeHeight);
|
||||
window.removeEventListener("mousemove", resizeWidth);
|
||||
window.removeEventListener("mousemove", moveComponent);
|
||||
window.removeEventListener("mouseup", stopResizeAndMove);
|
||||
};
|
||||
|
||||
const getResizeWidthElement: GetReactElementFunction = (): ReactElement => {
|
||||
if (!props.isSelected || !props.isEditMode) {
|
||||
const getMoveHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let resizeCursorIcon: string = "cursor-ew-resize";
|
||||
|
||||
// if already at min width then change icon to e-resize
|
||||
|
||||
if (component.widthInDashboardUnits <= component.minWidthInDashboardUnits) {
|
||||
resizeCursorIcon = "cursor-e-resize";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-20 flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
top: "calc(50% - 20px)",
|
||||
right: "-5px",
|
||||
height: "28px",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(59,130,246,0.08) 0%, rgba(59,130,246,0.02) 100%)",
|
||||
borderBottom: "1px solid rgba(59,130,246,0.12)",
|
||||
}}
|
||||
onMouseDown={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
window.addEventListener("mousemove", resizeWidth);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "move");
|
||||
}}
|
||||
className={`resize-width-element ${resizeCursorIcon} absolute right-0 w-2 h-12 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer`}
|
||||
></div>
|
||||
>
|
||||
<div className="flex items-center gap-0.5 opacity-40 hover:opacity-70 transition-opacity">
|
||||
<svg width="20" height="10" viewBox="0 0 20 10" fill="none">
|
||||
<circle cx="4" cy="3" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="10" cy="3" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="16" cy="3" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="4" cy="7" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="10" cy="7" r="1.2" fill="#3b82f6" />
|
||||
<circle cx="16" cy="7" r="1.2" fill="#3b82f6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMoveElement: GetReactElementFunction = (): ReactElement => {
|
||||
// if not selected, then return null
|
||||
|
||||
if (!props.isSelected || !props.isEditMode) {
|
||||
const getResizeWidthHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-20 group"
|
||||
style={{
|
||||
top: "-9px",
|
||||
left: "-9px",
|
||||
top: "28px",
|
||||
right: "-4px",
|
||||
bottom: "4px",
|
||||
width: "8px",
|
||||
cursor: "ew-resize",
|
||||
}}
|
||||
key={props.key}
|
||||
onMouseDown={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
|
||||
window.addEventListener("mousemove", moveComponent);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "resize-w");
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
stopResizeAndMove();
|
||||
}}
|
||||
className="move-element cursor-move absolute w-4 h-4 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer"
|
||||
onDragStart={(_event: React.DragEvent<HTMLDivElement>) => {}}
|
||||
onDragEnd={(_event: React.DragEvent<HTMLDivElement>) => {}}
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 right-0.5 w-1 rounded-full bg-blue-400 group-hover:bg-blue-500 transition-all duration-150"
|
||||
style={{
|
||||
height: "32px",
|
||||
transform: "translateY(-50%)",
|
||||
opacity: props.isSelected ? 0.8 : 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getResizeHeightElement: GetReactElementFunction = (): ReactElement => {
|
||||
if (!props.isSelected || !props.isEditMode) {
|
||||
const getResizeHeightHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let resizeCursorIcon: string = "cursor-ns-resize";
|
||||
|
||||
// if already at min height then change icon to s-resize
|
||||
|
||||
if (
|
||||
component.heightInDashboardUnits <= component.minHeightInDashboardUnits
|
||||
) {
|
||||
resizeCursorIcon = "cursor-s-resize";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-20 group"
|
||||
style={{
|
||||
bottom: "-5px",
|
||||
left: "calc(50% - 20px)",
|
||||
bottom: "-4px",
|
||||
left: "4px",
|
||||
right: "12px",
|
||||
height: "8px",
|
||||
cursor: "ns-resize",
|
||||
}}
|
||||
onMouseDown={(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
event.preventDefault();
|
||||
window.addEventListener("mousemove", resizeHeight);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "resize-h");
|
||||
}}
|
||||
className={`resize-height-element ${resizeCursorIcon} absolute bottom-0 left-0 w-12 h-2 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer`}
|
||||
></div>
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0.5 left-1/2 h-1 rounded-full bg-blue-400 group-hover:bg-blue-500 transition-all duration-150"
|
||||
style={{
|
||||
width: "32px",
|
||||
transform: "translateX(-50%)",
|
||||
opacity: props.isSelected ? 0.8 : 0.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getResizeCornerHandle: GetReactElementFunction = (): ReactElement => {
|
||||
if (!showHandles) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="absolute z-30 group"
|
||||
style={{
|
||||
bottom: "-4px",
|
||||
right: "-4px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
cursor: "nwse-resize",
|
||||
}}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
startSession(e, "resize-corner");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-1 right-1"
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRight: `2px solid ${props.isSelected ? "rgba(59,130,246,0.8)" : "rgba(59,130,246,0.5)"}`,
|
||||
borderBottom: `2px solid ${props.isSelected ? "rgba(59,130,246,0.8)" : "rgba(59,130,246,0.5)"}`,
|
||||
borderRadius: "0 0 2px 0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -362,51 +488,145 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
margin: `${MarginForEachUnitInPx}px`,
|
||||
height: `${
|
||||
GetDashboardUnitHeightInPx(props.totalCurrentDashboardWidthInPx) *
|
||||
heightOfComponent +
|
||||
SpaceBetweenUnitsInPx * (heightOfComponent - 1)
|
||||
}px`,
|
||||
width: `${
|
||||
GetDashboardUnitWidthInPx(props.totalCurrentDashboardWidthInPx) *
|
||||
widthOfComponent +
|
||||
(SpaceBetweenUnitsInPx - 2) * (widthOfComponent - 1)
|
||||
}px`,
|
||||
gridColumn: `span ${widthOfComponent}`,
|
||||
gridRow: `span ${heightOfComponent}`,
|
||||
boxShadow: isDragging
|
||||
? "0 20px 40px -8px rgba(59,130,246,0.15), 0 8px 16px -4px rgba(0,0,0,0.08)"
|
||||
: props.isSelected && props.isEditMode
|
||||
? "0 4px 12px -2px rgba(59,130,246,0.12), 0 2px 4px -1px rgba(0,0,0,0.04)"
|
||||
: "0 2px 8px -2px rgba(0,0,0,0.08), 0 1px 4px -1px rgba(0,0,0,0.04)",
|
||||
transition: isDragging
|
||||
? "none"
|
||||
: "box-shadow 0.2s ease, border-color 0.2s ease",
|
||||
}}
|
||||
ref={elRef}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (!isDragging) {
|
||||
props.onClick();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!isDragging) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
key={component.componentId?.toString() || Math.random().toString()}
|
||||
ref={dashboardComponentRef}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{getMoveElement()}
|
||||
{getMoveHandle()}
|
||||
|
||||
{component.componentType === DashboardComponentType.Text && (
|
||||
<DashboardTextComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTextComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Chart && (
|
||||
<DashboardChartComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardChartComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Value && (
|
||||
<DashboardValueComponent
|
||||
{...props}
|
||||
isSelected={props.isSelected}
|
||||
isEditMode={props.isEditMode}
|
||||
component={component as DashboardValueComponentType}
|
||||
{/* Tooltip — updated imperatively via ref, never causes a render */}
|
||||
<div
|
||||
className="absolute z-50 pointer-events-none"
|
||||
style={{
|
||||
top: "-32px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
display: isDragging ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="px-2 py-1 rounded-md text-xs font-mono font-medium text-white whitespace-nowrap"
|
||||
style={{
|
||||
background: "rgba(30, 41, 59, 0.9)",
|
||||
backdropFilter: "blur(4px)",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Component type badge */}
|
||||
{props.isEditMode && (props.isSelected || isHovered) && !isDragging && (
|
||||
<div
|
||||
className="absolute z-10 pointer-events-none"
|
||||
style={{
|
||||
top: showHandles ? "32px" : "6px",
|
||||
right: "6px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium capitalize"
|
||||
style={{
|
||||
background: "rgba(241, 245, 249, 0.9)",
|
||||
color: "#64748b",
|
||||
}}
|
||||
>
|
||||
{component.componentType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{getResizeWidthElement()}
|
||||
{getResizeHeightElement()}
|
||||
{/* Component content */}
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
padding: showHandles ? "28px 12px 12px 12px" : "12px",
|
||||
}}
|
||||
>
|
||||
{component.componentType === DashboardComponentType.Text && (
|
||||
<DashboardTextComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTextComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Chart && (
|
||||
<DashboardChartComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardChartComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Value && (
|
||||
<DashboardValueComponent
|
||||
{...props}
|
||||
isSelected={props.isSelected}
|
||||
isEditMode={props.isEditMode}
|
||||
component={component as DashboardValueComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Table && (
|
||||
<DashboardTableComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTableComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Gauge && (
|
||||
<DashboardGaugeComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardGaugeComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.LogStream && (
|
||||
<DashboardLogStreamComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardLogStreamComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.TraceList && (
|
||||
<DashboardTraceListComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTraceListComponentType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getResizeWidthHandle()}
|
||||
{getResizeHeightHandle()}
|
||||
{getResizeCornerHandle()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,12 +3,10 @@ import DashboardChartComponent from "Common/Types/Dashboard/DashboardComponents/
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import MetricCharts from "../../Metrics/MetricCharts";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricQueryConfigData, {
|
||||
MetricChartType,
|
||||
@@ -31,10 +29,28 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
// Resolve query configs - combine primary query with additional queries
|
||||
const resolveQueryConfigs: () => Array<MetricQueryConfigData> = () => {
|
||||
const configs: Array<MetricQueryConfigData> = [];
|
||||
|
||||
if (props.component.arguments.metricQueryConfig) {
|
||||
configs.push(props.component.arguments.metricQueryConfig);
|
||||
}
|
||||
|
||||
if (
|
||||
props.component.arguments.metricQueryConfigs &&
|
||||
props.component.arguments.metricQueryConfigs.length > 0
|
||||
) {
|
||||
configs.push(...props.component.arguments.metricQueryConfigs);
|
||||
}
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
const queryConfigs: Array<MetricQueryConfigData> = resolveQueryConfigs();
|
||||
|
||||
const metricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [props.component.arguments.metricQueryConfig]
|
||||
: [],
|
||||
queryConfigs: queryConfigs,
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
@@ -97,80 +113,121 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes]);
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
|
||||
MetricQueryConfigData | undefined
|
||||
>(props.component.arguments.metricQueryConfig);
|
||||
const [prevQueryConfigs, setPrevQueryConfigs] = React.useState<
|
||||
Array<MetricQueryConfigData> | MetricQueryConfigData | undefined
|
||||
>(
|
||||
props.component.arguments.metricQueryConfigs ||
|
||||
props.component.arguments.metricQueryConfig,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// set metricQueryConfig to the new value only if it is different from the previous value
|
||||
const currentConfigs:
|
||||
| Array<MetricQueryConfigData>
|
||||
| MetricQueryConfigData
|
||||
| undefined =
|
||||
props.component.arguments.metricQueryConfigs ||
|
||||
props.component.arguments.metricQueryConfig;
|
||||
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
props.component.arguments.metricQueryConfig || {},
|
||||
prevQueryConfigs || {},
|
||||
currentConfigs || {},
|
||||
)
|
||||
) {
|
||||
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
|
||||
setPrevQueryConfigs(currentConfigs);
|
||||
fetchAggregatedResults();
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
}, [
|
||||
props.component.arguments.metricQueryConfig,
|
||||
props.component.arguments.metricQueryConfigs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <ComponentLoader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading for chart - only on initial load
|
||||
return (
|
||||
<div className="m-auto flex flex-col justify-center w-full h-full">
|
||||
<div className="h-7 w-7 text-gray-400 w-full text-center mx-auto">
|
||||
<Icon icon={IconProp.ChartBar} />
|
||||
<div className="w-full h-full flex flex-col p-1 animate-pulse">
|
||||
<div className="h-3 w-28 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 flex items-end gap-1 px-2 pb-2">
|
||||
{Array.from({ length: 12 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gray-100 rounded-t"
|
||||
style={{
|
||||
height: `${20 + Math.random() * 60}%`,
|
||||
opacity: 0.4 + Math.random() * 0.4,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let heightOfChart: number | undefined =
|
||||
(props.dashboardComponentHeightInPx || 0) - 100;
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.ChartBar} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (heightOfChart < 0) {
|
||||
const numberOfCharts: number = queryConfigs.length || 1;
|
||||
// Account for widget-level header and per-chart overhead (title + legend + padding)
|
||||
const hasWidgetHeader: boolean = Boolean(
|
||||
props.component.arguments.chartTitle ||
|
||||
props.component.arguments.chartDescription,
|
||||
);
|
||||
const widgetHeaderHeight: number = hasWidgetHeader ? 50 : 0;
|
||||
// Each chart section: pt-5(20) + title(20) + legend(24) + pb-4(16) = ~80px overhead
|
||||
const perChartOverhead: number = 80;
|
||||
let heightOfChart: number | undefined =
|
||||
((props.dashboardComponentHeightInPx || 0) -
|
||||
widgetHeaderHeight -
|
||||
numberOfCharts * perChartOverhead) /
|
||||
numberOfCharts;
|
||||
|
||||
if (heightOfChart < 50) {
|
||||
heightOfChart = undefined;
|
||||
}
|
||||
|
||||
// add title and description.
|
||||
|
||||
type GetMetricChartType = () => MetricChartType;
|
||||
|
||||
// Convert dashboard chart type to metric chart type
|
||||
const getMetricChartType: GetMetricChartType = (): MetricChartType => {
|
||||
if (props.component.arguments.chartType === DashboardChartType.Bar) {
|
||||
return MetricChartType.BAR;
|
||||
}
|
||||
if (
|
||||
props.component.arguments.chartType === DashboardChartType.Area ||
|
||||
props.component.arguments.chartType === DashboardChartType.StackedArea
|
||||
) {
|
||||
return MetricChartType.AREA;
|
||||
}
|
||||
return MetricChartType.LINE;
|
||||
};
|
||||
|
||||
const chartMetricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [
|
||||
{
|
||||
...props.component.arguments.metricQueryConfig!,
|
||||
metricAliasData: {
|
||||
title: props.component.arguments.chartTitle || undefined,
|
||||
description:
|
||||
props.component.arguments.chartDescription || undefined,
|
||||
metricVariable: undefined,
|
||||
legend: props.component.arguments.legendText || undefined,
|
||||
legendUnit: props.component.arguments.legendUnit || undefined,
|
||||
},
|
||||
chartType: getMetricChartType(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
queryConfigs: queryConfigs.map((config: MetricQueryConfigData) => {
|
||||
return {
|
||||
...config,
|
||||
metricAliasData: {
|
||||
metricVariable: config.metricAliasData?.metricVariable || undefined,
|
||||
title: config.metricAliasData?.title || undefined,
|
||||
description: config.metricAliasData?.description || undefined,
|
||||
legend: config.metricAliasData?.legend || undefined,
|
||||
legendUnit: config.metricAliasData?.legendUnit || undefined,
|
||||
},
|
||||
chartType: config.chartType || getMetricChartType(),
|
||||
};
|
||||
}),
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
@@ -178,14 +235,37 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MetricCharts
|
||||
metricResults={metricResults}
|
||||
metricTypes={props.metricTypes}
|
||||
metricViewData={chartMetricViewData}
|
||||
hideCard={true}
|
||||
heightInPx={heightOfChart}
|
||||
/>
|
||||
<div
|
||||
className="w-full h-full overflow-hidden flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{(props.component.arguments.chartTitle ||
|
||||
props.component.arguments.chartDescription) && (
|
||||
<div className="px-2 pt-2 pb-1 flex-shrink-0">
|
||||
{props.component.arguments.chartTitle && (
|
||||
<h3 className="text-sm font-semibold text-gray-700 tracking-tight">
|
||||
{props.component.arguments.chartTitle}
|
||||
</h3>
|
||||
)}
|
||||
{props.component.arguments.chartDescription && (
|
||||
<p className="mt-0.5 text-xs text-gray-400">
|
||||
{props.component.arguments.chartDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<MetricCharts
|
||||
metricResults={metricResults}
|
||||
metricTypes={props.metricTypes}
|
||||
metricViewData={chartMetricViewData}
|
||||
hideCard={true}
|
||||
heightInPx={heightOfChart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardGaugeComponent from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardGaugeComponent;
|
||||
}
|
||||
|
||||
const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [metricResults, setMetricResults] = React.useState<
|
||||
Array<AggregatedResult>
|
||||
>([]);
|
||||
const [aggregationType, setAggregationType] = React.useState<AggregationType>(
|
||||
AggregationType.Avg,
|
||||
);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const metricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [props.component.arguments.metricQueryConfig]
|
||||
: [],
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
formulaConfigs: [],
|
||||
};
|
||||
|
||||
const fetchAggregatedResults: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (
|
||||
!metricViewData.startAndEndDate?.startValue ||
|
||||
!metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs ||
|
||||
metricViewData.queryConfigs.length === 0 ||
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData)
|
||||
.length === 0
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData
|
||||
?.aggegationType
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAggregationType(
|
||||
(metricViewData.queryConfigs[0].metricQueryData.filterData
|
||||
?.aggegationType as AggregationType) || AggregationType.Avg,
|
||||
);
|
||||
|
||||
try {
|
||||
const results: Array<AggregatedResult> = await MetricUtil.fetchResults({
|
||||
metricViewData: metricViewData,
|
||||
});
|
||||
|
||||
setMetricResults(results);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
|
||||
MetricQueryConfigData | undefined
|
||||
>(props.component.arguments.metricQueryConfig);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
props.component.arguments.metricQueryConfig || {},
|
||||
)
|
||||
) {
|
||||
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
|
||||
fetchAggregatedResults();
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading for gauge - only on initial load
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center animate-pulse">
|
||||
<div className="h-3 w-20 bg-gray-100 rounded mb-3"></div>
|
||||
<div
|
||||
className="bg-gray-100 rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(props.dashboardComponentWidthInPx * 0.5, 120)}px`,
|
||||
height: `${Math.min(props.dashboardComponentWidthInPx * 0.25, 60)}px`,
|
||||
borderRadius: "999px 999px 0 0",
|
||||
}}
|
||||
></div>
|
||||
<div className="h-5 w-12 bg-gray-100 rounded mt-2"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup state if no metric configured
|
||||
if (
|
||||
!props.component.arguments.metricQueryConfig ||
|
||||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
|
||||
Object.keys(
|
||||
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
|
||||
).length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-emerald-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{props.component.arguments.gaugeTitle || "Gauge Widget"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Click to configure metric
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate aggregated value
|
||||
let aggregatedValue: number = 0;
|
||||
let avgCount: number = 0;
|
||||
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
const value: number = item.value;
|
||||
|
||||
if (aggregationType === AggregationType.Avg) {
|
||||
aggregatedValue += value;
|
||||
avgCount += 1;
|
||||
} else if (aggregationType === AggregationType.Sum) {
|
||||
aggregatedValue += value;
|
||||
} else if (aggregationType === AggregationType.Min) {
|
||||
aggregatedValue = Math.min(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Max) {
|
||||
aggregatedValue = Math.max(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Count) {
|
||||
aggregatedValue += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregationType === AggregationType.Avg && avgCount > 0) {
|
||||
aggregatedValue = aggregatedValue / avgCount;
|
||||
}
|
||||
|
||||
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
|
||||
|
||||
const minValue: number = props.component.arguments.minValue ?? 0;
|
||||
const maxValue: number = props.component.arguments.maxValue ?? 100;
|
||||
const warningThreshold: number | undefined =
|
||||
props.component.arguments.warningThreshold;
|
||||
const criticalThreshold: number | undefined =
|
||||
props.component.arguments.criticalThreshold;
|
||||
|
||||
// Calculate percentage for the gauge arc
|
||||
const range: number = maxValue - minValue;
|
||||
const percentage: number =
|
||||
range > 0
|
||||
? Math.min(Math.max((aggregatedValue - minValue) / range, 0), 1)
|
||||
: 0;
|
||||
|
||||
// Determine color based on thresholds
|
||||
let gaugeColor: string = "#10b981"; // green
|
||||
if (criticalThreshold !== undefined && aggregatedValue >= criticalThreshold) {
|
||||
gaugeColor = "#ef4444"; // red
|
||||
} else if (
|
||||
warningThreshold !== undefined &&
|
||||
aggregatedValue >= warningThreshold
|
||||
) {
|
||||
gaugeColor = "#f59e0b"; // yellow
|
||||
}
|
||||
|
||||
// SVG gauge rendering
|
||||
const size: number = Math.min(
|
||||
props.dashboardComponentWidthInPx - 40,
|
||||
props.dashboardComponentHeightInPx - 60,
|
||||
);
|
||||
const gaugeSize: number = Math.max(size, 80);
|
||||
const strokeWidth: number = Math.max(gaugeSize * 0.1, 8);
|
||||
const radius: number = (gaugeSize - strokeWidth) / 2;
|
||||
const centerX: number = gaugeSize / 2;
|
||||
const centerY: number = gaugeSize / 2;
|
||||
|
||||
// Semi-circle arc (180 degrees, from left to right)
|
||||
const startAngle: number = Math.PI;
|
||||
const endAngle: number = 0;
|
||||
const sweepAngle: number = startAngle - endAngle;
|
||||
const currentAngle: number = startAngle - sweepAngle * percentage;
|
||||
|
||||
const arcStartX: number = centerX + radius * Math.cos(startAngle);
|
||||
const arcStartY: number = centerY - radius * Math.sin(startAngle);
|
||||
const arcEndX: number = centerX + radius * Math.cos(endAngle);
|
||||
const arcEndY: number = centerY - radius * Math.sin(endAngle);
|
||||
const arcCurrentX: number = centerX + radius * Math.cos(currentAngle);
|
||||
const arcCurrentY: number = centerY - radius * Math.sin(currentAngle);
|
||||
|
||||
const backgroundPath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 0 1 ${arcEndX} ${arcEndY}`;
|
||||
const valuePath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${percentage > 0.5 ? 1 : 0} 1 ${arcCurrentX} ${arcCurrentY}`;
|
||||
|
||||
const titleHeightInPx: number = Math.min(
|
||||
Math.max(props.dashboardComponentHeightInPx * 0.1, 12),
|
||||
16,
|
||||
);
|
||||
const valueHeightInPx: number = Math.max(gaugeSize * 0.22, 16);
|
||||
|
||||
// Generate a unique gradient ID for this component instance
|
||||
const gradientId: string = `gauge-gradient-${props.componentId?.toString() || "default"}`;
|
||||
|
||||
// Threshold marker positions on arc
|
||||
type ThresholdMarker = {
|
||||
angle: number;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const thresholdMarkers: Array<ThresholdMarker> = [];
|
||||
|
||||
if (warningThreshold !== undefined && range > 0) {
|
||||
const warningPct: number = Math.min(
|
||||
Math.max((warningThreshold - minValue) / range, 0),
|
||||
1,
|
||||
);
|
||||
const warningAngle: number = startAngle - sweepAngle * warningPct;
|
||||
thresholdMarkers.push({
|
||||
angle: warningAngle,
|
||||
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(warningAngle),
|
||||
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(warningAngle),
|
||||
color: "#f59e0b",
|
||||
});
|
||||
}
|
||||
|
||||
if (criticalThreshold !== undefined && range > 0) {
|
||||
const criticalPct: number = Math.min(
|
||||
Math.max((criticalThreshold - minValue) / range, 0),
|
||||
1,
|
||||
);
|
||||
const criticalAngle: number = startAngle - sweepAngle * criticalPct;
|
||||
thresholdMarkers.push({
|
||||
angle: criticalAngle,
|
||||
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(criticalAngle),
|
||||
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(criticalAngle),
|
||||
color: "#ef4444",
|
||||
});
|
||||
}
|
||||
|
||||
const percentDisplay: number = Math.round(percentage * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full text-center h-full flex flex-col items-center justify-center"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.gaugeTitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
|
||||
}}
|
||||
className="text-center font-medium text-gray-400 mb-2 truncate uppercase tracking-wider"
|
||||
>
|
||||
{props.component.arguments.gaugeTitle}
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
width={gaugeSize}
|
||||
height={gaugeSize / 2 + strokeWidth + 8}
|
||||
viewBox={`0 0 ${gaugeSize} ${gaugeSize / 2 + strokeWidth + 8}`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={gaugeColor} stopOpacity="0.6" />
|
||||
<stop offset="50%" stopColor={gaugeColor} stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor={gaugeColor} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
<filter
|
||||
id={`gauge-glow-${props.componentId?.toString() || "default"}`}
|
||||
>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Background track */}
|
||||
<path
|
||||
d={backgroundPath}
|
||||
fill="none"
|
||||
stroke="#f0f0f0"
|
||||
strokeWidth={strokeWidth + 4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Value arc */}
|
||||
{percentage > 0 && (
|
||||
<path
|
||||
d={valuePath}
|
||||
fill="none"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
filter={`url(#gauge-glow-${props.componentId?.toString() || "default"})`}
|
||||
/>
|
||||
)}
|
||||
{/* Threshold markers */}
|
||||
{thresholdMarkers.map((marker: ThresholdMarker, index: number) => {
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={marker.x}
|
||||
cy={marker.y}
|
||||
r={3}
|
||||
fill={marker.color}
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Needle tip dot at current position */}
|
||||
{percentage > 0 && (
|
||||
<circle
|
||||
cx={arcCurrentX}
|
||||
cy={arcCurrentY}
|
||||
r={strokeWidth * 0.4}
|
||||
fill="white"
|
||||
stroke={gaugeColor}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
{/* Value + percentage display */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: `-${gaugeSize * 0.2}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="font-bold text-gray-900"
|
||||
style={{
|
||||
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
{aggregatedValue}
|
||||
</div>
|
||||
<div
|
||||
className="text-gray-400 font-medium"
|
||||
style={{
|
||||
fontSize: `${Math.max(valueHeightInPx * 0.45, 10)}px`,
|
||||
}}
|
||||
>
|
||||
{percentDisplay}%
|
||||
</div>
|
||||
</div>
|
||||
{/* Min/Max labels */}
|
||||
<div
|
||||
className="flex justify-between w-full px-2 mt-0.5"
|
||||
style={{ maxWidth: `${gaugeSize + 10}px` }}
|
||||
>
|
||||
<span
|
||||
className="text-gray-300 tabular-nums"
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{minValue}
|
||||
</span>
|
||||
<span
|
||||
className="text-gray-300 tabular-nums"
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{maxValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardGaugeComponentElement;
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardLogStreamComponent from "Common/Types/Dashboard/DashboardComponents/DashboardLogStreamComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import Log from "Common/Models/AnalyticsModels/Log";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import {
|
||||
queryStringToFilter,
|
||||
LogFilter,
|
||||
} from "Common/Types/Log/LogQueryToFilter";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardLogStreamComponent;
|
||||
}
|
||||
|
||||
type SeverityColor = {
|
||||
dot: string;
|
||||
text: string;
|
||||
bg: string;
|
||||
};
|
||||
|
||||
const getSeverityColor: (severity: string) => SeverityColor = (
|
||||
severity: string,
|
||||
): SeverityColor => {
|
||||
const lower: string = severity.toLowerCase();
|
||||
if (lower === "fatal") {
|
||||
return {
|
||||
dot: "bg-purple-500",
|
||||
text: "text-purple-700",
|
||||
bg: "bg-purple-50",
|
||||
};
|
||||
}
|
||||
if (lower === "error") {
|
||||
return { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" };
|
||||
}
|
||||
if (lower === "warning") {
|
||||
return {
|
||||
dot: "bg-yellow-500",
|
||||
text: "text-yellow-700",
|
||||
bg: "bg-yellow-50",
|
||||
};
|
||||
}
|
||||
if (lower === "information") {
|
||||
return { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" };
|
||||
}
|
||||
if (lower === "debug") {
|
||||
return { dot: "bg-gray-400", text: "text-gray-600", bg: "bg-gray-50" };
|
||||
}
|
||||
if (lower === "trace") {
|
||||
return { dot: "bg-gray-300", text: "text-gray-500", bg: "bg-gray-50" };
|
||||
}
|
||||
return { dot: "bg-gray-300", text: "text-gray-500", bg: "bg-gray-50" };
|
||||
};
|
||||
|
||||
const DashboardLogStreamComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [logs, setLogs] = React.useState<Array<Log>>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const maxRows: number = props.component.arguments.maxRows || 50;
|
||||
|
||||
const fetchLogs: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
const startAndEndDate: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
);
|
||||
|
||||
if (!startAndEndDate.startValue || !startAndEndDate.endValue) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a valid start and end date.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query: Query<Log> = {
|
||||
time: new InBetween<Date>(
|
||||
startAndEndDate.startValue,
|
||||
startAndEndDate.endValue,
|
||||
),
|
||||
} as Query<Log>;
|
||||
|
||||
// Add severity filter if set
|
||||
if (
|
||||
props.component.arguments.severityFilter &&
|
||||
props.component.arguments.severityFilter !== ""
|
||||
) {
|
||||
(query as Record<string, unknown>)["severityText"] =
|
||||
props.component.arguments.severityFilter;
|
||||
}
|
||||
|
||||
// Add body contains filter if set
|
||||
if (
|
||||
props.component.arguments.bodyContains &&
|
||||
props.component.arguments.bodyContains.trim() !== ""
|
||||
) {
|
||||
(query as Record<string, unknown>)["body"] =
|
||||
props.component.arguments.bodyContains.trim();
|
||||
}
|
||||
|
||||
// Add attribute filters if set
|
||||
if (
|
||||
props.component.arguments.attributeFilterQuery &&
|
||||
props.component.arguments.attributeFilterQuery.trim() !== ""
|
||||
) {
|
||||
const parsedFilter: LogFilter = queryStringToFilter(
|
||||
props.component.arguments.attributeFilterQuery.trim(),
|
||||
);
|
||||
|
||||
if (parsedFilter.attributes) {
|
||||
(query as Record<string, unknown>)["attributes"] =
|
||||
parsedFilter.attributes;
|
||||
}
|
||||
}
|
||||
|
||||
const listResult: ListResult<Log> = await AnalyticsModelAPI.getList<Log>({
|
||||
modelType: Log,
|
||||
query: query,
|
||||
limit: maxRows,
|
||||
skip: 0,
|
||||
select: {
|
||||
time: true,
|
||||
severityText: true,
|
||||
body: true,
|
||||
serviceId: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
attributes: true,
|
||||
},
|
||||
sort: {
|
||||
time: SortOrder.Descending,
|
||||
},
|
||||
requestOptions: {},
|
||||
});
|
||||
|
||||
setLogs(listResult.data);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [props.dashboardStartAndEndDate, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [
|
||||
props.component.arguments.severityFilter,
|
||||
props.component.arguments.bodyContains,
|
||||
props.component.arguments.attributeFilterQuery,
|
||||
props.component.arguments.maxRows,
|
||||
]);
|
||||
|
||||
if (isLoading && logs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-2 items-center"
|
||||
style={{ opacity: 1 - i * 0.12 }}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 bg-gray-200 rounded-full"></div>
|
||||
<div className="h-3 w-16 bg-gray-100 rounded"></div>
|
||||
<div
|
||||
className="h-3 bg-gray-50 rounded flex-1"
|
||||
style={{ maxWidth: `${40 + Math.random() * 50}%` }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.List} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-auto flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.title && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{props.component.arguments.title}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 tabular-nums">
|
||||
{logs.length} entries
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{logs.map((log: Log, index: number) => {
|
||||
const severity: string =
|
||||
(log.severityText as string) || "Unspecified";
|
||||
const colors: SeverityColor = getSeverityColor(severity);
|
||||
const body: string = (log.body as string) || "";
|
||||
const time: Date | undefined = log.time
|
||||
? OneUptimeDate.fromString(log.time as unknown as string)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 px-3 py-1.5 hover:bg-gray-50/50 transition-colors duration-100 group"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${colors.dot}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-xs font-medium ${colors.text} ${colors.bg} px-1 py-0.5 rounded w-12 text-center`}
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{severity.substring(0, 4).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{time && (
|
||||
<span
|
||||
className="text-xs text-gray-400 shrink-0 tabular-nums"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(time, true)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xs text-gray-600 truncate flex-1 font-mono"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{body}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{logs.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-gray-400 text-sm">
|
||||
No logs found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLogStreamComponentElement;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardTableComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardTableComponent;
|
||||
}
|
||||
|
||||
const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [metricResults, setMetricResults] = React.useState<
|
||||
Array<AggregatedResult>
|
||||
>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const metricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [props.component.arguments.metricQueryConfig]
|
||||
: [],
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
formulaConfigs: [],
|
||||
};
|
||||
|
||||
const fetchAggregatedResults: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (
|
||||
!metricViewData.startAndEndDate?.startValue ||
|
||||
!metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a valid start and end date.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs ||
|
||||
metricViewData.queryConfigs.length === 0 ||
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData)
|
||||
.length === 0
|
||||
) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a metric. Click here to add a metric.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData
|
||||
?.aggegationType
|
||||
) {
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
"Please select an aggregation. Click here to add an aggregation.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results: Array<AggregatedResult> = await MetricUtil.fetchResults({
|
||||
metricViewData: metricViewData,
|
||||
});
|
||||
|
||||
setMetricResults(results);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
|
||||
MetricQueryConfigData | undefined
|
||||
>(props.component.arguments.metricQueryConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
props.component.arguments.metricQueryConfig || {},
|
||||
)
|
||||
) {
|
||||
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
|
||||
fetchAggregatedResults();
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading for table - only on initial load
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-3 w-32 bg-gray-100 rounded"></div>
|
||||
<div className="h-3 w-16 bg-gray-100 rounded ml-auto"></div>
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-4"
|
||||
style={{ opacity: 1 - i * 0.15 }}
|
||||
>
|
||||
<div className="h-3 w-28 bg-gray-50 rounded"></div>
|
||||
<div className="h-3 w-14 bg-gray-50 rounded ml-auto"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.TableCells} />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxRows: number = props.component.arguments.maxRows || 20;
|
||||
|
||||
const allData: Array<AggregatedModel> = [];
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
allData.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const displayData: Array<AggregatedModel> = allData.slice(0, maxRows);
|
||||
|
||||
// Calculate max value for bar visualization
|
||||
const maxDataValue: number =
|
||||
displayData.length > 0
|
||||
? Math.max(
|
||||
...displayData.map((item: AggregatedModel) => {
|
||||
return Math.abs(item.value);
|
||||
}),
|
||||
)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-auto flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.tableTitle && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{props.component.arguments.tableTitle}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 tabular-nums">
|
||||
{displayData.length} rows
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "45%" }}
|
||||
>
|
||||
Timestamp
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 font-medium tracking-wider text-right"
|
||||
style={{ width: "25%" }}
|
||||
>
|
||||
Value
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "30%" }}
|
||||
></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{displayData.map((item: AggregatedModel, index: number) => {
|
||||
const roundedValue: number = Math.round(item.value * 100) / 100;
|
||||
const barWidth: number =
|
||||
maxDataValue > 0
|
||||
? (Math.abs(roundedValue) / maxDataValue) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="hover:bg-gray-50/50 transition-colors duration-100 group"
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.fromString(item.timestamp),
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-semibold text-gray-900 text-right tabular-nums text-xs">
|
||||
{roundedValue}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-full h-3 bg-gray-50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(99, 102, 241, 0.2) 0%, rgba(99, 102, 241, 0.4) 100%)",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{displayData.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-4 py-8 text-center text-gray-400 text-sm"
|
||||
>
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTableComponentElement;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DashboardTextComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import LazyMarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardTextComponent;
|
||||
@@ -9,18 +10,31 @@ export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
const DashboardTextComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const textClassName: string = `m-auto truncate flex flex-col justify-center h-full ${props.component.arguments.isBold ? "font-medium" : ""} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline" : ""}`;
|
||||
const textHeightInxPx: number = props.dashboardComponentHeightInPx * 0.4;
|
||||
if (props.component.arguments.isMarkdown) {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-2">
|
||||
<LazyMarkdownViewer text={props.component.arguments.text || ""} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const textClassName: string = `flex items-center justify-center h-full text-gray-800 leading-snug ${props.component.arguments.isBold ? "font-semibold" : "font-normal"} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline decoration-gray-300 underline-offset-4" : ""}`;
|
||||
const textHeightInxPx: number = Math.min(
|
||||
props.dashboardComponentHeightInPx * 0.35,
|
||||
64,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full px-2">
|
||||
<div
|
||||
className={textClassName}
|
||||
style={{
|
||||
fontSize: textHeightInxPx > 0 ? `${textHeightInxPx}px` : "",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.text}
|
||||
{props.component.arguments.text || (
|
||||
<span className="text-gray-300 text-sm">No text configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardTraceListComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTraceListComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardTraceListComponent;
|
||||
}
|
||||
|
||||
type StatusStyle = {
|
||||
label: string;
|
||||
textClass: string;
|
||||
bgClass: string;
|
||||
};
|
||||
|
||||
const getStatusStyle: (statusCode: number) => StatusStyle = (
|
||||
statusCode: number,
|
||||
): StatusStyle => {
|
||||
if (statusCode === SpanStatus.Error) {
|
||||
return {
|
||||
label: "Error",
|
||||
textClass: "text-red-700",
|
||||
bgClass: "bg-red-50 border-red-100",
|
||||
};
|
||||
}
|
||||
if (statusCode === SpanStatus.Ok) {
|
||||
return {
|
||||
label: "Ok",
|
||||
textClass: "text-green-700",
|
||||
bgClass: "bg-green-50 border-green-100",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Unset",
|
||||
textClass: "text-gray-500",
|
||||
bgClass: "bg-gray-50 border-gray-100",
|
||||
};
|
||||
};
|
||||
|
||||
const formatDuration: (durationNano: number) => string = (
|
||||
durationNano: number,
|
||||
): string => {
|
||||
if (durationNano < 1000) {
|
||||
return `${durationNano}ns`;
|
||||
}
|
||||
const durationMicro: number = durationNano / 1000;
|
||||
if (durationMicro < 1000) {
|
||||
return `${Math.round(durationMicro)}µs`;
|
||||
}
|
||||
const durationMs: number = durationMicro / 1000;
|
||||
if (durationMs < 1000) {
|
||||
return `${Math.round(durationMs * 10) / 10}ms`;
|
||||
}
|
||||
const durationS: number = durationMs / 1000;
|
||||
return `${Math.round(durationS * 100) / 100}s`;
|
||||
};
|
||||
|
||||
const DashboardTraceListComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [spans, setSpans] = React.useState<Array<Span>>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const maxRows: number = props.component.arguments.maxRows || 50;
|
||||
|
||||
const fetchTraces: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
const startAndEndDate: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
);
|
||||
|
||||
if (!startAndEndDate.startValue || !startAndEndDate.endValue) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a valid start and end date.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query: Query<Span> = {
|
||||
startTime: new InBetween<Date>(
|
||||
startAndEndDate.startValue,
|
||||
startAndEndDate.endValue,
|
||||
),
|
||||
} as Query<Span>;
|
||||
|
||||
// Add status filter if set
|
||||
if (
|
||||
props.component.arguments.statusFilter &&
|
||||
props.component.arguments.statusFilter !== ""
|
||||
) {
|
||||
(query as Record<string, unknown>)["statusCode"] = parseInt(
|
||||
props.component.arguments.statusFilter,
|
||||
);
|
||||
}
|
||||
|
||||
const listResult: ListResult<Span> =
|
||||
await AnalyticsModelAPI.getList<Span>({
|
||||
modelType: Span,
|
||||
query: query,
|
||||
limit: maxRows,
|
||||
skip: 0,
|
||||
select: {
|
||||
startTime: true,
|
||||
name: true,
|
||||
statusCode: true,
|
||||
durationUnixNano: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
kind: true,
|
||||
serviceId: true,
|
||||
},
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
requestOptions: {},
|
||||
});
|
||||
|
||||
setSpans(listResult.data);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTraces();
|
||||
}, [props.dashboardStartAndEndDate, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTraces();
|
||||
}, [
|
||||
props.component.arguments.statusFilter,
|
||||
props.component.arguments.maxRows,
|
||||
]);
|
||||
|
||||
if (isLoading && spans.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-3 w-32 bg-gray-100 rounded"></div>
|
||||
<div className="h-3 w-16 bg-gray-100 rounded"></div>
|
||||
<div className="h-3 w-12 bg-gray-100 rounded ml-auto"></div>
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-4"
|
||||
style={{ opacity: 1 - i * 0.15 }}
|
||||
>
|
||||
<div className="h-3 w-28 bg-gray-50 rounded"></div>
|
||||
<div className="h-3 w-14 bg-gray-50 rounded"></div>
|
||||
<div className="h-3 w-10 bg-gray-50 rounded ml-auto"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.Activity} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-auto flex flex-col"
|
||||
style={{
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.title && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{props.component.arguments.title}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 tabular-nums">
|
||||
{spans.length} traces
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
|
||||
<tr>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "35%" }}
|
||||
>
|
||||
Span Name
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
Duration
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
Time
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{spans.map((span: Span, index: number) => {
|
||||
const statusCode: number =
|
||||
(span.statusCode as number) || SpanStatus.Unset;
|
||||
const statusStyle: StatusStyle = getStatusStyle(statusCode);
|
||||
const durationNano: number =
|
||||
(span.durationUnixNano as number) || 0;
|
||||
const startTime: Date | undefined = span.startTime
|
||||
? OneUptimeDate.fromString(span.startTime as unknown as string)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="hover:bg-gray-50/50 transition-colors duration-100 group"
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 font-mono truncate">
|
||||
{(span.name as string) || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 tabular-nums font-medium">
|
||||
{formatDuration(durationNano)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border ${statusStyle.textClass} ${statusStyle.bgClass}`}
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 tabular-nums">
|
||||
{startTime
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(
|
||||
startTime,
|
||||
true,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{spans.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-gray-400 text-sm"
|
||||
>
|
||||
No traces found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTraceListComponentElement;
|
||||
@@ -1,24 +1,88 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import DashboardValueComponent from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardValueComponent;
|
||||
component: DashboardValueComponentType;
|
||||
}
|
||||
|
||||
const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
// Mini sparkline SVG component
|
||||
interface SparklineProps {
|
||||
data: Array<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
const Sparkline: FunctionComponent<SparklineProps> = (
|
||||
props: SparklineProps,
|
||||
): ReactElement => {
|
||||
if (props.data.length < 2) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const dataPoints: Array<number> = props.data;
|
||||
const minVal: number = Math.min(...dataPoints);
|
||||
const maxVal: number = Math.max(...dataPoints);
|
||||
const range: number = maxVal - minVal || 1;
|
||||
const padding: number = 2;
|
||||
|
||||
const points: string = dataPoints
|
||||
.map((value: number, index: number) => {
|
||||
const x: number =
|
||||
padding +
|
||||
(index / (dataPoints.length - 1)) * (props.width - padding * 2);
|
||||
const y: number =
|
||||
props.height -
|
||||
padding -
|
||||
((value - minVal) / range) * (props.height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Create fill area path
|
||||
const firstX: number = padding;
|
||||
const lastX: number =
|
||||
padding +
|
||||
((dataPoints.length - 1) / (dataPoints.length - 1)) *
|
||||
(props.width - padding * 2);
|
||||
const fillPoints: string = `${firstX},${props.height} ${points} ${lastX},${props.height}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
viewBox={`0 0 ${props.width} ${props.height}`}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<polygon points={fillPoints} fill={props.fillColor} />
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={props.color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [metricResults, setMetricResults] = React.useState<
|
||||
@@ -99,14 +163,9 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes]);
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// set metricQueryConfig to the new value only if it is different from the previous value
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
@@ -118,40 +177,90 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ComponentLoader />;
|
||||
if (isLoading && metricResults.length === 0) {
|
||||
// Skeleton loading state - only on initial load
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center rounded-md animate-pulse">
|
||||
<div className="h-3 w-16 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="h-8 w-24 bg-gray-100 rounded mb-2"></div>
|
||||
<div className="h-6 w-32 bg-gray-50 rounded mt-1"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.ChartBar} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let heightOfText: number | undefined =
|
||||
(props.dashboardComponentHeightInPx || 0) - 100;
|
||||
// Show setup state if no metric configured
|
||||
if (
|
||||
!props.component.arguments.metricQueryConfig ||
|
||||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
|
||||
Object.keys(
|
||||
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
|
||||
).length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-indigo-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{props.component.arguments.title || "Value Widget"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Click to configure metric
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (heightOfText < 0) {
|
||||
heightOfText = undefined;
|
||||
// Collect all data points for sparkline and aggregation
|
||||
const allDataPoints: Array<AggregatedModel> = [];
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
allDataPoints.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let aggregatedValue: number = 0;
|
||||
let avgCount: number = 0;
|
||||
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
const value: number = item.value;
|
||||
for (const item of allDataPoints) {
|
||||
const value: number = item.value;
|
||||
|
||||
if (aggregationType === AggregationType.Avg) {
|
||||
aggregatedValue += value;
|
||||
avgCount += 1;
|
||||
} else if (aggregationType === AggregationType.Sum) {
|
||||
aggregatedValue += value;
|
||||
} else if (aggregationType === AggregationType.Min) {
|
||||
aggregatedValue = Math.min(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Max) {
|
||||
aggregatedValue = Math.max(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Count) {
|
||||
aggregatedValue += 1;
|
||||
}
|
||||
if (aggregationType === AggregationType.Avg) {
|
||||
aggregatedValue += value;
|
||||
avgCount += 1;
|
||||
} else if (aggregationType === AggregationType.Sum) {
|
||||
aggregatedValue += value;
|
||||
} else if (aggregationType === AggregationType.Min) {
|
||||
aggregatedValue = Math.min(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Max) {
|
||||
aggregatedValue = Math.max(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Count) {
|
||||
aggregatedValue += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +271,17 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
// round to 2 decimal places
|
||||
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
|
||||
|
||||
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.4;
|
||||
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.13;
|
||||
// Sparkline data - take raw values in order
|
||||
const sparklineData: Array<number> = allDataPoints.map(
|
||||
(item: AggregatedModel) => {
|
||||
return item.value;
|
||||
},
|
||||
);
|
||||
|
||||
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.35;
|
||||
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.11;
|
||||
const showSparkline: boolean =
|
||||
sparklineData.length >= 2 && props.dashboardComponentHeightInPx > 100;
|
||||
|
||||
const unit: string | undefined =
|
||||
props.metricTypes?.find((item: MetricType) => {
|
||||
@@ -173,27 +291,146 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
})?.unit || "";
|
||||
|
||||
// Determine color based on thresholds
|
||||
let valueColorClass: string = "text-gray-900";
|
||||
let bgStyle: React.CSSProperties = {};
|
||||
let sparklineColor: string = "#6366f1"; // indigo
|
||||
let sparklineFill: string = "rgba(99, 102, 241, 0.08)";
|
||||
const warningThreshold: number | undefined =
|
||||
props.component.arguments.warningThreshold;
|
||||
const criticalThreshold: number | undefined =
|
||||
props.component.arguments.criticalThreshold;
|
||||
|
||||
if (criticalThreshold !== undefined && aggregatedValue >= criticalThreshold) {
|
||||
valueColorClass = "text-red-600";
|
||||
bgStyle = {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(254, 226, 226, 0.4) 0%, rgba(254, 202, 202, 0.2) 100%)",
|
||||
};
|
||||
sparklineColor = "#ef4444";
|
||||
sparklineFill = "rgba(239, 68, 68, 0.08)";
|
||||
} else if (
|
||||
warningThreshold !== undefined &&
|
||||
aggregatedValue >= warningThreshold
|
||||
) {
|
||||
valueColorClass = "text-amber-600";
|
||||
bgStyle = {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(254, 243, 199, 0.4) 0%, rgba(253, 230, 138, 0.2) 100%)",
|
||||
};
|
||||
sparklineColor = "#f59e0b";
|
||||
sparklineFill = "rgba(245, 158, 11, 0.08)";
|
||||
}
|
||||
|
||||
// Calculate trend (compare first half avg to second half avg)
|
||||
let trendPercent: number | null = null;
|
||||
let trendDirection: "up" | "down" | "flat" = "flat";
|
||||
|
||||
if (sparklineData.length >= 4) {
|
||||
const midpoint: number = Math.floor(sparklineData.length / 2);
|
||||
const firstHalf: Array<number> = sparklineData.slice(0, midpoint);
|
||||
const secondHalf: Array<number> = sparklineData.slice(midpoint);
|
||||
const firstAvg: number =
|
||||
firstHalf.reduce((a: number, b: number) => {
|
||||
return a + b;
|
||||
}, 0) / firstHalf.length;
|
||||
const secondAvg: number =
|
||||
secondHalf.reduce((a: number, b: number) => {
|
||||
return a + b;
|
||||
}, 0) / secondHalf.length;
|
||||
|
||||
if (firstAvg !== 0) {
|
||||
trendPercent =
|
||||
Math.round(((secondAvg - firstAvg) / Math.abs(firstAvg)) * 1000) / 10;
|
||||
trendDirection =
|
||||
trendPercent > 0.5 ? "up" : trendPercent < -0.5 ? "down" : "flat";
|
||||
}
|
||||
}
|
||||
|
||||
const sparklineWidth: number = Math.min(
|
||||
props.dashboardComponentWidthInPx * 0.6,
|
||||
120,
|
||||
);
|
||||
const sparklineHeight: number = Math.min(
|
||||
props.dashboardComponentHeightInPx * 0.18,
|
||||
30,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full text-center h-full m-auto">
|
||||
<div
|
||||
style={{
|
||||
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
|
||||
}}
|
||||
className="text-center text-bold mb-1 truncate"
|
||||
>
|
||||
{props.component.arguments.title || " "}
|
||||
<div
|
||||
className="w-full h-full flex flex-col items-center justify-center rounded-md relative overflow-hidden"
|
||||
style={{
|
||||
...bgStyle,
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
style={{
|
||||
fontSize:
|
||||
titleHeightInPx > 0
|
||||
? `${Math.max(Math.min(titleHeightInPx, 14), 11)}px`
|
||||
: "12px",
|
||||
}}
|
||||
className="text-center font-medium text-gray-400 truncate uppercase tracking-wider"
|
||||
>
|
||||
{props.component.arguments.title || " "}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div
|
||||
className="text-center text-semibold truncate"
|
||||
className={`text-center font-bold truncate ${valueColorClass}`}
|
||||
style={{
|
||||
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
{aggregatedValue || "0"}
|
||||
{unit}
|
||||
<span
|
||||
className="text-gray-400 font-normal"
|
||||
style={{
|
||||
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx * 0.3}px` : "",
|
||||
}}
|
||||
>
|
||||
{unit ? ` ${unit}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trend indicator */}
|
||||
{trendPercent !== null && trendDirection !== "flat" && (
|
||||
<div
|
||||
className={`flex items-center gap-0.5 mt-0.5 ${
|
||||
trendDirection === "up" ? "text-emerald-500" : "text-red-500"
|
||||
}`}
|
||||
style={{
|
||||
fontSize: `${Math.max(Math.min(titleHeightInPx, 12), 10)}px`,
|
||||
}}
|
||||
>
|
||||
<span>{trendDirection === "up" ? "\u2191" : "\u2193"}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{Math.abs(trendPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sparkline */}
|
||||
{showSparkline && (
|
||||
<div className="mt-1">
|
||||
<Sparkline
|
||||
data={sparklineData}
|
||||
width={sparklineWidth}
|
||||
height={sparklineHeight}
|
||||
color={sparklineColor}
|
||||
fillColor={sparklineFill}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardValueComponent;
|
||||
export default DashboardValueComponentElement;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Icon, { SizeProp } from "Common/UI/Components/Icon/Icon";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: IconProp;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const DashboardTemplateCard: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer border border-gray-200 rounded-lg p-4 hover:border-indigo-500 hover:shadow-md transition-all duration-200 bg-white"
|
||||
onClick={props.onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
props.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-indigo-50 mb-3">
|
||||
<Icon
|
||||
icon={props.icon}
|
||||
size={SizeProp.Large}
|
||||
className="text-indigo-500 h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTemplateCard;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -9,12 +10,19 @@ import DashboardToolbar from "./Toolbar/DashboardToolbar";
|
||||
import DashboardCanvas from "./Canvas/Index";
|
||||
import DashboardMode from "Common/Types/Dashboard/DashboardMode";
|
||||
import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardViewConfig, {
|
||||
AutoRefreshInterval,
|
||||
getAutoRefreshIntervalInMs,
|
||||
} from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import { ObjectType } from "Common/Types/JSON";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import DashboardChartComponentUtil from "Common/Utils/Dashboard/Components/DashboardChartComponent";
|
||||
import DashboardValueComponentUtil from "Common/Utils/Dashboard/Components/DashboardValueComponent";
|
||||
import DashboardTextComponentUtil from "Common/Utils/Dashboard/Components/DashboardTextComponent";
|
||||
import DashboardTableComponentUtil from "Common/Utils/Dashboard/Components/DashboardTableComponent";
|
||||
import DashboardGaugeComponentUtil from "Common/Utils/Dashboard/Components/DashboardGaugeComponent";
|
||||
import DashboardLogStreamComponentUtil from "Common/Utils/Dashboard/Components/DashboardLogStreamComponent";
|
||||
import DashboardTraceListComponentUtil from "Common/Utils/Dashboard/Components/DashboardTraceListComponent";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
@@ -30,6 +38,7 @@ import MetricUtil from "../Metrics/Utils/Metrics";
|
||||
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
|
||||
export interface ComponentProps {
|
||||
dashboardId: ObjectID;
|
||||
@@ -49,6 +58,23 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
||||
// Auto-refresh state
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] =
|
||||
useState<AutoRefreshInterval>(AutoRefreshInterval.OFF);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [dashboardVariables, setDashboardVariables] = useState<
|
||||
Array<DashboardVariable>
|
||||
>([]);
|
||||
|
||||
// Zoom stack for time range
|
||||
const [timeRangeStack, setTimeRangeStack] = useState<
|
||||
Array<RangeStartAndEndDateTime>
|
||||
>([]);
|
||||
const autoRefreshTimerRef: React.MutableRefObject<ReturnType<
|
||||
typeof setInterval
|
||||
> | null> = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [refreshTick, setRefreshTick] = useState<number>(0);
|
||||
|
||||
// ref for dashboard div.
|
||||
|
||||
const dashboardViewRef: React.RefObject<HTMLDivElement> =
|
||||
@@ -76,6 +102,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
const [dashboardName, setDashboardName] = useState<string>("");
|
||||
const [dashboardDescription, setDashboardDescription] = useState<string>("");
|
||||
|
||||
const handleResize: VoidFunction = (): void => {
|
||||
setDashboardTotalWidth(dashboardViewRef.current?.offsetWidth || 0);
|
||||
@@ -132,6 +159,8 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
dashboardViewConfig: true,
|
||||
name: true,
|
||||
description: true,
|
||||
pageTitle: true,
|
||||
pageDescription: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -140,13 +169,28 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
return;
|
||||
}
|
||||
|
||||
setDashboardViewConfig(
|
||||
JSONFunctions.deserializeValue(
|
||||
dashboard.dashboardViewConfig ||
|
||||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
|
||||
) as DashboardViewConfig,
|
||||
const config: DashboardViewConfig = JSONFunctions.deserializeValue(
|
||||
dashboard.dashboardViewConfig ||
|
||||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
|
||||
) as DashboardViewConfig;
|
||||
|
||||
setDashboardViewConfig(config);
|
||||
setDashboardName(
|
||||
dashboard.pageTitle || dashboard.name || "Untitled Dashboard",
|
||||
);
|
||||
setDashboardName(dashboard.name || "Untitled Dashboard");
|
||||
setDashboardDescription(
|
||||
dashboard.pageDescription || dashboard.description || "",
|
||||
);
|
||||
|
||||
// Restore saved auto-refresh interval
|
||||
if (config.refreshInterval) {
|
||||
setAutoRefreshInterval(config.refreshInterval);
|
||||
}
|
||||
|
||||
// Restore saved variables
|
||||
if (config.variables) {
|
||||
setDashboardVariables(config.variables);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
|
||||
@@ -169,6 +213,47 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-refresh timer management
|
||||
const triggerRefresh: () => void = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
setRefreshTick((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
// Brief indicator
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear existing timer
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Don't auto-refresh in edit mode
|
||||
if (dashboardMode === DashboardMode.Edit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs: number | null =
|
||||
getAutoRefreshIntervalInMs(autoRefreshInterval);
|
||||
|
||||
if (intervalMs !== null) {
|
||||
autoRefreshTimerRef.current = setInterval(() => {
|
||||
triggerRefresh();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefreshInterval, dashboardMode, triggerRefresh]);
|
||||
|
||||
const isEditMode: boolean = dashboardMode === DashboardMode.Edit;
|
||||
|
||||
const sideBarWidth: number = isEditMode && selectedComponentId ? 650 : 0;
|
||||
@@ -191,9 +276,13 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
return (
|
||||
<div
|
||||
ref={dashboardViewRef}
|
||||
className="min-h-screen"
|
||||
style={{
|
||||
minWidth: "1000px",
|
||||
width: `calc(100% - ${sideBarWidth}px)`,
|
||||
background: isEditMode
|
||||
? "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)"
|
||||
: "#f8f9fb",
|
||||
}}
|
||||
>
|
||||
<DashboardToolbar
|
||||
@@ -217,17 +306,36 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
dashboardViewConfig={dashboardViewConfig}
|
||||
dashboardName={dashboardName}
|
||||
dashboardDescription={dashboardDescription}
|
||||
isSaving={isSaving}
|
||||
onSaveClick={() => {
|
||||
// Save auto-refresh interval with the config
|
||||
const configWithRefresh: DashboardViewConfig = {
|
||||
...dashboardViewConfig,
|
||||
refreshInterval: autoRefreshInterval,
|
||||
};
|
||||
setDashboardViewConfig(configWithRefresh);
|
||||
|
||||
saveDashboardViewConfig().catch((err: Error) => {
|
||||
setError(API.getFriendlyErrorMessage(err));
|
||||
});
|
||||
setDashboardMode(DashboardMode.View);
|
||||
}}
|
||||
startAndEndDate={startAndEndDate}
|
||||
canResetZoom={timeRangeStack.length > 0}
|
||||
onResetZoom={() => {
|
||||
if (timeRangeStack.length > 0) {
|
||||
const previousRange: RangeStartAndEndDateTime =
|
||||
timeRangeStack[timeRangeStack.length - 1]!;
|
||||
setStartAndEndDate(previousRange);
|
||||
setTimeRangeStack(timeRangeStack.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onStartAndEndDateChange={(
|
||||
newStartAndEndDate: RangeStartAndEndDateTime,
|
||||
) => {
|
||||
// Push current range to zoom stack before changing
|
||||
setTimeRangeStack([...timeRangeStack, startAndEndDate]);
|
||||
setStartAndEndDate(newStartAndEndDate);
|
||||
}}
|
||||
onCancelEditClick={async () => {
|
||||
@@ -238,6 +346,26 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
onEditClick={() => {
|
||||
setDashboardMode(DashboardMode.Edit);
|
||||
}}
|
||||
autoRefreshInterval={autoRefreshInterval}
|
||||
onAutoRefreshIntervalChange={(interval: AutoRefreshInterval) => {
|
||||
setAutoRefreshInterval(interval);
|
||||
}}
|
||||
isRefreshing={isRefreshing}
|
||||
variables={dashboardVariables}
|
||||
onVariableValueChange={(variableId: string, value: string) => {
|
||||
const updatedVariables: Array<DashboardVariable> =
|
||||
dashboardVariables.map((v: DashboardVariable) => {
|
||||
if (v.id === variableId) {
|
||||
return { ...v, selectedValue: value };
|
||||
}
|
||||
return v;
|
||||
});
|
||||
setDashboardVariables(updatedVariables);
|
||||
// Trigger refresh when variable changes
|
||||
setRefreshTick((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
}}
|
||||
onAddComponentClick={(componentType: DashboardComponentType) => {
|
||||
let newComponent: DashboardBaseComponent | null = null;
|
||||
|
||||
@@ -253,6 +381,24 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
newComponent = DashboardTextComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.Table) {
|
||||
newComponent = DashboardTableComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.Gauge) {
|
||||
newComponent = DashboardGaugeComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.LogStream) {
|
||||
newComponent =
|
||||
DashboardLogStreamComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.TraceList) {
|
||||
newComponent =
|
||||
DashboardTraceListComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (!newComponent) {
|
||||
throw new BadDataException(
|
||||
`Unknown component type: ${componentType}`,
|
||||
@@ -270,7 +416,15 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
setDashboardViewConfig(newDashboardConfig);
|
||||
}}
|
||||
/>
|
||||
<div ref={dashboardCanvasRef}>
|
||||
<div
|
||||
ref={dashboardCanvasRef}
|
||||
className="px-1 pb-4 mx-3 mb-4 rounded-2xl border border-gray-200/60"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
boxShadow:
|
||||
"0 1px 4px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.03)",
|
||||
}}
|
||||
>
|
||||
<DashboardCanvas
|
||||
dashboardViewConfig={dashboardViewConfig}
|
||||
onDashboardViewConfigChange={(newConfig: DashboardViewConfig) => {
|
||||
@@ -291,6 +445,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
telemetryAttributes,
|
||||
metricTypes,
|
||||
}}
|
||||
refreshTick={refreshTick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardMode from "Common/Types/Dashboard/DashboardMode";
|
||||
import MoreMenu from "Common/UI/Components/MoreMenu/MoreMenu";
|
||||
import MoreMenuItem from "Common/UI/Components/MoreMenu/MoreMenuItem";
|
||||
import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType";
|
||||
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardViewConfig, {
|
||||
AutoRefreshInterval,
|
||||
getAutoRefreshIntervalInMs,
|
||||
getAutoRefreshIntervalLabel,
|
||||
} from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import Loader from "Common/UI/Components/Loader/Loader";
|
||||
import DashboardVariableSelector from "./DashboardVariableSelector";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
|
||||
export interface ComponentProps {
|
||||
onEditClick: () => void;
|
||||
@@ -20,11 +37,229 @@ export interface ComponentProps {
|
||||
onAddComponentClick: (type: DashboardComponentType) => void;
|
||||
isSaving: boolean;
|
||||
dashboardName: string;
|
||||
dashboardDescription?: string | undefined;
|
||||
startAndEndDate: RangeStartAndEndDateTime;
|
||||
onStartAndEndDateChange: (startAndEndDate: RangeStartAndEndDateTime) => void;
|
||||
dashboardViewConfig: DashboardViewConfig;
|
||||
autoRefreshInterval: AutoRefreshInterval;
|
||||
onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void;
|
||||
isRefreshing?: boolean | undefined;
|
||||
variables?: Array<DashboardVariable> | undefined;
|
||||
onVariableValueChange?:
|
||||
| ((variableId: string, value: string) => void)
|
||||
| undefined;
|
||||
canResetZoom?: boolean | undefined;
|
||||
onResetZoom?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
interface CountdownCircleProps {
|
||||
durationMs: number;
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
label: string;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
const CountdownCircle: FunctionComponent<CountdownCircleProps> = (
|
||||
props: CountdownCircleProps,
|
||||
): ReactElement => {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const startTimeRef: React.MutableRefObject<number> = useRef<number>(
|
||||
Date.now(),
|
||||
);
|
||||
const animationFrameRef: React.MutableRefObject<number | null> = useRef<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const animate: () => void = useCallback(() => {
|
||||
const elapsed: number = Date.now() - startTimeRef.current;
|
||||
const newProgress: number = Math.min(elapsed / props.durationMs, 1);
|
||||
setProgress(newProgress);
|
||||
|
||||
if (newProgress < 1) {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Reset when complete
|
||||
startTimeRef.current = Date.now();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
}
|
||||
}, [props.durationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
startTimeRef.current = Date.now();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [props.durationMs, animate]);
|
||||
|
||||
// Reset on refresh
|
||||
useEffect(() => {
|
||||
if (props.isRefreshing) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
}, [props.isRefreshing]);
|
||||
|
||||
const radius: number = (props.size - props.strokeWidth) / 2;
|
||||
const circumference: number = 2 * Math.PI * radius;
|
||||
const strokeDashoffset: number = circumference * (1 - progress);
|
||||
const center: number = props.size / 2;
|
||||
|
||||
// Calculate remaining seconds
|
||||
const remainingMs: number = props.durationMs * (1 - progress);
|
||||
const remainingSec: number = Math.ceil(remainingMs / 1000);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: props.size, height: props.size }}
|
||||
>
|
||||
<svg
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth={props.strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={props.strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-none"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[8px] font-semibold text-indigo-600">
|
||||
{remainingSec}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-500 font-medium">
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AutoRefreshDropdownProps {
|
||||
autoRefreshInterval: AutoRefreshInterval;
|
||||
autoRefreshMs: number | null;
|
||||
isAutoRefreshActive: boolean;
|
||||
isRefreshing: boolean;
|
||||
onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void;
|
||||
}
|
||||
|
||||
const AutoRefreshDropdown: FunctionComponent<AutoRefreshDropdownProps> = (
|
||||
props: AutoRefreshDropdownProps,
|
||||
): ReactElement => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const dropdownRef: React.RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside: (event: MouseEvent) => void = (
|
||||
event: MouseEvent,
|
||||
): void => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={dropdownRef}>
|
||||
{/* Trigger: countdown circle when active, refresh icon when not */}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 transition-colors cursor-pointer border ${
|
||||
props.isAutoRefreshActive
|
||||
? "bg-indigo-50/50 border-indigo-100 hover:bg-indigo-50"
|
||||
: "bg-gray-50 border-gray-200/60 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
title="Auto-refresh settings"
|
||||
>
|
||||
{props.isAutoRefreshActive && props.autoRefreshMs ? (
|
||||
<CountdownCircle
|
||||
durationMs={props.autoRefreshMs}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
label={getAutoRefreshIntervalLabel(props.autoRefreshInterval)}
|
||||
isRefreshing={props.isRefreshing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
icon={IconProp.Refresh}
|
||||
className="w-3.5 h-3.5 text-gray-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">Auto-refresh: Off</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-lg bg-white shadow-xl ring-1 ring-gray-200 focus:outline-none py-1">
|
||||
{Object.values(AutoRefreshInterval).map(
|
||||
(interval: AutoRefreshInterval) => {
|
||||
const isSelected: boolean =
|
||||
interval === props.autoRefreshInterval;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={interval}
|
||||
className={`w-full text-left px-4 py-2 text-sm flex items-center gap-2 hover:bg-gray-50 ${
|
||||
isSelected ? "text-indigo-600 font-medium" : "text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
props.onAutoRefreshIntervalChange(interval);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="w-4 text-center">
|
||||
{isSelected ? "\u2713" : ""}
|
||||
</span>
|
||||
{interval === AutoRefreshInterval.OFF
|
||||
? "Auto-refresh Off"
|
||||
: `Refresh every ${getAutoRefreshIntervalLabel(interval)}`}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
@@ -34,103 +269,234 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const isSaving: boolean = props.isSaving;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-1.5 mb-1.5 ml-1 mr-1 p-1 h-20 pt-5 pb-5 pl-4 pr-4 rounded bg-white border-2 border-gray-100`}
|
||||
>
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="text-md font-medium mt-2">
|
||||
{/* Name Component */}
|
||||
{props.dashboardName}
|
||||
</div>
|
||||
{!isSaving && (
|
||||
<div className="flex">
|
||||
{props.dashboardViewConfig &&
|
||||
props.dashboardViewConfig.components &&
|
||||
props.dashboardViewConfig.components.length > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
const hasComponents: boolean = Boolean(
|
||||
props.dashboardViewConfig &&
|
||||
props.dashboardViewConfig.components &&
|
||||
props.dashboardViewConfig.components.length > 0,
|
||||
);
|
||||
|
||||
{isEditMode ? (
|
||||
<MoreMenu menuIcon={IconProp.Add} text="Add Component">
|
||||
<MoreMenuItem
|
||||
text={"Add Chart"}
|
||||
key={"add-chart"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Chart);
|
||||
const isAutoRefreshActive: boolean =
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF;
|
||||
const autoRefreshMs: number | null = getAutoRefreshIntervalInMs(
|
||||
props.autoRefreshInterval,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-3 mt-3 mb-3">
|
||||
<div
|
||||
className="rounded-2xl bg-white border border-gray-200/60"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 2px 8px -2px rgba(0, 0, 0, 0.08), 0 1px 4px -1px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
{/* Main toolbar row */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
{/* Left: Icon + Title + Description */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<Icon
|
||||
icon={IconProp.Layout}
|
||||
className="w-4 h-4 text-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold text-gray-800 truncate">
|
||||
{props.dashboardName}
|
||||
</h1>
|
||||
{isEditMode && (
|
||||
<span className="inline-flex items-center px-1.5 py-px rounded-full text-[10px] font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
|
||||
<span className="w-1 h-1 bg-blue-500 rounded-full mr-1"></span>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.dashboardDescription && !isEditMode && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5 max-w-md">
|
||||
{props.dashboardDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Time range + Auto-refresh + Variables + Actions */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{/* Time range + variables (view mode only) */}
|
||||
{hasComponents && !isEditMode && (
|
||||
<>
|
||||
{/* Template variables */}
|
||||
{props.variables &&
|
||||
props.variables.length > 0 &&
|
||||
props.onVariableValueChange && (
|
||||
<>
|
||||
<DashboardVariableSelector
|
||||
variables={props.variables}
|
||||
onVariableValueChange={props.onVariableValueChange}
|
||||
/>
|
||||
<div className="w-px h-5 bg-gray-200 mx-0.5"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Auto-refresh section */}
|
||||
<AutoRefreshDropdown
|
||||
autoRefreshInterval={props.autoRefreshInterval}
|
||||
autoRefreshMs={autoRefreshMs}
|
||||
isAutoRefreshActive={isAutoRefreshActive}
|
||||
isRefreshing={props.isRefreshing || false}
|
||||
onAutoRefreshIntervalChange={
|
||||
props.onAutoRefreshIntervalChange
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Reset Zoom button */}
|
||||
{props.canResetZoom && props.onResetZoom && (
|
||||
<Button
|
||||
icon={IconProp.Refresh}
|
||||
title="Reset Zoom"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={props.onResetZoom}
|
||||
tooltip="Reset to original time range"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* More menu: Edit + Full Screen (always visible in view mode) */}
|
||||
{!isEditMode && (
|
||||
<MoreMenu
|
||||
menuIcon={IconProp.EllipsisHorizontal}
|
||||
elementToBeShownInsteadOfButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center rounded-lg w-8 h-8 bg-gray-50 border border-gray-200/60 hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
title="More options"
|
||||
>
|
||||
<Icon
|
||||
icon={IconProp.EllipsisHorizontal}
|
||||
className="w-4 h-4 text-gray-500"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MoreMenuItem
|
||||
text={"Add Value"}
|
||||
key={"add-value"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Value);
|
||||
}}
|
||||
text={"Edit Dashboard"}
|
||||
icon={IconProp.Pencil}
|
||||
key={"edit"}
|
||||
onClick={props.onEditClick}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Add Text"}
|
||||
key={"add-text"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Text);
|
||||
}}
|
||||
text={"Full Screen"}
|
||||
icon={IconProp.Expand}
|
||||
key={"fullscreen"}
|
||||
onClick={props.onFullScreenClick}
|
||||
/>
|
||||
</MoreMenu>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{!isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Expand}
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onFullScreenClick}
|
||||
tooltip="Full Screen"
|
||||
/>
|
||||
{/* Edit mode actions */}
|
||||
{!isSaving && isEditMode && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MoreMenu menuIcon={IconProp.Add} text="Add Widget">
|
||||
<MoreMenuItem
|
||||
text={"Chart"}
|
||||
icon={IconProp.ChartBar}
|
||||
key={"add-chart"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Chart);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Value"}
|
||||
icon={IconProp.Hashtag}
|
||||
key={"add-value"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Value);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Text"}
|
||||
icon={IconProp.Text}
|
||||
key={"add-text"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Text);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Table"}
|
||||
icon={IconProp.TableCells}
|
||||
key={"add-table"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Table);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Gauge"}
|
||||
icon={IconProp.Gauge}
|
||||
key={"add-gauge"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Gauge);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Log Stream"}
|
||||
icon={IconProp.Logs}
|
||||
key={"add-log-stream"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(
|
||||
DashboardComponentType.LogStream,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Trace List"}
|
||||
icon={IconProp.Waterfall}
|
||||
key={"add-trace-list"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(
|
||||
DashboardComponentType.TraceList,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</MoreMenu>
|
||||
|
||||
<div className="w-px h-5 bg-gray-200 mx-0.5"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Check}
|
||||
title="Save"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={props.onSaveClick}
|
||||
/>
|
||||
<Button
|
||||
icon={IconProp.Close}
|
||||
title="Cancel"
|
||||
buttonStyle={ButtonStyleType.HOVER_DANGER_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setShowCancelModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Pencil}
|
||||
title="Edit"
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onEditClick}
|
||||
tooltip="Edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Check}
|
||||
title="Save"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
onClick={props.onSaveClick}
|
||||
/>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Close}
|
||||
title="Cancel"
|
||||
buttonStyle={ButtonStyleType.HOVER_DANGER_OUTLINE}
|
||||
onClick={() => {
|
||||
setShowCancelModal(true);
|
||||
}}
|
||||
/>
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader />
|
||||
<span className="text-xs text-gray-500">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSaving && (
|
||||
<div className="flex items-center">
|
||||
<Loader />
|
||||
<div className="ml-2 text-sm text-gray-400">Saving...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCancelModal ? (
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
|
||||
export interface ComponentProps {
|
||||
variables: Array<DashboardVariable>;
|
||||
onVariableValueChange: (variableId: string, value: string) => void;
|
||||
}
|
||||
|
||||
const DashboardVariableSelector: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
if (!props.variables || props.variables.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{props.variables.map((variable: DashboardVariable) => {
|
||||
const options: Array<string> = variable.customListValues
|
||||
? variable.customListValues.split(",").map((v: string) => {
|
||||
return v.trim();
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div key={variable.id} className="flex items-center gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||
{variable.label || variable.name}
|
||||
</label>
|
||||
{options.length > 0 ? (
|
||||
<select
|
||||
className="text-xs border border-gray-200 rounded-md px-2.5 py-1.5 bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 transition-colors"
|
||||
value={variable.selectedValue || variable.defaultValue || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
props.onVariableValueChange(variable.id, e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{options.map((option: string) => {
|
||||
return (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="text-xs border border-gray-200 rounded-md px-2.5 py-1.5 bg-white text-gray-700 w-28 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 transition-colors"
|
||||
value={variable.selectedValue || variable.defaultValue || ""}
|
||||
placeholder={variable.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onVariableValueChange(variable.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardVariableSelector;
|
||||
@@ -0,0 +1,734 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import TelemetryException from "Common/Models/DatabaseModels/TelemetryException";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import TelemetryServiceElement from "../TelemetryService/TelemetryServiceElement";
|
||||
import TelemetryExceptionElement from "./ExceptionElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceExceptionSummary {
|
||||
service: Service;
|
||||
unresolvedCount: number;
|
||||
totalOccurrences: number;
|
||||
}
|
||||
|
||||
const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [unresolvedCount, setUnresolvedCount] = useState<number>(0);
|
||||
const [resolvedCount, setResolvedCount] = useState<number>(0);
|
||||
const [archivedCount, setArchivedCount] = useState<number>(0);
|
||||
const [topExceptions, setTopExceptions] = useState<Array<TelemetryException>>(
|
||||
[],
|
||||
);
|
||||
const [recentExceptions, setRecentExceptions] = useState<
|
||||
Array<TelemetryException>
|
||||
>([]);
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceExceptionSummary>
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const projectId: ObjectID = ProjectUtil.getCurrentProjectId()!;
|
||||
|
||||
const [
|
||||
unresolvedResult,
|
||||
resolvedResult,
|
||||
archivedResult,
|
||||
topExceptionsResult,
|
||||
recentExceptionsResult,
|
||||
servicesResult,
|
||||
] = await Promise.all([
|
||||
ModelAPI.count({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
}),
|
||||
ModelAPI.count({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: true,
|
||||
isArchived: false,
|
||||
},
|
||||
}),
|
||||
ModelAPI.count({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isArchived: true,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
message: true,
|
||||
exceptionType: true,
|
||||
fingerprint: true,
|
||||
isResolved: true,
|
||||
isArchived: true,
|
||||
occuranceCount: true,
|
||||
lastSeenAt: true,
|
||||
firstSeenAt: true,
|
||||
environment: true,
|
||||
service: {
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
sort: {
|
||||
occuranceCount: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
message: true,
|
||||
exceptionType: true,
|
||||
fingerprint: true,
|
||||
isResolved: true,
|
||||
isArchived: true,
|
||||
occuranceCount: true,
|
||||
lastSeenAt: true,
|
||||
firstSeenAt: true,
|
||||
environment: true,
|
||||
service: {
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: 8,
|
||||
skip: 0,
|
||||
sort: {
|
||||
lastSeenAt: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
setUnresolvedCount(unresolvedResult);
|
||||
setResolvedCount(resolvedResult);
|
||||
setArchivedCount(archivedResult);
|
||||
setTopExceptions(topExceptionsResult.data || []);
|
||||
setRecentExceptions(recentExceptionsResult.data || []);
|
||||
|
||||
const loadedServices: Array<Service> = servicesResult.data || [];
|
||||
|
||||
// Load unresolved exception counts per service
|
||||
const serviceExceptionCounts: Array<ServiceExceptionSummary> = [];
|
||||
|
||||
for (const service of loadedServices) {
|
||||
const serviceExceptions: ListResult<TelemetryException> =
|
||||
await ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
serviceId: service.id!,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
occuranceCount: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
occuranceCount: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
|
||||
const exceptions: Array<TelemetryException> =
|
||||
serviceExceptions.data || [];
|
||||
|
||||
if (exceptions.length > 0) {
|
||||
let totalOccurrences: number = 0;
|
||||
for (const ex of exceptions) {
|
||||
totalOccurrences += ex.occuranceCount || 0;
|
||||
}
|
||||
serviceExceptionCounts.push({
|
||||
service,
|
||||
unresolvedCount: exceptions.length,
|
||||
totalOccurrences,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
serviceExceptionCounts.sort(
|
||||
(a: ServiceExceptionSummary, b: ServiceExceptionSummary) => {
|
||||
return b.unresolvedCount - a.unresolvedCount;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(serviceExceptionCounts);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount: number = unresolvedCount + resolvedCount + archivedCount;
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-green-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No exceptions caught yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start reporting exceptions, you{"'"}ll see bug
|
||||
frequency, affected services, and resolution status here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolutionRate: number =
|
||||
totalCount > 0
|
||||
? Math.round(((resolvedCount + archivedCount) / totalCount) * 100)
|
||||
: 0;
|
||||
|
||||
// Count how many of the top exceptions were first seen in last 24h
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneDayAgo: Date = OneUptimeDate.addRemoveHours(now, -24);
|
||||
const newTodayCount: number = topExceptions.filter(
|
||||
(e: TelemetryException) => {
|
||||
return e.firstSeenAt && new Date(e.firstSeenAt) > oneDayAgo;
|
||||
},
|
||||
).length;
|
||||
|
||||
const maxServiceBugs: number =
|
||||
serviceSummaries.length > 0 ? serviceSummaries[0]!.unresolvedCount : 1;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Unresolved Alert Banner */}
|
||||
{unresolvedCount > 0 && (
|
||||
<AppLink
|
||||
className="block mb-6"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center justify-between ${unresolvedCount > 20 ? "bg-red-50 border border-red-200" : unresolvedCount > 5 ? "bg-amber-50 border border-amber-200" : "bg-blue-50 border border-blue-200"}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${unresolvedCount > 20 ? "bg-red-100" : unresolvedCount > 5 ? "bg-amber-100" : "bg-blue-100"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-5 w-5 ${unresolvedCount > 20 ? "text-red-600" : unresolvedCount > 5 ? "text-amber-600" : "text-blue-600"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 12s-.148-.277-.277-.5M19.08 12s.147-.277.277-.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-semibold ${unresolvedCount > 20 ? "text-red-800" : unresolvedCount > 5 ? "text-amber-800" : "text-blue-800"}`}
|
||||
>
|
||||
{unresolvedCount} unresolved{" "}
|
||||
{unresolvedCount === 1 ? "bug" : "bugs"} need attention
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-0.5 ${unresolvedCount > 20 ? "text-red-600" : unresolvedCount > 5 ? "text-amber-600" : "text-blue-600"}`}
|
||||
>
|
||||
{newTodayCount > 0
|
||||
? `${newTodayCount} new in the last 24 hours`
|
||||
: "Click to view and triage"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`h-5 w-5 ${unresolvedCount > 20 ? "text-red-400" : unresolvedCount > 5 ? "text-amber-400" : "text-blue-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</AppLink>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-red-200 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Unresolved</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-red-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600 mt-2">
|
||||
{unresolvedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">needs attention</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_RESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-green-200 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Resolved</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-600 mt-2">
|
||||
{resolvedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">fixed</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_ARCHIVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-gray-300 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Archived</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-600 mt-2">
|
||||
{archivedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">dismissed</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Resolution Rate</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-indigo-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{resolutionRate}%
|
||||
</p>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden mt-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400"
|
||||
style={{ width: `${resolutionRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Most Frequent Exceptions - takes 2 columns */}
|
||||
{topExceptions.length > 0 && (
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Most Frequent Bugs
|
||||
</h3>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
View all
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{topExceptions.map(
|
||||
(exception: TelemetryException, index: number) => {
|
||||
const maxOccurrences: number =
|
||||
topExceptions[0]?.occuranceCount || 1;
|
||||
const barWidth: number =
|
||||
((exception.occuranceCount || 0) / maxOccurrences) * 100;
|
||||
|
||||
const isNewToday: boolean = Boolean(
|
||||
exception.firstSeenAt &&
|
||||
new Date(exception.firstSeenAt) > oneDayAgo,
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={exception.id?.toString() || index.toString()}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
to={
|
||||
exception.fingerprint
|
||||
? new Route(
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_VIEW_ROOT
|
||||
] as Route,
|
||||
)
|
||||
.toString()
|
||||
.replace(/\/?$/, `/${exception.fingerprint}`),
|
||||
)
|
||||
: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_UNRESOLVED
|
||||
] as Route,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TelemetryExceptionElement
|
||||
message={
|
||||
exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown exception"
|
||||
}
|
||||
isResolved={exception.isResolved || false}
|
||||
isArchived={exception.isArchived || false}
|
||||
className="text-sm"
|
||||
/>
|
||||
{isNewToday && (
|
||||
<span className="flex-shrink-0 text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded font-medium">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{exception.service && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{exception.service.name?.toString()}
|
||||
</span>
|
||||
)}
|
||||
{exception.exceptionType && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono">
|
||||
{exception.exceptionType}
|
||||
</span>
|
||||
)}
|
||||
{exception.environment && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||
{exception.environment}
|
||||
</span>
|
||||
)}
|
||||
{exception.lastSeenAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(
|
||||
new Date(exception.lastSeenAt),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{(exception.occuranceCount || 0).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">hits</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right sidebar: Affected Services + Recently Seen */}
|
||||
<div className="space-y-6">
|
||||
{/* Affected Services */}
|
||||
{serviceSummaries.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Affected Services
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{serviceSummaries.map((summary: ServiceExceptionSummary) => {
|
||||
const barWidth: number =
|
||||
(summary.unresolvedCount / maxServiceBugs) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.service.id?.toString()}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TelemetryServiceElement
|
||||
telemetryService={summary.service}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold text-red-600">
|
||||
{summary.unresolvedCount}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
bugs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400"
|
||||
style={{
|
||||
width: `${Math.max(barWidth, 3)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{summary.totalOccurrences.toLocaleString()} hits
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recently Active */}
|
||||
{recentExceptions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Recently Active
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentExceptions
|
||||
.slice(0, 5)
|
||||
.map((exception: TelemetryException, index: number) => {
|
||||
return (
|
||||
<AppLink
|
||||
key={exception.id?.toString() || index.toString()}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
to={
|
||||
exception.fingerprint
|
||||
? new Route(
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_VIEW_ROOT
|
||||
] as Route,
|
||||
)
|
||||
.toString()
|
||||
.replace(
|
||||
/\/?$/,
|
||||
`/${exception.fingerprint}`,
|
||||
),
|
||||
)
|
||||
: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_UNRESOLVED
|
||||
] as Route,
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-900 truncate font-medium">
|
||||
{exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{exception.service && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{exception.service.name?.toString()}
|
||||
</span>
|
||||
)}
|
||||
{exception.lastSeenAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(
|
||||
new Date(exception.lastSeenAt),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExceptionsDashboard;
|
||||
@@ -29,6 +29,9 @@ export interface ComponentProps {
|
||||
query: Query<TelemetryException>;
|
||||
title: string;
|
||||
description: string;
|
||||
onFetchSuccess?:
|
||||
| ((data: Array<TelemetryException>, totalCount: number) => void)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const TelemetryExceptionTable: FunctionComponent<ComponentProps> = (
|
||||
@@ -47,6 +50,7 @@ const TelemetryExceptionTable: FunctionComponent<ComponentProps> = (
|
||||
userPreferencesKey="telemetry-exception-table"
|
||||
isEditable={false}
|
||||
isCreateable={false}
|
||||
onFetchSuccess={props.onFetchSuccess}
|
||||
singularName="Exception"
|
||||
pluralName="Exceptions"
|
||||
name="TelemetryException"
|
||||
|
||||
@@ -3,6 +3,7 @@ import Route from "Common/Types/API/Route";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import MetricFormulaConfigData from "Common/Types/Metrics/MetricFormulaConfigData";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricsViewConfig from "Common/Types/Metrics/MetricsViewConfig";
|
||||
import {
|
||||
CheckOn,
|
||||
CriteriaFilter,
|
||||
@@ -35,6 +36,15 @@ export interface ComponentProps {
|
||||
monitorStep: MonitorStep;
|
||||
}
|
||||
|
||||
const isMetricOnlyMonitorType: (monitorType: MonitorType) => boolean = (
|
||||
monitorType: MonitorType,
|
||||
): boolean => {
|
||||
return (
|
||||
monitorType === MonitorType.Kubernetes ||
|
||||
monitorType === MonitorType.Metrics
|
||||
);
|
||||
};
|
||||
|
||||
const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
@@ -77,6 +87,22 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
}, [criteriaFilter]);
|
||||
|
||||
const isMetricOnly: boolean = isMetricOnlyMonitorType(props.monitorType);
|
||||
|
||||
// Auto-select MetricValue for metric-only monitor types (Kubernetes, Metrics)
|
||||
useEffect(() => {
|
||||
if (
|
||||
isMetricOnly &&
|
||||
criteriaFilter &&
|
||||
criteriaFilter.checkOn !== CheckOn.MetricValue
|
||||
) {
|
||||
props.onChange?.({
|
||||
...criteriaFilter,
|
||||
checkOn: CheckOn.MetricValue,
|
||||
});
|
||||
}
|
||||
}, [isMetricOnly]);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -125,15 +151,20 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
});
|
||||
|
||||
// Collect metric variables from both metricMonitor and kubernetesMonitor configs
|
||||
const metricViewConfig: MetricsViewConfig | undefined =
|
||||
props.monitorStep.data?.metricMonitor?.metricViewConfig ||
|
||||
props.monitorStep.data?.kubernetesMonitor?.metricViewConfig;
|
||||
|
||||
let metricVariables: Array<string> =
|
||||
props.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs?.map(
|
||||
metricViewConfig?.queryConfigs?.map(
|
||||
(queryConfig: MetricQueryConfigData) => {
|
||||
return queryConfig.metricAliasData?.metricVariable || "";
|
||||
},
|
||||
) || [];
|
||||
|
||||
// push formula variables as well.
|
||||
props.monitorStep.data?.metricMonitor?.metricViewConfig?.formulaConfigs?.forEach(
|
||||
metricViewConfig?.formulaConfigs?.forEach(
|
||||
(formulaConfig: MetricFormulaConfigData) => {
|
||||
metricVariables.push(formulaConfig.metricAliasData.metricVariable || "");
|
||||
},
|
||||
@@ -168,24 +199,29 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md p-2 bg-gray-50 my-5 border-gray-200 border-solid border-2">
|
||||
<div className="">
|
||||
<FieldLabelElement title="Filter Type" />
|
||||
<Dropdown
|
||||
value={checkOnOptions.find((i: DropdownOption) => {
|
||||
return i.value === criteriaFilter?.checkOn;
|
||||
})}
|
||||
options={checkOnOptions}
|
||||
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
|
||||
props.onChange?.({
|
||||
checkOn: value?.toString() as CheckOn,
|
||||
filterType: undefined,
|
||||
value: undefined,
|
||||
evaluateOverTime: false,
|
||||
evaluateOverTimeOptions: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Hide Filter Type dropdown for metric-only monitors since MetricValue is the only option */}
|
||||
{!isMetricOnly && (
|
||||
<div className="">
|
||||
<FieldLabelElement title="Filter Type" />
|
||||
<Dropdown
|
||||
value={checkOnOptions.find((i: DropdownOption) => {
|
||||
return i.value === criteriaFilter?.checkOn;
|
||||
})}
|
||||
options={checkOnOptions}
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
props.onChange?.({
|
||||
checkOn: value?.toString() as CheckOn,
|
||||
filterType: undefined,
|
||||
value: undefined,
|
||||
evaluateOverTime: false,
|
||||
evaluateOverTimeOptions: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{criteriaFilter?.checkOn &&
|
||||
criteriaFilter?.checkOn === CheckOn.DiskUsagePercent && (
|
||||
@@ -210,7 +246,14 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
{criteriaFilter?.checkOn &&
|
||||
criteriaFilter?.checkOn === CheckOn.MetricValue && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Select Metric Variable" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Metric" : "Select Metric Variable"}
|
||||
description={
|
||||
isMetricOnly
|
||||
? "Which metric query should this alert rule check?"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
value={selectedMetricVariableOption}
|
||||
options={metricVariableOptions}
|
||||
@@ -232,7 +275,14 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
{criteriaFilter?.checkOn &&
|
||||
criteriaFilter?.checkOn === CheckOn.MetricValue && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Select Aggregation" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Aggregation" : "Select Aggregation"}
|
||||
description={
|
||||
isMetricOnly
|
||||
? "How to combine multiple data points (e.g. Average, Max, Min)."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
value={metricAggregationValue}
|
||||
options={metricAggregationOptions}
|
||||
@@ -350,7 +400,12 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
{!criteriaFilter?.checkOn ||
|
||||
(criteriaFilter?.checkOn && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Filter Condition" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Condition" : "Filter Condition"}
|
||||
description={
|
||||
isMetricOnly ? "When should this alert trigger?" : undefined
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
value={filterConditionValue}
|
||||
options={filterTypeOptions}
|
||||
@@ -377,7 +432,12 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
checkOn: criteriaFilter?.checkOn,
|
||||
}) && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Value" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Threshold" : "Value"}
|
||||
description={
|
||||
isMetricOnly ? "The value to compare against." : undefined
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder={valuePlaceholder}
|
||||
value={criteriaFilter?.value?.toString()}
|
||||
@@ -425,7 +485,7 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
<div className="mt-3 -mr-2 w-full flex justify-end">
|
||||
<Button
|
||||
title="Delete Filter"
|
||||
title={isMetricOnly ? "Delete Rule" : "Delete Filter"}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
icon={IconProp.Trash}
|
||||
buttonSize={ButtonSize.Small}
|
||||
|
||||
@@ -3,6 +3,7 @@ import IconProp from "Common/Types/Icon/IconProp";
|
||||
import {
|
||||
CheckOn,
|
||||
CriteriaFilter,
|
||||
EvaluateOverTimeType,
|
||||
FilterType,
|
||||
} from "Common/Types/Monitor/CriteriaFilter";
|
||||
import MonitorStep from "Common/Types/Monitor/MonitorStep";
|
||||
@@ -98,18 +99,39 @@ const CriteriaFilters: FunctionComponent<ComponentProps> = (
|
||||
})}
|
||||
<div className="mt-3 -ml-3">
|
||||
<Button
|
||||
title="Add Filter"
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? "Add Rule"
|
||||
: "Add Filter"
|
||||
}
|
||||
buttonSize={ButtonSize.Small}
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
const newCriteriaFilters: Array<CriteriaFilter> = [
|
||||
...criteriaFilters,
|
||||
];
|
||||
newCriteriaFilters.push({
|
||||
checkOn: CheckOn.IsOnline,
|
||||
filterType: FilterType.EqualTo,
|
||||
value: "",
|
||||
});
|
||||
|
||||
const isMetricOnly: boolean =
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics;
|
||||
|
||||
newCriteriaFilters.push(
|
||||
isMetricOnly
|
||||
? {
|
||||
checkOn: CheckOn.MetricValue,
|
||||
filterType: FilterType.GreaterThan,
|
||||
value: "",
|
||||
metricMonitorOptions: {
|
||||
metricAggregationType: EvaluateOverTimeType.AnyValue,
|
||||
},
|
||||
}
|
||||
: {
|
||||
checkOn: CheckOn.IsOnline,
|
||||
filterType: FilterType.EqualTo,
|
||||
value: "",
|
||||
},
|
||||
);
|
||||
|
||||
props.onChange?.(newCriteriaFilters);
|
||||
}}
|
||||
@@ -117,8 +139,18 @@ const CriteriaFilters: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
{showCantDeleteModal ? (
|
||||
<ConfirmModal
|
||||
description={`We need at least one filter for this criteria. We cant delete one remaining filter. If you don't need filters, please feel free to delete criteria instead.`}
|
||||
title={`Cannot delete last remaining filter.`}
|
||||
description={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? `At least one alert rule is required. If you don't need rules, you can delete the entire criteria instead.`
|
||||
: `We need at least one filter for this criteria. We cant delete one remaining filter. If you don't need filters, please feel free to delete criteria instead.`
|
||||
}
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? `Cannot delete last remaining rule.`
|
||||
: `Cannot delete last remaining filter.`
|
||||
}
|
||||
onSubmit={() => {
|
||||
setShowCantDeleteModal(false);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Dropdown, {
|
||||
DropdownOption,
|
||||
DropdownOptionGroup,
|
||||
DropdownValue,
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import {
|
||||
getAllKubernetesMetrics,
|
||||
getAllKubernetesMetricCategories,
|
||||
KubernetesMetricDefinition,
|
||||
KubernetesMetricCategory,
|
||||
} from "Common/Types/Monitor/KubernetesMetricCatalog";
|
||||
|
||||
export interface ComponentProps {
|
||||
selectedMetricId?: string | undefined;
|
||||
onMetricSelected: (metric: KubernetesMetricDefinition) => void;
|
||||
}
|
||||
|
||||
const KubernetesMetricPicker: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const allMetrics: Array<KubernetesMetricDefinition> =
|
||||
getAllKubernetesMetrics();
|
||||
const allCategories: Array<KubernetesMetricCategory> =
|
||||
getAllKubernetesMetricCategories();
|
||||
|
||||
const groupedOptions: Array<DropdownOptionGroup> = allCategories.map(
|
||||
(category: KubernetesMetricCategory) => {
|
||||
const categoryMetrics: Array<KubernetesMetricDefinition> =
|
||||
allMetrics.filter((m: KubernetesMetricDefinition) => {
|
||||
return m.category === category;
|
||||
});
|
||||
|
||||
return {
|
||||
label: category,
|
||||
options: categoryMetrics.map((m: KubernetesMetricDefinition) => {
|
||||
return {
|
||||
label: `${m.friendlyName}${m.unit ? ` (${m.unit})` : ""}`,
|
||||
value: m.id,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const selectedMetric: KubernetesMetricDefinition | undefined =
|
||||
props.selectedMetricId
|
||||
? allMetrics.find((m: KubernetesMetricDefinition) => {
|
||||
return m.id === props.selectedMetricId;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const selectedOption: DropdownOption | undefined = selectedMetric
|
||||
? {
|
||||
label: `${selectedMetric.friendlyName}${selectedMetric.unit ? ` (${selectedMetric.unit})` : ""}`,
|
||||
value: selectedMetric.id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown
|
||||
options={groupedOptions}
|
||||
value={selectedOption}
|
||||
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metricId: string = value as string;
|
||||
const metric: KubernetesMetricDefinition | undefined =
|
||||
allMetrics.find((m: KubernetesMetricDefinition) => {
|
||||
return m.id === metricId;
|
||||
});
|
||||
|
||||
if (metric) {
|
||||
props.onMetricSelected(metric);
|
||||
}
|
||||
}}
|
||||
placeholder="Select a Kubernetes metric..."
|
||||
/>
|
||||
|
||||
{selectedMetric && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{selectedMetric.description} — Metric:{" "}
|
||||
<code className="bg-gray-100 px-1 rounded text-xs">
|
||||
{selectedMetric.metricName}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesMetricPicker;
|
||||
@@ -0,0 +1,747 @@
|
||||
import MonitorStepKubernetesMonitor, {
|
||||
MonitorStepKubernetesMonitorUtil,
|
||||
KubernetesResourceScope,
|
||||
} from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
|
||||
import MonitorStep from "Common/Types/Monitor/MonitorStep";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import MetricView from "../../../Metrics/MetricView";
|
||||
import RollingTime from "Common/Types/RollingTime/RollingTime";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RollingTimePicker from "Common/UI/Components/RollingTimePicker/RollingTimePicker";
|
||||
import RollingTimeUtil from "Common/Types/RollingTime/RollingTimeUtil";
|
||||
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import Dropdown, {
|
||||
DropdownOption,
|
||||
DropdownValue,
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import Input from "Common/UI/Components/Input/Input";
|
||||
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import Tabs from "Common/UI/Components/Tabs/Tabs";
|
||||
import { Tab } from "Common/UI/Components/Tabs/Tab";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
import KubernetesTemplatePicker from "./KubernetesTemplatePicker";
|
||||
import KubernetesMetricPicker from "./KubernetesMetricPicker";
|
||||
import {
|
||||
KubernetesAlertTemplate,
|
||||
getKubernetesAlertTemplateById,
|
||||
buildKubernetesMonitorConfig,
|
||||
} from "Common/Types/Monitor/KubernetesAlertTemplates";
|
||||
import { KubernetesMetricDefinition } from "Common/Types/Monitor/KubernetesMetricCatalog";
|
||||
import MonitorCriteria from "Common/Types/Monitor/MonitorCriteria";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
|
||||
export type KubernetesFormMode = "quick" | "custom" | "advanced";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor;
|
||||
onChange: (
|
||||
monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor,
|
||||
) => void;
|
||||
onMonitorCriteriaChange?: ((criteria: MonitorCriteria) => void) | undefined;
|
||||
onModeChange?: ((mode: KubernetesFormMode) => void) | undefined;
|
||||
initialTemplateId?: string | undefined;
|
||||
initialClusterId?: string | undefined;
|
||||
// These IDs are needed to build proper criteria from templates
|
||||
onlineMonitorStatusId?: ObjectID | undefined;
|
||||
offlineMonitorStatusId?: ObjectID | undefined;
|
||||
defaultIncidentSeverityId?: ObjectID | undefined;
|
||||
defaultAlertSeverityId?: ObjectID | undefined;
|
||||
monitorName?: string | undefined;
|
||||
}
|
||||
|
||||
const resourceScopeOptions: Array<DropdownOption> = [
|
||||
{
|
||||
label: "Cluster",
|
||||
value: KubernetesResourceScope.Cluster,
|
||||
},
|
||||
{
|
||||
label: "Namespace",
|
||||
value: KubernetesResourceScope.Namespace,
|
||||
},
|
||||
{
|
||||
label: "Workload",
|
||||
value: KubernetesResourceScope.Workload,
|
||||
},
|
||||
{
|
||||
label: "Node",
|
||||
value: KubernetesResourceScope.Node,
|
||||
},
|
||||
{
|
||||
label: "Pod",
|
||||
value: KubernetesResourceScope.Pod,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregationOptions: Array<DropdownOption> = [
|
||||
{ label: "Average", value: MetricsAggregationType.Avg },
|
||||
{ label: "Maximum", value: MetricsAggregationType.Max },
|
||||
{ label: "Minimum", value: MetricsAggregationType.Min },
|
||||
{ label: "Sum", value: MetricsAggregationType.Sum },
|
||||
{ label: "Count", value: MetricsAggregationType.Count },
|
||||
];
|
||||
|
||||
const KubernetesMonitorStepForm: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
// Read query params for template/cluster pre-fill
|
||||
const urlTemplateId: string | undefined =
|
||||
props.initialTemplateId ||
|
||||
Navigation.getQueryStringByName("templateId") ||
|
||||
undefined;
|
||||
const urlClusterId: string | undefined =
|
||||
props.initialClusterId ||
|
||||
Navigation.getQueryStringByName("clusterId") ||
|
||||
undefined;
|
||||
|
||||
const [, setMode] = React.useState<KubernetesFormMode>("quick");
|
||||
|
||||
const [rollingTime, setRollingTime] = React.useState<RollingTime | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor =
|
||||
props.monitorStepKubernetesMonitor ||
|
||||
MonitorStepKubernetesMonitorUtil.getDefault();
|
||||
|
||||
const [startAndEndTime, setStartAndEndTime] =
|
||||
React.useState<InBetween<Date> | null>(null);
|
||||
|
||||
const [clusterOptions, setClusterOptions] = React.useState<
|
||||
Array<DropdownOption>
|
||||
>([]);
|
||||
|
||||
const [, setIsLoadingClusters] = React.useState<boolean>(true);
|
||||
|
||||
// Quick Setup state
|
||||
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
|
||||
string | undefined
|
||||
>(urlTemplateId);
|
||||
|
||||
// Custom Metric state
|
||||
const [selectedMetricId, setSelectedMetricId] = React.useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [customAggregation, setCustomAggregation] =
|
||||
React.useState<MetricsAggregationType>(MetricsAggregationType.Avg);
|
||||
const [customResourceScope, setCustomResourceScope] =
|
||||
React.useState<KubernetesResourceScope>(KubernetesResourceScope.Cluster);
|
||||
|
||||
useEffect(() => {
|
||||
// Load clusters
|
||||
setIsLoadingClusters(true);
|
||||
ModelAPI.getList<KubernetesCluster>({
|
||||
modelType: KubernetesCluster,
|
||||
query: {},
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
clusterIdentifier: true,
|
||||
},
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
})
|
||||
.then((result: ListResult<KubernetesCluster>) => {
|
||||
const options: Array<DropdownOption> = result.data.map(
|
||||
(cluster: KubernetesCluster) => {
|
||||
return {
|
||||
label: cluster.name || cluster.clusterIdentifier || "Unknown",
|
||||
value: cluster.clusterIdentifier || "",
|
||||
};
|
||||
},
|
||||
);
|
||||
setClusterOptions(options);
|
||||
|
||||
// Auto-select cluster if initialClusterId or URL param is provided
|
||||
if (urlClusterId && !monitorStepKubernetesMonitor.clusterIdentifier) {
|
||||
const matchedCluster: DropdownOption | undefined = options.find(
|
||||
(o: DropdownOption) => {
|
||||
return o.value === urlClusterId;
|
||||
},
|
||||
);
|
||||
if (matchedCluster) {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
clusterIdentifier: matchedCluster.value as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((_err: Error) => {
|
||||
setClusterOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingClusters(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle initial template selection
|
||||
useEffect(() => {
|
||||
if (urlTemplateId && monitorStepKubernetesMonitor.clusterIdentifier) {
|
||||
const template: KubernetesAlertTemplate | undefined =
|
||||
getKubernetesAlertTemplateById(urlTemplateId);
|
||||
if (template) {
|
||||
handleTemplateSelection(template);
|
||||
}
|
||||
}
|
||||
}, [props.initialTemplateId, monitorStepKubernetesMonitor.clusterIdentifier]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rollingTime === monitorStepKubernetesMonitor.rollingTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRollingTime(monitorStepKubernetesMonitor.rollingTime);
|
||||
|
||||
setStartAndEndTime(
|
||||
RollingTimeUtil.convertToStartAndEndDate(
|
||||
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past1Minute,
|
||||
),
|
||||
);
|
||||
}, [monitorStepKubernetesMonitor.rollingTime]);
|
||||
|
||||
useEffect(() => {
|
||||
setStartAndEndTime(
|
||||
RollingTimeUtil.convertToStartAndEndDate(
|
||||
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past1Minute,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTemplateSelection: (template: KubernetesAlertTemplate) => void = (
|
||||
template: KubernetesAlertTemplate,
|
||||
): void => {
|
||||
setSelectedTemplateId(template.id);
|
||||
|
||||
/*
|
||||
* Build the kubernetes monitor config from the template's getMonitorStep
|
||||
* We need the cluster identifier to build the config
|
||||
*/
|
||||
const clusterIdentifier: string =
|
||||
monitorStepKubernetesMonitor.clusterIdentifier;
|
||||
|
||||
/*
|
||||
* Use real monitor status and severity IDs if available,
|
||||
* so the template criteria are properly configured
|
||||
*/
|
||||
const onlineMonitorStatusId: ObjectID =
|
||||
props.onlineMonitorStatusId || ObjectID.generate();
|
||||
const offlineMonitorStatusId: ObjectID =
|
||||
props.offlineMonitorStatusId || ObjectID.generate();
|
||||
const defaultIncidentSeverityId: ObjectID =
|
||||
props.defaultIncidentSeverityId || ObjectID.generate();
|
||||
const defaultAlertSeverityId: ObjectID =
|
||||
props.defaultAlertSeverityId || ObjectID.generate();
|
||||
const monitorName: string = props.monitorName || template.name;
|
||||
|
||||
const templateStep: MonitorStep = template.getMonitorStep({
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
onlineMonitorStatusId,
|
||||
offlineMonitorStatusId,
|
||||
defaultIncidentSeverityId,
|
||||
defaultAlertSeverityId,
|
||||
monitorName,
|
||||
});
|
||||
|
||||
// Extract the kubernetes monitor config
|
||||
if (templateStep.data?.kubernetesMonitor) {
|
||||
props.onChange({
|
||||
...templateStep.data.kubernetesMonitor,
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Also apply the template's criteria (alert rules, thresholds, incidents, etc.)
|
||||
if (templateStep.data?.monitorCriteria && props.onMonitorCriteriaChange) {
|
||||
props.onMonitorCriteriaChange(templateStep.data.monitorCriteria);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomMetricSelection: (
|
||||
metric: KubernetesMetricDefinition,
|
||||
) => void = (metric: KubernetesMetricDefinition): void => {
|
||||
setSelectedMetricId(metric.id);
|
||||
setCustomAggregation(metric.defaultAggregation);
|
||||
setCustomResourceScope(metric.defaultResourceScope);
|
||||
|
||||
const clusterIdentifier: string =
|
||||
monitorStepKubernetesMonitor.clusterIdentifier;
|
||||
|
||||
const config: MonitorStepKubernetesMonitor = buildKubernetesMonitorConfig({
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
metricName: metric.metricName,
|
||||
metricAlias: metric.id.replace(/-/g, "_"),
|
||||
resourceScope: metric.defaultResourceScope,
|
||||
rollingTime:
|
||||
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past5Minutes,
|
||||
aggregationType: metric.defaultAggregation,
|
||||
});
|
||||
|
||||
props.onChange(config);
|
||||
};
|
||||
|
||||
const showNamespaceFilter: boolean =
|
||||
monitorStepKubernetesMonitor.resourceScope ===
|
||||
KubernetesResourceScope.Namespace ||
|
||||
monitorStepKubernetesMonitor.resourceScope ===
|
||||
KubernetesResourceScope.Workload ||
|
||||
monitorStepKubernetesMonitor.resourceScope === KubernetesResourceScope.Pod;
|
||||
|
||||
const showWorkloadFilter: boolean =
|
||||
monitorStepKubernetesMonitor.resourceScope ===
|
||||
KubernetesResourceScope.Workload;
|
||||
|
||||
const showNodeFilter: boolean =
|
||||
monitorStepKubernetesMonitor.resourceScope === KubernetesResourceScope.Node;
|
||||
|
||||
const showPodFilter: boolean =
|
||||
monitorStepKubernetesMonitor.resourceScope === KubernetesResourceScope.Pod;
|
||||
|
||||
const renderClusterDropdown: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<FieldLabelElement
|
||||
title="Kubernetes Cluster"
|
||||
description={"Select the Kubernetes cluster to monitor."}
|
||||
required={true}
|
||||
/>
|
||||
<Dropdown
|
||||
options={clusterOptions}
|
||||
value={clusterOptions.find((option: DropdownOption) => {
|
||||
return (
|
||||
option.value === monitorStepKubernetesMonitor.clusterIdentifier
|
||||
);
|
||||
})}
|
||||
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
clusterIdentifier: (value as string) || "",
|
||||
});
|
||||
}}
|
||||
placeholder="Select a cluster..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResourceFilters: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
{showNamespaceFilter && (
|
||||
<div className="mt-3">
|
||||
<FieldLabelElement
|
||||
title="Namespace"
|
||||
description={"Filter by namespace (optional)."}
|
||||
required={false}
|
||||
/>
|
||||
<Input
|
||||
value={
|
||||
monitorStepKubernetesMonitor.resourceFilters.namespace || ""
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
resourceFilters: {
|
||||
...monitorStepKubernetesMonitor.resourceFilters,
|
||||
namespace: value || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. default, production"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWorkloadFilter && (
|
||||
<div className="mt-3">
|
||||
<FieldLabelElement
|
||||
title="Workload Name"
|
||||
description={"Filter by workload name (optional)."}
|
||||
required={false}
|
||||
/>
|
||||
<Input
|
||||
value={
|
||||
monitorStepKubernetesMonitor.resourceFilters.workloadName || ""
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
resourceFilters: {
|
||||
...monitorStepKubernetesMonitor.resourceFilters,
|
||||
workloadName: value || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. my-deployment"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNodeFilter && (
|
||||
<div className="mt-3">
|
||||
<FieldLabelElement
|
||||
title="Node Name"
|
||||
description={"Filter by node name (optional)."}
|
||||
required={false}
|
||||
/>
|
||||
<Input
|
||||
value={
|
||||
monitorStepKubernetesMonitor.resourceFilters.nodeName || ""
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
resourceFilters: {
|
||||
...monitorStepKubernetesMonitor.resourceFilters,
|
||||
nodeName: value || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. node-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPodFilter && (
|
||||
<div className="mt-3">
|
||||
<FieldLabelElement
|
||||
title="Pod Name"
|
||||
description={"Filter by pod name (optional)."}
|
||||
required={false}
|
||||
/>
|
||||
<Input
|
||||
value={monitorStepKubernetesMonitor.resourceFilters.podName || ""}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
resourceFilters: {
|
||||
...monitorStepKubernetesMonitor.resourceFilters,
|
||||
podName: value || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. my-pod-abc123"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderQuickSetup: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<KubernetesTemplatePicker
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
onTemplateSelected={(template: KubernetesAlertTemplate) => {
|
||||
handleTemplateSelection(template);
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedTemplateId && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">
|
||||
Template Configuration
|
||||
</h4>
|
||||
<p className="text-xs text-blue-700 mb-3">
|
||||
The following settings have been auto-configured. You can adjust
|
||||
the time range below.
|
||||
</p>
|
||||
|
||||
<FieldLabelElement
|
||||
title="Time Range"
|
||||
description={"Adjust the monitoring time range."}
|
||||
required={true}
|
||||
/>
|
||||
<RollingTimePicker
|
||||
value={monitorStepKubernetesMonitor.rollingTime}
|
||||
onChange={(value: RollingTime) => {
|
||||
if (value === monitorStepKubernetesMonitor.rollingTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
rollingTime: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCustomMetric: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Kubernetes Metric"
|
||||
description={
|
||||
"Select a Kubernetes metric to monitor. Metrics are organized by resource type."
|
||||
}
|
||||
required={true}
|
||||
/>
|
||||
<KubernetesMetricPicker
|
||||
selectedMetricId={selectedMetricId}
|
||||
onMetricSelected={(metric: KubernetesMetricDefinition) => {
|
||||
handleCustomMetricSelection(metric);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedMetricId && (
|
||||
<>
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Resource Scope"
|
||||
description={"Select the scope of resources to monitor."}
|
||||
required={true}
|
||||
/>
|
||||
<Dropdown
|
||||
options={resourceScopeOptions}
|
||||
value={resourceScopeOptions.find((option: DropdownOption) => {
|
||||
return option.value === customResourceScope;
|
||||
})}
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
const newScope: KubernetesResourceScope =
|
||||
(value as KubernetesResourceScope) ||
|
||||
KubernetesResourceScope.Cluster;
|
||||
setCustomResourceScope(newScope);
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
resourceScope: newScope,
|
||||
resourceFilters: {},
|
||||
});
|
||||
}}
|
||||
placeholder="Select resource scope..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderResourceFilters()}
|
||||
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Aggregation"
|
||||
description={
|
||||
"How should the metric values be aggregated over the time range."
|
||||
}
|
||||
required={true}
|
||||
/>
|
||||
<Dropdown
|
||||
options={aggregationOptions}
|
||||
value={aggregationOptions.find((option: DropdownOption) => {
|
||||
return option.value === customAggregation;
|
||||
})}
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
const newAgg: MetricsAggregationType =
|
||||
(value as MetricsAggregationType) ||
|
||||
MetricsAggregationType.Avg;
|
||||
setCustomAggregation(newAgg);
|
||||
|
||||
// Rebuild the config with updated aggregation
|
||||
if (
|
||||
monitorStepKubernetesMonitor.metricViewConfig.queryConfigs
|
||||
.length > 0
|
||||
) {
|
||||
const currentQueryConfig: MetricQueryConfigData =
|
||||
monitorStepKubernetesMonitor.metricViewConfig
|
||||
.queryConfigs[0]!;
|
||||
if (currentQueryConfig) {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
metricViewConfig: {
|
||||
...monitorStepKubernetesMonitor.metricViewConfig,
|
||||
queryConfigs: [
|
||||
{
|
||||
...currentQueryConfig,
|
||||
metricQueryData: {
|
||||
...currentQueryConfig.metricQueryData,
|
||||
filterData: {
|
||||
...currentQueryConfig.metricQueryData
|
||||
.filterData,
|
||||
aggegationType: newAgg,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Select aggregation..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Time Range"
|
||||
description={
|
||||
"Select the time range for the Kubernetes monitor."
|
||||
}
|
||||
required={true}
|
||||
/>
|
||||
<RollingTimePicker
|
||||
value={monitorStepKubernetesMonitor.rollingTime}
|
||||
onChange={(value: RollingTime) => {
|
||||
if (value === monitorStepKubernetesMonitor.rollingTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
rollingTime: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAdvanced: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Resource Scope"
|
||||
description={"Select the scope of resources to monitor."}
|
||||
required={true}
|
||||
/>
|
||||
<Dropdown
|
||||
options={resourceScopeOptions}
|
||||
value={resourceScopeOptions.find((option: DropdownOption) => {
|
||||
return (
|
||||
option.value === monitorStepKubernetesMonitor.resourceScope
|
||||
);
|
||||
})}
|
||||
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
resourceScope:
|
||||
(value as KubernetesResourceScope) ||
|
||||
KubernetesResourceScope.Cluster,
|
||||
resourceFilters: {},
|
||||
});
|
||||
}}
|
||||
placeholder="Select resource scope..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderResourceFilters()}
|
||||
|
||||
<div className="mt-3">
|
||||
<FieldLabelElement
|
||||
title="Time Range"
|
||||
description={"Select the time range for the Kubernetes monitor."}
|
||||
required={true}
|
||||
/>
|
||||
<RollingTimePicker
|
||||
value={monitorStepKubernetesMonitor.rollingTime}
|
||||
onChange={(value: RollingTime) => {
|
||||
if (value === monitorStepKubernetesMonitor.rollingTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
rollingTime: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<FieldLabelElement
|
||||
title="Select Metrics"
|
||||
description={
|
||||
"Select the Kubernetes metrics to monitor. Use the query builder for full control over metric selection and filtering."
|
||||
}
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<div className="mt-3"></div>
|
||||
|
||||
<MetricView
|
||||
hideStartAndEndDate={true}
|
||||
data={{
|
||||
startAndEndDate: startAndEndTime,
|
||||
queryConfigs:
|
||||
monitorStepKubernetesMonitor.metricViewConfig.queryConfigs,
|
||||
formulaConfigs:
|
||||
monitorStepKubernetesMonitor.metricViewConfig.formulaConfigs,
|
||||
}}
|
||||
hideCardInQueryElements={true}
|
||||
hideCardInCharts={true}
|
||||
chartCssClass="rounded-md border border-gray-200 mt-2 shadow-none"
|
||||
onChange={(data: MetricViewData) => {
|
||||
props.onChange({
|
||||
...monitorStepKubernetesMonitor,
|
||||
metricViewConfig: {
|
||||
queryConfigs: data.queryConfigs,
|
||||
formulaConfigs: data.formulaConfigs,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs: Array<Tab> = [
|
||||
{
|
||||
name: "Quick Setup",
|
||||
children: renderQuickSetup(),
|
||||
},
|
||||
{
|
||||
name: "Custom Metric",
|
||||
children: renderCustomMetric(),
|
||||
},
|
||||
{
|
||||
name: "Advanced",
|
||||
children: renderAdvanced(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderClusterDropdown()}
|
||||
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
onTabChange={(tab: Tab) => {
|
||||
let newMode: KubernetesFormMode = "quick";
|
||||
if (tab.name === "Quick Setup") {
|
||||
newMode = "quick";
|
||||
} else if (tab.name === "Custom Metric") {
|
||||
newMode = "custom";
|
||||
} else if (tab.name === "Advanced") {
|
||||
newMode = "advanced";
|
||||
}
|
||||
setMode(newMode);
|
||||
props.onModeChange?.(newMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesMonitorStepForm;
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import {
|
||||
getAllKubernetesAlertTemplates,
|
||||
KubernetesAlertTemplate,
|
||||
KubernetesAlertTemplateCategory,
|
||||
} from "Common/Types/Monitor/KubernetesAlertTemplates";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
|
||||
export interface ComponentProps {
|
||||
selectedTemplateId?: string | undefined;
|
||||
onTemplateSelected: (template: KubernetesAlertTemplate) => void;
|
||||
}
|
||||
|
||||
const categories: Array<{
|
||||
category: KubernetesAlertTemplateCategory;
|
||||
label: string;
|
||||
icon: IconProp;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
category: "Workload",
|
||||
label: "Workload",
|
||||
icon: IconProp.Cube,
|
||||
description:
|
||||
"Monitor workload health including pod restarts, replica mismatches, and job failures.",
|
||||
},
|
||||
{
|
||||
category: "Node",
|
||||
label: "Node",
|
||||
icon: IconProp.Server,
|
||||
description:
|
||||
"Monitor node health including CPU, memory, disk usage, and node readiness.",
|
||||
},
|
||||
{
|
||||
category: "ControlPlane",
|
||||
label: "Control Plane",
|
||||
icon: IconProp.Settings,
|
||||
description:
|
||||
"Monitor Kubernetes control plane components including etcd, API server, and scheduler.",
|
||||
},
|
||||
{
|
||||
category: "Storage",
|
||||
label: "Storage",
|
||||
icon: IconProp.Disc,
|
||||
description: "Monitor storage resources including disk usage.",
|
||||
},
|
||||
{
|
||||
category: "Scheduling",
|
||||
label: "Scheduling",
|
||||
icon: IconProp.Clock,
|
||||
description:
|
||||
"Monitor pod scheduling including pending pods and scheduler backlog.",
|
||||
},
|
||||
];
|
||||
|
||||
const KubernetesTemplatePicker: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const allTemplates: Array<KubernetesAlertTemplate> =
|
||||
getAllKubernetesAlertTemplates();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Select a pre-built alert template to quickly set up monitoring. The
|
||||
template will auto-configure the metric, scope, aggregation, time range,
|
||||
and thresholds.
|
||||
</p>
|
||||
|
||||
{categories.map(
|
||||
(cat: {
|
||||
category: KubernetesAlertTemplateCategory;
|
||||
label: string;
|
||||
icon: IconProp;
|
||||
description: string;
|
||||
}) => {
|
||||
const categoryTemplates: Array<KubernetesAlertTemplate> =
|
||||
allTemplates.filter((t: KubernetesAlertTemplate) => {
|
||||
return t.category === cat.category;
|
||||
});
|
||||
|
||||
if (categoryTemplates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={cat.category}>
|
||||
<div className="flex items-center mb-2">
|
||||
<Icon icon={cat.icon} className="mr-2 h-4 w-4 text-gray-500" />
|
||||
<h4 className="text-sm font-semibold text-gray-700">
|
||||
{cat.label}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{cat.description}</p>
|
||||
<div className="grid grid-cols-1 gap-2 mb-4">
|
||||
{categoryTemplates.map((template: KubernetesAlertTemplate) => {
|
||||
const isSelected: boolean =
|
||||
props.selectedTemplateId === template.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all hover:shadow-sm ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 ring-1 ring-blue-500"
|
||||
: "border-gray-200 bg-white hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
props.onTemplateSelected(template);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
props.onTemplateSelected(template);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{template.name}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
template.severity === "Critical"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{template.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="ml-3">
|
||||
<Icon
|
||||
icon={IconProp.CheckCircle}
|
||||
className="h-5 w-5 text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesTemplatePicker;
|
||||
@@ -247,8 +247,18 @@ const MonitorCriteriaInstanceElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
{/* Filters Section - Collapsible */}
|
||||
<CollapsibleSection
|
||||
title="Filters"
|
||||
description="Add criteria for different monitor properties."
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? "Alert Rules"
|
||||
: "Filters"
|
||||
}
|
||||
description={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? "Define when this alert should trigger based on metric values."
|
||||
: "Add criteria for different monitor properties."
|
||||
}
|
||||
badge={filterSummary}
|
||||
variant="bordered"
|
||||
defaultCollapsed={false}
|
||||
@@ -257,8 +267,18 @@ const MonitorCriteriaInstanceElement: FunctionComponent<ComponentProps> = (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<FieldLabelElement
|
||||
title="Filter Condition"
|
||||
description="Select All if you want all the criteria to be met. Select any if you like any criteria to be met."
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? "Match Condition"
|
||||
: "Filter Condition"
|
||||
}
|
||||
description={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? "Should all rules match, or just any one of them?"
|
||||
: "Select All if you want all the criteria to be met. Select any if you like any criteria to be met."
|
||||
}
|
||||
required={true}
|
||||
/>
|
||||
<Radio
|
||||
|
||||
@@ -67,6 +67,10 @@ import MetricMonitorStepForm from "./MetricMonitor/MetricMonitorStepForm";
|
||||
import MonitorStepMetricMonitor, {
|
||||
MonitorStepMetricMonitorUtil,
|
||||
} from "Common/Types/Monitor/MonitorStepMetricMonitor";
|
||||
import KubernetesMonitorStepForm from "./KubernetesMonitor/KubernetesMonitorStepForm";
|
||||
import MonitorStepKubernetesMonitor, {
|
||||
MonitorStepKubernetesMonitorUtil,
|
||||
} from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
|
||||
import Link from "Common/UI/Components/Link/Link";
|
||||
import TinyFormDocumentation from "Common/UI/Components/TinyFormDocumentation/TinyFormDocumentation";
|
||||
import ExceptionMonitorStepForm from "./ExceptionMonitor/ExceptionMonitorStepForm";
|
||||
@@ -106,6 +110,12 @@ export interface ComponentProps {
|
||||
allMonitorSteps: MonitorSteps;
|
||||
probes: Array<Probe>;
|
||||
monitorId?: ObjectID | undefined; // this is used to populate secrets when testing the monitor.
|
||||
// IDs needed for Kubernetes template criteria
|
||||
onlineMonitorStatusId?: ObjectID | undefined;
|
||||
offlineMonitorStatusId?: ObjectID | undefined;
|
||||
defaultIncidentSeverityId?: ObjectID | undefined;
|
||||
defaultAlertSeverityId?: ObjectID | undefined;
|
||||
monitorName?: string | undefined;
|
||||
}
|
||||
|
||||
const MonitorStepElement: FunctionComponent<ComponentProps> = (
|
||||
@@ -247,7 +257,14 @@ const MonitorStepElement: FunctionComponent<ComponentProps> = (
|
||||
if (props.monitorType === MonitorType.CustomJavaScriptCode) {
|
||||
codeEditorPlaceholder = `
|
||||
// You can use axios, http modules here.
|
||||
await axios.get('https://example.com');
|
||||
const response = await axios.get('https://example.com');
|
||||
|
||||
// To capture custom metrics, use oneuptime.captureMetric(name, value, attributes)
|
||||
// These metrics can be charted on dashboards via the Metric Explorer.
|
||||
oneuptime.captureMetric('api.response.time', response.data.latency);
|
||||
oneuptime.captureMetric('api.queue.depth', response.data.queueDepth, {
|
||||
region: 'us-east-1'
|
||||
});
|
||||
|
||||
// when you want to return a value, use return statement with data as a prop.
|
||||
|
||||
@@ -265,6 +282,7 @@ return {
|
||||
// - page: Playwright Page object to interact with the browser
|
||||
// - browserType: Browser type in the current run context - Chromium, Firefox, Webkit
|
||||
// - screenSizeType: Screen size type in the current run context - Mobile, Tablet, Desktop
|
||||
// - oneuptime.captureMetric: Capture custom metrics for dashboards
|
||||
|
||||
await page.goto('https://playwright.dev/');
|
||||
|
||||
@@ -276,6 +294,11 @@ const screenshots = {};
|
||||
|
||||
screenshots['screenshot-name'] = await page.screenshot(); // you can save multiple screenshots and have them with different names.
|
||||
|
||||
// To capture custom metrics, use oneuptime.captureMetric(name, value, attributes)
|
||||
// These metrics can be charted on dashboards via the Metric Explorer.
|
||||
const startTime = Date.now();
|
||||
await page.waitForSelector('h1');
|
||||
oneuptime.captureMetric('page.load.time', Date.now() - startTime);
|
||||
|
||||
// To log data, use console.log
|
||||
console.log('Hello World');
|
||||
@@ -742,6 +765,33 @@ return {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{props.monitorType === MonitorType.Kubernetes && (
|
||||
<Card
|
||||
title="Kubernetes Monitor Configuration"
|
||||
description="Configure your Kubernetes cluster monitoring using templates, curated metrics, or the advanced query builder."
|
||||
>
|
||||
<KubernetesMonitorStepForm
|
||||
monitorStepKubernetesMonitor={
|
||||
monitorStep.data?.kubernetesMonitor ||
|
||||
MonitorStepKubernetesMonitorUtil.getDefault()
|
||||
}
|
||||
onChange={(value: MonitorStepKubernetesMonitor) => {
|
||||
monitorStep.setKubernetesMonitor(value);
|
||||
props.onChange?.(MonitorStep.clone(monitorStep));
|
||||
}}
|
||||
onMonitorCriteriaChange={(criteria: MonitorCriteria) => {
|
||||
monitorStep.setMonitorCriteria(criteria);
|
||||
props.onChange?.(MonitorStep.clone(monitorStep));
|
||||
}}
|
||||
onlineMonitorStatusId={props.onlineMonitorStatusId}
|
||||
offlineMonitorStatusId={props.offlineMonitorStatusId}
|
||||
defaultIncidentSeverityId={props.defaultIncidentSeverityId}
|
||||
defaultAlertSeverityId={props.defaultAlertSeverityId}
|
||||
monitorName={props.monitorName}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{props.monitorType === MonitorType.Traces && (
|
||||
<Card
|
||||
title="Trace Monitor Configuration"
|
||||
|
||||
@@ -74,6 +74,19 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const [probes, setProbes] = React.useState<Array<Probe>>([]);
|
||||
|
||||
// IDs needed for Kubernetes template criteria
|
||||
const [onlineMonitorStatusId, setOnlineMonitorStatusId] = React.useState<
|
||||
ObjectID | undefined
|
||||
>(undefined);
|
||||
const [offlineMonitorStatusId, setOfflineMonitorStatusId] = React.useState<
|
||||
ObjectID | undefined
|
||||
>(undefined);
|
||||
const [defaultIncidentSeverityId, setDefaultIncidentSeverityId] =
|
||||
React.useState<ObjectID | undefined>(undefined);
|
||||
const [defaultAlertSeverityId, setDefaultAlertSeverityId] = React.useState<
|
||||
ObjectID | undefined
|
||||
>(undefined);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
|
||||
@@ -109,6 +122,23 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Extract online (operational) and offline status IDs for template criteria
|
||||
const onlineStatus: MonitorStatus | undefined =
|
||||
monitorStatusList.data.find((i: MonitorStatus) => {
|
||||
return i.isOperationalState;
|
||||
});
|
||||
const offlineStatus: MonitorStatus | undefined =
|
||||
monitorStatusList.data.find((i: MonitorStatus) => {
|
||||
return i.isOfflineState;
|
||||
});
|
||||
|
||||
if (onlineStatus?._id) {
|
||||
setOnlineMonitorStatusId(new ObjectID(onlineStatus._id));
|
||||
}
|
||||
if (offlineStatus?._id) {
|
||||
setOfflineMonitorStatusId(new ObjectID(offlineStatus._id));
|
||||
}
|
||||
}
|
||||
|
||||
const incidentSeverityList: ListResult<IncidentSeverity> =
|
||||
@@ -162,6 +192,16 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Use the first (highest priority) severity as default for templates
|
||||
if (
|
||||
incidentSeverityList.data.length > 0 &&
|
||||
incidentSeverityList.data[0]?._id
|
||||
) {
|
||||
setDefaultIncidentSeverityId(
|
||||
new ObjectID(incidentSeverityList.data[0]._id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (alertSeverityList.data) {
|
||||
@@ -173,6 +213,16 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Use the first (highest priority) severity as default for templates
|
||||
if (
|
||||
alertSeverityList.data.length > 0 &&
|
||||
alertSeverityList.data[0]?._id
|
||||
) {
|
||||
setDefaultAlertSeverityId(
|
||||
new ObjectID(alertSeverityList.data[0]._id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (onCallPolicyList.data) {
|
||||
@@ -356,6 +406,11 @@ const MonitorStepsElement: FunctionComponent<ComponentProps> = (
|
||||
value={i}
|
||||
probes={probes}
|
||||
monitorId={props.monitorId}
|
||||
onlineMonitorStatusId={onlineMonitorStatusId}
|
||||
offlineMonitorStatusId={offlineMonitorStatusId}
|
||||
defaultIncidentSeverityId={defaultIncidentSeverityId}
|
||||
defaultAlertSeverityId={defaultAlertSeverityId}
|
||||
monitorName={props.monitorName}
|
||||
/*
|
||||
* onDelete={() => {
|
||||
* // remove the criteria filter
|
||||
|
||||
@@ -284,9 +284,6 @@ const DashboardProjectPicker: FunctionComponent<ComponentProps> = (
|
||||
if (project && props.onProjectSelected) {
|
||||
props.onProjectSelected(project);
|
||||
}
|
||||
if (project && props.onProjectSelected) {
|
||||
props.onProjectSelected(project);
|
||||
}
|
||||
setShowModal(false);
|
||||
props.onProjectModalClose();
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import TelemetryIngestionKey from "Common/Models/DatabaseModels/TelemetryIngestionKey";
|
||||
import ModelAPI, { ListResult } from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import { HOST, HTTP_PROTOCOL } from "Common/UI/Config";
|
||||
import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import { FormType } from "Common/UI/Components/Forms/ModelForm";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import Dropdown, {
|
||||
DropdownOption,
|
||||
DropdownValue,
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import Protocol from "Common/Types/API/Protocol";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer";
|
||||
import { getKubernetesInstallationMarkdown } from "../../Pages/Kubernetes/Utils/DocumentationMarkdown";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
|
||||
export interface ComponentProps {
|
||||
clusterName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const KubernetesDocumentationCard: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
// Ingestion key state
|
||||
const [ingestionKeys, setIngestionKeys] = useState<
|
||||
Array<TelemetryIngestionKey>
|
||||
>([]);
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
|
||||
const [isLoadingKeys, setIsLoadingKeys] = useState<boolean>(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState<boolean>(false);
|
||||
const [keyError, setKeyError] = useState<string>("");
|
||||
|
||||
// Compute OneUptime URL
|
||||
const httpProtocol: string =
|
||||
HTTP_PROTOCOL === Protocol.HTTPS ? "https" : "http";
|
||||
const oneuptimeUrl: string = HOST
|
||||
? `${httpProtocol}://${HOST}`
|
||||
: "<YOUR_ONEUPTIME_URL>";
|
||||
|
||||
// Fetch ingestion keys on mount
|
||||
useEffect(() => {
|
||||
loadIngestionKeys().catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadIngestionKeys: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoadingKeys(true);
|
||||
setKeyError("");
|
||||
const result: ListResult<TelemetryIngestionKey> =
|
||||
await ModelAPI.getList<TelemetryIngestionKey>({
|
||||
modelType: TelemetryIngestionKey,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
limit: 50,
|
||||
skip: 0,
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
secretKey: true,
|
||||
description: true,
|
||||
},
|
||||
sort: {},
|
||||
});
|
||||
|
||||
setIngestionKeys(result.data);
|
||||
|
||||
// Auto-select the first key if available and none selected
|
||||
if (result.data.length > 0 && !selectedKeyId) {
|
||||
setSelectedKeyId(result.data[0]!.id?.toString() || "");
|
||||
}
|
||||
} catch (err) {
|
||||
setKeyError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoadingKeys(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get the selected key object
|
||||
const selectedKey: TelemetryIngestionKey | undefined = useMemo(() => {
|
||||
return ingestionKeys.find((k: TelemetryIngestionKey) => {
|
||||
return k.id?.toString() === selectedKeyId;
|
||||
});
|
||||
}, [ingestionKeys, selectedKeyId]);
|
||||
|
||||
// Get API key for code snippets
|
||||
const apiKeyValue: string =
|
||||
selectedKey?.secretKey?.toString() || "<YOUR_API_KEY>";
|
||||
|
||||
const renderKeySelector: () => ReactElement = (): ReactElement => {
|
||||
if (isLoadingKeys) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (keyError) {
|
||||
return <ErrorMessage message={keyError} />;
|
||||
}
|
||||
|
||||
if (ingestionKeys.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm font-medium text-gray-900 mb-1">
|
||||
No ingestion keys yet
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Create an ingestion key to authenticate your Kubernetes agent.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors shadow-sm"
|
||||
>
|
||||
<Icon icon={IconProp.Add} className="w-4 h-4" />
|
||||
Create Ingestion Key
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Key selector row */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<Dropdown
|
||||
options={ingestionKeys.map(
|
||||
(key: TelemetryIngestionKey): DropdownOption => {
|
||||
return {
|
||||
value: key.id?.toString() || "",
|
||||
label: key.name || "Unnamed Key",
|
||||
};
|
||||
},
|
||||
)}
|
||||
value={
|
||||
ingestionKeys
|
||||
.filter((key: TelemetryIngestionKey) => {
|
||||
return key.id?.toString() === selectedKeyId;
|
||||
})
|
||||
.map((key: TelemetryIngestionKey): DropdownOption => {
|
||||
return {
|
||||
value: key.id?.toString() || "",
|
||||
label: key.name || "Unnamed Key",
|
||||
};
|
||||
})[0]
|
||||
}
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
if (value) {
|
||||
setSelectedKeyId(value.toString());
|
||||
}
|
||||
}}
|
||||
placeholder="Select an ingestion key"
|
||||
ariaLabel="Select ingestion key"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition-colors flex-shrink-0"
|
||||
>
|
||||
<Icon icon={IconProp.Add} className="w-4 h-4" />
|
||||
New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Credentials display */}
|
||||
{selectedKey && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="grid grid-cols-1 divide-y divide-gray-100">
|
||||
<div className="px-4 py-3 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-md bg-blue-50 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Icon
|
||||
icon={IconProp.Globe}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
OneUptime URL
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 font-mono mt-0.5 break-all select-all">
|
||||
{oneuptimeUrl}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-md bg-amber-50 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Icon
|
||||
icon={IconProp.Key}
|
||||
className="w-4 h-4 text-amber-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
API Key
|
||||
</div>
|
||||
<div className="text-sm text-gray-900 font-mono mt-0.5 break-all select-all">
|
||||
{selectedKey.secretKey?.toString() || "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const installationMarkdown: string = getKubernetesInstallationMarkdown({
|
||||
clusterName: props.clusterName,
|
||||
oneuptimeUrl: oneuptimeUrl,
|
||||
apiKey: apiKeyValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title={props.title} description={props.description}>
|
||||
<div className="px-4 pb-6">
|
||||
{/* Ingestion Key Section */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Select Ingestion Key
|
||||
</label>
|
||||
{renderKeySelector()}
|
||||
</div>
|
||||
|
||||
{/* Documentation */}
|
||||
<MarkdownViewer text={installationMarkdown} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Create Ingestion Key Modal */}
|
||||
{showCreateModal && (
|
||||
<ModelFormModal<TelemetryIngestionKey>
|
||||
modelType={TelemetryIngestionKey}
|
||||
name="Create Ingestion Key"
|
||||
title="Create Ingestion Key"
|
||||
description="Create a new telemetry ingestion key for authenticating your Kubernetes agent."
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
}}
|
||||
submitButtonText="Create Key"
|
||||
onSuccess={(item: TelemetryIngestionKey) => {
|
||||
setShowCreateModal(false);
|
||||
loadIngestionKeys()
|
||||
.then(() => {
|
||||
if (item.id) {
|
||||
setSelectedKeyId(item.id.toString());
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
formProps={{
|
||||
name: "Create Ingestion Key",
|
||||
modelType: TelemetryIngestionKey,
|
||||
id: "create-ingestion-key",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
placeholder: "e.g. Kubernetes Agent Key",
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder: "Optional description for this key",
|
||||
},
|
||||
],
|
||||
formType: FormType.Create,
|
||||
}}
|
||||
onBeforeCreate={(
|
||||
item: TelemetryIngestionKey,
|
||||
): Promise<TelemetryIngestionKey> => {
|
||||
item.projectId = ProjectUtil.getCurrentProjectId()!;
|
||||
return Promise.resolve(item);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesDocumentationCard;
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
|
||||
import {
|
||||
KubernetesContainerPort,
|
||||
KubernetesContainerSpec,
|
||||
KubernetesContainerStatus,
|
||||
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
|
||||
import StatusBadge, {
|
||||
StatusBadgeType,
|
||||
} from "Common/UI/Components/StatusBadge/StatusBadge";
|
||||
import LocalTable from "Common/UI/Components/Table/LocalTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import type Columns from "Common/UI/Components/Table/Types/Columns";
|
||||
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
|
||||
|
||||
function formatK8sResourceValue(key: string, value: string): string {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// CPU values: millicores (e.g. "250m" = 0.25 cores)
|
||||
const cpuMilliMatch: RegExpMatchArray | null = value.match(/^(\d+)m$/);
|
||||
if (cpuMilliMatch && key.toLowerCase() === "cpu") {
|
||||
const millis: number = parseInt(cpuMilliMatch[1] || "0");
|
||||
if (millis >= 1000) {
|
||||
return `${value} (${millis / 1000} CPU cores)`;
|
||||
}
|
||||
return `${value} (${(millis / 1000).toFixed(2)} CPU cores)`;
|
||||
}
|
||||
|
||||
// CPU whole cores (e.g. "2" = 2 cores)
|
||||
const wholeNumberRegex: RegExp = /^\d+$/;
|
||||
if (key.toLowerCase() === "cpu" && wholeNumberRegex.test(value)) {
|
||||
const cores: number = parseInt(value);
|
||||
return `${value} (${cores} CPU core${cores !== 1 ? "s" : ""})`;
|
||||
}
|
||||
|
||||
// Memory values: Ki, Mi, Gi, Ti
|
||||
const memMatch: RegExpMatchArray | null = value.match(/^(\d+)(Ki|Mi|Gi|Ti)$/);
|
||||
if (memMatch) {
|
||||
const num: number = parseInt(memMatch[1] || "0");
|
||||
const unit: string = memMatch[2] || "";
|
||||
const explanations: Record<string, string> = {
|
||||
Ki: `${(num / 1024).toFixed(num >= 1024 ? 1 : 2)} MB`,
|
||||
Mi: num >= 1024 ? `${(num / 1024).toFixed(1)} GB` : `${num} MB`,
|
||||
Gi: `${num} GB`,
|
||||
Ti: `${num} TB`,
|
||||
};
|
||||
const readable: string | undefined = explanations[unit];
|
||||
if (readable) {
|
||||
return `${value} (${readable})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Ephemeral storage: same units
|
||||
const storageMatch: RegExpMatchArray | null = value.match(/^(\d+)(K|M|G|T)$/);
|
||||
if (storageMatch) {
|
||||
const num: number = parseInt(storageMatch[1] || "0");
|
||||
const unit: string = storageMatch[2] || "";
|
||||
const explanations: Record<string, string> = {
|
||||
K: `${(num / 1000).toFixed(num >= 1000 ? 1 : 2)} MB`,
|
||||
M: num >= 1000 ? `${(num / 1000).toFixed(1)} GB` : `${num} MB`,
|
||||
G: `${num} GB`,
|
||||
T: `${num} TB`,
|
||||
};
|
||||
const readable: string | undefined = explanations[unit];
|
||||
if (readable) {
|
||||
return `${value} (${readable})`;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function annotateResourceValues(
|
||||
resources: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of Object.keys(resources)) {
|
||||
result[key] = formatK8sResourceValue(key, resources[key] || "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
containers: Array<KubernetesContainerSpec>;
|
||||
initContainers: Array<KubernetesContainerSpec>;
|
||||
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
|
||||
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
|
||||
}
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: KubernetesContainerSpec;
|
||||
status?: KubernetesContainerStatus | undefined;
|
||||
isInit: boolean;
|
||||
}
|
||||
|
||||
interface VolumeMountRow {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
readOnly: string;
|
||||
}
|
||||
|
||||
const volumeMountColumns: Columns<VolumeMountRow> = [
|
||||
{
|
||||
title: "Volume Name",
|
||||
type: FieldType.Text,
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "Mount Path",
|
||||
type: FieldType.Element,
|
||||
key: "mountPath",
|
||||
getElement: (item: VolumeMountRow): ReactElement => {
|
||||
return (
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded font-mono">
|
||||
{item.mountPath}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Access",
|
||||
type: FieldType.Element,
|
||||
key: "readOnly",
|
||||
getElement: (item: VolumeMountRow): ReactElement => {
|
||||
return (
|
||||
<StatusBadge
|
||||
text={item.readOnly === "true" ? "Read-Only" : "Read-Write"}
|
||||
type={
|
||||
item.readOnly === "true"
|
||||
? StatusBadgeType.Warning
|
||||
: StatusBadgeType.Neutral
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ContainerCard: FunctionComponent<ContainerCardProps> = (
|
||||
props: ContainerCardProps,
|
||||
): ReactElement => {
|
||||
const [showEnv, setShowEnv] = useState<boolean>(false);
|
||||
const [showMounts, setShowMounts] = useState<boolean>(false);
|
||||
|
||||
const envRecord: Record<string, string> = {};
|
||||
for (const env of props.container.env) {
|
||||
envRecord[env.name] = env.value;
|
||||
}
|
||||
|
||||
const hasResources: boolean =
|
||||
Object.keys(props.container.resources.requests).length > 0 ||
|
||||
Object.keys(props.container.resources.limits).length > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={`${props.isInit ? "Init Container: " : "Container: "}${props.container.name}`}
|
||||
description={props.container.image}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Status Info Cards */}
|
||||
{props.status && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<InfoCard
|
||||
title="State"
|
||||
value={
|
||||
<StatusBadge
|
||||
text={props.status.state}
|
||||
type={
|
||||
props.status.state === "running"
|
||||
? StatusBadgeType.Success
|
||||
: props.status.state === "waiting"
|
||||
? StatusBadgeType.Warning
|
||||
: StatusBadgeType.Danger
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
title="Ready"
|
||||
value={
|
||||
<StatusBadge
|
||||
text={props.status.ready ? "Yes" : "No"}
|
||||
type={
|
||||
props.status.ready
|
||||
? StatusBadgeType.Success
|
||||
: StatusBadgeType.Danger
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
title="Restarts"
|
||||
value={
|
||||
<StatusBadge
|
||||
text={String(props.status.restartCount)}
|
||||
type={
|
||||
props.status.restartCount > 0
|
||||
? StatusBadgeType.Warning
|
||||
: StatusBadgeType.Neutral
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Command & Args */}
|
||||
{props.container.command.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
|
||||
Command
|
||||
</div>
|
||||
<code className="text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded-lg block font-mono text-gray-800">
|
||||
{props.container.command.join(" ")}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{props.container.args.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
|
||||
Args
|
||||
</div>
|
||||
<code className="text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded-lg block font-mono text-gray-800">
|
||||
{props.container.args.join(" ")}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ports */}
|
||||
{props.container.ports.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Ports
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{props.container.ports.map(
|
||||
(port: KubernetesContainerPort, idx: number) => {
|
||||
return (
|
||||
<StatusBadge
|
||||
key={idx}
|
||||
text={`${port.name ? `${port.name}: ` : ""}${port.containerPort}/${port.protocol}`}
|
||||
type={StatusBadgeType.Info}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resources */}
|
||||
{hasResources && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{Object.keys(props.container.resources.requests).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Requests
|
||||
</div>
|
||||
<DictionaryOfStringsViewer
|
||||
value={annotateResourceValues(
|
||||
props.container.resources.requests,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(props.container.resources.limits).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Limits
|
||||
</div>
|
||||
<DictionaryOfStringsViewer
|
||||
value={annotateResourceValues(
|
||||
props.container.resources.limits,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables (expandable) */}
|
||||
{props.container.env.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEnv(!showEnv);
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
|
||||
>
|
||||
<span className="text-xs">{showEnv ? "▼" : "▶"}</span>
|
||||
Environment Variables ({props.container.env.length})
|
||||
</button>
|
||||
{showEnv && (
|
||||
<div className="mt-3">
|
||||
<DictionaryOfStringsViewer value={envRecord} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Volume Mounts (expandable with table) */}
|
||||
{props.container.volumeMounts.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMounts(!showMounts);
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
|
||||
>
|
||||
<span className="text-xs">{showMounts ? "▼" : "▶"}</span>
|
||||
Volume Mounts ({props.container.volumeMounts.length})
|
||||
</button>
|
||||
{showMounts && (
|
||||
<div className="mt-3">
|
||||
<LocalTable
|
||||
id={`volume-mounts-${props.container.name}`}
|
||||
data={props.container.volumeMounts.map(
|
||||
(mount: {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
readOnly: boolean;
|
||||
}): VolumeMountRow => {
|
||||
return {
|
||||
name: mount.name,
|
||||
mountPath: mount.mountPath,
|
||||
readOnly: String(mount.readOnly),
|
||||
};
|
||||
},
|
||||
)}
|
||||
columns={volumeMountColumns}
|
||||
singularLabel="Mount"
|
||||
pluralLabel="Mounts"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const KubernetesContainersTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
if (props.containers.length === 0 && props.initContainers.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No container information available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatus: (
|
||||
name: string,
|
||||
isInit: boolean,
|
||||
) => KubernetesContainerStatus | undefined = (
|
||||
name: string,
|
||||
isInit: boolean,
|
||||
): KubernetesContainerStatus | undefined => {
|
||||
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
|
||||
? props.initContainerStatuses
|
||||
: props.containerStatuses;
|
||||
return statuses?.find((s: KubernetesContainerStatus) => {
|
||||
return s.name === name;
|
||||
});
|
||||
};
|
||||
|
||||
// Sort containers: running first, then waiting, then terminated
|
||||
function getStatePriority(state: string): number {
|
||||
const s: string = state.toLowerCase();
|
||||
if (s === "running") {
|
||||
return 0;
|
||||
}
|
||||
if (s === "waiting") {
|
||||
return 1;
|
||||
}
|
||||
if (s === "terminated") {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
const sortedContainers: Array<{
|
||||
container: KubernetesContainerSpec;
|
||||
isInit: boolean;
|
||||
}> = [
|
||||
...props.initContainers.map((container: KubernetesContainerSpec) => {
|
||||
return { container, isInit: true };
|
||||
}),
|
||||
...props.containers.map((container: KubernetesContainerSpec) => {
|
||||
return { container, isInit: false };
|
||||
}),
|
||||
].sort(
|
||||
(
|
||||
a: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
b: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
) => {
|
||||
const aStatus: KubernetesContainerStatus | undefined = getStatus(
|
||||
a.container.name,
|
||||
a.isInit,
|
||||
);
|
||||
const bStatus: KubernetesContainerStatus | undefined = getStatus(
|
||||
b.container.name,
|
||||
b.isInit,
|
||||
);
|
||||
const aPriority: number = getStatePriority(aStatus?.state || "unknown");
|
||||
const bPriority: number = getStatePriority(bStatus?.state || "unknown");
|
||||
return aPriority - bPriority;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedContainers.map(
|
||||
(
|
||||
item: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
index: number,
|
||||
) => {
|
||||
return (
|
||||
<ContainerCard
|
||||
key={`${item.isInit ? "init" : "container"}-${index}`}
|
||||
container={item.container}
|
||||
status={getStatus(item.container.name, item.isInit)}
|
||||
isInit={item.isInit}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesContainersTab;
|
||||
@@ -0,0 +1,285 @@
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import {
|
||||
KubernetesContainerEnvVar,
|
||||
KubernetesContainerSpec,
|
||||
KubernetesContainerStatus,
|
||||
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
|
||||
import StatusBadge, {
|
||||
StatusBadgeType,
|
||||
} from "Common/UI/Components/StatusBadge/StatusBadge";
|
||||
import LocalTable from "Common/UI/Components/Table/LocalTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import type Columns from "Common/UI/Components/Table/Types/Columns";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Input from "Common/UI/Components/Input/Input";
|
||||
|
||||
export interface ComponentProps {
|
||||
containers: Array<KubernetesContainerSpec>;
|
||||
initContainers: Array<KubernetesContainerSpec>;
|
||||
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
|
||||
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
|
||||
}
|
||||
|
||||
interface EnvVarRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const KubernetesEnvVarsTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const getStatus: (
|
||||
name: string,
|
||||
isInit: boolean,
|
||||
) => KubernetesContainerStatus | undefined = (
|
||||
name: string,
|
||||
isInit: boolean,
|
||||
): KubernetesContainerStatus | undefined => {
|
||||
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
|
||||
? props.initContainerStatuses
|
||||
: props.containerStatuses;
|
||||
return statuses?.find((s: KubernetesContainerStatus) => {
|
||||
return s.name === name;
|
||||
});
|
||||
};
|
||||
|
||||
function getStatePriority(state: string): number {
|
||||
const s: string = state.toLowerCase();
|
||||
if (s === "running") {
|
||||
return 0;
|
||||
}
|
||||
if (s === "waiting") {
|
||||
return 1;
|
||||
}
|
||||
if (s === "terminated") {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
const sortedContainers: Array<{
|
||||
container: KubernetesContainerSpec;
|
||||
isInit: boolean;
|
||||
}> = [
|
||||
...props.initContainers.map((container: KubernetesContainerSpec) => {
|
||||
return { container, isInit: true };
|
||||
}),
|
||||
...props.containers.map((container: KubernetesContainerSpec) => {
|
||||
return { container, isInit: false };
|
||||
}),
|
||||
].sort(
|
||||
(
|
||||
a: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
b: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
) => {
|
||||
const aStatus: KubernetesContainerStatus | undefined = getStatus(
|
||||
a.container.name,
|
||||
a.isInit,
|
||||
);
|
||||
const bStatus: KubernetesContainerStatus | undefined = getStatus(
|
||||
b.container.name,
|
||||
b.isInit,
|
||||
);
|
||||
const aPriority: number = getStatePriority(aStatus?.state || "unknown");
|
||||
const bPriority: number = getStatePriority(bStatus?.state || "unknown");
|
||||
return aPriority - bPriority;
|
||||
},
|
||||
);
|
||||
|
||||
const allContainers: Array<KubernetesContainerSpec> = sortedContainers.map(
|
||||
(item: { container: KubernetesContainerSpec; isInit: boolean }) => {
|
||||
return item.container;
|
||||
},
|
||||
);
|
||||
|
||||
if (allContainers.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No container information available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalEnvCount: number = allContainers.reduce(
|
||||
(sum: number, c: KubernetesContainerSpec) => {
|
||||
return sum + c.env.length;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalEnvCount === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No environment variables defined for any container.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const searchLower: string = search.toLowerCase();
|
||||
|
||||
const totalMatchCount: number = search
|
||||
? allContainers.reduce((sum: number, c: KubernetesContainerSpec) => {
|
||||
return (
|
||||
sum +
|
||||
c.env.filter((env: KubernetesContainerEnvVar) => {
|
||||
return (
|
||||
env.name.toLowerCase().includes(searchLower) ||
|
||||
env.value.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}).length
|
||||
);
|
||||
}, 0)
|
||||
: totalEnvCount;
|
||||
|
||||
const columns: Columns<EnvVarRow> = [
|
||||
{
|
||||
title: "Name",
|
||||
type: FieldType.Element,
|
||||
key: "name",
|
||||
getElement: (item: EnvVarRow): ReactElement => {
|
||||
return (
|
||||
<span className="font-mono font-medium text-gray-900">
|
||||
{item.name}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Value",
|
||||
type: FieldType.Element,
|
||||
key: "value",
|
||||
getElement: (item: EnvVarRow): ReactElement => {
|
||||
const isSecret: boolean =
|
||||
item.value.startsWith("<Secret:") ||
|
||||
item.value.startsWith("<ConfigMap:") ||
|
||||
item.value.startsWith("<FieldRef:") ||
|
||||
item.value.startsWith("<ResourceFieldRef:");
|
||||
|
||||
if (isSecret) {
|
||||
return (
|
||||
<StatusBadge
|
||||
text={item.value}
|
||||
type={
|
||||
item.value.startsWith("<Secret:")
|
||||
? StatusBadgeType.Warning
|
||||
: StatusBadgeType.Info
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="font-mono text-gray-600">
|
||||
{item.value || <span className="text-gray-400 italic">empty</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search bar */}
|
||||
<Card
|
||||
title="Environment Variables"
|
||||
description={`${totalEnvCount} variable${totalEnvCount !== 1 ? "s" : ""} across ${
|
||||
allContainers.filter((c: KubernetesContainerSpec) => {
|
||||
return c.env.length > 0;
|
||||
}).length
|
||||
} container${
|
||||
allContainers.filter((c: KubernetesContainerSpec) => {
|
||||
return c.env.length > 0;
|
||||
}).length !== 1
|
||||
? "s"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Icon icon={IconProp.Search} className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search environment variables..."
|
||||
value={search}
|
||||
onChange={(value: string) => {
|
||||
setSearch(value);
|
||||
}}
|
||||
className="block w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
{search && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-sm text-gray-500 tabular-nums">
|
||||
{totalMatchCount} of {totalEnvCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-md p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Icon icon={IconProp.Close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{sortedContainers.map(
|
||||
(
|
||||
item: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
containerIdx: number,
|
||||
) => {
|
||||
if (item.container.env.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredEnv: Array<KubernetesContainerEnvVar> = search
|
||||
? item.container.env.filter((env: KubernetesContainerEnvVar) => {
|
||||
return (
|
||||
env.name.toLowerCase().includes(searchLower) ||
|
||||
env.value.toLowerCase().includes(searchLower)
|
||||
);
|
||||
})
|
||||
: item.container.env;
|
||||
|
||||
if (filteredEnv.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tableData: Array<EnvVarRow> = filteredEnv.map(
|
||||
(env: KubernetesContainerEnvVar): EnvVarRow => {
|
||||
return {
|
||||
name: env.name,
|
||||
value: env.value,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={containerIdx}
|
||||
title={`${item.isInit ? "Init Container: " : ""}${item.container.name}`}
|
||||
description={`${filteredEnv.length} environment variable${filteredEnv.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
<LocalTable
|
||||
id={`env-vars-${containerIdx}`}
|
||||
data={tableData}
|
||||
columns={columns}
|
||||
singularLabel="Variable"
|
||||
pluralLabel="Variables"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesEnvVarsTab;
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
fetchK8sEventsForResource,
|
||||
KubernetesEvent,
|
||||
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import FilterButtons, {
|
||||
type FilterButtonOption,
|
||||
} from "Common/UI/Components/FilterButtons/FilterButtons";
|
||||
import StatusBadge, {
|
||||
StatusBadgeType,
|
||||
} from "Common/UI/Components/StatusBadge/StatusBadge";
|
||||
import ExpandableText from "Common/UI/Components/ExpandableText/ExpandableText";
|
||||
import LocalTable from "Common/UI/Components/Table/LocalTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import type Columns from "Common/UI/Components/Table/Types/Columns";
|
||||
|
||||
export interface ComponentProps {
|
||||
clusterIdentifier: string;
|
||||
resourceKind: string; // "Pod", "Node", "Deployment", etc.
|
||||
resourceName: string;
|
||||
namespace?: string | undefined;
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
timestamp: string;
|
||||
relativeTime: string;
|
||||
type: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
if (!timestamp) {
|
||||
return "-";
|
||||
}
|
||||
const date: Date = new Date(timestamp);
|
||||
const now: Date = new Date();
|
||||
const diffMs: number = now.getTime() - date.getTime();
|
||||
if (diffMs < 0) {
|
||||
return timestamp;
|
||||
}
|
||||
const diffSec: number = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}s ago`;
|
||||
}
|
||||
const diffMin: number = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) {
|
||||
return `${diffMin}m ago`;
|
||||
}
|
||||
const diffHrs: number = Math.floor(diffMin / 60);
|
||||
if (diffHrs < 24) {
|
||||
return `${diffHrs}h ago`;
|
||||
}
|
||||
const diffDays: number = Math.floor(diffHrs / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [events, setEvents] = useState<Array<KubernetesEvent>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result: Array<KubernetesEvent> = await fetchK8sEventsForResource({
|
||||
clusterIdentifier: props.clusterIdentifier,
|
||||
resourceKind: props.resourceKind,
|
||||
resourceName: props.resourceName,
|
||||
namespace: props.namespace,
|
||||
});
|
||||
setEvents(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch events");
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchEvents().catch(() => {});
|
||||
}, [
|
||||
props.clusterIdentifier,
|
||||
props.resourceKind,
|
||||
props.resourceName,
|
||||
props.namespace,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No events found for this {props.resourceKind.toLowerCase()} in the last
|
||||
24 hours.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const warningCount: number = events.filter((e: KubernetesEvent) => {
|
||||
return e.type.toLowerCase() === "warning";
|
||||
}).length;
|
||||
const normalCount: number = events.length - warningCount;
|
||||
|
||||
const filteredEvents: Array<KubernetesEvent> = events.filter(
|
||||
(e: KubernetesEvent) => {
|
||||
if (typeFilter === "warning") {
|
||||
return e.type.toLowerCase() === "warning";
|
||||
}
|
||||
if (typeFilter === "normal") {
|
||||
return e.type.toLowerCase() !== "warning";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const filterOptions: Array<FilterButtonOption> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Warnings", value: "warning", badge: warningCount },
|
||||
{ label: "Normal", value: "normal", badge: normalCount },
|
||||
];
|
||||
|
||||
const tableData: Array<EventRow> = filteredEvents.map(
|
||||
(event: KubernetesEvent): EventRow => {
|
||||
return {
|
||||
timestamp: event.timestamp,
|
||||
relativeTime: formatRelativeTime(event.timestamp),
|
||||
type: event.type,
|
||||
reason: event.reason,
|
||||
message: event.message,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const columns: Columns<EventRow> = [
|
||||
{
|
||||
title: "Time",
|
||||
type: FieldType.Text,
|
||||
key: "relativeTime",
|
||||
tooltipText: (item: EventRow): string => {
|
||||
return item.timestamp;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
type: FieldType.Element,
|
||||
key: "type",
|
||||
getElement: (item: EventRow): ReactElement => {
|
||||
const isWarning: boolean = item.type.toLowerCase() === "warning";
|
||||
return (
|
||||
<StatusBadge
|
||||
text={item.type}
|
||||
type={isWarning ? StatusBadgeType.Warning : StatusBadgeType.Success}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reason",
|
||||
type: FieldType.Text,
|
||||
key: "reason",
|
||||
},
|
||||
{
|
||||
title: "Message",
|
||||
type: FieldType.Element,
|
||||
key: "message",
|
||||
getElement: (item: EventRow): ReactElement => {
|
||||
return <ExpandableText text={item.message} maxLength={120} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary and Filters */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">{events.length}</span> events
|
||||
{warningCount > 0 && (
|
||||
<span>
|
||||
{" "}
|
||||
(
|
||||
<span className="text-amber-700 font-medium">
|
||||
{warningCount}
|
||||
</span>{" "}
|
||||
warning{warningCount !== 1 ? "s" : ""},{" "}
|
||||
<span className="text-emerald-700 font-medium">
|
||||
{normalCount}
|
||||
</span>{" "}
|
||||
normal)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<FilterButtons
|
||||
options={filterOptions}
|
||||
selectedValue={typeFilter}
|
||||
onSelect={setTypeFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LocalTable
|
||||
id="kubernetes-events-table"
|
||||
data={tableData}
|
||||
columns={columns}
|
||||
singularLabel="Event"
|
||||
pluralLabel="Events"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesEventsTab;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { FunctionComponent, ReactElement, useMemo } from "react";
|
||||
import DashboardLogsViewer from "../Logs/LogsViewer";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import Log from "Common/Models/AnalyticsModels/Log";
|
||||
|
||||
export interface ComponentProps {
|
||||
clusterIdentifier: string;
|
||||
podName: string;
|
||||
containerName?: string | undefined;
|
||||
namespace?: string | undefined;
|
||||
}
|
||||
|
||||
const KubernetesLogsTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const logQuery: Query<Log> = useMemo(() => {
|
||||
const attributeFilters: Record<string, string> = {
|
||||
"resource.k8s.cluster.name": props.clusterIdentifier,
|
||||
"resource.k8s.pod.name": props.podName,
|
||||
};
|
||||
|
||||
if (props.containerName) {
|
||||
attributeFilters["resource.k8s.container.name"] = props.containerName;
|
||||
}
|
||||
|
||||
if (props.namespace) {
|
||||
attributeFilters["resource.k8s.namespace.name"] = props.namespace;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: attributeFilters,
|
||||
} as Query<Log>;
|
||||
}, [
|
||||
props.clusterIdentifier,
|
||||
props.podName,
|
||||
props.containerName,
|
||||
props.namespace,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardLogsViewer
|
||||
id={`k8s-logs-${props.podName}`}
|
||||
logQuery={logQuery}
|
||||
showFilters={true}
|
||||
noLogsMessage="No application logs found for this pod. Logs will appear here once the kubernetes-agent's filelog receiver is collecting data."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesLogsTab;
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import MetricView from "../../Components/Metrics/MetricView";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
|
||||
export interface ComponentProps {
|
||||
queryConfigs: Array<MetricQueryConfigData>;
|
||||
}
|
||||
|
||||
const KubernetesMetricsTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
});
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
}),
|
||||
queryConfigs: [],
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
</div>
|
||||
<MetricView
|
||||
data={{
|
||||
...metricViewData,
|
||||
queryConfigs: props.queryConfigs,
|
||||
}}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: props.queryConfigs,
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesMetricsTab;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
|
||||
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
|
||||
import { KubernetesCondition } from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ConditionsTable, {
|
||||
type Condition,
|
||||
} from "Common/UI/Components/ConditionsTable/ConditionsTable";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import KubernetesResourceLink from "./KubernetesResourceLink";
|
||||
|
||||
export interface SummaryField {
|
||||
title: string;
|
||||
value: string | ReactElement;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
summaryFields: Array<SummaryField>;
|
||||
labels: Record<string, string>;
|
||||
annotations: Record<string, string>;
|
||||
conditions?: Array<KubernetesCondition> | undefined;
|
||||
ownerReferences?: Array<{ kind: string; name: string }> | undefined;
|
||||
modelId?: ObjectID | undefined;
|
||||
isLoading: boolean;
|
||||
emptyMessage?: string | undefined;
|
||||
}
|
||||
|
||||
const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
if (props.isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (
|
||||
props.summaryFields.length === 0 &&
|
||||
Object.keys(props.labels).length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
{props.emptyMessage ||
|
||||
"Resource details not yet available. Ensure the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and wait for the next data pull (up to 5 minutes)."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Convert KubernetesCondition[] to generic Condition[] for ConditionsTable
|
||||
const conditions: Array<Condition> | undefined = props.conditions?.map(
|
||||
(c: KubernetesCondition): Condition => {
|
||||
return {
|
||||
type: c.type,
|
||||
status: c.status,
|
||||
reason: c.reason,
|
||||
message: c.message,
|
||||
lastTransitionTime: c.lastTransitionTime,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Info Cards */}
|
||||
{props.summaryFields.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{props.summaryFields.map((field: SummaryField, index: number) => {
|
||||
return (
|
||||
<InfoCard key={index} title={field.title} value={field.value} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner References */}
|
||||
{props.ownerReferences && props.ownerReferences.length > 0 && (
|
||||
<Card
|
||||
title="Owner References"
|
||||
description="Resources that own this object."
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{props.ownerReferences.map(
|
||||
(ref: { kind: string; name: string }, index: number) => {
|
||||
return (
|
||||
<div key={index} className="text-sm">
|
||||
<span className="font-medium text-gray-700">
|
||||
{ref.kind}:
|
||||
</span>{" "}
|
||||
{props.modelId ? (
|
||||
<KubernetesResourceLink
|
||||
modelId={props.modelId}
|
||||
resourceKind={ref.kind}
|
||||
resourceName={ref.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-600">{ref.name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Conditions */}
|
||||
{conditions && conditions.length > 0 && (
|
||||
<Card
|
||||
title="Conditions"
|
||||
description="Current status conditions of this resource."
|
||||
>
|
||||
<ConditionsTable conditions={conditions} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{Object.keys(props.labels).length > 0 && (
|
||||
<Card
|
||||
title="Labels"
|
||||
description="Key-value labels attached to this resource."
|
||||
>
|
||||
<DictionaryOfStringsViewer value={props.labels} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Annotations */}
|
||||
{Object.keys(props.annotations).length > 0 && (
|
||||
<Card
|
||||
title="Annotations"
|
||||
description="Metadata annotations on this resource."
|
||||
>
|
||||
<DictionaryOfStringsViewer value={props.annotations} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesOverviewTab;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
|
||||
// Maps Kubernetes resource kinds to their detail page PageMap entries
|
||||
const kindToPageMap: Record<string, PageMap> = {
|
||||
Pod: PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL,
|
||||
Node: PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL,
|
||||
Namespace: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL,
|
||||
Deployment: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL,
|
||||
StatefulSet: PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL,
|
||||
DaemonSet: PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL,
|
||||
Job: PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL,
|
||||
CronJob: PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL,
|
||||
PersistentVolumeClaim: PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL,
|
||||
PersistentVolume: PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL,
|
||||
ReplicaSet: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL, // ReplicaSets are managed by Deployments
|
||||
};
|
||||
|
||||
export interface ComponentProps {
|
||||
modelId: ObjectID;
|
||||
resourceKind: string;
|
||||
resourceName: string;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const KubernetesResourceLink: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const pageMap: PageMap | undefined = kindToPageMap[props.resourceKind];
|
||||
|
||||
if (!pageMap) {
|
||||
// No detail page for this kind — render as plain text
|
||||
return <span className={props.className}>{props.resourceName}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => {
|
||||
Navigation.navigate(
|
||||
RouteUtil.populateRouteParams(RouteMap[pageMap] as Route, {
|
||||
modelId: props.modelId,
|
||||
subModelId: new ObjectID(props.resourceName),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className={`text-indigo-600 hover:text-indigo-800 cursor-pointer font-medium ${props.className || ""}`}
|
||||
>
|
||||
{props.resourceName}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesResourceLink;
|
||||
@@ -0,0 +1,505 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import KubernetesResourceUtils, {
|
||||
KubernetesResource,
|
||||
} from "../../Pages/Kubernetes/Utils/KubernetesResourceUtils";
|
||||
import Card, { CardButtonSchema } from "Common/UI/Components/Card/Card";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { getRefreshButton } from "Common/UI/Components/Card/CardButtons/Refresh";
|
||||
import Table from "Common/UI/Components/Table/Table";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Link from "Common/UI/Components/Link/Link";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import Column from "Common/UI/Components/Table/Types/Column";
|
||||
import Filter from "Common/UI/Components/Filters/Types/Filter";
|
||||
import FilterData from "Common/UI/Components/Filters/Types/FilterData";
|
||||
import Search from "Common/Types/BaseDatabase/Search";
|
||||
import Includes from "Common/Types/BaseDatabase/Includes";
|
||||
|
||||
export interface ResourceColumn {
|
||||
title: string;
|
||||
key: string;
|
||||
getValue?: (resource: KubernetesResource) => string;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
resources: Array<KubernetesResource>;
|
||||
title: string;
|
||||
description: string;
|
||||
columns?: Array<ResourceColumn>;
|
||||
showNamespace?: boolean;
|
||||
showStatus?: boolean;
|
||||
showResourceMetrics?: boolean;
|
||||
getViewRoute?: (resource: KubernetesResource) => Route;
|
||||
emptyMessage?: string;
|
||||
isLoading?: boolean;
|
||||
onRefreshClick?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const PAGE_SIZE: number = 25;
|
||||
|
||||
function getStatusBadgeClass(status: string): string {
|
||||
const s: string = status.toLowerCase();
|
||||
if (
|
||||
s === "running" ||
|
||||
s === "ready" ||
|
||||
s === "active" ||
|
||||
s === "bound" ||
|
||||
s === "succeeded" ||
|
||||
s === "available" ||
|
||||
s === "true"
|
||||
) {
|
||||
return "bg-green-50 text-green-700";
|
||||
}
|
||||
if (
|
||||
s === "pending" ||
|
||||
s === "unknown" ||
|
||||
s === "waiting" ||
|
||||
s === "terminating"
|
||||
) {
|
||||
return "bg-yellow-50 text-yellow-700";
|
||||
}
|
||||
if (
|
||||
s === "failed" ||
|
||||
s === "crashloopbackoff" ||
|
||||
s === "error" ||
|
||||
s === "lost" ||
|
||||
s === "notready" ||
|
||||
s === "imagepullbackoff" ||
|
||||
s === "false"
|
||||
) {
|
||||
return "bg-red-50 text-red-700";
|
||||
}
|
||||
return "bg-gray-50 text-gray-700";
|
||||
}
|
||||
|
||||
function getCpuBarColor(pct: number): string {
|
||||
if (pct > 80) {
|
||||
return "bg-red-500";
|
||||
}
|
||||
if (pct > 60) {
|
||||
return "bg-yellow-500";
|
||||
}
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
function getMemoryBarColor(pct: number): string {
|
||||
if (pct > 85) {
|
||||
return "bg-red-500";
|
||||
}
|
||||
if (pct > 70) {
|
||||
return "bg-yellow-500";
|
||||
}
|
||||
return "bg-blue-500";
|
||||
}
|
||||
|
||||
const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const showNamespace: boolean = props.showNamespace !== false;
|
||||
const showStatus: boolean = props.showStatus !== false;
|
||||
const showResourceMetrics: boolean = props.showResourceMetrics !== false;
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [sortBy, setSortBy] = useState<string | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.Ascending);
|
||||
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
|
||||
const [filterData, setFilterData] = useState<FilterData<KubernetesResource>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Build filter definitions from data
|
||||
const filters: Array<Filter<KubernetesResource>> = useMemo(() => {
|
||||
const result: Array<Filter<KubernetesResource>> = [
|
||||
{
|
||||
title: "Name",
|
||||
key: "name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
];
|
||||
|
||||
if (showNamespace) {
|
||||
const namespaces: Array<string> = Array.from(
|
||||
new Set(
|
||||
props.resources
|
||||
.map((r: KubernetesResource) => {
|
||||
return r.namespace;
|
||||
})
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort();
|
||||
result.push({
|
||||
title: "Namespace",
|
||||
key: "namespace",
|
||||
type: FieldType.Dropdown,
|
||||
filterDropdownOptions: namespaces.map((ns: string) => {
|
||||
return { label: ns, value: ns };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (showStatus) {
|
||||
const statuses: Array<string> = Array.from(
|
||||
new Set(
|
||||
props.resources
|
||||
.map((r: KubernetesResource) => {
|
||||
return r.status;
|
||||
})
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort();
|
||||
result.push({
|
||||
title: "Status",
|
||||
key: "status",
|
||||
type: FieldType.Dropdown,
|
||||
filterDropdownOptions: statuses.map((s: string) => {
|
||||
return { label: s, value: s };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [props.resources, showNamespace, showStatus]);
|
||||
|
||||
// Filter and sort data client-side
|
||||
const processedData: Array<KubernetesResource> = useMemo(() => {
|
||||
let data: Array<KubernetesResource> = [...props.resources];
|
||||
|
||||
// Apply filters from filterData
|
||||
for (const key of Object.keys(filterData) as Array<
|
||||
keyof KubernetesResource
|
||||
>) {
|
||||
const value: unknown = filterData[key];
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value instanceof Search) {
|
||||
const searchText: string = value.toString().toLowerCase();
|
||||
data = data.filter((r: KubernetesResource) => {
|
||||
const fieldValue: string = (r[key] as string) || "";
|
||||
return fieldValue.toLowerCase().includes(searchText);
|
||||
});
|
||||
} else if (value instanceof Includes) {
|
||||
const includeValues: Array<string> = value.values as Array<string>;
|
||||
data = data.filter((r: KubernetesResource) => {
|
||||
const fieldValue: string = (r[key] as string) || "";
|
||||
return includeValues.includes(fieldValue);
|
||||
});
|
||||
} else if (typeof value === "string") {
|
||||
// Dropdown single selection stores as plain string
|
||||
data = data.filter((r: KubernetesResource) => {
|
||||
const fieldValue: string = (r[key] as string) || "";
|
||||
return fieldValue === value;
|
||||
});
|
||||
} else if (Array.isArray(value)) {
|
||||
// Dropdown multi-selection stores as plain array
|
||||
const includeValues: Array<string> = value.map((v: unknown) => {
|
||||
return String(v);
|
||||
});
|
||||
data = data.filter((r: KubernetesResource) => {
|
||||
const fieldValue: string = (r[key] as string) || "";
|
||||
return includeValues.includes(fieldValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy) {
|
||||
data.sort((a: KubernetesResource, b: KubernetesResource) => {
|
||||
let cmp: number = 0;
|
||||
if (sortBy === "name") {
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
} else if (sortBy === "namespace") {
|
||||
cmp = a.namespace.localeCompare(b.namespace);
|
||||
} else if (sortBy === "status") {
|
||||
cmp = a.status.localeCompare(b.status);
|
||||
} else if (sortBy === "cpuUtilization") {
|
||||
cmp = (a.cpuUtilization ?? -1) - (b.cpuUtilization ?? -1);
|
||||
} else if (sortBy === "memoryUsageBytes") {
|
||||
cmp = (a.memoryUsageBytes ?? -1) - (b.memoryUsageBytes ?? -1);
|
||||
} else if (sortBy === "age") {
|
||||
cmp = a.age.localeCompare(b.age);
|
||||
}
|
||||
return sortOrder === SortOrder.Descending ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [props.resources, filterData, sortBy, sortOrder]);
|
||||
|
||||
// Paginate
|
||||
const paginatedData: Array<KubernetesResource> = useMemo(() => {
|
||||
const start: number = (currentPage - 1) * PAGE_SIZE;
|
||||
return processedData.slice(start, start + PAGE_SIZE);
|
||||
}, [processedData, currentPage]);
|
||||
|
||||
const tableColumns: Array<Column<KubernetesResource>> = [
|
||||
{
|
||||
title: "Name",
|
||||
type: FieldType.Element,
|
||||
key: "name",
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
return (
|
||||
<span className="font-medium text-gray-900">{resource.name}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (showNamespace) {
|
||||
tableColumns.push({
|
||||
title: "Namespace",
|
||||
type: FieldType.Element,
|
||||
key: "namespace",
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
return (
|
||||
<span className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700">
|
||||
{resource.namespace || "default"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (showStatus) {
|
||||
tableColumns.push({
|
||||
title: "Status",
|
||||
type: FieldType.Element,
|
||||
key: "status",
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
if (!resource.status) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${getStatusBadgeClass(resource.status)}`}
|
||||
>
|
||||
{resource.status}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (props.columns) {
|
||||
for (const col of props.columns) {
|
||||
tableColumns.push({
|
||||
title: col.title,
|
||||
type: FieldType.Element,
|
||||
key: col.key as keyof KubernetesResource,
|
||||
disableSort: true,
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
const value: string = col.getValue
|
||||
? col.getValue(resource)
|
||||
: resource.additionalAttributes[col.key] || "";
|
||||
return <span>{value}</span>;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (showResourceMetrics) {
|
||||
tableColumns.push(
|
||||
{
|
||||
title: "CPU",
|
||||
type: FieldType.Element,
|
||||
key: "cpuUtilization",
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
if (
|
||||
resource.cpuUtilization === null ||
|
||||
resource.cpuUtilization === undefined
|
||||
) {
|
||||
return <span className="text-gray-400">N/A</span>;
|
||||
}
|
||||
const pct: number = Math.min(resource.cpuUtilization, 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getCpuBarColor(pct)}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 whitespace-nowrap w-10 text-right">
|
||||
{KubernetesResourceUtils.formatCpuValue(
|
||||
resource.cpuUtilization,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Memory",
|
||||
type: FieldType.Element,
|
||||
key: "memoryUsageBytes",
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
if (
|
||||
resource.memoryUsageBytes === null ||
|
||||
resource.memoryUsageBytes === undefined
|
||||
) {
|
||||
return <span className="text-gray-400">N/A</span>;
|
||||
}
|
||||
|
||||
if (
|
||||
resource.memoryLimitBytes !== null &&
|
||||
resource.memoryLimitBytes !== undefined &&
|
||||
resource.memoryLimitBytes > 0
|
||||
) {
|
||||
const pct: number = Math.min(
|
||||
(resource.memoryUsageBytes / resource.memoryLimitBytes) * 100,
|
||||
100,
|
||||
);
|
||||
return (
|
||||
<div className="min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getMemoryBarColor(pct)}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 whitespace-nowrap">
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{KubernetesResourceUtils.formatMemoryValue(
|
||||
resource.memoryUsageBytes,
|
||||
)}{" "}
|
||||
/{" "}
|
||||
{KubernetesResourceUtils.formatMemoryValue(
|
||||
resource.memoryLimitBytes,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-gray-700">
|
||||
{KubernetesResourceUtils.formatMemoryValue(
|
||||
resource.memoryUsageBytes,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (showStatus) {
|
||||
tableColumns.push({
|
||||
title: "Age",
|
||||
type: FieldType.Element,
|
||||
key: "age",
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
if (!resource.age) {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
return <span className="text-sm text-gray-600">{resource.age}</span>;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (props.getViewRoute) {
|
||||
tableColumns.push({
|
||||
title: "",
|
||||
type: FieldType.Element,
|
||||
key: "name",
|
||||
disableSort: true,
|
||||
getElement: (resource: KubernetesResource): ReactElement => {
|
||||
return (
|
||||
<Link
|
||||
to={props.getViewRoute!(resource)}
|
||||
className="text-indigo-600 hover:text-indigo-900 font-medium"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const hasActiveFilters: boolean = Object.keys(filterData).length > 0;
|
||||
|
||||
const cardButtons: Array<CardButtonSchema> = [];
|
||||
|
||||
if (props.onRefreshClick) {
|
||||
cardButtons.push({
|
||||
...getRefreshButton(),
|
||||
className: "py-0 pr-0 pl-0 mt-1",
|
||||
onClick: props.onRefreshClick,
|
||||
});
|
||||
}
|
||||
|
||||
cardButtons.push({
|
||||
title: "",
|
||||
buttonStyle: ButtonStyleType.ICON,
|
||||
className: "py-0 pr-0 pl-1 mt-1",
|
||||
onClick: () => {
|
||||
setShowFilterModal(true);
|
||||
},
|
||||
icon: IconProp.Filter,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
buttons={cardButtons}
|
||||
>
|
||||
<Table<KubernetesResource>
|
||||
id={`kubernetes-${props.title.toLowerCase().replace(/\s+/g, "-")}-table`}
|
||||
columns={tableColumns}
|
||||
data={paginatedData}
|
||||
singularLabel={props.title}
|
||||
pluralLabel={props.title}
|
||||
isLoading={props.isLoading || false}
|
||||
error=""
|
||||
currentPageNumber={currentPage}
|
||||
totalItemsCount={processedData.length}
|
||||
itemsOnPage={paginatedData.length}
|
||||
onNavigateToPage={(page: number) => {
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
sortBy={sortBy as keyof KubernetesResource | null}
|
||||
sortOrder={sortOrder}
|
||||
onSortChanged={(
|
||||
newSortBy: keyof KubernetesResource | null,
|
||||
newSortOrder: SortOrder,
|
||||
) => {
|
||||
setSortBy(newSortBy as string | null);
|
||||
setSortOrder(newSortOrder);
|
||||
}}
|
||||
filters={filters}
|
||||
showFilterModal={showFilterModal}
|
||||
filterData={filterData}
|
||||
onFilterChanged={(newFilterData: FilterData<KubernetesResource>) => {
|
||||
setFilterData(newFilterData);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onFilterModalOpen={() => {
|
||||
setShowFilterModal(true);
|
||||
}}
|
||||
onFilterModalClose={() => {
|
||||
setShowFilterModal(false);
|
||||
}}
|
||||
noItemsMessage={
|
||||
hasActiveFilters
|
||||
? "No resources match the current filters."
|
||||
: props.emptyMessage ||
|
||||
"No resources found. Resources will appear here once the kubernetes-agent is sending data."
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesResourceTable;
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import {
|
||||
KubernetesContainerSpec,
|
||||
KubernetesContainerStatus,
|
||||
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
|
||||
import StatusBadge, {
|
||||
StatusBadgeType,
|
||||
} from "Common/UI/Components/StatusBadge/StatusBadge";
|
||||
import LocalTable from "Common/UI/Components/Table/LocalTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import type Columns from "Common/UI/Components/Table/Types/Columns";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Input from "Common/UI/Components/Input/Input";
|
||||
|
||||
export interface ComponentProps {
|
||||
containers: Array<KubernetesContainerSpec>;
|
||||
initContainers: Array<KubernetesContainerSpec>;
|
||||
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
|
||||
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
|
||||
}
|
||||
|
||||
interface VolumeMountRow {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
readOnly: string;
|
||||
}
|
||||
|
||||
const KubernetesVolumeMountsTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const getStatus: (
|
||||
name: string,
|
||||
isInit: boolean,
|
||||
) => KubernetesContainerStatus | undefined = (
|
||||
name: string,
|
||||
isInit: boolean,
|
||||
): KubernetesContainerStatus | undefined => {
|
||||
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
|
||||
? props.initContainerStatuses
|
||||
: props.containerStatuses;
|
||||
return statuses?.find((s: KubernetesContainerStatus) => {
|
||||
return s.name === name;
|
||||
});
|
||||
};
|
||||
|
||||
function getStatePriority(state: string): number {
|
||||
const s: string = state.toLowerCase();
|
||||
if (s === "running") {
|
||||
return 0;
|
||||
}
|
||||
if (s === "waiting") {
|
||||
return 1;
|
||||
}
|
||||
if (s === "terminated") {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
const sortedContainers: Array<{
|
||||
container: KubernetesContainerSpec;
|
||||
isInit: boolean;
|
||||
}> = [
|
||||
...props.initContainers.map((container: KubernetesContainerSpec) => {
|
||||
return { container, isInit: true };
|
||||
}),
|
||||
...props.containers.map((container: KubernetesContainerSpec) => {
|
||||
return { container, isInit: false };
|
||||
}),
|
||||
].sort(
|
||||
(
|
||||
a: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
b: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
) => {
|
||||
const aStatus: KubernetesContainerStatus | undefined = getStatus(
|
||||
a.container.name,
|
||||
a.isInit,
|
||||
);
|
||||
const bStatus: KubernetesContainerStatus | undefined = getStatus(
|
||||
b.container.name,
|
||||
b.isInit,
|
||||
);
|
||||
const aPriority: number = getStatePriority(aStatus?.state || "unknown");
|
||||
const bPriority: number = getStatePriority(bStatus?.state || "unknown");
|
||||
return aPriority - bPriority;
|
||||
},
|
||||
);
|
||||
|
||||
const allContainers: Array<KubernetesContainerSpec> = sortedContainers.map(
|
||||
(item: { container: KubernetesContainerSpec; isInit: boolean }) => {
|
||||
return item.container;
|
||||
},
|
||||
);
|
||||
|
||||
if (allContainers.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No container information available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalMountCount: number = allContainers.reduce(
|
||||
(sum: number, c: KubernetesContainerSpec) => {
|
||||
return sum + c.volumeMounts.length;
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
if (totalMountCount === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No volume mounts defined for any container.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const searchLower: string = search.toLowerCase();
|
||||
|
||||
const totalMatchCount: number = search
|
||||
? allContainers.reduce((sum: number, c: KubernetesContainerSpec) => {
|
||||
return (
|
||||
sum +
|
||||
c.volumeMounts.filter(
|
||||
(m: { name: string; mountPath: string; readOnly: boolean }) => {
|
||||
return (
|
||||
m.name.toLowerCase().includes(searchLower) ||
|
||||
m.mountPath.toLowerCase().includes(searchLower)
|
||||
);
|
||||
},
|
||||
).length
|
||||
);
|
||||
}, 0)
|
||||
: totalMountCount;
|
||||
|
||||
const columns: Columns<VolumeMountRow> = [
|
||||
{
|
||||
title: "Volume Name",
|
||||
type: FieldType.Element,
|
||||
key: "name",
|
||||
getElement: (item: VolumeMountRow): ReactElement => {
|
||||
return (
|
||||
<span className="font-mono font-medium text-gray-900">
|
||||
{item.name}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Mount Path",
|
||||
type: FieldType.Element,
|
||||
key: "mountPath",
|
||||
getElement: (item: VolumeMountRow): ReactElement => {
|
||||
return (
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded font-mono">
|
||||
{item.mountPath}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Access",
|
||||
type: FieldType.Element,
|
||||
key: "readOnly",
|
||||
getElement: (item: VolumeMountRow): ReactElement => {
|
||||
return (
|
||||
<StatusBadge
|
||||
text={item.readOnly === "true" ? "Read-Only" : "Read-Write"}
|
||||
type={
|
||||
item.readOnly === "true"
|
||||
? StatusBadgeType.Warning
|
||||
: StatusBadgeType.Neutral
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search bar */}
|
||||
<Card
|
||||
title="Volume Mounts"
|
||||
description={`${totalMountCount} mount${totalMountCount !== 1 ? "s" : ""} across ${
|
||||
allContainers.filter((c: KubernetesContainerSpec) => {
|
||||
return c.volumeMounts.length > 0;
|
||||
}).length
|
||||
} container${
|
||||
allContainers.filter((c: KubernetesContainerSpec) => {
|
||||
return c.volumeMounts.length > 0;
|
||||
}).length !== 1
|
||||
? "s"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Icon icon={IconProp.Search} className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search by volume name or mount path..."
|
||||
value={search}
|
||||
onChange={(value: string) => {
|
||||
setSearch(value);
|
||||
}}
|
||||
className="block w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
{search && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-sm text-gray-500 tabular-nums">
|
||||
{totalMatchCount} of {totalMountCount}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
}}
|
||||
className="rounded-md p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Icon icon={IconProp.Close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{sortedContainers.map(
|
||||
(
|
||||
item: { container: KubernetesContainerSpec; isInit: boolean },
|
||||
containerIdx: number,
|
||||
) => {
|
||||
if (item.container.volumeMounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredMounts: Array<{
|
||||
name: string;
|
||||
mountPath: string;
|
||||
readOnly: boolean;
|
||||
}> = search
|
||||
? item.container.volumeMounts.filter(
|
||||
(m: { name: string; mountPath: string; readOnly: boolean }) => {
|
||||
return (
|
||||
m.name.toLowerCase().includes(searchLower) ||
|
||||
m.mountPath.toLowerCase().includes(searchLower)
|
||||
);
|
||||
},
|
||||
)
|
||||
: item.container.volumeMounts;
|
||||
|
||||
if (filteredMounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tableData: Array<VolumeMountRow> = filteredMounts.map(
|
||||
(mount: {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
readOnly: boolean;
|
||||
}): VolumeMountRow => {
|
||||
return {
|
||||
name: mount.name,
|
||||
mountPath: mount.mountPath,
|
||||
readOnly: String(mount.readOnly),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={containerIdx}
|
||||
title={`${item.isInit ? "Init Container: " : ""}${item.container.name}`}
|
||||
description={`${filteredMounts.length} volume mount${filteredMounts.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
<LocalTable
|
||||
id={`volume-mounts-${containerIdx}`}
|
||||
data={tableData}
|
||||
columns={columns}
|
||||
singularLabel="Mount"
|
||||
pluralLabel="Mounts"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesVolumeMountsTab;
|
||||
@@ -0,0 +1,321 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { fetchRawK8sObject } from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
export interface ComponentProps {
|
||||
clusterIdentifier: string;
|
||||
resourceType: string;
|
||||
resourceName: string;
|
||||
namespace?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a JavaScript object to YAML string.
|
||||
*/
|
||||
function toYaml(obj: unknown, indent: number = 0): string {
|
||||
const prefix: string = " ".repeat(indent);
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (typeof obj === "string") {
|
||||
// Quote strings that contain special chars or look like numbers
|
||||
if (
|
||||
obj.includes(":") ||
|
||||
obj.includes("#") ||
|
||||
obj.includes("\n") ||
|
||||
obj.includes("'") ||
|
||||
obj.includes('"') ||
|
||||
obj === "" ||
|
||||
obj === "true" ||
|
||||
obj === "false" ||
|
||||
obj === "null" ||
|
||||
new RegExp("^\\d").test(obj)
|
||||
) {
|
||||
return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj === "number" || typeof obj === "boolean") {
|
||||
return String(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (obj.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
const lines: Array<string> = [];
|
||||
for (const item of obj) {
|
||||
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
||||
const entries: Array<[string, unknown]> = Object.entries(
|
||||
item as Record<string, unknown>,
|
||||
);
|
||||
if (entries.length > 0) {
|
||||
const [firstKey, firstVal] = entries[0]!;
|
||||
lines.push(`${prefix}- ${firstKey}: ${toYaml(firstVal, indent + 2)}`);
|
||||
for (let i: number = 1; i < entries.length; i++) {
|
||||
const [key, val] = entries[i]!;
|
||||
lines.push(`${prefix} ${key}: ${toYaml(val, indent + 2)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${prefix}- {}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${prefix}- ${toYaml(item, indent + 1)}`);
|
||||
}
|
||||
}
|
||||
return "\n" + lines.join("\n");
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
const record: Record<string, unknown> = obj as Record<string, unknown>;
|
||||
const keys: Array<string> = Object.keys(record);
|
||||
if (keys.length === 0) {
|
||||
return "{}";
|
||||
}
|
||||
const lines: Array<string> = [];
|
||||
for (const key of keys) {
|
||||
const val: unknown = record[key];
|
||||
if (
|
||||
val !== null &&
|
||||
val !== undefined &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
Object.keys(val as Record<string, unknown>).length > 0
|
||||
) {
|
||||
lines.push(`${prefix}${key}:`);
|
||||
lines.push(toYaml(val, indent + 1));
|
||||
} else if (Array.isArray(val) && val.length > 0) {
|
||||
lines.push(`${prefix}${key}:${toYaml(val, indent + 1)}`);
|
||||
} else {
|
||||
lines.push(`${prefix}${key}: ${toYaml(val, indent + 1)}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
return String(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove noisy internal Kubernetes fields that are not useful for users.
|
||||
* - managedFields: internal API server field ownership tracking
|
||||
* - resourceVersion: internal etcd revision
|
||||
* - uid: internal object UUID
|
||||
* - generation: internal object version counter
|
||||
* - selfLink: deprecated API field
|
||||
*/
|
||||
function cleanK8sObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const cleaned: Record<string, unknown> = { ...obj };
|
||||
|
||||
// Clean metadata sub-fields
|
||||
if (
|
||||
cleaned["metadata"] &&
|
||||
typeof cleaned["metadata"] === "object" &&
|
||||
!Array.isArray(cleaned["metadata"])
|
||||
) {
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(cleaned["metadata"] as Record<string, unknown>),
|
||||
};
|
||||
delete metadata["managedFields"];
|
||||
delete metadata["uid"];
|
||||
delete metadata["resourceVersion"];
|
||||
delete metadata["generation"];
|
||||
delete metadata["selfLink"];
|
||||
cleaned["metadata"] = metadata;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
const KubernetesYamlTab: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [yamlContent, setYamlContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result: Record<string, unknown> | null = await fetchRawK8sObject({
|
||||
clusterIdentifier: props.clusterIdentifier,
|
||||
resourceType: props.resourceType,
|
||||
resourceName: props.resourceName,
|
||||
namespace: props.namespace,
|
||||
});
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
// Remove noisy internal Kubernetes fields before rendering
|
||||
const cleaned: Record<string, unknown> = cleanK8sObject(result);
|
||||
const yaml: string = toYaml(cleaned);
|
||||
setYamlContent(yaml);
|
||||
} else {
|
||||
setYamlContent("");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to fetch resource data.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [
|
||||
props.clusterIdentifier,
|
||||
props.resourceType,
|
||||
props.resourceName,
|
||||
props.namespace,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (!yamlContent) {
|
||||
return (
|
||||
<ErrorMessage message="No resource spec data available. Ensure the kubernetes-agent has resourceSpecs.enabled set to true in the Helm values." />
|
||||
);
|
||||
}
|
||||
|
||||
const lines: Array<string> = yamlContent.split("\n");
|
||||
|
||||
/**
|
||||
* Simple YAML syntax highlighter.
|
||||
* Returns an array of React elements with colored spans for keys, values, etc.
|
||||
*/
|
||||
const highlightYamlLine: (line: string) => ReactElement = (
|
||||
line: string,
|
||||
): ReactElement => {
|
||||
// Empty or whitespace-only line
|
||||
if (line.trim() === "") {
|
||||
return <span>{line}</span>;
|
||||
}
|
||||
|
||||
// Comment lines
|
||||
if (line.trimStart().startsWith("#")) {
|
||||
return <span className="text-gray-400 italic">{line}</span>;
|
||||
}
|
||||
|
||||
// Array item prefix " - "
|
||||
const arrayMatch: RegExpMatchArray | null = line.match(/^(\s*)(- )(.*)$/);
|
||||
if (arrayMatch) {
|
||||
const [, indent, dash, rest] = arrayMatch;
|
||||
// Check if rest has a key: value pattern
|
||||
const kvMatch: RegExpMatchArray | null = (rest || "").match(
|
||||
/^([^:]+):\s*(.*)$/,
|
||||
);
|
||||
if (kvMatch) {
|
||||
const [, key, val] = kvMatch;
|
||||
return (
|
||||
<span>
|
||||
{indent}
|
||||
<span className="text-gray-500">{dash}</span>
|
||||
<span className="text-indigo-700 font-medium">{key}</span>
|
||||
<span className="text-gray-500">: </span>
|
||||
<span className="text-emerald-700">{val}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{indent}
|
||||
<span className="text-gray-500">{dash}</span>
|
||||
<span className="text-emerald-700">{rest}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Key: value lines
|
||||
const kvLineMatch: RegExpMatchArray | null = line.match(
|
||||
/^(\s*)([^:]+):\s*(.+)$/,
|
||||
);
|
||||
if (kvLineMatch) {
|
||||
const [, indent, key, val] = kvLineMatch;
|
||||
return (
|
||||
<span>
|
||||
{indent}
|
||||
<span className="text-indigo-700 font-medium">{key}</span>
|
||||
<span className="text-gray-500">: </span>
|
||||
<span className="text-emerald-700">{val}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Key-only lines (e.g., "metadata:")
|
||||
const keyOnlyMatch: RegExpMatchArray | null =
|
||||
line.match(/^(\s*)([^:]+):(\s*)$/);
|
||||
if (keyOnlyMatch) {
|
||||
const [, indent, key] = keyOnlyMatch;
|
||||
return (
|
||||
<span>
|
||||
{indent}
|
||||
<span className="text-indigo-700 font-medium">{key}</span>
|
||||
<span className="text-gray-500">:</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <span className="text-gray-800">{line}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Resource Specification"
|
||||
description="Full resource specification as collected by the kubernetes-agent."
|
||||
buttons={[
|
||||
{
|
||||
title: copied ? "Copied!" : "Copy",
|
||||
buttonStyle: ButtonStyleType.NORMAL,
|
||||
icon: IconProp.Copy,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(yamlContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="overflow-x-auto bg-gray-50 rounded-lg border border-gray-200">
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{lines.map((line: string, index: number) => {
|
||||
return (
|
||||
<tr key={index} className="hover:bg-gray-100/50">
|
||||
<td className="px-4 py-0 text-right text-xs text-gray-400 select-none w-12 align-top font-mono border-r border-gray-200">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="px-4 py-0 text-sm font-mono whitespace-pre">
|
||||
{highlightYamlLine(line)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesYamlTab;
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Dropdown, {
|
||||
DropdownOption,
|
||||
DropdownValue,
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import Input, { InputType } from "Common/UI/Components/Input/Input";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
|
||||
export interface FilterConditionData {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
condition: FilterConditionData;
|
||||
onChange: (condition: FilterConditionData) => void;
|
||||
onDelete: () => void;
|
||||
canDelete: boolean;
|
||||
index: number;
|
||||
connector: string;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
const fieldOptions: Array<DropdownOption> = [
|
||||
{
|
||||
value: "severityText",
|
||||
label: "Severity",
|
||||
description: "Log severity level (e.g. ERROR, WARNING, INFO)",
|
||||
},
|
||||
{
|
||||
value: "body",
|
||||
label: "Log Body",
|
||||
description: "The log message content",
|
||||
},
|
||||
{
|
||||
value: "serviceId",
|
||||
label: "Service ID",
|
||||
description: "The service that produced the log",
|
||||
},
|
||||
];
|
||||
|
||||
const operatorOptions: Array<DropdownOption> = [
|
||||
{ value: "=", label: "equals" },
|
||||
{ value: "!=", label: "does not equal" },
|
||||
{ value: "LIKE", label: "contains", description: "Use % as wildcard" },
|
||||
{ value: "IN", label: "is one of", description: "Comma-separated values" },
|
||||
];
|
||||
|
||||
const severityOptions: Array<DropdownOption> = [
|
||||
{ value: "TRACE", label: "TRACE" },
|
||||
{ value: "DEBUG", label: "DEBUG" },
|
||||
{ value: "INFO", label: "INFO" },
|
||||
{ value: "WARNING", label: "WARNING" },
|
||||
{ value: "ERROR", label: "ERROR" },
|
||||
{ value: "FATAL", label: "FATAL" },
|
||||
];
|
||||
|
||||
const FilterConditionElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const { condition } = props;
|
||||
|
||||
const isAttributeField: boolean = condition.field.startsWith("attributes.");
|
||||
const selectedFieldOption: DropdownOption | undefined = isAttributeField
|
||||
? undefined
|
||||
: fieldOptions.find((opt: DropdownOption) => {
|
||||
return opt.value === condition.field;
|
||||
});
|
||||
const selectedOperatorOption: DropdownOption | undefined =
|
||||
operatorOptions.find((opt: DropdownOption) => {
|
||||
return opt.value === condition.operator;
|
||||
});
|
||||
|
||||
const operatorHint: string | undefined =
|
||||
condition.operator === "LIKE"
|
||||
? "Use % as wildcard (e.g. %error%)"
|
||||
: condition.operator === "IN"
|
||||
? "Comma-separated values"
|
||||
: undefined;
|
||||
|
||||
const isFirst: boolean = props.index === 0;
|
||||
const connectorColor: string =
|
||||
props.connector === "AND" ? "text-indigo-600" : "text-amber-600";
|
||||
const connectorBgColor: string =
|
||||
props.connector === "AND"
|
||||
? "bg-indigo-50 border-indigo-200"
|
||||
: "bg-amber-50 border-amber-200";
|
||||
const lineColor: string =
|
||||
props.connector === "AND" ? "bg-indigo-200" : "bg-amber-200";
|
||||
|
||||
return (
|
||||
<div className="relative flex">
|
||||
{/* Timeline column */}
|
||||
<div className="flex-shrink-0 w-16 flex flex-col items-center relative">
|
||||
{/* Top line segment (hidden for first) */}
|
||||
{!isFirst && <div className={`w-0.5 h-3 ${lineColor}`} />}
|
||||
{isFirst && <div className="h-3" />}
|
||||
|
||||
{/* Node: "Where" dot or connector badge */}
|
||||
{isFirst ? (
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-gray-100 border-2 border-gray-300">
|
||||
<svg
|
||||
className="w-3 h-3 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center justify-center px-1.5 py-0.5 rounded-full text-[10px] font-bold border ${connectorBgColor} ${connectorColor}`}
|
||||
>
|
||||
{props.connector}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom line segment (hidden for last) */}
|
||||
{!props.isLast ? (
|
||||
<div className={`w-0.5 flex-1 ${lineColor}`} />
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition row */}
|
||||
<div className="flex-1 group pb-3 pt-0.5">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-transparent hover:border-gray-200 hover:bg-gray-50/50 transition-all duration-150 p-2 -ml-1">
|
||||
{/* Field */}
|
||||
<div className="w-40 flex-shrink-0">
|
||||
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
|
||||
Field
|
||||
</label>
|
||||
<Dropdown
|
||||
options={[
|
||||
...fieldOptions,
|
||||
{
|
||||
value: "__custom_attribute__",
|
||||
label: "Custom Attribute...",
|
||||
description: "Filter on a custom log attribute",
|
||||
},
|
||||
]}
|
||||
value={selectedFieldOption}
|
||||
placeholder="Select field..."
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
if (value === "__custom_attribute__") {
|
||||
props.onChange({
|
||||
...condition,
|
||||
field: "attributes.",
|
||||
});
|
||||
} else {
|
||||
props.onChange({
|
||||
...condition,
|
||||
field: value?.toString() || "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom attribute name */}
|
||||
{isAttributeField && (
|
||||
<div className="w-28 flex-shrink-0">
|
||||
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
|
||||
Attribute
|
||||
</label>
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="attr name"
|
||||
value={condition.field.replace("attributes.", "")}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({
|
||||
...condition,
|
||||
field: `attributes.${value}`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator */}
|
||||
<div className="w-40 flex-shrink-0">
|
||||
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
|
||||
Operator
|
||||
</label>
|
||||
<Dropdown
|
||||
options={operatorOptions}
|
||||
value={selectedOperatorOption}
|
||||
placeholder="Select..."
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
props.onChange({
|
||||
...condition,
|
||||
operator: value?.toString() || "=",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<label className="block text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">
|
||||
Value
|
||||
</label>
|
||||
{condition.field === "severityText" ? (
|
||||
<Dropdown
|
||||
options={severityOptions}
|
||||
value={
|
||||
condition.value
|
||||
? { value: condition.value, label: condition.value }
|
||||
: undefined
|
||||
}
|
||||
placeholder="Select severity..."
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
props.onChange({
|
||||
...condition,
|
||||
value: value?.toString() || "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="Enter value..."
|
||||
value={condition.value}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({ ...condition, value });
|
||||
}}
|
||||
/>
|
||||
{operatorHint && (
|
||||
<p className="mt-0.5 text-[10px] text-gray-400 leading-tight">
|
||||
{operatorHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete - uses same label spacer as other columns to align */}
|
||||
<div className="flex-shrink-0">
|
||||
<label className="block text-[10px] font-medium text-transparent uppercase tracking-wider mb-1">
|
||||
|
||||
</label>
|
||||
{props.canDelete ? (
|
||||
<Button
|
||||
icon={IconProp.Trash}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={props.onDelete}
|
||||
tooltip="Remove condition"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterConditionElement;
|
||||
@@ -0,0 +1,613 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import Modal, { ModalWidth } from "Common/UI/Components/Modal/Modal";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import BaseModel from "Common/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
|
||||
import FilterConditionElement, { FilterConditionData } from "./FilterCondition";
|
||||
|
||||
export interface ComponentProps {
|
||||
modelType: { new (): BaseModel };
|
||||
modelId: ObjectID;
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
type LogicalConnector = "AND" | "OR";
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
severityText: "Severity",
|
||||
body: "Log Body",
|
||||
serviceId: "Service ID",
|
||||
};
|
||||
|
||||
const operatorLabels: Record<string, string> = {
|
||||
"=": "equals",
|
||||
"!=": "does not equal",
|
||||
LIKE: "contains",
|
||||
IN: "is one of",
|
||||
};
|
||||
|
||||
function getFieldLabel(field: string): string {
|
||||
if (field.startsWith("attributes.")) {
|
||||
return field;
|
||||
}
|
||||
return fieldLabels[field] || field;
|
||||
}
|
||||
|
||||
function getOperatorLabel(operator: string): string {
|
||||
return operatorLabels[operator] || operator;
|
||||
}
|
||||
|
||||
function parseFilterQuery(query: string): {
|
||||
conditions: Array<FilterConditionData>;
|
||||
connector: LogicalConnector;
|
||||
} {
|
||||
const defaultResult: {
|
||||
conditions: Array<FilterConditionData>;
|
||||
connector: LogicalConnector;
|
||||
} = {
|
||||
conditions: [{ field: "severityText", operator: "=", value: "" }],
|
||||
connector: "AND",
|
||||
};
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return defaultResult;
|
||||
}
|
||||
|
||||
const connector: LogicalConnector = query.includes(" OR ") ? "OR" : "AND";
|
||||
const connectorRegex: RegExp = connector === "AND" ? / AND /i : / OR /i;
|
||||
const parts: Array<string> = query.split(connectorRegex);
|
||||
|
||||
const conditions: Array<FilterConditionData> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed: string = part.trim().replace(/^\(|\)$/g, "");
|
||||
|
||||
const likeMatch: RegExpMatchArray | null = trimmed.match(
|
||||
/^(\S+)\s+(LIKE)\s+'([^']*)'$/i,
|
||||
);
|
||||
const inMatch: RegExpMatchArray | null = trimmed.match(
|
||||
/^(\S+)\s+(IN)\s+\(([^)]*)\)$/i,
|
||||
);
|
||||
const eqMatch: RegExpMatchArray | null = trimmed.match(
|
||||
/^(\S+)\s*(=|!=)\s*'([^']*)'$/,
|
||||
);
|
||||
|
||||
if (likeMatch) {
|
||||
conditions.push({
|
||||
field: likeMatch[1]!,
|
||||
operator: "LIKE",
|
||||
value: likeMatch[3]!,
|
||||
});
|
||||
} else if (inMatch) {
|
||||
conditions.push({
|
||||
field: inMatch[1]!,
|
||||
operator: "IN",
|
||||
value: inMatch[3]!.replace(/'/g, "").trim(),
|
||||
});
|
||||
} else if (eqMatch) {
|
||||
conditions.push({
|
||||
field: eqMatch[1]!,
|
||||
operator: eqMatch[2]!,
|
||||
value: eqMatch[3]!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return defaultResult;
|
||||
}
|
||||
|
||||
return { conditions, connector };
|
||||
}
|
||||
|
||||
function buildFilterQuery(
|
||||
conditions: Array<FilterConditionData>,
|
||||
connector: LogicalConnector,
|
||||
): string {
|
||||
const parts: Array<string> = conditions
|
||||
.filter((c: FilterConditionData) => {
|
||||
return c.field && c.operator && c.value;
|
||||
})
|
||||
.map((c: FilterConditionData) => {
|
||||
if (c.operator === "LIKE") {
|
||||
return `${c.field} LIKE '${c.value}'`;
|
||||
}
|
||||
if (c.operator === "IN") {
|
||||
const values: string = c.value
|
||||
.split(",")
|
||||
.map((v: string) => {
|
||||
return `'${v.trim()}'`;
|
||||
})
|
||||
.join(", ");
|
||||
return `${c.field} IN (${values})`;
|
||||
}
|
||||
return `${c.field} ${c.operator} '${c.value}'`;
|
||||
});
|
||||
|
||||
return parts.join(` ${connector} `);
|
||||
}
|
||||
|
||||
function getSeverityColor(value: string): string {
|
||||
const v: string = value.toUpperCase();
|
||||
if (v === "FATAL") {
|
||||
return "bg-red-100 text-red-800 ring-red-600/20";
|
||||
}
|
||||
if (v === "ERROR") {
|
||||
return "bg-red-50 text-red-700 ring-red-600/10";
|
||||
}
|
||||
if (v === "WARNING") {
|
||||
return "bg-amber-50 text-amber-700 ring-amber-600/10";
|
||||
}
|
||||
if (v === "INFO") {
|
||||
return "bg-blue-50 text-blue-700 ring-blue-700/10";
|
||||
}
|
||||
if (v === "DEBUG") {
|
||||
return "bg-gray-50 text-gray-600 ring-gray-500/10";
|
||||
}
|
||||
if (v === "TRACE") {
|
||||
return "bg-gray-50 text-gray-500 ring-gray-500/10";
|
||||
}
|
||||
return "bg-gray-50 text-gray-600 ring-gray-500/10";
|
||||
}
|
||||
|
||||
const FilterQueryBuilder: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [conditions, setConditions] = useState<Array<FilterConditionData>>([
|
||||
{ field: "severityText", operator: "=", value: "" },
|
||||
]);
|
||||
const [connector, setConnector] = useState<LogicalConnector>("AND");
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
|
||||
const [modalConditions, setModalConditions] = useState<
|
||||
Array<FilterConditionData>
|
||||
>([]);
|
||||
const [modalConnector, setModalConnector] = useState<LogicalConnector>("AND");
|
||||
|
||||
const loadModel: () => Promise<void> =
|
||||
useCallback(async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const item: BaseModel | null = await ModelAPI.getItem({
|
||||
modelType: props.modelType,
|
||||
id: props.modelId,
|
||||
select: { filterQuery: true } as any,
|
||||
});
|
||||
|
||||
if (item && (item as any).filterQuery) {
|
||||
const parsed: {
|
||||
conditions: Array<FilterConditionData>;
|
||||
connector: LogicalConnector;
|
||||
} = parseFilterQuery((item as any).filterQuery as string);
|
||||
setConditions(parsed.conditions);
|
||||
setConnector(parsed.connector);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load filter conditions.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [props.modelId, props.modelType]);
|
||||
|
||||
useEffect(() => {
|
||||
loadModel().catch(() => {
|
||||
// error handled in loadModel
|
||||
});
|
||||
}, [loadModel]);
|
||||
|
||||
const handleSave: () => Promise<void> = async (): Promise<void> => {
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
|
||||
const query: string = buildFilterQuery(modalConditions, modalConnector);
|
||||
|
||||
try {
|
||||
await ModelAPI.updateById({
|
||||
modelType: props.modelType,
|
||||
id: props.modelId,
|
||||
data: { filterQuery: query || "" },
|
||||
});
|
||||
setConditions(modalConditions);
|
||||
setConnector(modalConnector);
|
||||
setShowModal(false);
|
||||
} catch {
|
||||
setError("Failed to save filter conditions.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openModal: () => void = (): void => {
|
||||
setModalConditions(
|
||||
conditions.map((c: FilterConditionData) => {
|
||||
return { ...c };
|
||||
}),
|
||||
);
|
||||
setModalConnector(connector);
|
||||
setError("");
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal: () => void = (): void => {
|
||||
setShowModal(false);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const cardTitle: string = props.title || "Filter Conditions";
|
||||
const cardDescription: string =
|
||||
props.description ||
|
||||
"Define which logs this rule applies to. Only logs that match these conditions will be affected. Leave empty to match all logs.";
|
||||
|
||||
const savedConditions: Array<FilterConditionData> = conditions.filter(
|
||||
(c: FilterConditionData) => {
|
||||
return c.field && c.operator && c.value;
|
||||
},
|
||||
);
|
||||
const hasConditions: boolean = savedConditions.length > 0;
|
||||
|
||||
const connectorLineColor: string =
|
||||
connector === "AND" ? "bg-indigo-200" : "bg-amber-200";
|
||||
const connectorBadgeStyle: string =
|
||||
connector === "AND"
|
||||
? "bg-indigo-50 text-indigo-600 ring-indigo-500/20"
|
||||
: "bg-amber-50 text-amber-600 ring-amber-500/20";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card title={cardTitle} description={cardDescription}>
|
||||
<div className="p-10 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="text-sm">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={cardTitle}
|
||||
description={cardDescription}
|
||||
buttons={[
|
||||
{
|
||||
title: "Edit",
|
||||
buttonStyle: ButtonStyleType.NORMAL,
|
||||
onClick: openModal,
|
||||
icon: IconProp.Edit,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="px-5 pt-4 pb-5">
|
||||
{hasConditions ? (
|
||||
<div>
|
||||
{/* Read-only conditions with vertical timeline */}
|
||||
<div className="relative">
|
||||
{savedConditions.map(
|
||||
(condition: FilterConditionData, index: number) => {
|
||||
const isSeverity: boolean =
|
||||
condition.field === "severityText";
|
||||
const isFirst: boolean = index === 0;
|
||||
const isLast: boolean =
|
||||
index === savedConditions.length - 1;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative flex">
|
||||
{/* Timeline column */}
|
||||
<div className="flex-shrink-0 w-10 flex flex-col items-center">
|
||||
{/* Top line */}
|
||||
{!isFirst && (
|
||||
<div className={`w-px h-2 ${connectorLineColor}`} />
|
||||
)}
|
||||
{isFirst && <div className="h-2" />}
|
||||
|
||||
{/* Node */}
|
||||
{isFirst ? (
|
||||
<div className="w-5 h-5 rounded-full bg-gray-100 border-2 border-gray-300 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full px-1 h-5 text-[9px] font-bold ring-1 ring-inset ${connectorBadgeStyle}`}
|
||||
>
|
||||
{connector}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom line */}
|
||||
{!isLast ? (
|
||||
<div
|
||||
className={`w-px flex-1 ${connectorLineColor}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition content */}
|
||||
<div className="flex-1 pb-2 pt-0">
|
||||
<div className="flex items-center gap-2 py-1 pl-2 rounded-md hover:bg-gray-50 transition-colors duration-100 cursor-default">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 text-xs font-semibold text-gray-700 tracking-tight">
|
||||
{getFieldLabel(condition.field)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
{getOperatorLabel(condition.operator)}
|
||||
</span>
|
||||
{isSeverity ? (
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-bold ring-1 ring-inset ${getSeverityColor(condition.value)}`}
|
||||
>
|
||||
{condition.value || "(empty)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-indigo-50 text-xs font-mono font-medium text-indigo-700 ring-1 ring-inset ring-indigo-700/10">
|
||||
{condition.value || "(empty)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
{savedConditions.length > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400">
|
||||
{savedConditions.length} conditions joined with{" "}
|
||||
<span
|
||||
className={`font-semibold ${connector === "AND" ? "text-indigo-500" : "text-amber-500"}`}
|
||||
>
|
||||
{connector}
|
||||
</span>
|
||||
{" \u2014 "}
|
||||
{connector === "AND"
|
||||
? "log must match all"
|
||||
: "log must match at least one"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="relative mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-100 border-2 border-white flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
No filter conditions
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1 max-w-xs">
|
||||
This rule matches all incoming logs. Add conditions to target
|
||||
specific logs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Edit modal */}
|
||||
{showModal && (
|
||||
<Modal
|
||||
title="Edit Filter Conditions"
|
||||
description="Build filter rules to target specific logs. Conditions are evaluated in order."
|
||||
onClose={closeModal}
|
||||
modalWidth={ModalWidth.Large}
|
||||
submitButtonText="Save Changes"
|
||||
onSubmit={() => {
|
||||
handleSave().catch(() => {
|
||||
// error handled inside handleSave
|
||||
});
|
||||
}}
|
||||
isLoading={isSaving}
|
||||
disableSubmitButton={isSaving}
|
||||
>
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
type={AlertType.DANGER}
|
||||
title={error}
|
||||
onClose={() => {
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector toggle */}
|
||||
{modalConditions.length > 1 && (
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">Log must match</span>
|
||||
<div className="inline-flex rounded-lg border border-gray-200 p-0.5 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3.5 py-1.5 text-xs font-semibold rounded-md transition-all duration-150 ${
|
||||
modalConnector === "AND"
|
||||
? "bg-white text-indigo-700 shadow-sm ring-1 ring-black/5"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setModalConnector("AND");
|
||||
}}
|
||||
>
|
||||
All conditions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-3.5 py-1.5 text-xs font-semibold rounded-md transition-all duration-150 ${
|
||||
modalConnector === "OR"
|
||||
? "bg-white text-amber-700 shadow-sm ring-1 ring-black/5"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setModalConnector("OR");
|
||||
}}
|
||||
>
|
||||
Any condition
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Condition builder with timeline */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="px-4 pt-2">
|
||||
{modalConditions.map(
|
||||
(condition: FilterConditionData, index: number) => {
|
||||
return (
|
||||
<FilterConditionElement
|
||||
key={index}
|
||||
condition={condition}
|
||||
canDelete={modalConditions.length > 1}
|
||||
index={index}
|
||||
connector={modalConnector}
|
||||
isLast={index === modalConditions.length - 1}
|
||||
onChange={(updated: FilterConditionData) => {
|
||||
const newConditions: Array<FilterConditionData> = [
|
||||
...modalConditions,
|
||||
];
|
||||
newConditions[index] = updated;
|
||||
setModalConditions(newConditions);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newConditions: Array<FilterConditionData> =
|
||||
modalConditions.filter(
|
||||
(_: FilterConditionData, i: number) => {
|
||||
return i !== index;
|
||||
},
|
||||
);
|
||||
setModalConditions(newConditions);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add condition footer */}
|
||||
<div className="px-4 py-3 bg-gray-50/50 border-t border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
title="Add condition"
|
||||
icon={IconProp.Add}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setModalConditions([
|
||||
...modalConditions,
|
||||
{ field: "severityText", operator: "=", value: "" },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
{modalConditions.length > 1 && (
|
||||
<Button
|
||||
title="Clear all"
|
||||
icon={IconProp.Close}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setModalConditions([
|
||||
{ field: "severityText", operator: "=", value: "" },
|
||||
]);
|
||||
setModalConnector("AND");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Query preview */}
|
||||
{buildFilterQuery(modalConditions, modalConnector) && (
|
||||
<div className="mt-4">
|
||||
<details className="group">
|
||||
<summary className="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 hover:text-gray-500 transition-colors select-none list-none">
|
||||
<svg
|
||||
className="w-3 h-3 transition-transform duration-150 group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">Preview query</span>
|
||||
</summary>
|
||||
<div className="mt-2 rounded-lg bg-gray-900 p-3.5 overflow-x-auto">
|
||||
<code className="text-[13px] text-emerald-400 font-mono break-all leading-relaxed whitespace-pre-wrap">
|
||||
{buildFilterQuery(modalConditions, modalConnector)}
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterQueryBuilder;
|
||||
@@ -0,0 +1,706 @@
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import Input, { InputType } from "Common/UI/Components/Input/Input";
|
||||
import Dropdown, {
|
||||
DropdownOption,
|
||||
DropdownValue,
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import Toggle from "Common/UI/Components/Toggle/Toggle";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
|
||||
import LogPipelineProcessor from "Common/Models/DatabaseModels/LogPipelineProcessor";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import FieldLabelElement from "Common/UI/Components/Detail/FieldLabel";
|
||||
import SeverityMappingRow, { SeverityMapping } from "./SeverityMappingRow";
|
||||
import { JSONObject, JSONValue } from "Common/Types/JSON";
|
||||
import Modal, { ModalWidth } from "Common/UI/Components/Modal/Modal";
|
||||
|
||||
export interface ComponentProps {
|
||||
pipelineId: ObjectID;
|
||||
onProcessorCreated: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type ProcessorType =
|
||||
| "SeverityRemapper"
|
||||
| "AttributeRemapper"
|
||||
| "CategoryProcessor"
|
||||
| "";
|
||||
|
||||
const processorTypeOptions: Array<DropdownOption> = [
|
||||
{
|
||||
value: "SeverityRemapper",
|
||||
label: "Severity Remapper",
|
||||
description:
|
||||
"Reads a raw value (e.g. 'warn') from a log attribute and maps it to a standard severity level (e.g. WARNING)",
|
||||
},
|
||||
{
|
||||
value: "AttributeRemapper",
|
||||
label: "Attribute Remapper",
|
||||
description:
|
||||
"Renames or copies a log attribute key to a new key (e.g. rename 'src_ip' to 'source_ip')",
|
||||
},
|
||||
{
|
||||
value: "CategoryProcessor",
|
||||
label: "Category Processor",
|
||||
description:
|
||||
"Tags logs with a category name based on filter rules. Stored in log attributes for easy searching.",
|
||||
},
|
||||
];
|
||||
|
||||
interface CategoryRule {
|
||||
name: string;
|
||||
filterQuery: string;
|
||||
}
|
||||
|
||||
const ProcessorForm: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
// Common fields
|
||||
const [name, setName] = useState<string>("");
|
||||
const [processorType, setProcessorType] = useState<ProcessorType>("");
|
||||
const [isEnabled, setIsEnabled] = useState<boolean>(true);
|
||||
|
||||
// Severity Remapper fields
|
||||
const [severitySourceKey, setSeveritySourceKey] = useState<string>("level");
|
||||
const [severityMappings, setSeverityMappings] = useState<
|
||||
Array<SeverityMapping>
|
||||
>([{ matchValue: "", severityText: "", severityNumber: 0 }]);
|
||||
|
||||
// Attribute Remapper fields
|
||||
const [attrSourceKey, setAttrSourceKey] = useState<string>("");
|
||||
const [attrTargetKey, setAttrTargetKey] = useState<string>("");
|
||||
const [preserveSource, setPreserveSource] = useState<boolean>(false);
|
||||
const [overrideOnConflict, setOverrideOnConflict] = useState<boolean>(true);
|
||||
|
||||
// Category Processor fields
|
||||
const [categoryTargetKey, setCategoryTargetKey] =
|
||||
useState<string>("category");
|
||||
const [categories, setCategories] = useState<Array<CategoryRule>>([
|
||||
{ name: "", filterQuery: "" },
|
||||
]);
|
||||
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const buildConfiguration: () => JSONObject = (): JSONObject => {
|
||||
switch (processorType) {
|
||||
case "SeverityRemapper":
|
||||
return {
|
||||
sourceKey: severitySourceKey,
|
||||
mappings: severityMappings.filter((m: SeverityMapping) => {
|
||||
return m.matchValue && m.severityText;
|
||||
}) as unknown as JSONValue,
|
||||
};
|
||||
case "AttributeRemapper":
|
||||
return {
|
||||
sourceKey: attrSourceKey,
|
||||
targetKey: attrTargetKey,
|
||||
preserveSource,
|
||||
overrideOnConflict,
|
||||
};
|
||||
case "CategoryProcessor":
|
||||
return {
|
||||
targetKey: categoryTargetKey,
|
||||
categories: categories.filter((c: CategoryRule) => {
|
||||
return c.name && c.filterQuery;
|
||||
}) as unknown as JSONValue,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const validate: () => string | null = (): string | null => {
|
||||
if (!name.trim()) {
|
||||
return "Name is required.";
|
||||
}
|
||||
if (!processorType) {
|
||||
return "Please select a processor type.";
|
||||
}
|
||||
|
||||
switch (processorType) {
|
||||
case "SeverityRemapper": {
|
||||
if (!severitySourceKey.trim()) {
|
||||
return "Source key is required for Severity Remapper.";
|
||||
}
|
||||
const validMappings: Array<SeverityMapping> = severityMappings.filter(
|
||||
(m: SeverityMapping) => {
|
||||
return m.matchValue && m.severityText;
|
||||
},
|
||||
);
|
||||
if (validMappings.length === 0) {
|
||||
return "At least one severity mapping is required.";
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "AttributeRemapper":
|
||||
if (!attrSourceKey.trim()) {
|
||||
return "Source key is required.";
|
||||
}
|
||||
if (!attrTargetKey.trim()) {
|
||||
return "Target key is required.";
|
||||
}
|
||||
break;
|
||||
case "CategoryProcessor": {
|
||||
if (!categoryTargetKey.trim()) {
|
||||
return "Target key is required.";
|
||||
}
|
||||
const validCategories: Array<CategoryRule> = categories.filter(
|
||||
(c: CategoryRule) => {
|
||||
return c.name && c.filterQuery;
|
||||
},
|
||||
);
|
||||
if (validCategories.length === 0) {
|
||||
return "At least one category rule is required.";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave: () => Promise<void> = async (): Promise<void> => {
|
||||
const validationError: string | null = validate();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const processor: LogPipelineProcessor = new LogPipelineProcessor();
|
||||
processor.name = name;
|
||||
processor.processorType = processorType;
|
||||
processor.configuration = buildConfiguration();
|
||||
processor.isEnabled = isEnabled;
|
||||
processor.logPipelineId = props.pipelineId;
|
||||
processor.sortOrder = 1;
|
||||
|
||||
await ModelAPI.create({
|
||||
model: processor,
|
||||
modelType: LogPipelineProcessor,
|
||||
});
|
||||
|
||||
props.onProcessorCreated();
|
||||
} catch {
|
||||
setError("Failed to create processor. Please try again.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add Processor"
|
||||
description="Processors transform logs as they flow through the pipeline. They run in order after the filter conditions match. Each processor modifies the log before it is stored."
|
||||
modalWidth={ModalWidth.Large}
|
||||
submitButtonText="Create Processor"
|
||||
onSubmit={handleSave}
|
||||
isLoading={isSaving}
|
||||
onClose={props.onCancel}
|
||||
>
|
||||
<div className="p-2 space-y-5">
|
||||
{error && (
|
||||
<Alert
|
||||
type={AlertType.DANGER}
|
||||
title={error}
|
||||
onClose={() => {
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<FieldLabelElement title="Processor Name" />
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="e.g. Remap severity levels"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processor Type */}
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Processor Type"
|
||||
description="Choose what this processor does"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<Dropdown
|
||||
options={processorTypeOptions}
|
||||
value={
|
||||
processorType
|
||||
? processorTypeOptions.find((opt: DropdownOption) => {
|
||||
return opt.value === processorType;
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
placeholder="Select processor type..."
|
||||
onChange={(
|
||||
value: DropdownValue | Array<DropdownValue> | null,
|
||||
) => {
|
||||
setProcessorType((value?.toString() as ProcessorType) || "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* === Severity Remapper Configuration === */}
|
||||
{processorType === "SeverityRemapper" && (
|
||||
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
Severity Remapper Configuration
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Normalizes raw severity values from your logs into standard levels
|
||||
(TRACE, DEBUG, INFO, WARNING, ERROR, FATAL). This processor reads
|
||||
a value from a log attribute and maps it to the log's{" "}
|
||||
<code className="px-1 py-0.5 bg-indigo-100 rounded text-indigo-700 text-[11px]">
|
||||
severityText
|
||||
</code>{" "}
|
||||
field.
|
||||
</p>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="mb-4 p-3 bg-white rounded-md border border-indigo-100">
|
||||
<p className="text-xs font-semibold text-gray-600 mb-1.5">
|
||||
How it works
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>
|
||||
1. The processor reads the value from the Source Attribute in
|
||||
your log's{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
|
||||
attributes
|
||||
</code>{" "}
|
||||
object.
|
||||
</p>
|
||||
<p>2. It looks up the value in your mappings below.</p>
|
||||
<p>
|
||||
3. If a match is found, the log's{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
|
||||
severityText
|
||||
</code>{" "}
|
||||
is updated to the mapped severity level.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 p-2 bg-gray-900 rounded text-[11px] font-mono text-gray-300 leading-relaxed">
|
||||
<span className="text-gray-500">// Example: incoming log</span>
|
||||
<br />
|
||||
<span className="text-amber-400">attributes</span>: {"{"}{" "}
|
||||
<span className="text-emerald-400">"level"</span>:{" "}
|
||||
<span className="text-sky-400">"warn"</span> {"}"}
|
||||
<br />
|
||||
<span className="text-gray-500">
|
||||
// After processing (with mapping: warn → WARNING)
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-amber-400">severityText</span>:{" "}
|
||||
<span className="text-sky-400">"WARNING"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<FieldLabelElement
|
||||
title="Source Attribute"
|
||||
description={
|
||||
'The key in your log\'s attributes object that contains the raw severity value. Many logging libraries (Pino, Winston, Bunyan) use "level" by default.'
|
||||
}
|
||||
/>
|
||||
<div className="mt-1 w-64">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="e.g. level"
|
||||
value={severitySourceKey}
|
||||
onChange={setSeveritySourceKey}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-400">
|
||||
Common values: <code className="text-gray-500">level</code>,{" "}
|
||||
<code className="text-gray-500">log_level</code>,{" "}
|
||||
<code className="text-gray-500">severity</code>,{" "}
|
||||
<code className="text-gray-500">priority</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Mappings"
|
||||
description="Define how raw attribute values map to standard severity levels. The match value should be exactly what your application emits."
|
||||
/>
|
||||
<div className="mt-2 space-y-2">
|
||||
{severityMappings.map(
|
||||
(mapping: SeverityMapping, index: number) => {
|
||||
return (
|
||||
<SeverityMappingRow
|
||||
key={index}
|
||||
mapping={mapping}
|
||||
canDelete={severityMappings.length > 1}
|
||||
onChange={(updated: SeverityMapping) => {
|
||||
const newMappings: Array<SeverityMapping> = [
|
||||
...severityMappings,
|
||||
];
|
||||
newMappings[index] = updated;
|
||||
setSeverityMappings(newMappings);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setSeverityMappings(
|
||||
severityMappings.filter(
|
||||
(_: SeverityMapping, i: number) => {
|
||||
return i !== index;
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
title="Add Mapping"
|
||||
icon={IconProp.Add}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setSeverityMappings([
|
||||
...severityMappings,
|
||||
{
|
||||
matchValue: "",
|
||||
severityText: "",
|
||||
severityNumber: 0,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Attribute Remapper Configuration === */}
|
||||
{processorType === "AttributeRemapper" && (
|
||||
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
Attribute Remapper Configuration
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Renames or copies a key inside the log's{" "}
|
||||
<code className="px-1 py-0.5 bg-indigo-100 rounded text-indigo-700 text-[11px]">
|
||||
attributes
|
||||
</code>{" "}
|
||||
object. Useful for standardizing attribute names across services
|
||||
or cleaning up legacy key names.
|
||||
</p>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="mb-4 p-3 bg-white rounded-md border border-indigo-100">
|
||||
<p className="text-xs font-semibold text-gray-600 mb-1.5">
|
||||
How it works
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>
|
||||
1. Reads the value from{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
|
||||
attributes[sourceKey]
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
2. Writes that value to{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
|
||||
attributes[targetKey]
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
3. Optionally removes the original source key (if Preserve
|
||||
Source is off).
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 p-2 bg-gray-900 rounded text-[11px] font-mono text-gray-300 leading-relaxed">
|
||||
<span className="text-gray-500">
|
||||
// Before: attributes has "src_ip"
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-amber-400">attributes</span>: {"{"}{" "}
|
||||
<span className="text-emerald-400">"src_ip"</span>:{" "}
|
||||
<span className="text-sky-400">"10.0.1.5"</span> {"}"}
|
||||
<br />
|
||||
<span className="text-gray-500">
|
||||
// After: renamed to "source_ip"
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-amber-400">attributes</span>: {"{"}{" "}
|
||||
<span className="text-emerald-400">"source_ip"</span>:{" "}
|
||||
<span className="text-sky-400">"10.0.1.5"</span> {"}"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Source Key"
|
||||
description="The attribute key to read the value from"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="e.g. src_ip"
|
||||
value={attrSourceKey}
|
||||
onChange={setAttrSourceKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Target Key"
|
||||
description="The new attribute key to write the value to"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="e.g. source_ip"
|
||||
value={attrTargetKey}
|
||||
onChange={setAttrTargetKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Toggle
|
||||
title="Preserve Source"
|
||||
description="Keep the original source attribute after remapping. If off, the source key is removed."
|
||||
value={preserveSource}
|
||||
onChange={setPreserveSource}
|
||||
/>
|
||||
<Toggle
|
||||
title="Override on Conflict"
|
||||
description="If the target key already exists, overwrite its value. If off and the target exists, the remap is skipped."
|
||||
value={overrideOnConflict}
|
||||
onChange={setOverrideOnConflict}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === Category Processor Configuration === */}
|
||||
{processorType === "CategoryProcessor" && (
|
||||
<div className="border border-indigo-200 rounded-lg p-4 bg-indigo-50/30">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
Category Processor Configuration
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Tags each log with a category name based on filter rules. The
|
||||
category value is stored in the log's{" "}
|
||||
<code className="px-1 py-0.5 bg-indigo-100 rounded text-indigo-700 text-[11px]">
|
||||
attributes
|
||||
</code>{" "}
|
||||
object under the Target Attribute key. Rules are evaluated in
|
||||
order and <strong>the first matching rule wins</strong>.
|
||||
</p>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="mb-4 p-3 bg-white rounded-md border border-indigo-100">
|
||||
<p className="text-xs font-semibold text-gray-600 mb-1.5">
|
||||
How it works
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>
|
||||
1. Each category rule has a filter condition (e.g.{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
|
||||
severityText = 'ERROR'
|
||||
</code>
|
||||
).
|
||||
</p>
|
||||
<p>
|
||||
2. The processor evaluates rules top to bottom. The first rule
|
||||
that matches the log is applied.
|
||||
</p>
|
||||
<p>
|
||||
3. The category name is stored at{" "}
|
||||
<code className="px-1 py-0.5 bg-gray-100 rounded text-gray-600 text-[11px]">
|
||||
attributes[targetAttribute]
|
||||
</code>{" "}
|
||||
on the log.
|
||||
</p>
|
||||
<p>
|
||||
4. You can then filter and search logs by this attribute in
|
||||
the Logs Viewer.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 p-2 bg-gray-900 rounded text-[11px] font-mono text-gray-300 leading-relaxed">
|
||||
<span className="text-gray-500">
|
||||
// Rule: "Critical Errors" when severityText =
|
||||
'ERROR'
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-gray-500">
|
||||
// Target Attribute: "category"
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
<span className="text-gray-500">// Before processing</span>
|
||||
<br />
|
||||
<span className="text-amber-400">severityText</span>:{" "}
|
||||
<span className="text-sky-400">"ERROR"</span>,{" "}
|
||||
<span className="text-amber-400">attributes</span>: {"{"} {"}"}
|
||||
<br />
|
||||
<span className="text-gray-500">// After processing</span>
|
||||
<br />
|
||||
<span className="text-amber-400">severityText</span>:{" "}
|
||||
<span className="text-sky-400">"ERROR"</span>,{" "}
|
||||
<span className="text-amber-400">attributes</span>: {"{"}{" "}
|
||||
<span className="text-emerald-400">"category"</span>:{" "}
|
||||
<span className="text-sky-400">
|
||||
"Critical Errors"
|
||||
</span>{" "}
|
||||
{"}"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<FieldLabelElement
|
||||
title="Target Attribute"
|
||||
description={
|
||||
"The key in the log's attributes where the matched category name will be stored. You can search logs by this attribute in the Logs Viewer."
|
||||
}
|
||||
/>
|
||||
<div className="mt-1 w-64">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="e.g. category"
|
||||
value={categoryTargetKey}
|
||||
onChange={setCategoryTargetKey}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-gray-400">
|
||||
The category will be accessible as{" "}
|
||||
<code className="text-gray-500">
|
||||
attributes.{categoryTargetKey || "category"}
|
||||
</code>{" "}
|
||||
in your logs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabelElement
|
||||
title="Category Rules"
|
||||
description="Define categories and the filter conditions that trigger them. Rules are evaluated top to bottom — the first match wins."
|
||||
/>
|
||||
<div className="mt-2 space-y-2">
|
||||
{categories.map((cat: CategoryRule, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-12 gap-3 items-center p-3 bg-gray-50 rounded-md border border-gray-200"
|
||||
>
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="Category name (e.g. Error)"
|
||||
value={cat.name}
|
||||
onChange={(value: string) => {
|
||||
const newCats: Array<CategoryRule> = [
|
||||
...categories,
|
||||
];
|
||||
newCats[index] = {
|
||||
...cat,
|
||||
name: value,
|
||||
};
|
||||
setCategories(newCats);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<span className="text-gray-400 text-sm font-medium">
|
||||
when
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-6">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder="e.g. severityText = 'ERROR'"
|
||||
value={cat.filterQuery}
|
||||
onChange={(value: string) => {
|
||||
const newCats: Array<CategoryRule> = [
|
||||
...categories,
|
||||
];
|
||||
newCats[index] = {
|
||||
...cat,
|
||||
filterQuery: value,
|
||||
};
|
||||
setCategories(newCats);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end">
|
||||
<Button
|
||||
icon={IconProp.Trash}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setCategories(
|
||||
categories.filter(
|
||||
(_: CategoryRule, i: number) => {
|
||||
return i !== index;
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
disabled={categories.length <= 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
title="Add Category Rule"
|
||||
icon={IconProp.Add}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setCategories([
|
||||
...categories,
|
||||
{ name: "", filterQuery: "" },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enabled toggle */}
|
||||
{processorType && (
|
||||
<div>
|
||||
<Toggle
|
||||
title="Enabled"
|
||||
description="Enable this processor to start processing logs"
|
||||
value={isEnabled}
|
||||
onChange={setIsEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessorForm;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Input, { InputType } from "Common/UI/Components/Input/Input";
|
||||
import Dropdown, {
|
||||
DropdownOption,
|
||||
DropdownValue,
|
||||
} from "Common/UI/Components/Dropdown/Dropdown";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
export interface SeverityMapping {
|
||||
matchValue: string;
|
||||
severityText: string;
|
||||
severityNumber: number;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
mapping: SeverityMapping;
|
||||
onChange: (mapping: SeverityMapping) => void;
|
||||
onDelete: () => void;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
const severityOptions: Array<DropdownOption> = [
|
||||
{ value: "TRACE", label: "TRACE" },
|
||||
{ value: "DEBUG", label: "DEBUG" },
|
||||
{ value: "INFO", label: "INFO" },
|
||||
{ value: "WARNING", label: "WARNING" },
|
||||
{ value: "ERROR", label: "ERROR" },
|
||||
{ value: "FATAL", label: "FATAL" },
|
||||
];
|
||||
|
||||
const severityNumberMap: Record<string, number> = {
|
||||
TRACE: 1,
|
||||
DEBUG: 5,
|
||||
INFO: 9,
|
||||
WARNING: 13,
|
||||
ERROR: 17,
|
||||
FATAL: 21,
|
||||
};
|
||||
|
||||
const SeverityMappingRow: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const { mapping } = props;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-3 items-center p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<div className="col-span-5">
|
||||
<Input
|
||||
type={InputType.TEXT}
|
||||
placeholder='Value to match (e.g. "warn", "err")'
|
||||
value={mapping.matchValue}
|
||||
onChange={(value: string) => {
|
||||
props.onChange({ ...mapping, matchValue: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex justify-center">
|
||||
<span className="text-gray-400 text-sm font-medium">maps to</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5">
|
||||
<Dropdown
|
||||
options={severityOptions}
|
||||
value={
|
||||
mapping.severityText
|
||||
? {
|
||||
value: mapping.severityText,
|
||||
label: mapping.severityText,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
placeholder="Select severity..."
|
||||
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
|
||||
const text: string = value?.toString() || "";
|
||||
props.onChange({
|
||||
...mapping,
|
||||
severityText: text,
|
||||
severityNumber: severityNumberMap[text] || 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex justify-end">
|
||||
<Button
|
||||
icon={IconProp.Trash}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={props.onDelete}
|
||||
disabled={!props.canDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeverityMappingRow;
|
||||
@@ -8,10 +8,25 @@ import LogsViewer, {
|
||||
HistogramBucket,
|
||||
FacetData,
|
||||
ActiveFilter,
|
||||
LogsViewMode,
|
||||
} from "Common/UI/Components/LogsViewer/LogsViewer";
|
||||
import {
|
||||
DEFAULT_LOGS_TABLE_COLUMNS,
|
||||
LogsSavedViewOption,
|
||||
normalizeLogsTableColumns,
|
||||
} from "Common/UI/Components/LogsViewer/types";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal";
|
||||
import { FormType } from "Common/UI/Components/Forms/ModelForm";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import LogSeverity from "Common/Types/Log/LogSeverity";
|
||||
import LogSavedView from "Common/Models/DatabaseModels/LogSavedView";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import LocalStorage from "Common/UI/Utils/LocalStorage";
|
||||
import ModelAPI, {
|
||||
ListResult as ModelListResult,
|
||||
} from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
@@ -36,6 +51,7 @@ import URL from "Common/Types/API/URL";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import RangeStartAndEndDateTime, {
|
||||
@@ -54,10 +70,101 @@ export interface ComponentProps {
|
||||
noLogsMessage?: string | undefined;
|
||||
logQuery?: Query<Log> | undefined;
|
||||
limit?: number | undefined;
|
||||
onCountChange?: ((count: number) => void) | undefined;
|
||||
onShowDocumentation?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE: number = 100;
|
||||
const LIVE_POLL_INTERVAL_MS: number = 10000;
|
||||
const SAVED_VIEWS_LIMIT: number = 100;
|
||||
const FACET_FILTER_KEYS: Array<string> = [
|
||||
"severityText",
|
||||
"serviceId",
|
||||
"traceId",
|
||||
"spanId",
|
||||
];
|
||||
|
||||
function getColumnsStorageKey(viewerId: string): string {
|
||||
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
|
||||
return `logs-columns:${projectId?.toString() || "global"}:${viewerId}`;
|
||||
}
|
||||
|
||||
function loadSelectedColumns(viewerId: string): Array<string> {
|
||||
const savedValue: unknown = LocalStorage.getItem(
|
||||
getColumnsStorageKey(viewerId),
|
||||
);
|
||||
|
||||
if (Array.isArray(savedValue)) {
|
||||
return normalizeLogsTableColumns(
|
||||
savedValue.filter((value: unknown): value is string => {
|
||||
return typeof value === "string";
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return [...DEFAULT_LOGS_TABLE_COLUMNS];
|
||||
}
|
||||
|
||||
function getQueryValues(value: unknown): Array<string> {
|
||||
if (value instanceof Includes) {
|
||||
return value.values.map((item: string | number | ObjectID) => {
|
||||
return item.toString();
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
value instanceof ObjectID
|
||||
) {
|
||||
return [value.toString()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildFacetFiltersFromQuery(
|
||||
query: Query<Log>,
|
||||
baseQuery: Query<Log>,
|
||||
): Map<string, Set<string>> {
|
||||
const nextFilters: Map<string, Set<string>> = new Map();
|
||||
|
||||
for (const facetKey of FACET_FILTER_KEYS) {
|
||||
if ((baseQuery as any)[facetKey] !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const values: Array<string> = getQueryValues((query as any)[facetKey]);
|
||||
|
||||
if (values.length > 0) {
|
||||
nextFilters.set(facetKey, new Set(values));
|
||||
}
|
||||
}
|
||||
|
||||
return nextFilters;
|
||||
}
|
||||
|
||||
function resolveSavedTimeRange(
|
||||
query: Query<Log>,
|
||||
): RangeStartAndEndDateTime | undefined {
|
||||
const timeFilter: unknown = (query as any).time;
|
||||
|
||||
if (!timeFilter || !(timeFilter instanceof InBetween)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startTime: Date = new Date(timeFilter.startValue as string | Date);
|
||||
const endTime: Date = new Date(timeFilter.endValue as string | Date);
|
||||
|
||||
if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
range: TimeRange.CUSTOM,
|
||||
startAndEndDate: new InBetween<Date>(startTime, endTime),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseQuery(props: ComponentProps): Query<Log> {
|
||||
const query: Query<Log> = {};
|
||||
@@ -137,8 +244,28 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.Descending);
|
||||
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
|
||||
const [isLiveUpdating, setIsLiveUpdating] = useState<boolean>(false);
|
||||
const [savedViews, setSavedViews] = useState<Array<LogSavedView>>([]);
|
||||
const [selectedSavedViewId, setSelectedSavedViewId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedColumns, setSelectedColumns] = useState<Array<string>>(() => {
|
||||
return loadSelectedColumns(props.id);
|
||||
});
|
||||
const [showCreateSavedViewModal, setShowCreateSavedViewModal] =
|
||||
useState<boolean>(false);
|
||||
const [savedViewToEdit, setSavedViewToEdit] = useState<
|
||||
LogSavedView | undefined
|
||||
>(undefined);
|
||||
const [savedViewToDelete, setSavedViewToDelete] = useState<
|
||||
LogSavedView | undefined
|
||||
>(undefined);
|
||||
const [isSavedViewLoading, setIsSavedViewLoading] = useState<boolean>(false);
|
||||
const [viewMode, setViewMode] = useState<LogsViewMode>("list");
|
||||
|
||||
const liveRequestInFlight: React.MutableRefObject<boolean> =
|
||||
useRef<boolean>(false);
|
||||
const hasAppliedInitialSavedView: React.MutableRefObject<boolean> =
|
||||
useRef<boolean>(false);
|
||||
|
||||
// Histogram state
|
||||
const [histogramBuckets, setHistogramBuckets] = useState<
|
||||
@@ -196,8 +323,91 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}, [props.serviceIds]);
|
||||
|
||||
// Extract attribute filters from logQuery for histogram/facets API calls
|
||||
const logQueryAttributes: Record<string, string> | undefined = useMemo(() => {
|
||||
if (!props.logQuery) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const attributes: Record<string, string> | undefined = (
|
||||
props.logQuery as any
|
||||
).attributes as Record<string, string> | undefined;
|
||||
|
||||
if (!attributes || Object.keys(attributes).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}, [props.logQuery]);
|
||||
|
||||
const savedViewOptions: Array<LogsSavedViewOption> = useMemo(() => {
|
||||
return [...savedViews]
|
||||
.sort((left: LogSavedView, right: LogSavedView) => {
|
||||
if (Boolean(left.isDefault) !== Boolean(right.isDefault)) {
|
||||
return left.isDefault ? -1 : 1;
|
||||
}
|
||||
|
||||
return (left.name || "").localeCompare(right.name || "");
|
||||
})
|
||||
.map((savedView: LogSavedView): LogsSavedViewOption => {
|
||||
return {
|
||||
id: savedView.id?.toString() || "",
|
||||
name: savedView.name || "Untitled View",
|
||||
isDefault: Boolean(savedView.isDefault),
|
||||
};
|
||||
});
|
||||
}, [savedViews]);
|
||||
|
||||
const selectedSavedView: LogSavedView | undefined = useMemo(() => {
|
||||
return savedViews.find((savedView: LogSavedView) => {
|
||||
return savedView.id?.toString() === selectedSavedViewId;
|
||||
});
|
||||
}, [savedViews, selectedSavedViewId]);
|
||||
|
||||
// --- Fetch logs ---
|
||||
|
||||
const fetchSavedViews: () => Promise<void> =
|
||||
useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setIsSavedViewLoading(true);
|
||||
|
||||
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
|
||||
|
||||
if (!projectId) {
|
||||
setSavedViews([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const result: ModelListResult<LogSavedView> = await ModelAPI.getList({
|
||||
modelType: LogSavedView,
|
||||
query: {
|
||||
projectId: projectId,
|
||||
},
|
||||
limit: SAVED_VIEWS_LIMIT,
|
||||
skip: 0,
|
||||
select: {
|
||||
name: true,
|
||||
query: true,
|
||||
columns: true,
|
||||
sortField: true,
|
||||
sortOrder: true,
|
||||
pageSize: true,
|
||||
isDefault: true,
|
||||
createdByUserId: true,
|
||||
},
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
setSavedViews(result.data);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSavedViewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
type FetchOptions = {
|
||||
skipLoadingState?: boolean;
|
||||
};
|
||||
@@ -220,10 +430,32 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* When live polling, recompute the time range so the query window
|
||||
* slides forward to "now" and new logs become visible.
|
||||
*/
|
||||
let query: Query<Log> = filterOptions;
|
||||
|
||||
if (
|
||||
skipLoadingState &&
|
||||
isLiveEnabled &&
|
||||
timeRange.range !== TimeRange.CUSTOM
|
||||
) {
|
||||
const freshRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
|
||||
query = {
|
||||
...filterOptions,
|
||||
time: new InBetween<Date>(
|
||||
freshRange.startValue,
|
||||
freshRange.endValue,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const listResult: ListResult<Log> =
|
||||
await AnalyticsModelAPI.getList<Log>({
|
||||
modelType: Log,
|
||||
query: filterOptions,
|
||||
query: query,
|
||||
limit: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
select: select,
|
||||
@@ -236,6 +468,10 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
setLogs(listResult.data);
|
||||
setTotalCount(listResult.count);
|
||||
|
||||
if (props.onCountChange) {
|
||||
props.onCountChange(listResult.count);
|
||||
}
|
||||
|
||||
const maximumPage: number = Math.max(
|
||||
1,
|
||||
Math.ceil(listResult.count / Math.max(pageSize, 1)),
|
||||
@@ -255,7 +491,16 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}
|
||||
},
|
||||
[filterOptions, page, pageSize, select, sortField, sortOrder],
|
||||
[
|
||||
filterOptions,
|
||||
isLiveEnabled,
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
sortField,
|
||||
sortOrder,
|
||||
timeRange,
|
||||
],
|
||||
);
|
||||
|
||||
// --- Fetch histogram ---
|
||||
@@ -308,6 +553,10 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
(requestData as any)["spanIds"] = Array.from(spanFilterValues);
|
||||
}
|
||||
|
||||
if (logQueryAttributes) {
|
||||
(requestData as any)["attributes"] = logQueryAttributes;
|
||||
}
|
||||
|
||||
const response: HTTPResponse<JSONObject> = await postApi(
|
||||
"/telemetry/logs/histogram",
|
||||
requestData,
|
||||
@@ -323,7 +572,7 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
} finally {
|
||||
setHistogramLoading(false);
|
||||
}
|
||||
}, [serviceIdStrings, appliedFacetFilters, timeRange]);
|
||||
}, [serviceIdStrings, appliedFacetFilters, timeRange, logQueryAttributes]);
|
||||
|
||||
// --- Fetch facets ---
|
||||
|
||||
@@ -346,6 +595,10 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
(requestData as any)["serviceIds"] = serviceIdStrings;
|
||||
}
|
||||
|
||||
if (logQueryAttributes) {
|
||||
(requestData as any)["attributes"] = logQueryAttributes;
|
||||
}
|
||||
|
||||
const response: HTTPResponse<JSONObject> = await postApi(
|
||||
"/telemetry/logs/facets",
|
||||
requestData,
|
||||
@@ -361,7 +614,51 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
} finally {
|
||||
setFacetLoading(false);
|
||||
}
|
||||
}, [serviceIdStrings, timeRange]);
|
||||
}, [serviceIdStrings, timeRange, logQueryAttributes]);
|
||||
|
||||
// --- Handlers (defined before effects that reference them) ---
|
||||
|
||||
const disableLiveMode: () => void = useCallback((): void => {
|
||||
if (isLiveEnabled) {
|
||||
setIsLiveEnabled(false);
|
||||
liveRequestInFlight.current = false;
|
||||
setIsLiveUpdating(false);
|
||||
}
|
||||
}, [isLiveEnabled]);
|
||||
|
||||
const applySavedView: (savedView: LogSavedView) => void = useCallback(
|
||||
(savedView: LogSavedView): void => {
|
||||
const baseQuery: Query<Log> = buildBaseQuery(props);
|
||||
const rawQuery: JSONObject =
|
||||
(savedView.query as unknown as JSONObject) || {};
|
||||
const savedQuery: Query<Log> = (JSONFunctions.deserialize(
|
||||
JSONFunctions.serialize(rawQuery),
|
||||
) || {}) as Query<Log>;
|
||||
const mergedQuery: Query<Log> = {
|
||||
...(savedQuery as unknown as JSONObject),
|
||||
...(baseQuery as unknown as JSONObject),
|
||||
} as unknown as Query<Log>;
|
||||
const nextTimeRange: RangeStartAndEndDateTime | undefined =
|
||||
resolveSavedTimeRange(savedQuery);
|
||||
|
||||
if (nextTimeRange) {
|
||||
setTimeRange(nextTimeRange);
|
||||
}
|
||||
|
||||
setAppliedFacetFilters(
|
||||
buildFacetFiltersFromQuery(mergedQuery, baseQuery),
|
||||
);
|
||||
setFilterOptions(mergedQuery);
|
||||
setPage(1);
|
||||
setPageSize(savedView.pageSize || DEFAULT_PAGE_SIZE);
|
||||
setSortField((savedView.sortField as LogsSortField) || "time");
|
||||
setSortOrder(savedView.sortOrder || SortOrder.Descending);
|
||||
setSelectedColumns(normalizeLogsTableColumns(savedView.columns || []));
|
||||
setSelectedSavedViewId(savedView.id?.toString() || null);
|
||||
disableLiveMode();
|
||||
},
|
||||
[disableLiveMode, props],
|
||||
);
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
@@ -379,6 +676,46 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
void fetchFacets();
|
||||
}, [fetchFacets]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSavedViews();
|
||||
}, [fetchSavedViews]);
|
||||
|
||||
useEffect(() => {
|
||||
LocalStorage.setItem(getColumnsStorageKey(props.id), selectedColumns);
|
||||
}, [props.id, selectedColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAppliedInitialSavedView.current || isSavedViewLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAppliedInitialSavedView.current = true;
|
||||
|
||||
const defaultSavedView: LogSavedView | undefined = savedViews.find(
|
||||
(savedView: LogSavedView) => {
|
||||
return Boolean(savedView.isDefault);
|
||||
},
|
||||
);
|
||||
|
||||
if (defaultSavedView) {
|
||||
applySavedView(defaultSavedView);
|
||||
}
|
||||
}, [applySavedView, isSavedViewLoading, savedViews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSavedViewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists: boolean = savedViews.some((savedView: LogSavedView) => {
|
||||
return savedView.id?.toString() === selectedSavedViewId;
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
setSelectedSavedViewId(null);
|
||||
}
|
||||
}, [savedViews, selectedSavedViewId]);
|
||||
|
||||
// Live polling
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -465,14 +802,6 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
[page, sortField, sortOrder],
|
||||
);
|
||||
|
||||
const disableLiveMode: () => void = useCallback((): void => {
|
||||
if (isLiveEnabled) {
|
||||
setIsLiveEnabled(false);
|
||||
liveRequestInFlight.current = false;
|
||||
setIsLiveUpdating(false);
|
||||
}
|
||||
}, [isLiveEnabled]);
|
||||
|
||||
const handleFilterChanged: (newFilter: Query<Log>) => void = useCallback(
|
||||
(newFilter: Query<Log>): void => {
|
||||
setFilterOptions(newFilter);
|
||||
@@ -761,6 +1090,68 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
[handleFacetInclude],
|
||||
);
|
||||
|
||||
// Build read-only base filter chips from props (serviceIds, traceIds, spanIds, logQuery attributes)
|
||||
const baseActiveFilters: Array<ActiveFilter> = useMemo(() => {
|
||||
const filters: Array<ActiveFilter> = [];
|
||||
|
||||
if (props.serviceIds && props.serviceIds.length > 0) {
|
||||
for (const serviceId of props.serviceIds) {
|
||||
filters.push({
|
||||
facetKey: "serviceId",
|
||||
value: serviceId.toString(),
|
||||
displayKey: "Service",
|
||||
displayValue: serviceId.toString(),
|
||||
readOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.traceIds && props.traceIds.length > 0) {
|
||||
for (const traceId of props.traceIds) {
|
||||
filters.push({
|
||||
facetKey: "traceId",
|
||||
value: traceId,
|
||||
displayKey: "Trace",
|
||||
displayValue: traceId,
|
||||
readOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (props.spanIds && props.spanIds.length > 0) {
|
||||
for (const spanId of props.spanIds) {
|
||||
filters.push({
|
||||
facetKey: "spanId",
|
||||
value: spanId,
|
||||
displayKey: "Span",
|
||||
displayValue: spanId,
|
||||
readOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logQueryAttributes) {
|
||||
const attributeDisplayNames: Record<string, string> = {
|
||||
"resource.k8s.cluster.name": "Cluster",
|
||||
"resource.k8s.pod.name": "Pod",
|
||||
"resource.k8s.container.name": "Container",
|
||||
"resource.k8s.namespace.name": "Namespace",
|
||||
};
|
||||
|
||||
for (const [attrKey, attrValue] of Object.entries(logQueryAttributes)) {
|
||||
filters.push({
|
||||
facetKey: `attributes.${attrKey}`,
|
||||
value: attrValue,
|
||||
displayKey: attributeDisplayNames[attrKey] || attrKey,
|
||||
displayValue: attrValue,
|
||||
readOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [props.serviceIds, props.traceIds, props.spanIds, logQueryAttributes]);
|
||||
|
||||
// Build activeFilters array for UI display
|
||||
const activeFilters: Array<ActiveFilter> = useMemo(() => {
|
||||
const filters: Array<ActiveFilter> = [];
|
||||
@@ -793,46 +1184,257 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div id={props.id}>
|
||||
<LogsViewer
|
||||
isLoading={isLoading}
|
||||
onFilterChanged={handleFilterChanged}
|
||||
filterData={filterOptions}
|
||||
logs={logs}
|
||||
showFilters={props.showFilters}
|
||||
noLogsMessage={props.noLogsMessage}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
liveOptions={{
|
||||
isLive: isLiveEnabled,
|
||||
onToggle: handleLiveToggle,
|
||||
isDisabled: isLiveUpdating,
|
||||
}}
|
||||
getTraceRoute={getTraceRoute}
|
||||
getSpanRoute={getSpanRoute}
|
||||
histogramBuckets={histogramBuckets}
|
||||
histogramLoading={histogramLoading}
|
||||
onHistogramTimeRangeSelect={handleHistogramTimeRangeSelect}
|
||||
facetData={facetData}
|
||||
facetLoading={facetLoading}
|
||||
onFacetInclude={handleFacetInclude}
|
||||
onFacetExclude={handleFacetExclude}
|
||||
showFacetSidebar={true}
|
||||
activeFilters={activeFilters}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onClearAllFilters={handleClearAllFilters}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onFieldValueSelect={handleFieldValueSelect}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{showCreateSavedViewModal && (
|
||||
<ModelFormModal<LogSavedView>
|
||||
modelType={LogSavedView}
|
||||
name="Save Log View"
|
||||
title="Save Log View"
|
||||
description="Save the current log explorer state as a reusable view."
|
||||
onClose={() => {
|
||||
setShowCreateSavedViewModal(false);
|
||||
}}
|
||||
submitButtonText="Save View"
|
||||
onBeforeCreate={async (savedView: LogSavedView) => {
|
||||
savedView.query = filterOptions;
|
||||
savedView.columns = selectedColumns;
|
||||
savedView.sortField = sortField;
|
||||
savedView.sortOrder = sortOrder;
|
||||
savedView.pageSize = pageSize;
|
||||
return savedView;
|
||||
}}
|
||||
onSuccess={async (savedView: LogSavedView) => {
|
||||
setShowCreateSavedViewModal(false);
|
||||
await fetchSavedViews();
|
||||
applySavedView(savedView);
|
||||
}}
|
||||
formProps={{
|
||||
name: "Save Log View",
|
||||
modelType: LogSavedView,
|
||||
id: "save-log-view",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
title: "Name",
|
||||
description: "Choose a name for this saved log view.",
|
||||
placeholder: "Errors in checkout",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isDefault: true,
|
||||
},
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
title: "Set as default",
|
||||
description: "Automatically apply this view when opening logs.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
formType: FormType.Create,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{savedViewToEdit && (
|
||||
<ModelFormModal<LogSavedView>
|
||||
modelType={LogSavedView}
|
||||
modelIdToEdit={savedViewToEdit.id!}
|
||||
name="Edit Log View"
|
||||
title="Edit Log View"
|
||||
description="Rename this saved view or change whether it loads by default."
|
||||
onClose={() => {
|
||||
setSavedViewToEdit(undefined);
|
||||
}}
|
||||
submitButtonText="Save Changes"
|
||||
onSuccess={async () => {
|
||||
setSavedViewToEdit(undefined);
|
||||
await fetchSavedViews();
|
||||
}}
|
||||
formProps={{
|
||||
name: "Edit Log View",
|
||||
modelType: LogSavedView,
|
||||
id: "edit-log-view",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
title: "Name",
|
||||
description: "Update the name of this saved view.",
|
||||
placeholder: "Errors in checkout",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isDefault: true,
|
||||
},
|
||||
fieldType: FormFieldSchemaType.Checkbox,
|
||||
title: "Set as default",
|
||||
description: "Automatically apply this view when opening logs.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
formType: FormType.Update,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{savedViewToDelete && (
|
||||
<ConfirmModal
|
||||
title={`Delete ${savedViewToDelete.name || "saved view"}`}
|
||||
description={`Are you sure you want to delete ${savedViewToDelete.name || "this saved view"}?`}
|
||||
isLoading={isSavedViewLoading}
|
||||
submitButtonText="Delete"
|
||||
submitButtonType={ButtonStyleType.DANGER}
|
||||
onSubmit={async () => {
|
||||
if (!savedViewToDelete.id) {
|
||||
setSavedViewToDelete(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavedViewLoading(true);
|
||||
|
||||
try {
|
||||
await ModelAPI.deleteItem({
|
||||
modelType: LogSavedView,
|
||||
id: savedViewToDelete.id,
|
||||
});
|
||||
|
||||
if (savedViewToDelete.id.toString() === selectedSavedViewId) {
|
||||
setSelectedSavedViewId(null);
|
||||
}
|
||||
|
||||
await fetchSavedViews();
|
||||
setSavedViewToDelete(undefined);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSavedViewLoading(false);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setSavedViewToDelete(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div id={props.id}>
|
||||
<LogsViewer
|
||||
isLoading={isLoading}
|
||||
onFilterChanged={handleFilterChanged}
|
||||
filterData={filterOptions}
|
||||
logs={logs}
|
||||
showFilters={props.showFilters}
|
||||
noLogsMessage={props.noLogsMessage}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
liveOptions={{
|
||||
isLive: isLiveEnabled,
|
||||
onToggle: handleLiveToggle,
|
||||
isDisabled: isLiveUpdating,
|
||||
}}
|
||||
getTraceRoute={getTraceRoute}
|
||||
getSpanRoute={getSpanRoute}
|
||||
histogramBuckets={histogramBuckets}
|
||||
histogramLoading={histogramLoading}
|
||||
onHistogramTimeRangeSelect={handleHistogramTimeRangeSelect}
|
||||
facetData={facetData}
|
||||
facetLoading={facetLoading}
|
||||
onFacetInclude={handleFacetInclude}
|
||||
onFacetExclude={handleFacetExclude}
|
||||
showFacetSidebar={true}
|
||||
activeFilters={activeFilters}
|
||||
baseActiveFilters={baseActiveFilters}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onClearAllFilters={handleClearAllFilters}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onFieldValueSelect={handleFieldValueSelect}
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
onShowDocumentation={props.onShowDocumentation}
|
||||
selectedColumns={selectedColumns}
|
||||
onSelectedColumnsChange={(columns: Array<string>) => {
|
||||
setSelectedColumns(normalizeLogsTableColumns(columns));
|
||||
}}
|
||||
savedViews={savedViewOptions}
|
||||
selectedSavedViewId={selectedSavedViewId}
|
||||
onSavedViewSelect={(viewId: string) => {
|
||||
const savedView: LogSavedView | undefined = savedViews.find(
|
||||
(item: LogSavedView) => {
|
||||
return item.id?.toString() === viewId;
|
||||
},
|
||||
);
|
||||
|
||||
if (savedView) {
|
||||
applySavedView(savedView);
|
||||
}
|
||||
}}
|
||||
onCreateSavedView={() => {
|
||||
setShowCreateSavedViewModal(true);
|
||||
}}
|
||||
onEditSavedView={(viewId: string) => {
|
||||
const savedView: LogSavedView | undefined = savedViews.find(
|
||||
(item: LogSavedView) => {
|
||||
return item.id?.toString() === viewId;
|
||||
},
|
||||
);
|
||||
|
||||
setSavedViewToEdit(savedView);
|
||||
}}
|
||||
onDeleteSavedView={(viewId: string) => {
|
||||
const savedView: LogSavedView | undefined = savedViews.find(
|
||||
(item: LogSavedView) => {
|
||||
return item.id?.toString() === viewId;
|
||||
},
|
||||
);
|
||||
|
||||
setSavedViewToDelete(savedView);
|
||||
}}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
analyticsServiceIds={serviceIdStrings}
|
||||
projectId={ProjectUtil.getCurrentProjectId() || undefined}
|
||||
analyticsAppliedFacetFilters={appliedFacetFilters}
|
||||
onUpdateCurrentSavedView={async () => {
|
||||
if (!selectedSavedView?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavedViewLoading(true);
|
||||
|
||||
try {
|
||||
await ModelAPI.updateById({
|
||||
modelType: LogSavedView,
|
||||
id: selectedSavedView.id,
|
||||
data: JSONFunctions.serialize({
|
||||
query: filterOptions,
|
||||
columns: selectedColumns,
|
||||
sortField: sortField,
|
||||
sortOrder: sortOrder,
|
||||
pageSize: pageSize,
|
||||
} as JSONObject) as JSONObject,
|
||||
});
|
||||
|
||||
await fetchSavedViews();
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSavedViewLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ComponentProps {
|
||||
data: MetricAliasData;
|
||||
isFormula: boolean;
|
||||
onDataChanged: (data: MetricAliasData) => void;
|
||||
hideVariableBadge?: boolean | undefined;
|
||||
}
|
||||
|
||||
const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
@@ -15,45 +16,70 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
): ReactElement => {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="flex space-x-3">
|
||||
{!props.isFormula && (
|
||||
<div className="bg-indigo-500 h-9 rounded w-9 p-3 pt-2 mt-2 font-medium text-white">
|
||||
{props.data.metricVariable}
|
||||
<div className="space-y-3">
|
||||
{/* Variable badge row — hidden when parent already shows it */}
|
||||
{!props.hideVariableBadge &&
|
||||
((!props.isFormula && props.data.metricVariable) ||
|
||||
props.isFormula) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!props.isFormula && props.data.metricVariable && (
|
||||
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-xs font-semibold text-white">
|
||||
{props.data.metricVariable}
|
||||
</div>
|
||||
)}
|
||||
{props.isFormula && (
|
||||
<div className="bg-indigo-500 h-7 w-7 min-w-7 rounded flex items-center justify-center text-white">
|
||||
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||
Display Settings
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and Description */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.title}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
title: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Chart title..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.isFormula && (
|
||||
<div className="bg-indigo-500 h-9 p-2 pt-2.5 rounded w-9 mt-2 font-bold text-white">
|
||||
<Icon thick={ThickProp.Thick} icon={IconProp.ChevronRight} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.description}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
description: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Chart description..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Input
|
||||
value={props.data.title}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
title: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Title..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Input
|
||||
value={props.data.description}
|
||||
onChange={(value: string) => {
|
||||
return props.onDataChanged({
|
||||
...props.data,
|
||||
metricVariable: props.data.metricVariable,
|
||||
description: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Description..."
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/3 flex space-x-3">
|
||||
<div className="w-full">
|
||||
|
||||
{/* Legend and Unit */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Legend
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.legend}
|
||||
onChange={(value: string) => {
|
||||
@@ -63,10 +89,13 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
legend: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Legend (e.g. Response Time)"
|
||||
placeholder="e.g. Response Time"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Unit
|
||||
</label>
|
||||
<Input
|
||||
value={props.data.legendUnit}
|
||||
onChange={(value: string) => {
|
||||
@@ -76,7 +105,7 @@ const MetricAlias: FunctionComponent<ComponentProps> = (
|
||||
legendUnit: value,
|
||||
});
|
||||
}}
|
||||
placeholder="Unit (e.g. ms)"
|
||||
placeholder="e.g. bytes, ms, %"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,10 @@ import OneUptimeDate from "Common/Types/Date";
|
||||
import XAxisType from "Common/UI/Components/Charts/Types/XAxis/XAxisType";
|
||||
import ChartGroup, {
|
||||
Chart,
|
||||
ChartMetricInfo,
|
||||
ChartType,
|
||||
} from "Common/UI/Components/Charts/ChartGroup/ChartGroup";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import { XAxisAggregateType } from "Common/UI/Components/Charts/Types/XAxis/XAxis";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
@@ -19,6 +21,8 @@ import YAxisType from "Common/UI/Components/Charts/Types/YAxis/YAxisType";
|
||||
import { YAxisPrecision } from "Common/UI/Components/Charts/Types/YAxis/YAxis";
|
||||
import ChartCurve from "Common/UI/Components/Charts/Types/ChartCurve";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import ChartReferenceLineProps from "Common/UI/Components/Charts/Types/ReferenceLineProps";
|
||||
import ValueFormatter from "Common/Utils/ValueFormatter";
|
||||
|
||||
export interface ComponentProps {
|
||||
metricViewData: MetricViewData;
|
||||
@@ -39,7 +43,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
props.metricViewData.startAndEndDate?.startValue &&
|
||||
props.metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
// if these are less than a day then we can use time
|
||||
const hourDifference: number = OneUptimeDate.getHoursBetweenTwoDates(
|
||||
props.metricViewData.startAndEndDate.startValue as Date,
|
||||
props.metricViewData.startAndEndDate.endValue as Date,
|
||||
@@ -113,10 +116,6 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
const series: ChartSeries = queryConfig.getSeries(item);
|
||||
const seriesName: string = series.title;
|
||||
|
||||
//check if the series already exists if it does then add the data to the existing series
|
||||
|
||||
// if it does not exist then create a new series and add the data to it
|
||||
|
||||
const existingSeries: SeriesPoint | undefined = chartSeries.find(
|
||||
(s: SeriesPoint) => {
|
||||
return s.seriesName === seriesName;
|
||||
@@ -159,11 +158,79 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}
|
||||
|
||||
// Determine chart type - use BAR for bar chart type, LINE for everything else
|
||||
const chartType: ChartType =
|
||||
queryConfig.chartType === MetricChartType.BAR
|
||||
? ChartType.BAR
|
||||
: ChartType.LINE;
|
||||
let chartType: ChartType;
|
||||
if (queryConfig.chartType === MetricChartType.BAR) {
|
||||
chartType = ChartType.BAR;
|
||||
} else if (queryConfig.chartType === MetricChartType.AREA) {
|
||||
chartType = ChartType.AREA;
|
||||
} else if (queryConfig.chartType === MetricChartType.LINE) {
|
||||
chartType = ChartType.LINE;
|
||||
} else {
|
||||
chartType = ChartType.AREA;
|
||||
}
|
||||
|
||||
// Resolve the unit for formatting
|
||||
const metricType: MetricType | undefined = props.metricTypes.find(
|
||||
(m: MetricType) => {
|
||||
return m.name === queryConfig.metricQueryData.filterData.metricName;
|
||||
},
|
||||
);
|
||||
const unit: string =
|
||||
queryConfig.metricAliasData?.legendUnit || metricType?.unit || "";
|
||||
|
||||
// Build reference lines from thresholds
|
||||
const referenceLines: Array<ChartReferenceLineProps> = [];
|
||||
|
||||
if (
|
||||
queryConfig.warningThreshold !== undefined &&
|
||||
queryConfig.warningThreshold !== null
|
||||
) {
|
||||
referenceLines.push({
|
||||
value: queryConfig.warningThreshold,
|
||||
label: `Warning: ${ValueFormatter.formatValue(queryConfig.warningThreshold, unit)}`,
|
||||
color: "#f59e0b", // amber
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
queryConfig.criticalThreshold !== undefined &&
|
||||
queryConfig.criticalThreshold !== null
|
||||
) {
|
||||
referenceLines.push({
|
||||
value: queryConfig.criticalThreshold,
|
||||
label: `Critical: ${ValueFormatter.formatValue(queryConfig.criticalThreshold, unit)}`,
|
||||
color: "#ef4444", // red
|
||||
});
|
||||
}
|
||||
|
||||
// Build metric info for the info icon modal
|
||||
const metricAttributes: Dictionary<string> = {};
|
||||
const filterAttributes:
|
||||
| Dictionary<string | boolean | number>
|
||||
| undefined = queryConfig.metricQueryData.filterData.attributes as
|
||||
| Dictionary<string | boolean | number>
|
||||
| undefined;
|
||||
|
||||
if (filterAttributes) {
|
||||
for (const key of Object.keys(filterAttributes)) {
|
||||
metricAttributes[key] = String(filterAttributes[key]);
|
||||
}
|
||||
}
|
||||
|
||||
const metricInfo: ChartMetricInfo = {
|
||||
metricName:
|
||||
queryConfig.metricQueryData.filterData.metricName?.toString() || "",
|
||||
aggregationType:
|
||||
queryConfig.metricQueryData.filterData.aggegationType?.toString() ||
|
||||
"",
|
||||
attributes:
|
||||
Object.keys(metricAttributes).length > 0
|
||||
? metricAttributes
|
||||
: undefined,
|
||||
groupByAttribute:
|
||||
queryConfig.metricQueryData.filterData.groupByAttribute?.toString(),
|
||||
unit,
|
||||
};
|
||||
|
||||
const chart: Chart = {
|
||||
id: index.toString(),
|
||||
@@ -173,6 +240,7 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
queryConfig.metricQueryData.filterData.metricName?.toString() ||
|
||||
"",
|
||||
description: queryConfig.metricAliasData?.description || "",
|
||||
metricInfo,
|
||||
props: {
|
||||
data: chartSeries,
|
||||
xAxis: {
|
||||
@@ -192,28 +260,25 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
// legend is the unit of the metric
|
||||
legend: queryConfig.metricAliasData?.legendUnit || "",
|
||||
legend: unit,
|
||||
options: {
|
||||
type: YAxisType.Number,
|
||||
formatter: (value: number) => {
|
||||
const metricType: MetricType | undefined =
|
||||
props.metricTypes.find((m: MetricType) => {
|
||||
return (
|
||||
m.name ===
|
||||
queryConfig.metricQueryData.filterData.metricName
|
||||
);
|
||||
});
|
||||
if (queryConfig.yAxisValueFormatter) {
|
||||
return queryConfig.yAxisValueFormatter(value);
|
||||
}
|
||||
|
||||
return `${value} ${queryConfig.metricAliasData?.legendUnit || metricType?.unit || ""}`;
|
||||
return ValueFormatter.formatValue(value, unit);
|
||||
},
|
||||
precision: YAxisPrecision.NoDecimals,
|
||||
max: "auto",
|
||||
min: "auto",
|
||||
},
|
||||
},
|
||||
curve: ChartCurve.LINEAR,
|
||||
curve: ChartCurve.MONOTONE,
|
||||
sync: true,
|
||||
referenceLines:
|
||||
referenceLines.length > 0 ? referenceLines : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
import MetricAlias from "./MetricAlias";
|
||||
import MetricQuery from "./MetricQuery";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import Button, {
|
||||
ButtonSize,
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricAliasData from "Common/Types/Metrics/MetricAliasData";
|
||||
import MetricQueryData from "Common/Types/Metrics/MetricQueryData";
|
||||
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import Input, { InputType } from "Common/UI/Components/Input/Input";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
|
||||
export interface ComponentProps {
|
||||
data: MetricQueryConfigData;
|
||||
@@ -34,56 +33,372 @@ export interface ComponentProps {
|
||||
const MetricGraphConfig: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const getContent: GetReactElementFunction = (): ReactElement => {
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
const [showDisplaySettings, setShowDisplaySettings] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const defaultAliasData: MetricAliasData = {
|
||||
metricVariable: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
legend: undefined,
|
||||
legendUnit: undefined,
|
||||
};
|
||||
|
||||
// Compute active attribute count for the header summary
|
||||
const attributes: Dictionary<string | number | boolean> | undefined = (
|
||||
props.data?.metricQueryData?.filterData as Record<string, unknown>
|
||||
)?.["attributes"] as Dictionary<string | number | boolean> | undefined;
|
||||
|
||||
const activeAttributeCount: number = attributes
|
||||
? Object.keys(attributes).length
|
||||
: 0;
|
||||
|
||||
const metricName: string =
|
||||
props.data?.metricQueryData?.filterData?.metricName?.toString() ||
|
||||
"No metric selected";
|
||||
|
||||
const aggregationType: string =
|
||||
props.data?.metricQueryData?.filterData?.aggegationType?.toString() ||
|
||||
"Avg";
|
||||
|
||||
// Remove a single attribute filter
|
||||
const handleRemoveAttribute: (key: string) => void = (key: string): void => {
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttributes: Dictionary<string | number | boolean> = {
|
||||
...attributes,
|
||||
};
|
||||
delete newAttributes[key];
|
||||
|
||||
const newFilterData: Record<string, unknown> = {
|
||||
...(props.data.metricQueryData.filterData as Record<string, unknown>),
|
||||
};
|
||||
|
||||
if (Object.keys(newAttributes).length > 0) {
|
||||
newFilterData["attributes"] = newAttributes;
|
||||
} else {
|
||||
delete newFilterData["attributes"];
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricQueryData: {
|
||||
...props.data.metricQueryData,
|
||||
filterData: newFilterData as MetricQueryData["filterData"],
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all attribute filters
|
||||
const handleClearAllAttributes: () => void = (): void => {
|
||||
const newFilterData: Record<string, unknown> = {
|
||||
...(props.data.metricQueryData.filterData as Record<string, unknown>),
|
||||
};
|
||||
delete newFilterData["attributes"];
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricQueryData: {
|
||||
...props.data.metricQueryData,
|
||||
filterData: newFilterData as MetricQueryData["filterData"],
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getHeader: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
{props.data?.metricAliasData && (
|
||||
<MetricAlias
|
||||
data={props.data?.metricAliasData || {}}
|
||||
onDataChanged={(data: MetricAliasData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({ ...props.data, metricAliasData: data });
|
||||
}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Variable badge */}
|
||||
{props.data?.metricAliasData?.metricVariable && (
|
||||
<div className="bg-indigo-500 h-8 w-8 min-w-8 rounded-lg flex items-center justify-center text-sm font-bold text-white shadow-sm">
|
||||
{props.data.metricAliasData.metricVariable}
|
||||
</div>
|
||||
)}
|
||||
{/* Summary info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-gray-900 truncate">
|
||||
{metricName}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
|
||||
{aggregationType}
|
||||
</span>
|
||||
{activeAttributeCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-xs font-medium text-indigo-700">
|
||||
<Icon
|
||||
icon={IconProp.Filter}
|
||||
className="h-3 w-3 text-indigo-500"
|
||||
/>
|
||||
{activeAttributeCount}{" "}
|
||||
{activeAttributeCount === 1 ? "filter" : "filters"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.data?.metricAliasData?.title &&
|
||||
props.data.metricAliasData.title !== metricName && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{props.data.metricAliasData.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
isFormula={false}
|
||||
/>
|
||||
)}
|
||||
{props.data?.metricQueryData && (
|
||||
<MetricQuery
|
||||
data={props.data?.metricQueryData || {}}
|
||||
onDataChanged={(data: MetricQueryData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({ ...props.data, metricQueryData: data });
|
||||
}
|
||||
}}
|
||||
metricTypes={props.metricTypes}
|
||||
telemetryAttributes={props.telemetryAttributes}
|
||||
onAdvancedFiltersToggle={props.onAdvancedFiltersToggle}
|
||||
isAttributesLoading={props.attributesLoading}
|
||||
attributesError={props.attributesError}
|
||||
onAttributesRetry={props.onAttributesRetry}
|
||||
/>
|
||||
)}
|
||||
{props.onRemove && (
|
||||
<div className="-ml-3">
|
||||
<Button
|
||||
title={"Remove"}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
<Icon
|
||||
icon={isExpanded ? IconProp.ChevronUp : IconProp.ChevronDown}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
{props.onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-gray-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
onClick={() => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
return props.onRemove?.();
|
||||
}}
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
/>
|
||||
title="Remove query"
|
||||
>
|
||||
<Icon icon={IconProp.Trash} className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributeChips: () => ReactElement | null =
|
||||
(): ReactElement | null => {
|
||||
if (!attributes || activeAttributeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400 font-medium mr-1">
|
||||
Filtered by:
|
||||
</span>
|
||||
{Object.entries(attributes).map(
|
||||
([key, value]: [string, string | number | boolean]) => {
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-indigo-200 bg-indigo-50 py-0.5 pl-2 pr-1 text-xs text-indigo-700"
|
||||
>
|
||||
<span className="font-medium text-indigo-500">{key}:</span>
|
||||
<span>{String(value)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded text-indigo-400 transition-colors hover:bg-indigo-100 hover:text-indigo-600"
|
||||
onClick={() => {
|
||||
handleRemoveAttribute(key);
|
||||
}}
|
||||
title={`Remove ${key}: ${String(value)}`}
|
||||
>
|
||||
<Icon icon={IconProp.Close} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{activeAttributeCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[11px] font-medium text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={handleClearAllAttributes}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getContent: () => ReactElement = (): ReactElement => {
|
||||
return (
|
||||
<div>
|
||||
{/* Header with summary */}
|
||||
{getHeader()}
|
||||
|
||||
{/* Attribute filter chips - always visible */}
|
||||
{!isExpanded && getAttributeChips()}
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Metric query selection */}
|
||||
{props.data?.metricQueryData && (
|
||||
<MetricQuery
|
||||
data={props.data?.metricQueryData || {}}
|
||||
onDataChanged={(data: MetricQueryData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
const selectedMetricName: string | undefined =
|
||||
data.filterData?.metricName?.toString();
|
||||
const previousMetricName: string | undefined =
|
||||
props.data?.metricQueryData?.filterData?.metricName?.toString();
|
||||
|
||||
// If metric changed, prefill all alias fields from MetricType
|
||||
if (
|
||||
selectedMetricName &&
|
||||
selectedMetricName !== previousMetricName
|
||||
) {
|
||||
const metricType: MetricType | undefined =
|
||||
props.metricTypes.find((m: MetricType) => {
|
||||
return m.name === selectedMetricName;
|
||||
});
|
||||
|
||||
if (metricType) {
|
||||
const currentAlias: MetricAliasData =
|
||||
props.data.metricAliasData || defaultAliasData;
|
||||
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricQueryData: data,
|
||||
metricAliasData: {
|
||||
...currentAlias,
|
||||
title: metricType.name || "",
|
||||
description: metricType.description || "",
|
||||
legend: metricType.name || "",
|
||||
legendUnit: metricType.unit || "",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
props.onChange({ ...props.data, metricQueryData: data });
|
||||
}
|
||||
}}
|
||||
metricTypes={props.metricTypes}
|
||||
telemetryAttributes={props.telemetryAttributes}
|
||||
onAdvancedFiltersToggle={props.onAdvancedFiltersToggle}
|
||||
isAttributesLoading={props.attributesLoading}
|
||||
attributesError={props.attributesError}
|
||||
onAttributesRetry={props.onAttributesRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attribute filter chips */}
|
||||
{getAttributeChips()}
|
||||
|
||||
{/* Display Settings - collapsible */}
|
||||
<div className="border-t border-gray-200 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wide hover:text-gray-700 transition-colors w-full"
|
||||
onClick={() => {
|
||||
setShowDisplaySettings(!showDisplaySettings);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
showDisplaySettings
|
||||
? IconProp.ChevronDown
|
||||
: IconProp.ChevronRight
|
||||
}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span>Display Settings</span>
|
||||
{(props.data?.metricAliasData?.title ||
|
||||
props.data?.warningThreshold !== undefined ||
|
||||
props.data?.criticalThreshold !== undefined) && (
|
||||
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-indigo-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDisplaySettings && (
|
||||
<div className="mt-3 space-y-4">
|
||||
<MetricAlias
|
||||
data={props.data?.metricAliasData || defaultAliasData}
|
||||
onDataChanged={(data: MetricAliasData) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
metricAliasData: data,
|
||||
});
|
||||
}
|
||||
}}
|
||||
isFormula={false}
|
||||
hideVariableBadge={true}
|
||||
/>
|
||||
|
||||
{/* Thresholds */}
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Warning Threshold
|
||||
</label>
|
||||
<Input
|
||||
value={props.data?.warningThreshold?.toString() || ""}
|
||||
type={InputType.NUMBER}
|
||||
onChange={(value: string) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
warningThreshold: value
|
||||
? Number(value)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 80"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
Critical Threshold
|
||||
</label>
|
||||
<Input
|
||||
value={props.data?.criticalThreshold?.toString() || ""}
|
||||
type={InputType.NUMBER}
|
||||
onChange={(value: string) => {
|
||||
props.onBlur?.();
|
||||
props.onFocus?.();
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
criticalThreshold: value
|
||||
? Number(value)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. 95"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.error && (
|
||||
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
|
||||
<p data-testid="error-message" className="mt-3 text-sm text-red-400">
|
||||
{props.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -12,13 +12,11 @@ import Button, {
|
||||
ButtonStyleType,
|
||||
} from "Common/UI/Components/Button/Button";
|
||||
import Text from "Common/Types/Text";
|
||||
import HorizontalRule from "Common/UI/Components/HorizontalRule/HorizontalRule";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
import StartAndEndDate, {
|
||||
StartAndEndDateType,
|
||||
} from "Common/UI/Components/Date/StartAndEndDate";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
@@ -34,6 +32,7 @@ import MetricCharts from "./MetricCharts";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
const getFetchRelevantState: (data: MetricViewData) => unknown = (
|
||||
data: MetricViewData,
|
||||
@@ -305,29 +304,33 @@ const MetricView: FunctionComponent<ComponentProps> = (
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{/* Time range selector */}
|
||||
{!props.hideStartAndEndDate && (
|
||||
<div className="mb-5">
|
||||
<Card>
|
||||
<div className="-mt-5">
|
||||
<FieldLabelElement title="Start and End Time" required={true} />
|
||||
<StartAndEndDate
|
||||
type={StartAndEndDateType.DateTime}
|
||||
value={props.data.startAndEndDate || undefined}
|
||||
onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
startAndEndDate: startAndEndDate,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Card>
|
||||
<div className="-mt-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Time Range
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<StartAndEndDate
|
||||
type={StartAndEndDateType.DateTime}
|
||||
value={props.data.startAndEndDate || undefined}
|
||||
onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
startAndEndDate: startAndEndDate,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Query configs */}
|
||||
{!props.hideQueryElements && (
|
||||
<div className="space-y-3">
|
||||
{props.data.queryConfigs.map(
|
||||
@@ -382,100 +385,89 @@ const MetricView: FunctionComponent<ComponentProps> = (
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!props.hideQueryElements && (
|
||||
<div className="space-y-3">
|
||||
{/* Formula configs and Add buttons */}
|
||||
{!props.hideQueryElements && (
|
||||
<div className="space-y-3">
|
||||
{props.data.formulaConfigs.map(
|
||||
(formulaConfig: MetricFormulaConfigData, index: number) => {
|
||||
return (
|
||||
<MetricGraphConfig
|
||||
key={index}
|
||||
onDataChanged={(data: MetricFormulaConfigData) => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> = [
|
||||
...props.data.formulaConfigs,
|
||||
];
|
||||
newGraphConfigs[index] = data;
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
data={formulaConfig}
|
||||
onRemove={() => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> = [
|
||||
...props.data.formulaConfigs,
|
||||
];
|
||||
newGraphConfigs.splice(index, 1);
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex -ml-3 mt-8 justify-between w-full">
|
||||
<div>
|
||||
<Button
|
||||
title="Add Metric"
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
queryConfigs: [
|
||||
...props.data.queryConfigs,
|
||||
getEmptyQueryConfigData(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* <Button
|
||||
title="Add Formula"
|
||||
buttonSize={ButtonSize.Small}
|
||||
onClick={() => {
|
||||
setMetricViewData({
|
||||
...metricViewData,
|
||||
formulaConfigs: [
|
||||
...metricViewData.formulaConfigs,
|
||||
getEmptyFormulaConfigData(),
|
||||
],
|
||||
});
|
||||
}}
|
||||
/> */}
|
||||
{props.data.formulaConfigs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{props.data.formulaConfigs.map(
|
||||
(formulaConfig: MetricFormulaConfigData, index: number) => {
|
||||
return (
|
||||
<MetricGraphConfig
|
||||
key={index}
|
||||
onDataChanged={(data: MetricFormulaConfigData) => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> =
|
||||
[...props.data.formulaConfigs];
|
||||
newGraphConfigs[index] = data;
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
data={formulaConfig}
|
||||
onRemove={() => {
|
||||
const newGraphConfigs: Array<MetricFormulaConfigData> =
|
||||
[...props.data.formulaConfigs];
|
||||
newGraphConfigs.splice(index, 1);
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
formulaConfigs: newGraphConfigs,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add metric button */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
title="Add Metric"
|
||||
buttonSize={ButtonSize.Small}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
if (props.onChange) {
|
||||
props.onChange({
|
||||
...props.data,
|
||||
queryConfigs: [
|
||||
...props.data.queryConfigs,
|
||||
getEmptyQueryConfigData(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalRule />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{isMetricResultsLoading && <ComponentLoader />}
|
||||
{/* Chart results */}
|
||||
{isMetricResultsLoading && <ComponentLoader />}
|
||||
|
||||
{metricResultsError && <ErrorMessage message={metricResultsError} />}
|
||||
{metricResultsError && <ErrorMessage message={metricResultsError} />}
|
||||
|
||||
{!isMetricResultsLoading && !metricResultsError && (
|
||||
<div className="grid grid-cols-1 gap-4 mt-3">
|
||||
{/** charts */}
|
||||
<MetricCharts
|
||||
hideCard={props.hideCardInCharts}
|
||||
metricResults={metricResults}
|
||||
metricTypes={metricTypes}
|
||||
metricViewData={props.data}
|
||||
chartCssClass={props.chartCssClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isMetricResultsLoading && !metricResultsError && (
|
||||
<div
|
||||
className={props.hideCardInCharts ? "" : "grid grid-cols-1 gap-4"}
|
||||
>
|
||||
<MetricCharts
|
||||
hideCard={props.hideCardInCharts}
|
||||
metricResults={metricResults}
|
||||
metricTypes={metricTypes}
|
||||
metricViewData={props.data}
|
||||
chartCssClass={props.chartCssClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCannotRemoveOneRemainingQueryError ? (
|
||||
<ConfirmModal
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceMetricSummary {
|
||||
service: Service;
|
||||
metricCount: number;
|
||||
metricNames: Array<string>;
|
||||
metricUnits: Array<string>;
|
||||
metricDescriptions: Array<string>;
|
||||
hasSystemMetrics: boolean;
|
||||
hasAppMetrics: boolean;
|
||||
}
|
||||
|
||||
interface MetricCategory {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceMetricSummary>
|
||||
>([]);
|
||||
const [totalMetricCount, setTotalMetricCount] = useState<number>(0);
|
||||
const [metricCategories, setMetricCategories] = useState<
|
||||
Array<MetricCategory>
|
||||
>([]);
|
||||
const [servicesWithNoMetrics, setServicesWithNoMetrics] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const categorizeMetric: (name: string) => string = (name: string): string => {
|
||||
const lower: string = name.toLowerCase();
|
||||
if (
|
||||
lower.includes("cpu") ||
|
||||
lower.includes("memory") ||
|
||||
lower.includes("disk") ||
|
||||
lower.includes("network") ||
|
||||
lower.includes("system") ||
|
||||
lower.includes("process") ||
|
||||
lower.includes("runtime") ||
|
||||
lower.includes("gc")
|
||||
) {
|
||||
return "System";
|
||||
}
|
||||
if (
|
||||
lower.includes("http") ||
|
||||
lower.includes("request") ||
|
||||
lower.includes("response") ||
|
||||
lower.includes("latency") ||
|
||||
lower.includes("duration") ||
|
||||
lower.includes("rpc")
|
||||
) {
|
||||
return "Request";
|
||||
}
|
||||
if (
|
||||
lower.includes("db") ||
|
||||
lower.includes("database") ||
|
||||
lower.includes("query") ||
|
||||
lower.includes("connection") ||
|
||||
lower.includes("pool")
|
||||
) {
|
||||
return "Database";
|
||||
}
|
||||
if (
|
||||
lower.includes("queue") ||
|
||||
lower.includes("message") ||
|
||||
lower.includes("kafka") ||
|
||||
lower.includes("rabbit") ||
|
||||
lower.includes("publish") ||
|
||||
lower.includes("consume")
|
||||
) {
|
||||
return "Messaging";
|
||||
}
|
||||
return "Custom";
|
||||
};
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
// Load services and metrics in parallel
|
||||
const [servicesResult, metricsResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: MetricType,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
unit: true,
|
||||
description: true,
|
||||
services: {
|
||||
_id: true,
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const services: Array<Service> = servicesResult.data || [];
|
||||
const metrics: Array<MetricType> = metricsResult.data || [];
|
||||
setTotalMetricCount(metrics.length);
|
||||
|
||||
// Build category counts
|
||||
const categoryMap: Map<string, number> = new Map();
|
||||
for (const metric of metrics) {
|
||||
const cat: string = categorizeMetric(metric.name || "");
|
||||
categoryMap.set(cat, (categoryMap.get(cat) || 0) + 1);
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, { color: string; bgColor: string }> =
|
||||
{
|
||||
System: { color: "text-blue-700", bgColor: "bg-blue-50" },
|
||||
Request: { color: "text-purple-700", bgColor: "bg-purple-50" },
|
||||
Database: { color: "text-amber-700", bgColor: "bg-amber-50" },
|
||||
Messaging: { color: "text-green-700", bgColor: "bg-green-50" },
|
||||
Custom: { color: "text-gray-700", bgColor: "bg-gray-50" },
|
||||
};
|
||||
|
||||
const categories: Array<MetricCategory> = Array.from(
|
||||
categoryMap.entries(),
|
||||
)
|
||||
.map(([name, count]: [string, number]) => {
|
||||
return {
|
||||
name,
|
||||
count,
|
||||
color: categoryColors[name]?.color || "text-gray-700",
|
||||
bgColor: categoryColors[name]?.bgColor || "bg-gray-50",
|
||||
};
|
||||
})
|
||||
.sort((a: MetricCategory, b: MetricCategory) => {
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setMetricCategories(categories);
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceMetricSummary> = new Map();
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
summaryMap.set(serviceId, {
|
||||
service,
|
||||
metricCount: 0,
|
||||
metricNames: [],
|
||||
metricUnits: [],
|
||||
metricDescriptions: [],
|
||||
hasSystemMetrics: false,
|
||||
hasAppMetrics: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const metric of metrics) {
|
||||
const metricServices: Array<Service> = metric.services || [];
|
||||
const cat: string = categorizeMetric(metric.name || "");
|
||||
|
||||
for (const metricService of metricServices) {
|
||||
const serviceId: string =
|
||||
metricService._id?.toString() || metricService.id?.toString() || "";
|
||||
let summary: ServiceMetricSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (!summary) {
|
||||
summary = {
|
||||
service: metricService,
|
||||
metricCount: 0,
|
||||
metricNames: [],
|
||||
metricUnits: [],
|
||||
metricDescriptions: [],
|
||||
hasSystemMetrics: false,
|
||||
hasAppMetrics: false,
|
||||
};
|
||||
summaryMap.set(serviceId, summary);
|
||||
}
|
||||
|
||||
summary.metricCount += 1;
|
||||
|
||||
if (cat === "System") {
|
||||
summary.hasSystemMetrics = true;
|
||||
} else {
|
||||
summary.hasAppMetrics = true;
|
||||
}
|
||||
|
||||
const metricName: string = metric.name || "";
|
||||
if (metricName && summary.metricNames.length < 6) {
|
||||
summary.metricNames.push(metricName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summariesWithData: Array<ServiceMetricSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceMetricSummary) => {
|
||||
return s.metricCount > 0;
|
||||
});
|
||||
|
||||
const noMetricsCount: number = services.length - summariesWithData.length;
|
||||
setServicesWithNoMetrics(noMetricsCount);
|
||||
|
||||
// Sort by metric count descending
|
||||
summariesWithData.sort(
|
||||
(a: ServiceMetricSummary, b: ServiceMetricSummary) => {
|
||||
return b.metricCount - a.metricCount;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No metrics data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending metrics via OpenTelemetry, you{"'"}ll
|
||||
see coverage, categories, and per-service breakdowns here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxMetrics: number = Math.max(
|
||||
...serviceSummaries.map((s: ServiceMetricSummary) => {
|
||||
return s.metricCount;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Total Metrics</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-indigo-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalMetricCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">unique metric types</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Services Reporting
|
||||
</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">actively sending data</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Avg per Service</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length > 0
|
||||
? Math.round(totalMetricCount / serviceSummaries.length)
|
||||
: 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">metrics per service</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">No Metrics</p>
|
||||
<div
|
||||
className={`h-9 w-9 rounded-lg flex items-center justify-center ${servicesWithNoMetrics > 0 ? "bg-amber-50" : "bg-gray-50"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-4.5 w-4.5 ${servicesWithNoMetrics > 0 ? "text-amber-600" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-2 ${servicesWithNoMetrics > 0 ? "text-amber-600" : "text-gray-900"}`}
|
||||
>
|
||||
{servicesWithNoMetrics}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{servicesWithNoMetrics > 0
|
||||
? "services not instrumented"
|
||||
: "all services covered"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Categories */}
|
||||
{metricCategories.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Metric Categories
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{metricCategories.map((cat: MetricCategory) => {
|
||||
const pct: number =
|
||||
totalMetricCount > 0
|
||||
? Math.round((cat.count / totalMetricCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2 rounded-lg ${cat.bgColor}`}
|
||||
>
|
||||
<span className={`text-sm font-semibold ${cat.color}`}>
|
||||
{cat.count}
|
||||
</span>
|
||||
<span className={`text-sm ${cat.color}`}>{cat.name}</span>
|
||||
<span className={`text-xs ${cat.color} opacity-60`}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Category distribution bar */}
|
||||
<div className="flex h-2 rounded-full overflow-hidden mt-3">
|
||||
{metricCategories.map((cat: MetricCategory) => {
|
||||
const pct: number =
|
||||
totalMetricCount > 0 ? (cat.count / totalMetricCount) * 100 : 0;
|
||||
const barColorMap: Record<string, string> = {
|
||||
System: "bg-blue-400",
|
||||
Request: "bg-purple-400",
|
||||
Database: "bg-amber-400",
|
||||
Messaging: "bg-green-400",
|
||||
Custom: "bg-gray-300",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`${barColorMap[cat.name] || "bg-gray-300"}`}
|
||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Services Reporting Metrics
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Coverage and instrumentation per service
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.METRICS_LIST] as Route,
|
||||
)}
|
||||
>
|
||||
View all metrics
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceMetricSummary) => {
|
||||
const coverage: number =
|
||||
maxMetrics > 0
|
||||
? Math.round((summary.metricCount / maxMetrics) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={
|
||||
summary.service.id?.toString() ||
|
||||
summary.service._id?.toString()
|
||||
}
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_METRICS] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
(summary.service._id as string) ||
|
||||
summary.service.id?.toString() ||
|
||||
"",
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-indigo-200 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<ServiceElement service={summary.service} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{summary.hasSystemMetrics && (
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
{summary.hasAppMetrics && (
|
||||
<span className="text-xs bg-purple-50 text-purple-700 px-2 py-0.5 rounded-full font-medium">
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric count with relative bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-end justify-between mb-1.5">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{summary.metricCount}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mb-1">
|
||||
metrics
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
||||
style={{ width: `${Math.max(coverage, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric name tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.metricNames.map((name: string) => {
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-600 border border-gray-100"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{summary.metricCount > summary.metricNames.length && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-400">
|
||||
+{summary.metricCount - summary.metricNames.length} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricsDashboard;
|
||||
@@ -17,6 +17,9 @@ import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType"
|
||||
|
||||
export interface ComponentProps {
|
||||
serviceIds?: Array<ObjectID> | undefined;
|
||||
onFetchSuccess?:
|
||||
| ((data: Array<MetricType>, totalCount: number) => void)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const MetricsTable: FunctionComponent<ComponentProps> = (
|
||||
@@ -40,9 +43,9 @@ const MetricsTable: FunctionComponent<ComponentProps> = (
|
||||
sortBy="name"
|
||||
sortOrder={SortOrder.Ascending}
|
||||
cardProps={{
|
||||
title: "Metrics",
|
||||
title: "All Metrics",
|
||||
description:
|
||||
"Metrics are the individual data points that make up a service. They are the building blocks of a service and represent the work done by a single service.",
|
||||
"All metrics collected from your services. Click on a metric to explore its data in the chart viewer.",
|
||||
}}
|
||||
onViewPage={async (item: MetricType) => {
|
||||
const route: Route = RouteUtil.populateRouteParams(
|
||||
@@ -113,6 +116,7 @@ const MetricsTable: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
showViewIdButton={false}
|
||||
noItemsMessage={"No metrics found for this service."}
|
||||
onFetchSuccess={props.onFetchSuccess}
|
||||
showRefreshButton={true}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
filters={[
|
||||
|
||||
@@ -12,7 +12,6 @@ import URL from "Common/Types/API/URL";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
|
||||
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
@@ -36,7 +35,7 @@ export default class MetricUtil {
|
||||
time: metricViewData.startAndEndDate!,
|
||||
name: queryConfig.metricQueryData.filterData.metricName!,
|
||||
attributes: queryConfig.metricQueryData.filterData
|
||||
.attributes as Dictionary<string | number | boolean>,
|
||||
.attributes as any,
|
||||
},
|
||||
aggregationType:
|
||||
(queryConfig.metricQueryData.filterData
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import AlertMetricType from "Common/Types/Alerts/AlertMetricType";
|
||||
import AlertMetricTypeUtil from "Common/Utils/Alerts/AlertMetricType";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import MetricQueryConfigData, {
|
||||
MetricChartType,
|
||||
} from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
}
|
||||
|
||||
const MonitorAlertMetrics: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const alertMetricTypes: Array<AlertMetricType> =
|
||||
AlertMetricTypeUtil.getAllAlertMetricTypes();
|
||||
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
});
|
||||
|
||||
type GetQueryConfigsFunction = () => Array<MetricQueryConfigData>;
|
||||
|
||||
const getQueryConfigs: GetQueryConfigsFunction =
|
||||
(): Array<MetricQueryConfigData> => {
|
||||
const queries: Array<MetricQueryConfigData> = [];
|
||||
|
||||
for (const metricType of alertMetricTypes) {
|
||||
queries.push({
|
||||
metricAliasData: {
|
||||
metricVariable: metricType,
|
||||
title: AlertMetricTypeUtil.getTitleByAlertMetricType(metricType),
|
||||
description:
|
||||
AlertMetricTypeUtil.getDescriptionByAlertMetricType(metricType),
|
||||
legend: AlertMetricTypeUtil.getLegendByAlertMetricType(metricType),
|
||||
legendUnit:
|
||||
AlertMetricTypeUtil.getLegendUnitByAlertMetricType(metricType),
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {
|
||||
metricName: metricType,
|
||||
attributes: {
|
||||
monitorId: props.monitorId.toString(),
|
||||
projectId: ProjectUtil.getCurrentProjectId()?.toString() || "",
|
||||
},
|
||||
aggegationType:
|
||||
AlertMetricTypeUtil.getAggregationTypeByAlertMetricType(
|
||||
metricType,
|
||||
),
|
||||
},
|
||||
groupBy: undefined,
|
||||
},
|
||||
chartType: MetricChartType.BAR,
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
}),
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Alert Metrics"
|
||||
description="Alert metrics for this monitor - count, time to acknowledge, time to resolve, and duration."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorAlertMetrics;
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import EmptyState from "Common/UI/Components/EmptyState/EmptyState";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Metric from "Common/Models/AnalyticsModels/Metric";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import Search from "Common/Types/BaseDatabase/Search";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
}
|
||||
|
||||
const MonitorCustomMetrics: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [customMetricNames, setCustomMetricNames] = useState<Array<string>>([]);
|
||||
|
||||
const fetchCustomMetricNames: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
/*
|
||||
* Query ClickHouse for recent metrics belonging to this monitor
|
||||
* with names starting with "custom.monitor."
|
||||
* monitorId is stored as serviceId in the Metric table.
|
||||
*/
|
||||
const listResult: ListResult<Metric> =
|
||||
await AnalyticsModelAPI.getList<Metric>({
|
||||
modelType: Metric,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
serviceId: props.monitorId,
|
||||
name: new Search("custom.monitor.") as any,
|
||||
time: new InBetween(
|
||||
OneUptimeDate.addRemoveDays(
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
-30,
|
||||
),
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
) as any,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
// Extract distinct metric names
|
||||
const nameSet: Set<string> = new Set<string>();
|
||||
for (const metric of listResult.data) {
|
||||
const name: string = (metric as any).name || "";
|
||||
if (name.length > 0) {
|
||||
nameSet.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const names: Array<string> = Array.from(nameSet).sort();
|
||||
setCustomMetricNames(names);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomMetricNames().catch((err: Error) => {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
});
|
||||
|
||||
const getQueryConfigs: () => Array<MetricQueryConfigData> =
|
||||
(): Array<MetricQueryConfigData> => {
|
||||
return customMetricNames.map(
|
||||
(metricName: string): MetricQueryConfigData => {
|
||||
const displayName: string = metricName.replace("custom.monitor.", "");
|
||||
|
||||
return {
|
||||
metricAliasData: {
|
||||
metricVariable: metricName,
|
||||
title: displayName,
|
||||
description: `Custom metric: ${displayName}`,
|
||||
legend: displayName,
|
||||
legendUnit: "",
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {
|
||||
metricName: metricName,
|
||||
attributes: {
|
||||
monitorId: props.monitorId.toString(),
|
||||
projectId:
|
||||
ProjectUtil.getCurrentProjectId()?.toString() || "",
|
||||
},
|
||||
aggegationType: AggregationType.Avg,
|
||||
},
|
||||
groupBy: {
|
||||
attributes: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
}),
|
||||
queryConfigs: [],
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (customMetricNames.length > 0) {
|
||||
setMetricViewData({
|
||||
startAndEndDate:
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange),
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}
|
||||
}, [customMetricNames]);
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (customMetricNames.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
id="no-custom-metrics"
|
||||
icon={IconProp.ChartBar}
|
||||
title="No Custom Metrics"
|
||||
description="No custom metrics have been captured yet. Use oneuptime.captureMetric() in your monitor script to capture custom metrics."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Custom Metrics"
|
||||
description="Custom metrics captured from your monitor script using oneuptime.captureMetric()."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorCustomMetrics;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Search from "Common/Types/BaseDatabase/Search";
|
||||
import IncidentMetricType from "Common/Types/Incident/IncidentMetricType";
|
||||
import IncidentMetricTypeUtil from "Common/Utils/Incident/IncidentMetricType";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import MetricQueryConfigData, {
|
||||
MetricChartType,
|
||||
} from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
}
|
||||
|
||||
const MonitorIncidentMetrics: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const incidentMetricTypes: Array<IncidentMetricType> =
|
||||
IncidentMetricTypeUtil.getAllIncidentMetricTypes();
|
||||
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
});
|
||||
|
||||
type GetQueryConfigsFunction = () => Array<MetricQueryConfigData>;
|
||||
|
||||
const getQueryConfigs: GetQueryConfigsFunction =
|
||||
(): Array<MetricQueryConfigData> => {
|
||||
const queries: Array<MetricQueryConfigData> = [];
|
||||
|
||||
for (const metricType of incidentMetricTypes) {
|
||||
queries.push({
|
||||
metricAliasData: {
|
||||
metricVariable: metricType,
|
||||
title:
|
||||
IncidentMetricTypeUtil.getTitleByIncidentMetricType(metricType),
|
||||
description:
|
||||
IncidentMetricTypeUtil.getDescriptionByIncidentMetricType(
|
||||
metricType,
|
||||
),
|
||||
legend:
|
||||
IncidentMetricTypeUtil.getLegendByIncidentMetricType(metricType),
|
||||
legendUnit:
|
||||
IncidentMetricTypeUtil.getLegendUnitByIncidentMetricType(
|
||||
metricType,
|
||||
),
|
||||
},
|
||||
metricQueryData: {
|
||||
filterData: {
|
||||
metricName: metricType,
|
||||
attributes: {
|
||||
monitorIds: new Search(props.monitorId.toString()),
|
||||
projectId: ProjectUtil.getCurrentProjectId()?.toString() || "",
|
||||
},
|
||||
aggegationType:
|
||||
IncidentMetricTypeUtil.getAggregationTypeByIncidentMetricType(
|
||||
metricType,
|
||||
),
|
||||
},
|
||||
groupBy: undefined,
|
||||
},
|
||||
chartType: MetricChartType.BAR,
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_DAY,
|
||||
}),
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Incident Metrics"
|
||||
description="Incident metrics for this monitor - count, time to acknowledge, time to resolve, and duration."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
queryConfigs: getQueryConfigs(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitorIncidentMetrics;
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import MonitorMetricTypeUtil from "Common/Utils/Monitor/MonitorMetricType";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import MetricView from "../Metrics/MetricView";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import MonitorMetricType from "Common/Types/Monitor/MonitorMetricType";
|
||||
@@ -29,6 +28,13 @@ import MetricQueryConfigData, {
|
||||
ChartSeries,
|
||||
} from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import RangeStartAndEndDateTime, {
|
||||
RangeStartAndEndDateTimeUtil,
|
||||
} from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
|
||||
export interface ComponentProps {
|
||||
monitorId: ObjectID;
|
||||
@@ -85,11 +91,9 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
|
||||
const monitorMetricTypesByMonitor: Array<MonitorMetricType> =
|
||||
MonitorMetricTypeUtil.getMonitorMetricTypesByMonitorType(monitorType);
|
||||
|
||||
// set it to past 1 hour
|
||||
const endDate: Date = OneUptimeDate.getCurrentDate();
|
||||
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -1);
|
||||
|
||||
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
|
||||
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
});
|
||||
|
||||
type GetQueryConfigByMonitorMetricTypesFunction =
|
||||
() => Array<MetricQueryConfigData>;
|
||||
@@ -269,11 +273,27 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
|
||||
startAndEndDate: startAndEndDate,
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
}),
|
||||
queryConfigs: getQueryConfigByMonitorMetricTypes(),
|
||||
formulaConfigs: [],
|
||||
});
|
||||
|
||||
const handleTimeRangeChange: (
|
||||
newTimeRange: RangeStartAndEndDateTime,
|
||||
) => void = useCallback((newTimeRange: RangeStartAndEndDateTime): void => {
|
||||
setTimeRange(newTimeRange);
|
||||
const dateRange: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
|
||||
setMetricViewData((prev: MetricViewData) => {
|
||||
return {
|
||||
...prev,
|
||||
startAndEndDate: dateRange,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
@@ -287,10 +307,21 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title="Monitor Metrics"
|
||||
description="Performance metrics collected from this monitor."
|
||||
rightElement={
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MetricView
|
||||
data={metricViewData}
|
||||
hideQueryElements={true}
|
||||
hideStartAndEndDate={true}
|
||||
hideCardInCharts={true}
|
||||
onChange={(data: MetricViewData) => {
|
||||
setMetricViewData({
|
||||
...data,
|
||||
@@ -299,7 +330,7 @@ const MonitorMetricsElement: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
{
|
||||
title: "Metrics",
|
||||
description: "Monitor system metrics.",
|
||||
description: "Monitor and visualize system metrics across your services.",
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.METRICS] as Route),
|
||||
activeRoute: RouteMap[PageMap.METRICS],
|
||||
icon: IconProp.Heartbeat,
|
||||
@@ -117,16 +117,25 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
{
|
||||
title: "Traces",
|
||||
description: "Distributed tracing analysis.",
|
||||
description: "Track requests across your services.",
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.TRACES] as Route),
|
||||
activeRoute: RouteMap[PageMap.TRACES],
|
||||
icon: IconProp.Waterfall,
|
||||
iconColor: "yellow",
|
||||
category: "Observability",
|
||||
},
|
||||
{
|
||||
title: "Performance Profiles",
|
||||
description: "Find slow functions and memory hotspots.",
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.PROFILES] as Route),
|
||||
activeRoute: RouteMap[PageMap.PROFILES],
|
||||
icon: IconProp.Fire,
|
||||
iconColor: "red",
|
||||
category: "Observability",
|
||||
},
|
||||
{
|
||||
title: "Exceptions",
|
||||
description: "Catch and fix bugs early.",
|
||||
description: "Track and resolve bugs across your services.",
|
||||
route: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS] as Route,
|
||||
),
|
||||
@@ -144,6 +153,17 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
|
||||
iconColor: "indigo",
|
||||
category: "Observability",
|
||||
},
|
||||
{
|
||||
title: "Kubernetes",
|
||||
description: "Monitor Kubernetes clusters.",
|
||||
route: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route,
|
||||
),
|
||||
activeRoute: RouteMap[PageMap.KUBERNETES_CLUSTERS],
|
||||
icon: IconProp.Kubernetes,
|
||||
iconColor: "blue",
|
||||
category: "Observability",
|
||||
},
|
||||
// Automation & Analytics
|
||||
{
|
||||
title: "Dashboards",
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
|
||||
export interface DiffFlamegraphProps {
|
||||
baselineStartTime: Date;
|
||||
baselineEndTime: Date;
|
||||
comparisonStartTime: Date;
|
||||
comparisonEndTime: Date;
|
||||
serviceIds?: Array<ObjectID> | undefined;
|
||||
profileType?: string | undefined;
|
||||
}
|
||||
|
||||
interface DiffFlamegraphNode {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
baselineValue: number;
|
||||
comparisonValue: number;
|
||||
delta: number;
|
||||
deltaPercent: number;
|
||||
selfBaselineValue: number;
|
||||
selfComparisonValue: number;
|
||||
selfDelta: number;
|
||||
children: DiffFlamegraphNode[];
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
interface TooltipData {
|
||||
name: string;
|
||||
fileName: string;
|
||||
baselineValue: number;
|
||||
comparisonValue: number;
|
||||
delta: number;
|
||||
deltaPercent: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const DiffFlamegraph: FunctionComponent<DiffFlamegraphProps> = (
|
||||
props: DiffFlamegraphProps,
|
||||
): ReactElement => {
|
||||
const [rootNode, setRootNode] = useState<DiffFlamegraphNode | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [zoomStack, setZoomStack] = useState<Array<DiffFlamegraphNode>>([]);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const loadDiffFlamegraph: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/diff-flamegraph",
|
||||
),
|
||||
data: {
|
||||
baselineStartTime: props.baselineStartTime.toISOString(),
|
||||
baselineEndTime: props.baselineEndTime.toISOString(),
|
||||
comparisonStartTime: props.comparisonStartTime.toISOString(),
|
||||
comparisonEndTime: props.comparisonEndTime.toISOString(),
|
||||
serviceIds: props.serviceIds?.map((id: ObjectID) => {
|
||||
return id.toString();
|
||||
}),
|
||||
profileType: props.profileType,
|
||||
},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
const data: DiffFlamegraphNode = response.data[
|
||||
"diffFlamegraph"
|
||||
] as unknown as DiffFlamegraphNode;
|
||||
setRootNode(data);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDiffFlamegraph();
|
||||
}, [
|
||||
props.baselineStartTime,
|
||||
props.baselineEndTime,
|
||||
props.comparisonStartTime,
|
||||
props.comparisonEndTime,
|
||||
props.serviceIds,
|
||||
props.profileType,
|
||||
]);
|
||||
|
||||
const activeRoot: DiffFlamegraphNode | null = useMemo(() => {
|
||||
if (zoomStack.length > 0) {
|
||||
return zoomStack[zoomStack.length - 1]!;
|
||||
}
|
||||
return rootNode;
|
||||
}, [rootNode, zoomStack]);
|
||||
|
||||
const handleClickNode: (node: DiffFlamegraphNode) => void = useCallback(
|
||||
(node: DiffFlamegraphNode): void => {
|
||||
if (node.children.length > 0) {
|
||||
setZoomStack((prev: Array<DiffFlamegraphNode>) => {
|
||||
return [...prev, node];
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleZoomOut: () => void = useCallback((): void => {
|
||||
setZoomStack((prev: Array<DiffFlamegraphNode>) => {
|
||||
return prev.slice(0, prev.length - 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetZoom: () => void = useCallback((): void => {
|
||||
setZoomStack([]);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter: (
|
||||
node: DiffFlamegraphNode,
|
||||
event: React.MouseEvent,
|
||||
) => void = useCallback(
|
||||
(node: DiffFlamegraphNode, event: React.MouseEvent): void => {
|
||||
setTooltip({
|
||||
name: node.functionName,
|
||||
fileName: node.fileName,
|
||||
baselineValue: node.baselineValue,
|
||||
comparisonValue: node.comparisonValue,
|
||||
delta: node.delta,
|
||||
deltaPercent: node.deltaPercent,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave: () => void = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDiffFlamegraph();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!activeRoot ||
|
||||
(activeRoot.baselineValue === 0 && activeRoot.comparisonValue === 0)
|
||||
) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No performance data found in the selected time ranges. Try adjusting the
|
||||
time periods.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getDeltaColor: (deltaPercent: number) => string = (
|
||||
deltaPercent: number,
|
||||
): string => {
|
||||
if (deltaPercent > 50) {
|
||||
return "bg-red-600";
|
||||
}
|
||||
if (deltaPercent > 20) {
|
||||
return "bg-red-500";
|
||||
}
|
||||
if (deltaPercent > 5) {
|
||||
return "bg-red-400";
|
||||
}
|
||||
if (deltaPercent > 0) {
|
||||
return "bg-red-300";
|
||||
}
|
||||
if (deltaPercent < -50) {
|
||||
return "bg-green-600";
|
||||
}
|
||||
if (deltaPercent < -20) {
|
||||
return "bg-green-500";
|
||||
}
|
||||
if (deltaPercent < -5) {
|
||||
return "bg-green-400";
|
||||
}
|
||||
if (deltaPercent < 0) {
|
||||
return "bg-green-300";
|
||||
}
|
||||
return "bg-gray-400";
|
||||
};
|
||||
|
||||
const renderNode: (
|
||||
node: DiffFlamegraphNode,
|
||||
_parentMax: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
) => ReactElement | null = (
|
||||
node: DiffFlamegraphNode,
|
||||
_parentMax: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
): ReactElement | null => {
|
||||
if (widthFraction < 0.005) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgColor: string = getDeltaColor(node.deltaPercent);
|
||||
const maxValue: number = Math.max(node.baselineValue, node.comparisonValue);
|
||||
|
||||
let childOffset: number = 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${node.functionName}-${depth}-${offsetFraction}`}>
|
||||
<div
|
||||
className={`absolute h-6 border border-white/30 cursor-pointer overflow-hidden text-xs text-white leading-6 px-1 truncate ${bgColor} hover:opacity-80`}
|
||||
style={{
|
||||
left: `${offsetFraction * 100}%`,
|
||||
width: `${widthFraction * 100}%`,
|
||||
top: `${depth * 26}px`,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleClickNode(node);
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent) => {
|
||||
handleMouseEnter(node, e);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`${node.functionName} (${node.deltaPercent >= 0 ? "+" : ""}${node.deltaPercent.toFixed(1)}%)`}
|
||||
>
|
||||
{widthFraction > 0.03 ? node.functionName : ""}
|
||||
</div>
|
||||
{node.children.map((child: DiffFlamegraphNode) => {
|
||||
const childMax: number = Math.max(
|
||||
child.baselineValue,
|
||||
child.comparisonValue,
|
||||
);
|
||||
const childWidth: number =
|
||||
maxValue > 0 ? (childMax / maxValue) * widthFraction : 0;
|
||||
const currentOffset: number = offsetFraction + childOffset;
|
||||
childOffset += childWidth;
|
||||
|
||||
return renderNode(
|
||||
child,
|
||||
maxValue,
|
||||
depth + 1,
|
||||
currentOffset,
|
||||
childWidth,
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const getMaxDepth: (node: DiffFlamegraphNode, depth: number) => number = (
|
||||
node: DiffFlamegraphNode,
|
||||
depth: number,
|
||||
): number => {
|
||||
let max: number = depth;
|
||||
for (const child of node.children) {
|
||||
const childDepth: number = getMaxDepth(child, depth + 1);
|
||||
if (childDepth > max) {
|
||||
max = childDepth;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const maxDepth: number = getMaxDepth(activeRoot, 0);
|
||||
const height: number = (maxDepth + 1) * 26 + 10;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{zoomStack.length > 0 && (
|
||||
<div className="mb-3 flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleResetZoom}
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Zoomed into: {activeRoot.functionName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<span className="font-medium">What the colors mean:</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-red-500" />
|
||||
<span>Got slower</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-green-500" />
|
||||
<span>Got faster</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-gray-400" />
|
||||
<span>No change</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-x-auto border border-gray-200 rounded bg-white"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{renderNode(
|
||||
activeRoot,
|
||||
Math.max(activeRoot.baselineValue, activeRoot.comparisonValue),
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 bg-gray-900 text-white text-xs rounded px-3 py-2 pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${tooltip.x + 12}px`,
|
||||
top: `${tooltip.y + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{tooltip.name}</div>
|
||||
{tooltip.fileName && (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
Before: {tooltip.baselineValue.toLocaleString()}
|
||||
</div>
|
||||
<div>After: {tooltip.comparisonValue.toLocaleString()}</div>
|
||||
<div
|
||||
className={
|
||||
tooltip.delta > 0
|
||||
? "text-red-300"
|
||||
: tooltip.delta < 0
|
||||
? "text-green-300"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Change: {tooltip.delta > 0 ? "+" : ""}
|
||||
{tooltip.delta.toLocaleString()} (
|
||||
{tooltip.deltaPercent >= 0 ? "+" : ""}
|
||||
{tooltip.deltaPercent.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffFlamegraph;
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import ProfileUtil, { ParsedStackFrame } from "../../Utils/ProfileUtil";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
|
||||
export interface ProfileFlamegraphProps {
|
||||
profileId: string;
|
||||
profileType?: string | undefined;
|
||||
}
|
||||
|
||||
interface FlamegraphNode {
|
||||
name: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
frameType: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
children: Map<string, FlamegraphNode>;
|
||||
}
|
||||
|
||||
interface TooltipData {
|
||||
name: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const ProfileFlamegraph: FunctionComponent<ProfileFlamegraphProps> = (
|
||||
props: ProfileFlamegraphProps,
|
||||
): ReactElement => {
|
||||
const [samples, setSamples] = useState<Array<ProfileSample>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [zoomStack, setZoomStack] = useState<Array<FlamegraphNode>>([]);
|
||||
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
|
||||
|
||||
const loadSamples: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const result: ListResult<ProfileSample> = await AnalyticsModelAPI.getList(
|
||||
{
|
||||
modelType: ProfileSample,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
profileId: props.profileId,
|
||||
...(props.profileType ? { profileType: props.profileType } : {}),
|
||||
},
|
||||
select: {
|
||||
stacktrace: true,
|
||||
frameTypes: true,
|
||||
value: true,
|
||||
profileType: true,
|
||||
},
|
||||
limit: 10000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
value: SortOrder.Descending,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setSamples(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSamples();
|
||||
}, [props.profileId, props.profileType]);
|
||||
|
||||
const rootNode: FlamegraphNode = useMemo(() => {
|
||||
const root: FlamegraphNode = {
|
||||
name: "root",
|
||||
fileName: "",
|
||||
lineNumber: 0,
|
||||
frameType: "",
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: new Map<string, FlamegraphNode>(),
|
||||
};
|
||||
|
||||
for (const sample of samples) {
|
||||
const stacktrace: Array<string> = sample.stacktrace || [];
|
||||
const frameTypes: Array<string> = sample.frameTypes || [];
|
||||
const value: number = sample.value || 0;
|
||||
|
||||
let currentNode: FlamegraphNode = root;
|
||||
root.totalValue += value;
|
||||
|
||||
// Walk from root to leaf (stacktrace is ordered root-to-leaf)
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: string = stacktrace[i]!;
|
||||
const frameType: string =
|
||||
i < frameTypes.length ? frameTypes[i]! : "unknown";
|
||||
|
||||
let child: FlamegraphNode | undefined = currentNode.children.get(frame);
|
||||
|
||||
if (!child) {
|
||||
const parsed: ParsedStackFrame = ProfileUtil.parseStackFrame(frame);
|
||||
child = {
|
||||
name: parsed.functionName,
|
||||
fileName: parsed.fileName,
|
||||
lineNumber: parsed.lineNumber,
|
||||
frameType,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
children: new Map<string, FlamegraphNode>(),
|
||||
};
|
||||
currentNode.children.set(frame, child);
|
||||
}
|
||||
|
||||
child.totalValue += value;
|
||||
|
||||
// Last frame in the stack is the leaf -- add self value
|
||||
if (i === stacktrace.length - 1) {
|
||||
child.selfValue += value;
|
||||
}
|
||||
|
||||
currentNode = child;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}, [samples]);
|
||||
|
||||
const activeRoot: FlamegraphNode = useMemo(() => {
|
||||
if (zoomStack.length > 0) {
|
||||
return zoomStack[zoomStack.length - 1]!;
|
||||
}
|
||||
return rootNode;
|
||||
}, [rootNode, zoomStack]);
|
||||
|
||||
const handleClickNode: (node: FlamegraphNode) => void = useCallback(
|
||||
(node: FlamegraphNode): void => {
|
||||
if (node.children.size > 0) {
|
||||
setZoomStack((prev: Array<FlamegraphNode>) => {
|
||||
return [...prev, node];
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleZoomOut: () => void = useCallback((): void => {
|
||||
setZoomStack((prev: Array<FlamegraphNode>) => {
|
||||
return prev.slice(0, prev.length - 1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetZoom: () => void = useCallback((): void => {
|
||||
setZoomStack([]);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter: (
|
||||
node: FlamegraphNode,
|
||||
event: React.MouseEvent,
|
||||
) => void = useCallback(
|
||||
(node: FlamegraphNode, event: React.MouseEvent): void => {
|
||||
setTooltip({
|
||||
name: node.name,
|
||||
fileName: node.fileName,
|
||||
selfValue: node.selfValue,
|
||||
totalValue: node.totalValue,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave: () => void = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadSamples();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No performance data found for this profile. This can happen if the
|
||||
profile was recently captured and data is still being processed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderNode: (
|
||||
node: FlamegraphNode,
|
||||
parentTotal: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
) => ReactElement | null = (
|
||||
node: FlamegraphNode,
|
||||
parentTotal: number,
|
||||
depth: number,
|
||||
offsetFraction: number,
|
||||
widthFraction: number,
|
||||
): ReactElement | null => {
|
||||
if (widthFraction < 0.005) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bgColor: string = ProfileUtil.getFrameTypeColor(node.frameType);
|
||||
const percentage: number =
|
||||
parentTotal > 0 ? (node.totalValue / parentTotal) * 100 : 0;
|
||||
|
||||
const children: Array<FlamegraphNode> = Array.from(
|
||||
node.children.values(),
|
||||
).sort((a: FlamegraphNode, b: FlamegraphNode) => {
|
||||
return b.totalValue - a.totalValue;
|
||||
});
|
||||
|
||||
let childOffset: number = 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${node.name}-${depth}-${offsetFraction}`}>
|
||||
<div
|
||||
className={`absolute h-6 border border-white/30 cursor-pointer overflow-hidden text-xs text-white leading-6 px-1 truncate ${bgColor} hover:opacity-80`}
|
||||
style={{
|
||||
left: `${offsetFraction * 100}%`,
|
||||
width: `${widthFraction * 100}%`,
|
||||
top: `${depth * 26}px`,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleClickNode(node);
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent) => {
|
||||
handleMouseEnter(node, e);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={`${node.name} (${percentage.toFixed(1)}%)`}
|
||||
>
|
||||
{widthFraction > 0.03 ? node.name : ""}
|
||||
</div>
|
||||
{children.map((child: FlamegraphNode) => {
|
||||
const childWidth: number =
|
||||
node.totalValue > 0
|
||||
? (child.totalValue / node.totalValue) * widthFraction
|
||||
: 0;
|
||||
const currentOffset: number = offsetFraction + childOffset;
|
||||
childOffset += childWidth;
|
||||
|
||||
return renderNode(
|
||||
child,
|
||||
node.totalValue,
|
||||
depth + 1,
|
||||
currentOffset,
|
||||
childWidth,
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const getMaxDepth: (node: FlamegraphNode, depth: number) => number = (
|
||||
node: FlamegraphNode,
|
||||
depth: number,
|
||||
): number => {
|
||||
let max: number = depth;
|
||||
for (const child of node.children.values()) {
|
||||
const childDepth: number = getMaxDepth(child, depth + 1);
|
||||
if (childDepth > max) {
|
||||
max = childDepth;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const maxDepth: number = getMaxDepth(activeRoot, 0);
|
||||
const height: number = (maxDepth + 1) * 26 + 10;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{zoomStack.length > 0 && (
|
||||
<div className="mb-3 flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-300"
|
||||
onClick={handleResetZoom}
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Zoomed into: {activeRoot.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center space-x-4 text-xs text-gray-600">
|
||||
<span className="font-medium">Code Type:</span>
|
||||
{[
|
||||
{ key: "kernel", label: "System / Kernel" },
|
||||
{ key: "native", label: "Native Code" },
|
||||
{ key: "jvm", label: "Java / JVM" },
|
||||
{ key: "cpython", label: "Python" },
|
||||
{ key: "go", label: "Go" },
|
||||
{ key: "v8js", label: "JavaScript" },
|
||||
{ key: "unknown", label: "Other" },
|
||||
].map((item: { key: string; label: string }) => {
|
||||
return (
|
||||
<span key={item.key} className="flex items-center space-x-1">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded ${ProfileUtil.getFrameTypeColor(item.key)}`}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative w-full overflow-x-auto border border-gray-200 rounded bg-white"
|
||||
style={{ height: `${height}px` }}
|
||||
>
|
||||
{renderNode(activeRoot, activeRoot.totalValue, 0, 0, 1)}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 bg-gray-900 text-white text-xs rounded px-3 py-2 pointer-events-none shadow-lg"
|
||||
style={{
|
||||
left: `${tooltip.x + 12}px`,
|
||||
top: `${tooltip.y + 12}px`,
|
||||
}}
|
||||
>
|
||||
<div className="font-semibold">{tooltip.name}</div>
|
||||
{tooltip.fileName && (
|
||||
<div className="text-gray-300">{tooltip.fileName}</div>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
Own Time: {tooltip.selfValue.toLocaleString()}
|
||||
</div>
|
||||
<div>Total Time: {tooltip.totalValue.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileFlamegraph;
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import ProfileSample from "Common/Models/AnalyticsModels/ProfileSample";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import ProfileUtil, { ParsedStackFrame } from "../../Utils/ProfileUtil";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
|
||||
export interface ProfileFunctionListProps {
|
||||
profileId: string;
|
||||
profileType?: string | undefined;
|
||||
}
|
||||
|
||||
interface FunctionRow {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
type SortField =
|
||||
| "functionName"
|
||||
| "fileName"
|
||||
| "selfValue"
|
||||
| "totalValue"
|
||||
| "sampleCount";
|
||||
|
||||
const ProfileFunctionList: FunctionComponent<ProfileFunctionListProps> = (
|
||||
props: ProfileFunctionListProps,
|
||||
): ReactElement => {
|
||||
const [samples, setSamples] = useState<Array<ProfileSample>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [sortField, setSortField] = useState<SortField>("selfValue");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const loadSamples: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const result: ListResult<ProfileSample> = await AnalyticsModelAPI.getList(
|
||||
{
|
||||
modelType: ProfileSample,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
profileId: props.profileId,
|
||||
...(props.profileType ? { profileType: props.profileType } : {}),
|
||||
},
|
||||
select: {
|
||||
stacktrace: true,
|
||||
frameTypes: true,
|
||||
value: true,
|
||||
profileType: true,
|
||||
},
|
||||
limit: 10000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
value: SortOrder.Descending,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
setSamples(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSamples();
|
||||
}, [props.profileId, props.profileType]);
|
||||
|
||||
const functionRows: Array<FunctionRow> = useMemo(() => {
|
||||
const functionMap: Map<
|
||||
string,
|
||||
{
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
for (const sample of samples) {
|
||||
const stacktrace: Array<string> = sample.stacktrace || [];
|
||||
const value: number = sample.value || 0;
|
||||
|
||||
const seenInThisSample: Set<string> = new Set<string>();
|
||||
|
||||
for (let i: number = 0; i < stacktrace.length; i++) {
|
||||
const frame: string = stacktrace[i]!;
|
||||
const parsed: ParsedStackFrame = ProfileUtil.parseStackFrame(frame);
|
||||
const key: string = `${parsed.functionName}@${parsed.fileName}`;
|
||||
|
||||
let entry: FunctionRow | undefined = functionMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
functionName: parsed.functionName,
|
||||
fileName: parsed.fileName,
|
||||
selfValue: 0,
|
||||
totalValue: 0,
|
||||
sampleCount: 0,
|
||||
};
|
||||
functionMap.set(key, entry);
|
||||
}
|
||||
|
||||
// Only add total value once per sample (avoid double-counting recursive calls)
|
||||
if (!seenInThisSample.has(key)) {
|
||||
entry.totalValue += value;
|
||||
entry.sampleCount += 1;
|
||||
seenInThisSample.add(key);
|
||||
}
|
||||
|
||||
// Self value is only for the leaf frame
|
||||
if (i === stacktrace.length - 1) {
|
||||
entry.selfValue += value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(functionMap.values());
|
||||
}, [samples]);
|
||||
|
||||
const sortedRows: Array<FunctionRow> = useMemo(() => {
|
||||
const rows: Array<FunctionRow> = [...functionRows];
|
||||
|
||||
rows.sort((a: FunctionRow, b: FunctionRow) => {
|
||||
let aVal: string | number = a[sortField];
|
||||
let bVal: string | number = b[sortField];
|
||||
|
||||
if (typeof aVal === "string") {
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = (bVal as string).toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return sortDirection === "asc" ? -1 : 1;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return sortDirection === "asc" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}, [functionRows, sortField, sortDirection]);
|
||||
|
||||
const handleSort: (field: SortField) => void = useCallback(
|
||||
(field: SortField): void => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((prev: "asc" | "desc") => {
|
||||
return prev === "asc" ? "desc" : "asc";
|
||||
});
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("desc");
|
||||
}
|
||||
},
|
||||
[sortField],
|
||||
);
|
||||
|
||||
const getSortIndicator: (field: SortField) => string = useCallback(
|
||||
(field: SortField): string => {
|
||||
if (field !== sortField) {
|
||||
return "";
|
||||
}
|
||||
return sortDirection === "asc" ? " \u2191" : " \u2193";
|
||||
},
|
||||
[sortField, sortDirection],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadSamples();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No performance data found for this profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-sm text-left border border-gray-200 rounded">
|
||||
<thead className="bg-gray-50 text-gray-700 font-medium">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("functionName");
|
||||
}}
|
||||
>
|
||||
Function{getSortIndicator("functionName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("fileName");
|
||||
}}
|
||||
>
|
||||
Source File{getSortIndicator("fileName")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("selfValue");
|
||||
}}
|
||||
>
|
||||
Own Time{getSortIndicator("selfValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("totalValue");
|
||||
}}
|
||||
>
|
||||
Total Time{getSortIndicator("totalValue")}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => {
|
||||
handleSort("sampleCount");
|
||||
}}
|
||||
>
|
||||
Occurrences{getSortIndicator("sampleCount")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row: FunctionRow, index: number) => {
|
||||
return (
|
||||
<tr
|
||||
key={`${row.functionName}-${row.fileName}-${index}`}
|
||||
className="border-t border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs truncate max-w-xs">
|
||||
{row.functionName}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs truncate max-w-xs">
|
||||
{row.fileName || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.selfValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.totalValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-xs">
|
||||
{row.sampleCount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileFunctionList;
|
||||
@@ -0,0 +1,350 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import AnalyticsModelTable from "Common/UI/Components/ModelTable/AnalyticsModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import API from "Common/Utils/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import ProfileUtil from "../../Utils/ProfileUtil";
|
||||
|
||||
export interface ComponentProps {
|
||||
modelId?: ObjectID | undefined;
|
||||
profileQuery?: Query<Profile> | undefined;
|
||||
isMinimalTable?: boolean | undefined;
|
||||
noItemsMessage?: string | undefined;
|
||||
}
|
||||
|
||||
const ProfileTable: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID | undefined = props.modelId;
|
||||
|
||||
const [attributes, setAttributes] = React.useState<Array<string>>([]);
|
||||
const [attributesLoaded, setAttributesLoaded] =
|
||||
React.useState<boolean>(false);
|
||||
const [attributesLoading, setAttributesLoading] =
|
||||
React.useState<boolean>(false);
|
||||
const [attributesError, setAttributesError] = React.useState<string>("");
|
||||
|
||||
const [isPageLoading, setIsPageLoading] = React.useState<boolean>(true);
|
||||
const [pageError, setPageError] = React.useState<string>("");
|
||||
|
||||
const [telemetryServices, setServices] = React.useState<Array<Service>>([]);
|
||||
|
||||
const [areAdvancedFiltersVisible, setAreAdvancedFiltersVisible] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const query: Query<Profile> = React.useMemo(() => {
|
||||
const baseQuery: Query<Profile> = {
|
||||
...(props.profileQuery || {}),
|
||||
};
|
||||
|
||||
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
|
||||
|
||||
if (projectId) {
|
||||
baseQuery.projectId = projectId;
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
baseQuery.serviceId = modelId;
|
||||
}
|
||||
|
||||
return baseQuery;
|
||||
}, [props.profileQuery, modelId]);
|
||||
|
||||
const loadServices: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setIsPageLoading(true);
|
||||
setPageError("");
|
||||
|
||||
const telemetryServicesResponse: ListResult<Service> =
|
||||
await ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
setServices(telemetryServicesResponse.data || []);
|
||||
} catch (err) {
|
||||
setPageError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsPageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAttributes: PromiseVoidFunction = async (): Promise<void> => {
|
||||
if (attributesLoading || attributesLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAttributesLoading(true);
|
||||
setAttributesError("");
|
||||
|
||||
const attributeResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/get-attributes",
|
||||
),
|
||||
data: {},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (attributeResponse instanceof HTTPErrorResponse) {
|
||||
throw attributeResponse;
|
||||
}
|
||||
|
||||
const fetchedAttributes: Array<string> = (attributeResponse.data[
|
||||
"attributes"
|
||||
] || []) as Array<string>;
|
||||
setAttributes(fetchedAttributes);
|
||||
setAttributesLoaded(true);
|
||||
} catch (err) {
|
||||
setAttributes([]);
|
||||
setAttributesLoaded(false);
|
||||
setAttributesError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setAttributesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadServices().catch((err: Error) => {
|
||||
setPageError(API.getFriendlyErrorMessage(err as Error));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAdvancedFiltersToggle: (show: boolean) => void = (
|
||||
show: boolean,
|
||||
): void => {
|
||||
setAreAdvancedFiltersVisible(show);
|
||||
|
||||
if (show && !attributesLoaded && !attributesLoading) {
|
||||
void loadAttributes();
|
||||
}
|
||||
};
|
||||
|
||||
if (isPageLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{pageError && (
|
||||
<div className="mb-4">
|
||||
<ErrorMessage
|
||||
message={`We couldn't load telemetry services. ${pageError}`}
|
||||
onRefreshClick={() => {
|
||||
void loadServices();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{areAdvancedFiltersVisible && attributesError && (
|
||||
<div className="mb-4">
|
||||
<ErrorMessage
|
||||
message={`We couldn't load profile attributes. ${attributesError}`}
|
||||
onRefreshClick={() => {
|
||||
setAttributesLoaded(false);
|
||||
void loadAttributes();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded">
|
||||
<AnalyticsModelTable<Profile>
|
||||
userPreferencesKey="profile-table"
|
||||
disablePagination={props.isMinimalTable}
|
||||
modelType={Profile}
|
||||
id="profiles-table"
|
||||
isDeleteable={false}
|
||||
isEditable={false}
|
||||
isCreateable={false}
|
||||
singularName="Performance Profile"
|
||||
pluralName="Performance Profiles"
|
||||
name="Performance Profiles"
|
||||
isViewable={true}
|
||||
cardProps={
|
||||
props.isMinimalTable
|
||||
? undefined
|
||||
: {
|
||||
title: "Performance Profiles",
|
||||
description:
|
||||
"See where your application spends the most time and memory. Use profiles to find slow functions and optimize performance.",
|
||||
}
|
||||
}
|
||||
query={query}
|
||||
selectMoreFields={{
|
||||
profileId: true,
|
||||
}}
|
||||
showViewIdButton={true}
|
||||
noItemsMessage={
|
||||
props.noItemsMessage
|
||||
? props.noItemsMessage
|
||||
: "No performance profiles found. Once your services start sending profiling data, they will appear here."
|
||||
}
|
||||
showRefreshButton={true}
|
||||
sortBy="startTime"
|
||||
sortOrder={SortOrder.Descending}
|
||||
onViewPage={(profile: Profile) => {
|
||||
return Promise.resolve(
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.PROFILE_VIEW]!, {
|
||||
modelId: profile.profileId!,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
},
|
||||
type: FieldType.MultiSelectDropdown,
|
||||
filterDropdownOptions: telemetryServices.map(
|
||||
(service: Service) => {
|
||||
return {
|
||||
label: service.name!,
|
||||
value: service.id!.toString(),
|
||||
};
|
||||
},
|
||||
),
|
||||
title: "Service",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Type",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
traceId: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Trace ID",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
type: FieldType.DateTime,
|
||||
title: "Captured At",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
attributes: true,
|
||||
},
|
||||
type: FieldType.JSON,
|
||||
title: "Attributes",
|
||||
jsonKeys: attributes,
|
||||
isAdvancedFilter: true,
|
||||
},
|
||||
]}
|
||||
onAdvancedFiltersToggle={handleAdvancedFiltersToggle}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
serviceId: true,
|
||||
},
|
||||
title: "Service",
|
||||
type: FieldType.Element,
|
||||
getElement: (profile: Profile): ReactElement => {
|
||||
const telemetryService: Service | undefined =
|
||||
telemetryServices.find((service: Service) => {
|
||||
return (
|
||||
service.id?.toString() === profile.serviceId?.toString()
|
||||
);
|
||||
});
|
||||
|
||||
if (!telemetryService) {
|
||||
return <p>Unknown</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ServiceElement service={telemetryService} />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
profileType: true,
|
||||
},
|
||||
title: "Type",
|
||||
type: FieldType.Element,
|
||||
getElement: (profile: Profile): ReactElement => {
|
||||
const profileType: string = profile.profileType || "unknown";
|
||||
const displayName: string =
|
||||
ProfileUtil.getProfileTypeDisplayName(profileType);
|
||||
const badgeColor: string =
|
||||
ProfileUtil.getProfileTypeBadgeColor(profileType);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${badgeColor}`}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
sampleCount: true,
|
||||
},
|
||||
title: "Data Points",
|
||||
type: FieldType.Number,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
startTime: true,
|
||||
},
|
||||
title: "Captured At",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTable;
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
export interface ProfileTimelineProps {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
serviceIds?: Array<ObjectID> | undefined;
|
||||
profileType?: string | undefined;
|
||||
onTimeRangeSelect?: ((start: Date, end: Date) => void) | undefined;
|
||||
}
|
||||
|
||||
interface TimelineBucket {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const BUCKET_COUNT: number = 50;
|
||||
|
||||
const ProfileTimeline: FunctionComponent<ProfileTimelineProps> = (
|
||||
props: ProfileTimelineProps,
|
||||
): ReactElement => {
|
||||
const [profiles, setProfiles] = useState<Array<Profile>>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadProfiles: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
startTime: new InBetween(props.startTime, props.endTime),
|
||||
};
|
||||
|
||||
if (
|
||||
props.serviceIds &&
|
||||
props.serviceIds.length > 0 &&
|
||||
props.serviceIds[0]
|
||||
) {
|
||||
query["serviceId"] = props.serviceIds[0];
|
||||
}
|
||||
|
||||
if (props.profileType) {
|
||||
query["profileType"] = props.profileType;
|
||||
}
|
||||
|
||||
const result: ListResult<Profile> = await AnalyticsModelAPI.getList({
|
||||
modelType: Profile,
|
||||
query: query,
|
||||
select: {
|
||||
startTime: true,
|
||||
profileId: true,
|
||||
},
|
||||
limit: 5000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
startTime: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
setProfiles(result.data || []);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfiles();
|
||||
}, [props.startTime, props.endTime, props.serviceIds, props.profileType]);
|
||||
|
||||
const buckets: Array<TimelineBucket> = useMemo(() => {
|
||||
const start: number = props.startTime.getTime();
|
||||
const end: number = props.endTime.getTime();
|
||||
const bucketWidth: number = (end - start) / BUCKET_COUNT;
|
||||
|
||||
const result: Array<TimelineBucket> = [];
|
||||
|
||||
for (let i: number = 0; i < BUCKET_COUNT; i++) {
|
||||
result.push({
|
||||
startTime: new Date(start + i * bucketWidth),
|
||||
endTime: new Date(start + (i + 1) * bucketWidth),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const profileTime: number = new Date(
|
||||
profile.startTime || new Date(),
|
||||
).getTime();
|
||||
const bucketIndex: number = Math.min(
|
||||
Math.floor(((profileTime - start) / (end - start)) * BUCKET_COUNT),
|
||||
BUCKET_COUNT - 1,
|
||||
);
|
||||
|
||||
if (bucketIndex >= 0 && bucketIndex < result.length) {
|
||||
result[bucketIndex]!.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [profiles, props.startTime, props.endTime]);
|
||||
|
||||
const maxCount: number = useMemo(() => {
|
||||
let max: number = 0;
|
||||
for (const bucket of buckets) {
|
||||
if (bucket.count > max) {
|
||||
max = bucket.count;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [buckets]);
|
||||
|
||||
const handleBucketClick: (bucket: TimelineBucket) => void = useCallback(
|
||||
(bucket: TimelineBucket): void => {
|
||||
if (props.onTimeRangeSelect) {
|
||||
props.onTimeRangeSelect(bucket.startTime, bucket.endTime);
|
||||
}
|
||||
},
|
||||
[props.onTimeRangeSelect],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadProfiles();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No profiles found in this time range.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Activity ({profiles.length} profiles captured)
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(props.startTime, true)} —{" "}
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(props.endTime, true)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end space-x-0.5 h-16 border border-gray-200 rounded bg-white p-1">
|
||||
{buckets.map((bucket: TimelineBucket, index: number) => {
|
||||
const heightPercent: number =
|
||||
maxCount > 0 ? (bucket.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex-1 rounded-t cursor-pointer transition-colors ${
|
||||
bucket.count > 0
|
||||
? "bg-blue-400 hover:bg-blue-500"
|
||||
: "bg-gray-100 hover:bg-gray-200"
|
||||
}`}
|
||||
style={{
|
||||
height: `${Math.max(heightPercent, bucket.count > 0 ? 8 : 2)}%`,
|
||||
}}
|
||||
title={`${bucket.count} profiles\n${OneUptimeDate.getDateAsLocalFormattedString(bucket.startTime, true)}`}
|
||||
onClick={() => {
|
||||
handleBucketClick(bucket);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTimeline;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ProfileTypeSelectorProps {
|
||||
selectedProfileType: string | undefined;
|
||||
onChange: (profileType: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface ProfileTypeOption {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}
|
||||
|
||||
const profileTypeOptions: Array<ProfileTypeOption> = [
|
||||
{ label: "All Types", value: undefined },
|
||||
{ label: "CPU Usage", value: "cpu" },
|
||||
{ label: "Wall Clock Time", value: "wall" },
|
||||
{ label: "Memory Allocations (Count)", value: "alloc_objects" },
|
||||
{ label: "Memory Allocations (Size)", value: "alloc_space" },
|
||||
{ label: "Goroutines", value: "goroutine" },
|
||||
{ label: "Lock Contention", value: "contention" },
|
||||
];
|
||||
|
||||
const ProfileTypeSelector: FunctionComponent<ProfileTypeSelectorProps> = (
|
||||
props: ProfileTypeSelectorProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm font-medium text-gray-700">Show:</label>
|
||||
<select
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={props.selectedProfileType || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value: string = e.target.value;
|
||||
props.onChange(value === "" ? undefined : value);
|
||||
}}
|
||||
>
|
||||
{profileTypeOptions.map((option: ProfileTypeOption, index: number) => {
|
||||
return (
|
||||
<option key={index} value={option.value || ""}>
|
||||
{option.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileTypeSelector;
|
||||
@@ -0,0 +1,621 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/Utils/API";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Profile from "Common/Models/AnalyticsModels/Profile";
|
||||
import AnalyticsModelAPI from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import ProfileUtil from "../../Utils/ProfileUtil";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceProfileSummary {
|
||||
service: Service;
|
||||
profileCount: number;
|
||||
latestProfileTime: Date | null;
|
||||
profileTypes: Array<string>;
|
||||
totalSamples: number;
|
||||
}
|
||||
|
||||
interface FunctionHotspot {
|
||||
functionName: string;
|
||||
fileName: string;
|
||||
selfValue: number;
|
||||
totalValue: number;
|
||||
sampleCount: number;
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
interface ProfileTypeStats {
|
||||
type: string;
|
||||
count: number;
|
||||
displayName: string;
|
||||
badgeColor: string;
|
||||
}
|
||||
|
||||
const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceProfileSummary>
|
||||
>([]);
|
||||
const [hotspots, setHotspots] = useState<Array<FunctionHotspot>>([]);
|
||||
const [profileTypeStats, setProfileTypeStats] = useState<
|
||||
Array<ProfileTypeStats>
|
||||
>([]);
|
||||
const [totalProfileCount, setTotalProfileCount] = useState<number>(0);
|
||||
const [totalSampleCount, setTotalSampleCount] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
|
||||
const [servicesResult, profilesResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
AnalyticsModelAPI.getList({
|
||||
modelType: Profile,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
startTime: new InBetween(oneHourAgo, now),
|
||||
},
|
||||
select: {
|
||||
serviceId: true,
|
||||
profileType: true,
|
||||
startTime: true,
|
||||
sampleCount: true,
|
||||
},
|
||||
limit: 5000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const services: Array<Service> = servicesResult.data || [];
|
||||
const profiles: Array<Profile> = profilesResult.data || [];
|
||||
|
||||
setTotalProfileCount(profiles.length);
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceProfileSummary> = new Map();
|
||||
const typeCountMap: Map<string, number> = new Map();
|
||||
let totalSamples: number = 0;
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
summaryMap.set(serviceId, {
|
||||
service,
|
||||
profileCount: 0,
|
||||
latestProfileTime: null,
|
||||
profileTypes: [],
|
||||
totalSamples: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const serviceId: string = profile.serviceId?.toString() || "";
|
||||
const summary: ServiceProfileSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (!summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.profileCount += 1;
|
||||
|
||||
const samples: number = (profile.sampleCount as number) || 0;
|
||||
summary.totalSamples += samples;
|
||||
totalSamples += samples;
|
||||
|
||||
const profileTime: Date | undefined = profile.startTime
|
||||
? new Date(profile.startTime)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
profileTime &&
|
||||
(!summary.latestProfileTime ||
|
||||
profileTime > summary.latestProfileTime)
|
||||
) {
|
||||
summary.latestProfileTime = profileTime;
|
||||
}
|
||||
|
||||
const profileType: string = profile.profileType || "";
|
||||
if (profileType && !summary.profileTypes.includes(profileType)) {
|
||||
summary.profileTypes.push(profileType);
|
||||
}
|
||||
|
||||
// Track global type stats
|
||||
typeCountMap.set(profileType, (typeCountMap.get(profileType) || 0) + 1);
|
||||
}
|
||||
|
||||
setTotalSampleCount(totalSamples);
|
||||
|
||||
// Build profile type stats
|
||||
const typeStats: Array<ProfileTypeStats> = Array.from(
|
||||
typeCountMap.entries(),
|
||||
)
|
||||
.map(([type, count]: [string, number]) => {
|
||||
return {
|
||||
type,
|
||||
count,
|
||||
displayName: ProfileUtil.getProfileTypeDisplayName(type),
|
||||
badgeColor: ProfileUtil.getProfileTypeBadgeColor(type),
|
||||
};
|
||||
})
|
||||
.sort((a: ProfileTypeStats, b: ProfileTypeStats) => {
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setProfileTypeStats(typeStats);
|
||||
|
||||
// Only show services that have profiles
|
||||
const summariesWithData: Array<ServiceProfileSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceProfileSummary) => {
|
||||
return s.profileCount > 0;
|
||||
});
|
||||
|
||||
summariesWithData.sort(
|
||||
(a: ServiceProfileSummary, b: ServiceProfileSummary) => {
|
||||
return b.profileCount - a.profileCount;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
|
||||
// Load top hotspots
|
||||
try {
|
||||
const hotspotsResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
"/telemetry/profiles/function-list",
|
||||
),
|
||||
data: {
|
||||
startTime: oneHourAgo.toISOString(),
|
||||
endTime: now.toISOString(),
|
||||
limit: 10,
|
||||
sortBy: "selfValue",
|
||||
},
|
||||
headers: {
|
||||
...ModelAPI.getCommonHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (hotspotsResponse instanceof HTTPErrorResponse) {
|
||||
throw hotspotsResponse;
|
||||
}
|
||||
|
||||
const functions: Array<FunctionHotspot> = (hotspotsResponse.data[
|
||||
"functions"
|
||||
] || []) as unknown as Array<FunctionHotspot>;
|
||||
setHotspots(functions);
|
||||
} catch {
|
||||
setHotspots([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No profiling data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending profiling data, you{"'"}ll see
|
||||
performance hotspots, resource usage patterns, and optimization
|
||||
opportunities.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxProfiles: number = Math.max(
|
||||
...serviceSummaries.map((s: ServiceProfileSummary) => {
|
||||
return s.profileCount;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Profiles</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-indigo-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalProfileCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">last hour</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Services</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">being profiled</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Samples</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalSampleCount >= 1_000_000
|
||||
? `${(totalSampleCount / 1_000_000).toFixed(1)}M`
|
||||
: totalSampleCount >= 1_000
|
||||
? `${(totalSampleCount / 1_000).toFixed(1)}K`
|
||||
: totalSampleCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">total samples</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Hotspots</p>
|
||||
<div
|
||||
className={`h-9 w-9 rounded-lg flex items-center justify-center ${hotspots.length > 0 ? "bg-orange-50" : "bg-gray-50"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-4.5 w-4.5 ${hotspots.length > 0 ? "text-orange-600" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 6.51 6.51 0 009 4.572c.163.07.322.148.476.232M12 18.75a6.743 6.743 0 002.14-1.234M12 18.75a6.72 6.72 0 01-2.14-1.234M12 18.75V21m-4.773-4.227l-1.591 1.591M5.636 5.636L4.045 4.045m0 15.91l1.591-1.591M18.364 5.636l1.591-1.591M21 12h-2.25M4.5 12H2.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{hotspots.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">functions identified</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Type Distribution */}
|
||||
{profileTypeStats.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Profile Types Collected
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{profileTypeStats.map((stat: ProfileTypeStats) => {
|
||||
const pct: number =
|
||||
totalProfileCount > 0
|
||||
? Math.round((stat.count / totalProfileCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={stat.type}
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2 rounded-lg ${stat.badgeColor}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{stat.count}</span>
|
||||
<span className="text-sm">{stat.displayName}</span>
|
||||
<span className="text-xs opacity-60">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Services Being Profiled
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Performance data collected in the last hour
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES_LIST] as Route,
|
||||
)}
|
||||
>
|
||||
View all profiles
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceProfileSummary) => {
|
||||
const coverage: number =
|
||||
maxProfiles > 0
|
||||
? Math.round((summary.profileCount / maxProfiles) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={summary.service.id?.toString()}
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(summary.service._id as string),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-indigo-200 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<ServiceElement service={summary.service} />
|
||||
<span className="text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded-full font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Profiles</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{summary.profileCount}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Samples</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{summary.totalSamples >= 1_000
|
||||
? `${(summary.totalSamples / 1_000).toFixed(1)}K`
|
||||
: summary.totalSamples.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile volume bar */}
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
||||
style={{ width: `${Math.max(coverage, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile type badges */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.profileTypes.map((profileType: string) => {
|
||||
const badgeColor: string =
|
||||
ProfileUtil.getProfileTypeBadgeColor(profileType);
|
||||
return (
|
||||
<span
|
||||
key={profileType}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeColor}`}
|
||||
>
|
||||
{ProfileUtil.getProfileTypeDisplayName(profileType)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{summary.latestProfileTime && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Last captured{" "}
|
||||
{OneUptimeDate.fromNow(summary.latestProfileTime)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Hotspots */}
|
||||
{hotspots.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Top Performance Hotspots
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Functions consuming the most resources across all services
|
||||
</p>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{hotspots.map((fn: FunctionHotspot, index: number) => {
|
||||
const maxSelf: number = hotspots[0]?.selfValue || 1;
|
||||
const barWidth: number = (fn.selfValue / maxSelf) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${fn.functionName}-${fn.fileName}-${index}`}
|
||||
className="px-5 py-3.5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="min-w-0 flex-1 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono w-5 flex-shrink-0">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<p className="font-mono text-sm text-gray-900 truncate">
|
||||
{fn.functionName}
|
||||
</p>
|
||||
{fn.frameType && (
|
||||
<span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-gray-100 text-gray-600">
|
||||
{fn.frameType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fn.fileName && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 ml-7 truncate">
|
||||
{fn.fileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-5 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold font-mono text-gray-900">
|
||||
{fn.selfValue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">own time</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono text-gray-700">
|
||||
{fn.totalValue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">total</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono text-gray-700">
|
||||
{fn.sampleCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">samples</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-7">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-orange-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilesDashboard;
|
||||
@@ -28,6 +28,7 @@ import Log from "Common/Models/AnalyticsModels/Log";
|
||||
import Span, {
|
||||
SpanEvent,
|
||||
SpanEventType,
|
||||
SpanLink,
|
||||
} from "Common/Models/AnalyticsModels/Span";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
@@ -37,6 +38,11 @@ import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import Link from "Common/UI/Components/Link/Link";
|
||||
import CriticalPathUtil, {
|
||||
SpanData,
|
||||
SpanSelfTime,
|
||||
} from "Common/Utils/Traces/CriticalPath";
|
||||
|
||||
export interface ComponentProps {
|
||||
id: string;
|
||||
@@ -45,6 +51,7 @@ export interface ComponentProps {
|
||||
onClose: () => void;
|
||||
telemetryService: Service;
|
||||
divisibilityFactor: DivisibilityFactor;
|
||||
allTraceSpans?: Span[];
|
||||
}
|
||||
|
||||
const SpanViewer: FunctionComponent<ComponentProps> = (
|
||||
@@ -76,7 +83,9 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
|
||||
serviceId: true,
|
||||
spanId: true,
|
||||
traceId: true,
|
||||
parentSpanId: true,
|
||||
events: true,
|
||||
links: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
startTimeUnixNano: true,
|
||||
@@ -169,6 +178,31 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Compute self-time for this span (must be before early returns to preserve hook order)
|
||||
const selfTimeInfo: SpanSelfTime | null = React.useMemo(() => {
|
||||
if (!span || !props.allTraceSpans || props.allTraceSpans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spanDataList: SpanData[] = props.allTraceSpans.map(
|
||||
(s: Span): SpanData => {
|
||||
return {
|
||||
spanId: s.spanId!,
|
||||
parentSpanId: s.parentSpanId || undefined,
|
||||
startTimeUnixNano: s.startTimeUnixNano!,
|
||||
endTimeUnixNano: s.endTimeUnixNano!,
|
||||
durationUnixNano: s.durationUnixNano!,
|
||||
serviceId: s.serviceId?.toString(),
|
||||
name: s.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const selfTimes: Map<string, SpanSelfTime> =
|
||||
CriticalPathUtil.computeSelfTimes(spanDataList);
|
||||
return selfTimes.get(span.spanId!) || null;
|
||||
}, [span, props.allTraceSpans]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
@@ -533,6 +567,83 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
};
|
||||
|
||||
const getLinksContentElement: GetReactElementFunction = (): ReactElement => {
|
||||
if (!span) {
|
||||
return <ErrorMessage message="Span not found" />;
|
||||
}
|
||||
|
||||
const links: Array<SpanLink> | undefined = span.links;
|
||||
|
||||
if (!links || links.length === 0) {
|
||||
return <ErrorMessage message="No linked spans found." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{links.map((link: SpanLink, index: number) => {
|
||||
const traceRoute: Route = RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{
|
||||
modelId: link.traceId,
|
||||
},
|
||||
);
|
||||
|
||||
const routeWithSpanId: Route = new Route(traceRoute.toString());
|
||||
routeWithSpanId.addQueryParams({ spanId: link.spanId });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border border-gray-200 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-gray-700">
|
||||
Link {index + 1}
|
||||
</div>
|
||||
<Link
|
||||
to={routeWithSpanId}
|
||||
className="text-xs font-medium text-indigo-600 hover:text-indigo-700 hover:underline"
|
||||
openInNewTab={true}
|
||||
>
|
||||
View Trace
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500 font-medium">Trace ID</div>
|
||||
<code className="text-gray-800 font-mono text-[11px] break-all">
|
||||
{link.traceId}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 font-medium">Span ID</div>
|
||||
<code className="text-gray-800 font-mono text-[11px] break-all">
|
||||
{link.spanId}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{link.attributes && Object.keys(link.attributes).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 font-medium mb-1">
|
||||
Attributes
|
||||
</div>
|
||||
<JSONTable
|
||||
json={JSONFunctions.nestJson(
|
||||
(link.attributes as any) || {},
|
||||
)}
|
||||
title="Link Attributes"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getBasicInfo: GetReactElementFunction = (): ReactElement => {
|
||||
if (!span) {
|
||||
return <ErrorMessage message="Span not found" />;
|
||||
@@ -664,6 +775,33 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
},
|
||||
},
|
||||
...(selfTimeInfo
|
||||
? [
|
||||
{
|
||||
key: "selfTime" as keyof Span,
|
||||
title: "Self Time",
|
||||
description:
|
||||
"Time spent in this span excluding child span durations.",
|
||||
fieldType: FieldType.Element,
|
||||
getElement: () => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
divisibilityFactor: props.divisibilityFactor,
|
||||
spanDurationInUnixNano:
|
||||
selfTimeInfo.selfTimeUnixNano,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{selfTimeInfo.selfTimePercent.toFixed(1)}% of span
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "kind",
|
||||
title: "Span Kind",
|
||||
@@ -710,6 +848,12 @@ const SpanViewer: FunctionComponent<ComponentProps> = (
|
||||
return event.name === SpanEventType.Exception.toLowerCase();
|
||||
}).length,
|
||||
},
|
||||
{
|
||||
name: "Links",
|
||||
children: getLinksContentElement(),
|
||||
countBadge: span?.links?.length || 0,
|
||||
tabType: TabType.Info,
|
||||
},
|
||||
]}
|
||||
onTabChange={() => {}}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
398
App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx
Normal file
398
App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import SpanUtil from "../../Utils/SpanUtil";
|
||||
import CriticalPathUtil, {
|
||||
SpanData,
|
||||
SpanSelfTime,
|
||||
} from "Common/Utils/Traces/CriticalPath";
|
||||
import Span from "Common/Models/AnalyticsModels/Span";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import Color from "Common/Types/Color";
|
||||
import { Black } from "Common/Types/BrandColors";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface FlameGraphProps {
|
||||
spans: Span[];
|
||||
telemetryServices: Service[];
|
||||
onSpanSelect?: (spanId: string) => void;
|
||||
selectedSpanId: string | undefined;
|
||||
}
|
||||
|
||||
interface FlameGraphNode {
|
||||
span: Span;
|
||||
children: FlameGraphNode[];
|
||||
depth: number;
|
||||
startTimeUnixNano: number;
|
||||
endTimeUnixNano: number;
|
||||
durationUnixNano: number;
|
||||
selfTimeUnixNano: number;
|
||||
serviceColor: Color;
|
||||
serviceName: string;
|
||||
}
|
||||
|
||||
const MIN_BLOCK_WIDTH_PX: number = 2;
|
||||
|
||||
const FlameGraph: FunctionComponent<FlameGraphProps> = (
|
||||
props: FlameGraphProps,
|
||||
): ReactElement => {
|
||||
const { spans, telemetryServices, onSpanSelect, selectedSpanId } = props;
|
||||
|
||||
const [hoveredSpanId, setHoveredSpanId] = React.useState<string | null>(null);
|
||||
const [focusedSpanId, setFocusedSpanId] = React.useState<string | null>(null);
|
||||
const containerRef: React.RefObject<HTMLDivElement | null> =
|
||||
React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build span data for critical path utility
|
||||
const spanDataList: SpanData[] = React.useMemo(() => {
|
||||
return spans.map((span: Span): SpanData => {
|
||||
return {
|
||||
spanId: span.spanId!,
|
||||
parentSpanId: span.parentSpanId || undefined,
|
||||
startTimeUnixNano: span.startTimeUnixNano!,
|
||||
endTimeUnixNano: span.endTimeUnixNano!,
|
||||
durationUnixNano: span.durationUnixNano!,
|
||||
serviceId: span.serviceId?.toString(),
|
||||
name: span.name,
|
||||
};
|
||||
});
|
||||
}, [spans]);
|
||||
|
||||
// Compute self-times
|
||||
const selfTimes: Map<string, SpanSelfTime> = React.useMemo(() => {
|
||||
return CriticalPathUtil.computeSelfTimes(spanDataList);
|
||||
}, [spanDataList]);
|
||||
|
||||
// Build tree structure
|
||||
const { rootNodes, traceStart, traceEnd } = React.useMemo(() => {
|
||||
if (spans.length === 0) {
|
||||
return { rootNodes: [], traceStart: 0, traceEnd: 0 };
|
||||
}
|
||||
|
||||
const spanMap: Map<string, Span> = new Map();
|
||||
const childrenMap: Map<string, Span[]> = new Map();
|
||||
const allSpanIds: Set<string> = new Set();
|
||||
let tStart: number = spans[0]!.startTimeUnixNano!;
|
||||
let tEnd: number = spans[0]!.endTimeUnixNano!;
|
||||
|
||||
for (const span of spans) {
|
||||
spanMap.set(span.spanId!, span);
|
||||
allSpanIds.add(span.spanId!);
|
||||
if (span.startTimeUnixNano! < tStart) {
|
||||
tStart = span.startTimeUnixNano!;
|
||||
}
|
||||
if (span.endTimeUnixNano! > tEnd) {
|
||||
tEnd = span.endTimeUnixNano!;
|
||||
}
|
||||
}
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.parentSpanId && allSpanIds.has(span.parentSpanId)) {
|
||||
const children: Span[] = childrenMap.get(span.parentSpanId) || [];
|
||||
children.push(span);
|
||||
childrenMap.set(span.parentSpanId, children);
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceInfo: (span: Span) => { color: Color; name: string } = (
|
||||
span: Span,
|
||||
): { color: Color; name: string } => {
|
||||
const service: Service | undefined = telemetryServices.find(
|
||||
(s: Service) => {
|
||||
return s._id?.toString() === span.serviceId?.toString();
|
||||
},
|
||||
);
|
||||
return {
|
||||
color: (service?.serviceColor as Color) || Black,
|
||||
name: service?.name || "Unknown",
|
||||
};
|
||||
};
|
||||
|
||||
const buildNode: (span: Span, depth: number) => FlameGraphNode = (
|
||||
span: Span,
|
||||
depth: number,
|
||||
): FlameGraphNode => {
|
||||
const children: Span[] = childrenMap.get(span.spanId!) || [];
|
||||
const selfTime: SpanSelfTime | undefined = selfTimes.get(span.spanId!);
|
||||
const serviceInfo: { color: Color; name: string } = getServiceInfo(span);
|
||||
|
||||
// Sort children by start time
|
||||
children.sort((a: Span, b: Span) => {
|
||||
return a.startTimeUnixNano! - b.startTimeUnixNano!;
|
||||
});
|
||||
|
||||
return {
|
||||
span,
|
||||
children: children.map((child: Span) => {
|
||||
return buildNode(child, depth + 1);
|
||||
}),
|
||||
depth,
|
||||
startTimeUnixNano: span.startTimeUnixNano!,
|
||||
endTimeUnixNano: span.endTimeUnixNano!,
|
||||
durationUnixNano: span.durationUnixNano!,
|
||||
selfTimeUnixNano: selfTime
|
||||
? selfTime.selfTimeUnixNano
|
||||
: span.durationUnixNano!,
|
||||
serviceColor: serviceInfo.color,
|
||||
serviceName: serviceInfo.name,
|
||||
};
|
||||
};
|
||||
|
||||
// Find root spans
|
||||
const roots: Span[] = spans.filter((span: Span) => {
|
||||
const p: string | undefined = span.parentSpanId;
|
||||
if (!p || p.trim() === "") {
|
||||
return true;
|
||||
}
|
||||
if (!allSpanIds.has(p)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const effectiveRoots: Span[] = roots.length > 0 ? roots : [spans[0]!];
|
||||
|
||||
return {
|
||||
rootNodes: effectiveRoots.map((root: Span) => {
|
||||
return buildNode(root, 0);
|
||||
}),
|
||||
traceStart: tStart,
|
||||
traceEnd: tEnd,
|
||||
};
|
||||
}, [spans, telemetryServices, selfTimes]);
|
||||
|
||||
// Find max depth for height calculation
|
||||
const maxDepth: number = React.useMemo(() => {
|
||||
let max: number = 0;
|
||||
const traverse: (node: FlameGraphNode) => void = (
|
||||
node: FlameGraphNode,
|
||||
): void => {
|
||||
if (node.depth > max) {
|
||||
max = node.depth;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
};
|
||||
for (const root of rootNodes) {
|
||||
traverse(root);
|
||||
}
|
||||
return max;
|
||||
}, [rootNodes]);
|
||||
|
||||
// Find the focused subtree range for zoom
|
||||
const { viewStart, viewEnd } = React.useMemo(() => {
|
||||
if (!focusedSpanId) {
|
||||
return { viewStart: traceStart, viewEnd: traceEnd };
|
||||
}
|
||||
|
||||
const findNode: (nodes: FlameGraphNode[]) => FlameGraphNode | null = (
|
||||
nodes: FlameGraphNode[],
|
||||
): FlameGraphNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.span.spanId === focusedSpanId) {
|
||||
return node;
|
||||
}
|
||||
const found: FlameGraphNode | null = findNode(node.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const focused: FlameGraphNode | null = findNode(rootNodes);
|
||||
if (focused) {
|
||||
return {
|
||||
viewStart: focused.startTimeUnixNano,
|
||||
viewEnd: focused.endTimeUnixNano,
|
||||
};
|
||||
}
|
||||
return { viewStart: traceStart, viewEnd: traceEnd };
|
||||
}, [focusedSpanId, rootNodes, traceStart, traceEnd]);
|
||||
|
||||
const totalDuration: number = viewEnd - viewStart;
|
||||
const rowHeight: number = 24;
|
||||
const chartHeight: number = (maxDepth + 1) * rowHeight + 8;
|
||||
|
||||
if (spans.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">
|
||||
No spans to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderNode: (node: FlameGraphNode) => ReactElement | null = (
|
||||
node: FlameGraphNode,
|
||||
): ReactElement | null => {
|
||||
// Calculate position relative to view
|
||||
const nodeStart: number = Math.max(node.startTimeUnixNano, viewStart);
|
||||
const nodeEnd: number = Math.min(node.endTimeUnixNano, viewEnd);
|
||||
|
||||
if (nodeEnd <= nodeStart) {
|
||||
return null; // Not in view
|
||||
}
|
||||
|
||||
const leftPercent: number =
|
||||
totalDuration > 0 ? ((nodeStart - viewStart) / totalDuration) * 100 : 0;
|
||||
const widthPercent: number =
|
||||
totalDuration > 0 ? ((nodeEnd - nodeStart) / totalDuration) * 100 : 0;
|
||||
|
||||
const isHovered: boolean = hoveredSpanId === node.span.spanId;
|
||||
const isSelected: boolean = selectedSpanId === node.span.spanId;
|
||||
const isFocused: boolean = focusedSpanId === node.span.spanId;
|
||||
|
||||
const durationStr: string = SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: node.durationUnixNano,
|
||||
divisibilityFactor: SpanUtil.getDivisibilityFactor(totalDuration),
|
||||
});
|
||||
|
||||
const selfTimeStr: string = SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: node.selfTimeUnixNano,
|
||||
divisibilityFactor: SpanUtil.getDivisibilityFactor(totalDuration),
|
||||
});
|
||||
|
||||
const colorStr: string = String(node.serviceColor);
|
||||
|
||||
return (
|
||||
<React.Fragment key={node.span.spanId}>
|
||||
<div
|
||||
className={`absolute cursor-pointer border border-white/30 transition-opacity overflow-hidden ${
|
||||
isSelected
|
||||
? "ring-2 ring-indigo-500 ring-offset-1 z-10"
|
||||
: isHovered
|
||||
? "ring-1 ring-gray-400 z-10"
|
||||
: ""
|
||||
} ${isFocused ? "ring-2 ring-amber-400 z-10" : ""}`}
|
||||
style={{
|
||||
left: `${leftPercent}%`,
|
||||
width: `${Math.max(widthPercent, 0.1)}%`,
|
||||
top: `${node.depth * rowHeight}px`,
|
||||
height: `${rowHeight - 2}px`,
|
||||
backgroundColor: colorStr,
|
||||
opacity: isHovered || isSelected ? 1 : 0.85,
|
||||
minWidth: `${MIN_BLOCK_WIDTH_PX}px`,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHoveredSpanId(node.span.spanId!);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredSpanId(null);
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onSpanSelect) {
|
||||
onSpanSelect(node.span.spanId!);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setFocusedSpanId((prev: string | null) => {
|
||||
return prev === node.span.spanId! ? null : node.span.spanId!;
|
||||
});
|
||||
}}
|
||||
title={`${node.span.name} (${node.serviceName})\nDuration: ${durationStr}\nSelf Time: ${selfTimeStr}`}
|
||||
>
|
||||
{widthPercent > 3 ? (
|
||||
<div className="px-1 text-[10px] font-medium text-white truncate leading-snug pt-0.5">
|
||||
{node.span.name}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{node.children.map((child: FlameGraphNode) => {
|
||||
return renderNode(child);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const hoveredNode: FlameGraphNode | null = React.useMemo(() => {
|
||||
if (!hoveredSpanId) {
|
||||
return null;
|
||||
}
|
||||
const findNode: (nodes: FlameGraphNode[]) => FlameGraphNode | null = (
|
||||
nodes: FlameGraphNode[],
|
||||
): FlameGraphNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.span.spanId === hoveredSpanId) {
|
||||
return node;
|
||||
}
|
||||
const found: FlameGraphNode | null = findNode(node.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findNode(rootNodes);
|
||||
}, [hoveredSpanId, rootNodes]);
|
||||
|
||||
return (
|
||||
<div className="flame-graph" ref={containerRef}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<div className="text-[11px] text-gray-500">
|
||||
Click a span to view details. Double-click to zoom into a subtree.
|
||||
</div>
|
||||
{focusedSpanId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFocusedSpanId(null);
|
||||
}}
|
||||
className="text-[11px] font-medium text-indigo-600 hover:text-indigo-700 hover:underline"
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flame graph */}
|
||||
<div
|
||||
className="relative overflow-hidden rounded border border-gray-200 bg-gray-50"
|
||||
style={{ height: `${chartHeight}px` }}
|
||||
>
|
||||
{rootNodes.map((root: FlameGraphNode) => {
|
||||
return renderNode(root);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hoveredNode ? (
|
||||
<div className="mt-2 px-3 py-2 rounded-md border border-gray-200 bg-white/90 text-xs space-y-1">
|
||||
<div className="font-semibold text-gray-800">
|
||||
{hoveredNode.span.name}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Service: </span>
|
||||
{hoveredNode.serviceName}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Duration: </span>
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: hoveredNode.durationUnixNano,
|
||||
divisibilityFactor:
|
||||
SpanUtil.getDivisibilityFactor(totalDuration),
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Self Time: </span>
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: hoveredNode.selfTimeUnixNano,
|
||||
divisibilityFactor:
|
||||
SpanUtil.getDivisibilityFactor(totalDuration),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlameGraph;
|
||||
@@ -1,17 +1,27 @@
|
||||
import DashboardLogsViewer from "../Logs/LogsViewer";
|
||||
import SpanStatusElement from "../Span/SpanStatusElement";
|
||||
import SpanViewer from "../Span/SpanViewer";
|
||||
import FlameGraph from "./FlameGraph";
|
||||
import TraceServiceMap from "./TraceServiceMap";
|
||||
import ServiceElement from "..//Service/ServiceElement";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import SpanUtil, {
|
||||
DivisibilityFactor,
|
||||
IntervalUnit,
|
||||
} from "../../Utils/SpanUtil";
|
||||
import CriticalPathUtil, {
|
||||
SpanData,
|
||||
CriticalPathResult,
|
||||
ServiceBreakdown,
|
||||
} from "Common/Utils/Traces/CriticalPath";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import Color from "Common/Types/Color";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
@@ -33,6 +43,12 @@ import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
|
||||
enum TraceViewMode {
|
||||
Waterfall = "Waterfall",
|
||||
FlameGraph = "Flame Graph",
|
||||
ServiceMap = "Service Map",
|
||||
}
|
||||
|
||||
const INITIAL_SPAN_FETCH_SIZE: number = 500;
|
||||
const SPAN_PAGE_SIZE: number = 500;
|
||||
const MAX_SPAN_FETCH_BATCH: number = LIMIT_PER_PROJECT;
|
||||
@@ -86,6 +102,12 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
|
||||
// UI State Enhancements
|
||||
const [showErrorsOnly, setShowErrorsOnly] = React.useState<boolean>(false);
|
||||
const [viewMode, setViewMode] = React.useState<TraceViewMode>(
|
||||
TraceViewMode.Waterfall,
|
||||
);
|
||||
const [spanSearchText, setSpanSearchText] = React.useState<string>("");
|
||||
const [showCriticalPath, setShowCriticalPath] =
|
||||
React.useState<boolean>(false);
|
||||
|
||||
const [traceId, setTraceId] = React.useState<string | null>(null);
|
||||
|
||||
@@ -329,6 +351,22 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 text-[11px] font-medium"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const profilesRoute: Route = RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROFILES] as Route,
|
||||
);
|
||||
Navigation.navigate(profilesRoute, {
|
||||
openInNewTab: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
View Profiles for this Trace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -654,7 +692,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
}, [servicesInTrace, selectedServiceIds]);
|
||||
|
||||
// Final spans after applying filters
|
||||
// Final spans after applying filters (including search)
|
||||
const displaySpans: Span[] = React.useMemo(() => {
|
||||
let filtered: Span[] = spans;
|
||||
if (showErrorsOnly) {
|
||||
@@ -669,8 +707,83 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
: false;
|
||||
});
|
||||
}
|
||||
if (spanSearchText.trim().length > 0) {
|
||||
const searchLower: string = spanSearchText.trim().toLowerCase();
|
||||
filtered = filtered.filter((s: Span): boolean => {
|
||||
// Match against span name
|
||||
if (s.name?.toLowerCase().includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
// Match against span ID
|
||||
if (s.spanId?.toLowerCase().includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
// Match against service name
|
||||
const service: Service | undefined = telemetryServices.find(
|
||||
(svc: Service) => {
|
||||
return svc._id?.toString() === s.serviceId?.toString();
|
||||
},
|
||||
);
|
||||
if (service?.name?.toLowerCase().includes(searchLower)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [spans, showErrorsOnly, selectedServiceIds]);
|
||||
}, [
|
||||
spans,
|
||||
showErrorsOnly,
|
||||
selectedServiceIds,
|
||||
spanSearchText,
|
||||
telemetryServices,
|
||||
]);
|
||||
|
||||
// Search match count for display
|
||||
const searchMatchCount: number = React.useMemo(() => {
|
||||
if (spanSearchText.trim().length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return displaySpans.length;
|
||||
}, [displaySpans, spanSearchText]);
|
||||
|
||||
// Critical path computation
|
||||
const criticalPathResult: CriticalPathResult | null = React.useMemo(() => {
|
||||
if (!showCriticalPath || spans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const spanDataList: SpanData[] = spans.map((s: Span): SpanData => {
|
||||
return {
|
||||
spanId: s.spanId!,
|
||||
parentSpanId: s.parentSpanId || undefined,
|
||||
startTimeUnixNano: s.startTimeUnixNano!,
|
||||
endTimeUnixNano: s.endTimeUnixNano!,
|
||||
durationUnixNano: s.durationUnixNano!,
|
||||
serviceId: s.serviceId?.toString(),
|
||||
name: s.name,
|
||||
};
|
||||
});
|
||||
return CriticalPathUtil.computeCriticalPath(spanDataList);
|
||||
}, [showCriticalPath, spans]);
|
||||
|
||||
// Service latency breakdown
|
||||
const serviceBreakdown: ServiceBreakdown[] = React.useMemo(() => {
|
||||
if (spans.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const spanDataList: SpanData[] = spans.map((s: Span): SpanData => {
|
||||
return {
|
||||
spanId: s.spanId!,
|
||||
parentSpanId: s.parentSpanId || undefined,
|
||||
startTimeUnixNano: s.startTimeUnixNano!,
|
||||
endTimeUnixNano: s.endTimeUnixNano!,
|
||||
durationUnixNano: s.durationUnixNano!,
|
||||
serviceId: s.serviceId?.toString(),
|
||||
name: s.name,
|
||||
};
|
||||
});
|
||||
return CriticalPathUtil.computeServiceBreakdown(spanDataList);
|
||||
}, [spans]);
|
||||
|
||||
const spanStats: {
|
||||
totalSpans: number;
|
||||
@@ -846,12 +959,28 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
}),
|
||||
);
|
||||
|
||||
const highlightableSpanIds: string[] = highlightSpanIds.filter(
|
||||
// Combine highlight span IDs with critical path span IDs
|
||||
let allHighlightSpanIds: string[] = highlightSpanIds.filter(
|
||||
(spanId: string) => {
|
||||
return displaySpanIds.has(spanId);
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
criticalPathResult &&
|
||||
criticalPathResult.criticalPathSpanIds.length > 0
|
||||
) {
|
||||
const criticalPathIds: string[] =
|
||||
criticalPathResult.criticalPathSpanIds.filter((spanId: string) => {
|
||||
return displaySpanIds.has(spanId);
|
||||
});
|
||||
allHighlightSpanIds = [
|
||||
...new Set([...allHighlightSpanIds, ...criticalPathIds]),
|
||||
];
|
||||
}
|
||||
|
||||
const highlightableSpanIds: string[] = allHighlightSpanIds;
|
||||
|
||||
const ganttChart: GanttChartProps = {
|
||||
id: "chart",
|
||||
selectedBarIds: selectedSpans,
|
||||
@@ -869,7 +998,7 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
setGanttChart(ganttChart);
|
||||
}, [displaySpans, selectedSpans, highlightSpanIds]);
|
||||
}, [displaySpans, selectedSpans, highlightSpanIds, criticalPathResult]);
|
||||
|
||||
if (isLoading && spans.length === 0) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
@@ -1086,56 +1215,134 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
return setShowErrorsOnly(false);
|
||||
}}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all ${
|
||||
!showErrorsOnly
|
||||
? "bg-indigo-600 text-white border-indigo-600 shadow-sm"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
All Spans
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
return setShowErrorsOnly(true);
|
||||
}}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
|
||||
showErrorsOnly
|
||||
? "bg-red-600 text-white border-red-600 shadow-sm"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span>Errors Only</span>
|
||||
{spanStats.errorSpans > 0 ? (
|
||||
<span className="text-[10px] bg-white/20 rounded px-1">
|
||||
{spanStats.errorSpans}
|
||||
</span>
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-0.5">
|
||||
{Object.values(TraceViewMode).map((mode: TraceViewMode) => {
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewMode(mode);
|
||||
}}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md transition-all ${
|
||||
viewMode === mode
|
||||
? "bg-white text-gray-800 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search spans by name, ID, or service..."
|
||||
value={spanSearchText}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSpanSearchText(e.target.value);
|
||||
}}
|
||||
className="text-xs border border-gray-200 rounded-md px-3 py-1.5 w-64 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent placeholder-gray-400"
|
||||
/>
|
||||
{spanSearchText.length > 0 ? (
|
||||
<div className="absolute right-2 flex items-center space-x-1">
|
||||
<span className="text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{searchMatchCount} of {spans.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSpanSearchText("");
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-2 w-2 rounded-full bg-rose-500" />
|
||||
<span>Error</span>
|
||||
{/* Toolbar Row 2: Filters & Controls */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
return setShowErrorsOnly(false);
|
||||
}}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all ${
|
||||
!showErrorsOnly
|
||||
? "bg-indigo-600 text-white border-indigo-600 shadow-sm"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
All Spans
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
return setShowErrorsOnly(true);
|
||||
}}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
|
||||
showErrorsOnly
|
||||
? "bg-red-600 text-white border-red-600 shadow-sm"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span>Errors Only</span>
|
||||
{spanStats.errorSpans > 0 ? (
|
||||
<span className="text-[10px] bg-white/20 rounded px-1">
|
||||
{spanStats.errorSpans}
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Critical Path Toggle */}
|
||||
{viewMode === TraceViewMode.Waterfall ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCriticalPath((prev: boolean) => {
|
||||
return !prev;
|
||||
});
|
||||
}}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md border transition-all flex items-center space-x-1 ${
|
||||
showCriticalPath
|
||||
? "bg-amber-500 text-white border-amber-500 shadow-sm"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span>Critical Path</span>
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span>OK</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span>Other</span>
|
||||
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-2 w-2 rounded-full bg-rose-500" />
|
||||
<span>Error</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span>OK</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span>Other</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1236,13 +1443,124 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
{ganttChart ? (
|
||||
<GanttChart chart={ganttChart} />
|
||||
) : (
|
||||
<div className="p-8">
|
||||
<ErrorMessage message={"No spans found"} />
|
||||
{/* Service Latency Breakdown */}
|
||||
{serviceBreakdown.length > 1 ? (
|
||||
<div className="mb-4 border border-gray-100 rounded-lg p-3 bg-gradient-to-br from-gray-50/60 to-white">
|
||||
<div className="text-[11px] uppercase tracking-wide text-gray-500 font-medium mb-2">
|
||||
Latency Breakdown by Service
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{serviceBreakdown.map((breakdown: ServiceBreakdown) => {
|
||||
const service: Service | undefined = telemetryServices.find(
|
||||
(s: Service) => {
|
||||
return s._id?.toString() === breakdown.serviceId;
|
||||
},
|
||||
);
|
||||
const serviceName: string = service?.name || "Unknown";
|
||||
const serviceColor: string = String(
|
||||
(service?.serviceColor as unknown as string) || "#6366f1",
|
||||
);
|
||||
const percent: number = Math.min(
|
||||
breakdown.percentOfTrace,
|
||||
100,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={breakdown.serviceId}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm ring-1 ring-black/10 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: serviceColor,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] font-medium text-gray-700 w-24 truncate">
|
||||
{serviceName}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.max(percent, 1)}%`,
|
||||
backgroundColor: serviceColor,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 w-20 text-right">
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: breakdown.selfTimeUnixNano,
|
||||
divisibilityFactor: divisibilityFactor,
|
||||
})}{" "}
|
||||
({percent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/* Critical Path Info */}
|
||||
{showCriticalPath && criticalPathResult ? (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
<span className="font-medium">Critical Path:</span>{" "}
|
||||
{criticalPathResult.criticalPathSpanIds.length} spans,{" "}
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano:
|
||||
criticalPathResult.criticalPathDurationUnixNano,
|
||||
divisibilityFactor: divisibilityFactor,
|
||||
})}{" "}
|
||||
of{" "}
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano:
|
||||
criticalPathResult.totalTraceDurationUnixNano,
|
||||
divisibilityFactor: divisibilityFactor,
|
||||
})}{" "}
|
||||
total trace duration (highlighted in waterfall)
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/* Main Visualization */}
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
{viewMode === TraceViewMode.Waterfall ? (
|
||||
<>
|
||||
{ganttChart ? (
|
||||
<GanttChart chart={ganttChart} />
|
||||
) : (
|
||||
<div className="p-8">
|
||||
<ErrorMessage message={"No spans found"} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : viewMode === TraceViewMode.FlameGraph ? (
|
||||
<div className="p-4">
|
||||
<FlameGraph
|
||||
spans={displaySpans}
|
||||
telemetryServices={telemetryServices}
|
||||
onSpanSelect={(spanId: string) => {
|
||||
setSelectedSpans([spanId]);
|
||||
}}
|
||||
selectedSpanId={
|
||||
selectedSpans.length > 0 ? selectedSpans[0] : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === TraceViewMode.ServiceMap ? (
|
||||
<div className="p-4">
|
||||
<TraceServiceMap
|
||||
spans={displaySpans}
|
||||
telemetryServices={telemetryServices}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1261,43 +1579,50 @@ const TraceExplorer: FunctionComponent<ComponentProps> = (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{selectedSpans.length > 0 ? (
|
||||
<SideOver
|
||||
title="View Span"
|
||||
description="View the span details."
|
||||
onClose={() => {
|
||||
setSelectedSpans([]);
|
||||
}}
|
||||
size={SideOverSize.Large}
|
||||
>
|
||||
<SpanViewer
|
||||
id={"span-viewer"}
|
||||
openTelemetrySpanId={selectedSpans[0] as string}
|
||||
traceStartTimeInUnixNano={spans[0]!.startTimeUnixNano!}
|
||||
onClose={() => {
|
||||
setSelectedSpans([]);
|
||||
}}
|
||||
telemetryService={
|
||||
telemetryServices.find((service: Service) => {
|
||||
const selectedSpan: Span | undefined = spans.find(
|
||||
(span: Span) => {
|
||||
return span.spanId?.toString() === selectedSpans[0]!;
|
||||
},
|
||||
);
|
||||
{selectedSpans.length > 0 && spans.length > 0 ? (
|
||||
(() => {
|
||||
const selectedSpan: Span | undefined = spans.find((span: Span) => {
|
||||
return span.spanId?.toString() === selectedSpans[0]!;
|
||||
});
|
||||
|
||||
if (!selectedSpan) {
|
||||
throw new BadDataException("Selected span not found");
|
||||
}
|
||||
if (!selectedSpan) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
service._id?.toString() ===
|
||||
selectedSpan.serviceId?.toString()
|
||||
);
|
||||
})!
|
||||
}
|
||||
divisibilityFactor={divisibilityFactor}
|
||||
/>
|
||||
</SideOver>
|
||||
const telemetryService: Service | undefined =
|
||||
telemetryServices.find((service: Service) => {
|
||||
return (
|
||||
service._id?.toString() === selectedSpan.serviceId?.toString()
|
||||
);
|
||||
});
|
||||
|
||||
if (!telemetryService) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SideOver
|
||||
title="View Span"
|
||||
description="View the span details."
|
||||
onClose={() => {
|
||||
setSelectedSpans([]);
|
||||
}}
|
||||
size={SideOverSize.Large}
|
||||
>
|
||||
<SpanViewer
|
||||
id={"span-viewer"}
|
||||
openTelemetrySpanId={selectedSpans[0] as string}
|
||||
traceStartTimeInUnixNano={spans[0]!.startTimeUnixNano!}
|
||||
onClose={() => {
|
||||
setSelectedSpans([]);
|
||||
}}
|
||||
telemetryService={telemetryService}
|
||||
divisibilityFactor={divisibilityFactor}
|
||||
allTraceSpans={spans}
|
||||
/>
|
||||
</SideOver>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import SpanUtil, { DivisibilityFactor } from "../../Utils/SpanUtil";
|
||||
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface TraceServiceMapProps {
|
||||
spans: Span[];
|
||||
telemetryServices: Service[];
|
||||
}
|
||||
|
||||
interface ServiceNode {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
serviceColor: string;
|
||||
spanCount: number;
|
||||
errorCount: number;
|
||||
totalDurationUnixNano: number;
|
||||
}
|
||||
|
||||
interface ServiceEdge {
|
||||
fromServiceId: string;
|
||||
toServiceId: string;
|
||||
callCount: number;
|
||||
totalDurationUnixNano: number;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
const TraceServiceMap: FunctionComponent<TraceServiceMapProps> = (
|
||||
props: TraceServiceMapProps,
|
||||
): ReactElement => {
|
||||
const { spans, telemetryServices } = props;
|
||||
|
||||
// Build nodes and edges from spans
|
||||
const { nodes, edges } = React.useMemo(() => {
|
||||
const nodeMap: Map<string, ServiceNode> = new Map();
|
||||
const edgeMap: Map<string, ServiceEdge> = new Map();
|
||||
const spanServiceMap: Map<string, string> = new Map(); // spanId -> serviceId
|
||||
|
||||
// First pass: build span -> service mapping and service nodes
|
||||
for (const span of spans) {
|
||||
const serviceId: string = span.serviceId?.toString() || "unknown";
|
||||
spanServiceMap.set(span.spanId!, serviceId);
|
||||
|
||||
const existing: ServiceNode | undefined = nodeMap.get(serviceId);
|
||||
if (existing) {
|
||||
existing.spanCount += 1;
|
||||
existing.totalDurationUnixNano += span.durationUnixNano!;
|
||||
if (span.statusCode === SpanStatus.Error) {
|
||||
existing.errorCount += 1;
|
||||
}
|
||||
} else {
|
||||
const service: Service | undefined = telemetryServices.find(
|
||||
(s: Service) => {
|
||||
return s._id?.toString() === serviceId;
|
||||
},
|
||||
);
|
||||
nodeMap.set(serviceId, {
|
||||
serviceId,
|
||||
serviceName: service?.name || "Unknown",
|
||||
serviceColor: String(
|
||||
(service?.serviceColor as unknown as string) || "#6366f1",
|
||||
),
|
||||
spanCount: 1,
|
||||
errorCount: span.statusCode === SpanStatus.Error ? 1 : 0,
|
||||
totalDurationUnixNano: span.durationUnixNano!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build edges from parent-child relationships
|
||||
for (const span of spans) {
|
||||
if (!span.parentSpanId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parentServiceId: string | undefined = spanServiceMap.get(
|
||||
span.parentSpanId,
|
||||
);
|
||||
const childServiceId: string = span.serviceId?.toString() || "unknown";
|
||||
|
||||
if (!parentServiceId || parentServiceId === childServiceId) {
|
||||
continue; // Skip same-service calls
|
||||
}
|
||||
|
||||
const edgeKey: string = `${parentServiceId}->${childServiceId}`;
|
||||
const existing: ServiceEdge | undefined = edgeMap.get(edgeKey);
|
||||
if (existing) {
|
||||
existing.callCount += 1;
|
||||
existing.totalDurationUnixNano += span.durationUnixNano!;
|
||||
if (span.statusCode === SpanStatus.Error) {
|
||||
existing.errorCount += 1;
|
||||
}
|
||||
} else {
|
||||
edgeMap.set(edgeKey, {
|
||||
fromServiceId: parentServiceId,
|
||||
toServiceId: childServiceId,
|
||||
callCount: 1,
|
||||
totalDurationUnixNano: span.durationUnixNano!,
|
||||
errorCount: span.statusCode === SpanStatus.Error ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodeMap.values()),
|
||||
edges: Array.from(edgeMap.values()),
|
||||
};
|
||||
}, [spans, telemetryServices]);
|
||||
|
||||
// Compute trace duration for context
|
||||
const traceDuration: number = React.useMemo(() => {
|
||||
if (spans.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
let minStart: number = spans[0]!.startTimeUnixNano!;
|
||||
let maxEnd: number = spans[0]!.endTimeUnixNano!;
|
||||
for (const span of spans) {
|
||||
if (span.startTimeUnixNano! < minStart) {
|
||||
minStart = span.startTimeUnixNano!;
|
||||
}
|
||||
if (span.endTimeUnixNano! > maxEnd) {
|
||||
maxEnd = span.endTimeUnixNano!;
|
||||
}
|
||||
}
|
||||
return maxEnd - minStart;
|
||||
}, [spans]);
|
||||
|
||||
const divisibilityFactor: DivisibilityFactor =
|
||||
SpanUtil.getDivisibilityFactor(traceDuration);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">
|
||||
No services found in this trace
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Layout: arrange nodes in a topological order based on edges
|
||||
* Simple layout: find entry nodes and lay out left-to-right
|
||||
*/
|
||||
const { nodePositions, layoutWidth, layoutHeight } = React.useMemo(() => {
|
||||
// Build adjacency list
|
||||
const adjList: Map<string, string[]> = new Map();
|
||||
const inDegree: Map<string, number> = new Map();
|
||||
|
||||
for (const node of nodes) {
|
||||
adjList.set(node.serviceId, []);
|
||||
inDegree.set(node.serviceId, 0);
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
const neighbors: string[] = adjList.get(edge.fromServiceId) || [];
|
||||
neighbors.push(edge.toServiceId);
|
||||
adjList.set(edge.fromServiceId, neighbors);
|
||||
inDegree.set(edge.toServiceId, (inDegree.get(edge.toServiceId) || 0) + 1);
|
||||
}
|
||||
|
||||
// Topological sort using BFS (Kahn's algorithm)
|
||||
const queue: string[] = [];
|
||||
for (const [nodeId, degree] of inDegree.entries()) {
|
||||
if (degree === 0) {
|
||||
queue.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const levels: Map<string, number> = new Map();
|
||||
let level: number = 0;
|
||||
const levelNodes: string[][] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const levelSize: number = queue.length;
|
||||
const currentLevel: string[] = [];
|
||||
|
||||
for (let i: number = 0; i < levelSize; i++) {
|
||||
const nodeId: string = queue.shift()!;
|
||||
levels.set(nodeId, level);
|
||||
currentLevel.push(nodeId);
|
||||
|
||||
const neighbors: string[] = adjList.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
const newDegree: number = (inDegree.get(neighbor) || 1) - 1;
|
||||
inDegree.set(neighbor, newDegree);
|
||||
if (newDegree === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
levelNodes.push(currentLevel);
|
||||
level++;
|
||||
}
|
||||
|
||||
// Handle cycles - place unvisited nodes at the end
|
||||
for (const node of nodes) {
|
||||
if (!levels.has(node.serviceId)) {
|
||||
if (levelNodes.length === 0) {
|
||||
levelNodes.push([]);
|
||||
}
|
||||
levelNodes[levelNodes.length - 1]!.push(node.serviceId);
|
||||
levels.set(node.serviceId, levelNodes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute positions
|
||||
const nodeWidth: number = 200;
|
||||
const nodeHeight: number = 80;
|
||||
const horizontalGap: number = 120;
|
||||
const verticalGap: number = 40;
|
||||
|
||||
const positions: Map<string, { x: number; y: number }> = new Map();
|
||||
let maxX: number = 0;
|
||||
let maxY: number = 0;
|
||||
|
||||
for (let l: number = 0; l < levelNodes.length; l++) {
|
||||
const levelNodeIds: string[] = levelNodes[l]!;
|
||||
const x: number = l * (nodeWidth + horizontalGap) + 20;
|
||||
|
||||
for (let n: number = 0; n < levelNodeIds.length; n++) {
|
||||
const y: number = n * (nodeHeight + verticalGap) + 20;
|
||||
positions.set(levelNodeIds[n]!, { x, y });
|
||||
if (x + nodeWidth > maxX) {
|
||||
maxX = x + nodeWidth;
|
||||
}
|
||||
if (y + nodeHeight > maxY) {
|
||||
maxY = y + nodeHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodePositions: positions,
|
||||
layoutWidth: maxX + 40,
|
||||
layoutHeight: maxY + 40,
|
||||
};
|
||||
}, [nodes, edges]);
|
||||
|
||||
const nodeWidth: number = 200;
|
||||
const nodeHeight: number = 80;
|
||||
|
||||
return (
|
||||
<div className="trace-service-map">
|
||||
<div className="text-[11px] text-gray-500 mb-2 px-1">
|
||||
Service flow for this trace. Arrows show cross-service calls with count
|
||||
and latency.
|
||||
</div>
|
||||
<div
|
||||
className="relative overflow-auto rounded border border-gray-200 bg-gray-50"
|
||||
style={{
|
||||
minHeight: `${Math.max(layoutHeight, 200)}px`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={layoutWidth}
|
||||
height={layoutHeight}
|
||||
className="absolute top-0 left-0"
|
||||
>
|
||||
{/* Render edges */}
|
||||
{edges.map((edge: ServiceEdge) => {
|
||||
const fromPos: { x: number; y: number } | undefined =
|
||||
nodePositions.get(edge.fromServiceId);
|
||||
const toPos: { x: number; y: number } | undefined =
|
||||
nodePositions.get(edge.toServiceId);
|
||||
|
||||
if (!fromPos || !toPos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x1: number = fromPos.x + nodeWidth;
|
||||
const y1: number = fromPos.y + nodeHeight / 2;
|
||||
const x2: number = toPos.x;
|
||||
const y2: number = toPos.y + nodeHeight / 2;
|
||||
|
||||
const midX: number = (x1 + x2) / 2;
|
||||
|
||||
const hasError: boolean = edge.errorCount > 0;
|
||||
const strokeColor: string = hasError ? "#ef4444" : "#9ca3af";
|
||||
|
||||
const avgDuration: number =
|
||||
edge.callCount > 0
|
||||
? edge.totalDurationUnixNano / edge.callCount
|
||||
: 0;
|
||||
const durationStr: string = SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: avgDuration,
|
||||
divisibilityFactor: divisibilityFactor,
|
||||
});
|
||||
|
||||
const edgeKey: string = `${edge.fromServiceId}->${edge.toServiceId}`;
|
||||
|
||||
return (
|
||||
<g key={edgeKey}>
|
||||
{/* Curved path */}
|
||||
<path
|
||||
d={`M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={Math.min(2 + edge.callCount * 0.5, 5)}
|
||||
strokeDasharray={hasError ? "4,4" : "none"}
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
{/* Label */}
|
||||
<text
|
||||
x={midX}
|
||||
y={(y1 + y2) / 2 - 8}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-gray-500"
|
||||
>
|
||||
{edge.callCount}x | avg {durationStr}
|
||||
</text>
|
||||
{hasError ? (
|
||||
<text
|
||||
x={midX}
|
||||
y={(y1 + y2) / 2 + 6}
|
||||
textAnchor="middle"
|
||||
className="text-[9px] fill-red-500 font-medium"
|
||||
>
|
||||
{edge.errorCount} error{edge.errorCount > 1 ? "s" : ""}
|
||||
</text>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Arrow marker definition */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="8"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#9ca3af" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Render nodes */}
|
||||
{nodes.map((node: ServiceNode) => {
|
||||
const pos: { x: number; y: number } | undefined = nodePositions.get(
|
||||
node.serviceId,
|
||||
);
|
||||
if (!pos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasErrors: boolean = node.errorCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.serviceId}
|
||||
className={`absolute rounded-lg border-2 bg-white shadow-sm p-3 ${
|
||||
hasErrors ? "border-red-300" : "border-gray-200"
|
||||
}`}
|
||||
style={{
|
||||
left: `${pos.x}px`,
|
||||
top: `${pos.y}px`,
|
||||
width: `${nodeWidth}px`,
|
||||
height: `${nodeHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm ring-1 ring-black/10 flex-shrink-0"
|
||||
style={{ backgroundColor: node.serviceColor }}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-gray-800 truncate">
|
||||
{node.serviceName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 text-[10px] text-gray-500">
|
||||
<span>{node.spanCount} spans</span>
|
||||
{hasErrors ? (
|
||||
<span className="text-red-600 font-medium">
|
||||
{node.errorCount} errors
|
||||
</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{SpanUtil.getSpanDurationAsString({
|
||||
spanDurationInUnixNano: node.totalDurationUnixNano,
|
||||
divisibilityFactor: divisibilityFactor,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TraceServiceMap;
|
||||
@@ -44,6 +44,9 @@ export interface ComponentProps {
|
||||
spanQuery?: Query<Span> | undefined;
|
||||
isMinimalTable?: boolean | undefined;
|
||||
noItemsMessage?: string | undefined;
|
||||
onFetchSuccess?:
|
||||
| ((data: Array<Span>, totalCount: number) => void)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const TraceTable: FunctionComponent<ComponentProps> = (
|
||||
@@ -298,14 +301,22 @@ const TraceTable: FunctionComponent<ComponentProps> = (
|
||||
noItemsMessage={
|
||||
props.noItemsMessage ? props.noItemsMessage : "No spans found."
|
||||
}
|
||||
onFetchSuccess={props.onFetchSuccess}
|
||||
showRefreshButton={true}
|
||||
sortBy="startTime"
|
||||
sortOrder={SortOrder.Descending}
|
||||
onViewPage={(span: Span) => {
|
||||
if (modelId) {
|
||||
return Promise.resolve(
|
||||
new Route(viewRoute.toString()).addRoute(
|
||||
span.traceId!.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Route(viewRoute.toString()).addRoute(
|
||||
span.traceId!.toString(),
|
||||
),
|
||||
RouteUtil.populateRouteParams(RouteMap[PageMap.TRACE_VIEW]!, {
|
||||
modelId: span.traceId!.toString(),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
filters={[
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import Service from "Common/Models/DatabaseModels/Service";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import API from "Common/Utils/API";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import AnalyticsModelAPI from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
import SpanStatusElement from "../Span/SpanStatusElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import AppLink from "../AppLink/AppLink";
|
||||
|
||||
interface ServiceTraceSummary {
|
||||
service: Service;
|
||||
totalTraces: number;
|
||||
errorTraces: number;
|
||||
latestTraceTime: Date | null;
|
||||
p50Nanos: number;
|
||||
p95Nanos: number;
|
||||
durations: Array<number>;
|
||||
}
|
||||
|
||||
interface RecentTrace {
|
||||
traceId: string;
|
||||
name: string;
|
||||
serviceId: string;
|
||||
startTime: Date;
|
||||
statusCode: SpanStatus;
|
||||
durationNano: number;
|
||||
}
|
||||
|
||||
const formatDuration: (nanos: number) => string = (nanos: number): string => {
|
||||
if (nanos >= 1_000_000_000) {
|
||||
return `${(nanos / 1_000_000_000).toFixed(2)}s`;
|
||||
}
|
||||
if (nanos >= 1_000_000) {
|
||||
return `${(nanos / 1_000_000).toFixed(1)}ms`;
|
||||
}
|
||||
if (nanos >= 1_000) {
|
||||
return `${(nanos / 1_000).toFixed(0)}us`;
|
||||
}
|
||||
return `${nanos}ns`;
|
||||
};
|
||||
|
||||
const getPercentile: (arr: Array<number>, p: number) => number = (
|
||||
arr: Array<number>,
|
||||
p: number,
|
||||
): number => {
|
||||
if (arr.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted: Array<number> = [...arr].sort((a: number, b: number) => {
|
||||
return a - b;
|
||||
});
|
||||
const idx: number = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, idx)] || 0;
|
||||
};
|
||||
|
||||
const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceTraceSummary>
|
||||
>([]);
|
||||
const [recentErrorTraces, setRecentErrorTraces] = useState<
|
||||
Array<RecentTrace>
|
||||
>([]);
|
||||
const [recentSlowTraces, setRecentSlowTraces] = useState<Array<RecentTrace>>(
|
||||
[],
|
||||
);
|
||||
const [services, setServices] = useState<Array<Service>>([]);
|
||||
const [totalRequests, setTotalRequests] = useState<number>(0);
|
||||
const [totalErrors, setTotalErrors] = useState<number>(0);
|
||||
const [globalP50, setGlobalP50] = useState<number>(0);
|
||||
const [globalP95, setGlobalP95] = useState<number>(0);
|
||||
const [globalP99, setGlobalP99] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
|
||||
const [servicesResult, spansResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
AnalyticsModelAPI.getList({
|
||||
modelType: Span,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
startTime: new InBetween(oneHourAgo, now),
|
||||
},
|
||||
select: {
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
parentSpanId: true,
|
||||
serviceId: true,
|
||||
name: true,
|
||||
startTime: true,
|
||||
statusCode: true,
|
||||
durationUnixNano: true,
|
||||
},
|
||||
limit: 5000,
|
||||
skip: 0,
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const loadedServices: Array<Service> = servicesResult.data || [];
|
||||
setServices(loadedServices);
|
||||
|
||||
const allSpans: Array<Span> = spansResult.data || [];
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceTraceSummary> = new Map();
|
||||
|
||||
for (const service of loadedServices) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
summaryMap.set(serviceId, {
|
||||
service,
|
||||
totalTraces: 0,
|
||||
errorTraces: 0,
|
||||
latestTraceTime: null,
|
||||
p50Nanos: 0,
|
||||
p95Nanos: 0,
|
||||
durations: [],
|
||||
});
|
||||
}
|
||||
|
||||
const serviceTraceIds: Map<string, Set<string>> = new Map();
|
||||
const serviceErrorTraceIds: Map<string, Set<string>> = new Map();
|
||||
const errorTraces: Array<RecentTrace> = [];
|
||||
const allTraces: Array<RecentTrace> = [];
|
||||
const seenTraceIds: Set<string> = new Set();
|
||||
const seenErrorTraceIds: Set<string> = new Set();
|
||||
const allDurations: Array<number> = [];
|
||||
|
||||
for (const span of allSpans) {
|
||||
const serviceId: string = span.serviceId?.toString() || "";
|
||||
const traceId: string = span.traceId?.toString() || "";
|
||||
const duration: number = (span.durationUnixNano as number) || 0;
|
||||
const summary: ServiceTraceSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (duration > 0) {
|
||||
allDurations.push(duration);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
if (!serviceTraceIds.has(serviceId)) {
|
||||
serviceTraceIds.set(serviceId, new Set());
|
||||
}
|
||||
if (!serviceErrorTraceIds.has(serviceId)) {
|
||||
serviceErrorTraceIds.set(serviceId, new Set());
|
||||
}
|
||||
|
||||
const traceSet: Set<string> = serviceTraceIds.get(serviceId)!;
|
||||
if (!traceSet.has(traceId)) {
|
||||
traceSet.add(traceId);
|
||||
summary.totalTraces += 1;
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
summary.durations.push(duration);
|
||||
}
|
||||
|
||||
if (span.statusCode === SpanStatus.Error) {
|
||||
const errorSet: Set<string> = serviceErrorTraceIds.get(serviceId)!;
|
||||
if (!errorSet.has(traceId)) {
|
||||
errorSet.add(traceId);
|
||||
summary.errorTraces += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const spanTime: Date | undefined = span.startTime
|
||||
? new Date(span.startTime)
|
||||
: undefined;
|
||||
if (
|
||||
spanTime &&
|
||||
(!summary.latestTraceTime || spanTime > summary.latestTraceTime)
|
||||
) {
|
||||
summary.latestTraceTime = spanTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (!seenTraceIds.has(traceId) && traceId) {
|
||||
seenTraceIds.add(traceId);
|
||||
allTraces.push({
|
||||
traceId,
|
||||
name: span.name?.toString() || "Unknown",
|
||||
serviceId,
|
||||
startTime: span.startTime ? new Date(span.startTime) : new Date(),
|
||||
statusCode: span.statusCode || SpanStatus.Unset,
|
||||
durationNano: duration,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
span.statusCode === SpanStatus.Error &&
|
||||
traceId &&
|
||||
!seenErrorTraceIds.has(traceId)
|
||||
) {
|
||||
seenErrorTraceIds.add(traceId);
|
||||
errorTraces.push({
|
||||
traceId,
|
||||
name: span.name?.toString() || "Unknown",
|
||||
serviceId,
|
||||
startTime: span.startTime ? new Date(span.startTime) : new Date(),
|
||||
statusCode: span.statusCode,
|
||||
durationNano: duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Compute global percentiles
|
||||
setGlobalP50(getPercentile(allDurations, 50));
|
||||
setGlobalP95(getPercentile(allDurations, 95));
|
||||
setGlobalP99(getPercentile(allDurations, 99));
|
||||
|
||||
// Compute per-service percentiles and filter
|
||||
const summariesWithData: Array<ServiceTraceSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
)
|
||||
.filter((s: ServiceTraceSummary) => {
|
||||
return s.totalTraces > 0;
|
||||
})
|
||||
.map((s: ServiceTraceSummary) => {
|
||||
return {
|
||||
...s,
|
||||
p50Nanos: getPercentile(s.durations, 50),
|
||||
p95Nanos: getPercentile(s.durations, 95),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: highest error rate first, then by total traces
|
||||
summariesWithData.sort(
|
||||
(a: ServiceTraceSummary, b: ServiceTraceSummary) => {
|
||||
const aErrorRate: number =
|
||||
a.totalTraces > 0 ? a.errorTraces / a.totalTraces : 0;
|
||||
const bErrorRate: number =
|
||||
b.totalTraces > 0 ? b.errorTraces / b.totalTraces : 0;
|
||||
if (bErrorRate !== aErrorRate) {
|
||||
return bErrorRate - aErrorRate;
|
||||
}
|
||||
return b.totalTraces - a.totalTraces;
|
||||
},
|
||||
);
|
||||
|
||||
let totalReqs: number = 0;
|
||||
let totalErrs: number = 0;
|
||||
for (const s of summariesWithData) {
|
||||
totalReqs += s.totalTraces;
|
||||
totalErrs += s.errorTraces;
|
||||
}
|
||||
setTotalRequests(totalReqs);
|
||||
setTotalErrors(totalErrs);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
setRecentErrorTraces(errorTraces.slice(0, 8));
|
||||
|
||||
const slowTraces: Array<RecentTrace> = [...allTraces]
|
||||
.sort((a: RecentTrace, b: RecentTrace) => {
|
||||
return b.durationNano - a.durationNano;
|
||||
})
|
||||
.slice(0, 8);
|
||||
setRecentSlowTraces(slowTraces);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, []);
|
||||
|
||||
const getServiceName: (serviceId: string) => string = (
|
||||
serviceId: string,
|
||||
): string => {
|
||||
const service: Service | undefined = services.find((s: Service) => {
|
||||
return s.id?.toString() === serviceId;
|
||||
});
|
||||
return service?.name?.toString() || "Unknown";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRefreshClick={() => {
|
||||
void loadDashboard();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No trace data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending distributed tracing data, you{"'"}ll
|
||||
see request rates, error rates, latency percentiles, and more.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overallErrorRate: number =
|
||||
totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm font-medium text-gray-500">Requests</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{totalRequests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">last hour</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${overallErrorRate > 5 ? "border-red-200 bg-red-50" : overallErrorRate > 1 ? "border-amber-200 bg-amber-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">Error Rate</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${overallErrorRate > 5 ? "text-red-600" : overallErrorRate > 1 ? "text-amber-600" : "text-green-600"}`}
|
||||
>
|
||||
{overallErrorRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{totalErrors.toLocaleString()} errors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm font-medium text-gray-500">P50 Latency</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{formatDuration(globalP50)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">median</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${globalP95 > 1_000_000_000 ? "border-amber-200 bg-amber-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">P95 Latency</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${globalP95 > 1_000_000_000 ? "text-amber-600" : "text-gray-900"}`}
|
||||
>
|
||||
{formatDuration(globalP95)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">95th percentile</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${globalP99 > 2_000_000_000 ? "border-red-200 bg-red-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">P99 Latency</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${globalP99 > 2_000_000_000 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{formatDuration(globalP99)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">99th percentile</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Health Table */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Service Health
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Sorted by error rate — services needing attention first
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACES_LIST] as Route,
|
||||
)}
|
||||
>
|
||||
View all spans
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Service
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Requests
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Error Rate
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
P50
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
P95
|
||||
</th>
|
||||
<th className="text-center text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{serviceSummaries.map((summary: ServiceTraceSummary) => {
|
||||
const errorRate: number =
|
||||
summary.totalTraces > 0
|
||||
? (summary.errorTraces / summary.totalTraces) * 100
|
||||
: 0;
|
||||
|
||||
let healthColor: string = "bg-green-500";
|
||||
let healthLabel: string = "Healthy";
|
||||
let healthBg: string = "bg-green-50 text-green-700";
|
||||
if (errorRate > 10) {
|
||||
healthColor = "bg-red-500";
|
||||
healthLabel = "Critical";
|
||||
healthBg = "bg-red-50 text-red-700";
|
||||
} else if (errorRate > 5) {
|
||||
healthColor = "bg-amber-500";
|
||||
healthLabel = "Degraded";
|
||||
healthBg = "bg-amber-50 text-amber-700";
|
||||
} else if (errorRate > 1) {
|
||||
healthColor = "bg-yellow-400";
|
||||
healthLabel = "Warning";
|
||||
healthBg = "bg-yellow-50 text-yellow-700";
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={summary.service.id?.toString()}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<ServiceElement service={summary.service} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{summary.totalTraces.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${errorRate > 10 ? "bg-red-500" : errorRate > 5 ? "bg-amber-400" : errorRate > 0 ? "bg-yellow-400" : "bg-green-400"}`}
|
||||
style={{
|
||||
width: `${Math.max(errorRate, errorRate > 0 ? 3 : 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${errorRate > 5 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{errorRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-mono text-gray-700">
|
||||
{formatDuration(summary.p50Nanos)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-mono text-gray-700">
|
||||
{formatDuration(summary.p95Nanos)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${healthBg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${healthColor}`}
|
||||
/>
|
||||
{healthLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<AppLink
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_TRACES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
summary.service._id as string,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
View
|
||||
</AppLink>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column: Errors + Slow Requests */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Errors */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Recent Errors
|
||||
</h3>
|
||||
{recentErrorTraces.length > 0 && (
|
||||
<span className="text-xs bg-red-50 text-red-700 px-2 py-0.5 rounded-full font-medium">
|
||||
{recentErrorTraces.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recentErrorTraces.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-10 text-center">
|
||||
<div className="mx-auto w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
No errors in the last hour
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Looking good!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentErrorTraces.map((trace: RecentTrace, index: number) => {
|
||||
return (
|
||||
<AppLink
|
||||
key={`${trace.traceId}-${index}`}
|
||||
className="block px-4 py-3 hover:bg-red-50/30 transition-colors"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{ modelId: trace.traceId },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<SpanStatusElement
|
||||
spanStatusCode={trace.statusCode}
|
||||
title=""
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{trace.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getServiceName(trace.serviceId)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-3">
|
||||
<p className="text-xs font-mono text-gray-600">
|
||||
{formatDuration(trace.durationNano)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(trace.startTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slowest Requests */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Slowest Requests
|
||||
</h3>
|
||||
</div>
|
||||
{recentSlowTraces.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-10 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
No traces in the last hour
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentSlowTraces.map((trace: RecentTrace, index: number) => {
|
||||
const maxDuration: number =
|
||||
recentSlowTraces[0]?.durationNano || 1;
|
||||
const barWidth: number =
|
||||
(trace.durationNano / maxDuration) * 100;
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={`${trace.traceId}-slow-${index}`}
|
||||
className="block px-4 py-3 hover:bg-amber-50/30 transition-colors"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{ modelId: trace.traceId },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<SpanStatusElement
|
||||
spanStatusCode={trace.statusCode}
|
||||
title=""
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{trace.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getServiceName(trace.serviceId)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-3">
|
||||
<p className="text-sm font-mono font-semibold text-gray-900">
|
||||
{formatDuration(trace.durationNano)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-8">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default TracesDashboard;
|
||||
@@ -0,0 +1,790 @@
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
import Monitor from "Common/Models/DatabaseModels/Monitor";
|
||||
import AlertSeverity from "Common/Models/DatabaseModels/AlertSeverity";
|
||||
import AlertState from "Common/Models/DatabaseModels/AlertState";
|
||||
import IncidentSeverity from "Common/Models/DatabaseModels/IncidentSeverity";
|
||||
import IncidentState from "Common/Models/DatabaseModels/IncidentState";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import WorkspaceType, {
|
||||
getWorkspaceTypeDisplayName,
|
||||
} from "Common/Types/Workspace/WorkspaceType";
|
||||
import WorkspaceNotificationSummary from "Common/Models/DatabaseModels/WorkspaceNotificationSummary";
|
||||
import WorkspaceNotificationSummaryType from "Common/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryType";
|
||||
import WorkspaceNotificationSummaryItem from "Common/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryItem";
|
||||
import NotificationRuleEventType from "Common/Types/Workspace/NotificationRules/EventType";
|
||||
import NotificationRuleCondition from "Common/Types/Workspace/NotificationRules/NotificationRuleCondition";
|
||||
import NotificationRuleConditions from "./NotificationRuleForm/NotificationRuleConditions";
|
||||
import FilterCondition from "Common/Types/Filter/FilterCondition";
|
||||
import API from "Common/Utils/API";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import Exception from "Common/Types/Exception/Exception";
|
||||
import { ErrorFunction, PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import EmptyResponseData from "Common/Types/API/EmptyResponse";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { APP_API_URL } from "Common/UI/Config";
|
||||
import { ShowAs } from "Common/UI/Components/ModelTable/BaseModelTable";
|
||||
import { ModalWidth } from "Common/UI/Components/Modal/Modal";
|
||||
import RecurringFieldElement from "Common/UI/Components/Events/RecurringFieldElement";
|
||||
import RecurringViewElement from "Common/UI/Components/Events/RecurringViewElement";
|
||||
import Recurring from "Common/Types/Events/Recurring";
|
||||
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
|
||||
import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import CheckboxElement from "Common/UI/Components/Checkbox/Checkbox";
|
||||
|
||||
export interface ComponentProps {
|
||||
workspaceType: WorkspaceType;
|
||||
summaryType: WorkspaceNotificationSummaryType;
|
||||
}
|
||||
|
||||
const WorkspaceSummaryTable: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | undefined>(undefined);
|
||||
|
||||
// Dropdown data for filters
|
||||
const [monitors, setMonitors] = React.useState<Array<Monitor>>([]);
|
||||
const [labels, setLabels] = React.useState<Array<Label>>([]);
|
||||
const [alertStates, setAlertStates] = React.useState<Array<AlertState>>([]);
|
||||
const [alertSeverities, setAlertSeverities] = React.useState<
|
||||
Array<AlertSeverity>
|
||||
>([]);
|
||||
const [incidentSeverities, setIncidentSeverities] = React.useState<
|
||||
Array<IncidentSeverity>
|
||||
>([]);
|
||||
const [incidentStates, setIncidentStates] = React.useState<
|
||||
Array<IncidentState>
|
||||
>([]);
|
||||
|
||||
// Test modal state
|
||||
const [showTestModal, setShowTestModal] = React.useState<boolean>(false);
|
||||
const [isTestLoading, setIsTestLoading] = React.useState<boolean>(false);
|
||||
const [testError, setTestError] = React.useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [testSummary, setTestSummary] = React.useState<
|
||||
WorkspaceNotificationSummary | undefined
|
||||
>(undefined);
|
||||
const [showTestSuccessModal, setShowTestSuccessModal] =
|
||||
React.useState<boolean>(false);
|
||||
|
||||
// Map summary type to notification rule event type for filters
|
||||
type GetEventTypeFunction = () => NotificationRuleEventType;
|
||||
|
||||
const getEventType: GetEventTypeFunction = (): NotificationRuleEventType => {
|
||||
switch (props.summaryType) {
|
||||
case WorkspaceNotificationSummaryType.Incident:
|
||||
return NotificationRuleEventType.Incident;
|
||||
case WorkspaceNotificationSummaryType.Alert:
|
||||
return NotificationRuleEventType.Alert;
|
||||
case WorkspaceNotificationSummaryType.IncidentEpisode:
|
||||
return NotificationRuleEventType.IncidentEpisode;
|
||||
case WorkspaceNotificationSummaryType.AlertEpisode:
|
||||
return NotificationRuleEventType.AlertEpisode;
|
||||
default:
|
||||
return NotificationRuleEventType.Incident;
|
||||
}
|
||||
};
|
||||
|
||||
const eventType: NotificationRuleEventType = getEventType();
|
||||
|
||||
// Load dropdown data for filter conditions
|
||||
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const monitorsResult: ListResult<Monitor> = await ModelAPI.getList({
|
||||
modelType: Monitor,
|
||||
query: { projectId: ProjectUtil.getCurrentProjectId()! },
|
||||
select: { name: true, _id: true },
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
sort: { name: SortOrder.Ascending },
|
||||
});
|
||||
setMonitors(monitorsResult.data);
|
||||
|
||||
const labelsResult: ListResult<Label> = await ModelAPI.getList({
|
||||
modelType: Label,
|
||||
query: { projectId: ProjectUtil.getCurrentProjectId()! },
|
||||
select: { name: true, _id: true, color: true },
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
sort: { name: SortOrder.Ascending },
|
||||
});
|
||||
setLabels(labelsResult.data);
|
||||
|
||||
const alertStatesResult: ListResult<AlertState> = await ModelAPI.getList({
|
||||
modelType: AlertState,
|
||||
query: { projectId: ProjectUtil.getCurrentProjectId()! },
|
||||
select: { name: true, _id: true, color: true },
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
sort: { name: SortOrder.Ascending },
|
||||
});
|
||||
setAlertStates(alertStatesResult.data);
|
||||
|
||||
const alertSevResult: ListResult<AlertSeverity> = await ModelAPI.getList({
|
||||
modelType: AlertSeverity,
|
||||
query: { projectId: ProjectUtil.getCurrentProjectId()! },
|
||||
select: { name: true, _id: true, color: true },
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
sort: { name: SortOrder.Ascending },
|
||||
});
|
||||
setAlertSeverities(alertSevResult.data);
|
||||
|
||||
const incSevResult: ListResult<IncidentSeverity> = await ModelAPI.getList(
|
||||
{
|
||||
modelType: IncidentSeverity,
|
||||
query: { projectId: ProjectUtil.getCurrentProjectId()! },
|
||||
select: { name: true, _id: true, color: true },
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
sort: { name: SortOrder.Ascending },
|
||||
},
|
||||
);
|
||||
setIncidentSeverities(incSevResult.data);
|
||||
|
||||
const incStatesResult: ListResult<IncidentState> = await ModelAPI.getList(
|
||||
{
|
||||
modelType: IncidentState,
|
||||
query: { projectId: ProjectUtil.getCurrentProjectId()! },
|
||||
select: { name: true, _id: true, color: true },
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
sort: { name: SortOrder.Ascending },
|
||||
},
|
||||
);
|
||||
setIncidentStates(incStatesResult.data);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Exception));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPage().catch((err: Exception) => {
|
||||
setError(API.getFriendlyErrorMessage(err as Exception));
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
type TestSummaryFunction = (summaryId: ObjectID) => Promise<void>;
|
||||
|
||||
const testSummaryFn: TestSummaryFunction = async (
|
||||
summaryId: ObjectID,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsTestLoading(true);
|
||||
setTestError(undefined);
|
||||
|
||||
const response: HTTPResponse<EmptyResponseData> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
url: URL.fromString(APP_API_URL.toString()).addRoute(
|
||||
`/workspace-notification-summary/test/${summaryId.toString()}`,
|
||||
),
|
||||
data: {},
|
||||
headers: ModelAPI.getCommonHeaders(),
|
||||
});
|
||||
|
||||
if (response.isSuccess()) {
|
||||
setIsTestLoading(false);
|
||||
setShowTestModal(false);
|
||||
setShowTestSuccessModal(true);
|
||||
}
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
setIsTestLoading(false);
|
||||
} catch (err) {
|
||||
setTestError(API.getFriendlyErrorMessage(err as Exception));
|
||||
setIsTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const allSummaryItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
Object.values(WorkspaceNotificationSummaryItem);
|
||||
|
||||
const typeLabel: string = props.summaryType;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ModelTable<WorkspaceNotificationSummary>
|
||||
modelType={WorkspaceNotificationSummary}
|
||||
query={{
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
summaryType: props.summaryType,
|
||||
workspaceType: props.workspaceType,
|
||||
}}
|
||||
userPreferencesKey={`workspace-summary-table-${props.summaryType}-${props.workspaceType}`}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "Send Test Now",
|
||||
buttonStyleType: ButtonStyleType.OUTLINE,
|
||||
icon: IconProp.Play,
|
||||
onClick: async (
|
||||
item: WorkspaceNotificationSummary,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: ErrorFunction,
|
||||
) => {
|
||||
try {
|
||||
setTestSummary(item);
|
||||
setShowTestModal(true);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onCompleteAction();
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
singularName={`${typeLabel} Summary`}
|
||||
pluralName={`${typeLabel} Summaries`}
|
||||
id={`workspace-summary-table-${props.summaryType}`}
|
||||
name={`${typeLabel} Workspace Summaries`}
|
||||
isDeleteable={true}
|
||||
isEditable={true}
|
||||
createEditModalWidth={ModalWidth.Large}
|
||||
isCreateable={true}
|
||||
cardProps={{
|
||||
title: `${typeLabel} Summary - ${getWorkspaceTypeDisplayName(props.workspaceType)}`,
|
||||
description: `Set up recurring ${typeLabel.toLowerCase()} summary reports posted to ${getWorkspaceTypeDisplayName(props.workspaceType)}. Each summary includes stats like total count, MTTA/MTTR, severity breakdown, and a list of ${typeLabel.toLowerCase()}s with links.`,
|
||||
}}
|
||||
showAs={ShowAs.List}
|
||||
noItemsMessage={`No ${typeLabel.toLowerCase()} summary rules configured yet. Create one to start receiving periodic reports.`}
|
||||
onBeforeCreate={(values: WorkspaceNotificationSummary) => {
|
||||
values.summaryType = props.summaryType;
|
||||
values.projectId = ProjectUtil.getCurrentProjectId()!;
|
||||
values.workspaceType = props.workspaceType;
|
||||
|
||||
// Set nextSendAt based on sendFirstReportAt or recurringInterval
|
||||
if (values.sendFirstReportAt) {
|
||||
const firstReportDate: Date = new Date(
|
||||
values.sendFirstReportAt as unknown as string,
|
||||
);
|
||||
if (
|
||||
firstReportDate.getTime() >
|
||||
OneUptimeDate.getCurrentDate().getTime()
|
||||
) {
|
||||
values.nextSendAt = firstReportDate;
|
||||
} else {
|
||||
values.nextSendAt = values.sendFirstReportAt;
|
||||
}
|
||||
} else if (values.recurringInterval) {
|
||||
const recurring: Recurring = Recurring.fromJSON(
|
||||
values.recurringInterval,
|
||||
);
|
||||
values.nextSendAt = Recurring.getNextDateInterval(
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
recurring,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse channel names from comma-separated string
|
||||
if (values.channelNames && typeof values.channelNames === "string") {
|
||||
values.channelNames = (values.channelNames as unknown as string)
|
||||
.split(",")
|
||||
.map((name: string) => {
|
||||
return name.trim();
|
||||
})
|
||||
.filter((name: string) => {
|
||||
return name.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Default to "All" if none selected
|
||||
if (
|
||||
!values.summaryItems ||
|
||||
(Array.isArray(values.summaryItems) &&
|
||||
values.summaryItems.length === 0)
|
||||
) {
|
||||
values.summaryItems = [WorkspaceNotificationSummaryItem.All];
|
||||
}
|
||||
|
||||
if (values.isEnabled === undefined || values.isEnabled === null) {
|
||||
values.isEnabled = true;
|
||||
}
|
||||
|
||||
// Clean up empty filters
|
||||
if (values.filters && Array.isArray(values.filters)) {
|
||||
values.filters = values.filters.filter(
|
||||
(f: NotificationRuleCondition) => {
|
||||
if (!f.value) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(f.value)) {
|
||||
return f.value.length > 0;
|
||||
}
|
||||
// String-based conditions (e.g., title contains "X")
|
||||
if (typeof f.value === "string") {
|
||||
return f.value.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!values.filterCondition) {
|
||||
values.filterCondition = FilterCondition.Any;
|
||||
}
|
||||
|
||||
return Promise.resolve(values);
|
||||
}}
|
||||
onBeforeEdit={(values: WorkspaceNotificationSummary) => {
|
||||
// Convert channelNames from JSON array to comma-separated string for the text input
|
||||
if (values.channelNames && Array.isArray(values.channelNames)) {
|
||||
values.channelNames = (values.channelNames as Array<string>).join(
|
||||
", ",
|
||||
) as unknown as Array<string>;
|
||||
}
|
||||
|
||||
return Promise.resolve(values);
|
||||
}}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Summary Name",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
stepId: "basic",
|
||||
placeholder: `Weekly ${typeLabel} Summary`,
|
||||
validation: {
|
||||
minLength: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
description: true,
|
||||
},
|
||||
stepId: "basic",
|
||||
title: "Description",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder: `e.g., Weekly ${typeLabel.toLowerCase()} summary for the engineering team.`,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
channelNames: true,
|
||||
},
|
||||
stepId: "basic",
|
||||
title: "Channel Names",
|
||||
description: `Enter one or more ${getWorkspaceTypeDisplayName(props.workspaceType)} channel names (comma-separated) where the summary will be posted.`,
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: true,
|
||||
placeholder: "#incidents-summary, #engineering",
|
||||
},
|
||||
...(props.workspaceType === WorkspaceType.MicrosoftTeams
|
||||
? [
|
||||
{
|
||||
field: {
|
||||
teamName: true,
|
||||
},
|
||||
stepId: "basic",
|
||||
title: "Team Name",
|
||||
description:
|
||||
"The name of the Microsoft Teams team where the summary will be posted.",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "Engineering Team",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
stepId: "basic",
|
||||
title: "Enabled",
|
||||
description:
|
||||
"When enabled, the summary will be sent automatically on the configured schedule.",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
recurringInterval: true,
|
||||
},
|
||||
title: "How Often",
|
||||
description:
|
||||
"Choose how frequently this summary should be posted (e.g., every 1 day, every 1 week).",
|
||||
fieldType: FormFieldSchemaType.CustomComponent,
|
||||
required: true,
|
||||
stepId: "schedule",
|
||||
getCustomElement: (
|
||||
value: FormValues<WorkspaceNotificationSummary>,
|
||||
elementProps: CustomElementProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<RecurringFieldElement
|
||||
error={elementProps.error}
|
||||
onChange={(recurring: Recurring) => {
|
||||
if (elementProps.onChange) {
|
||||
elementProps.onChange(recurring);
|
||||
}
|
||||
}}
|
||||
initialValue={
|
||||
value.recurringInterval &&
|
||||
value.recurringInterval instanceof Recurring
|
||||
? Recurring.fromJSON(value.recurringInterval as Recurring)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
sendFirstReportAt: true,
|
||||
},
|
||||
title: "Send First Report At",
|
||||
description:
|
||||
"When should the first summary report be sent? Subsequent reports will follow the recurring interval from this date. If left empty, the first report will be sent after the recurring interval from now.",
|
||||
fieldType: FormFieldSchemaType.DateTime,
|
||||
required: false,
|
||||
stepId: "schedule",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
numberOfDaysOfData: true,
|
||||
},
|
||||
title: "Lookback Period (Days)",
|
||||
description:
|
||||
"How many days of data to include in each summary. For example, 7 means the summary will cover the last 7 days.",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: true,
|
||||
stepId: "schedule",
|
||||
placeholder: "7",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
summaryItems: true,
|
||||
},
|
||||
title: "What to Include",
|
||||
description:
|
||||
'Choose which sections appear in the summary. Select "All" to include everything, or pick specific sections.',
|
||||
fieldType: FormFieldSchemaType.CustomComponent,
|
||||
required: false,
|
||||
stepId: "content",
|
||||
getCustomElement: (
|
||||
value: FormValues<WorkspaceNotificationSummary>,
|
||||
elementProps: CustomElementProps,
|
||||
): ReactElement => {
|
||||
const currentItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
(value.summaryItems as Array<WorkspaceNotificationSummaryItem>) || [
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
];
|
||||
|
||||
const isAllSelected: boolean = currentItems.includes(
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
);
|
||||
|
||||
const individualItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
allSummaryItems.filter(
|
||||
(item: WorkspaceNotificationSummaryItem) => {
|
||||
return item !== WorkspaceNotificationSummaryItem.All;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CheckboxElement
|
||||
title="All"
|
||||
value={isAllSelected}
|
||||
onChange={(checked: boolean) => {
|
||||
if (elementProps.onChange) {
|
||||
if (checked) {
|
||||
elementProps.onChange([
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
]);
|
||||
} else {
|
||||
elementProps.onChange([]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="ml-6 space-y-2">
|
||||
{individualItems.map(
|
||||
(item: WorkspaceNotificationSummaryItem) => {
|
||||
return (
|
||||
<CheckboxElement
|
||||
key={item}
|
||||
title={item}
|
||||
disabled={isAllSelected}
|
||||
value={isAllSelected || currentItems.includes(item)}
|
||||
onChange={(checked: boolean) => {
|
||||
if (elementProps.onChange) {
|
||||
let newItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
currentItems.filter(
|
||||
(i: WorkspaceNotificationSummaryItem) => {
|
||||
return (
|
||||
i !==
|
||||
WorkspaceNotificationSummaryItem.All &&
|
||||
i !== item
|
||||
);
|
||||
},
|
||||
);
|
||||
if (checked) {
|
||||
newItems.push(item);
|
||||
}
|
||||
// If all individual items are selected, switch to "All"
|
||||
if (
|
||||
newItems.length === individualItems.length
|
||||
) {
|
||||
newItems = [
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
];
|
||||
}
|
||||
elementProps.onChange(newItems);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
filterCondition: true,
|
||||
},
|
||||
title: "Filter Condition",
|
||||
description: `Choose whether ${typeLabel.toLowerCase()}s must match ALL filters or ANY filter. If no filters are added, the summary will include all ${typeLabel.toLowerCase()}s.`,
|
||||
fieldType: FormFieldSchemaType.RadioButton,
|
||||
required: false,
|
||||
stepId: "filters",
|
||||
radioButtonOptions: [
|
||||
{
|
||||
title: "Any",
|
||||
value: FilterCondition.Any,
|
||||
},
|
||||
{
|
||||
title: "All",
|
||||
value: FilterCondition.All,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: {
|
||||
filters: true,
|
||||
},
|
||||
title: "Filter Conditions",
|
||||
description: `Only include ${typeLabel.toLowerCase()}s that match these conditions. Leave empty to include all.`,
|
||||
fieldType: FormFieldSchemaType.CustomComponent,
|
||||
required: false,
|
||||
stepId: "filters",
|
||||
getCustomElement: (
|
||||
value: FormValues<WorkspaceNotificationSummary>,
|
||||
elementProps: CustomElementProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<NotificationRuleConditions
|
||||
eventType={eventType}
|
||||
monitors={monitors}
|
||||
labels={labels}
|
||||
alertStates={alertStates}
|
||||
alertSeverities={alertSeverities}
|
||||
incidentSeverities={incidentSeverities}
|
||||
incidentStates={incidentStates}
|
||||
scheduledMaintenanceStates={[]}
|
||||
monitorStatus={[]}
|
||||
onChange={(conditions: Array<NotificationRuleCondition>) => {
|
||||
if (elementProps.onChange) {
|
||||
elementProps.onChange(conditions);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
(value.filters as
|
||||
| Array<NotificationRuleCondition>
|
||||
| undefined) || []
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic Info",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "Schedule",
|
||||
id: "schedule",
|
||||
},
|
||||
{
|
||||
title: "Content",
|
||||
id: "content",
|
||||
},
|
||||
{
|
||||
title: "Filters",
|
||||
id: "filters",
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
type: FieldType.Text,
|
||||
title: "Summary Name",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
type: FieldType.Boolean,
|
||||
title: "Enabled",
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
name: true,
|
||||
},
|
||||
title: "Name",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isEnabled: true,
|
||||
},
|
||||
title: "Enabled",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
recurringInterval: true,
|
||||
},
|
||||
title: "Frequency",
|
||||
type: FieldType.Element,
|
||||
getElement: (value: WorkspaceNotificationSummary): ReactElement => {
|
||||
return (
|
||||
<RecurringViewElement
|
||||
value={value.recurringInterval as Recurring}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
sendFirstReportAt: true,
|
||||
},
|
||||
noValueMessage: "-",
|
||||
title: "First Report",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
numberOfDaysOfData: true,
|
||||
},
|
||||
title: "Lookback",
|
||||
type: FieldType.Element,
|
||||
getElement: (value: WorkspaceNotificationSummary): ReactElement => {
|
||||
return <span>{value.numberOfDaysOfData} days</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
lastSentAt: true,
|
||||
},
|
||||
noValueMessage: "Never",
|
||||
title: "Last Sent",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
nextSendAt: true,
|
||||
},
|
||||
noValueMessage: "-",
|
||||
title: "Next Send",
|
||||
type: FieldType.DateTime,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showTestModal && testSummary ? (
|
||||
<ConfirmModal
|
||||
title={`Send Test Summary Now`}
|
||||
error={testError}
|
||||
description={`This will send the "${testSummary.name}" summary to ${getWorkspaceTypeDisplayName(props.workspaceType)} right now. The summary will include data from the last ${testSummary.numberOfDaysOfData || 7} days. This will not affect the regular schedule.`}
|
||||
submitButtonText={"Send Now"}
|
||||
onClose={() => {
|
||||
setShowTestModal(false);
|
||||
setTestSummary(undefined);
|
||||
setTestError(undefined);
|
||||
}}
|
||||
isLoading={isTestLoading}
|
||||
onSubmit={async () => {
|
||||
if (!testSummary.id) {
|
||||
return;
|
||||
}
|
||||
await testSummaryFn(testSummary.id!);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{showTestSuccessModal ? (
|
||||
<ConfirmModal
|
||||
title={testError ? `Test Failed` : `Summary Sent`}
|
||||
error={testError}
|
||||
description={
|
||||
testError
|
||||
? `The test summary could not be sent. Please check your channel names and workspace connection settings.`
|
||||
: `The test summary was sent successfully. Check your ${getWorkspaceTypeDisplayName(props.workspaceType)} channel to see how it looks.`
|
||||
}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
submitButtonText={"Close"}
|
||||
onSubmit={async () => {
|
||||
setShowTestSuccessModal(false);
|
||||
setTestSummary(undefined);
|
||||
setShowTestModal(false);
|
||||
setTestError("");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSummaryTable;
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageMap from "../../../Utils/PageMap";
|
||||
import RouteMap from "../../../Utils/RouteMap";
|
||||
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
@@ -127,7 +127,10 @@ const TeamView: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
modelId={Navigation.getLastParamAsObjectID()}
|
||||
onDeleteSuccess={() => {
|
||||
Navigation.navigate(
|
||||
RouteMap[PageMap.ALERTS_SETTINGS_NOTE_TEMPLATES] as Route,
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.ALERTS_SETTINGS_NOTE_TEMPLATES] as Route,
|
||||
{ modelId },
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user