Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5766a55a9 | ||
|
|
c5902b397a | ||
|
|
e52ec2d6ee | ||
|
|
a2c978527f | ||
|
|
224dc60309 | ||
|
|
d8e4c775d5 | ||
|
|
a07d0059b7 | ||
|
|
1a48d303f7 | ||
|
|
5bb9ca970c | ||
|
|
2947b558a2 | ||
|
|
98a5fa9941 | ||
|
|
712350d669 | ||
|
|
1a287be323 | ||
|
|
014750f91e | ||
|
|
574078bdc8 | ||
|
|
7ab80ac3f4 | ||
|
|
a258910ccf | ||
|
|
e15a85bd61 | ||
|
|
ced90211a0 | ||
|
|
4ae8426766 | ||
|
|
32a5ca8799 | ||
|
|
5d77b8804f | ||
|
|
544502c6e5 | ||
|
|
1ef540679b | ||
|
|
18f38818e2 | ||
|
|
02e6333c3c | ||
|
|
cb47439e9f | ||
|
|
4bbfe2e889 | ||
|
|
187a0fc3d4 | ||
|
|
4e4d3c3971 | ||
|
|
6267e08a51 | ||
|
|
f2c23636fe | ||
|
|
11c89d150a | ||
|
|
6b75f9bf7c | ||
|
|
91de9e7fb7 | ||
|
|
21fb320f3a | ||
|
|
3586ac9437 | ||
|
|
b0ed75382e | ||
|
|
b1646cd83b | ||
|
|
7abc74ac98 | ||
|
|
a1b10c7469 | ||
|
|
0d75629dcb | ||
|
|
849ea26cf9 | ||
|
|
4a66cf6e11 | ||
|
|
41c7cfbdaa | ||
|
|
7fb57d8090 | ||
|
|
618258d282 | ||
|
|
e50568dba3 | ||
|
|
5fa894cb79 | ||
|
|
6d4e944a58 | ||
|
|
cfe7d2a359 | ||
|
|
5fb26367ed | ||
|
|
cd65488cbd | ||
|
|
4ea6539a7d | ||
|
|
a75bb5fae6 | ||
|
|
380968472f | ||
|
|
0cb48596a0 | ||
|
|
22d0123966 | ||
|
|
2a45b4d46f | ||
|
|
bdd4634a3b | ||
|
|
a5e66318b5 | ||
|
|
7ebbb05482 | ||
|
|
99c62961cf | ||
|
|
40a9f3f1e1 | ||
|
|
b37e4d9083 | ||
|
|
37dd9fad26 | ||
|
|
0f51d68ff0 | ||
|
|
dfc048522d | ||
|
|
1f9072a905 | ||
|
|
780ed8147b | ||
|
|
b2cfc60387 | ||
|
|
c8f84e9e20 | ||
|
|
9434cd8929 | ||
|
|
a277a89bef | ||
|
|
55184724d0 | ||
|
|
de4c528d55 | ||
|
|
d5e887903e | ||
|
|
6196b81fe4 | ||
|
|
c3598a67fa | ||
|
|
2c896a5506 | ||
|
|
97e23e4b3a | ||
|
|
be39ad0bfe | ||
|
|
7f0e577dd2 | ||
|
|
f308325bd6 | ||
|
|
c5303add56 | ||
|
|
fe6f755451 | ||
|
|
4a2429d316 | ||
|
|
d7f8bcc9e1 | ||
|
|
d51fdbd772 | ||
|
|
199d68e8a2 | ||
|
|
461a5e35dd | ||
|
|
8dd57d45e2 | ||
|
|
01e9655f42 | ||
|
|
d4d2735d23 | ||
|
|
253642dde9 | ||
|
|
9d0dc3f330 | ||
|
|
2f3de9797d | ||
|
|
1b3b8e5312 | ||
|
|
b55560e2f1 | ||
|
|
57ddbc474f | ||
|
|
862ff15eef | ||
|
|
da8b230cec | ||
|
|
13799af721 | ||
|
|
4fcb13694b | ||
|
|
3dc529733a | ||
|
|
d865103fb5 | ||
|
|
4335e6b7b6 | ||
|
|
4f5dba560d | ||
|
|
f52acb3f68 | ||
|
|
f4864b5455 | ||
|
|
f299098548 | ||
|
|
87eab6f996 | ||
|
|
31400a2502 | ||
|
|
fa0c0abcac | ||
|
|
9102335d3e | ||
|
|
011b93ecc1 | ||
|
|
20ce11497e | ||
|
|
50d0721822 | ||
|
|
c66872c7f4 | ||
|
|
b1be38368c | ||
|
|
278f652ea9 | ||
|
|
d3e12722b7 | ||
|
|
8c9ff41074 | ||
|
|
71a1194373 | ||
|
|
580a025353 | ||
|
|
33994cb3df | ||
|
|
44edf320a0 | ||
|
|
fea2ef4dbe | ||
|
|
360b4b8035 | ||
|
|
3857a71e62 | ||
|
|
b68367b04f | ||
|
|
08f104e283 | ||
|
|
46eb0b4063 | ||
|
|
2632f98ad0 | ||
|
|
84539f76cf | ||
|
|
308672d558 | ||
|
|
0f474d82d6 | ||
|
|
650cfe00ce | ||
|
|
69eb9957c6 | ||
|
|
ec01a5dfe4 | ||
|
|
b2ace1bec5 | ||
|
|
5da472da14 | ||
|
|
4bb366f5ab | ||
|
|
cc4b8b38f5 | ||
|
|
f41e0f0619 | ||
|
|
3f10b6e567 | ||
|
|
9ad20daa51 | ||
|
|
86b59da909 | ||
|
|
6a9068a151 | ||
|
|
b31ffa5318 | ||
|
|
71dd998390 | ||
|
|
102a9f1484 | ||
|
|
581ec9382b | ||
|
|
cebe9350f7 | ||
|
|
406518e0a5 | ||
|
|
c458d5fb65 | ||
|
|
ba0d685c6d | ||
|
|
912f1f960d | ||
|
|
1de0edf7e3 | ||
|
|
8c9cb4be77 | ||
|
|
727b904ca9 | ||
|
|
5c0279a6a7 | ||
|
|
9d72d20dd2 | ||
|
|
62d0630702 | ||
|
|
2260b4a4d2 | ||
|
|
cfb50afe42 | ||
|
|
b14b2142e3 | ||
|
|
896d73f12e | ||
|
|
c64c4cc5ff | ||
|
|
7071de294f | ||
|
|
5eb16fdbdb | ||
|
|
131fd1a6b0 | ||
|
|
f9bc02ed1c | ||
|
|
1035e397e0 | ||
|
|
9c788561a7 | ||
|
|
449e96d842 | ||
|
|
c7626c669a | ||
|
|
ede5bc4ecd | ||
|
|
8bb60bfd03 | ||
|
|
f009aa17d9 | ||
|
|
ef7ac8bef8 | ||
|
|
b75f895a1f | ||
|
|
53f5b61e8e | ||
|
|
64a2c69540 | ||
|
|
c4ec14db5d | ||
|
|
4560820649 | ||
|
|
3051bc7edc | ||
|
|
22232489a8 | ||
|
|
04277a6eb4 | ||
|
|
a8dc867e78 | ||
|
|
533783ba3e | ||
|
|
fd7b3e5f20 | ||
|
|
ae4b851620 | ||
|
|
e83f97a05a | ||
|
|
070682af74 | ||
|
|
8609e4bca0 | ||
|
|
2661d7a384 | ||
|
|
5b9d554f46 | ||
|
|
7fb8bab4be | ||
|
|
75d29124b6 | ||
|
|
1f0ea6cedc | ||
|
|
07a38b9c2c | ||
|
|
67806c94b1 | ||
|
|
26c0628f34 | ||
|
|
4bbc84ac48 | ||
|
|
2eeb088b61 | ||
|
|
7862b3c6f9 | ||
|
|
02b0daa37d | ||
|
|
1492569b73 | ||
|
|
a545b81569 | ||
|
|
ce9dce48b3 | ||
|
|
7fcdcc0568 | ||
|
|
9eea53aa5f | ||
|
|
bd7b6038f5 | ||
|
|
e2362ca809 | ||
|
|
9a541dac21 | ||
|
|
71fed33ecd | ||
|
|
074b94b061 | ||
|
|
c1af8995e4 | ||
|
|
431cada08e | ||
|
|
e012910332 | ||
|
|
7dbc7b3064 | ||
|
|
1b97539eae | ||
|
|
6e0afe223e | ||
|
|
37e1005479 | ||
|
|
795e1caca4 | ||
|
|
a5bb186814 | ||
|
|
bee3d4221e | ||
|
|
f7e1793bac | ||
|
|
9a8203b0d8 | ||
|
|
24d4595bb1 | ||
|
|
6587ddf389 | ||
|
|
9b939f2d94 | ||
|
|
530eeb0110 | ||
|
|
da0d2a47b4 | ||
|
|
700baf2061 | ||
|
|
de8eb53dd4 | ||
|
|
dbe0e2f4e3 | ||
|
|
3ddc6ef1cb | ||
|
|
ac94ae0fdd | ||
|
|
acbf26b03d | ||
|
|
f931954997 | ||
|
|
2cfc2915ce | ||
|
|
d965a82cc9 | ||
|
|
62fb17f64c | ||
|
|
8582958ad5 | ||
|
|
1372e66749 | ||
|
|
bdad949170 | ||
|
|
e69a2f05af | ||
|
|
9c796edbde | ||
|
|
84a1b994a1 | ||
|
|
db4689207a | ||
|
|
af465766c8 | ||
|
|
6ea4ca1aa9 | ||
|
|
c9b99eabb4 | ||
|
|
3e06cd39b1 | ||
|
|
efcc7829d4 | ||
|
|
77974682fb | ||
|
|
a1c54997bb | ||
|
|
5657d39589 | ||
|
|
cdd4953e03 | ||
|
|
786c803847 | ||
|
|
c13f26a4c3 | ||
|
|
56153b38a5 | ||
|
|
f85ed902e4 | ||
|
|
8ccc7cbe9a | ||
|
|
d347e29a28 | ||
|
|
f10fb452ba | ||
|
|
fc9d2fe69d | ||
|
|
ca896fa22f | ||
|
|
a7ee90e1ee | ||
|
|
06190a885a | ||
|
|
5f07860217 | ||
|
|
073635d786 | ||
|
|
af1d964e44 | ||
|
|
3b6b7a1e5d | ||
|
|
bc44a3baeb | ||
|
|
4e4bc5f319 | ||
|
|
2fec09df62 | ||
|
|
13e49c518d | ||
|
|
a1f9c0304f | ||
|
|
d158bcdce1 | ||
|
|
c45a21b4c8 | ||
|
|
a0738dff63 | ||
|
|
3cc4db9af5 | ||
|
|
94ecbfe365 | ||
|
|
5343241036 | ||
|
|
261fdd08f1 | ||
|
|
5470f44ebb | ||
|
|
b24e42e516 | ||
|
|
09d20c1bc7 | ||
|
|
d59bdf6c0c | ||
|
|
9d0f0116f3 | ||
|
|
3e56dc63a9 | ||
|
|
30881a0af1 | ||
|
|
ebbf1a8b1e | ||
|
|
9a6602b9e1 | ||
|
|
46211ac61d | ||
|
|
791a0c8483 | ||
|
|
e964234f54 | ||
|
|
e60bfd18ff | ||
|
|
560d64a482 | ||
|
|
c8826d90d5 | ||
|
|
cacedfd7d8 | ||
|
|
4ec03dfd04 | ||
|
|
78d3b23e93 | ||
|
|
1a1dc7d807 | ||
|
|
03174d98dd | ||
|
|
686ded2137 | ||
|
|
9c8bf4bb05 | ||
|
|
8f6c909acd | ||
|
|
1ca38a31c9 | ||
|
|
9ffcbd1242 | ||
|
|
840e12eb9a | ||
|
|
96b2266a9d | ||
|
|
80073af80a | ||
|
|
0c9334071f | ||
|
|
e444d5fbe6 | ||
|
|
e9dca23664 | ||
|
|
7fb0e9dc70 | ||
|
|
de148f9773 | ||
|
|
54ffd74048 | ||
|
|
4bc71a3d18 | ||
|
|
d03765c5fd | ||
|
|
f30085bae3 | ||
|
|
c24440f8c9 | ||
|
|
d8ce2dfdc8 | ||
|
|
28f8542bce | ||
|
|
2e258fa39a | ||
|
|
7dedcca41d | ||
|
|
97d6222297 | ||
|
|
14ddfc97b0 | ||
|
|
b23dbfeae0 | ||
|
|
68679bc0bf | ||
|
|
959a434d21 | ||
|
|
4298397e3e | ||
|
|
fc3fab4905 | ||
|
|
0b54b5c6cd | ||
|
|
358b1deec7 | ||
|
|
9839d48ca0 | ||
|
|
83c2df4390 | ||
|
|
31f6727519 | ||
|
|
96f8bf4a66 | ||
|
|
06bf5e45f5 | ||
|
|
d0fc402683 | ||
|
|
b922654cfc | ||
|
|
d7dce5d7a3 | ||
|
|
5d2aa28570 | ||
|
|
af3c72b055 | ||
|
|
b3e98ac95d | ||
|
|
3f62c8ec6a | ||
|
|
b2dca7b62f | ||
|
|
f731648007 | ||
|
|
a1199f957d | ||
|
|
ea8ad88ae9 | ||
|
|
6e271f834c | ||
|
|
0c21dcdd2a | ||
|
|
9bb95e1f0c | ||
|
|
ccfda938d5 | ||
|
|
cc137e1d06 | ||
|
|
41859f241a | ||
|
|
252067142e | ||
|
|
1152a70afb | ||
|
|
ffd7ea7517 | ||
|
|
a56bff2305 | ||
|
|
db05db0c0c | ||
|
|
cd520dacc5 | ||
|
|
637bb5cca9 | ||
|
|
ede93add64 | ||
|
|
4443a53a23 | ||
|
|
1b2d2887af | ||
|
|
54690fc73c | ||
|
|
6e3252aec8 | ||
|
|
edd9335e96 | ||
|
|
cf13bc329e | ||
|
|
80097afb71 | ||
|
|
5435167e73 | ||
|
|
059dc2737b | ||
|
|
158f2fffbe | ||
|
|
68c1f2e281 | ||
|
|
a719edb0fc | ||
|
|
53702ec04b | ||
|
|
84a73d9616 | ||
|
|
7b2358bd48 | ||
|
|
79957693fe | ||
|
|
7fd368def3 | ||
|
|
dd44e6c34f | ||
|
|
092f890f7f | ||
|
|
2468d3e287 | ||
|
|
bc25c15c34 | ||
|
|
1b6218ea56 | ||
|
|
6eba53d716 | ||
|
|
8bfb3a7de5 | ||
|
|
5cca0fb2d3 | ||
|
|
617ac3a9bb | ||
|
|
e92fe39e3b | ||
|
|
248dad01d4 | ||
|
|
840c936501 | ||
|
|
7096afcdf2 | ||
|
|
c15d6e0851 | ||
|
|
506133af29 | ||
|
|
03f168fa84 | ||
|
|
4b02bbec37 | ||
|
|
42aae20fa3 | ||
|
|
c71c833b77 | ||
|
|
cee7db64ee | ||
|
|
40e06a2f2b | ||
|
|
7478bd7d17 | ||
|
|
7b1d6c763f | ||
|
|
ff2e1fd648 | ||
|
|
9001a8d294 | ||
|
|
ab7f2cd8f4 | ||
|
|
c1f0bf3dd4 | ||
|
|
3fc7020a90 | ||
|
|
6f9642c298 | ||
|
|
3a22d3e819 | ||
|
|
697574a2a9 | ||
|
|
0cbd9ab8a2 | ||
|
|
9032fcd7f0 | ||
|
|
ed268b3099 | ||
|
|
e26825ef99 | ||
|
|
741ee7a92a | ||
|
|
099eaa38c3 | ||
|
|
c35b22e1ac | ||
|
|
1c9739d1da | ||
|
|
d4eaad6170 | ||
|
|
75516a220d | ||
|
|
f97ca8565a | ||
|
|
b5f8faf1e8 | ||
|
|
b088101160 | ||
|
|
521827e1d0 | ||
|
|
7dd378f085 | ||
|
|
a7fb235c56 | ||
|
|
7f68d23566 | ||
|
|
caebb93748 | ||
|
|
5640068907 | ||
|
|
441ecddf92 | ||
|
|
e5959ca91d | ||
|
|
2765ebfc3b | ||
|
|
8d1fd61bb4 | ||
|
|
865fa7cce6 | ||
|
|
2c780b8101 | ||
|
|
ee25c6b479 | ||
|
|
d0c8da1653 | ||
|
|
5916d0ca33 | ||
|
|
69db716f0b | ||
|
|
350083d179 | ||
|
|
11a6ee71e9 | ||
|
|
eaf93668f5 | ||
|
|
59267ef451 | ||
|
|
35f9381573 | ||
|
|
3df85b7522 | ||
|
|
012e09d30c | ||
|
|
4b921f4986 | ||
|
|
0eff052258 | ||
|
|
52c80b9a39 | ||
|
|
6ceb28d3a1 | ||
|
|
08bd2d56f3 | ||
|
|
05f265743b | ||
|
|
bc2115f578 | ||
|
|
c1e9fd6079 | ||
|
|
226864079c | ||
|
|
7cab326ad3 | ||
|
|
94f1f7e576 | ||
|
|
20d1d1407b | ||
|
|
da7e8a1ae8 | ||
|
|
c578387261 | ||
|
|
c5ef61e64c | ||
|
|
6e1e5dd1a5 | ||
|
|
514ee0d30b | ||
|
|
623db938f5 | ||
|
|
1c291bb893 | ||
|
|
466d356ad1 | ||
|
|
0c184e6d0c | ||
|
|
303f47a117 | ||
|
|
5fa74bd883 | ||
|
|
4c8342f379 | ||
|
|
dfcb3b4b21 | ||
|
|
2e19b85a2d | ||
|
|
5774cb0e06 | ||
|
|
750fc9e238 | ||
|
|
3646af9c04 | ||
|
|
d4782a97a3 | ||
|
|
e0122d3761 | ||
|
|
e3b0b33b15 | ||
|
|
19b5976f85 | ||
|
|
336dbe4de2 | ||
|
|
65c6f567b5 | ||
|
|
6e6ac5dbf6 | ||
|
|
e81fdb9810 | ||
|
|
9712579c4d | ||
|
|
98e0ffc983 | ||
|
|
ac251c4d3f | ||
|
|
08f167c18e | ||
|
|
8a608961e6 | ||
|
|
955c400731 | ||
|
|
093553f9ba | ||
|
|
e5282b9099 | ||
|
|
6b4444038c | ||
|
|
89464e9be4 | ||
|
|
6856914d41 | ||
|
|
c102cd8d26 | ||
|
|
ff28e74aca | ||
|
|
23b420d072 | ||
|
|
0172272dc3 | ||
|
|
3f088c70f8 | ||
|
|
ed54fa4668 | ||
|
|
0e0cace676 | ||
|
|
58f1ae7fe9 | ||
|
|
e3075318b6 | ||
|
|
81aab35961 | ||
|
|
4e495eed33 | ||
|
|
061da5e7ef | ||
|
|
a29b7037cf | ||
|
|
c7662727e7 | ||
|
|
13c921531c | ||
|
|
0cf8cfa5dc | ||
|
|
4182a964a6 | ||
|
|
04f7ef252a | ||
|
|
3201653f19 | ||
|
|
0988a61c1d | ||
|
|
80ea09b864 | ||
|
|
e0228918fb | ||
|
|
8369db5ca2 | ||
|
|
49e8d26bfc | ||
|
|
c28be97fb0 | ||
|
|
ed36db172a | ||
|
|
c021d04f53 | ||
|
|
86eeda3f0e | ||
|
|
25d81f4238 | ||
|
|
af8ee643e1 | ||
|
|
61e442493a | ||
|
|
9540084425 | ||
|
|
f50ba1bf23 | ||
|
|
b4e42bfd68 | ||
|
|
3af00281c4 | ||
|
|
4a90ab8e78 | ||
|
|
7598cf3007 | ||
|
|
e1c89cd093 | ||
|
|
80d4e818f1 | ||
|
|
4949acac1c | ||
|
|
586e79efb2 | ||
|
|
c961f5292d | ||
|
|
d53785b768 | ||
|
|
b9cbf199e6 | ||
|
|
ac6678a986 | ||
|
|
ed1ef56184 | ||
|
|
ac16126e2e | ||
|
|
8e600e9a87 | ||
|
|
790d83b7ed | ||
|
|
c567e4be5e | ||
|
|
936d9cd9a0 | ||
|
|
6e047d2cdb | ||
|
|
aad03b787f | ||
|
|
bb374db463 | ||
|
|
57e008c7ba | ||
|
|
5d3dc03c89 | ||
|
|
bf255a0178 | ||
|
|
36434c1160 | ||
|
|
63cecf3447 | ||
|
|
4d9e5821a8 | ||
|
|
5433a0cf5f | ||
|
|
d485adbb54 | ||
|
|
3eadb99c5e | ||
|
|
9c04061a04 | ||
|
|
897ffd5736 | ||
|
|
368595178d | ||
|
|
924c862244 | ||
|
|
cf451416b1 | ||
|
|
6e2a64fc9f | ||
|
|
0e52cb6231 | ||
|
|
507b96164f | ||
|
|
887521c376 | ||
|
|
caee9510eb | ||
|
|
825a81d326 | ||
|
|
bd13e9d9c8 | ||
|
|
92696d3685 | ||
|
|
5120390923 | ||
|
|
0810fcbb88 | ||
|
|
15453c2d8d | ||
|
|
70f10a73d8 | ||
|
|
930c06f5ff | ||
|
|
b088a8c878 | ||
|
|
067a186765 | ||
|
|
107b5cbd96 | ||
|
|
7fdd51c072 | ||
|
|
2331035da0 | ||
|
|
fd4edb7227 | ||
|
|
538b0df7e9 | ||
|
|
9f79feec8e | ||
|
|
fe703199f5 | ||
|
|
800080fd06 | ||
|
|
c1bcd9b206 | ||
|
|
5d55750b7b | ||
|
|
4f398e98d3 | ||
|
|
242e853bf0 | ||
|
|
523bf2253c | ||
|
|
f92dc835b7 | ||
|
|
0b0d412187 | ||
|
|
77b817778b | ||
|
|
eddb9a82a8 | ||
|
|
9459edce00 | ||
|
|
eaf1064413 | ||
|
|
dae6f9a570 | ||
|
|
9fa45c5871 | ||
|
|
2810cd3562 | ||
|
|
f4177baed5 | ||
|
|
5feb666bdb | ||
|
|
ceacaa5499 | ||
|
|
91343f996b | ||
|
|
312961e8c9 | ||
|
|
0475f194e9 | ||
|
|
bbb051406f | ||
|
|
40a7599388 | ||
|
|
dffce2709e | ||
|
|
62031a0ab1 | ||
|
|
ef175950b2 | ||
|
|
6d91efecad | ||
|
|
f7d3bcb696 | ||
|
|
4a093e2b97 | ||
|
|
8396f478c5 | ||
|
|
bbfd963deb | ||
|
|
fc0cda3da6 | ||
|
|
861d867a17 | ||
|
|
8d7c322279 | ||
|
|
68a9bece1e | ||
|
|
4f82a11c66 | ||
|
|
cc1814f507 | ||
|
|
436c11bcba | ||
|
|
d5ec2936ad | ||
|
|
937ffe4f3b | ||
|
|
0df71d6356 | ||
|
|
adba25269c | ||
|
|
386a384ef6 | ||
|
|
8bf5db0834 | ||
|
|
817d01c857 | ||
|
|
3f30acdbdf | ||
|
|
80b604b9ff | ||
|
|
9356b0d482 | ||
|
|
6699a83948 | ||
|
|
900c039672 | ||
|
|
a4ab7d662e | ||
|
|
807d7e10c3 | ||
|
|
39043f3945 | ||
|
|
5a72e1e123 | ||
|
|
959b150ebf | ||
|
|
552b7a037d | ||
|
|
8671685cf9 | ||
|
|
ff536f40a2 | ||
|
|
e3d63a1cb4 | ||
|
|
105d7f0580 | ||
|
|
e2a1f02971 | ||
|
|
b7f548ff15 | ||
|
|
29d05c1e57 | ||
|
|
0ec77a5ab1 | ||
|
|
95eb75f416 | ||
|
|
8f657dcd36 | ||
|
|
c0e33eb4fa | ||
|
|
754b0af3a3 | ||
|
|
a39792cf0b | ||
|
|
35c9618d59 | ||
|
|
c6c41c2e3e | ||
|
|
f58825d19f | ||
|
|
e4649f3d76 | ||
|
|
c7361666f5 | ||
|
|
3f5707721f | ||
|
|
cc5f033d0d | ||
|
|
d2d0fb788e | ||
|
|
9d969d67ca | ||
|
|
6e1c26b987 | ||
|
|
ef353dcb36 | ||
|
|
b21ef60cda | ||
|
|
f43bf9ab3d | ||
|
|
48ca64ec6f | ||
|
|
e036c6077e | ||
|
|
c9f0254aed | ||
|
|
b3d9c5b94c | ||
|
|
fd536541d7 | ||
|
|
7a82c76d5c | ||
|
|
5fab2d0a30 | ||
|
|
860b2d6214 | ||
|
|
8579edd56e | ||
|
|
969d24e369 | ||
|
|
52b95ad121 | ||
|
|
ea23610b3a | ||
|
|
a4cc322a55 | ||
|
|
2b9ff52f6b | ||
|
|
0f230c4d70 | ||
|
|
74783fd738 | ||
|
|
71d3e789ff | ||
|
|
f59a7c3113 | ||
|
|
ffbf223288 | ||
|
|
96021b7479 | ||
|
|
f0a5974eaf | ||
|
|
c9b8f434cd | ||
|
|
3685e0c476 | ||
|
|
d6f9fca907 | ||
|
|
ba943e90c2 | ||
|
|
dead97169f | ||
|
|
eb1d307932 | ||
|
|
a31dd2f19e | ||
|
|
d84fc7e299 | ||
|
|
8afd67a06d | ||
|
|
71a4fddffd | ||
|
|
3489ee000d | ||
|
|
93b9c5033a | ||
|
|
32ec909bf7 | ||
|
|
8e5757beb1 | ||
|
|
8e5a337f3e | ||
|
|
9db4e557b3 | ||
|
|
10174da4f0 | ||
|
|
99ded83bd5 | ||
|
|
dffa49ae9d | ||
|
|
89c86a7628 | ||
|
|
09446c9fc1 | ||
|
|
755134ee7f | ||
|
|
3b919044e6 | ||
|
|
08e9c02a3a | ||
|
|
85f674ee08 | ||
|
|
16036f421b | ||
|
|
7fe7b79103 | ||
|
|
a058c0518c | ||
|
|
5ed9ab780a | ||
|
|
e32590425b | ||
|
|
190a3b47da | ||
|
|
f740eef0fa | ||
|
|
5efd64d44d | ||
|
|
b28c84436e | ||
|
|
9082cf0826 | ||
|
|
9d453bdb0b | ||
|
|
39d92fb067 | ||
|
|
a54fdd89ab | ||
|
|
6aa9360b98 | ||
|
|
bb3f471d19 | ||
|
|
6d433fd0ac | ||
|
|
d8dafa311a | ||
|
|
398e5d7314 | ||
|
|
53ee45172d | ||
|
|
1ea6dafb1a | ||
|
|
9785dc146e | ||
|
|
1b1d8ea229 | ||
|
|
603bd1d814 | ||
|
|
158d976ccb | ||
|
|
380fdf470f | ||
|
|
1e2a4b4a84 | ||
|
|
5e0d8f0550 | ||
|
|
bffc4ee813 | ||
|
|
ad9ff7f5df | ||
|
|
8277c4ed13 | ||
|
|
f65238da6c | ||
|
|
64b8aab4d6 | ||
|
|
7244b35091 | ||
|
|
699883e0b1 | ||
|
|
f7211711ce | ||
|
|
cb0dd878d6 | ||
|
|
8c5be9ef00 | ||
|
|
d1b6d08c73 | ||
|
|
2d68e96782 | ||
|
|
306f2a60a2 | ||
|
|
abf6dc2908 | ||
|
|
7ce6068757 | ||
|
|
a7d9bb822e | ||
|
|
142ecaaf8b | ||
|
|
90bbc764fc | ||
|
|
ae4c433246 | ||
|
|
d2461ef118 | ||
|
|
47162f2064 | ||
|
|
8b9c41464a | ||
|
|
51b6ec1751 | ||
|
|
6165ae60e6 | ||
|
|
69e142f711 | ||
|
|
eff4f8ad69 | ||
|
|
bca902471d | ||
|
|
1b3f2f0365 | ||
|
|
be12991997 | ||
|
|
54596fe776 | ||
|
|
8d637dbcac | ||
|
|
771fd5b50f | ||
|
|
8dedf2a742 | ||
|
|
c882daf000 | ||
|
|
1aa7ae8f11 | ||
|
|
517139cbf4 | ||
|
|
ef049b6521 | ||
|
|
33f8641f3a | ||
|
|
2f22a52116 | ||
|
|
8af13b04ec | ||
|
|
ac77216f07 | ||
|
|
a43363ebfc | ||
|
|
0ca738b080 | ||
|
|
091c36cbf3 | ||
|
|
120708ed47 | ||
|
|
6a1ab622b3 | ||
|
|
0aef8b652f | ||
|
|
20863fbce7 | ||
|
|
fa7eaa4788 | ||
|
|
ee9c08b1bb | ||
|
|
7ee30e12f5 | ||
|
|
c5f57eda62 | ||
|
|
2bf3c08f3c | ||
|
|
187c8eaba1 | ||
|
|
25d8839441 | ||
|
|
0f4240d3db | ||
|
|
a37bc63f31 | ||
|
|
73f95ac8e1 | ||
|
|
c8c4bac2c7 | ||
|
|
9fecfa562f | ||
|
|
7991f3f6a1 | ||
|
|
692d4d89d6 | ||
|
|
f1b104b78d | ||
|
|
3c2b571313 | ||
|
|
abec4e6c70 | ||
|
|
d022f7d849 | ||
|
|
0fb210e9e1 | ||
|
|
ce4cb63035 | ||
|
|
f637b01631 | ||
|
|
e390bd764f | ||
|
|
30e8cfc9ec | ||
|
|
80d6868a1d | ||
|
|
942d75afcd | ||
|
|
c34fcc5cb3 | ||
|
|
409f9f6dc6 | ||
|
|
525206c1c1 | ||
|
|
9a85389b8c | ||
|
|
eeac784922 | ||
|
|
19b873ca87 | ||
|
|
0f99c7004d | ||
|
|
6a566c3693 | ||
|
|
dfcf0309fe | ||
|
|
395b7fbe2a | ||
|
|
d2c13fac5a | ||
|
|
dd16ac060a | ||
|
|
5628c202f7 | ||
|
|
372fc9869b | ||
|
|
87a6640c1d | ||
|
|
4e31499f78 |
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.env
|
||||||
|
**/*.env
|
||||||
|
.git
|
||||||
|
docker/dev/mongo/data
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
name: Build image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["develop", "master"]
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# checkmate-backend-mono is a transitional alias so existing deployments
|
||||||
|
# keep receiving updates; drop it after one release cycle.
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
ghcr.io/${{ github.repository_owner }}/checkmate
|
||||||
|
ghcr.io/${{ github.repository_owner }}/checkmate-backend-mono
|
||||||
|
flavor: |
|
||||||
|
latest=false
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
|
- name: Build and push multi-arch image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# The hosted staging/prod instances share one host and one compose file; each
|
||||||
|
# deploy pulls and recreates only its own service.
|
||||||
|
deploy-staging:
|
||||||
|
needs: build-and-push
|
||||||
|
if: github.ref == 'refs/heads/develop'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy staging.checkmate.so
|
||||||
|
uses: appleboy/ssh-action@v1.2.2
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEMO_SERVER_HOST }}
|
||||||
|
username: ${{ secrets.DEMO_SERVER_USER }}
|
||||||
|
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd checkmate
|
||||||
|
git pull
|
||||||
|
cd docs/hosting/checkmate-so
|
||||||
|
docker compose pull checkmate-staging
|
||||||
|
docker compose up -d checkmate-staging
|
||||||
|
docker system prune -af
|
||||||
|
|
||||||
|
deploy-prod:
|
||||||
|
needs: build-and-push
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy demo.checkmate.so
|
||||||
|
uses: appleboy/ssh-action@v1.2.2
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEMO_SERVER_HOST }}
|
||||||
|
username: ${{ secrets.DEMO_SERVER_USER }}
|
||||||
|
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd checkmate
|
||||||
|
git pull
|
||||||
|
cd docs/hosting/checkmate-so
|
||||||
|
docker compose pull checkmate-demo
|
||||||
|
docker compose up -d checkmate-demo
|
||||||
|
docker system prune -af
|
||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install client dependencies
|
- name: Install client dependencies
|
||||||
working-directory: client
|
working-directory: client
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Lockfile Check (Client & Server)
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-lockfiles:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Validate with the same node image the production build uses, so a lock
|
||||||
|
# that passes here cannot fail the image build's npm ci.
|
||||||
|
- name: Resolve node image from Dockerfile
|
||||||
|
id: node-image
|
||||||
|
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Check server lockfile
|
||||||
|
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
||||||
|
|
||||||
|
- name: Check client lockfile
|
||||||
|
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
working-directory: server
|
working-directory: server
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
name: Deploy images on release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
docker-build-and-push-client:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: extract_tag
|
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build Client Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }} \
|
|
||||||
-f ./docker/dist/client.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Client Docker image
|
|
||||||
run: |
|
|
||||||
docker push ghcr.io/bluewave-labs/checkmate-client:${{ steps.extract_tag.outputs.version }}
|
|
||||||
|
|
||||||
docker-build-and-push-server:
|
|
||||||
needs: docker-build-and-push-client
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Extract version
|
|
||||||
id: extract_tag
|
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build Server Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }} \
|
|
||||||
-f ./docker/dist/server.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Server Docker image
|
|
||||||
run: |
|
|
||||||
docker push ghcr.io/bluewave-labs/checkmate-backend:${{ steps.extract_tag.outputs.version }}
|
|
||||||
|
|
||||||
- name: Build Mongo Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }} \
|
|
||||||
-f ./docker/dist/mongoDB.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push MongoDB Docker image
|
|
||||||
run: |
|
|
||||||
docker push ghcr.io/bluewave-labs/checkmate-mongo:${{ steps.extract_tag.outputs.version }}
|
|
||||||
|
|
||||||
docker-build-and-push-server-mono-multiarch:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Extract version
|
|
||||||
id: extract_tag
|
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push multi-arch Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./docker/dist-arm/server.Dockerfile
|
|
||||||
push: true
|
|
||||||
no-cache: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:${{ steps.extract_tag.outputs.version }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
|
|
||||||
build-args: |
|
|
||||||
VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }}
|
|
||||||
|
|
||||||
docker-build-and-push-server-mono:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Extract version
|
|
||||||
id: extract_tag
|
|
||||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build Server Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }} \
|
|
||||||
-f ./docker/dist-mono/server.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
--build-arg VITE_APP_VERSION=${{ steps.extract_tag.outputs.version }} \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Server Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:${{ steps.extract_tag.outputs.version }}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
name: Deploy images
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
docker-build-and-push-client:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: vars
|
|
||||||
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
|
|
||||||
- name: Build Client Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-client:latest \
|
|
||||||
-f ./docker/dist/client.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Client Docker image
|
|
||||||
run: |
|
|
||||||
docker push ghcr.io/bluewave-labs/checkmate-client:latest
|
|
||||||
|
|
||||||
docker-build-and-push-server:
|
|
||||||
needs: docker-build-and-push-client
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build Server Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-backend:latest \
|
|
||||||
-f ./docker/dist/server.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Server Docker image
|
|
||||||
run: |
|
|
||||||
docker push ghcr.io/bluewave-labs/checkmate-backend:latest
|
|
||||||
|
|
||||||
- name: Build Mongo Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-mongo:latest \
|
|
||||||
-f ./docker/dist/mongoDB.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push MongoDB Docker image
|
|
||||||
run: |
|
|
||||||
docker push ghcr.io/bluewave-labs/checkmate-mongo:latest
|
|
||||||
|
|
||||||
docker-build-and-push-server-mono-multiarch:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: vars
|
|
||||||
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build and push multi-arch Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./docker/dist-arm/server.Dockerfile
|
|
||||||
push: true
|
|
||||||
no-cache: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:latest
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate
|
|
||||||
build-args: |
|
|
||||||
VITE_APP_VERSION=${{ env.VERSION }}
|
|
||||||
|
|
||||||
docker-build-and-push-server-mono:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: vars
|
|
||||||
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build Server Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate-backend-mono:latest \
|
|
||||||
-f ./docker/dist-mono/server.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Server Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate-backend-mono:latest
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
name: POEditor Translation Synchronization (disabled)
|
|
||||||
|
|
||||||
# This workflow is intentionally disabled — kept for reference only.
|
|
||||||
# The full definition is preserved commented-out below. To re-enable,
|
|
||||||
# delete the stub job and uncomment the section starting at `on:`.
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
disabled:
|
|
||||||
if: false
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo "This workflow is disabled. See comments below for the original definition."
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# on:
|
|
||||||
# # For manual triggering
|
|
||||||
# workflow_dispatch:
|
|
||||||
# inputs:
|
|
||||||
# languages:
|
|
||||||
# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)"
|
|
||||||
# required: false
|
|
||||||
# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th"
|
|
||||||
# format:
|
|
||||||
# description: "Export format (key_value_json or json)"
|
|
||||||
# required: false
|
|
||||||
# default: "key_value_json"
|
|
||||||
|
|
||||||
# permissions:
|
|
||||||
# contents: write
|
|
||||||
# pull-requests: write
|
|
||||||
|
|
||||||
# jobs:
|
|
||||||
# sync-translations:
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
# permissions:
|
|
||||||
# contents: write
|
|
||||||
# issues: write
|
|
||||||
# pull-requests: write
|
|
||||||
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout code
|
|
||||||
# uses: actions/checkout@v3
|
|
||||||
# with:
|
|
||||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# fetch-depth: 0
|
|
||||||
|
|
||||||
# - name: Setup Node.js
|
|
||||||
# uses: actions/setup-node@v3
|
|
||||||
# with:
|
|
||||||
# node-version: "22"
|
|
||||||
|
|
||||||
# - name: Create package.json for scripts
|
|
||||||
# run: |
|
|
||||||
# mkdir -p .github/scripts
|
|
||||||
# cat > .github/scripts/package.json << EOF
|
|
||||||
# {
|
|
||||||
# "name": "poeditor-scripts",
|
|
||||||
# "version": "1.0.0",
|
|
||||||
# "private": true,
|
|
||||||
# "type": "module",
|
|
||||||
# "dependencies": {
|
|
||||||
# "axios": "^1.6.0",
|
|
||||||
# "fs-extra": "^11.1.1"
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# EOF
|
|
||||||
|
|
||||||
# - name: Install dependencies
|
|
||||||
# run: |
|
|
||||||
# cd .github/scripts
|
|
||||||
# npm install
|
|
||||||
|
|
||||||
# - name: Download translations from POEditor
|
|
||||||
# env:
|
|
||||||
# POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
|
||||||
# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
|
||||||
# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }}
|
|
||||||
# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }}
|
|
||||||
# run: |
|
|
||||||
# mkdir -p temp
|
|
||||||
# node .github/scripts/download-translations.js
|
|
||||||
|
|
||||||
# - name: Verify translation files
|
|
||||||
# run: |
|
|
||||||
# echo "Verifying translation files..."
|
|
||||||
# for file in temp/*.json; do
|
|
||||||
# echo "Checking $file"
|
|
||||||
# if [ ! -s "$file" ]; then
|
|
||||||
# echo "Error: $file is empty or does not exist"
|
|
||||||
# exit 1
|
|
||||||
# fi
|
|
||||||
# # Validate JSON format
|
|
||||||
# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; }
|
|
||||||
# done
|
|
||||||
# echo "All translation files are valid"
|
|
||||||
|
|
||||||
# - name: Copy translations to project
|
|
||||||
# run: |
|
|
||||||
# mkdir -p client/src/locales
|
|
||||||
# cp -r temp/* client/src/locales/
|
|
||||||
# echo "Translation files copied to client/src/locales/"
|
|
||||||
|
|
||||||
# - name: Get current date
|
|
||||||
# id: date
|
|
||||||
# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# - name: Format client code
|
|
||||||
# run: |
|
|
||||||
# cd client
|
|
||||||
# npm ci
|
|
||||||
# npm run format
|
|
||||||
|
|
||||||
# - name: Create Pull Request
|
|
||||||
# uses: peter-evans/create-pull-request@v7
|
|
||||||
# with:
|
|
||||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# commit-message: "feat: translations updated from POEditor"
|
|
||||||
# title: "🌐 Updated Translations from POEditor"
|
|
||||||
# body: |
|
|
||||||
# This PR contains the latest translations from POEditor.
|
|
||||||
|
|
||||||
# 📅 Update Date: ${{ steps.date.outputs.date }}
|
|
||||||
# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }}
|
|
||||||
|
|
||||||
# ⚠️ Please review the translations and approve the PR if everything looks correct.
|
|
||||||
# branch: translation-update-${{ github.run_number }}
|
|
||||||
# delete-branch: true
|
|
||||||
# base: develop
|
|
||||||
# add-paths: |
|
|
||||||
# client/src/locales/*.json
|
|
||||||
# committer: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
|
||||||
# author: GitHub Action <github-actions[bot]@users.noreply.github.com>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
name: Demo deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "demo" ]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
docker-build-and-push-client:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: vars
|
|
||||||
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
|
|
||||||
- name: Build Client Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate:frontend-demo \
|
|
||||||
-f ./docker/prod/client.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Client Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-demo
|
|
||||||
|
|
||||||
docker-build-and-push-server:
|
|
||||||
needs: docker-build-and-push-client
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build Server Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate:backend-demo \
|
|
||||||
-f ./docker/prod/server.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Server Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate:backend-demo
|
|
||||||
|
|
||||||
- name: Build Mongo Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate:mongo-demo \
|
|
||||||
-f ./docker/prod/mongoDB.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push MongoDB Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-demo
|
|
||||||
|
|
||||||
deploy-to-demo:
|
|
||||||
needs: docker-build-and-push-server
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: SSH into server and restart container using Docker Compose
|
|
||||||
uses: appleboy/ssh-action@v1.2.2
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.DEMO_SERVER_HOST }}
|
|
||||||
username: ${{ secrets.DEMO_SERVER_USER }}
|
|
||||||
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd checkmate
|
|
||||||
git pull
|
|
||||||
cd docker/unified
|
|
||||||
docker compose down
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
docker system prune -af
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
name: Staging deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "develop" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
docker-build-and-push-client:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: vars
|
|
||||||
run: echo "VERSION=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
|
|
||||||
- name: Build Client Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate:frontend-staging \
|
|
||||||
-f ./docker/staging/client.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
--build-arg VITE_APP_VERSION=${{ env.VERSION }} \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Client Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate:frontend-staging
|
|
||||||
|
|
||||||
docker-build-and-push-server:
|
|
||||||
needs: docker-build-and-push-client
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build Server Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate:backend-staging \
|
|
||||||
-f ./docker/staging/server.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push Server Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate:backend-staging
|
|
||||||
|
|
||||||
- name: Build Mongo Docker image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t ghcr.io/bluewave-labs/checkmate:mongo-staging \
|
|
||||||
-f ./docker/staging/mongoDB.Dockerfile \
|
|
||||||
--label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push MongoDB Docker image
|
|
||||||
run: docker push ghcr.io/bluewave-labs/checkmate:mongo-staging
|
|
||||||
|
|
||||||
deploy-to-staging:
|
|
||||||
needs: docker-build-and-push-server
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: SSH into server and restart container using Docker Compose
|
|
||||||
uses: appleboy/ssh-action@v1.2.2
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.DEMO_SERVER_HOST }}
|
|
||||||
username: ${{ secrets.DEMO_SERVER_USER }}
|
|
||||||
key: ${{ secrets.DEMO_SERVER_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd checkmate
|
|
||||||
git pull
|
|
||||||
cd docker/unified
|
|
||||||
docker compose down
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
docker system prune -af
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
name: Upload Translations to POEditor on PR Merge
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [closed]
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- "client/src/locales/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
upload-translations:
|
|
||||||
# Only run if the PR was merged (not just closed) or manually triggered
|
|
||||||
if: github.event.pull_request.merged == true
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Fetch all history to get changed files
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
|
|
||||||
- name: Create package.json for scripts
|
|
||||||
run: |
|
|
||||||
mkdir -p .github/scripts
|
|
||||||
cat > .github/scripts/package.json << EOF
|
|
||||||
{
|
|
||||||
"name": "poeditor-scripts",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"axios": "^1.6.0",
|
|
||||||
"fs-extra": "^11.1.1",
|
|
||||||
"form-data": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
cd .github/scripts
|
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Get changed locale files
|
|
||||||
id: changed-files
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
# Get base and head commits
|
|
||||||
BASE_SHA=${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD_SHA=${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
echo "Base SHA: $BASE_SHA"
|
|
||||||
echo "Head SHA: $HEAD_SHA"
|
|
||||||
|
|
||||||
# Get list of changed files in client/src/locales directory
|
|
||||||
CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json')
|
|
||||||
|
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
|
||||||
echo "No changes detected in locale files"
|
|
||||||
echo "CHANGED_FILES=" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "Changed files:"
|
|
||||||
echo "$CHANGED_FILES"
|
|
||||||
echo "CHANGED_FILES<<EOF" >> $GITHUB_ENV
|
|
||||||
echo "$CHANGED_FILES" >> $GITHUB_ENV
|
|
||||||
echo "EOF" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload changed translations to POEditor
|
|
||||||
if: env.CHANGED_FILES != ''
|
|
||||||
env:
|
|
||||||
POEDITOR_API: ${{ secrets.POEDITOR_API }}
|
|
||||||
POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }}
|
|
||||||
run: |
|
|
||||||
# Process each changed file
|
|
||||||
for FILE in $CHANGED_FILES; do
|
|
||||||
if [[ -f "$FILE" ]]; then
|
|
||||||
# Extract language code from filename (e.g., client/src/locales/en.json -> en)
|
|
||||||
FILENAME=$(basename "$FILE")
|
|
||||||
|
|
||||||
# Special case: map gb.json to en language code
|
|
||||||
if [ "$FILENAME" == "gb.json" ]; then
|
|
||||||
LANG="en"
|
|
||||||
echo "Found gb.json, mapping to language code 'en'"
|
|
||||||
else
|
|
||||||
LANG=$(basename "$FILE" .json)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Processing $FILE for language $LANG"
|
|
||||||
|
|
||||||
# Upload to POEditor
|
|
||||||
LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Notify on success
|
|
||||||
if: success() && env.CHANGED_FILES != ''
|
|
||||||
run: |
|
|
||||||
echo "Successfully uploaded translation files to POEditor."
|
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
|
||||||
echo "Manual trigger comment: ${{ github.event.inputs.comment }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on no changes
|
|
||||||
if: env.CHANGED_FILES == ''
|
|
||||||
run: |
|
|
||||||
echo "No translation files were found to upload."
|
|
||||||
@@ -35,9 +35,9 @@ npm run format # Prettier formatting
|
|||||||
|
|
||||||
### Docker Development
|
### Docker Development
|
||||||
```bash
|
```bash
|
||||||
cd docker/dev
|
docker build -f docker/Dockerfile -t checkmate . # build the mono image (server + built client)
|
||||||
./build_images.sh
|
docker compose -f docker/dev/docker-compose.yaml up # local full stack (builds image, runs mongo + worker)
|
||||||
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
|
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0 # just a dev database
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
@@ -62,25 +62,29 @@ VITE_APP_LOG_LEVEL="debug"
|
|||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
- `/client` - React 18 + TypeScript + Vite + MUI frontend
|
||||||
- `/server` - Node.js 20+ + Express + TypeScript backend
|
- `/server` - Node.js 24+ + Express + TypeScript backend
|
||||||
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
- `/docker` - Multi-environment Docker configs (dev, staging, prod, arm, mono)
|
||||||
|
|
||||||
### Backend Layers
|
### Backend Layers
|
||||||
```
|
```
|
||||||
server/src/
|
server/src/
|
||||||
├── controllers/ # Route handlers (authController, monitorController, etc.)
|
├── api/ # HTTP layer
|
||||||
├── service/ # Business logic
|
│ ├── controllers/ # Route handlers (authController, monitorController, etc.)
|
||||||
│ ├── business/ # Core monitoring logic
|
│ ├── middleware/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
||||||
│ ├── infrastructure/ # Server/system utilities
|
│ ├── routes/ # API route definitions
|
||||||
│ └── system/ # App-level settings
|
│ └── validation/ # Zod request-payload schemas
|
||||||
|
├── domain/ # Business logic + data access, one folder per entity
|
||||||
|
│ ├── monitors/ # e.g. monitor.service.ts, monitor.repository.ts, monitor.type.ts
|
||||||
|
│ ├── checks/
|
||||||
|
│ ├── incidents/
|
||||||
|
│ └── … # users, teams, status-pages, notifications, etc.
|
||||||
|
├── service/ # Cross-cutting services (e.g. infrastructure/)
|
||||||
|
├── config/ # App config wiring (controllers, routes, services, envValidation)
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── models/ # Mongoose schemas (Monitor, Check, Incident, User, etc.)
|
│ └── migration/ # Database migrations (run on startup)
|
||||||
│ ├── migration/ # Database migrations (run on startup)
|
├── templates/ # Email/notification templates
|
||||||
│ └── modules/ # Database-specific modules
|
├── types/ # Shared const tuples + derived types
|
||||||
├── middleware/v1/ # verifyJWT, rateLimiter, sanitization, responseHandler
|
└── utils/ # Shared utilities
|
||||||
├── routes/v1/ # API route definitions
|
|
||||||
├── validation/ # Joi input validation schemas
|
|
||||||
└── repositories/ # Data access layer
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Structure
|
### Frontend Structure
|
||||||
@@ -105,7 +109,7 @@ client/src/
|
|||||||
- **Data Fetching**: SWR + Axios
|
- **Data Fetching**: SWR + Axios
|
||||||
- **Database**: MongoDB with Mongoose ODM
|
- **Database**: MongoDB with Mongoose ODM
|
||||||
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
- **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling)
|
||||||
- **i18n**: i18next + react-i18next (translations via PoEditor)
|
- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+30
-25
@@ -88,34 +88,10 @@ cd Checkmate
|
|||||||
|
|
||||||
#### Step 2: Set Up Docker Containers (MongoDB)
|
#### Step 2: Set Up Docker Containers (MongoDB)
|
||||||
|
|
||||||
Navigate to the Docker dev directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd docker/dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Build the Docker images:
|
|
||||||
|
|
||||||
Linux:
|
|
||||||
```bash
|
|
||||||
./build_images.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows:
|
|
||||||
```bash
|
|
||||||
./build_images.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
Run MongoDB container:
|
Run MongoDB container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0
|
docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:8.0
|
||||||
```
|
|
||||||
|
|
||||||
Navigate back to the root directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ../..
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Step 3: Set Up the Backend (Server)
|
#### Step 3: Set Up the Backend (Server)
|
||||||
@@ -256,6 +232,35 @@ docker rm uptime_database_mongo
|
|||||||
|
|
||||||
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
Start with [good first issues](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||||
|
|
||||||
|
### Improve the documentation?
|
||||||
|
|
||||||
|
Documentation contributions are very welcome — fixing a typo, clarifying a step, or adding a missing example is a valuable PR.
|
||||||
|
|
||||||
|
- Small edits (typos, wording, formatting): open a PR directly against `develop`.
|
||||||
|
- Larger changes (restructuring a section, adding a new guide): open an issue first so we can align on scope, then a PR.
|
||||||
|
- Screenshots are helpful when documenting UI behaviour.
|
||||||
|
- README translations live in `docs/translations/`. If you're adding a new language, please also add a flag + link to the language bar in the root `README.md` and every existing translated README so they stay in sync.
|
||||||
|
|
||||||
|
### Help with translations?
|
||||||
|
|
||||||
|
The application uses `i18next` with one JSON file per language under `client/src/locales/` (`en.json` is the source of truth). English source strings are the definitive copy; other languages are generated as one-time machine translations that anyone is welcome to refine.
|
||||||
|
|
||||||
|
**To improve an existing language:**
|
||||||
|
|
||||||
|
1. Edit the relevant `client/src/locales/<code>.json` file directly.
|
||||||
|
2. Keep the JSON key structure identical to `en.json` — only translate the string values.
|
||||||
|
3. Open a PR against `develop`. No language-selector code changes are needed.
|
||||||
|
|
||||||
|
**To add a new language:**
|
||||||
|
|
||||||
|
1. Add `client/src/locales/<code>.json` with the same key structure as `en.json`, translated. If you can't translate every key, leave unfamiliar strings in English rather than removing keys — i18next falls back to the key path when a value is missing.
|
||||||
|
2. Add the display name to `languageNames` in `client/src/Components/inputs/LanguageSelector.tsx` (e.g. `it: "Italiano"`). The selector picks up any language present in `resources` automatically.
|
||||||
|
3. Open a PR against `develop`.
|
||||||
|
|
||||||
|
If translating a whole file is more than you want to take on, open an issue asking us to add the language and we'll generate an initial machine-translated file that you (or others) can refine over time.
|
||||||
|
|
||||||
|
**Note:** an older workflow that synced translations with PoEditor is still in the repo but no longer authoritative. Please treat the `.json` files in `client/src/locales/` as the source of truth.
|
||||||
|
|
||||||
### Submit a pull request?
|
### Submit a pull request?
|
||||||
|
|
||||||
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
|
Follow the [pull request checklist](#pull-request-checklist). Your PR should:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
🇪🇸 <a href="docs/translations/README.es.md">Español</a> |
|
🇪🇸 <a href="docs/translations/README.es.md">Español</a> |
|
||||||
🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> |
|
🇫🇮 <a href="docs/translations/README.fi.md">Suomi</a> |
|
||||||
🇫🇷 <a href="docs/translations/README.fr.md">Français</a> |
|
🇫🇷 <a href="docs/translations/README.fr.md">Français</a> |
|
||||||
|
🇮🇹 <a href="docs/translations/README.it.md">Italiano</a> |
|
||||||
🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> |
|
🇯🇵 <a href="docs/translations/README.ja.md">日本語</a> |
|
||||||
🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> |
|
🇧🇷 <a href="docs/translations/README.pt-BR.md">Português (Brasil)</a> |
|
||||||
🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> |
|
🇷🇺 <a href="docs/translations/README.ru.md">Русский</a> |
|
||||||
@@ -31,12 +32,12 @@
|
|||||||
|
|
||||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||||
|
|
||||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=checkmate)
|
[](https://www.pikapods.com/pods?run=checkmate)
|
||||||
|
|
||||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
<img width="1461" height="853" alt="image" src="https://github.com/user-attachments/assets/d5ee1a4e-9db9-47cc-ad47-e1ba88a9d796" />
|
||||||
|
</p>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
|
||||||
@@ -79,14 +80,168 @@ Usage instructions can be found [here](https://checkmate.so/docs).
|
|||||||
<a id="installation"></a>
|
<a id="installation"></a>
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
|
The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
|
||||||
|
|
||||||
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
|
||||||
|
JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||||
|
|
||||||
|
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
||||||
|
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [Easypanel](https://easypanel.io/templates/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). Note that the Helm chart has not yet been migrated to the all-in-one image: it still deploys the legacy `checkmate-client`, `checkmate-backend` and `checkmate-mongo` images, pinned at v3.8.1.
|
||||||
|
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The image is configured entirely through environment variables on the server container:
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DB_CONNECTION_STRING` | Yes | MongoDB connection string, e.g. `mongodb://mongodb:27017/uptime_db` |
|
||||||
|
| `JWT_SECRET` | Yes | Secret used to sign auth tokens; generate one with `openssl rand -hex 32` |
|
||||||
|
| `CLIENT_HOST` | Yes | The URL users reach the app at, e.g. `https://checkmate.example.com`; used for CORS and for links in notifications and emails |
|
||||||
|
| `ENCRYPTION_KEY` | No | Encrypts stored Docker TLS client keys at rest; generate one with `openssl rand -base64 32`. Comma-separated list: the first key encrypts, every key decrypts. Must be identical on the API and every worker. To rotate without downtime, deploy `OLD_KEY,NEW_KEY` everywhere, then `NEW_KEY,OLD_KEY` everywhere, wait for the worker to re-encrypt every row, then drop `OLD_KEY`. |
|
||||||
|
| `PORT` | No | Port the API and web client are served on (default `52345`) |
|
||||||
|
| `HEALTH_PORT` | No | Port for the `/livez`, `/readyz` and `/metrics` endpoints, served by any process that runs the job worker (default `52346`) |
|
||||||
|
| `NODE_ENV` | No | `development`, `production` or `test` (default `development`). `development` disables the general API rate limiter; set `production` on real deployments |
|
||||||
|
| `LOG_LEVEL` | No | Server log level: `error`, `warn`, `info`, or `debug` (default `debug`) |
|
||||||
|
| `TOKEN_TTL` | No | Lifetime of issued auth tokens, e.g. `12h` or `7d` (default `99d`) |
|
||||||
|
| `QUEUE_MODE` | No | `primary` (default) runs the API, the web client and the job scheduler; `worker` runs a job-processing worker only, with no API |
|
||||||
|
| `QUEUE_PRIMARY_PROCESSES` | No | `true` (default) or `false`. Whether a `primary` node also processes monitoring jobs itself; set `false` when dedicated `worker` nodes handle all checks. Ignored in `worker` mode |
|
||||||
|
| `STATUS_PAGE_THEMES_ENABLED` | No | `true` (default) or `false`. When `false`, status pages ignore theme settings and always render the default theme |
|
||||||
|
|
||||||
|
The web client needs no configuration by default: it calls the API on the same origin it was served from (`/api/v1`). For setups where the defaults don't apply — for example, the API is reached through a different origin than the page — the server renders overrides into the client at runtime via these optional variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `CLIENT_CONFIG_API_BASE_URL` | Full base URL the client calls the API at, e.g. `https://api.example.com/api/v1`; defaults to same-origin `/api/v1` |
|
||||||
|
| `CLIENT_CONFIG_CLIENT_HOST` | Origin used when the client builds absolute links (invites, status pages); defaults to the browser's current origin |
|
||||||
|
| `CLIENT_CONFIG_LOG_LEVEL` | Browser console log level: `error`, `warn`, `info`, or `debug` (default `error`) |
|
||||||
|
|
||||||
|
> **Upgrading from an older image?** The `UPTIME_APP_*` variables (`UPTIME_APP_API_BASE_URL`, `UPTIME_APP_CLIENT_HOST`, `UPTIME_APP_LOG_LEVEL`) are no longer read. In most setups no replacement is needed — the same-origin defaults cover them; if you pointed the client at a different origin, use the `CLIENT_CONFIG_*` equivalents above. The `checkmate-client`, `checkmate-backend`, `checkmate-mongo`, and `checkmate-backend-mono-multiarch` images are no longer updated — switch to `ghcr.io/bluewave-labs/checkmate`, keeping your existing MongoDB service and data volume.
|
||||||
|
|
||||||
|
See full installation instructions in the [Checkmate documentation portal](https://checkmate.so/docs).
|
||||||
|
|
||||||
|
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) (legacy images, pinned at v3.8.1), [Sive Host](https://sive.host) (South Africa), [Cloudzy](https://cloudzy.com/marketplace/checkmate) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
|
||||||
|
|
||||||
### Using a Custom CA
|
### Using a Custom CA
|
||||||
|
|
||||||
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
If you need to monitor internal HTTPS endpoints with certificates from private Certificate Authorities (like Smallstep), see our [Custom CA Trust Guide](./docs/custom-ca-trust.md) for Docker configuration options.
|
||||||
|
|
||||||
|
### Docker monitors
|
||||||
|
|
||||||
|
A Docker monitor reports on every container running on a host. It can connect to the Docker daemon directly or read container metrics from a Capture agent. Direct monitors use the daemon's ping response for availability and record container state, health, CPU and memory usage, restart count, published ports and mounts. Enabling **Collect container logs** additionally stores the latest 200 log lines per container on every direct check; logs are kept for 7 days.
|
||||||
|
|
||||||
|
The **Docker host** field accepts three forms:
|
||||||
|
|
||||||
|
| Host | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Local socket | `unix:///var/run/docker.sock` | Also accepts a bare absolute path such as `/var/run/docker.sock`. Use this for the daemon on the same machine Checkmate runs on. |
|
||||||
|
| Remote daemon | `tcp://docker.example.com:2376` | Always uses mutual TLS; the port defaults to `2376`. Unencrypted daemons on `2375` are not supported. |
|
||||||
|
| Capture agent | `https://capture.example.com/api/v1/metrics/docker` | Enter the full endpoint ending in `/metrics/docker` and its authorization secret. Checkmate requests `all=true` so stopped containers are included. |
|
||||||
|
|
||||||
|
Capture-backed monitors normalize Capture's container state, health, CPU, memory, start time and exposed ports into the same Docker pages. Capture does not currently provide restart counts, mounts, published host-port bindings or container logs, so those values are shown as unavailable. Capture also reports a container whose health check is still starting as healthy, so the `starting` health state only appears on direct monitors, and stopped containers carry no CPU or memory metrics.
|
||||||
|
|
||||||
|
**Monitoring the local socket.** The reference Compose file does not mount the socket, so add it and grant the container the host's `docker` group. The image runs as an unprivileged user and cannot read the socket otherwise. Find the group id with `stat -c %g /var/run/docker.sock`, then:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
checkmate:
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
group_add:
|
||||||
|
- "989" # the gid printed by stat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monitoring a remote daemon.** Set the host to `tcp://host:port` and fill in the **TLS credentials** section of the monitor form with the same PEM files you would pass to `docker --tlsverify`:
|
||||||
|
|
||||||
|
- **CA certificate**: the CA that signed the daemon's server certificate. Required unless **Ignore TLS/SSL errors** is on, which skips verifying the daemon's identity.
|
||||||
|
- **Client certificate**: the certificate the daemon uses to authenticate Checkmate.
|
||||||
|
- **Client key**: the matching private key, unencrypted (no passphrase). Checkmate checks that it matches the certificate, encrypts it with `ENCRYPTION_KEY`, and never shows it again. Leave the field blank when editing to keep the stored key.
|
||||||
|
|
||||||
|
TLS Docker monitors require `ENCRYPTION_KEY` to be set on the server (see [Configuration](#configuration)). Saving one without it fails with an error telling you so. If the key is ever removed or rotated incorrectly, affected checks fail with a decryption error until it is restored.
|
||||||
|
|
||||||
|
If the daemon's certificate is signed by a private CA that you also want the rest of Checkmate to trust, see the [Custom CA Trust Guide](./docs/custom-ca-trust.md); for Docker monitors alone, the **CA certificate** field is enough.
|
||||||
|
|
||||||
|
### Securing the Docker daemon with TLS
|
||||||
|
|
||||||
|
The Docker daemon does not enable TLS by default. Follow these steps on the Docker host to generate a CA, a server certificate, and a client certificate for Checkmate. This is the procedure from [Docker's own guide](https://docs.docker.com/engine/security/protect-access/), condensed. Replace `docker.example.com` and `203.0.113.10` with your daemon's DNS name and IP.
|
||||||
|
|
||||||
|
**1. Create a CA.** The CA key gets a passphrase; keep it offline once the certificates are issued.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -aes256 -out ca-key.pem 4096
|
||||||
|
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj "/CN=docker-ca" -out ca.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create the server certificate.** The subject alternative names must cover every name or address Checkmate will use to reach the daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -out server-key.pem 4096
|
||||||
|
openssl req -subj "/CN=docker.example.com" -sha256 -new -key server-key.pem -out server.csr
|
||||||
|
cat > server-ext.cnf <<EOF
|
||||||
|
subjectAltName = DNS:docker.example.com,IP:203.0.113.10
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
EOF
|
||||||
|
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out server-cert.pem -extfile server-ext.cnf
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create the client certificate for Checkmate.** Do not add a passphrase to this key; Checkmate cannot use encrypted private keys.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genrsa -out key.pem 4096
|
||||||
|
openssl req -subj "/CN=checkmate" -new -key key.pem -out client.csr
|
||||||
|
echo "extendedKeyUsage = clientAuth" > client-ext.cnf
|
||||||
|
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
||||||
|
-out cert.pem -extfile client-ext.cnf
|
||||||
|
rm client.csr server.csr server-ext.cnf client-ext.cnf
|
||||||
|
chmod 0400 ca-key.pem key.pem server-key.pem
|
||||||
|
chmod 0444 ca.pem server-cert.pem cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Point the daemon at the certificates.** Move `ca.pem`, `server-cert.pem` and `server-key.pem` to `/etc/docker/certs/` and configure `/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
|
||||||
|
"tls": true,
|
||||||
|
"tlsverify": true,
|
||||||
|
"tlscacert": "/etc/docker/certs/ca.pem",
|
||||||
|
"tlscert": "/etc/docker/certs/server-cert.pem",
|
||||||
|
"tlskey": "/etc/docker/certs/server-key.pem"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On distributions where the systemd unit already passes `-H fd://` (Debian, Ubuntu and derivatives), the daemon refuses to start with `hosts` set in both places. Remove the flag from the unit with an override, then restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl edit docker.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/dockerd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
Open port `2376` on the host firewall only to the machine running Checkmate.
|
||||||
|
|
||||||
|
**5. Verify from the Checkmate host**, then paste `ca.pem`, `cert.pem` and `key.pem` into the monitor form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --tlsverify --tlscacert=ca.pem --tlscert=cert.pem --tlskey=key.pem \
|
||||||
|
-H=docker.example.com:2376 version
|
||||||
|
```
|
||||||
|
|
||||||
For more documentation, see the [docs directory](./docs/).
|
For more documentation, see the [docs directory](./docs/).
|
||||||
|
|
||||||
<a id="performance"></a>
|
<a id="performance"></a>
|
||||||
@@ -96,7 +251,7 @@ Thanks to extensive optimizations, Checkmate operates with an exceptionally smal
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can see the memory footprint of MongoDB and Redis on the same server (398Mb and 15Mb) for the same amount of servers:
|
You can see the memory footprint of MongoDB on the same server (398Mb) for the same amount of servers:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -114,16 +269,16 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
- Completely open source, deployable on your servers or home devices (e.g Raspberry Pi 4 or 5)
|
||||||
- Several monitoring options: Uptime, Docker, Ping, SSL, Port, Game server
|
- Several monitoring options: HTTP (with SSL certificate expiry), Ping, Port, DNS, Docker, gRPC, WebSocket, Game server
|
||||||
- Page speed monitoring
|
- Page speed monitoring
|
||||||
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
- Infrastructure monitoring (memory, disk usage, CPU performance, network etc) - requires [Capture](https://github.com/bluewave-labs/capture) agent
|
||||||
- Selective disk monitoring with mountpoint selection
|
- Selective disk monitoring with mountpoint selection
|
||||||
- Incidents at a glance
|
- Incidents at a glance
|
||||||
- Status pages with 4 beautiful themes
|
- Status pages with 5 beautiful themes
|
||||||
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
|
- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, ntfy, SignalGrid, Twilio (SMS) notifications
|
||||||
- Scheduled maintenance
|
- Scheduled maintenance
|
||||||
- JSON query monitoring
|
- JSON query monitoring
|
||||||
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
- Multi-language support for Arabic, Catalan, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Italian, Japanese, Polish, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
|
||||||
|
|
||||||
|
|
||||||
## Monitor Lifecycle
|
## Monitor Lifecycle
|
||||||
@@ -174,9 +329,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
|||||||
<a id="contributing"></a>
|
<a id="contributing"></a>
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
|
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||||
|
|
||||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
|
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K stars and attracted 150+ contributors from around the globe.
|
||||||
|
|
||||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ apiVersion: v2
|
|||||||
name: checkmate-chart
|
name: checkmate-chart
|
||||||
description: A Helm chart for Checkmate App
|
description: A Helm chart for Checkmate App
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
appVersion: "2.3"
|
# DO NOT bump appVersion past v3.8.1 until this chart is migrated to the all-in-one
|
||||||
|
# image (ghcr.io/bluewave-labs/checkmate). The checkmate-client / checkmate-backend /
|
||||||
|
# checkmate-mongo images this chart references are frozen at v3.8.1 — later versions
|
||||||
|
# are never published, so a bumped chart will fail to pull.
|
||||||
|
appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)
|
||||||
@@ -19,12 +19,11 @@ cd checkmate/charts/helm/checkmate
|
|||||||
|
|
||||||
### 2. Customize values.yaml
|
### 2. Customize values.yaml
|
||||||
Edit `values.yaml` to update:
|
Edit `values.yaml` to update:
|
||||||
- `client.ingress.host` and `server.ingress.host` with your domain names
|
- `client.ingress.host` and `api.ingress.host` with your domain names
|
||||||
- `server.protocol` (usually http or https)
|
- `api.protocol` (usually http or https)
|
||||||
- **If upgrading**: Migrate persistence settings from flat structure to nested:
|
- **If upgrading**: Migrate persistence settings from flat structure to nested:
|
||||||
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
|
- Old: `persistence.mongodbSize` → New: `persistence.mongo.size`
|
||||||
- Old: `persistence.redisSize` → New: `persistence.redis.size`
|
- Add: `persistence.mongo.storageClass` (leave empty for default)
|
||||||
- Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default)
|
|
||||||
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
|
- Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values
|
||||||
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
|
- **For TLS/HTTPS**: Configure ingress TLS settings (see section below)
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ Edit `values.yaml` to update:
|
|||||||
```bash
|
```bash
|
||||||
helm install checkmate ./charts/helm/checkmate
|
helm install checkmate ./charts/helm/checkmate
|
||||||
```
|
```
|
||||||
This will deploy the client, server, MongoDB, and Redis components.
|
This will deploy the client, api, and MongoDB components.
|
||||||
|
|
||||||
### 4. Verify the deployment
|
### 4. Verify the deployment
|
||||||
Check pods and services:
|
Check pods and services:
|
||||||
@@ -43,6 +42,137 @@ kubectl get svc
|
|||||||
|
|
||||||
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
|
Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts.
|
||||||
|
|
||||||
|
## Upgrading from a previous release
|
||||||
|
|
||||||
|
This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis.
|
||||||
|
Read this before running `helm upgrade` on an existing install.
|
||||||
|
|
||||||
|
### `server.*` → `api.*` (values rename)
|
||||||
|
|
||||||
|
The value block formerly named `server` is now `api` (`server.image` → `api.image`,
|
||||||
|
`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`,
|
||||||
|
`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.).
|
||||||
|
|
||||||
|
**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on
|
||||||
|
top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The
|
||||||
|
shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:`
|
||||||
|
block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins —
|
||||||
|
don't mix them.
|
||||||
|
|
||||||
|
### Resource renames cause a replace, not a rename
|
||||||
|
|
||||||
|
The Kubernetes objects are renamed alongside the values:
|
||||||
|
|
||||||
|
| Old object | New object |
|
||||||
|
| --- | --- |
|
||||||
|
| Deployment/Service `checkmate-server` | `checkmate-api` |
|
||||||
|
| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` |
|
||||||
|
| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` |
|
||||||
|
|
||||||
|
Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a
|
||||||
|
**new ClusterIP** for the API Service — update anything outside the chart that referenced the
|
||||||
|
`checkmate-server` Service by name. MongoDB data (its PVC) is untouched.
|
||||||
|
|
||||||
|
### Redis removed
|
||||||
|
|
||||||
|
The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the
|
||||||
|
scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are
|
||||||
|
unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are
|
||||||
|
deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:`
|
||||||
|
keys in your values file are harmless (ignored).
|
||||||
|
|
||||||
|
### New secret defaults
|
||||||
|
|
||||||
|
`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets`
|
||||||
|
block and merge into your install unless you override them. Set them explicitly if you relied on
|
||||||
|
different values (e.g. a non-production `NODE_ENV`).
|
||||||
|
|
||||||
|
### Image tags follow the chart's `appVersion`
|
||||||
|
|
||||||
|
Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's
|
||||||
|
`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the
|
||||||
|
chart. You can still pin per tier:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api:
|
||||||
|
image: ghcr.io/bluewave-labs/checkmate-backend # repo only
|
||||||
|
tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<tier>.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and
|
||||||
|
`api.image`) unless you set `worker.tag` / `worker.image`.
|
||||||
|
- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is
|
||||||
|
used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged.
|
||||||
|
|
||||||
|
**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is
|
||||||
|
cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or
|
||||||
|
let it default to `appVersion`) for reproducible, rollback-able deploys.
|
||||||
|
|
||||||
|
## Scaling: the worker tier & autoscaling
|
||||||
|
|
||||||
|
By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring
|
||||||
|
jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated,
|
||||||
|
horizontally-scalable worker tier.
|
||||||
|
|
||||||
|
### Topology
|
||||||
|
|
||||||
|
| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed |
|
||||||
|
| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs |
|
||||||
|
|
||||||
|
`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API
|
||||||
|
and worker tier can never both process (or both ignore) the queue.
|
||||||
|
|
||||||
|
### Enabling the worker tier
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
worker:
|
||||||
|
enabled: true
|
||||||
|
replicas: 2 # used only when autoscaling.enabled = false
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on
|
||||||
|
`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit.
|
||||||
|
|
||||||
|
> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the
|
||||||
|
> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight
|
||||||
|
> checks.
|
||||||
|
|
||||||
|
> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are
|
||||||
|
> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate
|
||||||
|
> this; full failover to the API would need leader election (not supported).
|
||||||
|
|
||||||
|
### Autoscaling with KEDA
|
||||||
|
|
||||||
|
Backlog-driven autoscaling is **off by default** and requires the
|
||||||
|
[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
worker:
|
||||||
|
enabled: true
|
||||||
|
autoscaling:
|
||||||
|
enabled: true
|
||||||
|
minReplicaCount: 1 # processing floor — never 0, or checks silently stall
|
||||||
|
maxReplicaCount: 10
|
||||||
|
backlogPerReplica: 50 # target due-job backlog per worker
|
||||||
|
mongo:
|
||||||
|
dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING
|
||||||
|
```
|
||||||
|
|
||||||
|
A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker
|
||||||
|
Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment
|
||||||
|
omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName`
|
||||||
|
matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0
|
||||||
|
backlog → never scales up).
|
||||||
|
|
||||||
|
#### Fallback: Prometheus-adapter HPA (not shipped)
|
||||||
|
|
||||||
|
If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape
|
||||||
|
the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target
|
||||||
|
it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes
|
||||||
|
`now` in code on this path, so the query coercion is unnecessary.
|
||||||
|
|
||||||
## Enabling TLS/HTTPS with cert-manager
|
## Enabling TLS/HTTPS with cert-manager
|
||||||
|
|
||||||
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
|
If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers.
|
||||||
@@ -68,7 +198,7 @@ client:
|
|||||||
enabled: true
|
enabled: true
|
||||||
secretName: checkmate-client-tls
|
secretName: checkmate-client-tls
|
||||||
|
|
||||||
server:
|
api:
|
||||||
protocol: https
|
protocol: https
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -78,7 +208,7 @@ server:
|
|||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
tls:
|
tls:
|
||||||
enabled: true
|
enabled: true
|
||||||
secretName: checkmate-server-tls
|
secretName: checkmate-api-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: Using --set flags
|
### Alternative: Using --set flags
|
||||||
@@ -88,13 +218,13 @@ You can also enable TLS during installation using Helm's `--set` flags:
|
|||||||
```bash
|
```bash
|
||||||
helm install checkmate ./charts/helm/checkmate \
|
helm install checkmate ./charts/helm/checkmate \
|
||||||
--set client.protocol=https \
|
--set client.protocol=https \
|
||||||
--set server.protocol=https \
|
--set api.protocol=https \
|
||||||
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
--set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||||
--set client.ingress.tls.enabled=true \
|
--set client.ingress.tls.enabled=true \
|
||||||
--set client.ingress.tls.secretName=checkmate-client-tls \
|
--set client.ingress.tls.secretName=checkmate-client-tls \
|
||||||
--set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
--set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \
|
||||||
--set server.ingress.tls.enabled=true \
|
--set api.ingress.tls.enabled=true \
|
||||||
--set server.ingress.tls.secretName=checkmate-server-tls
|
--set api.ingress.tls.secretName=checkmate-api-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
@@ -107,7 +237,7 @@ kubectl get certificates
|
|||||||
|
|
||||||
# Check certificate details
|
# Check certificate details
|
||||||
kubectl describe certificate checkmate-client-tls
|
kubectl describe certificate checkmate-client-tls
|
||||||
kubectl describe certificate checkmate-server-tls
|
kubectl describe certificate checkmate-api-tls
|
||||||
|
|
||||||
# Verify the secrets were created
|
# Verify the secrets were created
|
||||||
kubectl get secrets | grep checkmate-tls
|
kubectl get secrets | grep checkmate-tls
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{- /*
|
||||||
|
Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that
|
||||||
|
still pass a legacy `server:` block keep working — this returns the effective API values with any
|
||||||
|
legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set).
|
||||||
|
|
||||||
|
New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as:
|
||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
|
then reference `$api.*` instead of `.Values.api.*`.
|
||||||
|
*/}}
|
||||||
|
{{- define "checkmate.api" -}}
|
||||||
|
{{- $api := deepCopy .Values.api -}}
|
||||||
|
{{- with .Values.server -}}
|
||||||
|
{{- $api = mergeOverwrite $api (deepCopy .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $api | toYaml -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of
|
||||||
|
truth for tags: bumping Chart.appVersion moves every tier at once. Call as:
|
||||||
|
{{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }}
|
||||||
|
|
||||||
|
If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps
|
||||||
|
legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest").
|
||||||
|
A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still
|
||||||
|
appended. Otherwise the tag is `tag` if set, else the chart's appVersion.
|
||||||
|
*/}}
|
||||||
|
{{- define "checkmate.image" -}}
|
||||||
|
{{- $lastSegment := .image | splitList "/" | last -}}
|
||||||
|
{{- if contains ":" $lastSegment -}}
|
||||||
|
{{- .image -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: checkmate-api
|
||||||
|
spec:
|
||||||
|
replicas: 1 # single scheduler — seeds/reconciles the jobs collection
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: checkmate-api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: checkmate-api
|
||||||
|
spec:
|
||||||
|
{{- with $api.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with $api.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }}
|
||||||
|
imagePullPolicy: {{ $api.imagePullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ $api.port }}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: checkmate-secrets
|
||||||
|
# Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive.
|
||||||
|
# NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is
|
||||||
|
# also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5.
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ $api.probes.liveness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ $api.probes.liveness.failureThreshold }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ $api.probes.readiness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ $api.probes.readiness.failureThreshold }}
|
||||||
|
{{- with $api.resources }}
|
||||||
|
resources:
|
||||||
|
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
env:
|
||||||
|
- name: CLIENT_HOST
|
||||||
|
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||||
|
- name: QUEUE_MODE
|
||||||
|
value: "primary"
|
||||||
|
# Derived: when a worker tier is deployed the API only schedules (false); otherwise it is
|
||||||
|
# all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs.
|
||||||
|
- name: QUEUE_PRIMARY_PROCESSES
|
||||||
|
value: {{ not .Values.worker.enabled | quote }}
|
||||||
+12
-11
@@ -1,11 +1,12 @@
|
|||||||
{{- if .Values.server.ingress.enabled }}
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
|
{{- if $api.ingress.enabled }}
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: checkmate-server-ingress
|
name: checkmate-api-ingress
|
||||||
{{- if .Values.server.ingress.annotations }}
|
{{- if $api.ingress.annotations }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- range $key, $value := .Values.server.ingress.annotations }}
|
{{- range $key, $value := $api.ingress.annotations }}
|
||||||
{{ $key }}: {{ $value | quote }}
|
{{ $key }}: {{ $value | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -17,22 +18,22 @@ metadata:
|
|||||||
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
#nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||||
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
|
#nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}}
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: {{ .Values.server.ingress.className }}
|
ingressClassName: {{ $api.ingress.className }}
|
||||||
{{- if .Values.server.ingress.tls.enabled }}
|
{{- if $api.ingress.tls.enabled }}
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- {{ .Values.server.ingress.host }}
|
- {{ $api.ingress.host }}
|
||||||
secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }}
|
secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
rules:
|
rules:
|
||||||
- host: {{ .Values.server.ingress.host }}
|
- host: {{ $api.ingress.host }}
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /api/v1
|
- path: /api/v1
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: checkmate-server
|
name: checkmate-api
|
||||||
port:
|
port:
|
||||||
number: {{ .Values.server.port }}
|
number: {{ $api.port }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: checkmate-api
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: checkmate-api
|
||||||
|
ports:
|
||||||
|
- port: {{ $api.port }}
|
||||||
+5
-3
@@ -1,3 +1,4 @@
|
|||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -22,12 +23,13 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: client
|
- name: client
|
||||||
image: {{ .Values.client.image }}
|
image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }}
|
||||||
|
imagePullPolicy: {{ .Values.client.imagePullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- containerPort: {{ .Values.client.port }}
|
- containerPort: {{ .Values.client.port }}
|
||||||
env:
|
env:
|
||||||
- name: UPTIME_APP_API_BASE_URL
|
- name: UPTIME_APP_API_BASE_URL
|
||||||
value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1"
|
value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1"
|
||||||
- name: UPTIME_APP_CLIENT_HOST
|
- name: UPTIME_APP_CLIENT_HOST
|
||||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
@@ -41,4 +43,4 @@ spec:
|
|||||||
volumes:
|
volumes:
|
||||||
- name: config-volume
|
- name: config-volume
|
||||||
configMap:
|
configMap:
|
||||||
name: checkmate-server-nginx-cm
|
name: checkmate-client-nginx-cm
|
||||||
File renamed without changes.
+5
-4
@@ -1,7 +1,8 @@
|
|||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: checkmate-server-nginx-cm
|
name: checkmate-client-nginx-cm
|
||||||
data:
|
data:
|
||||||
default.conf: |
|
default.conf: |
|
||||||
server {
|
server {
|
||||||
@@ -22,7 +23,7 @@ data:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# location /api/ {
|
# location /api/ {
|
||||||
# proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/;
|
# proxy_pass http://{{ $api.ingress.host }}:5000/api/;
|
||||||
# proxy_http_version 1.1;
|
# proxy_http_version 1.1;
|
||||||
# proxy_set_header Host $host;
|
# proxy_set_header Host $host;
|
||||||
# proxy_set_header X-Real-IP $remote_addr;
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -31,7 +32,7 @@ data:
|
|||||||
# }
|
# }
|
||||||
|
|
||||||
location /api-docs/ {
|
location /api-docs/ {
|
||||||
proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/;
|
proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
File renamed without changes.
File renamed without changes.
+5
-2
@@ -22,7 +22,8 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: mongodb
|
- name: mongodb
|
||||||
image: {{ .Values.mongodb.image }}
|
image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }}
|
||||||
|
imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- containerPort: {{ .Values.mongodb.port }}
|
- containerPort: {{ .Values.mongodb.port }}
|
||||||
command: ["mongod", "--quiet", "--bind_ip_all"]
|
command: ["mongod", "--quiet", "--bind_ip_all"]
|
||||||
@@ -37,7 +38,9 @@ spec:
|
|||||||
- metadata:
|
- metadata:
|
||||||
name: checkmate-mongo-persistent-storage
|
name: checkmate-mongo-persistent-storage
|
||||||
spec:
|
spec:
|
||||||
storageClassName: {{ .Values.persistence.mongo.storageClass | quote }}
|
{{- with .Values.persistence.mongo.storageClass }}
|
||||||
|
storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}}
|
||||||
|
{{- end }}
|
||||||
accessModes: ["ReadWriteOnce"]
|
accessModes: ["ReadWriteOnce"]
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
{{- if eq .Values.client.ingress.host "change_me" }}
|
{{- if eq .Values.client.ingress.host "change_me" }}
|
||||||
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
|
{{- fail "client.ingress.host must be overridden and not set to 'change_me'" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if eq .Values.server.ingress.host "change_me" }}
|
{{- if eq $api.ingress.host "change_me" }}
|
||||||
{{- fail "server.ingress.host must be overridden and not set to 'change_me'" }}
|
{{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- $serverProtocol := .Values.server.protocol }}
|
{{- $apiProtocol := $api.protocol }}
|
||||||
{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }}
|
{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }}
|
||||||
{{- fail "server.protocol must be either 'http' or 'https'" }}
|
{{- fail "api.protocol must be either 'http' or 'https'" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- $clientProtocol := .Values.client.protocol }}
|
{{- $clientProtocol := .Values.client.protocol }}
|
||||||
@@ -21,28 +22,33 @@
|
|||||||
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
|
{{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }}
|
{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }}
|
||||||
{{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }}
|
{{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/* If client runs on https, API must also be https to avoid mixed content */}}
|
{{/* If client runs on https, API must also be https to avoid mixed content */}}
|
||||||
{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }}
|
{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }}
|
||||||
{{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }}
|
{{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
|
{{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}}
|
||||||
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
|
{{- $cAnn := .Values.client.ingress.annotations | default dict }}
|
||||||
{{- $sAnn := .Values.server.ingress.annotations | default dict }}
|
{{- $aAnn := $api.ingress.annotations | default dict }}
|
||||||
|
|
||||||
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
|
{{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }}
|
||||||
{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }}
|
{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }}
|
||||||
|
|
||||||
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
|
{{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }}
|
||||||
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
{{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }}
|
{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }}
|
||||||
{{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
{{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/* Secret name can be omitted; we default to <release>-client|server-tls in templates */}}
|
{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}}
|
||||||
|
{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }}
|
||||||
|
{{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/* Secret name can be omitted; we default to <release>-client|api-tls in templates */}}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{{- if .Values.redis.enabled }}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: checkmate-redis
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: checkmate-redis
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.redis.port }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{- if .Values.redis.enabled }}
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: StatefulSet
|
|
||||||
metadata:
|
|
||||||
name: checkmate-redis
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: checkmate-redis
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: checkmate-redis
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: redis
|
|
||||||
image: {{ .Values.redis.image }}
|
|
||||||
ports:
|
|
||||||
- containerPort: {{ .Values.redis.port }}
|
|
||||||
volumeMounts:
|
|
||||||
- name: redis-data
|
|
||||||
mountPath: /data
|
|
||||||
volumeClaimTemplates:
|
|
||||||
- metadata:
|
|
||||||
name: checkmate-redis-persistent-storage
|
|
||||||
spec:
|
|
||||||
storageClassName: {{ .Values.persistence.redis.storageClass | quote }}
|
|
||||||
accessModes: ["ReadWriteOnce"]
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -4,6 +4,14 @@
|
|||||||
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
|
{{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it
|
||||||
|
by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there,
|
||||||
|
so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so
|
||||||
|
the one connection string resolves from every namespace. External/already-qualified hosts are not
|
||||||
|
matched (the char after the host name is "." for an FQDN, which the pattern excludes).
|
||||||
|
*/}}
|
||||||
|
{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@@ -11,5 +19,9 @@ metadata:
|
|||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
{{- range $key, $value := $secrets }}
|
{{- range $key, $value := $secrets }}
|
||||||
|
{{- if eq $key "DB_CONNECTION_STRING" }}
|
||||||
|
{{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }}
|
||||||
|
{{- else }}
|
||||||
{{ $key }}: {{ $value | quote }}
|
{{ $key }}: {{ $value | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: checkmate-server
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: checkmate-server
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: checkmate-server
|
|
||||||
spec:
|
|
||||||
{{- with .Values.server.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.server.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
containers:
|
|
||||||
- name: server
|
|
||||||
image: {{ .Values.server.image }}
|
|
||||||
ports:
|
|
||||||
- containerPort: {{ .Values.server.port }}
|
|
||||||
envFrom:
|
|
||||||
- secretRef:
|
|
||||||
name: checkmate-secrets
|
|
||||||
{{- with .Values.server.resources }}
|
|
||||||
resources:
|
|
||||||
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
|
||||||
{{- end }}
|
|
||||||
env:
|
|
||||||
- name: CLIENT_HOST
|
|
||||||
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: checkmate-server
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: checkmate-server
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.server.port }}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{{- $api := include "checkmate.api" . | fromYaml -}}
|
||||||
|
{{- if .Values.worker.enabled }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: checkmate-worker
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.worker.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: checkmate-worker
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: checkmate-worker
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }}
|
||||||
|
{{- with .Values.worker.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.worker.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- tpl ( . | toYaml) $ | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: worker
|
||||||
|
image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }}
|
||||||
|
imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: health
|
||||||
|
containerPort: {{ .Values.worker.healthPort }}
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv)
|
||||||
|
env:
|
||||||
|
# Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes.
|
||||||
|
- name: CLIENT_HOST
|
||||||
|
value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}"
|
||||||
|
- name: QUEUE_MODE
|
||||||
|
value: "worker"
|
||||||
|
- name: HEALTH_PORT
|
||||||
|
value: {{ .Values.worker.healthPort | quote }}
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: health
|
||||||
|
initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: health
|
||||||
|
initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }}
|
||||||
|
{{- with .Values.worker.resources }}
|
||||||
|
resources:
|
||||||
|
{{- tpl ( . | toYaml) $ | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }}
|
||||||
|
apiVersion: keda.sh/v1alpha1
|
||||||
|
kind: ScaledObject
|
||||||
|
metadata:
|
||||||
|
name: checkmate-worker
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
name: checkmate-worker
|
||||||
|
minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks
|
||||||
|
maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }}
|
||||||
|
cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }}
|
||||||
|
advanced:
|
||||||
|
horizontalPodAutoscalerConfig:
|
||||||
|
behavior:
|
||||||
|
scaleDown:
|
||||||
|
stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }}
|
||||||
|
triggers:
|
||||||
|
- type: mongodb
|
||||||
|
metadata:
|
||||||
|
dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }}
|
||||||
|
collection: {{ .Values.worker.autoscaling.mongo.collection | quote }}
|
||||||
|
# The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong):
|
||||||
|
query: >-
|
||||||
|
{ "type": "check", "isActive": true,
|
||||||
|
"$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] },
|
||||||
|
"$or": [ { "lockedUntil": null },
|
||||||
|
{ "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] }
|
||||||
|
queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }}
|
||||||
|
connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets
|
||||||
|
{{- end }}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
client:
|
client:
|
||||||
image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0
|
image: ghcr.io/bluewave-labs/checkmate-client
|
||||||
|
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||||
|
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||||
port: 80
|
port: 80
|
||||||
protocol: http
|
protocol: http
|
||||||
ingress:
|
ingress:
|
||||||
@@ -17,10 +19,24 @@ client:
|
|||||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||||
|
|
||||||
server:
|
api:
|
||||||
image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0
|
image: ghcr.io/bluewave-labs/checkmate-backend
|
||||||
|
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag)
|
||||||
|
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||||
port: 52345
|
port: 52345
|
||||||
protocol: http
|
protocol: http
|
||||||
|
# Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up).
|
||||||
|
probes:
|
||||||
|
liveness:
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
readiness:
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
host: change_me
|
host: change_me
|
||||||
@@ -31,40 +47,62 @@ server:
|
|||||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
tls:
|
tls:
|
||||||
enabled: false
|
enabled: false
|
||||||
# secretName: {{ .Release.Name }}-server-tls # Optional; defaults to <release>-server-tls if omitted
|
# secretName: {{ .Release.Name }}-api-tls # Optional; defaults to <release>-api-tls if omitted
|
||||||
# Note: when enabling TLS, also set server.protocol: https and add
|
# Note: when enabling TLS, also set api.protocol: https and add
|
||||||
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
# a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod").
|
||||||
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
# The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation
|
||||||
|
|
||||||
redis:
|
|
||||||
enabled: false
|
|
||||||
image: redis:7.2
|
|
||||||
port: 6379
|
|
||||||
|
|
||||||
mongodb:
|
mongodb:
|
||||||
image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0
|
image: ghcr.io/bluewave-labs/checkmate-mongo
|
||||||
|
tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin
|
||||||
|
imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled)
|
||||||
port: 27017
|
port: 27017
|
||||||
|
|
||||||
|
worker:
|
||||||
|
# Off by default; existing installs are unchanged. When true, a dedicated worker processes all
|
||||||
|
# jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the
|
||||||
|
# API is all-in-one and processes jobs itself.
|
||||||
|
enabled: false
|
||||||
|
image: "" # defaults to api.image when empty, same as API image
|
||||||
|
tag: "" # defaults to api.tag (-> Chart.appVersion) when empty
|
||||||
|
imagePullPolicy: "" # defaults to api.imagePullPolicy when empty
|
||||||
|
replicas: 1 # used only when autoscaling.enabled = false
|
||||||
|
healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics
|
||||||
|
terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain
|
||||||
|
resources: {}
|
||||||
|
affinity: {}
|
||||||
|
tolerations: {}
|
||||||
|
probes:
|
||||||
|
liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
readiness: # /readyz — flips to 503 on drain
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
autoscaling:
|
||||||
|
enabled: false # requires the KEDA operator installed in-cluster
|
||||||
|
minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks)
|
||||||
|
maxReplicaCount: 10
|
||||||
|
backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker
|
||||||
|
cooldownPeriod: 300 # seconds before scaling an idle target down
|
||||||
|
stabilizationWindowSeconds: 300 # damp the interval-boundary herd
|
||||||
|
mongo:
|
||||||
|
dbName: uptime_db # must match DB_CONNECTION_STRING's database
|
||||||
|
collection: jobs
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
JWT_SECRET: change_me
|
JWT_SECRET: change_me
|
||||||
# REFRESH_TOKEN_SECRET: change_me
|
DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db
|
||||||
# SYSTEM_EMAIL_ADDRESS: test@example.com
|
|
||||||
# SYSTEM_EMAIL_PASSWORD: change_me
|
|
||||||
# SYSTEM_EMAIL_HOST: smtp.example.com
|
|
||||||
# SYSTEM_EMAIL_PORT: "587"
|
|
||||||
# PAGESPEED_API_KEY: change_me
|
|
||||||
DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db
|
|
||||||
CLIENT_HOST: change_me
|
CLIENT_HOST: change_me
|
||||||
# REDIS_HOST: redis
|
NODE_ENV: production
|
||||||
# REDIS_PORT: "6379"
|
LOG_LEVEL: info
|
||||||
# DB_TYPE: MongoDB
|
TOKEN_TTL: 99d
|
||||||
# TOKEN_TTL: 99d
|
|
||||||
# REFRESH_TOKEN_TTL: 99d
|
|
||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
mongo:
|
mongo:
|
||||||
size: 5Gi
|
size: 5Gi
|
||||||
storageClass: ""
|
storageClass: ""
|
||||||
redis:
|
|
||||||
size: 1Gi
|
|
||||||
storageClass: ""
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
VITE_APP_API_BASE_URL=UPTIME_APP_API_BASE_URL
|
# Production builds use same-origin defaults: API at /api/v1, client host from
|
||||||
VITE_APP_API_V2_BASE_URL=UPTIME_APP_API_V2_BASE_URL
|
# window.location.origin. The empty values below override any local .env so a
|
||||||
VITE_APP_CLIENT_HOST=UPTIME_APP_CLIENT_HOST
|
# production build never bakes in a developer's localhost URLs.
|
||||||
VITE_APP_LOG_LEVEL=UPTIME_APP_LOG_LEVEL
|
VITE_APP_API_BASE_URL=
|
||||||
|
VITE_APP_CLIENT_HOST=
|
||||||
|
VITE_APP_LOG_LEVEL=
|
||||||
@@ -28,5 +28,4 @@ dist-ssr
|
|||||||
|
|
||||||
# Scripts
|
# Scripts
|
||||||
*.sh
|
*.sh
|
||||||
!env.sh
|
|
||||||
.env.development
|
.env.development
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
for i in $(env | grep UPTIME_APP_)
|
|
||||||
do
|
|
||||||
key=$(echo $i | cut -d '=' -f 1)
|
|
||||||
value=$(echo $i | cut -d '=' -f 2-)
|
|
||||||
echo $key=$value
|
|
||||||
find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' +
|
|
||||||
done
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<title>Checkmate</title>
|
<title>Checkmate</title>
|
||||||
|
<script src="/config.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+331
-211
File diff suppressed because it is too large.
Load diff
+12
-4
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.8.1",
|
"version": "3.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build-dev": "tsc -b && vite build --mode development",
|
"build-dev": "tsc -b && vite build --mode development",
|
||||||
@@ -11,14 +16,17 @@
|
|||||||
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format-check": "prettier --check ."
|
"format-check": "prettier --check .",
|
||||||
|
"compile": "tsc -b"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*": "prettier --write"
|
"**/*": "prettier --write --ignore-unknown"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"@emotion/react": "^11.13.3",
|
"@emotion/react": "^11.13.3",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"@hello-pangea/dnd": "^18.0.0",
|
"@hello-pangea/dnd": "^18.0.0",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@mui/lab": "7.0.0",
|
"@mui/lab": "7.0.0",
|
||||||
@@ -32,7 +40,7 @@
|
|||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"human-interval": "2.0.1",
|
"human-interval": "2.0.1",
|
||||||
"i18next": "25.4.2",
|
"i18next": "25.4.2",
|
||||||
"joi": "17.13.3",
|
"joi": "17.13.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"maplibre-gl": "^5.19.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"mui-color-input": "^7.0.0",
|
"mui-color-input": "^7.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Fallback so /config.js always resolves (e.g. under the Vite dev server). In
|
||||||
|
// the Docker image the server shadows this path and renders the real config
|
||||||
|
// from its CLIENT_CONFIG_* environment variables.
|
||||||
|
window.__CHECKMATE_CONFIG__ = {};
|
||||||
@@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu";
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
export type ActionMenuItem = {
|
export type ActionMenuItem = {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
@@ -12,6 +13,7 @@ export type ActionMenuItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||||
|
const theme = useTheme();
|
||||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
@@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<IconButton onClick={handleClick}>
|
<IconButton
|
||||||
|
onClick={handleClick}
|
||||||
|
sx={{
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.action.controlHover,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Settings
|
<Settings
|
||||||
size={20}
|
size={20}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
||||||
import type { CheckSnapshot } from "@/Types/Check";
|
import type { CheckSnapshot } from "@/Types/Check";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
@@ -58,7 +58,7 @@ export const HeatmapResponseTimeTooltip = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
{t("common.labels.responseTime")}:{" "}
|
{t("common.labels.responseTime")}:{" "}
|
||||||
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
|
{check?.responseTime ? formatMs(check.responseTime) : "N/A"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography textTransform={"capitalize"}>
|
<Typography textTransform={"capitalize"}>
|
||||||
Status:{" "}
|
Status:{" "}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||||
|
import { computeBarHeights } from "@/Utils/DataUtils";
|
||||||
|
import { formatMs } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
interface HistogramResponseTimeProps {
|
interface HistogramResponseTimeProps {
|
||||||
checks: CheckSnapshot[];
|
checks: CheckSnapshot[];
|
||||||
@@ -18,25 +20,25 @@ interface HistogramResponseTimeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ResponseTimeStats {
|
interface ResponseTimeStats {
|
||||||
max: number | string;
|
max: string | null;
|
||||||
avg: number | string;
|
avg: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HEIGHT = 50;
|
const DEFAULT_HEIGHT = 50;
|
||||||
|
|
||||||
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
|
||||||
if (!Array.isArray(checks) || checks.length === 0) {
|
if (!Array.isArray(checks) || checks.length === 0) {
|
||||||
return { max: "-", avg: "-" };
|
return { max: null, avg: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validChecks = checks.filter((check) => check.originalResponseTime != null);
|
const validChecks = checks.filter((check) => check.responseTime != null);
|
||||||
if (validChecks.length === 0) {
|
if (validChecks.length === 0) {
|
||||||
return { max: "-", avg: "-" };
|
return { max: null, avg: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseTimes = validChecks.map((check) => check.originalResponseTime);
|
const responseTimes = validChecks.map((check) => check.responseTime);
|
||||||
const max = Math.round(Math.max(...responseTimes));
|
const max = formatMs(Math.max(...responseTimes));
|
||||||
const avg = Math.round(
|
const avg = formatMs(
|
||||||
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ export const HistogramResponseTime = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
|
||||||
|
const barHeights = useMemo(() => computeBarHeights(checks), [checks]);
|
||||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||||
|
|
||||||
const data =
|
const data =
|
||||||
@@ -68,29 +70,28 @@ export const HistogramResponseTime = ({
|
|||||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||||
const gridGap = gap ?? theme.spacing(0.5);
|
const gridGap = gap ?? theme.spacing(0.5);
|
||||||
|
|
||||||
const statsContent = showStats &&
|
const statsContent = showStats && (stats.max !== null || stats.avg !== null) && (
|
||||||
(typeof stats.max === "number" || typeof stats.avg === "number") && (
|
<Stack
|
||||||
<Stack
|
justifyContent="center"
|
||||||
justifyContent="center"
|
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
|
||||||
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
|
width={110}
|
||||||
width={110}
|
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
||||||
pr={statsPosition === "left" ? theme.spacing(8) : 0}
|
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
||||||
pl={statsPosition === "right" ? theme.spacing(8) : 0}
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
>
|
>
|
||||||
<Typography
|
{t("common.charts.histogram.avg", { value: stats.avg })}
|
||||||
variant="body2"
|
</Typography>
|
||||||
noWrap
|
<Typography
|
||||||
>
|
variant="body2"
|
||||||
{t("common.charts.histogram.avg", { value: stats.avg })}
|
noWrap
|
||||||
</Typography>
|
>
|
||||||
<Typography
|
{t("common.charts.histogram.max", { value: stats.max })}
|
||||||
variant="body2"
|
</Typography>
|
||||||
noWrap
|
</Stack>
|
||||||
>
|
);
|
||||||
{t("common.charts.histogram.max", { value: stats.max })}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
@@ -111,7 +112,7 @@ export const HistogramResponseTime = ({
|
|||||||
>
|
>
|
||||||
{data.map((check, index) => {
|
{data.map((check, index) => {
|
||||||
const isPlaceholder = (check as any).status === "placeholder";
|
const isPlaceholder = (check as any).status === "placeholder";
|
||||||
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
const heightPct = `${barHeights[index] ?? 0}%`;
|
||||||
const barColor =
|
const barColor =
|
||||||
check.status === true
|
check.status === true
|
||||||
? theme.palette.success.light
|
? theme.palette.success.light
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMediaQuery } from "@mui/material";
|
import { useMediaQuery } from "@mui/material";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
interface MonitorTimeFrameHeaderProps {
|
interface MonitorTimeFrameHeaderProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
hasDateRange?: boolean;
|
hasDateRange?: boolean;
|
||||||
dateRange: string;
|
dateRange: DateRange;
|
||||||
setDateRange: (dateRange: string) => void;
|
setDateRange: (dateRange: DateRange) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderTimeRange = ({
|
export const HeaderTimeRange = ({
|
||||||
@@ -25,7 +26,7 @@ export const HeaderTimeRange = ({
|
|||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
newValue: string | null
|
newValue: DateRange | null
|
||||||
) => {
|
) => {
|
||||||
if (newValue !== null) {
|
if (newValue !== null) {
|
||||||
setDateRange(newValue);
|
setDateRange(newValue);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Box, { type BoxProps } from "@mui/material/Box";
|
import Box, { type BoxProps } from "@mui/material/Box";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
export const BaseBox = forwardRef<HTMLDivElement, BoxProps>(({ sx, ...rest }, ref) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={ref}
|
||||||
{...rest}
|
{...rest}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
@@ -16,4 +18,6 @@ export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
BaseBox.displayName = "BaseBox";
|
||||||
@@ -10,39 +10,66 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
type BaseChartProps = React.PropsWithChildren<{
|
type BaseChartProps = React.PropsWithChildren<{
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
rightTitle?: string;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
maxWidth?: number | string;
|
maxWidth?: number | string;
|
||||||
|
flexBasis?: number | string;
|
||||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||||
|
onClick?: () => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const BaseChart = ({
|
export const BaseChart = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
rightTitle,
|
||||||
width = "100%",
|
width = "100%",
|
||||||
maxWidth = "100%",
|
maxWidth = "100%",
|
||||||
|
flexBasis = "0%",
|
||||||
padding,
|
padding,
|
||||||
|
onClick,
|
||||||
}: BaseChartProps) => {
|
}: BaseChartProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<BaseBox
|
<BaseBox
|
||||||
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
padding: padding ?? theme.spacing(LAYOUT.MD),
|
padding: padding ?? theme.spacing(LAYOUT.MD),
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
flexBasis,
|
||||||
width: width,
|
width: width,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
|
...(onClick && {
|
||||||
|
cursor: "pointer",
|
||||||
|
"& .recharts-wrapper": { cursor: "pointer !important" },
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
gap={theme.spacing(LAYOUT.MD)}
|
gap={theme.spacing(LAYOUT.MD)}
|
||||||
flex={1}
|
flex={1}
|
||||||
>
|
>
|
||||||
<Typography
|
<Stack
|
||||||
variant="eyebrow"
|
direction="row"
|
||||||
color="text.secondary"
|
justifyContent={"space-between"}
|
||||||
>
|
>
|
||||||
{title}
|
<Typography
|
||||||
</Typography>
|
variant="eyebrow"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{rightTitle && (
|
||||||
|
<Typography
|
||||||
|
variant="eyebrow"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
|
>
|
||||||
|
{rightTitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
<Box flex={1}>{children}</Box>
|
<Box flex={1}>{children}</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
|
|||||||
@@ -83,12 +83,17 @@ export const Breadcrumb = ({
|
|||||||
const basePage = segments[0] || t("common.breadcrumbs.home");
|
const basePage = segments[0] || t("common.breadcrumbs.home");
|
||||||
|
|
||||||
const secondSegment = segments[1];
|
const secondSegment = segments[1];
|
||||||
|
const thirdSegment = segments[2];
|
||||||
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
|
||||||
const isDetailsPage = secondSegment && isId(secondSegment);
|
const isDetailsPage = secondSegment && isId(secondSegment);
|
||||||
const hasSubPage = isActionPage || isDetailsPage;
|
// page/section/:id routes such as docker/host/:monitorId
|
||||||
|
const isSectionDetailsPage = Boolean(
|
||||||
|
secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment)
|
||||||
|
);
|
||||||
|
const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage;
|
||||||
|
|
||||||
const getSubPageLabel = (): string => {
|
const getSubPageLabel = (): string => {
|
||||||
if (isActionPage) {
|
if (isActionPage || isSectionDetailsPage) {
|
||||||
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
|
||||||
}
|
}
|
||||||
return t("common.breadcrumbs.details");
|
return t("common.breadcrumbs.details");
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
export const DotSizes = ["xs", "sm", "md", "lg"] as const;
|
||||||
|
export type DotSize = (typeof DotSizes)[number];
|
||||||
|
|
||||||
|
const DOT_SIZE_PX: Record<DotSize, string> = {
|
||||||
|
xs: "2px",
|
||||||
|
sm: "4px",
|
||||||
|
md: "8px",
|
||||||
|
lg: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
interface DotProps {
|
interface DotProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: string;
|
size?: DotSize;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
content: '""',
|
content: '""',
|
||||||
width: size,
|
width: DOT_SIZE_PX[size],
|
||||||
height: size,
|
height: DOT_SIZE_PX[size],
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
|||||||
() => ({
|
() => ({
|
||||||
circumference: 2 * Math.PI * radius,
|
circumference: 2 * Math.PI * radius,
|
||||||
totalSize: radius * 2 + strokeWidth * 2,
|
totalSize: radius * 2 + strokeWidth * 2,
|
||||||
strokeLength: (progress / 100) * (2 * Math.PI * radius),
|
strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius),
|
||||||
}),
|
}),
|
||||||
[radius, strokeWidth, progress]
|
[radius, strokeWidth, progressWithinRange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [offset, setOffset] = useState(circumference);
|
const [offset, setOffset] = useState(circumference);
|
||||||
@@ -52,7 +52,7 @@ export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [progress, circumference, strokeLength]);
|
}, [circumference, strokeLength]);
|
||||||
|
|
||||||
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ export const DetailGauge = ({
|
|||||||
lowerLabel,
|
lowerLabel,
|
||||||
lowerValue,
|
lowerValue,
|
||||||
maxWidth = 225,
|
maxWidth = 225,
|
||||||
|
flexBasis = "0%",
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -131,6 +132,7 @@ export const DetailGauge = ({
|
|||||||
lowerLabel?: string;
|
lowerLabel?: string;
|
||||||
lowerValue?: string | number;
|
lowerValue?: string | number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
|
flexBasis?: number | string;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -138,6 +140,7 @@ export const DetailGauge = ({
|
|||||||
icon={null}
|
icon={null}
|
||||||
title={title}
|
title={title}
|
||||||
maxWidth={maxWidth}
|
maxWidth={maxWidth}
|
||||||
|
flexBasis={flexBasis}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
alignItems={"center"}
|
alignItems={"center"}
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ interface IconProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
stroke?: string;
|
stroke?: string;
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
size={size}
|
size={size}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
|
color={color}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
|
||||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import prettyMilliseconds from "pretty-ms";
|
|
||||||
import { typographyLevels } from "@/Utils/Theme/Palette";
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
if (!monitor) {
|
if (!monitor) {
|
||||||
@@ -52,7 +55,12 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
|||||||
<>
|
<>
|
||||||
<Dot />
|
<Dot />
|
||||||
<Typography>
|
<Typography>
|
||||||
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
{t("pages.common.monitors.statBoxes.checkingEvery", {
|
||||||
|
interval: formatDuration(monitor.interval, {
|
||||||
|
long: true,
|
||||||
|
locale: i18n.resolvedLanguage,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: theme.zIndex.snackbar,
|
zIndex: theme.zIndex.snackbar,
|
||||||
backgroundColor: theme.palette.error.main,
|
backgroundColor: theme.palette.error.dark,
|
||||||
color: theme.palette.error.contrastText,
|
color: "#FFFFFF",
|
||||||
px: theme.spacing(8),
|
px: theme.spacing(8),
|
||||||
py: theme.spacing(4),
|
py: theme.spacing(4),
|
||||||
transition: "top 1s ease-in-out",
|
transition: "top 1s ease-in-out",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Link as RouterLink } from "react-router-dom";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
import { ChevronLeft, HelpCircle, FileText, Code } from "lucide-react";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ export const PageHeader = ({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
lineHeight: 1.55,
|
lineHeight: 1.55,
|
||||||
maxWidth: 640,
|
maxWidth: "108ch",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
@@ -142,7 +143,7 @@ export const PageHeader = ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 32,
|
height: INPUT_BASE_HEIGHT,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
borderRight:
|
borderRight:
|
||||||
idx < linkItems.length - 1
|
idx < linkItems.length - 1
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Box, Stack, useTheme } from "@mui/material";
|
import { Box, Stack, useTheme } from "@mui/material";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface SkeletonCardProps {
|
interface SkeletonCardProps {
|
||||||
width?: number | string | { xs?: number | string; md?: number | string };
|
width?: number | string | { xs?: number | string; md?: number | string };
|
||||||
@@ -44,7 +45,7 @@ export const SkeletonCard = ({
|
|||||||
|
|
||||||
const blockStyle = {
|
const blockStyle = {
|
||||||
width: 55,
|
width: 55,
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
background: theme.palette.secondary.main,
|
background: theme.palette.secondary.main,
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { lighten } from "@mui/material/styles";
|
import { darken, lighten } from "@mui/material/styles";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
import type { PaletteKey } from "@/Utils/Theme/Theme";
|
||||||
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
import { BaseBox, TooltipWithInfo } from "@/Components/design-elements";
|
||||||
@@ -11,17 +11,18 @@ import type { SxProps } from "@mui/material";
|
|||||||
type GradientBox = React.PropsWithChildren<{
|
type GradientBox = React.PropsWithChildren<{
|
||||||
palette?: PaletteKey;
|
palette?: PaletteKey;
|
||||||
sx?: SxProps;
|
sx?: SxProps;
|
||||||
|
interactive?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
const isLight = theme.palette.mode === "light";
|
const isLight = theme.palette.mode === "light";
|
||||||
const paper = theme.palette.background.paper;
|
const paper = theme.palette.background.paper;
|
||||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08);
|
||||||
const paperEnd = paper;
|
const paperEnd = paper;
|
||||||
const bg = palette
|
const bg = palette
|
||||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)`
|
||||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,6 +31,12 @@ export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
|||||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||||
background: bg,
|
background: bg,
|
||||||
|
...(interactive && {
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundImage: `linear-gradient(${theme.palette.action.rowHover}, ${theme.palette.action.rowHover}), ${bg}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -62,10 +69,8 @@ export const StatBox = ({
|
|||||||
return (
|
return (
|
||||||
<GradientBox
|
<GradientBox
|
||||||
palette={palette}
|
palette={palette}
|
||||||
sx={{
|
interactive={Boolean(onClick)}
|
||||||
...(sx as object),
|
sx={{ ...(sx as object) }}
|
||||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack onClick={onClick}>
|
<Stack onClick={onClick}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { SxProps } from "@mui/material";
|
import type { SxProps } from "@mui/material";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { alpha, useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
type StatusBoxProps = React.PropsWithChildren<{
|
type StatusBoxProps = React.PropsWithChildren<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,8 +34,8 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => {
|
|||||||
sx={{
|
sx={{
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
|
linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px),
|
||||||
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
|
linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px)
|
||||||
`,
|
`,
|
||||||
backgroundSize: "24px 24px",
|
backgroundSize: "24px 24px",
|
||||||
maskImage:
|
maskImage:
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import { BaseBox } from "@/Components/design-elements";
|
import { BaseBox } from "@/Components/design-elements";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
|
||||||
import type { MonitorStatus } from "@/Types/Monitor";
|
import type { MonitorStatus } from "@/Types/Monitor";
|
||||||
import type { SxProps } from "@mui/material/styles";
|
import type { SxProps } from "@mui/material/styles";
|
||||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
import {
|
||||||
|
getDockerStatePalette,
|
||||||
|
getStatusPalette,
|
||||||
|
getValuePalette,
|
||||||
|
} from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { DockerContainerState } from "@/Types/Check";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||||
export type ValueType = (typeof ValueTypes)[number];
|
export type ValueType = (typeof ValueTypes)[number];
|
||||||
@@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) =
|
|||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DockerStateLabel = ({
|
||||||
|
state,
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
}: {
|
||||||
|
state: DockerContainerState;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
}) => {
|
||||||
|
const palette = getDockerStatePalette(state);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={SPACING.LG}
|
||||||
|
alignItems={"center"}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
width={7}
|
||||||
|
height={7}
|
||||||
|
bgcolor={theme.palette[palette].light}
|
||||||
|
borderRadius="50%"
|
||||||
|
marginRight="5px"
|
||||||
|
/>
|
||||||
|
<Stack>
|
||||||
|
<Typography>{name}</Typography>
|
||||||
|
<Typography variant="body2">{image}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -52,6 +52,7 @@ type DataTableProps<T extends { id?: string | number; _id?: string | number }> =
|
|||||||
emptyViewText?: string;
|
emptyViewText?: string;
|
||||||
emptyViewPositive?: boolean;
|
emptyViewPositive?: boolean;
|
||||||
getRowSx?: (row: T) => SxProps<Theme>;
|
getRowSx?: (row: T) => SxProps<Theme>;
|
||||||
|
isRowSelected?: (row: T) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DataTable<
|
export function DataTable<
|
||||||
@@ -70,6 +71,7 @@ export function DataTable<
|
|||||||
emptyViewText,
|
emptyViewText,
|
||||||
emptyViewPositive,
|
emptyViewPositive,
|
||||||
getRowSx,
|
getRowSx,
|
||||||
|
isRowSelected,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||||
@@ -80,6 +82,22 @@ export function DataTable<
|
|||||||
|
|
||||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
|
||||||
|
const hasTextSelectionWithin = (element: Element) => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
selection.anchorNode !== null &&
|
||||||
|
element.contains(
|
||||||
|
selection.anchorNode.nodeType === Node.TEXT_NODE
|
||||||
|
? selection.anchorNode.parentNode
|
||||||
|
: selection.anchorNode
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const isInteractive = Boolean(onRowClick) || expandableRows;
|
||||||
|
|
||||||
if (data.length === 0 || headers.length === 0) {
|
if (data.length === 0 || headers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyView
|
<EmptyView
|
||||||
@@ -99,7 +117,10 @@ export function DataTable<
|
|||||||
keys.push(key);
|
keys.push(key);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
onClick={(e) => {
|
||||||
|
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||||
|
if (onRowClick) onRowClick(row);
|
||||||
|
}}
|
||||||
spacing={theme.spacing(LAYOUT.XS)}
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
sx={{
|
sx={{
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
@@ -108,6 +129,11 @@ export function DataTable<
|
|||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
padding: theme.spacing(LAYOUT.XS),
|
padding: theme.spacing(LAYOUT.XS),
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
cursor: onRowClick ? "pointer" : "default",
|
||||||
|
...(onRowClick && {
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.action.rowHover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
key={key}
|
key={key}
|
||||||
>
|
>
|
||||||
@@ -191,7 +217,7 @@ export function DataTable<
|
|||||||
"& :is(th)": {
|
"& :is(th)": {
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
fontWeight: 500,
|
fontWeight: 600,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.08em",
|
letterSpacing: "0.08em",
|
||||||
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
|
||||||
@@ -227,15 +253,29 @@ export function DataTable<
|
|||||||
{data.map((row) => {
|
{data.map((row) => {
|
||||||
const key = row.id || row._id || Math.random();
|
const key = row.id || row._id || Math.random();
|
||||||
const isExpanded = expanded === key;
|
const isExpanded = expanded === key;
|
||||||
|
const selected = isRowSelected?.(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
<TableRow
|
<TableRow
|
||||||
sx={{
|
sx={{
|
||||||
cursor: onRowClick ? "pointer" : "default",
|
cursor: isInteractive ? "pointer" : "default",
|
||||||
|
...(isInteractive && {
|
||||||
|
"&:hover .MuiTableCell-root": {
|
||||||
|
backgroundColor: selected
|
||||||
|
? theme.palette.action.selectedHover
|
||||||
|
: theme.palette.action.rowHover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(selected && {
|
||||||
|
"& .MuiTableCell-root": {
|
||||||
|
backgroundColor: theme.palette.action.selected,
|
||||||
|
},
|
||||||
|
}),
|
||||||
...(getRowSx?.(row) as object),
|
...(getRowSx?.(row) as object),
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
if (hasTextSelectionWithin(e.currentTarget)) return;
|
||||||
if (expandableRows) handleExpand(row);
|
if (expandableRows) handleExpand(row);
|
||||||
else if (onRowClick) onRowClick(row);
|
else if (onRowClick) onRowClick(row);
|
||||||
}}
|
}}
|
||||||
@@ -292,6 +332,12 @@ interface TablePaginationActionsProps {
|
|||||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controlHoverSx = (theme: Theme): SxProps<Theme> => ({
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: theme.palette.action.controlHover,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { count, page, rowsPerPage, onPageChange } = props;
|
const { count, page, rowsPerPage, onPageChange } = props;
|
||||||
@@ -321,6 +367,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleFirstPageButtonClick}
|
onClick={handleFirstPageButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="first page"
|
aria-label="first page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsRight
|
<ChevronsRight
|
||||||
@@ -338,6 +385,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleBackButtonClick}
|
onClick={handleBackButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="previous page"
|
aria-label="previous page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -355,6 +403,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleNextButtonClick}
|
onClick={handleNextButtonClick}
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
@@ -372,6 +421,7 @@ function TablePaginationActions(props: TablePaginationActionsProps) {
|
|||||||
onClick={handleLastPageButtonClick}
|
onClick={handleLastPageButtonClick}
|
||||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||||
aria-label="last page"
|
aria-label="last page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsLeft
|
<ChevronsLeft
|
||||||
@@ -419,6 +469,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleFirstPageButtonClick}
|
onClick={handleFirstPageButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="first page"
|
aria-label="first page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronsRight
|
<ChevronsRight
|
||||||
@@ -436,6 +487,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleBackButtonClick}
|
onClick={handleBackButtonClick}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
aria-label="previous page"
|
aria-label="previous page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -453,6 +505,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
onClick={handleNextButtonClick}
|
onClick={handleNextButtonClick}
|
||||||
disabled={hasMore === false}
|
disabled={hasMore === false}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? (
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
@@ -470,6 +523,7 @@ function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps
|
|||||||
<IconButton
|
<IconButton
|
||||||
disabled={hasMore === false}
|
disabled={hasMore === false}
|
||||||
aria-label="next page"
|
aria-label="next page"
|
||||||
|
sx={controlHoverSx(theme)}
|
||||||
>
|
>
|
||||||
<Ellipsis
|
<Ellipsis
|
||||||
size={20}
|
size={20}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import MuiTabs from "@mui/material/Tabs";
|
import MuiTabs from "@mui/material/Tabs";
|
||||||
import type { TabsProps } from "@mui/material/Tabs";
|
import type { TabsProps } from "@mui/material/Tabs";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { typographyLevels } from "@/Utils/Theme/Palette";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
interface CustomTabsProps extends TabsProps {}
|
interface CustomTabsProps extends TabsProps {}
|
||||||
|
|
||||||
export const Tabs = (props: CustomTabsProps) => {
|
export const Tabs = (props: CustomTabsProps) => {
|
||||||
@@ -8,7 +10,7 @@ export const Tabs = (props: CustomTabsProps) => {
|
|||||||
return (
|
return (
|
||||||
<MuiTabs
|
<MuiTabs
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
"& .MuiTabs-indicator": {
|
"& .MuiTabs-indicator": {
|
||||||
backgroundColor: theme.palette.primary.main,
|
backgroundColor: theme.palette.primary.main,
|
||||||
@@ -38,9 +40,9 @@ export const Tab = (props: CustomTabProps) => {
|
|||||||
iconPosition="start"
|
iconPosition="start"
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
fontSize: 14,
|
fontSize: typographyLevels.m,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
padding: theme.spacing(1, 0),
|
padding: theme.spacing(1, 0),
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
minWidth: "auto",
|
minWidth: "auto",
|
||||||
@@ -48,7 +50,6 @@ export const Tab = (props: CustomTabProps) => {
|
|||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
fontWeight: 600,
|
|
||||||
},
|
},
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type AutoCompleteInputProps = Omit<
|
type AutoCompleteInputProps = Omit<
|
||||||
AutocompleteProps<any, boolean, boolean, boolean>,
|
AutocompleteProps<any, boolean, boolean, boolean>,
|
||||||
@@ -15,6 +16,9 @@ type AutoCompleteInputProps = Omit<
|
|||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
renderOptionContent?: (option: any) => React.ReactNode;
|
renderOptionContent?: (option: any) => React.ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AutoCompleteInput = ({
|
export const AutoCompleteInput = ({
|
||||||
@@ -22,15 +26,21 @@ export const AutoCompleteInput = ({
|
|||||||
required,
|
required,
|
||||||
renderInput,
|
renderInput,
|
||||||
renderOptionContent,
|
renderOptionContent,
|
||||||
|
placeholder,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
...props
|
...props
|
||||||
}: AutoCompleteInputProps) => {
|
}: AutoCompleteInputProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
const multiple = props.multiple;
|
const multiple = props.multiple;
|
||||||
const { renderOption: customRenderOption, ...autocompleteProps } = props;
|
const { renderOption: customRenderOption, ...autocompleteProps } = props;
|
||||||
const defaultRenderInput = (params: any) => (
|
const defaultRenderInput = (params: any) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
placeholder="Type to search"
|
placeholder={placeholder ?? t("common.form.autocomplete.placeholder")}
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import type { ButtonProps } from "@mui/material/Button";
|
import type { ButtonProps } from "@mui/material/Button";
|
||||||
import { useTheme, darken } from "@mui/material/styles";
|
import { useTheme, darken } from "@mui/material/styles";
|
||||||
import { HOVER } from "@/Utils/Theme/constants";
|
import { INPUT_BASE_HEIGHT, HOVER } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
const PALETTE_COLORS = [
|
const PALETTE_COLORS = [
|
||||||
"primary",
|
"primary",
|
||||||
@@ -43,7 +43,8 @@ export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
sx={{
|
sx={{
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
|
lineHeight: 1.2,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import { useTheme } from "@mui/material";
|
import { useTheme } from "@mui/material";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface ColorPickerProps extends MuiColorInputProps {
|
interface ColorPickerProps extends MuiColorInputProps {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -18,7 +19,7 @@ export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps)
|
|||||||
sx={{
|
sx={{
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
fontSize: typographyLevels.base,
|
fontSize: typographyLevels.base,
|
||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FieldLabel } from "./FieldLabel";
|
|||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -55,7 +56,7 @@ export const DatePickerComponent = ({
|
|||||||
flexDirection: "row-reverse",
|
flexDirection: "row-reverse",
|
||||||
},
|
},
|
||||||
"& input": {
|
"& input": {
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
p: 0,
|
p: 0,
|
||||||
pl: theme.spacing(3),
|
pl: theme.spacing(3),
|
||||||
pr: theme.spacing(5),
|
pr: theme.spacing(5),
|
||||||
@@ -66,7 +67,9 @@ export const DatePickerComponent = ({
|
|||||||
},
|
},
|
||||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||||
{
|
{
|
||||||
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
borderColor: error
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.text.disabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export const languageNames: Record<string, string> = {
|
|||||||
ca: "Català",
|
ca: "Català",
|
||||||
es: "Español",
|
es: "Español",
|
||||||
fr: "Français",
|
fr: "Français",
|
||||||
|
it: "Italiano",
|
||||||
"pt-BR": "Português (BR)",
|
"pt-BR": "Português (BR)",
|
||||||
|
pl: "Polski",
|
||||||
cs: "Čeština",
|
cs: "Čeština",
|
||||||
fi: "Suomi",
|
fi: "Suomi",
|
||||||
tr: "Türkçe",
|
tr: "Türkçe",
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export const RadioWithDescription = ({
|
|||||||
"& .MuiButtonBase-root": {
|
"& .MuiButtonBase-root": {
|
||||||
mr: theme.spacing(6),
|
mr: theme.spacing(6),
|
||||||
},
|
},
|
||||||
|
"&:has(.Mui-focusVisible)": {
|
||||||
|
backgroundColor: theme.palette.action.hover,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import Stack from "@mui/material/Stack";
|
|||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
helperText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectInputInner = <T,>(
|
const SelectInputInner = <T,>(
|
||||||
{ fieldLabel, required, placeholder, ...props }: SelectInputProps<T>,
|
{ fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps<T>,
|
||||||
ref: React.ForwardedRef<HTMLDivElement>
|
ref: React.ForwardedRef<HTMLDivElement>
|
||||||
) => {
|
) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -27,7 +29,7 @@ const SelectInputInner = <T,>(
|
|||||||
: selected === undefined || selected === null || selected === "";
|
: selected === undefined || selected === null || selected === "";
|
||||||
|
|
||||||
if (isEmpty && placeholder) {
|
if (isEmpty && placeholder) {
|
||||||
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
return <Typography color={emptyPlaceholderColor}>{placeholder}</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMultiple) {
|
if (isMultiple) {
|
||||||
@@ -70,7 +72,7 @@ const SelectInputInner = <T,>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
sx={{
|
sx={{
|
||||||
height: "34px",
|
height: `${INPUT_BASE_HEIGHT}px`,
|
||||||
"& .MuiSelect-select": {
|
"& .MuiSelect-select": {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -82,21 +84,27 @@ const SelectInputInner = <T,>(
|
|||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
},
|
||||||
...props.sx,
|
...props.sx,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fieldLabel) {
|
const helper = helperText ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color={props.error ? theme.palette.error.main : theme.palette.text.secondary}
|
||||||
|
>
|
||||||
|
{helperText}
|
||||||
|
</Typography>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (fieldLabel || helper) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={theme.spacing(2)}>
|
<Stack spacing={theme.spacing(2)}>
|
||||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
||||||
{select}
|
{select}
|
||||||
|
{helper}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { typographyLevels } from "@/Utils/Theme/Palette";
|
|||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { LAYOUT } from "@/Utils/Theme/constants";
|
import { INPUT_BASE_HEIGHT, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
@@ -22,16 +22,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
"& .MuiOutlinedInput-root": {
|
"& .MuiOutlinedInput-root": {
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
height: 34,
|
height: props.multiline ? "auto" : INPUT_BASE_HEIGHT,
|
||||||
fontSize: typographyLevels.base,
|
fontSize: typographyLevels.base,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
"& .MuiFormHelperText-root": {
|
"& .MuiFormHelperText-root": {
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
marginRight: 0,
|
marginRight: 0,
|
||||||
@@ -39,7 +33,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|||||||
},
|
},
|
||||||
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
"& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active":
|
||||||
{
|
{
|
||||||
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`,
|
WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`,
|
||||||
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
WebkitTextFillColor: `${theme.palette.text.primary} !important`,
|
||||||
caretColor: theme.palette.text.primary,
|
caretColor: theme.palette.text.primary,
|
||||||
transition: "background-color 5000s ease-in-out 0s",
|
transition: "background-color 5000s ease-in-out 0s",
|
||||||
|
|||||||
@@ -3,18 +3,24 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker
|
|||||||
import type { Dayjs } from "dayjs";
|
import type { Dayjs } from "dayjs";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { FieldLabel } from "./FieldLabel";
|
import { FieldLabel } from "./FieldLabel";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimePickerComponent = ({
|
export const TimePickerComponent = ({
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
required,
|
required,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
...props
|
...props
|
||||||
}: TimePickerComponentProps) => {
|
}: TimePickerComponentProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -28,17 +34,17 @@ export const TimePickerComponent = ({
|
|||||||
sx: {
|
sx: {
|
||||||
width: "fit-content",
|
width: "fit-content",
|
||||||
"& input": {
|
"& input": {
|
||||||
minHeight: 34,
|
minHeight: INPUT_BASE_HEIGHT,
|
||||||
p: 0,
|
p: 0,
|
||||||
px: theme.spacing(5),
|
px: theme.spacing(5),
|
||||||
},
|
},
|
||||||
"& fieldset": {
|
"& fieldset": {
|
||||||
borderColor: theme.palette.divider,
|
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||||
borderRadius: theme.shape.borderRadius,
|
borderRadius: theme.shape.borderRadius,
|
||||||
},
|
},
|
||||||
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
|
||||||
{
|
{
|
||||||
borderColor: theme.palette.divider,
|
borderColor: error ? theme.palette.error.main : theme.palette.divider,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -48,14 +54,18 @@ export const TimePickerComponent = ({
|
|||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fieldLabel) {
|
return (
|
||||||
return (
|
<Stack spacing={theme.spacing(2)}>
|
||||||
<Stack spacing={theme.spacing(2)}>
|
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
|
||||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
{picker}
|
||||||
{picker}
|
{helperText && (
|
||||||
</Stack>
|
<Typography
|
||||||
);
|
variant="caption"
|
||||||
}
|
color={error ? "error" : "text.secondary"}
|
||||||
|
>
|
||||||
return picker;
|
{helperText}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
@@ -4,6 +4,7 @@ import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
|||||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { alpha } from "@mui/material/styles";
|
import { alpha } from "@mui/material/styles";
|
||||||
|
import { INPUT_BASE_HEIGHT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -20,7 +21,7 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|||||||
sx={{
|
sx={{
|
||||||
px: 8,
|
px: 8,
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: 34,
|
height: INPUT_BASE_HEIGHT,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
@@ -34,7 +35,6 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
|||||||
"&.Mui-selected": {
|
"&.Mui-selected": {
|
||||||
backgroundColor: selectedBg,
|
backgroundColor: selectedBg,
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
fontWeight: 500,
|
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: selectedHoverBg,
|
backgroundColor: selectedHoverBg,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { Autocomplete } from "@/Components/inputs";
|
||||||
|
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
||||||
|
|
||||||
|
export interface AutocompleteOption {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormAutocompleteProps<T extends FieldValues, O extends AutocompleteOption> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
options: O[];
|
||||||
|
fieldLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
filterOptions?: AutocompleteProps<O, false, false, false>["filterOptions"];
|
||||||
|
getOptionDisabled?: AutocompleteProps<O, false, false, false>["getOptionDisabled"];
|
||||||
|
disableClearable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormAutocompleteField = <
|
||||||
|
T extends FieldValues,
|
||||||
|
O extends AutocompleteOption,
|
||||||
|
>({
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
fieldLabel,
|
||||||
|
placeholder,
|
||||||
|
filterOptions,
|
||||||
|
getOptionDisabled,
|
||||||
|
disableClearable,
|
||||||
|
}: FormAutocompleteProps<T, O>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={options}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
value={options.find((o) => o.id === field.value) ?? null}
|
||||||
|
onChange={(_: unknown, newValue: O | null) => {
|
||||||
|
if (newValue === null && disableClearable) return;
|
||||||
|
field.onChange(newValue?.id ?? "");
|
||||||
|
}}
|
||||||
|
fieldLabel={fieldLabel}
|
||||||
|
placeholder={placeholder}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
getOptionDisabled={getOptionDisabled}
|
||||||
|
disableClearable={disableClearable}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import { Checkbox } from "@/Components/inputs";
|
||||||
|
|
||||||
|
interface FormCheckboxFieldProps<T extends FieldValues> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormCheckboxField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
}: FormCheckboxFieldProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { ColorInput } from "@/Components/inputs";
|
||||||
|
|
||||||
|
interface FormColorFieldProps<T extends FieldValues> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
fieldLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormColorField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
fieldLabel,
|
||||||
|
}: FormColorFieldProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<ColorInput
|
||||||
|
format="hex"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
fieldLabel={fieldLabel}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import dayjs, { type Dayjs } from "dayjs";
|
||||||
|
import { DatePicker, TimePicker } from "@/Components/inputs";
|
||||||
|
|
||||||
|
export interface FormPickerFieldProps<T extends FieldValues> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
fieldLabel?: string;
|
||||||
|
format?: string;
|
||||||
|
onValueChange?: (value: string) => void; // e.g. () => trigger("startDate")
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayjsPickerProps {
|
||||||
|
fieldLabel?: string;
|
||||||
|
value: Dayjs | null;
|
||||||
|
onChange: (value: Dayjs | null) => void;
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormDayjsPickerFieldProps<
|
||||||
|
T extends FieldValues,
|
||||||
|
> extends FormPickerFieldProps<T> {
|
||||||
|
picker: React.ComponentType<DayjsPickerProps>;
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormDayjsPickerField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
fieldLabel,
|
||||||
|
format,
|
||||||
|
onValueChange,
|
||||||
|
picker: Picker,
|
||||||
|
}: FormDayjsPickerFieldProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Picker
|
||||||
|
fieldLabel={fieldLabel}
|
||||||
|
value={field.value ? dayjs(field.value, format) : null}
|
||||||
|
onChange={(value) => {
|
||||||
|
const next = value ? value.format(format) : "";
|
||||||
|
field.onChange(next);
|
||||||
|
onValueChange?.(next);
|
||||||
|
}}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormDatePickerField = <T extends FieldValues>({
|
||||||
|
format = "YYYY-MM-DD",
|
||||||
|
...rest
|
||||||
|
}: FormPickerFieldProps<T>) => (
|
||||||
|
<FormDayjsPickerField
|
||||||
|
{...rest}
|
||||||
|
format={format}
|
||||||
|
picker={DatePicker}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FormTimePickerField = <T extends FieldValues>({
|
||||||
|
format = "HH:mm",
|
||||||
|
...rest
|
||||||
|
}: FormPickerFieldProps<T>) => (
|
||||||
|
<FormDayjsPickerField
|
||||||
|
{...rest}
|
||||||
|
format={format}
|
||||||
|
picker={TimePicker}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { Trash2, GripVertical } from "lucide-react";
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
import type { DropResult } from "@hello-pangea/dnd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Autocomplete } from "@/Components/inputs";
|
||||||
|
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
options: O[];
|
||||||
|
fieldLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
description?: string;
|
||||||
|
renderRow?: (option: O) => React.ReactNode;
|
||||||
|
renderOptionContent?: (option: O) => React.ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
fieldLabel,
|
||||||
|
placeholder,
|
||||||
|
description,
|
||||||
|
renderRow,
|
||||||
|
renderOptionContent,
|
||||||
|
sortable = false,
|
||||||
|
}: FormMultiSelectFieldProps<T, O>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const optionsById = useMemo(
|
||||||
|
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
|
||||||
|
[options]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => {
|
||||||
|
const selectedIds: O["id"][] = field.value ?? [];
|
||||||
|
const selected = selectedIds
|
||||||
|
.map((id) => optionsById.get(id))
|
||||||
|
.filter((o): o is O => o !== undefined);
|
||||||
|
|
||||||
|
const handleDragEnd = (result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
const reordered = selected.map((o) => o.id);
|
||||||
|
const [moved] = reordered.splice(result.source.index, 1);
|
||||||
|
reordered.splice(result.destination.index, 0, moved);
|
||||||
|
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
|
||||||
|
field.onChange([...reordered, ...unresolved]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowContent = (option: O) =>
|
||||||
|
renderRow ? (
|
||||||
|
renderRow(option)
|
||||||
|
) : (
|
||||||
|
<Typography flexGrow={1}>{option.name}</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeButton = (option: O) => (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(selectedIds.filter((id) => id !== option.id));
|
||||||
|
}}
|
||||||
|
aria-label={t("common.buttons.removeItem", { item: option.name })}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={theme.spacing(LAYOUT.MD)}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
autoHighlight
|
||||||
|
options={options}
|
||||||
|
value={selected}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
onChange={(_: unknown, newValue: O[]) => {
|
||||||
|
// The Autocomplete only sees resolved options, so ids missing
|
||||||
|
// from options must be re-appended or they'd be silently
|
||||||
|
// dropped (e.g. a role excluded from the selectable list).
|
||||||
|
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
|
||||||
|
field.onChange([...newValue.map((o) => o.id), ...unresolved]);
|
||||||
|
}}
|
||||||
|
fieldLabel={fieldLabel}
|
||||||
|
placeholder={selected.length > 0 ? "" : placeholder}
|
||||||
|
renderOptionContent={renderOptionContent}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
/>
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
|
sx={{ opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{sortable
|
||||||
|
? selected.length > 0 && (
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<Droppable droppableId={`${name}-list`}>
|
||||||
|
{(provided) => (
|
||||||
|
<Stack
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
>
|
||||||
|
{selected.map((option, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={option.id}
|
||||||
|
draggableId={String(option.id)}
|
||||||
|
index={index}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<Stack
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
|
padding={theme.spacing(LAYOUT.XS)}
|
||||||
|
marginTop={theme.spacing(SPACING.LG)}
|
||||||
|
borderRadius={1}
|
||||||
|
border={`1px solid ${theme.palette.divider}`}
|
||||||
|
sx={{
|
||||||
|
cursor: "grab",
|
||||||
|
"&:active": { cursor: "grabbing" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={20} />
|
||||||
|
{rowContent(option)}
|
||||||
|
{removeButton(option)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
)
|
||||||
|
: selected.map((option) => (
|
||||||
|
<Stack
|
||||||
|
key={option.id}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={theme.spacing(LAYOUT.XS)}
|
||||||
|
padding={theme.spacing(LAYOUT.XS)}
|
||||||
|
marginTop={theme.spacing(SPACING.LG)}
|
||||||
|
borderRadius={1}
|
||||||
|
border={`1px solid ${theme.palette.divider}`}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{rowContent(option)}
|
||||||
|
{removeButton(option)}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { TextField } from "@/Components/inputs";
|
||||||
|
|
||||||
|
interface FormNumberFieldProps<T extends FieldValues> extends Omit<
|
||||||
|
React.ComponentProps<typeof TextField>,
|
||||||
|
"name" | "error" | "helperText" | "type"
|
||||||
|
> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormNumberField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
emptyValue = 0,
|
||||||
|
...rest
|
||||||
|
}: FormNumberFieldProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
value={
|
||||||
|
field.value === undefined || field.value === emptyValue ? "" : field.value
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
field.onChange(val === "" ? emptyValue : Number(val));
|
||||||
|
}}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? ""}
|
||||||
|
fullWidth
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { RadioGroup, useTheme } from "@mui/material";
|
||||||
|
import FormControl from "@mui/material/FormControl";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { LAYOUT } from "@/Utils/Theme/constants";
|
||||||
|
import { RadioWithDescription } from "@/Components/inputs/Radio";
|
||||||
|
import { FieldLabel } from "@/Components/inputs/FieldLabel";
|
||||||
|
export interface FormRadioOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormRadioGroupProps<T extends FieldValues> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
options: FormRadioOption[];
|
||||||
|
fieldLabel?: string;
|
||||||
|
}
|
||||||
|
export const FormRadioGroup = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
fieldLabel,
|
||||||
|
}: FormRadioGroupProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
const theme = useTheme();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormControl error={!!fieldState.error}>
|
||||||
|
{fieldLabel && <FieldLabel>{fieldLabel}</FieldLabel>}
|
||||||
|
<RadioGroup
|
||||||
|
{...field}
|
||||||
|
sx={{ gap: theme.spacing(LAYOUT.MD) }}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<RadioWithDescription
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
label={option.label}
|
||||||
|
description={option.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldValues,
|
||||||
|
type FieldPath,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { Select } from "@/Components/inputs";
|
||||||
|
|
||||||
|
export interface FormSelectOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormSelectProps<T extends FieldValues> extends Omit<
|
||||||
|
React.ComponentProps<typeof Select>,
|
||||||
|
"name" | "error" | "value" | "onChange"
|
||||||
|
> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
options: FormSelectOption[];
|
||||||
|
fallbackValue?: string | number; // display value while the field is undefined
|
||||||
|
onValueChange?: (value: string | number) => void; // side effects, see Notes
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormSelectField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
options,
|
||||||
|
fallbackValue,
|
||||||
|
onValueChange,
|
||||||
|
...rest
|
||||||
|
}: FormSelectProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? fallbackValue ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
onValueChange?.(e.target.value as string | number);
|
||||||
|
}}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
{...rest}
|
||||||
|
helperText={fieldState.error?.message ?? rest.helperText}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { SliderWithLabel } from "@/Components/inputs";
|
||||||
|
|
||||||
|
interface FormSliderProps<T extends FieldValues> extends Omit<
|
||||||
|
React.ComponentProps<typeof SliderWithLabel>,
|
||||||
|
"name" | "value" | "onChange"
|
||||||
|
> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormSliderField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
...rest
|
||||||
|
}: FormSliderProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<SliderWithLabel
|
||||||
|
{...field}
|
||||||
|
sliderMaxWidth={{ xs: "100%", md: "50%" }}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { SwitchComponent } from "@/Components/inputs";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
|
interface FormSwitchProps<T extends FieldValues> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
label: string;
|
||||||
|
labelPlacement?: "start" | "end"; // left | right
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormSwitchField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
labelPlacement = "end",
|
||||||
|
}: FormSwitchProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={labelPlacement === "start" ? undefined : theme.spacing(SPACING.LG)}
|
||||||
|
justifyContent={labelPlacement === "start" ? "space-between" : undefined}
|
||||||
|
>
|
||||||
|
{labelPlacement === "start" ? (
|
||||||
|
<>
|
||||||
|
<Typography>{label}</Typography>
|
||||||
|
<SwitchComponent
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SwitchComponent
|
||||||
|
checked={field.value ?? false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<Typography>{label}</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
useFormContext,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { TextField } from "@/Components/inputs";
|
||||||
|
|
||||||
|
interface FormTextFieldProps<T extends FieldValues> extends Omit<
|
||||||
|
React.ComponentProps<typeof TextField>,
|
||||||
|
"name" | "error" | "helperText"
|
||||||
|
> {
|
||||||
|
name: FieldPath<T>;
|
||||||
|
helperText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormTextField = <T extends FieldValues>({
|
||||||
|
name,
|
||||||
|
...rest
|
||||||
|
}: FormTextFieldProps<T>) => {
|
||||||
|
const { control } = useFormContext<T>();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
{...rest}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
helperText={fieldState.error?.message ?? rest.helperText ?? ""}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,15 +18,13 @@ const RootLayout = () => {
|
|||||||
flex={1}
|
flex={1}
|
||||||
padding={6}
|
padding={6}
|
||||||
overflow={"hidden"}
|
overflow={"hidden"}
|
||||||
sx={{
|
bgcolor={
|
||||||
backgroundColor:
|
theme.palette.mode === "dark"
|
||||||
theme.palette.mode === "dark"
|
? "rgba(255, 255, 255, 0.01)"
|
||||||
? "rgba(255, 255, 255, 0.01)"
|
: "rgba(124, 116, 116, 0.01)"
|
||||||
: "rgba(0, 0, 0, 0.01)",
|
}
|
||||||
display: "flex",
|
alignItems="center"
|
||||||
alignItems: "center",
|
paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12}
|
||||||
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
maxWidth={1280}
|
maxWidth={1280}
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag";
|
|||||||
import { Typography, useTheme } from "@mui/material";
|
import { Typography, useTheme } from "@mui/material";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
|
const types = ["http", "ping", "port", "game", "grpc", "websocket"];
|
||||||
const typeDisplayNames: Record<string, string> = {
|
const typeDisplayNames: Record<string, string> = {
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
ping: "Ping",
|
ping: "Ping",
|
||||||
port: "Port",
|
port: "Port",
|
||||||
docker: "Docker",
|
|
||||||
game: "Game",
|
game: "Game",
|
||||||
grpc: "gRPC",
|
grpc: "gRPC",
|
||||||
websocket: "WebSocket",
|
websocket: "WebSocket",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { FlatGeoCheck } from "@/Types/GeoCheck";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatStatusCode } from "@/Utils/statusCode";
|
import { formatStatusCode } from "@/Utils/statusCode";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import { formatMs } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
interface GeoChecksMapProps {
|
interface GeoChecksMapProps {
|
||||||
geoChecks: FlatGeoCheck[];
|
geoChecks: FlatGeoCheck[];
|
||||||
@@ -83,10 +84,6 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
return status ? theme.palette.success.main : theme.palette.error.main;
|
return status ? theme.palette.success.main : theme.palette.error.main;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatResponseTime = (timing: number): string => {
|
|
||||||
return `${timing.toFixed(0)}ms`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mapPopupStyles}
|
{mapPopupStyles}
|
||||||
@@ -186,7 +183,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
|||||||
>
|
>
|
||||||
Response Time:
|
Response Time:
|
||||||
</Typography>{" "}
|
</Typography>{" "}
|
||||||
{formatResponseTime(selectedCheck.timings.total)}
|
{formatMs(selectedCheck.timings.total)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
|||||||
@@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({
|
|||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
gap={theme.spacing(2)}
|
gap={theme.spacing(2)}
|
||||||
>
|
>
|
||||||
<Button
|
{isAdmin && (
|
||||||
variant="contained"
|
<Button
|
||||||
color="secondary"
|
variant="contained"
|
||||||
loading={isPosting}
|
color="secondary"
|
||||||
startIcon={<Icon icon={Mail} />}
|
loading={isPosting}
|
||||||
onClick={async () => {
|
startIcon={<Icon icon={Mail} />}
|
||||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
onClick={async () => {
|
||||||
}}
|
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||||
sx={{
|
}}
|
||||||
whiteSpace: "nowrap",
|
sx={{
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
>
|
}}
|
||||||
{t("common.buttons.testNotifications")}
|
>
|
||||||
</Button>
|
{t("common.buttons.testNotifications")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { HeaderCreate } from "@/Components/common";
|
||||||
|
import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
|
||||||
|
import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
|
||||||
|
import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
|
||||||
|
import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
|
||||||
|
import { useIsAdmin } from "@/Hooks/useIsAdmin";
|
||||||
|
import type { MonitorListController } from "@/Hooks/useMonitorListController";
|
||||||
|
import { useMediaQuery, useTheme } from "@mui/material";
|
||||||
|
import { SPACING } from "@/Utils/Theme/constants";
|
||||||
|
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { TextField, Button, Dialog } from "@/Components/inputs";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
|
||||||
|
interface MonitorListPageProps {
|
||||||
|
headerKey: string;
|
||||||
|
page: string;
|
||||||
|
actionLink: string;
|
||||||
|
controller: MonitorListController;
|
||||||
|
bulkActions?: boolean;
|
||||||
|
showTypeFilter?: boolean;
|
||||||
|
summaryProps?: { showBreached?: boolean };
|
||||||
|
priorityFallback?: ReactNode;
|
||||||
|
extraLoading?: boolean;
|
||||||
|
extraError?: unknown;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base monitor list page, all monitors pages are nearly identical, but with different tables.
|
||||||
|
|
||||||
|
export const MonitorListPage = ({
|
||||||
|
headerKey,
|
||||||
|
page,
|
||||||
|
actionLink,
|
||||||
|
controller,
|
||||||
|
bulkActions,
|
||||||
|
showTypeFilter,
|
||||||
|
summaryProps,
|
||||||
|
priorityFallback,
|
||||||
|
extraLoading,
|
||||||
|
extraError,
|
||||||
|
children,
|
||||||
|
}: MonitorListPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
const isLoading = controller.isLoading || Boolean(extraLoading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonitorBasePageWithStates
|
||||||
|
headerKey={headerKey}
|
||||||
|
loading={isLoading}
|
||||||
|
error={controller.error || extraError}
|
||||||
|
totalCount={controller.effectiveTotalCount}
|
||||||
|
page={page}
|
||||||
|
actionLink={actionLink}
|
||||||
|
priorityFallback={priorityFallback}
|
||||||
|
>
|
||||||
|
<HeaderCreate
|
||||||
|
path={actionLink}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
<HeaderMonitorsSummary
|
||||||
|
summary={controller.summary}
|
||||||
|
{...summaryProps}
|
||||||
|
/>
|
||||||
|
<Stack
|
||||||
|
direction={isSmall ? "column" : "row"}
|
||||||
|
justifyContent={isSmall ? "flex-start" : "space-between"}
|
||||||
|
gap={theme.spacing(4)}
|
||||||
|
>
|
||||||
|
<ControlsFilter
|
||||||
|
{...(showTypeFilter === false
|
||||||
|
? { showTypes: false }
|
||||||
|
: {
|
||||||
|
selectedTypes: controller.selectedTypes,
|
||||||
|
setSelectedTypes: controller.setSelectedTypes,
|
||||||
|
})}
|
||||||
|
selectedStatus={controller.selectedStatus}
|
||||||
|
setSelectedStatus={controller.setSelectedStatus}
|
||||||
|
selectedState={controller.selectedState}
|
||||||
|
setSelectedState={controller.setSelectedState}
|
||||||
|
tagOptions={controller.tags ?? []}
|
||||||
|
selectedTags={controller.selectedTags}
|
||||||
|
setSelectedTags={controller.setSelectedTags}
|
||||||
|
onClearFilters={controller.handleClearFilters}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
placeholder={t("pages.uptime.filters.search.placeholder")}
|
||||||
|
value={controller.search}
|
||||||
|
onChange={(event) => {
|
||||||
|
controller.setSearch(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
{controller.selectedTags.length > 0 && (
|
||||||
|
<Stack
|
||||||
|
direction={isSmall ? "column" : "row"}
|
||||||
|
alignItems={isSmall ? "flex-start" : "center"}
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={theme.spacing(SPACING.XL)}
|
||||||
|
>
|
||||||
|
<Typography color={theme.palette.text.secondary}>
|
||||||
|
{t("pages.uptime.filters.activeTags")}
|
||||||
|
</Typography>
|
||||||
|
{controller.selectedTags.map((tagId) => {
|
||||||
|
const tag = controller.tags?.find((t) => t.id === tagId);
|
||||||
|
if (!tag) return null;
|
||||||
|
return (
|
||||||
|
<ColoredLabel
|
||||||
|
key={tag.id}
|
||||||
|
text={tag.name}
|
||||||
|
color={tag.color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
{bulkActions && !isLoading && (
|
||||||
|
<BulkActionsBar
|
||||||
|
selectedCount={controller.selectedRows.length}
|
||||||
|
onCancel={controller.handleCancelSelection}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Play size={16} />}
|
||||||
|
onClick={controller.handleBulkResume}
|
||||||
|
>
|
||||||
|
{t("common.buttons.resume")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<Pause size={16} />}
|
||||||
|
onClick={controller.handleBulkPause}
|
||||||
|
>
|
||||||
|
{t("common.buttons.pause")}
|
||||||
|
</Button>
|
||||||
|
</BulkActionsBar>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
<Dialog
|
||||||
|
open={controller.isDialogOpen}
|
||||||
|
title={t("common.dialogs.delete.title")}
|
||||||
|
content={t("common.dialogs.delete.description")}
|
||||||
|
onConfirm={controller.handleConfirmDelete}
|
||||||
|
onCancel={controller.handleCancelDelete}
|
||||||
|
loading={controller.isDeleting}
|
||||||
|
/>
|
||||||
|
</MonitorBasePageWithStates>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { StatBox } from "@/Components/design-elements";
|
import { StatBox } from "@/Components/design-elements";
|
||||||
|
|
||||||
import prettyMilliseconds from "pretty-ms";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
||||||
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatMs, formatDuration } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
interface MonitorStatBoxesProps {
|
interface MonitorStatBoxesProps {
|
||||||
monitor?: Monitor;
|
monitor?: Monitor;
|
||||||
monitorStats: MonitorStats | null;
|
monitorStats: MonitorStats | null;
|
||||||
certificateExpiry?: string;
|
certificateExpiry?: string;
|
||||||
|
domainExpiry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MonitorStatBoxes = ({
|
export const MonitorStatBoxes = ({
|
||||||
monitor,
|
monitor,
|
||||||
monitorStats,
|
monitorStats,
|
||||||
certificateExpiry,
|
certificateExpiry,
|
||||||
|
domainExpiry,
|
||||||
}: MonitorStatBoxesProps) => {
|
}: MonitorStatBoxesProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
if (!monitorStats || !monitor) {
|
if (!monitorStats || !monitor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -27,18 +29,16 @@ export const MonitorStatBoxes = ({
|
|||||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||||
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
||||||
|
|
||||||
// Determine time since last check
|
|
||||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0;
|
|
||||||
|
|
||||||
const options = {
|
const streakTime = formatDuration(timeSinceLastFailure);
|
||||||
secondsDecimalDigits: 0,
|
const lastCheckTime = timeOfLastCheck
|
||||||
millisecondsDecimalDigits: 0,
|
? formatDuration(Date.now() - timeOfLastCheck, {
|
||||||
};
|
long: true,
|
||||||
|
relative: true,
|
||||||
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
|
locale: i18n.resolvedLanguage,
|
||||||
|
})
|
||||||
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
|
: t("common.labels.na");
|
||||||
const isActive =
|
const isActive =
|
||||||
monitor?.status === "up" ||
|
monitor?.status === "up" ||
|
||||||
monitor?.status === "paused" ||
|
monitor?.status === "paused" ||
|
||||||
@@ -67,14 +67,20 @@ export const MonitorStatBoxes = ({
|
|||||||
/>
|
/>
|
||||||
<StatBox
|
<StatBox
|
||||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
||||||
subtitle={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
|
subtitle={formatMs(monitorStats?.lastResponseTime ?? 0)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{monitor?.type === "http" && (
|
{monitor?.type === "http" && (
|
||||||
<StatBox
|
<>
|
||||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
<StatBox
|
||||||
subtitle={certificateExpiry || "N/A"}
|
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||||
/>
|
subtitle={certificateExpiry || "N/A"}
|
||||||
|
/>
|
||||||
|
<StatBox
|
||||||
|
title={t("pages.common.monitors.statBoxes.domainExpiry")}
|
||||||
|
subtitle={domainExpiry || "N/A"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export const computeYAxisCap = (values: number[]): number | undefined => {
|
||||||
|
const sorted = values.slice().sort((a, b) => a - b);
|
||||||
|
if (sorted.length < 2) return undefined;
|
||||||
|
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
||||||
|
return p95 > 0 ? Math.ceil(p95 * 2) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PHASE_KEYS = [
|
||||||
|
"avgDns",
|
||||||
|
"avgTcp",
|
||||||
|
"avgTls",
|
||||||
|
"avgRequest",
|
||||||
|
"avgFirstByte",
|
||||||
|
"avgDownload",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PHASE_COLOR_KEYS = {
|
||||||
|
avgDns: "dns",
|
||||||
|
avgTcp: "tcp",
|
||||||
|
avgTls: "tls",
|
||||||
|
avgRequest: "request",
|
||||||
|
avgFirstByte: "firstByte",
|
||||||
|
avgDownload: "download",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// i18n keys, resolved with t() at render
|
||||||
|
export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = {
|
||||||
|
avgDns: "common.charts.phases.dns",
|
||||||
|
avgTcp: "common.charts.phases.tcp",
|
||||||
|
avgTls: "common.charts.phases.tls",
|
||||||
|
avgRequest: "common.charts.phases.request",
|
||||||
|
avgFirstByte: "common.charts.phases.firstByte",
|
||||||
|
avgDownload: "common.charts.phases.download",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AREA_COLORS = [
|
||||||
|
// Blues
|
||||||
|
"#3182bd", // Deep blue
|
||||||
|
"#6baed6", // Medium blue
|
||||||
|
"#9ecae1", // Light blue
|
||||||
|
|
||||||
|
// Greens
|
||||||
|
"#74c476", // Soft green
|
||||||
|
"#a1d99b", // Light green
|
||||||
|
"#c7e9c0", // Pale green
|
||||||
|
|
||||||
|
// Oranges
|
||||||
|
"#fdae6b", // Warm orange
|
||||||
|
"#fdd0a2", // Light orange
|
||||||
|
"#feedde", // Pale orange
|
||||||
|
|
||||||
|
// Purples
|
||||||
|
"#9467bd", // Lavender
|
||||||
|
"#a55194", // Deep magenta
|
||||||
|
"#c994c7", // Soft magenta
|
||||||
|
|
||||||
|
// Reds
|
||||||
|
"#ff9896", // Soft red
|
||||||
|
"#de2d26", // Deep red
|
||||||
|
"#fc9272", // Medium red
|
||||||
|
|
||||||
|
// Cyans/Teals
|
||||||
|
"#17becf", // Cyan
|
||||||
|
"#7fcdbb", // Teal
|
||||||
|
"#a1dab4", // Light teal
|
||||||
|
|
||||||
|
// Yellows
|
||||||
|
"#fec44f", // Mustard
|
||||||
|
"#fee391", // Light yellow
|
||||||
|
"#ffffd4", // Pale yellow
|
||||||
|
|
||||||
|
// Additional colors
|
||||||
|
"#e377c2", // Soft pink
|
||||||
|
"#bcbd22", // Olive
|
||||||
|
"#2ca02c", // Vibrant green
|
||||||
|
];
|
||||||
|
|
||||||
|
export const createGradient = ({
|
||||||
|
id,
|
||||||
|
startColor,
|
||||||
|
endColor,
|
||||||
|
startOpacity = 0.8,
|
||||||
|
endOpacity = 0,
|
||||||
|
direction = "vertical",
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
startColor: string;
|
||||||
|
endColor: string;
|
||||||
|
startOpacity?: number;
|
||||||
|
endOpacity?: number;
|
||||||
|
direction?: "vertical" | "horizontal";
|
||||||
|
}) => (
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={id}
|
||||||
|
x1={direction === "vertical" ? "0" : "0"}
|
||||||
|
y1={direction === "vertical" ? "0" : "0"}
|
||||||
|
x2={direction === "vertical" ? "0" : "1"}
|
||||||
|
y2={direction === "vertical" ? "1" : "0"}
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={startColor}
|
||||||
|
stopOpacity={startOpacity}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={endColor}
|
||||||
|
stopOpacity={endOpacity}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
);
|
||||||
@@ -1,53 +1,39 @@
|
|||||||
|
import Stack from "@mui/material/Stack";
|
||||||
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
|
import { Dot } from "@/Components/design-elements";
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
XAxis,
|
XAxis,
|
||||||
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Text,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import {
|
import { useTranslation } from "react-i18next";
|
||||||
formatDateWithTz,
|
import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils";
|
||||||
tickDateFormatLookup,
|
|
||||||
tooltipDateFormatLookup,
|
|
||||||
} from "@/Utils/TimeUtils";
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { Theme } from "@mui/material/styles";
|
import type { Theme } from "@mui/material/styles";
|
||||||
import type { GroupedCheck } from "@/Types/Check";
|
import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check";
|
||||||
import type { RootState } from "@/Types/state";
|
import type { RootState } from "@/Types/state";
|
||||||
|
import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
|
||||||
type XTickProps = {
|
import { useMemo, useState } from "react";
|
||||||
x: number;
|
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
|
||||||
y: number;
|
import {
|
||||||
payload: { value: string };
|
PHASE_COLOR_KEYS,
|
||||||
range: string;
|
PHASE_KEYS,
|
||||||
};
|
PHASE_LABEL_KEYS,
|
||||||
|
} from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
|
||||||
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
type DetailsCheck = GroupedCheck &
|
||||||
const format = tickDateFormatLookup(range);
|
Partial<Pick<GroupedUptimeCheck, (typeof PHASE_KEYS)[number]>>;
|
||||||
const theme = useTheme();
|
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
x={x}
|
|
||||||
y={y + 10}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill={theme.palette.text.secondary}
|
|
||||||
fontSize={11}
|
|
||||||
fontWeight={400}
|
|
||||||
>
|
|
||||||
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
type ResponseTimeToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
range: string;
|
range: string;
|
||||||
@@ -63,17 +49,65 @@ const ResponseTimeToolTip = ({
|
|||||||
theme,
|
theme,
|
||||||
uiTimezone,
|
uiTimezone,
|
||||||
}: ResponseTimeToolTipProps) => {
|
}: ResponseTimeToolTipProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
const format = tooltipDateFormatLookup(range);
|
const format = tooltipDateFormatLookup(range);
|
||||||
console.log(payload);
|
const responseTime = payload[0]?.payload?.avgResponseTime ?? 0;
|
||||||
const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0);
|
|
||||||
return (
|
return (
|
||||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||||
<Typography>Response time: {responseTime} ms</Typography>
|
<Typography>
|
||||||
|
{t("common.charts.histogram.responseTime", {
|
||||||
|
value: formatMs(responseTime),
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</BaseBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimingPhaseToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
|
range: string;
|
||||||
|
theme: Theme;
|
||||||
|
uiTimezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimingPhasesToolTip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
range,
|
||||||
|
theme,
|
||||||
|
uiTimezone,
|
||||||
|
}: TimingPhaseToolTipProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const bucket = payload[0].payload as GroupedUptimeCheck;
|
||||||
|
const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0);
|
||||||
|
const format = tooltipDateFormatLookup(range);
|
||||||
|
return (
|
||||||
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
|
<Typography>{formatDateWithTz(bucket.bucketDate, format, uiTimezone)}</Typography>
|
||||||
|
<Typography>
|
||||||
|
{t("common.charts.histogram.total", { value: formatMs(total) })}
|
||||||
|
</Typography>
|
||||||
|
{PHASE_KEYS.map((key) => (
|
||||||
|
<Stack
|
||||||
|
alignItems={"center"}
|
||||||
|
direction="row"
|
||||||
|
key={key}
|
||||||
|
gap={SPACING.LG}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
size="lg"
|
||||||
|
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
/>
|
||||||
|
<Typography>
|
||||||
|
{t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
</BaseBox>
|
</BaseBox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -82,11 +116,28 @@ export const HistogramDetails = ({
|
|||||||
checks,
|
checks,
|
||||||
range,
|
range,
|
||||||
}: {
|
}: {
|
||||||
checks: GroupedCheck[];
|
checks: DetailsCheck[];
|
||||||
range: string;
|
range: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showTimingPhases, setShowTimingPhases] = useState(false);
|
||||||
|
const [hoveredPhase, setHoveredPhase] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
const yMax = useMemo(() => {
|
||||||
|
const totals = checks.map((check) => {
|
||||||
|
const phaseSum = PHASE_KEYS.reduce((sum, key) => sum + (check[key] ?? 0), 0);
|
||||||
|
return Math.max(check.avgResponseTime ?? 0, phaseSum);
|
||||||
|
});
|
||||||
|
return computeYAxisCap(totals);
|
||||||
|
}, [checks]);
|
||||||
|
|
||||||
|
const hasTimingPhases = useMemo(
|
||||||
|
() => (checks ?? []).some((check) => PHASE_KEYS.some((key) => (check[key] ?? 0) > 0)),
|
||||||
|
[checks]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseChart
|
<BaseChart
|
||||||
icon={
|
icon={
|
||||||
@@ -95,7 +146,15 @@ export const HistogramDetails = ({
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title="Response times"
|
title={
|
||||||
|
showTimingPhases
|
||||||
|
? t("common.charts.labels.timingPhases")
|
||||||
|
: t("common.charts.labels.responseTime")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasTimingPhases) return;
|
||||||
|
setShowTimingPhases((prev) => !prev);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer
|
<ResponsiveContainer
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -128,7 +187,34 @@ export const HistogramDetails = ({
|
|||||||
stopOpacity={0}
|
stopOpacity={0}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
{PHASE_KEYS.map((key) => (
|
||||||
|
<linearGradient
|
||||||
|
key={key}
|
||||||
|
id={`phaseGradient-${key}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
stopOpacity={0.8}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
stopOpacity={0.2}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
))}
|
||||||
</defs>
|
</defs>
|
||||||
|
<YAxis
|
||||||
|
hide
|
||||||
|
// scale="sqrt"
|
||||||
|
domain={[0, yMax ?? "auto"]}
|
||||||
|
allowDataOverflow
|
||||||
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -141,24 +227,99 @@ export const HistogramDetails = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip
|
{showTimingPhases ? (
|
||||||
content={(props) => (
|
<Tooltip
|
||||||
<ResponseTimeToolTip
|
content={(props) => (
|
||||||
{...props}
|
<TimingPhasesToolTip
|
||||||
range={range}
|
{...props}
|
||||||
theme={theme}
|
range={range}
|
||||||
uiTimezone={uiTimezone}
|
theme={theme}
|
||||||
/>
|
uiTimezone={uiTimezone}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
<Area
|
/>
|
||||||
type="monotone"
|
) : (
|
||||||
dataKey="avgResponseTime"
|
<Tooltip
|
||||||
stroke={theme.palette.primary.main}
|
content={(props) => (
|
||||||
fill="url(#colorUv)"
|
<ResponseTimeToolTip
|
||||||
/>
|
{...props}
|
||||||
|
range={range}
|
||||||
|
theme={theme}
|
||||||
|
uiTimezone={uiTimezone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!showTimingPhases && (
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avgResponseTime"
|
||||||
|
stroke={theme.palette.primary.main}
|
||||||
|
fill="url(#colorUv)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasTimingPhases &&
|
||||||
|
showTimingPhases &&
|
||||||
|
PHASE_KEYS.map((key) => {
|
||||||
|
const dimmed = hoveredPhase !== null && hoveredPhase !== key;
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
stackId={1}
|
||||||
|
dataKey={key}
|
||||||
|
stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
fill={`url(#phaseGradient-${key})`}
|
||||||
|
fillOpacity={dimmed ? 0.2 : undefined}
|
||||||
|
strokeOpacity={dimmed ? 0.2 : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
<Stack
|
||||||
|
justifyContent={"center"}
|
||||||
|
gap={LAYOUT.XS}
|
||||||
|
direction={"row"}
|
||||||
|
flexWrap={"wrap"}
|
||||||
|
>
|
||||||
|
{showTimingPhases ? (
|
||||||
|
PHASE_KEYS.map((key) => {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={SPACING.LG}
|
||||||
|
key={key}
|
||||||
|
onMouseEnter={() => setHoveredPhase(key)}
|
||||||
|
onMouseLeave={() => setHoveredPhase(null)}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<Typography lineHeight={1}>{t(PHASE_LABEL_KEYS[key])}</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
gap={SPACING.LG}
|
||||||
|
onMouseEnter={() => setHoveredPhase("avgResponseTime")}
|
||||||
|
onMouseLeave={() => setHoveredPhase(null)}
|
||||||
|
>
|
||||||
|
<Dot
|
||||||
|
color={theme.palette.primary.main}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<Typography lineHeight={1}>
|
||||||
|
{t("common.charts.labels.totalResponseTime")}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { BaseChart, BaseBox } from "@/Components/design-elements";
|
||||||
|
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { DockerContainerStats } from "@/Types/Monitor";
|
||||||
|
import type { TooltipProps } from "recharts";
|
||||||
|
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||||
|
import type { Theme } from "@mui/material/styles";
|
||||||
|
import type { RootState } from "@/Types/state";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import { Fragment, useId } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||||
|
import { tooltipDateFormatLookup, formatDateWithTz } from "@/Utils/TimeUtils";
|
||||||
|
import type { DateRange } from "@/Types/Query";
|
||||||
|
|
||||||
|
type DockerHistogramToolTipProps = TooltipProps<ValueType, NameType> & {
|
||||||
|
dataKey: string;
|
||||||
|
range: string;
|
||||||
|
theme: Theme;
|
||||||
|
uiTimezone: string;
|
||||||
|
valueFormatter: (value: number) => string;
|
||||||
|
labelKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DockerHistogramToolTip = ({
|
||||||
|
dataKey,
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
range,
|
||||||
|
theme,
|
||||||
|
uiTimezone,
|
||||||
|
valueFormatter,
|
||||||
|
labelKey,
|
||||||
|
}: DockerHistogramToolTipProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!payload) return null;
|
||||||
|
if (!active) return null;
|
||||||
|
const format = tooltipDateFormatLookup(range);
|
||||||
|
const ts = payload[0].payload?._id ?? null;
|
||||||
|
const dataPoint = payload[0].payload?.[dataKey] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||||
|
<Typography>{formatDateWithTz(ts, format, uiTimezone)}</Typography>
|
||||||
|
<Typography>{t(labelKey, { value: valueFormatter(dataPoint) })}</Typography>
|
||||||
|
</BaseBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
interface HistogramDockerContainerProps {
|
||||||
|
title: string;
|
||||||
|
rightTitle: string;
|
||||||
|
stats: DockerContainerStats;
|
||||||
|
dataKey: string;
|
||||||
|
strokeColor: string;
|
||||||
|
gradientStartColor: string;
|
||||||
|
range: DateRange;
|
||||||
|
formatter: (value: number) => string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistogramDockerContainer = ({
|
||||||
|
title,
|
||||||
|
rightTitle,
|
||||||
|
stats,
|
||||||
|
dataKey,
|
||||||
|
strokeColor,
|
||||||
|
gradientStartColor,
|
||||||
|
range,
|
||||||
|
formatter,
|
||||||
|
labelKey,
|
||||||
|
}: HistogramDockerContainerProps) => {
|
||||||
|
const uniqueId = useId();
|
||||||
|
const theme = useTheme();
|
||||||
|
const gradientId = `gradient-${uniqueId}`;
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
return (
|
||||||
|
<BaseChart
|
||||||
|
icon="null"
|
||||||
|
title={title}
|
||||||
|
rightTitle={rightTitle}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height={200}
|
||||||
|
>
|
||||||
|
<AreaChart data={stats.aggregate}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={theme.palette.divider}
|
||||||
|
strokeOpacity={1}
|
||||||
|
fill="transparent"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<Fragment>
|
||||||
|
{createGradient({
|
||||||
|
id: gradientId,
|
||||||
|
startColor: gradientStartColor,
|
||||||
|
endColor: "transparent",
|
||||||
|
direction: "vertical",
|
||||||
|
})}
|
||||||
|
<Tooltip
|
||||||
|
content={(props) => (
|
||||||
|
<DockerHistogramToolTip
|
||||||
|
{...props}
|
||||||
|
range={range}
|
||||||
|
theme={theme}
|
||||||
|
uiTimezone={uiTimezone}
|
||||||
|
dataKey={dataKey}
|
||||||
|
valueFormatter={formatter}
|
||||||
|
labelKey={labelKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey={dataKey}
|
||||||
|
type="monotone"
|
||||||
|
stroke={strokeColor}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</BaseChart>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,89 +8,11 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Fragment, useId } from "react";
|
import { Fragment, useId } from "react";
|
||||||
import { XTick } from "@/Components/monitors";
|
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||||
|
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import type { HardwareCheckStats } from "@/Types/Monitor";
|
import type { HardwareCheckStats } from "@/Types/Monitor";
|
||||||
|
import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils";
|
||||||
const AREA_COLORS = [
|
|
||||||
// Blues
|
|
||||||
"#3182bd", // Deep blue
|
|
||||||
"#6baed6", // Medium blue
|
|
||||||
"#9ecae1", // Light blue
|
|
||||||
|
|
||||||
// Greens
|
|
||||||
"#74c476", // Soft green
|
|
||||||
"#a1d99b", // Light green
|
|
||||||
"#c7e9c0", // Pale green
|
|
||||||
|
|
||||||
// Oranges
|
|
||||||
"#fdae6b", // Warm orange
|
|
||||||
"#fdd0a2", // Light orange
|
|
||||||
"#feedde", // Pale orange
|
|
||||||
|
|
||||||
// Purples
|
|
||||||
"#9467bd", // Lavender
|
|
||||||
"#a55194", // Deep magenta
|
|
||||||
"#c994c7", // Soft magenta
|
|
||||||
|
|
||||||
// Reds
|
|
||||||
"#ff9896", // Soft red
|
|
||||||
"#de2d26", // Deep red
|
|
||||||
"#fc9272", // Medium red
|
|
||||||
|
|
||||||
// Cyans/Teals
|
|
||||||
"#17becf", // Cyan
|
|
||||||
"#7fcdbb", // Teal
|
|
||||||
"#a1dab4", // Light teal
|
|
||||||
|
|
||||||
// Yellows
|
|
||||||
"#fec44f", // Mustard
|
|
||||||
"#fee391", // Light yellow
|
|
||||||
"#ffffd4", // Pale yellow
|
|
||||||
|
|
||||||
// Additional colors
|
|
||||||
"#e377c2", // Soft pink
|
|
||||||
"#bcbd22", // Olive
|
|
||||||
"#2ca02c", // Vibrant green
|
|
||||||
];
|
|
||||||
|
|
||||||
const createGradient = ({
|
|
||||||
id,
|
|
||||||
startColor,
|
|
||||||
endColor,
|
|
||||||
startOpacity = 0.8,
|
|
||||||
endOpacity = 0,
|
|
||||||
direction = "vertical",
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
startColor: string;
|
|
||||||
endColor: string;
|
|
||||||
startOpacity?: number;
|
|
||||||
endOpacity?: number;
|
|
||||||
direction?: "vertical" | "horizontal";
|
|
||||||
}) => (
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id={id}
|
|
||||||
x1={direction === "vertical" ? "0" : "0"}
|
|
||||||
y1={direction === "vertical" ? "0" : "0"}
|
|
||||||
x2={direction === "vertical" ? "0" : "1"}
|
|
||||||
y2={direction === "vertical" ? "1" : "0"}
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="0%"
|
|
||||||
stopColor={startColor}
|
|
||||||
stopOpacity={startOpacity}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="100%"
|
|
||||||
stopColor={endColor}
|
|
||||||
stopOpacity={endOpacity}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const HistogramInfrastructure = ({
|
export const HistogramInfrastructure = ({
|
||||||
dateRange,
|
dateRange,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { TrendingUp } from "lucide-react";
|
import { TrendingUp } from "lucide-react";
|
||||||
import { XTick } from "@/Components/monitors";
|
import { XTick } from "@/Components/monitors/charts/XTick";
|
||||||
import {
|
import {
|
||||||
XAxis,
|
XAxis,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { BaseChart } from "@/Components/design-elements";
|
import { BaseChart } from "@/Components/design-elements";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||||
@@ -100,7 +100,7 @@ export const HistogramStatus = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography variant="caption">{titleText}</Typography>
|
<Typography variant="caption">{titleText}</Typography>
|
||||||
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
|
<Typography variant="body2">{formatMs(avg)}</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||||
import { useTheme } from "@mui/material/styles";
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import { formatMs } from "@/Utils/TimeUtils";
|
||||||
|
|
||||||
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -20,7 +21,7 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
|
|||||||
const msg: Record<string, string> = {
|
const msg: Record<string, string> = {
|
||||||
success: "Excellent",
|
success: "Excellent",
|
||||||
warning: "Average",
|
warning: "Average",
|
||||||
danger: "Poor",
|
error: "Poor",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +85,9 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) =>
|
|||||||
<Typography
|
<Typography
|
||||||
variant="h1"
|
variant="h1"
|
||||||
textAlign={"center"}
|
textAlign={"center"}
|
||||||
>{`${avg?.toFixed()}ms`}</Typography>
|
>
|
||||||
|
{formatMs(avg)}
|
||||||
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseChart>
|
</BaseChart>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { formatDateWithTz, tickDateFormatLookup } from "@/Utils/TimeUtils";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { Text } from "recharts";
|
||||||
|
import { useTheme } from "@mui/material";
|
||||||
|
import type { RootState } from "@/Types/state";
|
||||||
|
|
||||||
|
type XTickProps = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
payload: { value: string };
|
||||||
|
range: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
||||||
|
const format = tickDateFormatLookup(range);
|
||||||
|
const theme = useTheme();
|
||||||
|
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
x={x}
|
||||||
|
y={y + 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={theme.palette.text.secondary}
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={400}
|
||||||
|
>
|
||||||
|
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
|||||||
export * from "./charts/HistogramInfrastructure";
|
export * from "./charts/HistogramInfrastructure";
|
||||||
export * from "./HeaderMonitorsSummary";
|
export * from "./HeaderMonitorsSummary";
|
||||||
export * from "./BulkActionsBar";
|
export * from "./BulkActionsBar";
|
||||||
|
export * from "./MonitorListPage";
|
||||||
@@ -10,10 +10,11 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { MoreVertical, LogOut } from "lucide-react";
|
import { MoreVertical, LogOut, Moon, Sun } from "lucide-react";
|
||||||
import { Avatar, Icon } from "@/Components/design-elements";
|
import { Avatar, Icon } from "@/Components/design-elements";
|
||||||
import { clearAuthState } from "@/Features/Auth/authSlice.js";
|
import { clearAuthState } from "@/Features/Auth/authSlice.js";
|
||||||
import type { RootState } from "@/Types/state.js";
|
import type { RootState } from "@/Types/state.js";
|
||||||
|
import { setMode } from "@/Features/UI/uiSlice.js";
|
||||||
|
|
||||||
interface AuthFooterProps {
|
interface AuthFooterProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -27,6 +28,7 @@ interface AuthFooterProps {
|
|||||||
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
|
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const mode = useSelector((state: RootState) => state.ui.mode);
|
||||||
const user = useSelector((state: RootState) => state.auth.user);
|
const user = useSelector((state: RootState) => state.auth.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -105,13 +107,14 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
<Typography
|
<Typography
|
||||||
fontWeight={500}
|
fontWeight={500}
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ color: theme.palette.text.primary }}
|
color={theme.palette.text.primary}
|
||||||
>
|
>
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
|
textTransform={"capitalize"}
|
||||||
|
color={theme.palette.text.secondary}
|
||||||
>
|
>
|
||||||
{getRoleText()}
|
{getRoleText()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -154,15 +157,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
pb={2}
|
pb={2}
|
||||||
sx={{ pointerEvents: "none" }}
|
sx={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography fontWeight={500}>
|
||||||
fontWeight={500}
|
|
||||||
fontSize={13}
|
|
||||||
>
|
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
fontSize={12}
|
variant="body2"
|
||||||
sx={{ textTransform: "capitalize" }}
|
textTransform={"capitalize"}
|
||||||
>
|
>
|
||||||
{getRoleText()}
|
{getRoleText()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -178,6 +178,18 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
|
|||||||
{item.name}
|
{item.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setMode(mode === "light" ? "dark" : "light"));
|
||||||
|
handleMenuClose();
|
||||||
|
}}
|
||||||
|
sx={menuItemSx}
|
||||||
|
>
|
||||||
|
<Icon icon={mode === "light" ? Moon : Sun} />
|
||||||
|
{mode === "light"
|
||||||
|
? t("pages.settings.form.ui.option.theme.dark")
|
||||||
|
: t("pages.settings.form.ui.option.theme.light")}
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
sx={menuItemSx}
|
sx={menuItemSx}
|
||||||
|
|||||||
Loaded 100 of 794 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user