Compare commits
689 Commits
v0.0.1
...
995d3ca00d
| Author | SHA1 | Date | |
|---|---|---|---|
| 995d3ca00d | |||
|
|
ca18e58f5e | ||
| b345ac46a1 | |||
|
|
374131b7b5 | ||
| 0ac84a10e8 | |||
|
|
191d4f39c1 | ||
|
|
4bc38453fe | ||
|
|
9466551044 | ||
|
|
39687bc8a9 | ||
|
|
7ec56f3bd0 | ||
|
|
605c8ad270 | ||
|
|
2ede06f32a | ||
|
|
fb53dc6dfc | ||
|
|
3d910af491 | ||
|
|
eadcd160a3 | ||
|
|
ba0a1850a9 | ||
|
|
b6b93dc3cc | ||
|
|
3f9fd44ea5 | ||
|
|
ba53f91f4a | ||
|
|
be585934b9 | ||
|
|
2771dffb78 | ||
|
|
80bc092ec1 | ||
|
|
4ea8bb368a | ||
|
|
f24a5e5ff0 | ||
|
|
1971c70638 | ||
|
|
69dcce2a8f | ||
|
|
cb36d7936f | ||
|
|
f95a78a380 | ||
|
|
3f94c98c5b | ||
|
|
ff62a34d89 | ||
|
|
bfed8174ca | ||
|
|
827ba3c798 | ||
|
|
3bf470f83f | ||
|
|
de46cee440 | ||
|
|
04c90bde06 | ||
|
|
2df5e0d7ba | ||
|
|
7b822a787a | ||
|
|
e88db56f79 | ||
|
|
eb7cd9ba62 | ||
|
|
b86e95f08e | ||
|
|
0720053523 | ||
|
|
a4a569a253 | ||
|
|
6288084daf | ||
|
|
64ebf19ad3 | ||
|
|
20f3dfe59d | ||
|
|
c923d8233b | ||
|
|
c72424543e | ||
|
|
18ffbea9db | ||
|
|
19da9b9f9f | ||
|
|
8b3c4ba2fe | ||
|
|
96fbca1b35 | ||
|
|
977bfc1c6b | ||
|
|
7e0536b5b3 | ||
|
|
6e444a414d | ||
|
|
f8d42026da | ||
|
|
fef3ef6184 | ||
|
|
76eacb17e6 | ||
|
|
3f2fec2815 | ||
|
|
55bdab472b | ||
|
|
b7d00548c5 | ||
|
|
fef0239b1d | ||
|
|
6eff271238 | ||
|
|
01e0062767 | ||
|
|
0fccdb636f | ||
|
|
123e66e44d | ||
|
|
b196918e70 | ||
|
|
dd4442329c | ||
|
|
da6bf694f8 | ||
|
|
7e47f1628d | ||
|
|
863a992cc4 | ||
|
|
0ccb8bc68d | ||
|
|
0a3733f9ba | ||
|
|
056b747c3f | ||
|
|
0b2d231b6b | ||
|
|
7503641afe | ||
|
|
967156d41b | ||
|
|
0a0733def7 | ||
|
|
b7f215e90c | ||
|
|
6a32b83326 | ||
|
|
c4fe992179 | ||
|
|
01ac47eeb4 | ||
|
|
1c5ecb02e3 | ||
|
|
b1b7e142bb | ||
|
|
de4ca10fa5 | ||
|
|
875062e59a | ||
|
|
e04dca55aa | ||
|
|
448a63adc9 | ||
|
|
a8b977a2db | ||
|
|
529e2c727c | ||
|
|
9af0043915 | ||
|
|
2e006051bc | ||
|
|
d9160b7d0e | ||
|
|
36e8b2d8ff | ||
|
|
3d20d7a0cb | ||
|
|
8f2aafadc1 | ||
|
|
248b716cb9 | ||
|
|
b05b7e5597 | ||
|
|
585e078667 | ||
|
|
55068ff625 | ||
|
|
17f45645ff | ||
|
|
fd2e52e155 | ||
|
|
85530d5ea3 | ||
|
|
32ae642fab | ||
|
|
ec9856d8a2 | ||
|
|
847c1f792b | ||
|
|
ac9ce4f2e7 | ||
|
|
7657081b78 | ||
|
|
b5e85162f8 | ||
|
|
7904a18f67 | ||
|
|
67ca1e726f | ||
|
|
b969075007 | ||
|
|
d734597ec3 | ||
|
|
dd5cf1b38c | ||
|
|
e1cb17707b | ||
|
|
b5cf35ef9a | ||
|
|
2f8fcb866e | ||
|
|
bd78207060 | ||
|
|
96ba7cd711 | ||
|
|
c6682c4c9c | ||
|
|
6a1d3bb129 | ||
|
|
9cbf647203 | ||
|
|
07f3c2584c | ||
|
|
ca1b549f10 | ||
|
|
7d5866bca8 | ||
|
|
f601074e78 | ||
|
|
725f826513 | ||
|
|
52f5a0414e | ||
|
|
11fc85e2b9 | ||
|
|
d4b530ff8a | ||
|
|
03ff9a3813 | ||
|
|
95eb388283 | ||
|
|
8852ec1483 | ||
|
|
23e90d6afb | ||
|
|
d19551f8aa | ||
|
|
b2e4b91d94 | ||
|
|
95b35f6203 | ||
|
|
a443abe6ae | ||
|
|
a5340059d7 | ||
|
|
45cccdbd8a | ||
|
|
281e168790 | ||
|
|
1386e80670 | ||
|
|
f372d0d63c | ||
|
|
6ef66a14ec | ||
|
|
0761d0dbee | ||
|
|
0de392ff6e | ||
|
|
c502a42f17 | ||
|
|
07ff576eb6 | ||
|
|
c249c6f3e0 | ||
|
|
bb6a9c9269 | ||
|
|
c6a8a4471f | ||
|
|
640a48114d | ||
|
|
b1655b366e | ||
|
|
e54f308607 | ||
|
|
e69b44f566 | ||
|
|
0c77f8d594 | ||
|
|
a96cf2afed | ||
|
|
549dbaa322 | ||
|
|
f4eafd9a0f | ||
|
|
4e12fcbe7a | ||
|
|
9c2e6aacad | ||
|
|
c757a0ea51 | ||
|
|
9a40626a27 | ||
|
|
4496be08bd | ||
|
|
e8bcc39ca9 | ||
|
|
94bfb8fc4a | ||
|
|
c628c25081 | ||
|
|
3cea306e17 | ||
|
|
4244dd82e9 | ||
|
|
d7001804f7 | ||
|
|
5c4c7ad321 | ||
|
|
0fab20e67a | ||
|
|
d7563902a7 | ||
|
|
99e2a8354f | ||
|
|
083cb8b9ec | ||
|
|
0609220cdf | ||
|
|
ca92b3ce7d | ||
|
|
7ebbc18b31 | ||
|
|
5b7c92848d | ||
|
|
44f3821df4 | ||
|
|
51abe45fba | ||
|
|
3c70313d78 | ||
|
|
12bb734c2d | ||
|
|
cbeaf30bc7 | ||
|
|
c4d2fa90ab | ||
|
|
e9ef97bc20 | ||
|
|
eecb0adf93 | ||
|
|
c47b8b9998 | ||
|
|
22d812d832 | ||
|
|
fec6717a85 | ||
|
|
3bd07c9b07 | ||
|
|
a5c4e0cead | ||
|
|
de85cdf5a2 | ||
|
|
2277a0498f | ||
|
|
ac87aa6eb2 | ||
|
|
f16d331621 | ||
|
|
69055f7d74 | ||
|
|
37eb56332a | ||
|
|
72ec87a3ba | ||
|
|
346e38ee1d | ||
|
|
39d9ec9cd6 | ||
|
|
08f2a01057 | ||
|
|
574f82b731 | ||
|
|
c2d4d38bfb | ||
|
|
694d0eef59 | ||
|
|
babdc1d7a4 | ||
|
|
a188308ec5 | ||
|
|
ee7226cf1c | ||
|
|
7429b85964 | ||
|
|
a5c07b8585 | ||
|
|
45a74075a1 | ||
|
|
abed4dc96f | ||
|
|
170b2c4a02 | ||
|
|
66e91ba18c | ||
|
|
e30b561dfe | ||
|
|
5ae94e1e2c | ||
|
|
7dca8f2609 | ||
|
|
2589c681c5 | ||
|
|
352fa43ef8 | ||
|
|
b04b12220b | ||
|
|
633a61d89d | ||
|
|
e0aac4bf0a | ||
|
|
ac94a67a49 | ||
|
|
e1cb9d7872 | ||
|
|
a9ec424d52 | ||
|
|
81f13396a0 | ||
|
|
670e458376 | ||
|
|
d4327af6a4 | ||
|
|
bb3e1e2bc3 | ||
|
|
984bb2d40f | ||
|
|
6f00ff2e28 | ||
|
|
2708bcec17 | ||
|
|
901dfd1eb8 | ||
|
|
726e77bb91 | ||
|
|
d30c267292 | ||
|
|
37c10ae0a6 | ||
|
|
c16f0e62ed | ||
|
|
2bc3efad7f | ||
|
|
0632f1c6a8 | ||
|
|
bdac363e40 | ||
|
|
d9615204bf | ||
|
|
2896bb90a9 | ||
|
|
a036d8a027 | ||
|
|
44a37317d1 | ||
|
|
146398b183 | ||
|
|
69ca52b25e | ||
|
|
111bcc302d | ||
|
|
cf36f81ef1 | ||
|
|
28f38331cc | ||
|
|
394fde30c7 | ||
|
|
62b5c56c56 | ||
|
|
9b401558a5 | ||
|
|
38b76513c7 | ||
|
|
2265ebf801 | ||
|
|
20af81a5dc | ||
|
|
d819f88ae4 | ||
|
|
5880abdd93 | ||
|
|
b676450995 | ||
|
|
e495b80432 | ||
|
|
45eab761b7 | ||
|
|
8d899cc70c | ||
|
|
520b80444a | ||
|
|
17aff5ef9d | ||
|
|
b714d3363f | ||
|
|
0acceaf1a9 | ||
|
|
ca1d472b78 | ||
|
|
c3b4f70913 | ||
|
|
027e45aadf | ||
|
|
f39f07e7bf | ||
|
|
d21d8b2c48 | ||
|
|
d5f5601554 | ||
|
|
00042b1d14 | ||
|
|
fe49eb5aba | ||
|
|
bc913eef6e | ||
|
|
d70ad91b33 | ||
|
|
ba361af2d7 | ||
|
|
78777d2ba6 | ||
|
|
3f8a9715a4 | ||
|
|
f00a3e8b97 | ||
|
|
d5028193c0 | ||
|
|
a484364029 | ||
|
|
d95e518622 | ||
|
|
56297701e6 | ||
|
|
8c7c9911c4 | ||
|
|
4d66d6ab23 | ||
|
|
b73f5e6dd4 | ||
|
|
a52751da1b | ||
|
|
51780031ea | ||
|
|
eb2cafc7fa | ||
|
|
805e6d51cb | ||
|
|
f3feaddbfe | ||
|
|
9057981cf7 | ||
|
|
b30a5b5760 | ||
|
|
910230cbf8 | ||
|
|
1d791bb329 | ||
|
|
9781fe0d7c | ||
|
|
92951f1dcf | ||
|
|
a7d256b38a | ||
|
|
e26266532a | ||
|
|
178bc40706 | ||
|
|
4168a6d45b | ||
|
|
a028905e41 | ||
|
|
f82aa26371 | ||
|
|
188810e54b | ||
|
|
283e38a20d | ||
|
|
5ed7d38bf7 | ||
|
|
4cdbcdaeea | ||
|
|
aa2d203f4e | ||
|
|
ce4abaf862 | ||
|
|
40ce4a57b4 | ||
|
|
b44ffd08be | ||
|
|
cf439248b5 | ||
|
|
e8f9ada1d1 | ||
|
|
bc70797e31 | ||
|
|
f6123b8a7c | ||
|
|
d739094a56 | ||
|
|
91400defe9 | ||
|
|
909d713837 | ||
|
|
ad8dd73596 | ||
|
|
e50c9fa60d | ||
|
|
d4dbfa7ae6 | ||
|
|
59374482bc | ||
|
|
43e187a023 | ||
|
|
bc1c71277c | ||
|
|
520181d241 | ||
|
|
95b9dea5c4 | ||
|
|
151b96a680 | ||
|
|
0661fd995f | ||
|
|
190ae2797d | ||
|
|
968117c41a | ||
|
|
7d7eb52afb | ||
|
|
c73e4abf68 | ||
|
|
cd63d300b3 | ||
|
|
f7daadaaa9 | ||
|
|
af080337f5 | ||
|
|
606f81a970 | ||
|
|
154bce366a | ||
|
|
a669df08bd | ||
|
|
af18fc4142 | ||
|
|
1a00eed389 | ||
|
|
0423518f72 | ||
|
|
9df00fdde0 | ||
|
|
052990bb59 | ||
|
|
eb0d26814f | ||
|
|
c8e6bbe059 | ||
|
|
a9eabe97f7 | ||
|
|
e724607a66 | ||
|
|
07f215b0fd | ||
|
|
38551eac9d | ||
|
|
31f7113b3f | ||
|
|
6052407c82 | ||
|
|
776f2ce90d | ||
|
|
62420cf0c2 | ||
|
|
81f7f8afe1 | ||
|
|
b30dfa39f4 | ||
|
|
20c8e17843 | ||
| a96fe59840 | |||
|
|
7cf849269f | ||
| 76afcaa637 | |||
|
|
b1c5cc0616 | ||
| 8838077eff | |||
|
|
8eeaecf6f3 | ||
| b54bef302d | |||
|
|
f8505401d7 | ||
| a0f1a4aba4 | |||
|
|
aa5fc1b830 | ||
|
|
c42e13932b | ||
|
|
59dd629b0e | ||
|
|
697c689192 | ||
|
|
7a2a0ee649 | ||
|
|
1b991f99a3 | ||
|
|
21991b6cf8 | ||
|
|
53766aeb56 | ||
|
|
bf0e9ea418 | ||
|
|
6e30b7ec65 | ||
|
|
08934376df | ||
|
|
23f901279a | ||
|
|
6171827243 | ||
|
|
c77d8a7af0 | ||
|
|
e7eda7a7b3 | ||
|
|
ebe768711b | ||
|
|
af45f93854 | ||
|
|
da1d74309e | ||
|
|
7a4d7b6915 | ||
|
|
ab7031e6ed | ||
|
|
cf3cec0164 | ||
|
|
79762c3f0d | ||
|
|
715cbc1894 | ||
|
|
dd398178f0 | ||
|
|
8b0d473fcd | ||
|
|
30e9b55379 | ||
|
|
3091754b0f | ||
|
|
26de222884 | ||
|
|
2f2f93f37e | ||
|
|
1b9a3b84a0 | ||
|
|
c77de4a232 | ||
|
|
15b8c09e17 | ||
|
|
77e87504d6 | ||
|
|
d8a21f0724 | ||
|
|
4a91ca0774 | ||
|
|
52c22f1eb9 | ||
|
|
a517785050 | ||
|
|
474738a894 | ||
|
|
41397ae067 | ||
|
|
dd91a4989b | ||
|
|
f06f5f2bb1 | ||
|
|
c8caf3dc44 | ||
|
|
2de10f6eb0 | ||
|
|
e2c0f203f9 | ||
|
|
a383b9bcf4 | ||
|
|
6aeba1fe83 | ||
|
|
7a1625c297 | ||
|
|
9d2d87e7e1 | ||
|
|
b5c19b6774 | ||
|
|
213aa86c47 | ||
|
|
b2ae37637d | ||
|
|
7e968dc06b | ||
|
|
0ec41bc02c | ||
|
|
59ddbb65b9 | ||
|
|
673f0958c5 | ||
|
|
e8039f9cc4 | ||
|
|
9eb2c2692b | ||
|
|
090c51c809 | ||
|
|
32cde5363f | ||
|
|
604e5db874 | ||
|
|
a4fcb8810f | ||
|
|
3d71345181 | ||
|
|
5103f40196 | ||
|
|
09a60c5a6c | ||
|
|
7a84914866 | ||
|
|
88c51b75bf | ||
|
|
3f87f37095 | ||
|
|
ac4476ccd6 | ||
|
|
30344d29b1 | ||
|
|
f12f5f3c8d | ||
|
|
c6f70968a2 | ||
|
|
faf5d505f4 | ||
|
|
c4b396e618 | ||
|
|
e5e6175aca | ||
|
|
0516207e83 | ||
|
|
d79e7d0168 | ||
|
|
7c88b03956 | ||
|
|
55e1c7cbb5 | ||
|
|
6a1d199da6 | ||
|
|
459f4d2e0c | ||
|
|
27249c2440 | ||
|
|
f59423bc91 | ||
|
|
e5be9f81e0 | ||
|
|
9f281c3354 | ||
|
|
f2a094f349 | ||
|
|
dd1cae6f70 | ||
|
|
7903a300db | ||
|
|
5873e6a57c | ||
|
|
816a034d4a | ||
|
|
2fade7192a | ||
|
|
175e62f514 | ||
|
|
b4c9be9334 | ||
|
|
8b276a92a7 | ||
|
|
01c6d5c131 | ||
|
|
626501cb04 | ||
|
|
3362417907 | ||
|
|
7b2622fca9 | ||
|
|
24d760af8a | ||
|
|
d32bde58e2 | ||
|
|
3d86d57a80 | ||
|
|
29f4be542b | ||
|
|
2f2e503447 | ||
|
|
7ee57ca975 | ||
|
|
c8fcee9d09 | ||
|
|
0ed30d92f1 | ||
|
|
4e59b0bcd0 | ||
|
|
eaeef6f0b2 | ||
|
|
9f0c2e1225 | ||
|
|
e934b31164 | ||
|
|
77d871c4f8 | ||
|
|
4296d41cad | ||
|
|
a5ba684c7d | ||
|
|
a658ed9135 | ||
|
|
b863370511 | ||
|
|
048f6566a9 | ||
|
|
5cb3de03af | ||
|
|
ef9d8c8066 | ||
|
|
1ca4cac396 | ||
|
|
6b06e7f86b | ||
|
|
e703a9d39d | ||
|
|
67bae5640c | ||
|
|
c06f0c89e5 | ||
|
|
73560d761d | ||
|
|
4ed804141a | ||
|
|
de2281cad2 | ||
|
|
5af20d0f63 | ||
|
|
91171590e6 | ||
|
|
699ef86f8f | ||
|
|
d63a9f8ce7 | ||
|
|
77c73fe3e6 | ||
|
|
1e6de17084 | ||
| 7ee7076eec | |||
|
|
698b97d536 | ||
|
|
4fe418cc89 | ||
|
|
66abb1fe3a | ||
|
|
611c201887 | ||
|
|
f2abe296ee | ||
|
|
fc27880d96 | ||
|
|
8219c54422 | ||
|
|
c1b156bdb4 | ||
|
|
0eb377b515 | ||
|
|
facf7fb6ef | ||
|
|
90be1875e0 | ||
|
|
065517f032 | ||
|
|
99b97c53dd | ||
|
|
79e5caaf7a | ||
|
|
5b5fa28ba0 | ||
|
|
3b2c5ccdbe | ||
|
|
c8d824d347 | ||
|
|
615a3c6e99 | ||
|
|
dbf64ecb48 | ||
|
|
1702200a60 | ||
|
|
004574d442 | ||
|
|
41111b082c | ||
|
|
e9b1c94d1a | ||
|
|
0d7d04501c | ||
|
|
6393e5096f | ||
|
|
4af71aabac | ||
|
|
acb7cade90 | ||
|
|
19d3c8fa93 | ||
|
|
990d607d4b | ||
|
|
0df7735d20 | ||
|
|
7926179ed9 | ||
|
|
1855153dbe | ||
|
|
3751762c69 | ||
|
|
56f98671ca | ||
|
|
cbe41d7ac7 | ||
|
|
bd8e95c6ce | ||
|
|
fee9b4bd83 | ||
|
|
7ec683aca0 | ||
|
|
ac750b603f | ||
|
|
5306be3f2e | ||
|
|
b0dcd0ac6b | ||
|
|
159e4adf07 | ||
|
|
085c4e395b | ||
|
|
d7166b6d0a | ||
|
|
25e23c0b87 | ||
|
|
cf9e847f84 | ||
|
|
bfd76261ef | ||
|
|
0b8efa1998 | ||
|
|
3027e9b24f | ||
|
|
3d5d462de0 | ||
|
|
f675451384 | ||
|
|
021a52e56b | ||
|
|
5ccefa3cdb | ||
|
|
e4c66b1311 | ||
|
|
5da03d0938 | ||
|
|
3af1d1f3b6 | ||
|
|
1984c597de | ||
|
|
3029704051 | ||
|
|
2b805ec196 | ||
|
|
ff59dc5d57 | ||
|
|
3928743ea7 | ||
|
|
cf6c4bd60c | ||
|
|
edd841ffeb | ||
|
|
889f0e5263 | ||
|
|
3a41e1f1d3 | ||
|
|
509159417b | ||
|
|
30c8fe1091 | ||
|
|
b1ff05439a | ||
|
|
eb9c20e734 | ||
|
|
f6220a9f89 | ||
|
|
9b7626f6ff | ||
|
|
20d1182259 | ||
|
|
afcb7d3175 | ||
|
|
ac32396a57 | ||
|
|
78e12f5cf9 | ||
|
|
62709ce80b | ||
|
|
ea88042ef5 | ||
|
|
cde79bd172 | ||
|
|
a2a8e4ae3f | ||
|
|
6e187ccb48 | ||
|
|
862a27b0b8 | ||
|
|
d6c1f2c25b | ||
|
|
100b780b47 | ||
|
|
bd63a8ce95 | ||
|
|
ef9ec6069f | ||
|
|
bf84f1814f | ||
|
|
00efaf0ca0 | ||
|
|
900b6f45c5 | ||
|
|
dd6ea7563f | ||
|
|
57bb84a2df | ||
|
|
a0fbf785c3 | ||
|
|
91e51d4f6a | ||
|
|
b52d588fc5 | ||
|
|
23b23bbb66 | ||
|
|
82b47f4364 | ||
|
|
e4b2dd2604 | ||
|
|
3b31e69ae4 | ||
|
|
499fd7f8e8 | ||
|
|
1080c76e99 | ||
|
|
7f58bca0e6 | ||
|
|
c087e4af08 | ||
|
|
387ed44989 | ||
|
|
64b677696e | ||
|
|
78813ea15f | ||
|
|
807e191397 | ||
|
|
47ff122c48 | ||
|
|
eb796f531f | ||
|
|
a3706cf7c2 | ||
|
|
2b1d49c032 | ||
|
|
ae1ee38441 | ||
|
|
d6d96aad07 | ||
|
|
2d6cc4c634 | ||
|
|
ca5250c134 | ||
|
|
64f797bd96 | ||
|
|
f08461cf35 | ||
|
|
2b5d803a60 | ||
|
|
e3902cd85f | ||
|
|
25ca8d5132 | ||
|
|
0d94132c98 | ||
|
|
0e6de69cd9 | ||
|
|
e53274bcb9 | ||
|
|
4433b26bf8 | ||
|
|
74fa08f41f | ||
|
|
4b66d78cf4 | ||
|
|
b1c2950b1e | ||
|
|
b0484459a2 | ||
|
|
056a6f0ff5 | ||
|
|
f4bf38fcba | ||
|
|
15632a2170 | ||
|
|
479b67cd2d | ||
|
|
bde0459416 | ||
|
|
a01712e68c | ||
|
|
9aa78f681d | ||
|
|
befefe457f | ||
|
|
ea665ff411 | ||
|
|
f9bd492191 | ||
|
|
1be303b801 | ||
|
|
d57249906a | ||
|
|
6a24dd01e9 | ||
|
|
e10f021c54 | ||
|
|
b3c5e87230 | ||
|
|
9b63443842 | ||
|
|
cd30c2d9b5 | ||
|
|
b612941aae | ||
|
|
20ee448f4e | ||
|
|
2bbca8ae38 | ||
|
|
fea50b51ae | ||
| 79d37118e0 | |||
|
|
7fd55ea8ba | ||
|
|
c96fbef5d5 | ||
|
|
7423e2ca14 | ||
|
|
bf600f8c5f | ||
|
|
996ea65293 | ||
|
|
9866dd5f23 | ||
|
|
d9c8816647 | ||
|
|
b32c97c02b | ||
|
|
552f02d25c | ||
|
|
9f9968abab | ||
|
|
69a3eb192f | ||
|
|
488a32f319 | ||
|
|
bf57fd139b | ||
|
|
581d53a33e | ||
|
|
f4dd2b3415 | ||
|
|
7532cc9d59 | ||
|
|
e7590d72fd | ||
|
|
57ce1db248 | ||
|
|
c97d730a00 | ||
|
|
581c4f9ad9 | ||
|
|
ef6bc4be21 | ||
|
|
8534bb8839 | ||
|
|
a5bc7cf6d1 | ||
|
|
5d2eff4f73 | ||
|
|
9a4a4dc1af | ||
|
|
f3241e904f | ||
|
|
5de792744e | ||
|
|
0a5f4a03b5 | ||
|
|
4ac11551c9 | ||
|
|
6fea5f2c5b | ||
|
|
b7cac68ee1 | ||
|
|
cdbe330c47 | ||
| 53e9073dca | |||
| b8c316727e | |||
|
|
48455cd559 | ||
| aa3d9f375b | |||
|
|
e54d20bcb7 | ||
|
|
81f85aa82d | ||
| 2887fe9599 | |||
| b1679b110c | |||
| e7835e1100 | |||
| ed65b87af2 | |||
| 4a99e6cf6b | |||
| 4d9a9ff851 | |||
| 292a38fe30 |
11
.gitea/sanitize-branch.sh
Normal file
11
.gitea/sanitize-branch.sh
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Shared branch slug sanitization for CI jobs.
|
||||||
|
# Strips prefix (feature/, fix/, etc.), lowercases, replaces non-alphanum, truncates to 20 chars.
|
||||||
|
sanitize_branch() {
|
||||||
|
echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \
|
||||||
|
| tr '[:upper:]' '[:lower:]' \
|
||||||
|
| sed 's/[^a-z0-9-]/-/g' \
|
||||||
|
| sed 's/--*/-/g; s/^-//; s/-$//' \
|
||||||
|
| cut -c1-20 \
|
||||||
|
| sed 's/-$//'
|
||||||
|
}
|
||||||
@@ -14,16 +14,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'delete'
|
if: github.event_name != 'delete'
|
||||||
container:
|
container:
|
||||||
image: maven:3.9-eclipse-temurin-17
|
image: gitea.siegeln.net/cameleer/cameleer-build:1
|
||||||
|
credentials:
|
||||||
|
username: cameleer
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y ca-certificates curl gnupg
|
|
||||||
mkdir -p /etc/apt/keyrings
|
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
|
|
||||||
apt-get update && apt-get install -y nodejs
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Configure Gitea Maven Registry
|
- name: Configure Gitea Maven Registry
|
||||||
@@ -53,22 +48,28 @@ jobs:
|
|||||||
- name: Build UI
|
- name: Build UI
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: |
|
run: |
|
||||||
|
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}' >> .npmrc
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
- name: Build and Test
|
- name: Build and Test
|
||||||
run: mvn clean verify -DskipITs --batch-mode
|
run: mvn clean verify -DskipITs -U --batch-mode
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
container:
|
container:
|
||||||
image: docker:27
|
image: gitea.siegeln.net/cameleer/cameleer-docker-builder:1
|
||||||
|
credentials:
|
||||||
|
username: cameleer
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git
|
|
||||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
|
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
@@ -78,14 +79,7 @@ jobs:
|
|||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
- name: Compute branch slug
|
- name: Compute branch slug
|
||||||
run: |
|
run: |
|
||||||
sanitize_branch() {
|
. .gitea/sanitize-branch.sh
|
||||||
echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \
|
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
|
||||||
| sed 's/[^a-z0-9-]/-/g' \
|
|
||||||
| sed 's/--*/-/g; s/^-//; s/-$//' \
|
|
||||||
| cut -c1-20 \
|
|
||||||
| sed 's/-$//'
|
|
||||||
}
|
|
||||||
if [ "$GITHUB_REF_NAME" = "main" ]; then
|
if [ "$GITHUB_REF_NAME" = "main" ]; then
|
||||||
echo "BRANCH_SLUG=main" >> "$GITHUB_ENV"
|
echo "BRANCH_SLUG=main" >> "$GITHUB_ENV"
|
||||||
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
|
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
|
||||||
@@ -95,7 +89,7 @@ jobs:
|
|||||||
echo "IMAGE_TAGS=branch-$SLUG" >> "$GITHUB_ENV"
|
echo "IMAGE_TAGS=branch-$SLUG" >> "$GITHUB_ENV"
|
||||||
fi
|
fi
|
||||||
- name: Set up QEMU for cross-platform builds
|
- name: Set up QEMU for cross-platform builds
|
||||||
run: docker run --rm --privileged tonistiigi/binfmt --install all
|
run: docker run --rm --privileged gitea.siegeln.net/cameleer/binfmt:1 --install all
|
||||||
- name: Build and push server
|
- name: Build and push server
|
||||||
run: |
|
run: |
|
||||||
docker buildx create --use --name cibuilder
|
docker buildx create --use --name cibuilder
|
||||||
@@ -118,9 +112,11 @@ jobs:
|
|||||||
for TAG in $IMAGE_TAGS; do
|
for TAG in $IMAGE_TAGS; do
|
||||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer3-server-ui:$TAG"
|
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer3-server-ui:$TAG"
|
||||||
done
|
done
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
-f ui/Dockerfile \
|
-f ui/Dockerfile \
|
||||||
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||||
|
--build-arg VITE_APP_VERSION="$SHORT_SHA" \
|
||||||
$TAGS \
|
$TAGS \
|
||||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
||||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
||||||
@@ -133,7 +129,6 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
- name: Cleanup old container images
|
- name: Cleanup old container images
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache curl jq
|
|
||||||
API="https://gitea.siegeln.net/api/v1"
|
API="https://gitea.siegeln.net/api/v1"
|
||||||
AUTH="Authorization: token ${REGISTRY_TOKEN}"
|
AUTH="Authorization: token ${REGISTRY_TOKEN}"
|
||||||
CURRENT_SHA="${{ github.sha }}"
|
CURRENT_SHA="${{ github.sha }}"
|
||||||
@@ -210,27 +205,28 @@ jobs:
|
|||||||
--from-literal=POSTGRES_DB="${POSTGRES_DB:-cameleer}" \
|
--from-literal=POSTGRES_DB="${POSTGRES_DB:-cameleer}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic opensearch-credentials \
|
kubectl create secret generic logto-credentials \
|
||||||
--namespace=cameleer \
|
--namespace=cameleer \
|
||||||
--from-literal=OPENSEARCH_USER="${OPENSEARCH_USER:-admin}" \
|
--from-literal=PG_USER="${LOGTO_PG_USER:-logto}" \
|
||||||
--from-literal=OPENSEARCH_PASSWORD="$OPENSEARCH_PASSWORD" \
|
--from-literal=PG_PASSWORD="${LOGTO_PG_PASSWORD}" \
|
||||||
|
--from-literal=ENDPOINT="${LOGTO_ENDPOINT}" \
|
||||||
|
--from-literal=ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic authentik-credentials \
|
kubectl create secret generic clickhouse-credentials \
|
||||||
--namespace=cameleer \
|
--namespace=cameleer \
|
||||||
--from-literal=PG_USER="${AUTHENTIK_PG_USER:-authentik}" \
|
--from-literal=CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" \
|
||||||
--from-literal=PG_PASSWORD="${AUTHENTIK_PG_PASSWORD}" \
|
--from-literal=CLICKHOUSE_PASSWORD="$CLICKHOUSE_PASSWORD" \
|
||||||
--from-literal=AUTHENTIK_SECRET_KEY="${AUTHENTIK_SECRET_KEY}" \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl apply -f deploy/postgres.yaml
|
kubectl apply -f deploy/postgres.yaml
|
||||||
kubectl -n cameleer rollout status statefulset/postgres --timeout=120s
|
kubectl -n cameleer rollout status statefulset/postgres --timeout=120s
|
||||||
|
|
||||||
kubectl apply -f deploy/opensearch.yaml
|
kubectl apply -f deploy/clickhouse.yaml
|
||||||
kubectl -n cameleer rollout status statefulset/opensearch --timeout=180s
|
kubectl -n cameleer rollout status statefulset/clickhouse --timeout=180s
|
||||||
|
|
||||||
kubectl apply -f deploy/authentik.yaml
|
kubectl apply -f deploy/logto.yaml
|
||||||
kubectl -n cameleer rollout status deployment/authentik-server --timeout=180s
|
kubectl -n cameleer rollout status deployment/logto --timeout=180s
|
||||||
|
|
||||||
kubectl apply -k deploy/overlays/main
|
kubectl apply -k deploy/overlays/main
|
||||||
kubectl -n cameleer set image deployment/cameleer3-server \
|
kubectl -n cameleer set image deployment/cameleer3-server \
|
||||||
@@ -249,11 +245,12 @@ jobs:
|
|||||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||||
OPENSEARCH_USER: ${{ secrets.OPENSEARCH_USER }}
|
LOGTO_PG_USER: ${{ secrets.LOGTO_PG_USER }}
|
||||||
OPENSEARCH_PASSWORD: ${{ secrets.OPENSEARCH_PASSWORD }}
|
LOGTO_PG_PASSWORD: ${{ secrets.LOGTO_PG_PASSWORD }}
|
||||||
AUTHENTIK_PG_USER: ${{ secrets.AUTHENTIK_PG_USER }}
|
LOGTO_ENDPOINT: ${{ secrets.LOGTO_ENDPOINT }}
|
||||||
AUTHENTIK_PG_PASSWORD: ${{ secrets.AUTHENTIK_PG_PASSWORD }}
|
LOGTO_ADMIN_ENDPOINT: ${{ secrets.LOGTO_ADMIN_ENDPOINT }}
|
||||||
AUTHENTIK_SECRET_KEY: ${{ secrets.AUTHENTIK_SECRET_KEY }}
|
CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
|
||||||
|
CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||||
|
|
||||||
deploy-feature:
|
deploy-feature:
|
||||||
needs: docker
|
needs: docker
|
||||||
@@ -275,14 +272,7 @@ jobs:
|
|||||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
||||||
- name: Compute branch variables
|
- name: Compute branch variables
|
||||||
run: |
|
run: |
|
||||||
sanitize_branch() {
|
. .gitea/sanitize-branch.sh
|
||||||
echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \
|
|
||||||
| tr '[:upper:]' '[:lower:]' \
|
|
||||||
| sed 's/[^a-z0-9-]/-/g' \
|
|
||||||
| sed 's/--*/-/g; s/^-//; s/-$//' \
|
|
||||||
| cut -c1-20 \
|
|
||||||
| sed 's/-$//'
|
|
||||||
}
|
|
||||||
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
|
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
|
||||||
NS="cam-${SLUG}"
|
NS="cam-${SLUG}"
|
||||||
SCHEMA="cam_$(echo $SLUG | tr '-' '_')"
|
SCHEMA="cam_$(echo $SLUG | tr '-' '_')"
|
||||||
@@ -293,7 +283,7 @@ jobs:
|
|||||||
run: kubectl create namespace "$BRANCH_NS" --dry-run=client -o yaml | kubectl apply -f -
|
run: kubectl create namespace "$BRANCH_NS" --dry-run=client -o yaml | kubectl apply -f -
|
||||||
- name: Copy secrets from cameleer namespace
|
- name: Copy secrets from cameleer namespace
|
||||||
run: |
|
run: |
|
||||||
for SECRET in gitea-registry postgres-credentials opensearch-credentials cameleer-auth; do
|
for SECRET in gitea-registry postgres-credentials clickhouse-credentials cameleer-auth; do
|
||||||
kubectl get secret "$SECRET" -n cameleer -o json \
|
kubectl get secret "$SECRET" -n cameleer -o json \
|
||||||
| jq 'del(.metadata.namespace, .metadata.resourceVersion, .metadata.uid, .metadata.creationTimestamp, .metadata.managedFields)' \
|
| jq 'del(.metadata.namespace, .metadata.resourceVersion, .metadata.uid, .metadata.creationTimestamp, .metadata.managedFields)' \
|
||||||
| kubectl apply -n "$BRANCH_NS" -f -
|
| kubectl apply -n "$BRANCH_NS" -f -
|
||||||
@@ -373,15 +363,6 @@ jobs:
|
|||||||
kubectl wait --for=condition=Ready pod/cleanup-schema-${BRANCH_SLUG} -n cameleer --timeout=30s || true
|
kubectl wait --for=condition=Ready pod/cleanup-schema-${BRANCH_SLUG} -n cameleer --timeout=30s || true
|
||||||
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/cleanup-schema-${BRANCH_SLUG} -n cameleer --timeout=60s || true
|
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/cleanup-schema-${BRANCH_SLUG} -n cameleer --timeout=60s || true
|
||||||
kubectl delete pod cleanup-schema-${BRANCH_SLUG} -n cameleer --ignore-not-found
|
kubectl delete pod cleanup-schema-${BRANCH_SLUG} -n cameleer --ignore-not-found
|
||||||
- name: Delete OpenSearch indices
|
|
||||||
run: |
|
|
||||||
kubectl run cleanup-indices-${BRANCH_SLUG} \
|
|
||||||
--namespace=cameleer \
|
|
||||||
--image=curlimages/curl:latest \
|
|
||||||
--restart=Never \
|
|
||||||
--command -- curl -sf -X DELETE "http://opensearch:9200/cam-${BRANCH_SLUG}-*"
|
|
||||||
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/cleanup-indices-${BRANCH_SLUG} -n cameleer --timeout=60s || true
|
|
||||||
kubectl delete pod cleanup-indices-${BRANCH_SLUG} -n cameleer --ignore-not-found
|
|
||||||
- name: Cleanup Docker images
|
- name: Cleanup Docker images
|
||||||
run: |
|
run: |
|
||||||
API="https://gitea.siegeln.net/api/v1"
|
API="https://gitea.siegeln.net/api/v1"
|
||||||
|
|||||||
63
.gitea/workflows/sonarqube.yml
Normal file
63
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: SonarQube
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea.siegeln.net/cameleer/cameleer-build:1
|
||||||
|
credentials:
|
||||||
|
username: cameleer
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure Gitea Maven Registry
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.m2
|
||||||
|
cat > ~/.m2/settings.xml << 'SETTINGS'
|
||||||
|
<settings>
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>gitea</id>
|
||||||
|
<username>cameleer</username>
|
||||||
|
<password>${env.REGISTRY_TOKEN}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
</settings>
|
||||||
|
SETTINGS
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Cache Maven dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.m2/repository
|
||||||
|
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||||
|
restore-keys: ${{ runner.os }}-maven-
|
||||||
|
|
||||||
|
- name: Install UI dependencies
|
||||||
|
working-directory: ui
|
||||||
|
run: |
|
||||||
|
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}' >> .npmrc
|
||||||
|
npm ci
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Lint UI
|
||||||
|
working-directory: ui
|
||||||
|
run: npm run lint -- --format json --output-file eslint-report.json || true
|
||||||
|
|
||||||
|
- name: Build, Test and Analyze
|
||||||
|
run: |
|
||||||
|
mvn clean verify sonar:sonar -DskipITs -U --batch-mode \
|
||||||
|
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }} \
|
||||||
|
-Dsonar.token=${{ secrets.SONAR_TOKEN }} \
|
||||||
|
-Dsonar.projectKey=cameleer3-server \
|
||||||
|
-Dsonar.projectName="Cameleer3 Server"
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ logs/
|
|||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
.worktrees/
|
||||||
|
.gitnexus
|
||||||
|
|||||||
1
.superpowers/brainstorm/10188-1774613058/.server-stopped
Normal file
1
.superpowers/brainstorm/10188-1774613058/.server-stopped
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1774616238650}
|
||||||
1
.superpowers/brainstorm/10188-1774613058/.server.pid
Normal file
1
.superpowers/brainstorm/10188-1774613058/.server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
10188
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<h2>ProcessDiagram Component Hierarchy</h2>
|
||||||
|
<p class="subtitle">How the SVG rendering is structured — from data fetch to pixels</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Component Tree</div>
|
||||||
|
<div class="mockup-body" style="padding: 20px; font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1.8; color: #1A1612;">
|
||||||
|
<div><strong style="color: #1A7F8E;">ProcessDiagram</strong> — root, fetches layout, manages state</div>
|
||||||
|
<div style="padding-left: 24px; border-left: 2px solid #E4DFD8;">
|
||||||
|
<div><svg> container with viewBox (zoom/pan transforms)</div>
|
||||||
|
<div style="padding-left: 24px; border-left: 2px solid #E4DFD8;">
|
||||||
|
<div><strong style="color: #7C3AED;">DiagramSection</strong> label="Main Route"</div>
|
||||||
|
<div style="padding-left: 24px; border-left: 2px solid #E4DFD8;">
|
||||||
|
<div><strong style="color: #9C9184;"><g></strong> edges layer (rendered first, behind nodes)</div>
|
||||||
|
<div style="padding-left: 24px;">
|
||||||
|
<div><strong style="color: #C6820E;">DiagramEdge</strong> × N — SVG <path> with arrowhead</div>
|
||||||
|
</div>
|
||||||
|
<div><strong style="color: #9C9184;"><g></strong> nodes layer</div>
|
||||||
|
<div style="padding-left: 24px;">
|
||||||
|
<div><strong style="color: #C6820E;">DiagramNode</strong> × N — top-bar card</div>
|
||||||
|
<div style="padding-left: 24px; border-left: 2px solid #E4DFD8;">
|
||||||
|
<div><strong style="color: #3D7C47;">ConfigBadge</strong> × 0..N — tap/trace indicators</div>
|
||||||
|
<div><strong style="color: #3D7C47;">NodeToolbar</strong> — floating on hover</div>
|
||||||
|
</div>
|
||||||
|
<div><strong style="color: #C6820E;">CompoundNode</strong> × 0..N — choice/split container</div>
|
||||||
|
<div style="padding-left: 24px; border-left: 2px solid #E4DFD8;">
|
||||||
|
<div><strong style="color: #C6820E;">DiagramNode</strong> × N — children inside compound</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 8px;"><strong style="color: #C0392B;">DiagramSection</strong> label="onException" variant="error"</div>
|
||||||
|
<div style="padding-left: 24px; border-left: 2px solid #C0392B;">
|
||||||
|
<div><em style="color: #9C9184;">same edge + node structure as above</em></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 8px;"><strong style="color: #1A7F8E;">ZoomControls</strong> — HTML overlay (not SVG)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top: 24px;">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">SVG Structure (simplified)</div>
|
||||||
|
<div class="mockup-body" style="padding: 20px; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.7; color: #5C5347; background: #F5F2ED;">
|
||||||
|
<pre style="margin: 0;"><div class="process-diagram"> <span style="color:#9C9184">/* wrapper div */</span>
|
||||||
|
<svg viewBox="0 0 {w} {h}"> <span style="color:#9C9184">/* zoom = viewBox transform */</span>
|
||||||
|
<g class="diagram-content"> <span style="color:#9C9184">/* pan offset */</span>
|
||||||
|
|
||||||
|
<span style="color:#7C3AED"><!-- Main Route section --></span>
|
||||||
|
<g class="section section--main">
|
||||||
|
<g class="edges">
|
||||||
|
<path d="M 100 40 C ..." /> <span style="color:#9C9184">/* cubic bezier edge */</span>
|
||||||
|
<marker>...</marker> <span style="color:#9C9184">/* arrowhead def */</span>
|
||||||
|
</g>
|
||||||
|
<g class="nodes">
|
||||||
|
<g transform="translate(x, y)"> <span style="color:#9C9184">/* positioned by ELK */</span>
|
||||||
|
<rect .../> <span style="color:#9C9184">/* card background */</span>
|
||||||
|
<rect .../> <span style="color:#9C9184">/* color top bar */</span>
|
||||||
|
<text>LOG</text> <span style="color:#9C9184">/* label */</span>
|
||||||
|
<g class="badges">...</g> <span style="color:#9C9184">/* config indicators */</span>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<span style="color:#C0392B"><!-- Error Handler section --></span>
|
||||||
|
<g class="section section--error"
|
||||||
|
transform="translate(0, {mainH + gap})">
|
||||||
|
<text>onException</text> <span style="color:#9C9184">/* section label */</span>
|
||||||
|
<line .../> <span style="color:#9C9184">/* divider line */</span>
|
||||||
|
<g class="edges">...</g>
|
||||||
|
<g class="nodes">...</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="zoom-controls">...</div> <span style="color:#9C9184">/* HTML overlay */</span>
|
||||||
|
</div></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top: 24px;">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Data Flow</div>
|
||||||
|
<div class="mockup-body" style="padding: 20px; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.8; color: #5C5347;">
|
||||||
|
<pre style="margin: 0;">
|
||||||
|
<span style="color:#1A7F8E">GET /diagrams/{hash}/render?direction=LR</span>
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DiagramLayout { nodes[], edges[], width, height }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
<span style="color:#7C3AED">separateFlows(nodes)</span> → mainNodes[] + errorSections[]
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
<span style="color:#C6820E">renderMainSection()</span> <span style="color:#C0392B">renderErrorSection()</span>
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
SVG groups with SVG groups offset below
|
||||||
|
ELK x/y coordinates main section by mainHeight + gap
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<h2>Node Interactions & Config Badges</h2>
|
||||||
|
<p class="subtitle">Hover toolbar, selection states, and active config indicators</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Node States</div>
|
||||||
|
<div class="mockup-body" style="padding: 24px; background: #F5F2ED;">
|
||||||
|
<svg width="100%" height="340" viewBox="0 0 520 340">
|
||||||
|
|
||||||
|
<!-- 1. Normal state -->
|
||||||
|
<text x="10" y="16" fill="#9C9184" font-size="11" font-weight="600">NORMAL</text>
|
||||||
|
<g transform="translate(10, 24)">
|
||||||
|
<rect x="0" y="0" width="200" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="200" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="192" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="28" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 2. Hovered state with toolbar -->
|
||||||
|
<text x="270" y="16" fill="#9C9184" font-size="11" font-weight="600">HOVERED (toolbar appears)</text>
|
||||||
|
<g transform="translate(270, 24)">
|
||||||
|
<rect x="0" y="0" width="200" height="56" rx="4" fill="#FFFCF5" stroke="#C6820E" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="200" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="192" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="28" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<!-- Floating toolbar -->
|
||||||
|
<g transform="translate(30, -32)">
|
||||||
|
<rect x="0" y="0" width="140" height="28" rx="6" fill="#1A1612" opacity="0.92"/>
|
||||||
|
<!-- Icons as circles -->
|
||||||
|
<g transform="translate(10, 4)">
|
||||||
|
<circle cx="10" cy="10" r="9" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<text x="10" y="14" fill="white" font-size="11" text-anchor="middle">🔍</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(40, 4)">
|
||||||
|
<circle cx="10" cy="10" r="9" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<text x="10" y="14" fill="white" font-size="11" text-anchor="middle">T</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(70, 4)">
|
||||||
|
<circle cx="10" cy="10" r="9" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<text x="10" y="14" fill="white" font-size="11" text-anchor="middle">✎</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(100, 4)">
|
||||||
|
<circle cx="10" cy="10" r="9" fill="rgba(255,255,255,0.15)"/>
|
||||||
|
<text x="10" y="14" fill="white" font-size="11" text-anchor="middle">⋯</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 3. Selected state -->
|
||||||
|
<text x="10" y="112" fill="#9C9184" font-size="11" font-weight="600">SELECTED (click)</text>
|
||||||
|
<g transform="translate(10, 120)">
|
||||||
|
<rect x="-2" y="-2" width="204" height="60" rx="6" fill="none" stroke="#C6820E" stroke-width="2.5" stroke-dasharray="none"/>
|
||||||
|
<rect x="0" y="0" width="200" height="56" rx="4" fill="white" stroke="#C6820E" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="200" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="192" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="28" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 4. With config badges -->
|
||||||
|
<text x="270" y="112" fill="#9C9184" font-size="11" font-weight="600">WITH CONFIG BADGES</text>
|
||||||
|
<g transform="translate(270, 120)">
|
||||||
|
<rect x="0" y="0" width="200" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="200" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="192" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="28" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<!-- Trace badge (top-right corner) -->
|
||||||
|
<g transform="translate(165, -6)">
|
||||||
|
<rect x="0" y="0" width="38" height="16" rx="8" fill="#1A7F8E"/>
|
||||||
|
<text x="19" y="12" fill="white" font-size="8" font-weight="600" text-anchor="middle">TRACE</text>
|
||||||
|
</g>
|
||||||
|
<!-- Tap badge -->
|
||||||
|
<g transform="translate(124, -6)">
|
||||||
|
<rect x="0" y="0" width="36" height="16" rx="8" fill="#7C3AED"/>
|
||||||
|
<text x="18" y="12" fill="white" font-size="8" font-weight="600" text-anchor="middle">TAP</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 5. Error node style -->
|
||||||
|
<text x="10" y="210" fill="#9C9184" font-size="11" font-weight="600">ERROR HANDLER NODE</text>
|
||||||
|
<g transform="translate(10, 218)">
|
||||||
|
<rect x="0" y="0" width="200" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="200" height="6" rx="4" fill="#C0392B"/>
|
||||||
|
<rect x="4" y="0" width="192" height="6" fill="#C0392B"/>
|
||||||
|
<text x="16" y="32" fill="#C0392B" font-size="14">⚠</text>
|
||||||
|
<text x="36" y="28" fill="#1A1612" font-size="11" font-weight="600">ON_EXCEPTION</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">java.lang.Exception</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 6. Compound node (Choice) -->
|
||||||
|
<text x="270" y="210" fill="#9C9184" font-size="11" font-weight="600">COMPOUND NODE (CHOICE)</text>
|
||||||
|
<g transform="translate(270, 218)">
|
||||||
|
<rect x="0" y="0" width="220" height="110" rx="4" fill="white" stroke="#7C3AED" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="220" height="22" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="4" y="4" width="212" height="18" fill="#7C3AED"/>
|
||||||
|
<text x="110" y="16" fill="white" font-size="10" font-weight="600" text-anchor="middle">◆ CHOICE</text>
|
||||||
|
<!-- Children -->
|
||||||
|
<g transform="translate(10, 30)">
|
||||||
|
<rect x="0" y="0" width="200" height="32" rx="3" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="200" height="4" rx="3" fill="#7C3AED"/>
|
||||||
|
<rect x="3" y="0" width="194" height="4" fill="#7C3AED"/>
|
||||||
|
<text x="12" y="22" fill="#7C3AED" font-size="10">◆</text>
|
||||||
|
<text x="28" y="22" fill="#1A1612" font-size="10" font-weight="600">WHEN</text>
|
||||||
|
<text x="66" y="22" fill="#5C5347" font-size="9">type == 'A'</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(10, 70)">
|
||||||
|
<rect x="0" y="0" width="200" height="32" rx="3" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="200" height="4" rx="3" fill="#7C3AED"/>
|
||||||
|
<rect x="3" y="0" width="194" height="4" fill="#7C3AED"/>
|
||||||
|
<text x="12" y="22" fill="#7C3AED" font-size="10">◆</text>
|
||||||
|
<text x="28" y="22" fill="#1A1612" font-size="10" font-weight="600">OTHERWISE</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top: 24px;">
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Toolbar Actions</div>
|
||||||
|
<div class="mockup-body" style="padding: 16px;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 2px solid #E4DFD8;">
|
||||||
|
<th style="text-align: left; padding: 8px; color: #5C5347;">Icon</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #5C5347;">Action</th>
|
||||||
|
<th style="text-align: left; padding: 8px; color: #5C5347;">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom: 1px solid #EDE9E3;">
|
||||||
|
<td style="padding: 8px;">🔍</td>
|
||||||
|
<td style="padding: 8px; font-weight: 600;">Inspect</td>
|
||||||
|
<td style="padding: 8px; color: #5C5347;">Select node & open detail side-panel</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #EDE9E3;">
|
||||||
|
<td style="padding: 8px;">T</td>
|
||||||
|
<td style="padding: 8px; font-weight: 600;">Toggle Trace</td>
|
||||||
|
<td style="padding: 8px; color: #5C5347;">Enable/disable capture of input+output for this processor</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #EDE9E3;">
|
||||||
|
<td style="padding: 8px;">✎</td>
|
||||||
|
<td style="padding: 8px; font-weight: 600;">Configure Tap</td>
|
||||||
|
<td style="padding: 8px; color: #5C5347;">Open tap expression editor for this processor</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px;">⋯</td>
|
||||||
|
<td style="padding: 8px; font-weight: 600;">More</td>
|
||||||
|
<td style="padding: 8px; color: #5C5347;">Copy processor ID, jump to code, view in search</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
119
.superpowers/brainstorm/10188-1774613058/node-interactions.html
Normal file
119
.superpowers/brainstorm/10188-1774613058/node-interactions.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<h2>Node Interaction Model</h2>
|
||||||
|
<p class="subtitle">What happens when you interact with a processor node on the diagram?</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<!-- Option A: Click-to-select + context menu -->
|
||||||
|
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding: 24px; background: #F5F2ED;">
|
||||||
|
<svg width="100%" height="180" viewBox="0 0 420 180">
|
||||||
|
<!-- Normal node -->
|
||||||
|
<g transform="translate(10, 10)">
|
||||||
|
<rect x="0" y="0" width="180" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="180" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="172" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="30" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<text x="96" y="72" fill="#9C9184" font-size="10" text-anchor="middle" font-style="italic">normal state</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Selected node (amber ring) -->
|
||||||
|
<g transform="translate(10, 100)">
|
||||||
|
<rect x="-2" y="-2" width="184" height="60" rx="6" fill="none" stroke="#C6820E" stroke-width="2.5"/>
|
||||||
|
<rect x="0" y="0" width="180" height="56" rx="4" fill="white" stroke="#C6820E" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="180" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="172" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="30" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<text x="96" y="72" fill="#C6820E" font-size="10" text-anchor="middle" font-weight="600">click = select</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Context menu on right-click -->
|
||||||
|
<g transform="translate(220, 10)">
|
||||||
|
<rect x="0" y="0" width="180" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="180" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="172" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="30" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<!-- Context menu -->
|
||||||
|
<g transform="translate(100, 40)">
|
||||||
|
<rect x="0" y="0" width="140" height="96" rx="6" fill="white" stroke="#E4DFD8" stroke-width="1" filter="url(#shadow)"/>
|
||||||
|
<text x="12" y="20" fill="#1A1612" font-size="11">🔍 View Snapshot</text>
|
||||||
|
<line x1="8" y1="28" x2="132" y2="28" stroke="#EDE9E3" stroke-width="1"/>
|
||||||
|
<text x="12" y="44" fill="#1A7F8E" font-size="11">⚙ Enable Tracing</text>
|
||||||
|
<text x="12" y="64" fill="#1A7F8E" font-size="11">📌 Set Tap</text>
|
||||||
|
<line x1="8" y1="72" x2="132" y2="72" stroke="#EDE9E3" stroke-width="1"/>
|
||||||
|
<text x="12" y="88" fill="#5C5347" font-size="11">📋 Copy Processor ID</text>
|
||||||
|
</g>
|
||||||
|
<text x="90" y="152" fill="#9C9184" font-size="10" text-anchor="middle" font-style="italic">right-click = context menu</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-4" y="-2" width="148" height="104">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-opacity="0.12"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A: Click-Select + Right-Click Menu</h3>
|
||||||
|
<p>Click to select a node (amber highlight ring). Right-click for context menu with tracing/tap/snapshot actions. Clean separation of concerns. Standard desktop UX.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option B: Hover toolbar -->
|
||||||
|
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding: 24px; background: #F5F2ED;">
|
||||||
|
<svg width="100%" height="180" viewBox="0 0 420 180">
|
||||||
|
<!-- Normal node -->
|
||||||
|
<g transform="translate(10, 10)">
|
||||||
|
<rect x="0" y="0" width="180" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="180" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="172" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="30" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<text x="96" y="72" fill="#9C9184" font-size="10" text-anchor="middle" font-style="italic">normal state</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Hovered node with floating toolbar -->
|
||||||
|
<g transform="translate(10, 100)">
|
||||||
|
<rect x="0" y="0" width="180" height="56" rx="4" fill="#FFFCF5" stroke="#C6820E" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="180" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="172" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="30" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<!-- Floating toolbar above -->
|
||||||
|
<g transform="translate(20, -30)">
|
||||||
|
<rect x="0" y="0" width="140" height="26" rx="13" fill="#1A1612" opacity="0.9"/>
|
||||||
|
<text x="18" y="17" fill="white" font-size="12" title="View">🔍</text>
|
||||||
|
<text x="46" y="17" fill="white" font-size="12" title="Trace">⚙</text>
|
||||||
|
<text x="74" y="17" fill="white" font-size="12" title="Tap">📌</text>
|
||||||
|
<text x="102" y="17" fill="white" font-size="12" title="Copy">📋</text>
|
||||||
|
<text x="124" y="17" fill="white" font-size="12" title="More">⋯</text>
|
||||||
|
</g>
|
||||||
|
<text x="96" y="72" fill="#C6820E" font-size="10" text-anchor="middle" font-weight="600">hover = toolbar appears</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Click = select (same as A) -->
|
||||||
|
<g transform="translate(220, 50)">
|
||||||
|
<rect x="-2" y="-2" width="184" height="60" rx="6" fill="none" stroke="#C6820E" stroke-width="2.5"/>
|
||||||
|
<rect x="0" y="0" width="180" height="56" rx="4" fill="white" stroke="#C6820E" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="180" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="172" height="6" fill="#C6820E"/>
|
||||||
|
<text x="16" y="32" fill="#C6820E" font-size="14">⚙</text>
|
||||||
|
<text x="36" y="30" fill="#1A1612" font-size="11" font-weight="600">LOG</text>
|
||||||
|
<text x="36" y="44" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
<text x="96" y="72" fill="#C6820E" font-size="10" text-anchor="middle" font-weight="600">click = select</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B: Hover Floating Toolbar</h3>
|
||||||
|
<p>Hover reveals a dark floating icon toolbar above the node. Click still selects. More discoverable than right-click, but can feel cluttered on dense diagrams.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
208
.superpowers/brainstorm/10188-1774613058/node-style.html
Normal file
208
.superpowers/brainstorm/10188-1774613058/node-style.html
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<h2>Node Visual Style</h2>
|
||||||
|
<p class="subtitle">Which processor node style fits our design system best? Think MuleSoft / TIBCO BW5 but adapted to our warm parchment theme.</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<!-- Option A: Icon-first blocks (MuleSoft-inspired) -->
|
||||||
|
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding: 24px; background: #F5F2ED;">
|
||||||
|
<svg width="100%" height="220" viewBox="0 0 400 220">
|
||||||
|
<!-- FROM node -->
|
||||||
|
<g transform="translate(20, 10)">
|
||||||
|
<rect x="0" y="0" width="160" height="56" rx="8" fill="#1A7F8E" opacity="0.12" stroke="#1A7F8E" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="42" height="56" rx="8" fill="#1A7F8E"/>
|
||||||
|
<rect x="8" y="0" width="34" height="56" fill="#1A7F8E"/>
|
||||||
|
<text x="21" y="34" fill="white" font-size="20" text-anchor="middle">▶</text>
|
||||||
|
<text x="100" y="25" fill="#1A1612" font-size="12" font-weight="600" text-anchor="middle">FROM</text>
|
||||||
|
<text x="100" y="42" fill="#5C5347" font-size="11" text-anchor="middle">direct:orders</text>
|
||||||
|
</g>
|
||||||
|
<!-- Connector -->
|
||||||
|
<line x1="100" y1="66" x2="100" y2="86" stroke="#9C9184" stroke-width="1.5"/>
|
||||||
|
<polygon points="95,82 100,90 105,82" fill="#9C9184"/>
|
||||||
|
<!-- PROCESS node -->
|
||||||
|
<g transform="translate(20, 90)">
|
||||||
|
<rect x="0" y="0" width="160" height="56" rx="8" fill="#C6820E" opacity="0.12" stroke="#C6820E" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="42" height="56" rx="8" fill="#C6820E"/>
|
||||||
|
<rect x="8" y="0" width="34" height="56" fill="#C6820E"/>
|
||||||
|
<text x="21" y="34" fill="white" font-size="18" text-anchor="middle">⚙</text>
|
||||||
|
<text x="100" y="25" fill="#1A1612" font-size="12" font-weight="600" text-anchor="middle">LOG</text>
|
||||||
|
<text x="100" y="42" fill="#5C5347" font-size="11" text-anchor="middle">Processing order</text>
|
||||||
|
</g>
|
||||||
|
<!-- Connector -->
|
||||||
|
<line x1="100" y1="146" x2="100" y2="166" stroke="#9C9184" stroke-width="1.5"/>
|
||||||
|
<polygon points="95,162 100,170 105,162" fill="#9C9184"/>
|
||||||
|
<!-- TO node -->
|
||||||
|
<g transform="translate(20, 170)">
|
||||||
|
<rect x="0" y="0" width="160" height="56" rx="8" fill="#3D7C47" opacity="0.12" stroke="#3D7C47" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="42" height="56" rx="8" fill="#3D7C47"/>
|
||||||
|
<rect x="8" y="0" width="34" height="56" fill="#3D7C47"/>
|
||||||
|
<text x="21" y="34" fill="white" font-size="18" text-anchor="middle">◼</text>
|
||||||
|
<text x="100" y="25" fill="#1A1612" font-size="12" font-weight="600" text-anchor="middle">TO</text>
|
||||||
|
<text x="100" y="42" fill="#5C5347" font-size="11" text-anchor="middle">kafka:processed</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CHOICE compound on the right -->
|
||||||
|
<g transform="translate(210, 10)">
|
||||||
|
<rect x="0" y="0" width="180" height="210" rx="10" fill="#7C3AED" opacity="0.06" stroke="#7C3AED" stroke-width="1.5" stroke-dasharray="4 2"/>
|
||||||
|
<text x="10" y="20" fill="#7C3AED" font-size="11" font-weight="600">CHOICE</text>
|
||||||
|
<!-- When child -->
|
||||||
|
<g transform="translate(10, 30)">
|
||||||
|
<rect x="0" y="0" width="160" height="48" rx="6" fill="#7C3AED" opacity="0.12" stroke="#7C3AED" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="36" height="48" rx="6" fill="#7C3AED"/>
|
||||||
|
<rect x="6" y="0" width="30" height="48" fill="#7C3AED"/>
|
||||||
|
<text x="18" y="30" fill="white" font-size="14" text-anchor="middle">◆</text>
|
||||||
|
<text x="96" y="20" fill="#1A1612" font-size="11" font-weight="600" text-anchor="middle">WHEN</text>
|
||||||
|
<text x="96" y="36" fill="#5C5347" font-size="10" text-anchor="middle">header.type == 'A'</text>
|
||||||
|
</g>
|
||||||
|
<!-- Otherwise child -->
|
||||||
|
<g transform="translate(10, 88)">
|
||||||
|
<rect x="0" y="0" width="160" height="48" rx="6" fill="#7C3AED" opacity="0.12" stroke="#7C3AED" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="36" height="48" rx="6" fill="#7C3AED"/>
|
||||||
|
<rect x="6" y="0" width="30" height="48" fill="#7C3AED"/>
|
||||||
|
<text x="18" y="30" fill="white" font-size="14" text-anchor="middle">◆</text>
|
||||||
|
<text x="96" y="20" fill="#1A1612" font-size="11" font-weight="600" text-anchor="middle">OTHERWISE</text>
|
||||||
|
<text x="96" y="36" fill="#5C5347" font-size="10" text-anchor="middle">default branch</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A: Icon Sidebar Blocks</h3>
|
||||||
|
<p>MuleSoft-style: colored icon strip on the left, label + detail on the right. Color encodes node type. Compound nodes (choice, split) use dashed containers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option B: Rounded pill with centered icon -->
|
||||||
|
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding: 24px; background: #F5F2ED;">
|
||||||
|
<svg width="100%" height="220" viewBox="0 0 400 220">
|
||||||
|
<!-- FROM node -->
|
||||||
|
<g transform="translate(20, 10)">
|
||||||
|
<rect x="0" y="0" width="160" height="50" rx="25" fill="#1A7F8E" opacity="0.15" stroke="#1A7F8E" stroke-width="1.5"/>
|
||||||
|
<circle cx="30" cy="25" r="16" fill="#1A7F8E"/>
|
||||||
|
<text x="30" y="31" fill="white" font-size="14" text-anchor="middle">▶</text>
|
||||||
|
<text x="98" y="22" fill="#1A1612" font-size="12" font-weight="600" text-anchor="middle">FROM</text>
|
||||||
|
<text x="98" y="38" fill="#5C5347" font-size="10" text-anchor="middle">direct:orders</text>
|
||||||
|
</g>
|
||||||
|
<!-- Connector -->
|
||||||
|
<line x1="100" y1="60" x2="100" y2="80" stroke="#9C9184" stroke-width="1.5"/>
|
||||||
|
<polygon points="95,76 100,84 105,76" fill="#9C9184"/>
|
||||||
|
<!-- PROCESS node -->
|
||||||
|
<g transform="translate(20, 84)">
|
||||||
|
<rect x="0" y="0" width="160" height="50" rx="25" fill="#C6820E" opacity="0.15" stroke="#C6820E" stroke-width="1.5"/>
|
||||||
|
<circle cx="30" cy="25" r="16" fill="#C6820E"/>
|
||||||
|
<text x="30" y="31" fill="white" font-size="14" text-anchor="middle">⚙</text>
|
||||||
|
<text x="98" y="22" fill="#1A1612" font-size="12" font-weight="600" text-anchor="middle">LOG</text>
|
||||||
|
<text x="98" y="38" fill="#5C5347" font-size="10" text-anchor="middle">Processing order</text>
|
||||||
|
</g>
|
||||||
|
<!-- Connector -->
|
||||||
|
<line x1="100" y1="134" x2="100" y2="154" stroke="#9C9184" stroke-width="1.5"/>
|
||||||
|
<polygon points="95,150 100,158 105,150" fill="#9C9184"/>
|
||||||
|
<!-- TO node -->
|
||||||
|
<g transform="translate(20, 158)">
|
||||||
|
<rect x="0" y="0" width="160" height="50" rx="25" fill="#3D7C47" opacity="0.15" stroke="#3D7C47" stroke-width="1.5"/>
|
||||||
|
<circle cx="30" cy="25" r="16" fill="#3D7C47"/>
|
||||||
|
<text x="30" y="31" fill="white" font-size="14" text-anchor="middle">◼</text>
|
||||||
|
<text x="98" y="22" fill="#1A1612" font-size="12" font-weight="600" text-anchor="middle">TO</text>
|
||||||
|
<text x="98" y="38" fill="#5C5347" font-size="10" text-anchor="middle">kafka:processed</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CHOICE compound on the right -->
|
||||||
|
<g transform="translate(210, 10)">
|
||||||
|
<rect x="0" y="0" width="180" height="200" rx="12" fill="#7C3AED" opacity="0.06" stroke="#7C3AED" stroke-width="1.5" stroke-dasharray="5 3"/>
|
||||||
|
<text x="90" y="20" fill="#7C3AED" font-size="11" font-weight="600" text-anchor="middle">CHOICE</text>
|
||||||
|
<!-- When child -->
|
||||||
|
<g transform="translate(10, 30)">
|
||||||
|
<rect x="0" y="0" width="160" height="44" rx="22" fill="#7C3AED" opacity="0.15" stroke="#7C3AED" stroke-width="1"/>
|
||||||
|
<circle cx="26" cy="22" r="14" fill="#7C3AED"/>
|
||||||
|
<text x="26" y="28" fill="white" font-size="12" text-anchor="middle">◆</text>
|
||||||
|
<text x="96" y="18" fill="#1A1612" font-size="11" font-weight="600" text-anchor="middle">WHEN</text>
|
||||||
|
<text x="96" y="34" fill="#5C5347" font-size="10" text-anchor="middle">type == 'A'</text>
|
||||||
|
</g>
|
||||||
|
<!-- Otherwise child -->
|
||||||
|
<g transform="translate(10, 84)">
|
||||||
|
<rect x="0" y="0" width="160" height="44" rx="22" fill="#7C3AED" opacity="0.15" stroke="#7C3AED" stroke-width="1"/>
|
||||||
|
<circle cx="26" cy="22" r="14" fill="#7C3AED"/>
|
||||||
|
<text x="26" y="28" fill="white" font-size="12" text-anchor="middle">◆</text>
|
||||||
|
<text x="96" y="18" fill="#1A1612" font-size="11" font-weight="600" text-anchor="middle">OTHERWISE</text>
|
||||||
|
<text x="96" y="34" fill="#5C5347" font-size="10" text-anchor="middle">default</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B: Rounded Pills</h3>
|
||||||
|
<p>Softer, more modern look with pill-shaped nodes and circular icons. Lighter feel. Compounds still use dashed containers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option C: TIBCO BW5 style - rectangular with top color bar -->
|
||||||
|
<div class="card" data-choice="c" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image" style="padding: 24px; background: #F5F2ED;">
|
||||||
|
<svg width="100%" height="220" viewBox="0 0 400 220">
|
||||||
|
<!-- FROM node -->
|
||||||
|
<g transform="translate(20, 10)">
|
||||||
|
<rect x="0" y="0" width="160" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="160" height="6" rx="4" fill="#1A7F8E"/>
|
||||||
|
<rect x="4" y="0" width="152" height="6" fill="#1A7F8E"/>
|
||||||
|
<text x="18" y="32" fill="#1A7F8E" font-size="16">▶</text>
|
||||||
|
<text x="40" y="30" fill="#1A1612" font-size="12" font-weight="600">FROM</text>
|
||||||
|
<text x="40" y="46" fill="#5C5347" font-size="10">direct:orders</text>
|
||||||
|
</g>
|
||||||
|
<!-- Connector -->
|
||||||
|
<line x1="100" y1="66" x2="100" y2="86" stroke="#9C9184" stroke-width="1.5"/>
|
||||||
|
<polygon points="95,82 100,90 105,82" fill="#9C9184"/>
|
||||||
|
<!-- PROCESS node -->
|
||||||
|
<g transform="translate(20, 90)">
|
||||||
|
<rect x="0" y="0" width="160" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="160" height="6" rx="4" fill="#C6820E"/>
|
||||||
|
<rect x="4" y="0" width="152" height="6" fill="#C6820E"/>
|
||||||
|
<text x="18" y="32" fill="#C6820E" font-size="16">⚙</text>
|
||||||
|
<text x="40" y="30" fill="#1A1612" font-size="12" font-weight="600">LOG</text>
|
||||||
|
<text x="40" y="46" fill="#5C5347" font-size="10">Processing order</text>
|
||||||
|
</g>
|
||||||
|
<!-- Connector -->
|
||||||
|
<line x1="100" y1="146" x2="100" y2="166" stroke="#9C9184" stroke-width="1.5"/>
|
||||||
|
<polygon points="95,162 100,170 105,162" fill="#9C9184"/>
|
||||||
|
<!-- TO node -->
|
||||||
|
<g transform="translate(20, 170)">
|
||||||
|
<rect x="0" y="0" width="160" height="56" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="160" height="6" rx="4" fill="#3D7C47"/>
|
||||||
|
<rect x="4" y="0" width="152" height="6" fill="#3D7C47"/>
|
||||||
|
<text x="18" y="32" fill="#3D7C47" font-size="16">◼</text>
|
||||||
|
<text x="40" y="30" fill="#1A1612" font-size="12" font-weight="600">TO</text>
|
||||||
|
<text x="40" y="46" fill="#5C5347" font-size="10">kafka:processed</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- CHOICE compound on the right -->
|
||||||
|
<g transform="translate(210, 10)">
|
||||||
|
<rect x="0" y="0" width="180" height="210" rx="4" fill="white" stroke="#7C3AED" stroke-width="1.5"/>
|
||||||
|
<rect x="0" y="0" width="180" height="22" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="4" y="4" width="172" height="18" fill="#7C3AED"/>
|
||||||
|
<text x="90" y="16" fill="white" font-size="11" font-weight="600" text-anchor="middle">CHOICE</text>
|
||||||
|
<!-- When child -->
|
||||||
|
<g transform="translate(10, 32)">
|
||||||
|
<rect x="0" y="0" width="160" height="48" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="160" height="5" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="4" y="0" width="152" height="5" fill="#7C3AED"/>
|
||||||
|
<text x="14" y="28" fill="#7C3AED" font-size="14">◆</text>
|
||||||
|
<text x="34" y="26" fill="#1A1612" font-size="11" font-weight="600">WHEN</text>
|
||||||
|
<text x="34" y="40" fill="#5C5347" font-size="10">type == 'A'</text>
|
||||||
|
</g>
|
||||||
|
<!-- Otherwise child -->
|
||||||
|
<g transform="translate(10, 90)">
|
||||||
|
<rect x="0" y="0" width="160" height="48" rx="4" fill="white" stroke="#E4DFD8" stroke-width="1"/>
|
||||||
|
<rect x="0" y="0" width="160" height="5" rx="4" fill="#7C3AED"/>
|
||||||
|
<rect x="4" y="0" width="152" height="5" fill="#7C3AED"/>
|
||||||
|
<text x="14" y="28" fill="#7C3AED" font-size="14">◆</text>
|
||||||
|
<text x="34" y="26" fill="#1A1612" font-size="11" font-weight="600">OTHERWISE</text>
|
||||||
|
<text x="34" y="40" fill="#5C5347" font-size="10">default</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>C: Top-Bar Cards</h3>
|
||||||
|
<p>TIBCO BW5-inspired: white cards with colored top accent bar. Clean, professional, card-like. Compound nodes get a full colored header bar with white title text.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
3
.superpowers/brainstorm/10188-1774613058/waiting.html
Normal file
3
.superpowers/brainstorm/10188-1774613058/waiting.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">Continuing in terminal...</p>
|
||||||
|
</div>
|
||||||
1
.superpowers/brainstorm/14618-1774629192/.server-stopped
Normal file
1
.superpowers/brainstorm/14618-1774629192/.server-stopped
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1774632733532}
|
||||||
1
.superpowers/brainstorm/14618-1774629192/.server.pid
Normal file
1
.superpowers/brainstorm/14618-1774629192/.server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
14618
|
||||||
287
.superpowers/brainstorm/14618-1774629192/detail-panel-tabs.html
Normal file
287
.superpowers/brainstorm/14618-1774629192/detail-panel-tabs.html
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
<h2>Detail Panel: Tab Designs</h2>
|
||||||
|
<p class="subtitle">Bottom panel content when a processor node is selected</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Info Tab — processor metadata + attributes</div>
|
||||||
|
<div class="mockup-body" style="background: #fff; padding: 0;">
|
||||||
|
<!-- Processor header -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 6px 14px; border-bottom: 1px solid #E4DFD8; background: #FAFAF8;">
|
||||||
|
<span style="font-size: 11px; font-weight: 600; color: #1A1612;">bean:validate</span>
|
||||||
|
<span style="font-size: 10px; color: #C0392B; background: #FDF2F0; padding: 1px 6px; border-radius: 8px;">FAILED</span>
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">processor-5</span>
|
||||||
|
</div>
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div style="display: flex; gap: 0; border-bottom: 1px solid #E4DFD8; background: #FAFAF8; padding: 0 14px;">
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C6820E; border-bottom: 2px solid #C6820E; font-weight: 600; cursor: pointer;">Info</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Headers</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Input</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Output</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C0392B; cursor: pointer;">Error</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Config</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Timeline</div>
|
||||||
|
</div>
|
||||||
|
<!-- Info content -->
|
||||||
|
<div style="padding: 12px 14px; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px 24px; font-size: 12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Processor ID</div>
|
||||||
|
<div style="color: #1A1612; font-family: monospace; font-size: 11px;">processor-5</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Type</div>
|
||||||
|
<div style="color: #1A1612;">BEAN</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Status</div>
|
||||||
|
<div style="color: #C0392B; font-weight: 500;">FAILED</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Start Time</div>
|
||||||
|
<div style="color: #1A1612;">14:32:05.123</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">End Time</div>
|
||||||
|
<div style="color: #1A1612;">14:32:05.243</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Duration</div>
|
||||||
|
<div style="color: #1A1612; font-weight: 500;">120ms</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Endpoint URI</div>
|
||||||
|
<div style="color: #1A1612; font-family: monospace; font-size: 11px;">bean:orderValidator?method=validate</div>
|
||||||
|
</div>
|
||||||
|
<div style="grid-column: span 2;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Resolved URI</div>
|
||||||
|
<div style="color: #1A1612; font-family: monospace; font-size: 11px;">bean://com.example.OrderValidator?method=validate</div>
|
||||||
|
</div>
|
||||||
|
<!-- Attributes from taps -->
|
||||||
|
<div style="grid-column: span 3; border-top: 1px solid #E4DFD8; padding-top: 8px; margin-top: 4px;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;">Attributes</div>
|
||||||
|
<div style="display: flex; gap: 6px; flex-wrap: wrap;">
|
||||||
|
<span style="font-size: 10px; padding: 2px 8px; background: #F5F0EA; border-radius: 10px; color: #5C5347;">orderId: <strong>ORD-1234</strong></span>
|
||||||
|
<span style="font-size: 10px; padding: 2px 8px; background: #F5F0EA; border-radius: 10px; color: #5C5347;">customer: <strong>Acme Corp</strong></span>
|
||||||
|
<span style="font-size: 10px; padding: 2px 8px; background: #F5F0EA; border-radius: 10px; color: #5C5347;">priority: <strong>HIGH</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Headers Tab — input vs output side by side</div>
|
||||||
|
<div class="mockup-body" style="background: #fff; padding: 0;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 6px 14px; border-bottom: 1px solid #E4DFD8; background: #FAFAF8;">
|
||||||
|
<span style="font-size: 11px; font-weight: 600; color: #1A1612;">log:incoming</span>
|
||||||
|
<span style="font-size: 10px; color: #3D7C47; background: #F0F9F1; padding: 1px 6px; border-radius: 8px;">COMPLETED</span>
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">processor-2</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0; border-bottom: 1px solid #E4DFD8; background: #FAFAF8; padding: 0 14px;">
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Info</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C6820E; border-bottom: 2px solid #C6820E; font-weight: 600; cursor: pointer;">Headers</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Input</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Output</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Error</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Config</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Timeline</div>
|
||||||
|
</div>
|
||||||
|
<!-- Headers side by side -->
|
||||||
|
<div style="display: flex; gap: 0; padding: 0;">
|
||||||
|
<!-- Input headers -->
|
||||||
|
<div style="flex: 1; padding: 10px 14px; border-right: 1px solid #E4DFD8;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">Input Headers</div>
|
||||||
|
<table style="width: 100%; font-size: 11px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500; width: 40%;">Content-Type</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">application/json</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500;">JMSMessageID</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">ID:broker-42</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500;">breadcrumbId</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">abc-123-def</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500;">CamelHttpMethod</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">POST</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Output headers -->
|
||||||
|
<div style="flex: 1; padding: 10px 14px;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;">Output Headers</div>
|
||||||
|
<table style="width: 100%; font-size: 11px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500; width: 40%;">Content-Type</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">application/json</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500;">JMSMessageID</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">ID:broker-42</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500;">breadcrumbId</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">abc-123-def</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid #F5F0EA;">
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500;">CamelHttpMethod</td>
|
||||||
|
<td style="padding: 3px 0; color: #1A1612; font-family: monospace; font-size: 10px;">POST</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 0; color: #5C5347; font-weight: 500; color: #3D7C47;">orderStatus</td>
|
||||||
|
<td style="padding: 3px 0; color: #3D7C47; font-family: monospace; font-size: 10px;">validated <span style="font-size: 9px; color: #9C9184; font-family: sans-serif;">(new)</span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Input Tab — formatted message body</div>
|
||||||
|
<div class="mockup-body" style="background: #fff; padding: 0;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 6px 14px; border-bottom: 1px solid #E4DFD8; background: #FAFAF8;">
|
||||||
|
<span style="font-size: 11px; font-weight: 600; color: #1A1612;">log:incoming</span>
|
||||||
|
<span style="font-size: 10px; color: #3D7C47; background: #F0F9F1; padding: 1px 6px; border-radius: 8px;">COMPLETED</span>
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">processor-2 · 5ms</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0; border-bottom: 1px solid #E4DFD8; background: #FAFAF8; padding: 0 14px;">
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Info</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Headers</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C6820E; border-bottom: 2px solid #C6820E; font-weight: 600; cursor: pointer;">Input</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Output</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Error</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Config</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Timeline</div>
|
||||||
|
</div>
|
||||||
|
<!-- Body content with syntax highlighting -->
|
||||||
|
<div style="padding: 10px 14px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">JSON · 234 bytes</span>
|
||||||
|
<button style="font-size: 9px; padding: 2px 8px; border: 1px solid #E4DFD8; background: #FAFAF8; border-radius: 3px; cursor: pointer; color: #5C5347;">Copy</button>
|
||||||
|
</div>
|
||||||
|
<pre style="font-size: 11px; background: #1A1612; color: #E4DFD8; padding: 12px; border-radius: 6px; margin: 0; line-height: 1.6; overflow-x: auto;">{
|
||||||
|
<span style="color: #1A7F8E;">"orderId"</span>: <span style="color: #C6820E;">"ORD-1234"</span>,
|
||||||
|
<span style="color: #1A7F8E;">"customer"</span>: {
|
||||||
|
<span style="color: #1A7F8E;">"name"</span>: <span style="color: #C6820E;">"Acme Corp"</span>,
|
||||||
|
<span style="color: #1A7F8E;">"id"</span>: <span style="color: #7C3AED;">42</span>
|
||||||
|
},
|
||||||
|
<span style="color: #1A7F8E;">"items"</span>: [
|
||||||
|
{
|
||||||
|
<span style="color: #1A7F8E;">"product"</span>: <span style="color: #C6820E;">"Widget A"</span>,
|
||||||
|
<span style="color: #1A7F8E;">"quantity"</span>: <span style="color: #7C3AED;">5</span>,
|
||||||
|
<span style="color: #1A7F8E;">"price"</span>: <span style="color: #7C3AED;">29.99</span>
|
||||||
|
}
|
||||||
|
],
|
||||||
|
<span style="color: #1A7F8E;">"priority"</span>: <span style="color: #C6820E;">"HIGH"</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Timeline Tab — Gantt-style processor durations</div>
|
||||||
|
<div class="mockup-body" style="background: #fff; padding: 0;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 6px 14px; border-bottom: 1px solid #E4DFD8; background: #FAFAF8;">
|
||||||
|
<span style="font-size: 11px; font-weight: 600; color: #1A1612;">content-based-routing</span>
|
||||||
|
<span style="font-size: 10px; color: #C0392B; background: #FDF2F0; padding: 1px 6px; border-radius: 8px;">FAILED</span>
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">247ms total</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0; border-bottom: 1px solid #E4DFD8; background: #FAFAF8; padding: 0 14px;">
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Info</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Headers</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Input</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Output</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C0392B; cursor: pointer;">Error</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Config</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C6820E; border-bottom: 2px solid #C6820E; font-weight: 600; cursor: pointer;">Timeline</div>
|
||||||
|
</div>
|
||||||
|
<!-- Gantt chart -->
|
||||||
|
<div style="padding: 10px 14px;">
|
||||||
|
<!-- Time axis -->
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 9px; color: #9C9184; margin-bottom: 4px; padding-left: 110px;">
|
||||||
|
<span>0ms</span><span>50ms</span><span>100ms</span><span>150ms</span><span>200ms</span><span>247ms</span>
|
||||||
|
</div>
|
||||||
|
<!-- Processor rows -->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 3px;">
|
||||||
|
<!-- from:jms -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 10px; color: #5C5347; width: 100px; text-align: right; flex-shrink: 0;">from:jms</span>
|
||||||
|
<div style="flex: 1; height: 16px; background: #F5F0EA; border-radius: 2px; position: relative;">
|
||||||
|
<div style="position: absolute; left: 0%; width: 0.8%; height: 100%; background: #3D7C47; border-radius: 2px; min-width: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 9px; color: #9C9184; width: 36px; flex-shrink: 0;">2ms</span>
|
||||||
|
</div>
|
||||||
|
<!-- log -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 10px; color: #5C5347; width: 100px; text-align: right; flex-shrink: 0;">log</span>
|
||||||
|
<div style="flex: 1; height: 16px; background: #F5F0EA; border-radius: 2px; position: relative;">
|
||||||
|
<div style="position: absolute; left: 0.8%; width: 2%; height: 100%; background: #3D7C47; border-radius: 2px; min-width: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 9px; color: #9C9184; width: 36px; flex-shrink: 0;">5ms</span>
|
||||||
|
</div>
|
||||||
|
<!-- setHeader -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 10px; color: #5C5347; width: 100px; text-align: right; flex-shrink: 0;">setHeader</span>
|
||||||
|
<div style="flex: 1; height: 16px; background: #F5F0EA; border-radius: 2px; position: relative;">
|
||||||
|
<div style="position: absolute; left: 2.8%; width: 0.4%; height: 100%; background: #3D7C47; border-radius: 2px; min-width: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 9px; color: #9C9184; width: 36px; flex-shrink: 0;">1ms</span>
|
||||||
|
</div>
|
||||||
|
<!-- bean:validate (FAILED - long) -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 10px; color: #C0392B; font-weight: 600; width: 100px; text-align: right; flex-shrink: 0;">bean:validate</span>
|
||||||
|
<div style="flex: 1; height: 16px; background: #F5F0EA; border-radius: 2px; position: relative;">
|
||||||
|
<div style="position: absolute; left: 3.2%; width: 48.6%; height: 100%; background: #C0392B; border-radius: 2px; opacity: 0.8;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size: 9px; color: #C0392B; font-weight: 500; width: 36px; flex-shrink: 0;">120ms</span>
|
||||||
|
</div>
|
||||||
|
<!-- to:http (skipped) -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; opacity: 0.35;">
|
||||||
|
<span style="font-size: 10px; color: #5C5347; width: 100px; text-align: right; flex-shrink: 0;">to:http</span>
|
||||||
|
<div style="flex: 1; height: 16px; background: #F5F0EA; border-radius: 2px;"></div>
|
||||||
|
<span style="font-size: 9px; color: #9C9184; width: 36px; flex-shrink: 0;">—</span>
|
||||||
|
</div>
|
||||||
|
<!-- to:jms (skipped) -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; opacity: 0.35;">
|
||||||
|
<span style="font-size: 10px; color: #5C5347; width: 100px; text-align: right; flex-shrink: 0;">to:jms</span>
|
||||||
|
<div style="flex: 1; height: 16px; background: #F5F0EA; border-radius: 2px;"></div>
|
||||||
|
<span style="font-size: 9px; color: #9C9184; width: 36px; flex-shrink: 0;">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 8px; font-size: 10px; color: #9C9184;">Click a bar to select that processor in the diagram</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Error Tab — grayed out when no error on selected processor</div>
|
||||||
|
<div class="mockup-body" style="background: #fff; padding: 0;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 6px 14px; border-bottom: 1px solid #E4DFD8; background: #FAFAF8;">
|
||||||
|
<span style="font-size: 11px; font-weight: 600; color: #1A1612;">log:incoming</span>
|
||||||
|
<span style="font-size: 10px; color: #3D7C47; background: #F0F9F1; padding: 1px 6px; border-radius: 8px;">COMPLETED</span>
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">processor-2 · 5ms</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0; border-bottom: 1px solid #E4DFD8; background: #FAFAF8; padding: 0 14px;">
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Info</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Headers</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Input</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Output</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4; cursor: not-allowed;">Error</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.4;">Config</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Timeline</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 20px 14px; text-align: center; color: #9C9184; font-size: 12px;">
|
||||||
|
No error on this processor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<h2>Execution Overlay: Full Design Mockup</h2>
|
||||||
|
<p class="subtitle">ExecutionDiagram component — diagram with execution overlay + detail panel</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">ExecutionDiagram — Failed Exchange View</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 0;">
|
||||||
|
<!-- Top bar: Exchange summary -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px; padding: 8px 14px; background: #fff; border-bottom: 1px solid #E4DFD8; font-size: 12px; color: #5C5347;">
|
||||||
|
<span style="font-weight: 600; color: #1A1612;">Exchange</span>
|
||||||
|
<code style="font-size: 11px; background: #F5F0EA; padding: 2px 6px; border-radius: 3px; color: #1A1612;">abc-123-def-456</code>
|
||||||
|
<span style="background: #C0392B; color: white; font-size: 10px; padding: 1px 8px; border-radius: 10px; font-weight: 600;">FAILED</span>
|
||||||
|
<span style="color: #9C9184;">sample-app / content-based-routing</span>
|
||||||
|
<span style="color: #9C9184;">247ms</span>
|
||||||
|
<div style="margin-left: auto; display: flex; gap: 6px;">
|
||||||
|
<button style="font-size: 10px; padding: 3px 10px; border: 1px solid #C0392B; background: #FDF2F0; color: #C0392B; border-radius: 4px; cursor: pointer; font-weight: 500;">Jump to Error</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content: Diagram top, Detail bottom -->
|
||||||
|
<div style="display: flex; flex-direction: column; height: 480px;">
|
||||||
|
|
||||||
|
<!-- TOP: Process Diagram with Overlay -->
|
||||||
|
<div style="flex: 1; position: relative; overflow: hidden; background: #fff; border-bottom: 2px solid #E4DFD8;">
|
||||||
|
|
||||||
|
<!-- Breadcrumbs (if drilled down) -->
|
||||||
|
|
||||||
|
<!-- Diagram content -->
|
||||||
|
<div style="padding: 24px 30px;">
|
||||||
|
<!-- Main flow -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 0;">
|
||||||
|
|
||||||
|
<!-- Node: from:jms (COMPLETED) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #1A7F8E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">from:jms:orders</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">ENDPOINT</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">2ms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge -->
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="30" y2="5" stroke="#9CA3AF" stroke-width="1.5"/><polygon points="25,2 30,5 25,8" fill="#9CA3AF"/></svg>
|
||||||
|
|
||||||
|
<!-- Node: log (COMPLETED) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">log:incoming</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">LOG</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">5ms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge -->
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="30" y2="5" stroke="#9CA3AF" stroke-width="1.5"/><polygon points="25,2 30,5 25,8" fill="#9CA3AF"/></svg>
|
||||||
|
|
||||||
|
<!-- Node: CHOICE compound -->
|
||||||
|
<div style="position: relative; border: 2px dashed #7C3AED; border-radius: 8px; padding: 0; background: #FAFAFF;">
|
||||||
|
<!-- Compound header -->
|
||||||
|
<div style="background: #7C3AED; color: white; font-size: 10px; font-weight: 600; padding: 3px 10px; border-radius: 5px 5px 0 0;">CHOICE</div>
|
||||||
|
<div style="padding: 10px; display: flex; gap: 16px;">
|
||||||
|
<!-- WHEN branch (taken, failed) -->
|
||||||
|
<div style="border: 1px solid #E4DFD8; border-radius: 5px; padding: 6px; background: #fff;">
|
||||||
|
<div style="font-size: 8px; color: #7C3AED; font-weight: 600; margin-bottom: 4px;">WHEN: header.type == 'A'</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0;">
|
||||||
|
<!-- Node: bean (FAILED) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 120px; height: 48px; background: #FDF2F0; border: 2px solid #C0392B; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #C0392B;">bean:validate</div>
|
||||||
|
<div style="font-size: 8px; color: #C0392B;">FAILED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #C0392B; font-weight: 500;">120ms</div>
|
||||||
|
<!-- Error icon -->
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: white; font-weight: bold;">!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="20" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="20" y2="5" stroke="#9CA3AF" stroke-width="1"/></svg>
|
||||||
|
|
||||||
|
<!-- Node: to:http (NOT EXECUTED - dimmed) -->
|
||||||
|
<div style="position: relative; opacity: 0.35;">
|
||||||
|
<div style="width: 120px; height: 48px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #1A1612;">to:http:api</div>
|
||||||
|
<div style="font-size: 8px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OTHERWISE branch (not taken - dimmed) -->
|
||||||
|
<div style="border: 1px solid #E4DFD8; border-radius: 5px; padding: 6px; background: #fff; opacity: 0.35;">
|
||||||
|
<div style="font-size: 8px; color: #7C3AED; font-weight: 600; margin-bottom: 4px;">OTHERWISE</div>
|
||||||
|
<div style="width: 120px; height: 48px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #1A1612;">to:direct:alt</div>
|
||||||
|
<div style="font-size: 8px; color: #9C9184;">DIRECT</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom controls (bottom-right) -->
|
||||||
|
<div style="position: absolute; bottom: 8px; right: 8px; display: flex; align-items: center; gap: 3px; background: #fff; border: 1px solid #E4DFD8; border-radius: 4px; padding: 3px; box-shadow: 0 1px 4px rgba(0,0,0,0.06);">
|
||||||
|
<button style="width: 22px; height: 22px; border: none; background: transparent; font-size: 12px; cursor: pointer; color: #1A1612;">+</button>
|
||||||
|
<span style="font-size: 9px; color: #9C9184; min-width: 30px; text-align: center;">100%</span>
|
||||||
|
<button style="width: 22px; height: 22px; border: none; background: transparent; font-size: 12px; cursor: pointer; color: #1A1612;">-</button>
|
||||||
|
<button style="width: 22px; height: 22px; border: none; background: transparent; font-size: 10px; cursor: pointer; color: #1A1612;">Fit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Minimap (bottom-left) -->
|
||||||
|
<div style="position: absolute; bottom: 8px; left: 8px; width: 100px; height: 60px; background: #fff; border: 1px solid #E4DFD8; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); overflow: hidden;">
|
||||||
|
<div style="padding: 4px;">
|
||||||
|
<div style="display: flex; gap: 2px; align-items: center; transform: scale(0.3); transform-origin: top left;">
|
||||||
|
<div style="width: 60px; height: 20px; background: #1A7F8E; border-radius: 2px;"></div>
|
||||||
|
<div style="width: 60px; height: 20px; background: #C6820E; border-radius: 2px;"></div>
|
||||||
|
<div style="width: 100px; height: 40px; border: 1px solid #7C3AED; border-radius: 2px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SPLITTER -->
|
||||||
|
<div style="height: 4px; background: #E4DFD8; cursor: row-resize; flex-shrink: 0;"></div>
|
||||||
|
|
||||||
|
<!-- BOTTOM: Detail Panel -->
|
||||||
|
<div style="flex: 0 0 180px; background: #fff; display: flex; flex-direction: column;">
|
||||||
|
<!-- Selected processor header -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; padding: 6px 14px; border-bottom: 1px solid #E4DFD8; background: #FAFAF8;">
|
||||||
|
<span style="font-size: 11px; font-weight: 600; color: #C0392B;">bean:validate</span>
|
||||||
|
<span style="font-size: 10px; color: #C0392B; background: #FDF2F0; padding: 1px 6px; border-radius: 8px;">FAILED</span>
|
||||||
|
<span style="font-size: 10px; color: #9C9184;">processor-5 · 120ms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div style="display: flex; gap: 0; border-bottom: 1px solid #E4DFD8; background: #FAFAF8; padding: 0 14px;">
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Info</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Headers</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Input</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Output</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #C0392B; border-bottom: 2px solid #C0392B; font-weight: 600; cursor: pointer;">Error</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer; opacity: 0.5;">Config</div>
|
||||||
|
<div style="font-size: 11px; padding: 6px 12px; color: #9C9184; cursor: pointer;">Timeline</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content: Error -->
|
||||||
|
<div style="flex: 1; padding: 10px 14px; overflow-y: auto;">
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Exception</div>
|
||||||
|
<div style="font-size: 12px; color: #C0392B; font-weight: 500;">javax.validation.ValidationException</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Message</div>
|
||||||
|
<div style="font-size: 12px; color: #1A1612;">Order quantity must be positive: received -3</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Root Cause</div>
|
||||||
|
<div style="font-size: 12px; color: #C0392B;">java.lang.IllegalArgumentException: quantity must be > 0</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;">Stack Trace</div>
|
||||||
|
<pre style="font-size: 10px; color: #5C5347; background: #F5F0EA; padding: 8px; border-radius: 4px; overflow-x: auto; margin: 0; line-height: 1.6;">at com.example.OrderValidator.validate(OrderValidator.java:42)
|
||||||
|
at com.example.OrderRoute.process(OrderRoute.java:18)
|
||||||
|
at org.apache.camel.processor.DelegateSyncProcessor.process(...)
|
||||||
|
at org.apache.camel.processor.Pipeline.process(Pipeline.java:184)
|
||||||
|
... 8 more</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px;">
|
||||||
|
<h3>Design Decisions Shown</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-size: 13px; color: #5C5347;">
|
||||||
|
<div style="background: #f8f8f6; padding: 10px; border-radius: 6px; border-left: 3px solid #3D7C47;">
|
||||||
|
<strong style="color: #1A1612;">Executed (OK)</strong><br/>
|
||||||
|
Green left border, duration badge bottom-right
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f8f6; padding: 10px; border-radius: 6px; border-left: 3px solid #C0392B;">
|
||||||
|
<strong style="color: #1A1612;">Failed</strong><br/>
|
||||||
|
Red border, red tint background, red ! badge top-right
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f8f6; padding: 10px; border-radius: 6px; border-left: 3px solid #9C9184;">
|
||||||
|
<strong style="color: #1A1612;">Not Executed</strong><br/>
|
||||||
|
Dimmed to 35% opacity — full topology visible
|
||||||
|
</div>
|
||||||
|
<div style="background: #f8f8f6; padding: 10px; border-radius: 6px; border-left: 3px solid #C6820E;">
|
||||||
|
<strong style="color: #1A1612;">Selected</strong><br/>
|
||||||
|
Amber ring (existing), detail panel updates below
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
143
.superpowers/brainstorm/14618-1774629192/iteration-stepper.html
Normal file
143
.superpowers/brainstorm/14618-1774629192/iteration-stepper.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<h2>Per-Compound Iteration Stepper</h2>
|
||||||
|
<p class="subtitle">Each loop/split compound gets its own stepper in the header bar</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Loop with iteration stepper — iteration 3 of 5</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 20px;">
|
||||||
|
|
||||||
|
<!-- LOOP compound -->
|
||||||
|
<div style="position: relative; border: 2px dashed #7C3AED; border-radius: 8px; background: #FAFAFF; max-width: 600px;">
|
||||||
|
<!-- Compound header with stepper -->
|
||||||
|
<div style="background: #7C3AED; color: white; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 5px 5px 0 0; display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<span>LOOP</span>
|
||||||
|
<!-- Iteration stepper -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px; background: rgba(255,255,255,0.15); border-radius: 3px; padding: 1px 4px;">
|
||||||
|
<button style="width: 18px; height: 18px; border: none; background: rgba(255,255,255,0.2); color: white; border-radius: 2px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center;">‹</button>
|
||||||
|
<span style="font-size: 10px; min-width: 30px; text-align: center; font-variant-numeric: tabular-nums;">3 / 5</span>
|
||||||
|
<button style="width: 18px; height: 18px; border: none; background: rgba(255,255,255,0.2); color: white; border-radius: 2px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center;">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loop body: showing iteration 3 data -->
|
||||||
|
<div style="padding: 12px; display: flex; align-items: center; gap: 0;">
|
||||||
|
<!-- transform (OK in iteration 3) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 130px; height: 48px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 5px; border-left: 3px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 4px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #1A1612;">transform</div>
|
||||||
|
<div style="font-size: 8px; color: #9C9184;">TRANSFORM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #3D7C47;">3ms</div>
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 13px; height: 13px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">✓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="24" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="20" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="17,2 22,5 17,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- to:http (OK in iteration 3) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 130px; height: 48px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 5px; border-left: 3px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 4px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #1A1612;">to:http:api</div>
|
||||||
|
<div style="font-size: 8px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #3D7C47;">45ms</div>
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 13px; height: 13px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">✓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="24" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="20" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="17,2 22,5 17,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- log (OK in iteration 3) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 130px; height: 48px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 5px; border-left: 3px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 4px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #1A1612;">log:result</div>
|
||||||
|
<div style="font-size: 8px; color: #9C9184;">LOG</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #3D7C47;">1ms</div>
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 13px; height: 13px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">✓</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px;">Nested Loops</h3>
|
||||||
|
<p class="subtitle">Each compound level has its own independent stepper</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Outer loop (iteration 2/3) containing inner split (branch 1/4)</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 20px;">
|
||||||
|
|
||||||
|
<!-- Outer LOOP -->
|
||||||
|
<div style="border: 2px dashed #7C3AED; border-radius: 8px; background: #FAFAFF; max-width: 550px;">
|
||||||
|
<div style="background: #7C3AED; color: white; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 5px 5px 0 0; display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<span>LOOP</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px; background: rgba(255,255,255,0.15); border-radius: 3px; padding: 1px 4px;">
|
||||||
|
<button style="width: 18px; height: 18px; border: none; background: rgba(255,255,255,0.2); color: white; border-radius: 2px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center;">‹</button>
|
||||||
|
<span style="font-size: 10px; min-width: 30px; text-align: center;">2 / 3</span>
|
||||||
|
<button style="width: 18px; height: 18px; border: none; background: rgba(255,255,255,0.2); color: white; border-radius: 2px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center;">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 12px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0;">
|
||||||
|
|
||||||
|
<!-- Processor before split -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 110px; height: 44px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 5px; border-left: 3px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 4px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 3px 6px;">
|
||||||
|
<div style="font-size: 9px; font-weight: 600; color: #1A1612;">setBody</div>
|
||||||
|
<div style="font-size: 8px; color: #9C9184;">SET_BODY</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 13px; height: 13px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">✓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="20" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="16" y2="5" stroke="#3D7C47" stroke-width="1.5"/></svg>
|
||||||
|
|
||||||
|
<!-- Inner SPLIT -->
|
||||||
|
<div style="border: 2px dashed #7C3AED; border-radius: 6px; background: #F8F7FF;">
|
||||||
|
<div style="background: #9B6AED; color: white; font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 3px 3px 0 0; display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<span>SPLIT</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 3px; background: rgba(255,255,255,0.15); border-radius: 3px; padding: 1px 3px;">
|
||||||
|
<button style="width: 16px; height: 16px; border: none; background: rgba(255,255,255,0.2); color: white; border-radius: 2px; cursor: pointer; font-size: 9px; display: flex; align-items: center; justify-content: center;">‹</button>
|
||||||
|
<span style="font-size: 9px; min-width: 26px; text-align: center;">1 / 4</span>
|
||||||
|
<button style="width: 16px; height: 16px; border: none; background: rgba(255,255,255,0.2); color: white; border-radius: 2px; cursor: pointer; font-size: 9px; display: flex; align-items: center; justify-content: center;">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 8px; display: flex; align-items: center; gap: 0;">
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 100px; height: 40px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 4px; border-left: 3px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 3px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 2px 5px;">
|
||||||
|
<div style="font-size: 8px; font-weight: 600; color: #1A1612;">to:kafka</div>
|
||||||
|
<div style="font-size: 7px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; top: -4px; right: -4px; width: 12px; height: 12px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 7px; color: white;">✓</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px;">Stepper Behavior</h3>
|
||||||
|
<div style="font-size: 13px; color: #5C5347; line-height: 1.8;">
|
||||||
|
<ul style="margin: 0; padding-left: 20px;">
|
||||||
|
<li><strong>Independent per compound</strong> — outer loop at iteration 2, inner split at branch 1</li>
|
||||||
|
<li><strong>Overlay updates per-compound</strong> — stepping the loop re-renders its children's execution data for that iteration</li>
|
||||||
|
<li><strong>CHOICE shows which branch was taken</strong> — no stepper, just highlights the taken branch</li>
|
||||||
|
<li><strong>Keyboard</strong> — when a compound is focused/hovered, left/right arrow keys step through iterations</li>
|
||||||
|
<li><strong>Detail panel syncs</strong> — selecting a processor inside a loop shows that iteration's data</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
166
.superpowers/brainstorm/14618-1774629192/layout-overview.html
Normal file
166
.superpowers/brainstorm/14618-1774629192/layout-overview.html
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<h2>Execution Overlay: Page Layout</h2>
|
||||||
|
<p class="subtitle">How should the diagram + execution details be arranged?</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<!-- Option A: Horizontal Split -->
|
||||||
|
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="padding: 16px; background: #1a1612; border-radius: 6px;">
|
||||||
|
<!-- IDE-style: diagram top, detail bottom -->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px; height: 280px;">
|
||||||
|
<!-- Top: Diagram -->
|
||||||
|
<div style="flex: 1; background: #2a2520; border-radius: 4px; padding: 12px; position: relative; overflow: hidden;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; margin-bottom: 8px;">DIAGRAM</div>
|
||||||
|
<!-- Mini route flow mockup -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-left: 20px;">
|
||||||
|
<div style="width: 60px; height: 28px; background: #1A7F8E; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">from:jms</div>
|
||||||
|
<div style="width: 20px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 60px; height: 28px; background: #C6820E; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; border: 2px solid #3D7C47;">log</div>
|
||||||
|
<div style="width: 20px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 60px; height: 28px; background: #C0392B; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; border: 2px solid #C0392B; opacity: 0.9;">bean</div>
|
||||||
|
<div style="width: 20px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 60px; height: 28px; background: #3D7C47; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; opacity: 0.4;">to:http</div>
|
||||||
|
</div>
|
||||||
|
<!-- Zoom controls hint -->
|
||||||
|
<div style="position: absolute; bottom: 6px; right: 6px; font-size: 8px; color: #5C5347; background: #2a2520; padding: 2px 6px; border: 1px solid #3a3530; border-radius: 3px;">100%</div>
|
||||||
|
<!-- Iteration stepper -->
|
||||||
|
<div style="position: absolute; top: 6px; right: 6px; font-size: 8px; color: #C6820E; background: #2a2520; padding: 2px 8px; border: 1px solid #3a3530; border-radius: 3px;">Loop 2/5</div>
|
||||||
|
</div>
|
||||||
|
<!-- Resizable splitter -->
|
||||||
|
<div style="height: 3px; background: #3a3530; border-radius: 2px; cursor: row-resize;"></div>
|
||||||
|
<!-- Bottom: Details -->
|
||||||
|
<div style="flex: 0 0 100px; background: #2a2520; border-radius: 4px; padding: 8px; overflow: hidden;">
|
||||||
|
<div style="display: flex; gap: 12px; font-size: 9px; color: #9C9184; border-bottom: 1px solid #3a3530; padding-bottom: 4px; margin-bottom: 6px;">
|
||||||
|
<span style="color: #C6820E; border-bottom: 2px solid #C6820E; padding-bottom: 3px;">Input</span>
|
||||||
|
<span>Output</span>
|
||||||
|
<span>Headers</span>
|
||||||
|
<span>Error</span>
|
||||||
|
<span>Timeline</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family: monospace; font-size: 8px; color: #9C9184; line-height: 1.5;">
|
||||||
|
<div>{"orderId": "ORD-1234",</div>
|
||||||
|
<div> "product": "Widget A",</div>
|
||||||
|
<div> "quantity": 5}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>A: Top/Bottom Split (IDE Style)</h3>
|
||||||
|
<p>Diagram on top, tabbed detail panel below. Resizable splitter between them. Maximizes diagram width. Tabs: Input, Output, Headers, Error, Timeline.</p>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros"><h4>Pros</h4><ul><li>Full diagram width</li><li>Familiar IDE pattern</li><li>Detail panel always visible</li></ul></div>
|
||||||
|
<div class="cons"><h4>Cons</h4><ul><li>Vertical space shared</li><li>Diagram shrinks on small screens</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option B: Right Panel -->
|
||||||
|
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="padding: 16px; background: #1a1612; border-radius: 6px;">
|
||||||
|
<div style="display: flex; gap: 8px; height: 280px;">
|
||||||
|
<!-- Left: Diagram -->
|
||||||
|
<div style="flex: 1; background: #2a2520; border-radius: 4px; padding: 12px; position: relative; overflow: hidden;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; margin-bottom: 8px;">DIAGRAM</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-left: 10px;">
|
||||||
|
<div style="width: 55px; height: 26px; background: #1A7F8E; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 7px; color: white;">from:jms</div>
|
||||||
|
<div style="width: 14px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 55px; height: 26px; background: #C6820E; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 7px; color: white; border: 2px solid #3D7C47;">log</div>
|
||||||
|
<div style="width: 14px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 55px; height: 26px; background: #C0392B; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 7px; color: white; border: 2px solid #C0392B;">bean</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 6px; right: 6px; font-size: 8px; color: #5C5347; background: #2a2520; padding: 2px 6px; border: 1px solid #3a3530; border-radius: 3px;">100%</div>
|
||||||
|
</div>
|
||||||
|
<!-- Resizable splitter -->
|
||||||
|
<div style="width: 3px; background: #3a3530; border-radius: 2px; cursor: col-resize;"></div>
|
||||||
|
<!-- Right: Detail Panel -->
|
||||||
|
<div style="flex: 0 0 200px; background: #2a2520; border-radius: 4px; padding: 8px; overflow: hidden;">
|
||||||
|
<div style="font-size: 9px; color: #C6820E; font-weight: 600; margin-bottom: 6px;">log (processor-3)</div>
|
||||||
|
<div style="font-size: 8px; color: #3D7C47; margin-bottom: 8px;">COMPLETED - 12ms</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 2px; font-size: 8px; color: #9C9184; border-bottom: 1px solid #3a3530; padding-bottom: 4px; margin-bottom: 6px;">
|
||||||
|
<div style="display: flex; gap: 6px;">
|
||||||
|
<span style="color: #C6820E; font-weight: 600;">Input</span>
|
||||||
|
<span>Output</span>
|
||||||
|
<span>Headers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-family: monospace; font-size: 7px; color: #9C9184; line-height: 1.4;">
|
||||||
|
<div>{"orderId": "ORD-1234",</div>
|
||||||
|
<div> "product": "Widget A",</div>
|
||||||
|
<div> "quantity": 5,</div>
|
||||||
|
<div> "price": 29.99}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>B: Left/Right Split</h3>
|
||||||
|
<p>Diagram on left, collapsible detail panel on right. Slide-in when node selected. Diagram keeps full height.</p>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros"><h4>Pros</h4><ul><li>Full diagram height</li><li>Panel can collapse</li><li>Good for wide screens</li></ul></div>
|
||||||
|
<div class="cons"><h4>Cons</h4><ul><li>Steals diagram width</li><li>Tight on narrow screens</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Option C: Hybrid -->
|
||||||
|
<div class="card" data-choice="c" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image">
|
||||||
|
<div style="padding: 16px; background: #1a1612; border-radius: 6px;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px; height: 280px;">
|
||||||
|
<!-- Top: Full width diagram -->
|
||||||
|
<div style="flex: 1; background: #2a2520; border-radius: 4px; padding: 12px; position: relative; overflow: hidden;">
|
||||||
|
<div style="font-size: 10px; color: #9C9184; margin-bottom: 8px;">DIAGRAM</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-left: 20px;">
|
||||||
|
<div style="width: 60px; height: 28px; background: #1A7F8E; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">from:jms</div>
|
||||||
|
<div style="width: 20px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 60px; height: 28px; background: #C6820E; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; border: 2px solid #3D7C47;">log</div>
|
||||||
|
<div style="width: 20px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 60px; height: 28px; background: #C0392B; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; border: 2px solid #C0392B;">bean</div>
|
||||||
|
<div style="width: 20px; height: 1px; background: #5C5347;"></div>
|
||||||
|
<div style="width: 60px; height: 28px; background: #3D7C47; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; opacity: 0.4;">to:http</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 6px; right: 6px; font-size: 8px; color: #5C5347; background: #2a2520; padding: 2px 6px; border: 1px solid #3a3530; border-radius: 3px;">100%</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom: Two-column detail -->
|
||||||
|
<div style="height: 3px; background: #3a3530; border-radius: 2px;"></div>
|
||||||
|
<div style="flex: 0 0 100px; display: flex; gap: 8px;">
|
||||||
|
<!-- Left: Processor list / timeline -->
|
||||||
|
<div style="flex: 0 0 140px; background: #2a2520; border-radius: 4px; padding: 6px; overflow: hidden;">
|
||||||
|
<div style="font-size: 8px; color: #9C9184; margin-bottom: 4px; font-weight: 600;">Processors</div>
|
||||||
|
<div style="font-size: 7px; line-height: 1.8;">
|
||||||
|
<div style="color: #3D7C47; padding: 1px 4px; background: #2a2a20; border-radius: 2px;">from:jms - 2ms</div>
|
||||||
|
<div style="color: #C6820E; padding: 1px 4px; background: #3a3020; border-radius: 2px; border-left: 2px solid #C6820E;">log - 12ms</div>
|
||||||
|
<div style="color: #C0392B; padding: 1px 4px;">bean - FAILED</div>
|
||||||
|
<div style="color: #5C5347; padding: 1px 4px; opacity: 0.5;">to:http - skipped</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right: Selected processor detail -->
|
||||||
|
<div style="flex: 1; background: #2a2520; border-radius: 4px; padding: 6px; overflow: hidden;">
|
||||||
|
<div style="display: flex; gap: 8px; font-size: 8px; color: #9C9184; border-bottom: 1px solid #3a3530; padding-bottom: 3px; margin-bottom: 4px;">
|
||||||
|
<span style="color: #C6820E;">Input</span>
|
||||||
|
<span>Output</span>
|
||||||
|
<span>Headers</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family: monospace; font-size: 7px; color: #9C9184; line-height: 1.4;">
|
||||||
|
<div>{"orderId": "ORD-1234",</div>
|
||||||
|
<div> "product": "Widget A"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>C: Top/Bottom with Processor List</h3>
|
||||||
|
<p>Diagram on top, bottom split into processor list (left) + detail tabs (right). Clicking processor in list or diagram syncs selection. Most information density.</p>
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros"><h4>Pros</h4><ul><li>Processor list as navigation</li><li>Full diagram width</li><li>Maximum information density</li></ul></div>
|
||||||
|
<div class="cons"><h4>Cons</h4><ul><li>More complex layout</li><li>May feel crowded</li></ul></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
190
.superpowers/brainstorm/14618-1774629192/overlay-intensity.html
Normal file
190
.superpowers/brainstorm/14618-1774629192/overlay-intensity.html
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<h2>Execution Overlay: Visual Intensity Comparison</h2>
|
||||||
|
<p class="subtitle">How strong should the overlay tinting be?</p>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<!-- Current: Subtle -->
|
||||||
|
<div class="mockup" data-choice="subtle" onclick="toggleSelect(this)">
|
||||||
|
<div class="mockup-header">Current: Subtle (border only)</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 16px;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<!-- OK node - border only -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184; width: 70px;">Completed</span>
|
||||||
|
<div style="position: relative; width: 160px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #1A7F8E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">from:jms:orders</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">ENDPOINT</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">2ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Failed node - border only -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184; width: 70px;">Failed</span>
|
||||||
|
<div style="position: relative; width: 160px; height: 52px; background: #fff; border: 2px solid #C0392B; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">bean:validate</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">BEAN</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #C0392B; font-weight: 500;">120ms</div>
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: white; font-weight: bold;">!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Skipped node -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184; width: 70px;">Skipped</span>
|
||||||
|
<div style="opacity: 0.35; width: 160px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">to:http:api</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed: Tinted backgrounds -->
|
||||||
|
<div class="mockup" data-choice="tinted" onclick="toggleSelect(this)">
|
||||||
|
<div class="mockup-header">Proposed: Tinted backgrounds</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 16px;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<!-- OK node - green tint -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184; width: 70px;">Completed</span>
|
||||||
|
<div style="position: relative; width: 160px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #1A7F8E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">from:jms:orders</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">ENDPOINT</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">2ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Failed node - red tint -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184; width: 70px;">Failed</span>
|
||||||
|
<div style="position: relative; width: 160px; height: 52px; background: #FDF2F0; border: 2px solid #C0392B; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #C0392B;">bean:validate</div>
|
||||||
|
<div style="font-size: 9px; color: #C0392B;">FAILED</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #C0392B; font-weight: 500;">120ms</div>
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: white; font-weight: bold;">!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Skipped node -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 10px; color: #9C9184; width: 70px;">Skipped</span>
|
||||||
|
<div style="opacity: 0.35; width: 160px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">to:http:api</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px;">Full Flow Comparison</h3>
|
||||||
|
<p class="subtitle">Same route, tinted version — see how it reads at a glance</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Tinted overlay on a full route</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 20px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0;">
|
||||||
|
|
||||||
|
<!-- from:jms (OK) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #1A7F8E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">from:jms:orders</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">ENDPOINT</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">2ms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="22,2 28,5 22,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- log (OK) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">log:incoming</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">LOG</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">5ms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="22,2 28,5 22,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- setHeader (OK) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">setHeader:type</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">SET_HEADER</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">1ms</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="22,2 28,5 22,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- bean:validate (FAILED) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #FDF2F0; border: 2px solid #C0392B; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #C0392B;">bean:validate</div>
|
||||||
|
<div style="font-size: 9px; color: #C0392B;">FAILED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #C0392B; font-weight: 500;">120ms</div>
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: white; font-weight: bold;">!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#9CA3AF" stroke-width="1" stroke-dasharray="3,3"/></svg>
|
||||||
|
|
||||||
|
<!-- to:http (SKIPPED) -->
|
||||||
|
<div style="opacity: 0.35;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">to:http:api</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#9CA3AF" stroke-width="1" stroke-dasharray="3,3"/></svg>
|
||||||
|
|
||||||
|
<!-- to:jms (SKIPPED) -->
|
||||||
|
<div style="opacity: 0.35;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">to:jms:result</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px; font-size: 11px; color: #5C5347;">
|
||||||
|
<strong>Note:</strong> Edges between executed nodes turn green. Edges leading to skipped nodes become dashed gray.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<h2>Execution Overlay: Success + Error Markers</h2>
|
||||||
|
<p class="subtitle">Every executed node gets a status badge — green check or red exclamation</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Full route with status markers</div>
|
||||||
|
<div class="mockup-body" style="background: #FAFAF8; padding: 20px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0;">
|
||||||
|
|
||||||
|
<!-- from:jms (OK) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #1A7F8E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">from:jms:orders</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">ENDPOINT</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">2ms</div>
|
||||||
|
<!-- Success marker -->
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: bold;">✓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="22,2 28,5 22,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- log (OK) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">log:incoming</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">LOG</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">5ms</div>
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: bold;">✓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="22,2 28,5 22,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- setHeader (OK) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 6px; border-left: 4px solid #3D7C47; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">setHeader:type</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">SET_HEADER</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #3D7C47; font-weight: 500;">1ms</div>
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: bold;">✓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="22,2 28,5 22,8" fill="#3D7C47"/></svg>
|
||||||
|
|
||||||
|
<!-- bean:validate (FAILED) -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #FDF2F0; border: 2px solid #C0392B; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #C6820E;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #C0392B;">bean:validate</div>
|
||||||
|
<div style="font-size: 9px; color: #C0392B;">FAILED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; bottom: 2px; right: 6px; font-size: 8px; color: #C0392B; font-weight: 500;">120ms</div>
|
||||||
|
<!-- Error marker -->
|
||||||
|
<div style="position: absolute; top: -6px; right: -6px; width: 16px; height: 16px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; color: white; font-weight: bold;">!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#9CA3AF" stroke-width="1" stroke-dasharray="3,3"/></svg>
|
||||||
|
|
||||||
|
<!-- to:http (SKIPPED) -->
|
||||||
|
<div style="opacity: 0.35;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">to:http:api</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width="30" height="10" style="flex-shrink:0;"><line x1="0" y1="5" x2="25" y2="5" stroke="#9CA3AF" stroke-width="1" stroke-dasharray="3,3"/></svg>
|
||||||
|
|
||||||
|
<!-- to:jms (SKIPPED) -->
|
||||||
|
<div style="opacity: 0.35;">
|
||||||
|
<div style="width: 140px; height: 52px; background: #fff; border: 1px solid #E4DFD8; border-radius: 6px; overflow: hidden;">
|
||||||
|
<div style="height: 5px; background: #3D7C47;"></div>
|
||||||
|
<div style="padding: 4px 8px;">
|
||||||
|
<div style="font-size: 10px; font-weight: 600; color: #1A1612;">to:jms:result</div>
|
||||||
|
<div style="font-size: 9px; color: #9C9184;">TO</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px;">Node State Legend</h3>
|
||||||
|
<div style="display: flex; gap: 16px; flex-wrap: wrap; margin-top: 8px;">
|
||||||
|
|
||||||
|
<!-- Completed -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; background: #f8f8f6; padding: 10px 14px; border-radius: 6px;">
|
||||||
|
<div style="position: relative; width: 80px; height: 36px; background: #F0F9F1; border: 1.5px solid #3D7C47; border-radius: 4px; border-left: 3px solid #3D7C47;">
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 14px; height: 14px; background: #3D7C47; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white;">✓</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #3D7C47;">5ms</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; font-weight: 600; color: #3D7C47;">Completed</div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184;">Green tint + border + check badge + duration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Failed -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; background: #f8f8f6; padding: 10px 14px; border-radius: 6px;">
|
||||||
|
<div style="position: relative; width: 80px; height: 36px; background: #FDF2F0; border: 2px solid #C0392B; border-radius: 4px;">
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 14px; height: 14px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; font-weight: bold;">!</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #C0392B;">120ms</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; font-weight: 600; color: #C0392B;">Failed</div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184;">Red tint + border + ! badge + duration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-route failure -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; background: #f8f8f6; padding: 10px 14px; border-radius: 6px;">
|
||||||
|
<div style="position: relative; width: 80px; height: 36px; background: #FDF2F0; border: 2px solid #C0392B; border-radius: 4px;">
|
||||||
|
<div style="position: absolute; top: -5px; right: -5px; width: 14px; height: 14px; background: #C0392B; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; font-weight: bold;">!</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; left: 4px; font-size: 8px; color: #C0392B;">↴</div>
|
||||||
|
<div style="position: absolute; bottom: 1px; right: 4px; font-size: 7px; color: #C0392B;">85ms</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; font-weight: 600; color: #C0392B;">Sub-route Failure</div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184;">Same as failed + drill-down arrow</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skipped -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; background: #f8f8f6; padding: 10px 14px; border-radius: 6px;">
|
||||||
|
<div style="opacity: 0.35; width: 80px; height: 36px; background: #fff; border: 1px solid #E4DFD8; border-radius: 4px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 12px; font-weight: 600; color: #9C9184;">Skipped</div>
|
||||||
|
<div style="font-size: 10px; color: #9C9184;">35% opacity, no badge, no duration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px;">Edge States</h3>
|
||||||
|
<div style="display: flex; gap: 16px; flex-wrap: wrap; margin-top: 8px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; background: #f8f8f6; padding: 10px 14px; border-radius: 6px;">
|
||||||
|
<svg width="60" height="10"><line x1="0" y1="5" x2="50" y2="5" stroke="#3D7C47" stroke-width="1.5"/><polygon points="47,2 53,5 47,8" fill="#3D7C47"/></svg>
|
||||||
|
<div style="font-size: 11px; color: #5C5347;"><strong>Traversed</strong> — green, solid</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; background: #f8f8f6; padding: 10px 14px; border-radius: 6px;">
|
||||||
|
<svg width="60" height="10"><line x1="0" y1="5" x2="50" y2="5" stroke="#9CA3AF" stroke-width="1" stroke-dasharray="3,3"/><polygon points="47,2 53,5 47,8" fill="#9CA3AF"/></svg>
|
||||||
|
<div style="font-size: 11px; color: #5C5347;"><strong>Not traversed</strong> — gray, dashed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
3
.superpowers/brainstorm/14618-1774629192/waiting.html
Normal file
3
.superpowers/brainstorm/14618-1774629192/waiting.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">Continuing in terminal...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<h2>AppConfigDetailPage — New Sections</h2>
|
||||||
|
<p class="subtitle">Taps overview, route recording map, and compress success toggle added to existing config page</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">AppConfigDetailPage — Full Layout (scrollable)</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:20px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;">
|
||||||
|
|
||||||
|
<!-- Back + Header -->
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:16px;">←</span>
|
||||||
|
<span style="font-size:16px;font-weight:600;">order-service</span>
|
||||||
|
<span style="font-family:monospace;font-size:11px;color:#6b7280;margin-left:8px;">v14 · Updated 3 min ago</span>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:8px;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:14px;" title="Edit">✎</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ EXISTING: Logging Section ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;margin-bottom:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Logging</div>
|
||||||
|
<div style="display:flex;gap:24px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Log Forwarding Level</div>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:2px 10px;border-radius:4px;font-size:11px;">INFO</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ EXISTING: Observability Section ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;margin-bottom:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Observability</div>
|
||||||
|
<div style="display:flex;gap:24px;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Engine Level</div>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:2px 10px;border-radius:4px;font-size:11px;">REGULAR</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Payload Capture</div>
|
||||||
|
<span style="background:#2d1f3b;color:#d8b4fe;padding:2px 10px;border-radius:4px;font-size:11px;">BOTH</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Metrics</div>
|
||||||
|
<span style="background:#1a3a2a;color:#86efac;padding:2px 10px;border-radius:4px;font-size:11px;">ON</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Sampling Rate</div>
|
||||||
|
<span style="font-family:monospace;font-size:12px;color:#e0e0e0;">1.0</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Compress Success</div>
|
||||||
|
<span style="background:#3b2f1f;color:#fcd34d;padding:2px 10px;border-radius:4px;font-size:11px;">OFF</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ EXISTING: Traced Processors ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;margin-bottom:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Traced Processors</div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:8px;">2 processors with custom capture modes</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Processor ID</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Capture Mode</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">unmarshal1</td>
|
||||||
|
<td style="padding:6px 8px;"><span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">BOTH</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">toDatabase</td>
|
||||||
|
<td style="padding:6px 8px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:4px;font-size:10px;">INPUT</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ NEW: Data Extraction Taps ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Data Extraction Taps</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;">3 taps · manage on route pages</span>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Attribute</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Processor</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Expression</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Language</th>
|
||||||
|
<th style="text-align:center;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-weight:500;">orderId</td>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;color:#60a5fa;">unmarshal1</td>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;"><span style="background:#161630;padding:1px 6px;border-radius:4px;">${body.orderId}</span></td>
|
||||||
|
<td style="padding:6px 8px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 6px;border-radius:4px;font-size:10px;">simple</span></td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;"><span style="color:#4ade80;">✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-weight:500;">customerId</td>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;color:#60a5fa;">unmarshal1</td>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;"><span style="background:#161630;padding:1px 6px;border-radius:4px;">${body.customer.id}</span></td>
|
||||||
|
<td style="padding:6px 8px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 6px;border-radius:4px;font-size:10px;">simple</span></td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;"><span style="color:#4ade80;">✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px;font-weight:500;">orderTotal</td>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;color:#60a5fa;">enrichPrice</td>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;"><span style="background:#161630;padding:1px 6px;border-radius:4px;">$.total</span></td>
|
||||||
|
<td style="padding:6px 8px;"><span style="background:#3b2f1f;color:#fcd34d;padding:1px 6px;border-radius:4px;font-size:10px;">jsonpath</span></td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;"><span style="color:#6b7280;">✗</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ NEW: Route Recording ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Route Recording</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;">4 of 5 routes recording</span>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Route</th>
|
||||||
|
<th style="text-align:center;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Recording</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">processOrder</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">processPayment</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">sendNotification</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">handleRefund</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#4b5563;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;left:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">healthCheck</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<h2>AppConfigDetailPage — Final Layout</h2>
|
||||||
|
<p class="subtitle">Three clean sections: Settings, Traces & Taps, Route Recording</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">AppConfigDetailPage — Complete</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:20px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;">
|
||||||
|
|
||||||
|
<!-- Back + Header -->
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:16px;">←</span>
|
||||||
|
<span style="font-size:16px;font-weight:600;">order-service</span>
|
||||||
|
<span style="font-family:monospace;font-size:11px;color:#6b7280;margin-left:8px;">v14 · Updated 3 min ago</span>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:8px;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:14px;" title="Edit">✎</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Section 1: Settings ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;margin-bottom:12px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Settings</div>
|
||||||
|
<div style="display:flex;gap:28px;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Log Forwarding</div>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:2px 10px;border-radius:4px;font-size:11px;">INFO</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Engine Level</div>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:2px 10px;border-radius:4px;font-size:11px;">REGULAR</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Payload Capture</div>
|
||||||
|
<span style="background:#2d1f3b;color:#d8b4fe;padding:2px 10px;border-radius:4px;font-size:11px;">BOTH</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Metrics</div>
|
||||||
|
<span style="background:#1a3a2a;color:#86efac;padding:2px 10px;border-radius:4px;font-size:11px;">ON</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Sampling Rate</div>
|
||||||
|
<span style="font-family:monospace;font-size:12px;color:#e0e0e0;">1.0</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:3px;">Compress Success</div>
|
||||||
|
<span style="background:#3b2f1f;color:#fcd34d;padding:2px 10px;border-radius:4px;font-size:11px;">OFF</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Section 2: Traces & Taps ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;margin-bottom:12px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Traces & Taps</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;">2 traced · 3 taps · manage taps on route pages</span>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Processor</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Capture</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Taps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:#60a5fa;">unmarshal1</td>
|
||||||
|
<td style="padding:8px;"><span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">BOTH</span></td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">orderId <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">customerId <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:#60a5fa;">toDatabase</td>
|
||||||
|
<td style="padding:8px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:4px;font-size:10px;">INPUT</span></td>
|
||||||
|
<td style="padding:8px;"><span style="color:#6b7280;font-size:11px;">—</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:#60a5fa;">enrichPrice</td>
|
||||||
|
<td style="padding:8px;"><span style="color:#6b7280;font-size:11px;">—</span></td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="background:#3b2f1f;color:#fcd34d;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">orderTotal <span style="color:#6b7280;margin-left:2px;">✗</span></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Section 3: Route Recording ═══ -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Route Recording</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;">4 of 5 routes recording</span>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;">Route</th>
|
||||||
|
<th style="text-align:center;padding:6px 8px;color:#9ca3af;font-size:11px;font-weight:500;width:80px;">Recording</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">processOrder</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">processPayment</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">sendNotification</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">handleRefund</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#4b5563;border-radius:9px;position:relative;margin:0 auto;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;left:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px;font-family:monospace;font-size:11px;">healthCheck</td>
|
||||||
|
<td style="padding:6px 8px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<h2>AppConfigDetailPage — Merged "Traces & Taps" Section</h2>
|
||||||
|
<p class="subtitle">Single table combining traced processors and data extraction taps</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Traces & Taps — Merged Table</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:20px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;">
|
||||||
|
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Traces & Taps</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;">2 traced · 3 taps · manage taps on route pages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Processor</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Capture</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Taps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Processor with both trace + taps -->
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:#60a5fa;">unmarshal1</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">BOTH</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">orderId <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">customerId <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Processor with trace only -->
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:#60a5fa;">toDatabase</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:4px;font-size:10px;">INPUT</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="color:#6b7280;font-size:11px;">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Processor with tap only (no trace override) -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;color:#60a5fa;">enrichPrice</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="color:#6b7280;font-size:11px;">—</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span style="background:#3b2f1f;color:#fcd34d;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">orderTotal <span style="color:#6b7280;margin-left:2px;">✗</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;"></div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Design Notes</h3>
|
||||||
|
<ul style="font-size:14px;line-height:1.8;">
|
||||||
|
<li><strong>One row per processor</strong> that has either a capture override or taps (or both)</li>
|
||||||
|
<li><strong>Capture column:</strong> shows the trace capture mode badge, or em-dash if default</li>
|
||||||
|
<li><strong>Taps column:</strong> attribute name badges with enabled/disabled indicator (✓ / ✗), or em-dash if none</li>
|
||||||
|
<li><strong>Tap badges color-coded by language:</strong> blue = simple, yellow = jsonpath (matches RouteDetail tap table)</li>
|
||||||
|
<li><strong>Edit mode:</strong> capture column becomes a dropdown, taps remain read-only (manage on route pages)</li>
|
||||||
|
<li><strong>Empty state:</strong> "No processor-specific traces or taps configured" with link to route pages</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<h2>ExchangeDetail — Business Attributes & Replay</h2>
|
||||||
|
<p class="subtitle">New elements added to the existing exchange detail page</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Exchange Detail Page — Header Card (enhanced)</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:20px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;">
|
||||||
|
|
||||||
|
<!-- Exchange Header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
|
||||||
|
<span style="width:10px;height:10px;border-radius:50%;background:#4ade80;display:inline-block;"></span>
|
||||||
|
<span style="font-family:monospace;font-size:15px;font-weight:600;">a1b2c3d4-e5f6-7890-abcd-ef1234567890</span>
|
||||||
|
<span style="background:#065f46;color:#6ee7b7;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">COMPLETED</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;font-size:12px;color:#9ca3af;">
|
||||||
|
<span>Route: <span style="color:#60a5fa;">processOrder</span></span>
|
||||||
|
<span>App: <span style="font-family:monospace;">order-service</span></span>
|
||||||
|
<span>Correlation: <span style="font-family:monospace;">corr-abc123</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<!-- REPLAY BUTTON (NEW) -->
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:6px;">
|
||||||
|
↻ Replay
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Business Attributes Strip (NEW) -->
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;margin-bottom:16px;">
|
||||||
|
<span style="font-size:11px;color:#9ca3af;margin-right:4px;line-height:24px;">Attributes</span>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:2px 10px;border-radius:12px;font-size:11px;font-family:monospace;">orderId: ORD-2024-78542</span>
|
||||||
|
<span style="background:#3b1f4b;color:#d8b4fe;padding:2px 10px;border-radius:12px;font-size:11px;font-family:monospace;">customerId: CUST-1234</span>
|
||||||
|
<span style="background:#1a3a2a;color:#86efac;padding:2px 10px;border-radius:12px;font-size:11px;font-family:monospace;">orderTotal: €149.90</span>
|
||||||
|
<span style="background:#3b2f1f;color:#fcd34d;padding:2px 10px;border-radius:12px;font-size:11px;font-family:monospace;">region: EU-WEST</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat boxes row -->
|
||||||
|
<div style="display:flex;gap:12px;">
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:10px 14px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Duration</div>
|
||||||
|
<div style="font-size:18px;font-weight:600;color:#4ade80;">245ms</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:10px 14px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Agent</div>
|
||||||
|
<div style="font-size:14px;font-family:monospace;color:#e0e0e0;">order-svc-01</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:10px 14px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Started</div>
|
||||||
|
<div style="font-size:14px;font-family:monospace;color:#e0e0e0;">14:23:45.123</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:10px 14px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Processors</div>
|
||||||
|
<div style="font-size:18px;font-weight:600;color:#e0e0e0;">12</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Replay Confirmation Dialog</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:40px;display:flex;justify-content:center;">
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:12px;padding:24px;width:480px;box-shadow:0 20px 60px rgba(0,0,0,0.5);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<span style="font-size:15px;font-weight:600;">Replay Exchange</span>
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;">✕</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#9ca3af;margin-bottom:16px;">
|
||||||
|
This will re-execute the exchange on the target agent. The original exchange data will be used as input.
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;">Original Exchange</div>
|
||||||
|
<div style="font-family:monospace;font-size:12px;background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;">a1b2c3d4-e5f6-7890-abcd-ef1234567890</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;">Target Agent</div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span style="font-family:monospace;">order-svc-01</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;">Route</div>
|
||||||
|
<div style="font-family:monospace;font-size:12px;background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;">processOrder</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button style="background:transparent;color:#9ca3af;border:1px solid #2d2d50;padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;">Cancel</button>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">↻ Replay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Dashboard — Exchanges Table (with business attributes)</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:16px;font-family:system-ui,-apple-system,sans-serif;font-size:12px;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;text-align:left;">
|
||||||
|
<th style="padding:8px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Status</th>
|
||||||
|
<th style="padding:8px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Route</th>
|
||||||
|
<th style="padding:8px 12px;color:#9ca3af;font-size:11px;font-weight:500;">App</th>
|
||||||
|
<th style="padding:8px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Attributes</th>
|
||||||
|
<th style="padding:8px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Exchange ID</th>
|
||||||
|
<th style="padding:8px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #1e1e3a;">
|
||||||
|
<td style="padding:8px 12px;"><span style="width:8px;height:8px;border-radius:50%;background:#4ade80;display:inline-block;"></span> <span style="color:#6ee7b7;font-size:11px;">OK</span></td>
|
||||||
|
<td style="padding:8px 12px;color:#60a5fa;">processOrder</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;">order-svc</td>
|
||||||
|
<td style="padding:8px 12px;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 6px;border-radius:8px;font-size:10px;font-family:monospace;">ORD-78542</span>
|
||||||
|
<span style="background:#3b1f4b;color:#d8b4fe;padding:1px 6px;border-radius:8px;font-size:10px;font-family:monospace;">CUST-1234</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;">a1b2c3d4-e5f6…</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;color:#4ade80;">245ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #1e1e3a;">
|
||||||
|
<td style="padding:8px 12px;"><span style="width:8px;height:8px;border-radius:50%;background:#f87171;display:inline-block;"></span> <span style="color:#fca5a5;font-size:11px;">ERR</span></td>
|
||||||
|
<td style="padding:8px 12px;color:#60a5fa;">processPayment</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;">payment-svc</td>
|
||||||
|
<td style="padding:8px 12px;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 6px;border-radius:8px;font-size:10px;font-family:monospace;">PAY-91023</span>
|
||||||
|
<span style="color:#6b7280;font-size:10px;">+2</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;">f8e7d6c5-b4a3…</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;color:#f87171;">1,234ms</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #1e1e3a;">
|
||||||
|
<td style="padding:8px 12px;"><span style="width:8px;height:8px;border-radius:50%;background:#4ade80;display:inline-block;"></span> <span style="color:#6ee7b7;font-size:11px;">OK</span></td>
|
||||||
|
<td style="padding:8px 12px;color:#60a5fa;">sendNotification</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;">notif-svc</td>
|
||||||
|
<td style="padding:8px 12px;"><span style="color:#6b7280;font-size:10px;font-style:italic;">—</span></td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;">12345678-abcd…</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;color:#4ade80;">89ms</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:12px;font-size:11px;color:#6b7280;">
|
||||||
|
Note: Attributes column shows first 2 values as compact badges, "+N" overflow indicator when more exist. Em-dash when no attributes extracted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<h2>Replay Dialog — Revised</h2>
|
||||||
|
<p class="subtitle">Target agent selection + editable payload and headers</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Replay Exchange Dialog (large modal)</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:40px;display:flex;justify-content:center;">
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:12px;padding:0;width:640px;box-shadow:0 20px 60px rgba(0,0,0,0.5);overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Dialog header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #2d2d50;">
|
||||||
|
<span style="font-size:15px;font-weight:600;">Replay Exchange</span>
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:18px;">✕</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px;">
|
||||||
|
<!-- Warning -->
|
||||||
|
<div style="font-size:12px;color:#fbbf24;background:#3b2f1f;border:1px solid #854d0e;border-radius:6px;padding:8px 12px;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<span>⚠</span> This will re-execute the exchange on the selected agent.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target Agent -->
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Target Agent</div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span style="font-family:monospace;">order-svc-01</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#6b7280;margin-top:4px;">Only LIVE agents for this application are shown</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs: Headers / Body -->
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:0;border-bottom:1px solid #2d2d50;">
|
||||||
|
<div style="padding:8px 16px;font-size:12px;font-weight:600;color:#60a5fa;border-bottom:2px solid #3b82f6;cursor:pointer;">Headers</div>
|
||||||
|
<div style="padding:8px 16px;font-size:12px;color:#9ca3af;cursor:pointer;">Body</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Headers tab content -->
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-top:none;border-radius:0 0 6px 6px;padding:12px;margin-bottom:16px;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:11px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:4px 8px;color:#9ca3af;font-weight:500;width:35%;">Key</th>
|
||||||
|
<th style="text-align:left;padding:4px 8px;color:#9ca3af;font-weight:500;">Value</th>
|
||||||
|
<th style="width:32px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #1e1e3a;">
|
||||||
|
<td style="padding:4px 8px;"><input style="background:#1a1a2e;border:1px solid #2d2d50;border-radius:4px;color:#e0e0e0;padding:4px 8px;width:100%;font-family:monospace;font-size:11px;box-sizing:border-box;" value="Content-Type" /></td>
|
||||||
|
<td style="padding:4px 8px;"><input style="background:#1a1a2e;border:1px solid #2d2d50;border-radius:4px;color:#e0e0e0;padding:4px 8px;width:100%;font-family:monospace;font-size:11px;box-sizing:border-box;" value="application/json" /></td>
|
||||||
|
<td style="padding:4px 8px;text-align:center;"><span style="color:#f87171;cursor:pointer;font-size:14px;">✕</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #1e1e3a;">
|
||||||
|
<td style="padding:4px 8px;"><input style="background:#1a1a2e;border:1px solid #2d2d50;border-radius:4px;color:#e0e0e0;padding:4px 8px;width:100%;font-family:monospace;font-size:11px;box-sizing:border-box;" value="X-Correlation-Id" /></td>
|
||||||
|
<td style="padding:4px 8px;"><input style="background:#1a1a2e;border:1px solid #2d2d50;border-radius:4px;color:#e0e0e0;padding:4px 8px;width:100%;font-family:monospace;font-size:11px;box-sizing:border-box;" value="corr-abc123" /></td>
|
||||||
|
<td style="padding:4px 8px;text-align:center;"><span style="color:#f87171;cursor:pointer;font-size:14px;">✕</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" style="padding:6px 8px;">
|
||||||
|
<span style="color:#3b82f6;cursor:pointer;font-size:11px;">+ Add header</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid #2d2d50;background:#1a1a30;">
|
||||||
|
<button style="background:transparent;color:#9ca3af;border:1px solid #2d2d50;padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;">Cancel</button>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">↻ Replay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Replay Dialog — Body Tab</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:40px;display:flex;justify-content:center;">
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:12px;padding:0;width:640px;box-shadow:0 20px 60px rgba(0,0,0,0.5);overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Dialog header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #2d2d50;">
|
||||||
|
<span style="font-size:15px;font-weight:600;">Replay Exchange</span>
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:18px;">✕</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px;">
|
||||||
|
<!-- Warning -->
|
||||||
|
<div style="font-size:12px;color:#fbbf24;background:#3b2f1f;border:1px solid #854d0e;border-radius:6px;padding:8px 12px;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<span>⚠</span> This will re-execute the exchange on the selected agent.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target Agent (collapsed) -->
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Target Agent</div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span style="font-family:monospace;">order-svc-01</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs: Headers / Body -->
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:0;border-bottom:1px solid #2d2d50;">
|
||||||
|
<div style="padding:8px 16px;font-size:12px;color:#9ca3af;cursor:pointer;">Headers</div>
|
||||||
|
<div style="padding:8px 16px;font-size:12px;font-weight:600;color:#60a5fa;border-bottom:2px solid #3b82f6;cursor:pointer;">Body</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body tab content — editable code area -->
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-top:none;border-radius:0 0 6px 6px;padding:0;margin-bottom:16px;position:relative;">
|
||||||
|
<div style="display:flex;justify-content:flex-end;padding:6px 8px;border-bottom:1px solid #2d2d50;">
|
||||||
|
<span style="font-size:10px;color:#6b7280;background:#1a1a2e;padding:2px 8px;border-radius:4px;">JSON</span>
|
||||||
|
</div>
|
||||||
|
<pre style="margin:0;padding:12px;font-family:monospace;font-size:11px;line-height:1.6;color:#e0e0e0;min-height:160px;overflow:auto;white-space:pre;"><span style="color:#9ca3af;">{</span>
|
||||||
|
<span style="color:#7dd3fc;">"orderId"</span><span style="color:#9ca3af;">:</span> <span style="color:#fcd34d;">"ORD-2024-78542"</span><span style="color:#9ca3af;">,</span>
|
||||||
|
<span style="color:#7dd3fc;">"customerId"</span><span style="color:#9ca3af;">:</span> <span style="color:#fcd34d;">"CUST-1234"</span><span style="color:#9ca3af;">,</span>
|
||||||
|
<span style="color:#7dd3fc;">"items"</span><span style="color:#9ca3af;">:</span> <span style="color:#9ca3af;">[</span>
|
||||||
|
<span style="color:#9ca3af;">{</span>
|
||||||
|
<span style="color:#7dd3fc;">"sku"</span><span style="color:#9ca3af;">:</span> <span style="color:#fcd34d;">"WIDGET-001"</span><span style="color:#9ca3af;">,</span>
|
||||||
|
<span style="color:#7dd3fc;">"qty"</span><span style="color:#9ca3af;">:</span> <span style="color:#c4b5fd;">3</span><span style="color:#9ca3af;">,</span>
|
||||||
|
<span style="color:#7dd3fc;">"price"</span><span style="color:#9ca3af;">:</span> <span style="color:#c4b5fd;">49.97</span>
|
||||||
|
<span style="color:#9ca3af;">}</span>
|
||||||
|
<span style="color:#9ca3af;">],</span>
|
||||||
|
<span style="color:#7dd3fc;">"total"</span><span style="color:#9ca3af;">:</span> <span style="color:#c4b5fd;">149.90</span>
|
||||||
|
<span style="color:#9ca3af;">}</span></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid #2d2d50;background:#1a1a30;">
|
||||||
|
<button style="background:transparent;color:#9ca3af;border:1px solid #2d2d50;padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;">Cancel</button>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">↻ Replay</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<h2>RouteDetail — Tap Management & Recording Toggle</h2>
|
||||||
|
<p class="subtitle">New "Taps" tab on RouteDetail + recording toggle in header</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">RouteDetail Page — Header with Recording Toggle</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:20px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;">
|
||||||
|
|
||||||
|
<!-- Route header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:16px;font-weight:600;margin-bottom:4px;">processOrder</div>
|
||||||
|
<div style="font-size:12px;color:#9ca3af;">
|
||||||
|
<span style="font-family:monospace;">order-service</span>
|
||||||
|
<span style="margin:0 8px;color:#2d2d50;">|</span>
|
||||||
|
<span style="color:#4ade80;">99.2% success</span>
|
||||||
|
<span style="margin:0 8px;color:#2d2d50;">|</span>
|
||||||
|
<span>245ms avg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<!-- Recording toggle -->
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:6px 12px;">
|
||||||
|
<span style="font-size:11px;color:#9ca3af;">Recording</span>
|
||||||
|
<div style="width:36px;height:20px;background:#3b82f6;border-radius:10px;position:relative;cursor:pointer;">
|
||||||
|
<div style="width:16px;height:16px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;transition:all 0.2s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI strip (abbreviated) -->
|
||||||
|
<div style="display:flex;gap:10px;margin-bottom:16px;">
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:8px 12px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;">Success Rate</div>
|
||||||
|
<div style="font-size:16px;font-weight:600;color:#4ade80;">99.2%</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:8px 12px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;">Avg Duration</div>
|
||||||
|
<div style="font-size:16px;font-weight:600;">245ms</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:8px 12px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;">Total</div>
|
||||||
|
<div style="font-size:16px;font-weight:600;">12,482</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:8px 12px;">
|
||||||
|
<div style="font-size:10px;color:#9ca3af;">Active Taps</div>
|
||||||
|
<div style="font-size:16px;font-weight:600;color:#60a5fa;">3</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div style="display:flex;gap:0;border-bottom:1px solid #2d2d50;margin-bottom:16px;">
|
||||||
|
<div style="padding:8px 16px;font-size:12px;color:#9ca3af;cursor:pointer;">Overview</div>
|
||||||
|
<div style="padding:8px 16px;font-size:12px;color:#9ca3af;cursor:pointer;">Processors</div>
|
||||||
|
<div style="padding:8px 16px;font-size:12px;color:#9ca3af;cursor:pointer;">Errors</div>
|
||||||
|
<div style="padding:8px 16px;font-size:12px;color:#9ca3af;cursor:pointer;">Executions</div>
|
||||||
|
<div style="padding:8px 16px;font-size:12px;font-weight:600;color:#60a5fa;border-bottom:2px solid #3b82f6;cursor:pointer;">Taps</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Taps tab content -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;">Data Extraction Taps</div>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:4px;">+ Add Tap</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Taps table -->
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;overflow:hidden;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Attribute</th>
|
||||||
|
<th style="text-align:left;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Processor</th>
|
||||||
|
<th style="text-align:left;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Expression</th>
|
||||||
|
<th style="text-align:left;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Language</th>
|
||||||
|
<th style="text-align:left;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Target</th>
|
||||||
|
<th style="text-align:left;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Type</th>
|
||||||
|
<th style="text-align:center;padding:10px 12px;color:#9ca3af;font-size:11px;font-weight:500;">Enabled</th>
|
||||||
|
<th style="width:60px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px 12px;font-weight:500;">orderId</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;color:#60a5fa;">unmarshal1</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;"><span style="background:#161630;padding:2px 6px;border-radius:4px;">${body.orderId}</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:4px;font-size:10px;">simple</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">OUTPUT</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#1a3a2a;color:#86efac;padding:1px 8px;border-radius:4px;font-size:10px;">BUSINESS</span></td>
|
||||||
|
<td style="padding:8px 12px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;text-align:center;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:14px;" title="Edit">✎</span>
|
||||||
|
<span style="color:#f87171;cursor:pointer;font-size:14px;margin-left:6px;" title="Delete">✕</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px 12px;font-weight:500;">customerId</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;color:#60a5fa;">unmarshal1</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;"><span style="background:#161630;padding:2px 6px;border-radius:4px;">${body.customer.id}</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:4px;font-size:10px;">simple</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">OUTPUT</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#1a3a2a;color:#86efac;padding:1px 8px;border-radius:4px;font-size:10px;">CORRELATION</span></td>
|
||||||
|
<td style="padding:8px 12px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#3b82f6;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;text-align:center;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:14px;" title="Edit">✎</span>
|
||||||
|
<span style="color:#f87171;cursor:pointer;font-size:14px;margin-left:6px;" title="Delete">✕</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 12px;font-weight:500;">orderTotal</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;color:#60a5fa;">enrichPrice</td>
|
||||||
|
<td style="padding:8px 12px;font-family:monospace;font-size:11px;"><span style="background:#161630;padding:2px 6px;border-radius:4px;">$.total</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#3b2f1f;color:#fcd34d;padding:1px 8px;border-radius:4px;font-size:10px;">jsonpath</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">OUTPUT</span></td>
|
||||||
|
<td style="padding:8px 12px;"><span style="background:#1a3a2a;color:#86efac;padding:1px 8px;border-radius:4px;font-size:10px;">BUSINESS</span></td>
|
||||||
|
<td style="padding:8px 12px;text-align:center;">
|
||||||
|
<div style="width:32px;height:18px;background:#4b5563;border-radius:9px;position:relative;margin:0 auto;cursor:pointer;">
|
||||||
|
<div style="width:14px;height:14px;background:white;border-radius:50%;position:absolute;top:2px;left:2px;"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding:8px 12px;text-align:center;">
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:14px;" title="Edit">✎</span>
|
||||||
|
<span style="color:#f87171;cursor:pointer;font-size:14px;margin-left:6px;" title="Delete">✕</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Add/Edit Tap — Modal Dialog</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:40px;display:flex;justify-content:center;">
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:12px;padding:0;width:520px;box-shadow:0 20px 60px rgba(0,0,0,0.5);overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #2d2d50;">
|
||||||
|
<span style="font-size:15px;font-weight:600;">Add Tap</span>
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:18px;">✕</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px;">
|
||||||
|
<!-- Attribute Name -->
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Attribute Name <span style="color:#f87171;">*</span></div>
|
||||||
|
<input style="background:#161630;border:1px solid #2d2d50;border-radius:6px;color:#e0e0e0;padding:8px 12px;width:100%;font-size:12px;box-sizing:border-box;" placeholder="e.g. orderId, customerId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processor -->
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Processor <span style="color:#f87171;">*</span></div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span style="color:#6b7280;">Select processor…</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#6b7280;margin-top:3px;">Processors from this route's diagram</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two columns: Language + Target -->
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:14px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Language <span style="color:#f87171;">*</span></div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>simple</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Target <span style="color:#f87171;">*</span></div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>OUTPUT</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expression -->
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Expression <span style="color:#f87171;">*</span></div>
|
||||||
|
<textarea style="background:#161630;border:1px solid #2d2d50;border-radius:6px;color:#e0e0e0;padding:8px 12px;width:100%;font-family:monospace;font-size:12px;box-sizing:border-box;resize:vertical;min-height:48px;" placeholder="e.g. ${body.orderId} or $.customer.id">${body.orderId}</textarea>
|
||||||
|
<div style="font-size:10px;color:#6b7280;margin-top:3px;">Camel expression — evaluated at the selected processor</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attribute Type -->
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Attribute Type</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<div style="background:#1e3a5f;color:#7dd3fc;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #3b82f6;">BUSINESS_OBJECT</div>
|
||||||
|
<div style="background:#161630;color:#9ca3af;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #2d2d50;">CORRELATION</div>
|
||||||
|
<div style="background:#161630;color:#9ca3af;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #2d2d50;">EVENT</div>
|
||||||
|
<div style="background:#161630;color:#9ca3af;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #2d2d50;">CUSTOM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enabled -->
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||||
|
<div style="width:36px;height:20px;background:#3b82f6;border-radius:10px;position:relative;cursor:pointer;">
|
||||||
|
<div style="width:16px;height:16px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:12px;color:#e0e0e0;">Enabled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid #2d2d50;background:#1a1a30;">
|
||||||
|
<button style="background:transparent;color:#9ca3af;border:1px solid #2d2d50;padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;">Cancel</button>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">Save Tap</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<h2>Add Tap — With Expression Testing</h2>
|
||||||
|
<p class="subtitle">Collapsible test section at bottom of the tap modal</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Add Tap Modal — Test Expression (Recent Exchange)</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:40px;display:flex;justify-content:center;">
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:12px;padding:0;width:560px;box-shadow:0 20px 60px rgba(0,0,0,0.5);overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #2d2d50;">
|
||||||
|
<span style="font-size:15px;font-weight:600;">Add Tap</span>
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:18px;">✕</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px;max-height:70vh;overflow-y:auto;">
|
||||||
|
<!-- Form fields (collapsed for brevity) -->
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Attribute Name <span style="color:#f87171;">*</span></div>
|
||||||
|
<input style="background:#161630;border:1px solid #2d2d50;border-radius:6px;color:#e0e0e0;padding:8px 12px;width:100%;font-size:12px;box-sizing:border-box;" value="orderId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Processor <span style="color:#f87171;">*</span></div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span style="font-family:monospace;">unmarshal1</span>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;margin-bottom:14px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Language</div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>simple</span><span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Target</div>
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:12px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<span>OUTPUT</span><span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Expression <span style="color:#f87171;">*</span></div>
|
||||||
|
<textarea style="background:#161630;border:1px solid #2d2d50;border-radius:6px;color:#e0e0e0;padding:8px 12px;width:100%;font-family:monospace;font-size:12px;box-sizing:border-box;resize:vertical;min-height:40px;">${body.orderId}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px;font-weight:500;">Attribute Type</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<div style="background:#1e3a5f;color:#7dd3fc;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #3b82f6;">BUSINESS_OBJECT</div>
|
||||||
|
<div style="background:#161630;color:#9ca3af;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #2d2d50;">CORRELATION</div>
|
||||||
|
<div style="background:#161630;color:#9ca3af;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #2d2d50;">EVENT</div>
|
||||||
|
<div style="background:#161630;color:#9ca3af;padding:4px 12px;border-radius:6px;font-size:11px;cursor:pointer;border:1px solid #2d2d50;">CUSTOM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ TEST EXPRESSION SECTION ═══ -->
|
||||||
|
<div style="border-top:1px solid #2d2d50;margin-top:8px;padding-top:14px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;">
|
||||||
|
<span style="color:#60a5fa;font-size:10px;">▼</span>
|
||||||
|
<span style="font-size:12px;font-weight:600;color:#60a5fa;">Test Expression</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data source tabs -->
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:0;border-bottom:1px solid #2d2d50;">
|
||||||
|
<div style="padding:6px 14px;font-size:11px;font-weight:600;color:#60a5fa;border-bottom:2px solid #3b82f6;cursor:pointer;">Recent Exchange</div>
|
||||||
|
<div style="padding:6px 14px;font-size:11px;color:#9ca3af;cursor:pointer;">Custom Payload</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent exchange picker -->
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-top:none;border-radius:0 0 6px 6px;padding:12px;">
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<div style="background:#1a1a2e;border:1px solid #2d2d50;border-radius:6px;padding:8px 12px;font-size:11px;display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="width:7px;height:7px;border-radius:50%;background:#4ade80;display:inline-block;"></span>
|
||||||
|
<span style="font-family:monospace;color:#e0e0e0;">a1b2c3d4-e5f6-7890</span>
|
||||||
|
<span style="color:#6b7280;">·</span>
|
||||||
|
<span style="color:#6b7280;">245ms</span>
|
||||||
|
<span style="color:#6b7280;">·</span>
|
||||||
|
<span style="color:#6b7280;">2 min ago</span>
|
||||||
|
</div>
|
||||||
|
<span style="color:#9ca3af;font-size:10px;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test button + result -->
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-start;">
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap;">▶ Test</button>
|
||||||
|
<div style="flex:1;background:#0f2a1a;border:1px solid #166534;border-radius:6px;padding:8px 12px;">
|
||||||
|
<div style="font-size:10px;color:#6b7280;margin-bottom:2px;">Result</div>
|
||||||
|
<div style="font-family:monospace;font-size:12px;color:#4ade80;">ORD-2024-78542</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid #2d2d50;background:#1a1a30;">
|
||||||
|
<button style="background:transparent;color:#9ca3af;border:1px solid #2d2d50;padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;">Cancel</button>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">Save Tap</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:24px;"></div>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Test Expression — Custom Payload Mode</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:40px;display:flex;justify-content:center;">
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:12px;padding:0;width:560px;box-shadow:0 20px 60px rgba(0,0,0,0.5);overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #2d2d50;">
|
||||||
|
<span style="font-size:15px;font-weight:600;">Add Tap</span>
|
||||||
|
<span style="color:#9ca3af;cursor:pointer;font-size:18px;">✕</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px;">
|
||||||
|
<!-- Form fields abbreviated -->
|
||||||
|
<div style="text-align:center;padding:8px;font-size:11px;color:#6b7280;border:1px dashed #2d2d50;border-radius:6px;margin-bottom:14px;">
|
||||||
|
⬆ Form fields above (attribute name, processor, language, target, expression, type)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ TEST EXPRESSION SECTION ═══ -->
|
||||||
|
<div style="border-top:1px solid #2d2d50;padding-top:14px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:12px;cursor:pointer;">
|
||||||
|
<span style="color:#60a5fa;font-size:10px;">▼</span>
|
||||||
|
<span style="font-size:12px;font-weight:600;color:#60a5fa;">Test Expression</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data source tabs -->
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:0;border-bottom:1px solid #2d2d50;">
|
||||||
|
<div style="padding:6px 14px;font-size:11px;color:#9ca3af;cursor:pointer;">Recent Exchange</div>
|
||||||
|
<div style="padding:6px 14px;font-size:11px;font-weight:600;color:#60a5fa;border-bottom:2px solid #3b82f6;cursor:pointer;">Custom Payload</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom payload editor -->
|
||||||
|
<div style="background:#161630;border:1px solid #2d2d50;border-top:none;border-radius:0 0 6px 6px;padding:12px;">
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<textarea style="background:#1a1a2e;border:1px solid #2d2d50;border-radius:6px;color:#e0e0e0;padding:8px 12px;width:100%;font-family:monospace;font-size:11px;box-sizing:border-box;resize:vertical;min-height:100px;line-height:1.5;">{
|
||||||
|
"orderId": "ORD-2024-78542",
|
||||||
|
"customer": {
|
||||||
|
"id": "CUST-1234",
|
||||||
|
"name": "Acme Corp"
|
||||||
|
},
|
||||||
|
"total": 149.90
|
||||||
|
}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test button + error result -->
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-start;">
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap;">▶ Test</button>
|
||||||
|
<div style="flex:1;background:#2a0f0f;border:1px solid #991b1b;border-radius:6px;padding:8px 12px;">
|
||||||
|
<div style="font-size:10px;color:#6b7280;margin-bottom:2px;">Error</div>
|
||||||
|
<div style="font-family:monospace;font-size:11px;color:#f87171;">Expression evaluation timed out (50ms limit)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#6b7280;margin-top:8px;">Evaluated by agent <span style="font-family:monospace;">order-svc-01</span> using Camel's <span style="font-family:monospace;">simple</span> language</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid #2d2d50;background:#1a1a30;">
|
||||||
|
<button style="background:transparent;color:#9ca3af;border:1px solid #2d2d50;padding:6px 16px;border-radius:6px;font-size:12px;cursor:pointer;">Cancel</button>
|
||||||
|
<button style="background:#3b82f6;color:white;border:none;padding:6px 16px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;">Save Tap</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<h2>Traces & Taps — With Route Column</h2>
|
||||||
|
<p class="subtitle">Route column added to prevent ambiguity across routes</p>
|
||||||
|
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">Traces & Taps — Updated</div>
|
||||||
|
<div class="mockup-body" style="background:#1a1a2e;color:#e0e0e0;padding:20px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;">
|
||||||
|
|
||||||
|
<div style="background:#1e1e3a;border:1px solid #2d2d50;border-radius:8px;padding:16px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||||
|
<div style="font-size:12px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;">Traces & Taps</div>
|
||||||
|
<span style="font-size:11px;color:#6b7280;">3 traced · 4 taps · manage taps on route pages</span>
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom:1px solid #2d2d50;">
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Route</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Processor</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Capture</th>
|
||||||
|
<th style="text-align:left;padding:8px;color:#9ca3af;font-size:11px;font-weight:500;">Taps</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;color:#60a5fa;font-size:11px;">processOrder</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;">unmarshal1</td>
|
||||||
|
<td style="padding:8px;"><span style="background:#2d1f3b;color:#d8b4fe;padding:1px 8px;border-radius:4px;font-size:10px;">BOTH</span></td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">orderId <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">customerId <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;color:#60a5fa;font-size:11px;">processOrder</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;">enrichPrice</td>
|
||||||
|
<td style="padding:8px;"><span style="color:#6b7280;font-size:11px;">—</span></td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="background:#3b2f1f;color:#fcd34d;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">orderTotal <span style="color:#6b7280;margin-left:2px;">✗</span></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom:1px solid #161630;">
|
||||||
|
<td style="padding:8px;color:#60a5fa;font-size:11px;">processPayment</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;">toDatabase</td>
|
||||||
|
<td style="padding:8px;"><span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:4px;font-size:10px;">INPUT</span></td>
|
||||||
|
<td style="padding:8px;"><span style="color:#6b7280;font-size:11px;">—</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px;color:#60a5fa;font-size:11px;">processPayment</td>
|
||||||
|
<td style="padding:8px;font-family:monospace;font-size:11px;">validate1</td>
|
||||||
|
<td style="padding:8px;"><span style="color:#6b7280;font-size:11px;">—</span></td>
|
||||||
|
<td style="padding:8px;">
|
||||||
|
<span style="background:#1e3a5f;color:#7dd3fc;padding:1px 8px;border-radius:10px;font-size:10px;font-family:monospace;">paymentRef <span style="color:#4ade80;margin-left:2px;">✓</span></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">Continuing in terminal...</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1774552065018}
|
||||||
1
.superpowers/brainstorm/2048-1774541143/state/server.pid
Normal file
1
.superpowers/brainstorm/2048-1774541143/state/server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2048
|
||||||
338
CLAUDE.md
338
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Cameleer3 Server — observability server that receives, stores, and serves Camel route execution data and route diagrams from Cameleer3 agents. Pushes config and commands to agents via SSE.
|
Cameleer3 Server — observability server that receives, stores, and serves Camel route execution data and route diagrams from Cameleer3 agents. Pushes config and commands to agents via SSE. Also orchestrates Docker container deployments when running under cameleer-saas.
|
||||||
|
|
||||||
## Related Project
|
## Related Project
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ Cameleer3 Server — observability server that receives, stores, and serves Came
|
|||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
- `cameleer3-server-core` — domain logic, storage, agent registry
|
- `cameleer3-server-core` — domain logic, storage interfaces, services (no Spring dependencies)
|
||||||
- `cameleer3-server-app` — Spring Boot web app, REST controllers, SSE, static resources
|
- `cameleer3-server-app` — Spring Boot web app, REST controllers, SSE, persistence, Docker orchestration
|
||||||
|
|
||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
@@ -30,33 +30,345 @@ mvn clean verify # Full build with tests
|
|||||||
java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Key Classes by Package
|
||||||
|
|
||||||
|
### Core Module (`cameleer3-server-core/src/main/java/com/cameleer3/server/core/`)
|
||||||
|
|
||||||
|
**agent/** — Agent lifecycle and commands
|
||||||
|
- `AgentRegistryService` — in-memory registry (ConcurrentHashMap), register/heartbeat/lifecycle
|
||||||
|
- `AgentInfo` — record: id, name, application, environmentId, version, routeIds, capabilities, state
|
||||||
|
- `AgentCommand` — record: id, type, targetAgent, payload, createdAt, expiresAt
|
||||||
|
- `AgentEventService` — records agent state changes, heartbeats
|
||||||
|
|
||||||
|
**runtime/** — App/Environment/Deployment domain
|
||||||
|
- `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
|
||||||
|
- `AppVersion` — record: id, appId, version, jarPath
|
||||||
|
- `Environment` — record: id, slug, jarRetentionCount
|
||||||
|
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
|
||||||
|
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
|
||||||
|
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
|
||||||
|
- `DeploymentService` — createDeployment (deletes terminal deployments first), markRunning, markFailed, markStopped
|
||||||
|
- `ContainerRequest` — record: 17 fields for Docker container creation
|
||||||
|
- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, cpuShares, cpuLimit, appPort, replicas, routingMode, etc.
|
||||||
|
- `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig
|
||||||
|
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs
|
||||||
|
|
||||||
|
**search/** — Execution search
|
||||||
|
- `SearchService` — search, topErrors, punchcard, distinctAttributeKeys
|
||||||
|
- `SearchRequest` / `SearchResult` — search DTOs
|
||||||
|
|
||||||
|
**storage/** — Storage abstractions
|
||||||
|
- `ExecutionStore`, `MetricsStore`, `DiagramStore`, `SearchIndex`, `LogIndex` — interfaces
|
||||||
|
|
||||||
|
**rbac/** — Role-based access control
|
||||||
|
- `RbacService` — getDirectRolesForUser, syncOidcRoles, assignRole
|
||||||
|
- `SystemRole` — enum: AGENT, VIEWER, OPERATOR, ADMIN; `normalizeScope()` maps scopes
|
||||||
|
- `UserDetail`, `RoleDetail`, `GroupDetail` — records
|
||||||
|
|
||||||
|
**security/** — Auth
|
||||||
|
- `JwtService` — interface: createAccessToken, validateAccessToken
|
||||||
|
- `Ed25519SigningService` — interface: sign, verify (config signing)
|
||||||
|
- `OidcConfig` — record: issuerUri, clientId, audience, rolesClaim, additionalScopes
|
||||||
|
|
||||||
|
**ingestion/** — Buffered data pipeline
|
||||||
|
- `IngestionService` — ingestExecution, ingestMetric, ingestLog, ingestDiagram
|
||||||
|
- `ChunkAccumulator` — batches data for efficient flush
|
||||||
|
|
||||||
|
### App Module (`cameleer3-server-app/src/main/java/com/cameleer3/server/app/`)
|
||||||
|
|
||||||
|
**controller/** — REST endpoints
|
||||||
|
- `AgentRegistrationController` — POST /register, POST /heartbeat, GET / (list), POST /refresh-token
|
||||||
|
- `AgentSseController` — GET /sse (Server-Sent Events connection)
|
||||||
|
- `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack
|
||||||
|
- `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions
|
||||||
|
- `DeploymentController` — GET/POST /api/v1/apps/{appId}/deployments, POST /{id}/stop, POST /{id}/promote, GET /{id}/logs
|
||||||
|
- `EnvironmentAdminController` — CRUD /api/v1/admin/environments, PUT /{id}/jar-retention
|
||||||
|
- `ExecutionController` — GET /api/v1/executions (search + detail)
|
||||||
|
- `SearchController` — POST /api/v1/search, GET /routes, GET /top-errors, GET /punchcard
|
||||||
|
- `LogQueryController` — GET /api/v1/logs, GET /tail
|
||||||
|
- `ChunkIngestionController` — POST /api/v1/ingestion/chunk/{executions|metrics|diagrams}
|
||||||
|
- `UserAdminController` — CRUD /api/v1/admin/users, POST /{id}/roles, POST /{id}/set-password
|
||||||
|
- `RoleAdminController` — CRUD /api/v1/admin/roles
|
||||||
|
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
||||||
|
- `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test
|
||||||
|
- `AuditLogController` — GET /api/v1/admin/audit
|
||||||
|
- `MetricsController` — GET /api/v1/metrics, GET /timeseries
|
||||||
|
- `DiagramController` — GET /api/v1/diagrams/{id}, POST /
|
||||||
|
- `DiagramRenderController` — POST /api/v1/diagrams/render (ELK layout)
|
||||||
|
- `LicenseAdminController` — GET/POST /api/v1/admin/license
|
||||||
|
|
||||||
|
**runtime/** — Docker orchestration
|
||||||
|
- `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle
|
||||||
|
- `DeploymentExecutor` — @Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE
|
||||||
|
- `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers
|
||||||
|
- `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status
|
||||||
|
- `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing
|
||||||
|
- `DisabledRuntimeOrchestrator` — no-op when runtime not enabled
|
||||||
|
|
||||||
|
**storage/** — PostgreSQL repositories (JdbcTemplate)
|
||||||
|
- `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository`
|
||||||
|
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
||||||
|
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
||||||
|
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`
|
||||||
|
|
||||||
|
**storage/** — ClickHouse stores
|
||||||
|
- `ClickHouseExecutionStore`, `ClickHouseMetricsStore`, `ClickHouseLogStore`
|
||||||
|
- `ClickHouseStatsStore` — pre-aggregated stats, punchcard
|
||||||
|
- `ClickHouseDiagramStore`, `ClickHouseAgentEventRepository`
|
||||||
|
- `ClickHouseSearchIndex` — full-text search
|
||||||
|
- `ClickHouseUsageTracker` — usage_events for billing
|
||||||
|
|
||||||
|
**security/** — Spring Security
|
||||||
|
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional
|
||||||
|
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
|
||||||
|
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
|
||||||
|
- `OidcAuthController` — /api/v1/auth/oidc (login-uri, token-exchange, logout)
|
||||||
|
- `OidcTokenExchanger` — code -> tokens, role extraction from access_token then id_token
|
||||||
|
- `OidcProviderHelper` — OIDC discovery, JWK source cache
|
||||||
|
|
||||||
|
**agent/** — Agent lifecycle
|
||||||
|
- `SseConnectionManager` — manages per-agent SSE connections, delivers commands
|
||||||
|
- `AgentLifecycleMonitor` — @Scheduled 10s, LIVE->STALE->DEAD transitions
|
||||||
|
|
||||||
|
**retention/** — JAR cleanup
|
||||||
|
- `JarRetentionJob` — @Scheduled 03:00 daily, per-environment retention, skips deployed versions
|
||||||
|
|
||||||
|
**config/** — Spring beans
|
||||||
|
- `RuntimeOrchestratorAutoConfig` — conditional Docker/Disabled orchestrator + NetworkManager + EventMonitor
|
||||||
|
- `RuntimeBeanConfig` — DeploymentExecutor, AppService, EnvironmentService
|
||||||
|
- `SecurityBeanConfig` — JwtService, Ed25519, BootstrapTokenValidator
|
||||||
|
- `StorageBeanConfig` — all repositories
|
||||||
|
- `ClickHouseConfig` — ClickHouse JdbcTemplate, schema initializer
|
||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
- Java 17+ required
|
- Java 17+ required
|
||||||
- Spring Boot 3.4.3 parent POM
|
- Spring Boot 3.4.3 parent POM
|
||||||
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
|
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
|
||||||
- Jackson `JavaTimeModule` for `Instant` deserialization
|
- Jackson `JavaTimeModule` for `Instant` deserialization
|
||||||
- Communication: receives HTTP POST data from agents, serves SSE event streams for config push/commands
|
- Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands (config-update, deep-trace, replay, route-control)
|
||||||
- Maintains agent instance registry with states: LIVE → STALE → DEAD
|
- Environment filtering: all data queries (exchanges, dashboard stats, route metrics, agent events, correlation) filter by the selected environment. All commands (config-update, route-control, set-traced-processors, replay) target only agents in the selected environment when one is selected. `AgentRegistryService.findByApplicationAndEnvironment()` for environment-scoped command dispatch. Backend endpoints accept optional `environment` query parameter; null = all environments (backward compatible).
|
||||||
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search
|
- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim > `"default"`). Capabilities and route states updated on every heartbeat (protocol v2). Route catalog falls back to ClickHouse stats for route discovery when registry has incomplete data.
|
||||||
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
- Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class — agents send `environmentId` at registration and in heartbeats. JWT carries `env` claim for environment persistence across token refresh. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`). ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`.
|
||||||
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
|
- Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION.
|
||||||
|
- Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml
|
||||||
|
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_CORS_ALLOWED_ORIGINS` (comma-separated) overrides `CAMELEER_UI_ORIGIN` for multi-origin setups (e.g., reverse proxy). UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER. Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. Last-ADMIN guard: system prevents removal of the last ADMIN role (409 Conflict on role removal, user deletion, group role removal). Password policy: min 12 chars, 3-of-4 character classes, no username match (enforced on user creation and admin password reset). Brute-force protection: 5 failed attempts -> 15 min lockout (tracked via `failed_login_attempts` / `locked_until` on users table). Token revocation: `token_revoked_before` column on users, checked in `JwtAuthenticationFilter`, set on password change.
|
||||||
|
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` -> ADMIN, `operator`/`server:operator` -> OPERATOR, `viewer`/`server:viewer` -> VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login via `syncOidcRoles` — always overwrites directly-assigned roles (falls back to `defaultRoles` when OIDC returns none); uses `getDirectRolesForUser` to avoid touching group-inherited roles. Group memberships are never touched. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set).
|
||||||
|
- OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type, decoded by a separate processor), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator — included in both authorization request and token exchange POST body to trigger JWT access tokens) and `additionalScopes` (extra scopes for the SPA to request). The `rolesClaim` config points to the claim name in the token (e.g., `"roles"` for Custom JWT claims, `"realm_access.roles"` for Keycloak). All provider-specific configuration is external — no provider-specific code in the server.
|
||||||
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
||||||
|
- Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
PostgreSQL (Flyway): `cameleer3-server-app/src/main/resources/db/migration/`
|
||||||
|
- V1 — RBAC (users, roles, groups, audit_log)
|
||||||
|
- V2 — Claim mappings (OIDC)
|
||||||
|
- V3 — Runtime management (apps, environments, deployments, app_versions)
|
||||||
|
- V4 — Environment config (default_container_config JSONB)
|
||||||
|
- V5 — App container config (container_config JSONB on apps)
|
||||||
|
- V6 — JAR retention policy (jar_retention_count on environments)
|
||||||
|
- V7 — Deployment orchestration (target_state, deployment_strategy, replica_states JSONB, deploy_stage)
|
||||||
|
- V8 — Deployment active config (resolved_config JSONB on deployments)
|
||||||
|
- V9 — Password hardening (failed_login_attempts, locked_until, token_revoked_before on users)
|
||||||
|
|
||||||
|
ClickHouse: `cameleer3-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
|
||||||
|
|
||||||
## CI/CD & Deployment
|
## CI/CD & Deployment
|
||||||
|
|
||||||
- CI workflow: `.gitea/workflows/ci.yml` — build → docker → deploy on push to main or feature branches
|
- CI workflow: `.gitea/workflows/ci.yml` — build -> docker -> deploy on push to main or feature branches
|
||||||
- Build step skips integration tests (`-DskipITs`) — Testcontainers needs Docker daemon
|
- Build step skips integration tests (`-DskipITs`) — Testcontainers needs Docker daemon
|
||||||
- Docker: multi-stage build (`Dockerfile`), `$BUILDPLATFORM` for native Maven on ARM64 runner, amd64 runtime
|
- Docker: multi-stage build (`Dockerfile`), `$BUILDPLATFORM` for native Maven on ARM64 runner, amd64 runtime
|
||||||
- `REGISTRY_TOKEN` build arg required for `cameleer3-common` dependency resolution
|
- `REGISTRY_TOKEN` build arg required for `cameleer3-common` dependency resolution
|
||||||
- Registry: `gitea.siegeln.net/cameleer/cameleer3-server` (container images)
|
- Registry: `gitea.siegeln.net/cameleer/cameleer3-server` (container images)
|
||||||
- K8s manifests in `deploy/` — Kustomize base + overlays (main/feature), shared infra (PostgreSQL, OpenSearch, Authentik) as top-level manifests
|
- K8s manifests in `deploy/` — Kustomize base + overlays (main/feature), shared infra (PostgreSQL, ClickHouse, Logto) as top-level manifests
|
||||||
- Deployment target: k3s at 192.168.50.86, namespace `cameleer` (main), `cam-<slug>` (feature branches)
|
- Deployment target: k3s at 192.168.50.86, namespace `cameleer` (main), `cam-<slug>` (feature branches)
|
||||||
- Feature branches: isolated namespace, PG schema, OpenSearch index prefix; Traefik Ingress at `<slug>-api.cameleer.siegeln.net`
|
- Feature branches: isolated namespace, PG schema; Traefik Ingress at `<slug>-api.cameleer.siegeln.net`
|
||||||
- Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials`
|
- Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `clickhouse-credentials`
|
||||||
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health`
|
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready -U "$POSTGRES_USER"` (env var, not hardcoded)
|
||||||
|
- K8s security: server and database pods run with `securityContext.runAsNonRoot`. UI (nginx) runs without securityContext (needs root for entrypoint setup).
|
||||||
|
- Docker: server Dockerfile has no default credentials — all DB config comes from env vars at runtime
|
||||||
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
|
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
|
||||||
|
- CI: branch slug sanitization extracted to `.gitea/sanitize-branch.sh`, sourced by docker and deploy-feature jobs
|
||||||
|
|
||||||
|
## UI Structure
|
||||||
|
|
||||||
|
The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments**.
|
||||||
|
|
||||||
|
- **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`)
|
||||||
|
- **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`)
|
||||||
|
- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`)
|
||||||
|
- **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`)
|
||||||
|
- Config sub-tabs: **Variables | Monitoring | Traces & Taps | Route Recording | Resources**
|
||||||
|
- Create app: full page at `/apps/new` (not a modal)
|
||||||
|
- Deployment progress: `ui/src/components/DeploymentProgress.tsx` (7-stage step indicator)
|
||||||
|
|
||||||
|
### Key UI Files
|
||||||
|
|
||||||
|
- `ui/src/router.tsx` — React Router v6 routes
|
||||||
|
- `ui/src/config.ts` — apiBaseUrl, basePath
|
||||||
|
- `ui/src/auth/auth-store.ts` — Zustand: accessToken, user, roles, login/logout
|
||||||
|
- `ui/src/api/environment-store.ts` — Zustand: selected environment (localStorage)
|
||||||
|
- `ui/src/components/ContentTabs.tsx` — main tab switcher
|
||||||
|
- `ui/src/components/ExecutionDiagram/` — interactive trace view (canvas)
|
||||||
|
- `ui/src/components/ProcessDiagram/` — ELK-rendered route diagram
|
||||||
|
- `ui/src/hooks/useScope.ts` — TabKey type, scope inference
|
||||||
|
|
||||||
|
## UI Styling
|
||||||
|
|
||||||
|
- Always use `@cameleer/design-system` CSS variables for colors (`var(--amber)`, `var(--error)`, `var(--success)`, etc.) — never hardcode hex values. This applies to CSS modules, inline styles, and SVG `fill`/`stroke` attributes. SVG presentation attributes resolve `var()` correctly. All colors use CSS variables (no hardcoded hex).
|
||||||
|
- Shared CSS modules in `ui/src/styles/` (table-section, log-panel, rate-colors, refresh-indicator, chart-card, section-card) — import these instead of duplicating patterns.
|
||||||
|
- Shared `PageLoader` component replaces copy-pasted spinner patterns.
|
||||||
|
- Design system components used consistently: `Select`, `Tabs`, `Toggle`, `Button`, `LogViewer`, `Label` — prefer DS components over raw HTML elements.
|
||||||
|
- Environment slugs are auto-computed from display name (read-only in UI).
|
||||||
|
- Brand assets: `@cameleer/design-system/assets/` provides `camel-logo.svg` (currentColor), `cameleer3-{16,32,48,192,512}.png`, and `cameleer3-logo.png`. Copied to `ui/public/` for use as favicon (`favicon-16.png`, `favicon-32.png`) and logo (`camel-logo.svg` — login dialog 36px, sidebar 28x24px).
|
||||||
|
- Sidebar generates `/exchanges/` paths directly (no legacy `/apps/` redirects). basePath is centralized in `ui/src/config.ts`; router.tsx imports it instead of re-reading `<base>` tag.
|
||||||
|
- Global user preferences (environment selection) use Zustand stores with localStorage persistence — never URL search params. URL params are for page-specific state only (e.g. `?text=` search query). Switching environment resets all filters and remounts pages.
|
||||||
|
|
||||||
|
## Docker Orchestration
|
||||||
|
|
||||||
|
When deployed via the cameleer-saas platform, this server orchestrates customer app containers using Docker. Key components:
|
||||||
|
|
||||||
|
- **ConfigMerger** (`core/runtime/ConfigMerger.java`) — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig. Three-layer merge: global (application.yml) -> environment (defaultContainerConfig JSONB) -> app (containerConfig JSONB).
|
||||||
|
- **TraefikLabelBuilder** (`app/runtime/TraefikLabelBuilder.java`) — generates Traefik Docker labels for path-based (`/{envSlug}/{appSlug}/`) or subdomain-based (`{appSlug}-{envSlug}.{domain}`) routing. Supports strip-prefix and SSL offloading toggles.
|
||||||
|
- **DockerNetworkManager** (`app/runtime/DockerNetworkManager.java`) — manages two Docker network tiers:
|
||||||
|
- `cameleer-traefik` — shared network; Traefik, server, and all app containers attach here. Server joined via docker-compose with `cameleer3-server` DNS alias.
|
||||||
|
- `cameleer-env-{slug}` — per-environment isolated network; containers in the same environment discover each other via Docker DNS.
|
||||||
|
- **DockerEventMonitor** (`app/runtime/DockerEventMonitor.java`) — persistent Docker event stream listener for containers with `managed-by=cameleer3-server` label. Detects die/oom/start/stop events and updates deployment replica states. Periodic reconciliation (@Scheduled every 30s) inspects actual container state and corrects deployment status mismatches (fixes stale DEGRADED with all replicas healthy).
|
||||||
|
- **DeploymentProgress** (`ui/src/components/DeploymentProgress.tsx`) — UI step indicator showing 7 deploy stages with amber active/green completed styling.
|
||||||
|
|
||||||
|
### Deployment Status Model
|
||||||
|
|
||||||
|
Deployments move through these statuses:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `STOPPED` | Intentionally stopped or initial state |
|
||||||
|
| `STARTING` | Deploy in progress |
|
||||||
|
| `RUNNING` | All replicas healthy and serving |
|
||||||
|
| `DEGRADED` | Some replicas healthy, some dead |
|
||||||
|
| `STOPPING` | Graceful shutdown in progress |
|
||||||
|
| `FAILED` | Terminal failure (pre-flight, health check, or crash) |
|
||||||
|
|
||||||
|
**Replica support**: deployments can specify a replica count. `DEGRADED` is used when at least one but not all replicas are healthy.
|
||||||
|
|
||||||
|
**Deploy stages** (`DeployStage`): PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE (or FAILED at any stage).
|
||||||
|
|
||||||
|
**Blue/green strategy**: when re-deploying, new replicas are started and health-checked before old ones are stopped, minimising downtime.
|
||||||
|
|
||||||
|
**Deployment uniqueness**: `DeploymentService.createDeployment()` deletes any STOPPED/FAILED deployments for the same app+environment before creating a new one, preventing duplicate rows.
|
||||||
|
|
||||||
|
### JAR Management
|
||||||
|
|
||||||
|
- **Retention policy** per environment: configurable maximum number of JAR versions to keep. Older JARs are deleted automatically.
|
||||||
|
- **Nightly cleanup job** (`JarRetentionJob`, Spring `@Scheduled` 03:00): purges JARs exceeding the retention limit and removes orphaned files not referenced by any app version. Skips versions currently deployed.
|
||||||
|
- **Volume-based JAR mounting** for Docker-in-Docker setups: set `CAMELEER_JAR_DOCKER_VOLUME` to the Docker volume name that contains the JAR storage directory. When set, the orchestrator mounts this volume into the container instead of bind-mounting the host path (required when the SaaS container itself runs inside Docker and the host path is not accessible from sibling containers).
|
||||||
|
|
||||||
|
### nginx / Reverse Proxy
|
||||||
|
|
||||||
|
- `client_max_body_size 200m` is required in the nginx config to allow JAR uploads up to 200 MB. Without this, large JAR uploads return 413.
|
||||||
|
|
||||||
## Disabled Skills
|
## Disabled Skills
|
||||||
|
|
||||||
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
||||||
|
|
||||||
|
<!-- gitnexus:start -->
|
||||||
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
|
This project is indexed by GitNexus as **cameleer3-server** (5509 symbols, 13919 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
## Always Do
|
||||||
|
|
||||||
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||||
|
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||||
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||||
|
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||||
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||||
|
|
||||||
|
## When Debugging
|
||||||
|
|
||||||
|
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||||
|
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||||
|
3. `READ gitnexus://repo/cameleer3-server/process/{processName}` — trace the full execution flow step by step
|
||||||
|
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||||
|
|
||||||
|
## When Refactoring
|
||||||
|
|
||||||
|
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||||
|
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||||
|
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||||
|
|
||||||
|
## Never Do
|
||||||
|
|
||||||
|
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||||
|
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||||
|
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||||
|
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||||
|
|
||||||
|
## Tools Quick Reference
|
||||||
|
|
||||||
|
| Tool | When to use | Command |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||||
|
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||||
|
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||||
|
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||||
|
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||||
|
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||||
|
|
||||||
|
## Impact Risk Levels
|
||||||
|
|
||||||
|
| Depth | Meaning | Action |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||||
|
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||||
|
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
| Resource | Use for |
|
||||||
|
|----------|---------|
|
||||||
|
| `gitnexus://repo/cameleer3-server/context` | Codebase overview, check index freshness |
|
||||||
|
| `gitnexus://repo/cameleer3-server/clusters` | All functional areas |
|
||||||
|
| `gitnexus://repo/cameleer3-server/processes` | All execution flows |
|
||||||
|
| `gitnexus://repo/cameleer3-server/process/{name}` | Step-by-step execution trace |
|
||||||
|
|
||||||
|
## Self-Check Before Finishing
|
||||||
|
|
||||||
|
Before completing any code modification task, verify:
|
||||||
|
1. `gitnexus_impact` was run for all modified symbols
|
||||||
|
2. No HIGH/CRITICAL risk warnings were ignored
|
||||||
|
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||||
|
4. All d=1 (WILL BREAK) dependents were updated
|
||||||
|
|
||||||
|
## Keeping the Index Fresh
|
||||||
|
|
||||||
|
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx gitnexus analyze --embeddings
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||||
|
|
||||||
|
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
| Task | Read this skill file |
|
||||||
|
|------|---------------------|
|
||||||
|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||||
|
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||||
|
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||||
|
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||||
|
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||||
|
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||||
|
|
||||||
|
<!-- gitnexus:end -->
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -12,16 +12,12 @@ COPY cameleer3-server-app/pom.xml cameleer3-server-app/
|
|||||||
# Cache deps — only re-downloaded when POMs change
|
# Cache deps — only re-downloaded when POMs change
|
||||||
RUN mvn dependency:go-offline -B || true
|
RUN mvn dependency:go-offline -B || true
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mvn clean package -DskipTests -B
|
RUN mvn clean package -DskipTests -U -B
|
||||||
|
|
||||||
FROM eclipse-temurin:17-jre
|
FROM eclipse-temurin:17-jre
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /build/cameleer3-server-app/target/cameleer3-server-app-*.jar /app/server.jar
|
COPY --from=build /build/cameleer3-server-app/target/cameleer3-server-app-*.jar /app/server.jar
|
||||||
|
|
||||||
ENV SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/cameleer3
|
|
||||||
ENV SPRING_DATASOURCE_USERNAME=cameleer
|
|
||||||
ENV SPRING_DATASOURCE_PASSWORD=cameleer_dev
|
|
||||||
ENV OPENSEARCH_URL=http://opensearch:9200
|
|
||||||
|
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
ENTRYPOINT exec java -jar /app/server.jar
|
ENV TZ=UTC
|
||||||
|
ENTRYPOINT exec java -Duser.timezone=UTC -jar /app/server.jar
|
||||||
|
|||||||
128
HOWTO.md
128
HOWTO.md
@@ -21,18 +21,17 @@ mvn clean verify # compile + run all tests (needs Docker for integrati
|
|||||||
|
|
||||||
## Infrastructure Setup
|
## Infrastructure Setup
|
||||||
|
|
||||||
Start PostgreSQL and OpenSearch:
|
Start PostgreSQL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts TimescaleDB (PostgreSQL 16) and OpenSearch 2.19. The database schema is applied automatically via Flyway migrations on server startup.
|
This starts PostgreSQL 16. The database schema is applied automatically via Flyway migrations on server startup. ClickHouse tables are created by the schema initializer on startup.
|
||||||
|
|
||||||
| Service | Port | Purpose |
|
| Service | Port | Purpose |
|
||||||
|------------|------|----------------------|
|
|------------|------|----------------------|
|
||||||
| PostgreSQL | 5432 | JDBC (Spring JDBC) |
|
| PostgreSQL | 5432 | JDBC (Spring JDBC) |
|
||||||
| OpenSearch | 9200 | REST API (full-text) |
|
|
||||||
|
|
||||||
PostgreSQL credentials: `cameleer` / `cameleer_dev`, database `cameleer3`.
|
PostgreSQL credentials: `cameleer` / `cameleer_dev`, database `cameleer3`.
|
||||||
|
|
||||||
@@ -40,9 +39,15 @@ PostgreSQL credentials: `cameleer` / `cameleer_dev`, database `cameleer3`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mvn clean package -DskipTests
|
mvn clean package -DskipTests
|
||||||
CAMELEER_AUTH_TOKEN=my-secret-token java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/cameleer3 \
|
||||||
|
SPRING_DATASOURCE_USERNAME=cameleer \
|
||||||
|
SPRING_DATASOURCE_PASSWORD=cameleer_dev \
|
||||||
|
CAMELEER_AUTH_TOKEN=my-secret-token \
|
||||||
|
java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** The Docker image no longer includes default database credentials. When running via `docker run`, pass `-e SPRING_DATASOURCE_URL=...` etc. The docker-compose setup provides these automatically.
|
||||||
|
|
||||||
The server starts on **port 8081**. The `CAMELEER_AUTH_TOKEN` environment variable is **required** — the server fails fast on startup if it is not set.
|
The server starts on **port 8081**. The `CAMELEER_AUTH_TOKEN` environment variable is **required** — the server fails fast on startup if it is not set.
|
||||||
|
|
||||||
For token rotation without downtime, set `CAMELEER_AUTH_TOKEN_PREVIOUS` to the old token while rolling out the new one. The server accepts both during the overlap window.
|
For token rotation without downtime, set `CAMELEER_AUTH_TOKEN_PREVIOUS` to the old token while rolling out the new one. The server accepts both during the overlap window.
|
||||||
@@ -100,13 +105,15 @@ JWTs carry a `roles` claim. Endpoints are restricted by role:
|
|||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `AGENT` | Data ingestion (`/data/**`), heartbeat, SSE events, command ack |
|
| `AGENT` | Data ingestion (`/data/**` — executions, diagrams, metrics, logs), heartbeat, SSE events, command ack |
|
||||||
| `VIEWER` | Search, execution detail, diagrams, agent list |
|
| `VIEWER` | Search, execution detail, diagrams, agent list, app config (read-only) |
|
||||||
| `OPERATOR` | VIEWER + send commands to agents |
|
| `OPERATOR` | VIEWER + send commands to agents, route control, replay, edit app config |
|
||||||
| `ADMIN` | OPERATOR + user management (`/admin/**`) |
|
| `ADMIN` | OPERATOR + user management, audit log, OIDC config, database admin (`/admin/**`) |
|
||||||
|
|
||||||
The env-var local user gets `ADMIN` role. Agents get `AGENT` role at registration.
|
The env-var local user gets `ADMIN` role. Agents get `AGENT` role at registration.
|
||||||
|
|
||||||
|
**UI role gating:** The sidebar hides the Admin section for non-ADMIN users. Admin routes (`/admin/*`) redirect to `/` for non-admin. The diagram node toolbar and route control bar are hidden for VIEWER. Config is a main tab (`/config` shows all apps, `/config/:appId` filters to one app with detail panel; sidebar clicks stay on config tab, route clicks resolve to parent app). VIEWER sees read-only, OPERATOR+ can edit.
|
||||||
|
|
||||||
### OIDC Login (Optional)
|
### OIDC Login (Optional)
|
||||||
|
|
||||||
OIDC configuration is stored in PostgreSQL and managed via the admin API or UI. The SPA checks if OIDC is available:
|
OIDC configuration is stored in PostgreSQL and managed via the admin API or UI. The SPA checks if OIDC is available:
|
||||||
@@ -139,7 +146,7 @@ curl -s -X PUT http://localhost:8081/api/v1/admin/oidc \
|
|||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-d '{
|
-d '{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"issuerUri": "http://authentik:9000/application/o/cameleer/",
|
"issuerUri": "http://logto:3001/oidc",
|
||||||
"clientId": "your-client-id",
|
"clientId": "your-client-id",
|
||||||
"clientSecret": "your-client-secret",
|
"clientSecret": "your-client-secret",
|
||||||
"rolesClaim": "realm_access.roles",
|
"rolesClaim": "realm_access.roles",
|
||||||
@@ -157,28 +164,48 @@ curl -s -X DELETE http://localhost:8081/api/v1/admin/oidc \
|
|||||||
|
|
||||||
**Initial provisioning**: OIDC can also be seeded from `CAMELEER_OIDC_*` env vars on first startup (when DB is empty). After that, the admin API takes over.
|
**Initial provisioning**: OIDC can also be seeded from `CAMELEER_OIDC_*` env vars on first startup (when DB is empty). After that, the admin API takes over.
|
||||||
|
|
||||||
### Authentik Setup (OIDC Provider)
|
### Logto Setup (OIDC Provider)
|
||||||
|
|
||||||
Authentik is deployed alongside the Cameleer stack. After first deployment:
|
Logto is deployed alongside the Cameleer stack. After first deployment:
|
||||||
|
|
||||||
1. **Initial setup**: Open `http://192.168.50.86:30950/if/flow/initial-setup/` and create the admin account
|
Logto is proxy-aware via `TRUST_PROXY_HEADER=1`. The `LOGTO_ENDPOINT` and `LOGTO_ADMIN_ENDPOINT` secrets define the public-facing URLs that Logto uses for OIDC discovery, issuer URI, and redirect URLs. When behind a reverse proxy (e.g., Traefik), set these to the external URLs (e.g., `https://auth.cameleer.my.domain`). Logto needs its own subdomain — it cannot be path-prefixed under another app.
|
||||||
2. **Create provider**: Admin Interface → Providers → Create → OAuth2/OpenID Provider
|
|
||||||
- Name: `Cameleer`
|
1. **Initial setup**: Open the Logto admin console (the `LOGTO_ADMIN_ENDPOINT` URL) and create the admin account
|
||||||
- Authorization flow: `default-provider-authorization-explicit-consent`
|
2. **Create SPA application**: Applications → Create → Single Page App
|
||||||
- Client type: `Confidential`
|
- Name: `Cameleer UI`
|
||||||
- Redirect URIs: `http://192.168.50.86:30090/callback` (or your UI URL)
|
- Redirect URI: your UI URL + `/oidc/callback`
|
||||||
|
- Note the **Client ID**
|
||||||
|
3. **Create API Resource**: API Resources → Create
|
||||||
|
- Name: `Cameleer Server API`
|
||||||
|
- Indicator: your API URL (e.g., `https://cameleer.siegeln.net/api`)
|
||||||
|
- Add permissions: `server:admin`, `server:operator`, `server:viewer`
|
||||||
|
4. **Create M2M application** (for SaaS platform): Applications → Create → Machine-to-Machine
|
||||||
|
- Name: `Cameleer SaaS`
|
||||||
|
- Assign the API Resource created above with `server:admin` scope
|
||||||
- Note the **Client ID** and **Client Secret**
|
- Note the **Client ID** and **Client Secret**
|
||||||
3. **Create application**: Admin Interface → Applications → Create
|
5. **Configure Cameleer OIDC login**: Use the admin API (`PUT /api/v1/admin/oidc`) or set env vars for initial seeding:
|
||||||
- Name: `Cameleer`
|
|
||||||
- Provider: select `Cameleer` (created above)
|
|
||||||
4. **Configure roles** (optional): Create groups in Authentik and map them to Cameleer roles via the `roles-claim` config. Default claim path is `realm_access.roles`. For Authentik, you may need to customize the OIDC scope to include group claims.
|
|
||||||
5. **Configure Cameleer**: Use the admin API (`PUT /api/v1/admin/oidc`) or set env vars for initial seeding:
|
|
||||||
```
|
```
|
||||||
CAMELEER_OIDC_ENABLED=true
|
CAMELEER_OIDC_ENABLED=true
|
||||||
CAMELEER_OIDC_ISSUER=http://authentik:9000/application/o/cameleer/
|
CAMELEER_OIDC_ISSUER=<LOGTO_ENDPOINT>/oidc
|
||||||
CAMELEER_OIDC_CLIENT_ID=<client-id-from-step-2>
|
CAMELEER_OIDC_CLIENT_ID=<client-id-from-step-2>
|
||||||
CAMELEER_OIDC_CLIENT_SECRET=<client-secret-from-step-2>
|
CAMELEER_OIDC_CLIENT_SECRET=<not-needed-for-public-spa>
|
||||||
```
|
```
|
||||||
|
6. **Configure resource server** (for M2M token validation):
|
||||||
|
```
|
||||||
|
CAMELEER_OIDC_ISSUER_URI=<LOGTO_ENDPOINT>/oidc
|
||||||
|
CAMELEER_OIDC_JWK_SET_URI=http://logto:3001/oidc/jwks
|
||||||
|
CAMELEER_OIDC_AUDIENCE=<api-resource-indicator-from-step-3>
|
||||||
|
CAMELEER_OIDC_TLS_SKIP_VERIFY=true # optional — skip cert verification for self-signed CAs
|
||||||
|
```
|
||||||
|
`JWK_SET_URI` is needed when the public issuer URL isn't reachable from inside containers — it fetches JWKS directly from the internal Logto service. `TLS_SKIP_VERIFY` disables certificate verification for all OIDC HTTP calls (discovery, token exchange, JWKS); use only when the provider has a self-signed CA.
|
||||||
|
|
||||||
|
### SSO Behavior
|
||||||
|
|
||||||
|
When OIDC is configured and enabled, the UI automatically redirects to the OIDC provider for silent SSO (`prompt=none`). Users with an active provider session are signed in without seeing a login form. On first login, the provider may show a consent screen (scopes), after which subsequent logins are seamless. If auto-signup is enabled, new users are automatically provisioned with the configured default roles.
|
||||||
|
|
||||||
|
- **Bypass SSO**: Navigate to `/login?local` to see the local login form
|
||||||
|
- **Subpath deployments**: The OIDC redirect_uri respects `BASE_PATH` (e.g., `https://host/server/oidc/callback`)
|
||||||
|
- **Role sync**: System roles (ADMIN/OPERATOR/VIEWER) are synced from OIDC scopes on every login — revoking a scope in the provider takes effect on next login. Manually assigned group memberships are preserved.
|
||||||
|
|
||||||
### User Management (ADMIN only)
|
### User Management (ADMIN only)
|
||||||
|
|
||||||
@@ -220,6 +247,20 @@ curl -s -X POST http://localhost:8081/api/v1/data/metrics \
|
|||||||
-H "X-Protocol-Version: 1" \
|
-H "X-Protocol-Version: 1" \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-d '[{"agentId":"agent-1","metricName":"cpu","value":42.0,"timestamp":"2026-03-11T00:00:00Z","tags":{}}]'
|
-d '[{"agentId":"agent-1","metricName":"cpu","value":42.0,"timestamp":"2026-03-11T00:00:00Z","tags":{}}]'
|
||||||
|
|
||||||
|
# Post application log entries (batch)
|
||||||
|
curl -s -X POST http://localhost:8081/api/v1/data/logs \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"entries": [{
|
||||||
|
"timestamp": "2026-03-25T10:00:00Z",
|
||||||
|
"level": "INFO",
|
||||||
|
"loggerName": "com.acme.MyService",
|
||||||
|
"message": "Processing order #12345",
|
||||||
|
"threadName": "main"
|
||||||
|
}]
|
||||||
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The `X-Protocol-Version: 1` header is required on all `/api/v1/data/**` endpoints. Missing or wrong version returns 400.
|
**Note:** The `X-Protocol-Version: 1` header is required on all `/api/v1/data/**` endpoints. Missing or wrong version returns 400.
|
||||||
@@ -311,6 +352,12 @@ curl -s -X POST http://localhost:8081/api/v1/agents/groups/order-service-prod/co
|
|||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-d '{"type":"deep-trace","payload":{"routeId":"route-1","durationSeconds":60}}'
|
-d '{"type":"deep-trace","payload":{"routeId":"route-1","durationSeconds":60}}'
|
||||||
|
|
||||||
|
# Send route control command to agent group (start/stop/suspend/resume)
|
||||||
|
curl -s -X POST http://localhost:8081/api/v1/agents/groups/order-service-prod/commands \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"type":"route-control","payload":{"routeId":"route-1","action":"stop","nonce":"unique-uuid"}}'
|
||||||
|
|
||||||
# Broadcast command to all live agents
|
# Broadcast command to all live agents
|
||||||
curl -s -X POST http://localhost:8081/api/v1/agents/commands \
|
curl -s -X POST http://localhost:8081/api/v1/agents/commands \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -324,10 +371,14 @@ curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/commands/{commandId}
|
|||||||
|
|
||||||
**Agent lifecycle:** LIVE (heartbeat within 90s) → STALE (missed 3 heartbeats) → DEAD (5min after STALE). DEAD agents kept indefinitely.
|
**Agent lifecycle:** LIVE (heartbeat within 90s) → STALE (missed 3 heartbeats) → DEAD (5min after STALE). DEAD agents kept indefinitely.
|
||||||
|
|
||||||
**SSE events:** `config-update`, `deep-trace`, `replay` commands pushed in real time. Server sends ping keepalive every 15s.
|
**Server restart resilience:** The agent registry is in-memory and lost on server restart. Agents auto-re-register on their next heartbeat or SSE connection — the server reconstructs registry entries from JWT claims (subject, application). Route catalog uses ClickHouse execution data as fallback until agents re-register with full route IDs. Agents should also handle 404 on heartbeat by triggering a full re-registration.
|
||||||
|
|
||||||
|
**SSE events:** `config-update`, `deep-trace`, `replay`, `route-control` commands pushed in real time. Server sends ping keepalive every 15s.
|
||||||
|
|
||||||
**Command expiry:** Unacknowledged commands expire after 60 seconds.
|
**Command expiry:** Unacknowledged commands expire after 60 seconds.
|
||||||
|
|
||||||
|
**Route control responses:** Route control commands return `CommandGroupResponse` with per-agent status, response count, and timed-out agent IDs.
|
||||||
|
|
||||||
### Backpressure
|
### Backpressure
|
||||||
|
|
||||||
When the write buffer is full (default capacity: 50,000), ingestion endpoints return **503 Service Unavailable**. Already-buffered data is not lost.
|
When the write buffer is full (default capacity: 50,000), ingestion endpoints return **503 Service Unavailable**. Already-buffered data is not lost.
|
||||||
@@ -354,6 +405,7 @@ Key settings in `cameleer3-server-app/src/main/resources/application.yml`:
|
|||||||
| `security.ui-user` | `admin` | UI login username (`CAMELEER_UI_USER` env var) |
|
| `security.ui-user` | `admin` | UI login username (`CAMELEER_UI_USER` env var) |
|
||||||
| `security.ui-password` | `admin` | UI login password (`CAMELEER_UI_PASSWORD` env var) |
|
| `security.ui-password` | `admin` | UI login password (`CAMELEER_UI_PASSWORD` env var) |
|
||||||
| `security.ui-origin` | `http://localhost:5173` | CORS allowed origin for UI (`CAMELEER_UI_ORIGIN` env var) |
|
| `security.ui-origin` | `http://localhost:5173` | CORS allowed origin for UI (`CAMELEER_UI_ORIGIN` env var) |
|
||||||
|
| `security.cors-allowed-origins` | *(empty)* | Comma-separated CORS origins (`CAMELEER_CORS_ALLOWED_ORIGINS`) — overrides `ui-origin` when set |
|
||||||
| `security.jwt-secret` | *(random)* | HMAC secret for JWT signing (`CAMELEER_JWT_SECRET`). If set, tokens survive restarts |
|
| `security.jwt-secret` | *(random)* | HMAC secret for JWT signing (`CAMELEER_JWT_SECRET`). If set, tokens survive restarts |
|
||||||
| `security.oidc.enabled` | `false` | Enable OIDC login (`CAMELEER_OIDC_ENABLED`) |
|
| `security.oidc.enabled` | `false` | Enable OIDC login (`CAMELEER_OIDC_ENABLED`) |
|
||||||
| `security.oidc.issuer-uri` | | OIDC provider issuer URL (`CAMELEER_OIDC_ISSUER`) |
|
| `security.oidc.issuer-uri` | | OIDC provider issuer URL (`CAMELEER_OIDC_ISSUER`) |
|
||||||
@@ -361,6 +413,8 @@ Key settings in `cameleer3-server-app/src/main/resources/application.yml`:
|
|||||||
| `security.oidc.client-secret` | | OAuth2 client secret (`CAMELEER_OIDC_CLIENT_SECRET`) |
|
| `security.oidc.client-secret` | | OAuth2 client secret (`CAMELEER_OIDC_CLIENT_SECRET`) |
|
||||||
| `security.oidc.roles-claim` | `realm_access.roles` | JSONPath to roles in OIDC id_token (`CAMELEER_OIDC_ROLES_CLAIM`) |
|
| `security.oidc.roles-claim` | `realm_access.roles` | JSONPath to roles in OIDC id_token (`CAMELEER_OIDC_ROLES_CLAIM`) |
|
||||||
| `security.oidc.default-roles` | `VIEWER` | Default roles for new OIDC users (`CAMELEER_OIDC_DEFAULT_ROLES`) |
|
| `security.oidc.default-roles` | `VIEWER` | Default roles for new OIDC users (`CAMELEER_OIDC_DEFAULT_ROLES`) |
|
||||||
|
| `cameleer.indexer.debounce-ms` | `2000` | Search indexer debounce delay (`CAMELEER_INDEXER_DEBOUNCE_MS`) |
|
||||||
|
| `cameleer.indexer.queue-size` | `10000` | Search indexer queue capacity (`CAMELEER_INDEXER_QUEUE_SIZE`) |
|
||||||
|
|
||||||
## Web UI Development
|
## Web UI Development
|
||||||
|
|
||||||
@@ -385,7 +439,7 @@ npm run generate-api # Requires backend running on :8081
|
|||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
Integration tests use Testcontainers (starts PostgreSQL and OpenSearch automatically — requires Docker):
|
Integration tests use Testcontainers (starts PostgreSQL automatically — requires Docker):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All tests
|
# All tests
|
||||||
@@ -416,13 +470,15 @@ The full stack is deployed to k3s via CI/CD on push to `main`. K8s manifests are
|
|||||||
```
|
```
|
||||||
cameleer namespace:
|
cameleer namespace:
|
||||||
PostgreSQL (StatefulSet, 10Gi PVC) ← postgres:5432 (ClusterIP)
|
PostgreSQL (StatefulSet, 10Gi PVC) ← postgres:5432 (ClusterIP)
|
||||||
OpenSearch (StatefulSet, 10Gi PVC) ← opensearch:9200 (ClusterIP)
|
ClickHouse (StatefulSet, 10Gi PVC) ← clickhouse:8123 (ClusterIP)
|
||||||
cameleer3-server (Deployment) ← NodePort 30081
|
cameleer3-server (Deployment) ← NodePort 30081
|
||||||
cameleer3-ui (Deployment, Nginx) ← NodePort 30090
|
cameleer3-ui (Deployment, Nginx) ← NodePort 30090
|
||||||
Authentik Server (Deployment) ← NodePort 30950
|
cameleer-deploy-demo (Deployment) ← NodePort 30092
|
||||||
Authentik Worker (Deployment)
|
Logto Server (Deployment) ← NodePort 30951/30952
|
||||||
Authentik PostgreSQL (StatefulSet, 1Gi) ← ClusterIP
|
Logto PostgreSQL (StatefulSet, 1Gi) ← ClusterIP
|
||||||
Authentik Redis (Deployment) ← ClusterIP
|
|
||||||
|
cameleer-demo namespace:
|
||||||
|
(deployed Camel applications — managed by cameleer-deploy-demo)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Access (from your network)
|
### Access (from your network)
|
||||||
@@ -432,13 +488,15 @@ cameleer namespace:
|
|||||||
| Web UI | `http://192.168.50.86:30090` |
|
| Web UI | `http://192.168.50.86:30090` |
|
||||||
| Server API | `http://192.168.50.86:30081/api/v1/health` |
|
| Server API | `http://192.168.50.86:30081/api/v1/health` |
|
||||||
| Swagger UI | `http://192.168.50.86:30081/api/v1/swagger-ui.html` |
|
| Swagger UI | `http://192.168.50.86:30081/api/v1/swagger-ui.html` |
|
||||||
| Authentik | `http://192.168.50.86:30950` |
|
| Deploy Demo | `http://192.168.50.86:30092` |
|
||||||
|
| Logto API | `LOGTO_ENDPOINT` secret (NodePort 30951 direct, or behind reverse proxy) |
|
||||||
|
| Logto Admin | `LOGTO_ADMIN_ENDPOINT` secret (NodePort 30952 direct, or behind reverse proxy) |
|
||||||
|
|
||||||
### CI/CD Pipeline
|
### CI/CD Pipeline
|
||||||
|
|
||||||
Push to `main` triggers: **build** (UI npm + Maven, unit tests) → **docker** (buildx amd64 for server + UI, push to Gitea registry) → **deploy** (kubectl apply + rolling update).
|
Push to `main` triggers: **build** (UI npm + Maven, unit tests) → **docker** (buildx amd64 for server + UI, push to Gitea registry) → **deploy** (kubectl apply + rolling update).
|
||||||
|
|
||||||
Required Gitea org secrets: `REGISTRY_TOKEN`, `KUBECONFIG_BASE64`, `CAMELEER_AUTH_TOKEN`, `CAMELEER_JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `OPENSEARCH_USER`, `OPENSEARCH_PASSWORD`, `CAMELEER_UI_USER` (optional), `CAMELEER_UI_PASSWORD` (optional), `AUTHENTIK_PG_USER`, `AUTHENTIK_PG_PASSWORD`, `AUTHENTIK_SECRET_KEY`, `CAMELEER_OIDC_ENABLED`, `CAMELEER_OIDC_ISSUER`, `CAMELEER_OIDC_CLIENT_ID`, `CAMELEER_OIDC_CLIENT_SECRET`.
|
Required Gitea org secrets: `REGISTRY_TOKEN`, `KUBECONFIG_BASE64`, `CAMELEER_AUTH_TOKEN`, `CAMELEER_JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `CLICKHOUSE_USER`, `CLICKHOUSE_PASSWORD`, `CAMELEER_UI_USER` (optional), `CAMELEER_UI_PASSWORD` (optional), `LOGTO_PG_USER`, `LOGTO_PG_PASSWORD`, `LOGTO_ENDPOINT` (public-facing Logto URL, e.g., `https://auth.cameleer.my.domain`), `LOGTO_ADMIN_ENDPOINT` (admin console URL), `CAMELEER_OIDC_ISSUER_URI` (optional, for resource server M2M token validation), `CAMELEER_OIDC_AUDIENCE` (optional, API resource indicator), `CAMELEER_OIDC_TLS_SKIP_VERIFY` (optional, skip TLS cert verification for self-signed CAs).
|
||||||
|
|
||||||
### Manual K8s Commands
|
### Manual K8s Commands
|
||||||
|
|
||||||
@@ -452,8 +510,8 @@ kubectl -n cameleer logs -f deploy/cameleer3-server
|
|||||||
# View PostgreSQL logs
|
# View PostgreSQL logs
|
||||||
kubectl -n cameleer logs -f statefulset/postgres
|
kubectl -n cameleer logs -f statefulset/postgres
|
||||||
|
|
||||||
# View OpenSearch logs
|
# View ClickHouse logs
|
||||||
kubectl -n cameleer logs -f statefulset/opensearch
|
kubectl -n cameleer logs -f statefulset/clickhouse
|
||||||
|
|
||||||
# Restart server
|
# Restart server
|
||||||
kubectl -n cameleer rollout restart deployment/cameleer3-server
|
kubectl -n cameleer rollout restart deployment/cameleer3-server
|
||||||
|
|||||||
259
UI-CONSISTENCY-AUDIT.md
Normal file
259
UI-CONSISTENCY-AUDIT.md
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
> **Status: RESOLVED** — All phases (1-5) executed on 2026-04-09. Remaining: responsive design (separate initiative).
|
||||||
|
|
||||||
|
# UI Consistency Audit — cameleer3-server
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
**Scope:** All files under `ui/src/` (26 CSS modules, ~45 TSX components, ~15 pages)
|
||||||
|
**Verdict:** ~55% design system adoption for interactive UI. Significant duplication and inline style debt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Dimension | Score | Key Issue |
|
||||||
|
|-----------|-------|-----------|
|
||||||
|
| Design system component adoption | 55% | 32 raw `<button>`, 12 raw `<select>`, 8 raw `<input>` should use DS |
|
||||||
|
| Color consistency | Poor | ~140 violations: 45 hardcoded hex in TSX, 13 naked hex in CSS, ~55 fallback hex in `var()` |
|
||||||
|
| Inline styles | Poor | 55 RED (static inline styles), 8 YELLOW, 14 GREEN (justified) |
|
||||||
|
| Layout consistency | Mixed | 3 different page padding values, mixed gap/margin approaches |
|
||||||
|
| CSS module duplication | 22% | ~135 of 618 classes are copy-pasted across files |
|
||||||
|
| Responsive design | None | Zero `@media` queries in entire UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Critical: Hardcoded Colors (CLAUDE.md violation)
|
||||||
|
|
||||||
|
The project rule states: *"Always use `@cameleer/design-system` CSS variables for colors — never hardcode hex values."*
|
||||||
|
|
||||||
|
### Worst offenders
|
||||||
|
|
||||||
|
| File | Violations | Severity |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `ProcessDiagram/DiagramNode.tsx` | ~20 hex values in SVG fill/stroke | Critical |
|
||||||
|
| `ExecutionDiagram/ExecutionDiagram.module.css` | 17 naked hex + ~40 hex fallbacks in `var()` | Critical |
|
||||||
|
| `ProcessDiagram/CompoundNode.tsx` | 8 hex values | Critical |
|
||||||
|
| `ProcessDiagram/DiagramEdge.tsx` | 3 hex values | High |
|
||||||
|
| `ProcessDiagram/ConfigBadge.tsx` | 3 hex values | High |
|
||||||
|
| `ProcessDiagram/ErrorSection.tsx` | 2 hex values | High |
|
||||||
|
| `ProcessDiagram/NodeToolbar.tsx` | 2 hex values | High |
|
||||||
|
| `ProcessDiagram/Minimap.tsx` | 3 hex values | High |
|
||||||
|
| `Dashboard/Dashboard.module.css` | `#5db866` (not even a DS color) | High |
|
||||||
|
| `AppsTab/AppsTab.module.css` | `var(--accent, #6c7aff)` (undefined DS variable) | Medium |
|
||||||
|
|
||||||
|
### Undefined CSS variables (not in design system)
|
||||||
|
|
||||||
|
| Variable | Files | Should be |
|
||||||
|
|----------|-------|-----------|
|
||||||
|
| `--accent` | EnvironmentSelector, AppsTab | `--amber` (or define in DS) |
|
||||||
|
| `--bg-base` | LoginPage | `--bg-body` |
|
||||||
|
| `--surface` | ContentTabs, ExchangeHeader | `--bg-surface` |
|
||||||
|
| `--bg-surface-raised` | AgentHealth | `--bg-raised` |
|
||||||
|
|
||||||
|
### Missing DS tokens needed
|
||||||
|
|
||||||
|
Several tint/background colors are used repeatedly but have no DS variable:
|
||||||
|
- `--error-bg` (used as `#FDF2F0`, `#F9E0DC`)
|
||||||
|
- `--success-bg` (used as `#F0F9F1`)
|
||||||
|
- `--amber-bg` / `--warning-bg` (used as `#FFF8F0`)
|
||||||
|
- `--bg-inverse` / `--text-inverse` (used as `#1A1612` / `#E4DFD8`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical: CSS Module Duplication (~22%)
|
||||||
|
|
||||||
|
~135 of 618 class definitions are copy-pasted across files.
|
||||||
|
|
||||||
|
### Table section pattern — 5 files, ~35 duplicate classes
|
||||||
|
|
||||||
|
`.tableSection`, `.tableHeader`, `.tableTitle`, `.tableMeta`, `.tableRight` are **identical** in:
|
||||||
|
- `DashboardTab.module.css`
|
||||||
|
- `AuditLogPage.module.css`
|
||||||
|
- `ClickHouseAdminPage.module.css`
|
||||||
|
- `RoutesMetrics.module.css`
|
||||||
|
- `RouteDetail.module.css`
|
||||||
|
|
||||||
|
### Log viewer panel — 2 files, ~50 lines identical
|
||||||
|
|
||||||
|
`.logCard`, `.logHeader`, `.logToolbar`, `.logSearchWrap`, `.logSearchInput`, `.logSearchClear`, `.logClearFilters`, `.logEmpty`, `.sortBtn`, `.refreshBtn`, `.headerActions` — byte-for-byte identical in `AgentHealth.module.css` and `AgentInstance.module.css`.
|
||||||
|
|
||||||
|
### Tap modal form — 2 files, ~40 lines identical
|
||||||
|
|
||||||
|
`.typeSelector`, `.typeOption`, `.typeOptionActive`, `.testSection`, `.testTabs`, `.testTabBtn`, `.testTabBtnActive`, `.testBody`, `.testResult`, `.testSuccess`, `.testError` — identical in `TapConfigModal.module.css` and `RouteDetail.module.css`.
|
||||||
|
|
||||||
|
### Other duplicates
|
||||||
|
|
||||||
|
| Pattern | Files | Lines |
|
||||||
|
|---------|-------|-------|
|
||||||
|
| Rate color classes (`.rateGood/.rateWarn/.rateBad/.rateNeutral`) | DashboardTab, RouteDetail, RoutesMetrics | ~12 each |
|
||||||
|
| Refresh indicator + `@keyframes pulse` | DashboardTab, RoutesMetrics | ~15 each |
|
||||||
|
| Chart card (`.chartCard`) | AgentInstance, RouteDetail | ~6 each |
|
||||||
|
| Section card (`.section`) | AppConfigDetailPage, OidcConfigPage | ~7 each |
|
||||||
|
| Meta grid (`.metaGrid/.metaLabel/.metaValue`) | AboutMeDialog, UserManagement | ~9 each |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. High: Inline Styles (55 RED violations)
|
||||||
|
|
||||||
|
### Files with zero CSS modules (all inline)
|
||||||
|
|
||||||
|
| File | Issue |
|
||||||
|
|------|-------|
|
||||||
|
| `pages/Admin/AdminLayout.tsx` | Entire layout wrapper is inline styled |
|
||||||
|
| `pages/Admin/DatabaseAdminPage.tsx` | All layout, typography, spacing inline — no CSS module |
|
||||||
|
| `auth/OidcCallback.tsx` | Full-page layout inline — no CSS module |
|
||||||
|
|
||||||
|
### Most inline violations
|
||||||
|
|
||||||
|
| File | RED count | Primary patterns |
|
||||||
|
|------|-----------|-----------------|
|
||||||
|
| `pages/AppsTab/AppsTab.tsx` | ~25 | Fixed-width inputs (`width: 50-90px` x18), visually-hidden pattern x2, table cell layouts |
|
||||||
|
| `components/LayoutShell.tsx` | 6 | StarredList sub-component, sidebar layout |
|
||||||
|
| `pages/Admin/EnvironmentsPage.tsx` | 8 | Raw `<select>` fully styled inline, save/cancel button rows |
|
||||||
|
| `pages/Routes/RouteDetail.tsx` | 5 | Heading styles, tab panel margins |
|
||||||
|
|
||||||
|
### Repeated inline patterns that need extraction
|
||||||
|
|
||||||
|
| Pattern | Occurrences | Fix |
|
||||||
|
|---------|-------------|-----|
|
||||||
|
| `style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}` (loading fallback) | 3 files | Create shared `<PageLoader>` |
|
||||||
|
| `style={{ position: 'absolute', width: 1, height: 1, clip: 'rect(0,0,0,0)' }}` (visually hidden) | 2 in AppsTab | Create `.visuallyHidden` utility class |
|
||||||
|
| `style={{ width: N }}` on `<Input>`/`<Select>` (fixed widths) | 18+ in AppsTab | Size classes or CSS module rules |
|
||||||
|
| `style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}` (action row) | 3+ in EnvironmentsPage | Shared `.editActions` class |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. High: Design System Component Adoption Gaps
|
||||||
|
|
||||||
|
### Native HTML that should use DS components
|
||||||
|
|
||||||
|
| Element | Instances | Files | DS Replacement |
|
||||||
|
|---------|-----------|-------|---------------|
|
||||||
|
| `<button>` | 32 | 8 files | `Button`, `SegmentedTabs` |
|
||||||
|
| `<select>` | 12 | 4 files | `Select` |
|
||||||
|
| `<input>` | 8 | 4 files | `Input`, `Toggle`, `Checkbox` |
|
||||||
|
| `<label>` | 9 | 2 files | `FormField`, `Label` |
|
||||||
|
| `<table>` (data) | 2 | 2 files | `DataTable`, `LogViewer` |
|
||||||
|
|
||||||
|
### Highest-priority replacements
|
||||||
|
|
||||||
|
1. **`EnvironmentSelector.tsx`** — zero DS imports, entire component is a bare `<select>`. Used globally in sidebar.
|
||||||
|
2. **`ExecutionDiagram/tabs/LogTab.tsx`** — reimplements LogViewer from scratch (raw table + input + button). AgentInstance and AgentHealth already use DS `LogViewer` correctly.
|
||||||
|
3. **`AppsTab.tsx` sub-tabs** — 3 instances of homegrown `<button>` tab bars. DS provides `SegmentedTabs` and `Tabs`.
|
||||||
|
4. **`AppConfigDetailPage.tsx`** — 4x `<select>`, 4x `<label>`, 2x `<input type="checkbox">`, 4x `<button>` — all have DS equivalents already used elsewhere.
|
||||||
|
5. **`AgentHealth.tsx`** — config bar uses `Toggle` (correct) alongside raw `<select>` and `<button>` (incorrect).
|
||||||
|
|
||||||
|
### Cross-page inconsistencies
|
||||||
|
|
||||||
|
| Pattern | Correct usage | Incorrect usage |
|
||||||
|
|---------|--------------|-----------------|
|
||||||
|
| Log viewer | AgentInstance, AgentHealth use DS `LogViewer` | LogTab rebuilds from scratch |
|
||||||
|
| Config edit form | Both pages render same 4 fields | AgentHealth uses `Toggle`, AppConfigDetail uses `<input type="checkbox">` |
|
||||||
|
| Sub-tabs | RbacPage uses DS `Tabs` | AppsTab uses homegrown `<button>` tabs with non-DS `--accent` color |
|
||||||
|
| Select dropdowns | AppsTab uses DS `Select` for some fields | Same file uses raw `<select>` for other fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Medium: Layout Inconsistencies
|
||||||
|
|
||||||
|
### Page padding (3 different values)
|
||||||
|
|
||||||
|
| Pages | Padding |
|
||||||
|
|-------|---------|
|
||||||
|
| AgentHealth, AgentInstance, AdminLayout | `20px 24px 40px` |
|
||||||
|
| AppsTab | `16px` (all sides) |
|
||||||
|
| DashboardTab, Dashboard | No padding (full-bleed) |
|
||||||
|
|
||||||
|
### Section gap spacing (mixed approaches)
|
||||||
|
|
||||||
|
| Approach | Pages |
|
||||||
|
|----------|-------|
|
||||||
|
| CSS `gap: 20px` on flex container | DashboardTab, RoutesMetrics |
|
||||||
|
| `margin-bottom: 20px` | AgentInstance |
|
||||||
|
| Mixed `margin-bottom: 16px` and `20px` on same page | AgentHealth, ClickHouseAdminPage |
|
||||||
|
|
||||||
|
### Typography inconsistencies
|
||||||
|
|
||||||
|
| Issue | Details |
|
||||||
|
|-------|---------|
|
||||||
|
| Card title weight | Most use `font-weight: 600`, RouteDetail `.paneTitle` uses `700` |
|
||||||
|
| Chart title style | RouteDetail: `12px/700/uppercase`, AgentHealth: `12px/600/uppercase` |
|
||||||
|
| Font units | ExchangeHeader + TabKpis use `rem`, everything else uses `px` |
|
||||||
|
| Raw headings | DatabaseAdminPage uses `<h2>`/`<h3>` with inline styles; all others use DS `SectionHeader` or CSS classes |
|
||||||
|
| Table header padding | Most: `12px 16px`, Dashboard: `8px 12px`, AgentHealth eventCard: `10px 16px` |
|
||||||
|
|
||||||
|
### Stat strip layouts
|
||||||
|
|
||||||
|
| Page | Layout | Gap |
|
||||||
|
|------|--------|-----|
|
||||||
|
| AgentHealth, AgentInstance, RbacPage | CSS grid `repeat(N, 1fr)` | `10px` |
|
||||||
|
| ClickHouseAdminPage | Flexbox (unequal widths) | `10px` |
|
||||||
|
| DatabaseAdminPage | Inline flex | `1rem` (16px) |
|
||||||
|
|
||||||
|
### Empty state patterns (4 different approaches)
|
||||||
|
|
||||||
|
1. DS `<EmptyState>` component (AgentInstance — correct)
|
||||||
|
2. `EntityList emptyMessage` prop (EnvironmentsPage, RbacPage)
|
||||||
|
3. `.logEmpty` CSS class, `12px`, `var(--text-faint)` (AgentHealth, AgentInstance)
|
||||||
|
4. `.emptyNote` CSS class, `12px`, `italic` (AppsTab)
|
||||||
|
5. Inline `0.875rem`, `var(--text-muted)` (ExchangesPage)
|
||||||
|
|
||||||
|
### Loading state patterns (3 different approaches)
|
||||||
|
|
||||||
|
1. `<Spinner size="lg">` in flex div with inline `padding: 4rem` — copy-pasted 3 times
|
||||||
|
2. `<Spinner size="md">` returned directly, no centering (EnvironmentsPage)
|
||||||
|
3. No loading UI, data simply absent (DashboardL1/L2/L3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Low: Other Findings
|
||||||
|
|
||||||
|
- **`!important`**: 1 use in `RouteControlBar.module.css` — works around specificity conflict
|
||||||
|
- **Zero responsive design**: no `@media` queries anywhere
|
||||||
|
- **Z-index**: only 4 uses, all in diagram components (5 and 10), consistent
|
||||||
|
- **Naming convention**: all camelCase — consistent, no issues
|
||||||
|
- **Unused CSS classes**: ~11 likely unused in AppsTab (old create-modal classes) and TapConfigModal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Fix Order
|
||||||
|
|
||||||
|
### Phase 1: Design system tokens (unblocks everything else)
|
||||||
|
|
||||||
|
1. Add missing DS variables: `--error-bg`, `--success-bg`, `--amber-bg`, `--bg-inverse`, `--text-inverse`
|
||||||
|
2. Fix undefined variables: `--accent` -> `--amber`, `--bg-base` -> `--bg-body`, `--surface` -> `--bg-surface`
|
||||||
|
|
||||||
|
### Phase 2: Eliminate CSS duplication (~22% of all classes)
|
||||||
|
|
||||||
|
3. Extract shared `tableSection` pattern to shared CSS module (saves ~140 duplicate lines across 5 files)
|
||||||
|
4. Extract shared log viewer CSS to shared module (saves ~50 lines across 2 files)
|
||||||
|
5. Remove duplicate tap modal CSS from RouteDetail (saves ~40 lines)
|
||||||
|
6. Extract shared rate/refresh/chart patterns
|
||||||
|
|
||||||
|
### Phase 3: Fix hardcoded colors
|
||||||
|
|
||||||
|
7. Replace all hex in `ProcessDiagram/*.tsx` SVG components (~45 values)
|
||||||
|
8. Replace all hex in `ExecutionDiagram.module.css` (~17 naked + strip ~40 fallbacks)
|
||||||
|
9. Fix remaining CSS hex violations (Dashboard, AppsTab, AgentHealth)
|
||||||
|
|
||||||
|
### Phase 4: Replace native HTML with DS components
|
||||||
|
|
||||||
|
10. `EnvironmentSelector` -> DS `Select`
|
||||||
|
11. `LogTab` -> DS `LogViewer`
|
||||||
|
12. `AppsTab` sub-tabs -> DS `SegmentedTabs`
|
||||||
|
13. `AppConfigDetailPage` form elements -> DS `Select`/`Toggle`/`FormField`/`Button`
|
||||||
|
14. Remaining `<button>` -> DS `Button`
|
||||||
|
|
||||||
|
### Phase 5: Eliminate inline styles
|
||||||
|
|
||||||
|
15. Create CSS modules for AdminLayout, DatabaseAdminPage, OidcCallback
|
||||||
|
16. Extract shared `<PageLoader>` component
|
||||||
|
17. Move AppsTab fixed-width inputs to CSS module size classes
|
||||||
|
18. Move remaining inline margins/flex patterns to CSS classes
|
||||||
|
|
||||||
|
### Phase 6: Standardize layout patterns
|
||||||
|
|
||||||
|
19. Unify page padding to `20px 24px 40px`
|
||||||
|
20. Standardize section gaps to `gap: 20px` on flex containers
|
||||||
|
21. Normalize font units to `px` throughout
|
||||||
|
22. Standardize empty state to DS `<EmptyState>`
|
||||||
|
23. Standardize loading state to shared `<PageLoader>`
|
||||||
294
UI_FINDINGS.md
Normal file
294
UI_FINDINGS.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# UI/UX Evaluation Report — Cameleer3 Server
|
||||||
|
|
||||||
|
**Date:** 2026-03-25
|
||||||
|
**Evaluated URL:** http://192.168.50.86:30090/
|
||||||
|
**Methodology:** Playwright-driven navigation of all major pages (14 screenshots), evaluated by 3 specialist agents: Visual Design, Information Architecture & Usability, Readability & Accessibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The Cameleer3 dashboard has a **distinctive, well-crafted warm amber design language** that stands out in the observability space. The core monitoring pages (Dashboard, Exchange Detail, Routes, Agents) are polished and consistent. The design system provides a solid foundation.
|
||||||
|
|
||||||
|
**Key strengths:** KPI strip pattern, command palette (Ctrl+K), agent card grouping, dark mode token system, cohesive brand identity.
|
||||||
|
|
||||||
|
**Critical gaps to address:**
|
||||||
|
1. **Font sizes too small** — pervasive 10-11px text for critical data impairs reading under stress
|
||||||
|
2. **Color contrast failures** — `--text-muted` and `--text-faint` fail WCAG AA in both themes
|
||||||
|
3. **Status indicators rely on color alone** — not accessible for color-blind users
|
||||||
|
4. **Admin infrastructure pages lag in polish** — Database/OpenSearch use ad-hoc styling
|
||||||
|
5. **Dashboard is a monitoring display, not yet an incident response tool** — missing error highlighting, per-route error breakdowns, actionable status pages
|
||||||
|
|
||||||
|
**Overall Score: 7/10** — Strong foundation, needs targeted fixes for production readiness under stress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages Evaluated
|
||||||
|
|
||||||
|
| # | Page | Screenshot |
|
||||||
|
|---|------|-----------|
|
||||||
|
| 1 | Login | `screenshots/14-login-page.png` |
|
||||||
|
| 2 | Dashboard (light) | `screenshots/01-dashboard.png` |
|
||||||
|
| 3 | Dashboard + Detail Panel | `screenshots/02-dashboard-detail-panel.png` |
|
||||||
|
| 4 | Exchange Detail | `screenshots/03-exchange-detail.png` |
|
||||||
|
| 5 | Routes Metrics | `screenshots/04-routes-metrics.png` |
|
||||||
|
| 6 | Agent Health | `screenshots/05-agents.png` |
|
||||||
|
| 7 | Agent Instance | `screenshots/06-agent-instance.png` |
|
||||||
|
| 8 | Admin RBAC | `screenshots/07-admin-rbac.png` |
|
||||||
|
| 9 | Admin Audit Log | `screenshots/08-admin-audit.png` |
|
||||||
|
| 10 | Admin OIDC | `screenshots/09-admin-oidc.png` |
|
||||||
|
| 11 | Admin Database | `screenshots/10-admin-database.png` |
|
||||||
|
| 12 | Admin OpenSearch | `screenshots/11-admin-opensearch.png` |
|
||||||
|
| 13 | Command Palette | `screenshots/12-command-palette.png` |
|
||||||
|
| 14 | Dashboard (dark) | `screenshots/13-dashboard-dark-mode.png` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page-by-Page Findings
|
||||||
|
|
||||||
|
### Login Page
|
||||||
|
|
||||||
|
- **[Important]** No brand identity — missing camel logo/icon from sidebar. First impression feels generic.
|
||||||
|
- **[Important]** Sign-in button color mismatch — uses washed-out gold, not the saturated `--amber` (#C6820E) used throughout the app.
|
||||||
|
- **[Important]** No SSO/OIDC button visible — system supports OIDC but login page only shows username/password.
|
||||||
|
- **[Important]** Subtitle text `--text-muted` (#9C9184) on white fails WCAG AA (~2.8:1, needs 4.5:1).
|
||||||
|
- **[Important]** White text on amber button fails WCAG AA for normal text (~3.2:1).
|
||||||
|
- **[Nice-to-have]** Card has no shadow/border against the `--bg-body` cream background — minimal separation.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- **[Important]** Errors KPI card uses red/orange accent border even when errors = 0. Zero-error state should feel reassuring (green/neutral), not alarming. Creates false alarm fatigue.
|
||||||
|
- **[Important]** Table lacks visible sort indicators — no arrows showing current sort direction.
|
||||||
|
- **[Important]** Duration column uses color alone (`.durFast` green, `.durSlow` amber, `.durBreach` red) — not color-blind safe.
|
||||||
|
- **[Important]** Status dots are ~6px — too small to reliably identify, especially for color-blind users.
|
||||||
|
- **[Critical]** Table meta text at 11px with `--text-muted` is borderline illegible for fatigued users.
|
||||||
|
- **[Critical]** KPI stat labels at 10px with `--text-muted` — below recommended 12px minimum.
|
||||||
|
- **[Nice-to-have]** Exchange ID column too wide — truncate to 8 chars with copy-on-click.
|
||||||
|
|
||||||
|
### Dashboard — Detail Panel
|
||||||
|
|
||||||
|
- **[Important]** Panel lacks clear visual separation from main table — needs left border accent or different background.
|
||||||
|
- **[Important]** Processor timeline preview in panel is too small to read — adds visual noise without utility.
|
||||||
|
- **[Critical]** Overview labels at 10px with `--text-muted` — nearly invisible.
|
||||||
|
- **[Critical]** Panel section meta at 10px with `--text-faint` (#C4BAB0) on white — contrast ratio ~1.9:1, severely fails WCAG AA.
|
||||||
|
- **[Nice-to-have]** No quick actions (copy exchange ID, view logs, view route diagram).
|
||||||
|
|
||||||
|
### Exchange Detail
|
||||||
|
|
||||||
|
- **[Critical]** Processor timeline label column too narrow — processor names are truncated/illegible. This is the page's primary visualization.
|
||||||
|
- **[Critical]** No error highlighting in processor timeline — failed processors need red bars/icons. During incidents, engineers must instantly see WHICH processor failed.
|
||||||
|
- **[Important]** No linkage to route diagram — "View in Route Diagram" would overlay execution on the visual route graph.
|
||||||
|
- **[Important]** Long exchange ID in breadcrumb is visually heavy — truncate with copy button.
|
||||||
|
- **[Important]** Header stat labels at 10px uppercase with `--text-muted` — same contrast issue.
|
||||||
|
|
||||||
|
### Routes Metrics
|
||||||
|
|
||||||
|
- **[Important]** KPI number formatting inconsistent — Dashboard shows "11.742 ms" (decimal + space), Routes shows "11742ms" (no decimal, no space).
|
||||||
|
- **[Important]** No per-route error rate column — error rate in KPI strip but not broken down per route.
|
||||||
|
- **[Important]** Charts disconnected from table — clicking a route should filter/highlight its chart data.
|
||||||
|
- **[Nice-to-have]** No visual comparison between routes (bar chart or heatmap for quick identification of slowest).
|
||||||
|
|
||||||
|
### Agent Health
|
||||||
|
|
||||||
|
- **[Critical]** Stale/Dead agent visual distinction is too subtle — at 3am, the difference between LIVE and DEAD must scream. Dead agents should have prominent red background or strikethrough, not just `--text-muted`.
|
||||||
|
- **[Critical]** Agent state dots (green live, amber stale, gray dead) use color alone — no shape variation for color-blind users.
|
||||||
|
- **[Important]** "2/26" active routes KPI is ambiguous — unit and meaning need to be explicit.
|
||||||
|
- **[Nice-to-have]** Timeline at bottom takes significant space — consider making it collapsible.
|
||||||
|
|
||||||
|
### Agent Instance Detail
|
||||||
|
|
||||||
|
- **[Important]** Charts lack threshold/alert lines — CPU at 2% is fine, but where is "concerning"? Configurable thresholds (CPU > 80%, Memory > 90%) would make charts actionable.
|
||||||
|
- **[Important]** Chart axis labels appear too small.
|
||||||
|
- **[Nice-to-have]** GC Pauses uses area fill while others use line charts — minor inconsistency.
|
||||||
|
- **[Nice-to-have]** Six charts in 2x3 grid can create cognitive overload — consider collapsible groups.
|
||||||
|
|
||||||
|
### Admin — RBAC
|
||||||
|
|
||||||
|
- **[Important]** KPI strip for "Users: 1, Groups: 2, Roles: 4" has too much visual weight — these low-value numbers don't need full stat-card treatment.
|
||||||
|
- **[Important]** "ADMIN" role badge vs "ADMINS" group badge look identical — different badge styles needed (outlined for groups, filled for roles).
|
||||||
|
- **[Nice-to-have]** Empty detail panel ("Select a user to view details") needs icon/illustration.
|
||||||
|
|
||||||
|
### Admin — Audit Log
|
||||||
|
|
||||||
|
- **[Important]** "no data" empty state is uninformative — should explain "No audit events match your filters" with guidance.
|
||||||
|
- **[Important]** No export functionality — audit logs need CSV/JSON export for compliance.
|
||||||
|
- **[Important]** Date range filters use raw datetime inputs — inconsistent with dashboard's polished time range pills.
|
||||||
|
|
||||||
|
### Admin — OIDC Config
|
||||||
|
|
||||||
|
- **[Critical]** "Delete OIDC Configuration" is a destructive action without confirmation dialog — could lock out all SSO users.
|
||||||
|
- **[Important]** No inline validation — Issuer URL should validate format on blur, required fields need indicators.
|
||||||
|
- **[Nice-to-have]** No connection test result display area.
|
||||||
|
|
||||||
|
### Admin — Database
|
||||||
|
|
||||||
|
- **[Important]** Visual treatment inconsistent with rest of app — "Connected" status and pool stats use ad-hoc text, not design system components.
|
||||||
|
- **[Important]** Page title "Database Administration" implies actions, but page is read-only — rename to "Database Status" or add operations.
|
||||||
|
- **[Nice-to-have]** Table row counts should be right-aligned for numerical scanning.
|
||||||
|
|
||||||
|
### Admin — OpenSearch
|
||||||
|
|
||||||
|
- **[Critical]** "Disconnected" status displayed as plain text — needs error styling (red text, error badge, or status banner). Infrastructure disconnection is a critical state.
|
||||||
|
- **[Important]** "Yellow" cluster health displayed as plain text with no visual hierarchy — same size/weight as version number and node count.
|
||||||
|
- **[Important]** Indexing pipeline stats use ad-hoc inline format — should use consistent stat-card pattern.
|
||||||
|
- **[Important]** "Disconnected" + "Yellow" health shown simultaneously is contradictory — if disconnected, clarify whether data is stale.
|
||||||
|
|
||||||
|
### Command Palette
|
||||||
|
|
||||||
|
- **[Nice-to-have]** No visible keyboard navigation hint for currently selected item.
|
||||||
|
- **[Nice-to-have]** Empty palette should show recent/frequent items instead of requiring typing.
|
||||||
|
- Overall well-executed — categories, counts, keyboard hints in footer.
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
|
||||||
|
- **[Critical]** `--text-muted` (#7A7068) on `--bg-surface` (#242019) is ~2.9:1 — fails WCAG AA. Affects ALL muted labels across every page.
|
||||||
|
- **[Critical]** `--text-faint` (#4A4238) on `--bg-surface` (#242019) is ~1.4:1 — catastrophically fails WCAG AA. Essentially invisible.
|
||||||
|
- **[Important]** `--amber` (#D4941E) on `--bg-surface` (#242019) is ~3.6:1 — amber links/active text fail AA.
|
||||||
|
- **[Important]** KPI sparkline chart lines are harder to read — thin strokes need increased width or brightness.
|
||||||
|
- **[Important]** Sidebar boundary contrast drops significantly (`--sidebar-bg` #141210 vs `--bg-body` #1A1714 is only ~6 units apart).
|
||||||
|
- **[Important]** Table row alternation contrast near zero in dark mode.
|
||||||
|
- **[Nice-to-have]** Amber accent color shift from #C6820E to #D4941E is well-handled.
|
||||||
|
- **[Nice-to-have]** Semantic colors (success, error, warning) appropriately increase luminance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Cutting Issues
|
||||||
|
|
||||||
|
### 1. Color Contrast (WCAG AA Failures)
|
||||||
|
|
||||||
|
**Light Mode:**
|
||||||
|
|
||||||
|
| Element | Foreground | Background | Ratio | Required | Verdict |
|
||||||
|
|---------|-----------|------------|-------|----------|---------|
|
||||||
|
| StatCard labels, table meta, section headers | `--text-muted` #9C9184 | #FFFFFF | ~3.0:1 | 4.5:1 | **FAIL** |
|
||||||
|
| Panel meta, overview hints | `--text-faint` #C4BAB0 | #FFFFFF | ~1.9:1 | 4.5:1 | **FAIL** |
|
||||||
|
| Sign-in button text | #FFFFFF | `--amber` #C6820E | ~3.2:1 | 4.5:1 | **FAIL** |
|
||||||
|
| Sidebar muted text | #9C9184 | `--sidebar-bg` #2C2520 | ~3.1:1 | 4.5:1 | **FAIL** |
|
||||||
|
|
||||||
|
**Dark Mode:**
|
||||||
|
|
||||||
|
| Element | Foreground | Background | Ratio | Required | Verdict |
|
||||||
|
|---------|-----------|------------|-------|----------|---------|
|
||||||
|
| All muted labels | #7A7068 | #242019 | ~2.9:1 | 4.5:1 | **FAIL** |
|
||||||
|
| All faint hints | #4A4238 | #242019 | ~1.4:1 | 4.5:1 | **FAIL** |
|
||||||
|
| Amber links/active text | #D4941E | #242019 | ~3.6:1 | 4.5:1 | **FAIL** |
|
||||||
|
|
||||||
|
**Fix:** Change `--text-muted` to **#766A5E** (light) / **#9A9088** (dark). Restrict `--text-faint` to decorative use only or lighten dark variant to #6A6058.
|
||||||
|
|
||||||
|
### 2. Font Size Floor
|
||||||
|
|
||||||
|
10px text is used for: StatCard labels, overview labels, chain labels, section meta, error class names, detail labels, sidebar tree labels. 11px is used for: table meta, error messages, pagination, toggle buttons, chart titles.
|
||||||
|
|
||||||
|
**Fix:** Establish `--font-size-min: 12px` as a design system floor. Update all 10px instances to 12px, all 11px instances to 12px.
|
||||||
|
|
||||||
|
### 3. Number/Unit Formatting
|
||||||
|
|
||||||
|
Inconsistent across pages:
|
||||||
|
- Dashboard: "11.742 ms" (decimal + space)
|
||||||
|
- Routes: "11742ms" (no decimal, no space)
|
||||||
|
- Dashboard: "1.1 msg/s" vs Agent Instance: "0.1/s"
|
||||||
|
|
||||||
|
**Fix:** Create a shared formatting utility enforcing: consistent decimal precision, space before unit, consistent abbreviations.
|
||||||
|
|
||||||
|
### 4. KPI Strip Inconsistency
|
||||||
|
|
||||||
|
Used on Dashboard, Routes, Agents, Agent Instance (consistent). But RBAC uses oversized cards for trivial counts, and Database/OpenSearch use ad-hoc text rendering.
|
||||||
|
|
||||||
|
**Fix:** Admin infra pages should adopt KPI stat strip or a compact-stat component.
|
||||||
|
|
||||||
|
### 5. Empty States
|
||||||
|
|
||||||
|
Inconsistent handling:
|
||||||
|
- Audit Log: "no data" in plain gray
|
||||||
|
- RBAC detail: "Select a user to view details" in gray
|
||||||
|
- No consistent empty state component with icon + message + CTA
|
||||||
|
|
||||||
|
**Fix:** Design system EmptyState component with icon, message, and optional action.
|
||||||
|
|
||||||
|
### 6. Status Indicator Accessibility
|
||||||
|
|
||||||
|
Color-only status encoding throughout:
|
||||||
|
- Duration: green (fast), amber (slow), red (breach) — no icons
|
||||||
|
- Status dots: green (live), amber (stale), gray (dead) — no shapes
|
||||||
|
- Agent dead state uses `--text-muted` instead of `--error`
|
||||||
|
|
||||||
|
**Fix:** Add shape variation (checkmark/triangle/X), increase dot size to 10px minimum, always render text label alongside.
|
||||||
|
|
||||||
|
### 7. Sidebar Structure
|
||||||
|
|
||||||
|
Same apps listed 3x (under Applications, Agents, Routes) — triples sidebar length and scales poorly.
|
||||||
|
|
||||||
|
**Fix:** Unified application-centric tree where expanding an app shows its agents and routes as children.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prioritized Recommendations
|
||||||
|
|
||||||
|
### Critical (fix now)
|
||||||
|
|
||||||
|
| # | Recommendation | Impact |
|
||||||
|
|---|---------------|--------|
|
||||||
|
| 1 | **Bump `--text-muted` to WCAG AA compliance** — #766A5E (light) / #9A9088 (dark). Single highest-impact fix across all pages. | Fixes majority of contrast failures |
|
||||||
|
| 2 | **Establish 12px minimum font size** — update all 10px and 11px instances. Especially StatCard labels, overview labels, table meta. | Readable under stress |
|
||||||
|
| 3 | **Add error highlighting to processor timeline** — red bars, error icons for failed processors. Core debugging view. | Incident response speed |
|
||||||
|
| 4 | **Make Stale/Dead agent states unmistakable** — full card background color (yellow stale, red dead), prominent badge. Change dead from `--text-muted` to `--error`. | Prevents missed outages |
|
||||||
|
| 5 | **Fix OpenSearch "Disconnected" status** — use error badge/banner, add "Reconnect" action, clarify stale data. | Actionable admin page |
|
||||||
|
| 6 | **Add confirmation dialog for OIDC deletion** — type-to-confirm to prevent locking out SSO users. | Prevents lockout |
|
||||||
|
| 7 | **Color Errors KPI card conditionally** — green/neutral at 0, red only when > 0. Prevents false alarm fatigue. | Reduces noise |
|
||||||
|
|
||||||
|
### Important (next sprint)
|
||||||
|
|
||||||
|
| # | Recommendation | Impact |
|
||||||
|
|---|---------------|--------|
|
||||||
|
| 8 | **Add secondary encoding to status indicators** — shapes (checkmark/triangle/X) alongside color dots. Increase dot size to 10px+. | Accessibility compliance |
|
||||||
|
| 9 | **Standardize number/unit formatting** — shared utility for decimals, spacing, unit abbreviations. | Visual consistency |
|
||||||
|
| 10 | **Add per-route error rate to Routes table** — essential for isolating failing routes. | Incident triage |
|
||||||
|
| 11 | **Add visible sort indicators to data tables** — arrows on column headers. | Data exploration |
|
||||||
|
| 12 | **Bring admin infra pages to design system quality** — replace ad-hoc text with KPI strips/stat cards. | Professional polish |
|
||||||
|
| 13 | **Fix login page brand identity** — add camel logo, use correct `--amber` for button, add SSO button when OIDC configured. | First impression |
|
||||||
|
| 14 | **Fix dark mode specifics** — increase sidebar boundary contrast (add 1px border), boost chart stroke width, fix amber link contrast. | Dark mode usability |
|
||||||
|
| 15 | **Widen processor timeline label column** — prevent name truncation, add tooltips for long names. | Core visualization |
|
||||||
|
| 16 | **Add detail panel visual separation** — 2px left border accent. | Layout clarity |
|
||||||
|
| 17 | **Pin Admin/API Docs to sidebar footer** — accessible without scrolling. | Navigation |
|
||||||
|
| 18 | **Audit log improvements** — informative empty state, CSV/JSON export, date picker consistent with dashboard. | Admin usability |
|
||||||
|
| 19 | **OIDC form validation** — inline URL validation, required field indicators, test result display. | Configuration safety |
|
||||||
|
| 20 | **Fix amber button text contrast** — darken button to #8B5A06 or use dark text on amber. | Accessibility |
|
||||||
|
|
||||||
|
### Nice-to-have (backlog)
|
||||||
|
|
||||||
|
| # | Recommendation | Impact |
|
||||||
|
|---|---------------|--------|
|
||||||
|
| 21 | Unify sidebar into single application-centric tree (Applications > agents + routes) | Scalability |
|
||||||
|
| 22 | Truncate Exchange IDs to 8 chars with copy-on-click | Table space |
|
||||||
|
| 23 | Add threshold/alert lines to agent metric charts | Actionable monitoring |
|
||||||
|
| 24 | Link charts to table selection on Routes Metrics | Data exploration |
|
||||||
|
| 25 | Add clickable KPI cards navigating to filtered views | Navigation shortcuts |
|
||||||
|
| 26 | Add `prefers-reduced-motion` support for StatusDot pulse animation | Accessibility |
|
||||||
|
| 27 | Add tooltips to sparkline charts showing value on hover | Data context |
|
||||||
|
| 28 | Replace hardcoded `#5db866` in Dashboard.module.css with `var(--success)` | Token compliance |
|
||||||
|
| 29 | Add keyboard navigation indicators to command palette (selected item highlight) | Power user UX |
|
||||||
|
| 30 | Show recent/frequent items in empty command palette | Discoverability |
|
||||||
|
| 31 | Consolidate duplicated table-header CSS into design system component | Maintainability |
|
||||||
|
| 32 | Login page card shadow for visual lift | Polish |
|
||||||
|
| 33 | Collapsible agent event timeline | Space efficiency |
|
||||||
|
| 34 | Dark mode `--text-faint` increase to #6A6058 for 3:1 minimum | Accessibility |
|
||||||
|
| 35 | Increase DataTable row height to 44px (touch target minimum) | Accessibility |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Mode Assessment
|
||||||
|
|
||||||
|
**Grade: Good foundation, specific contrast concerns.**
|
||||||
|
|
||||||
|
**What works well:**
|
||||||
|
- Token system remaps all semantic colors without introducing cold blue-grays — warm brand preserved
|
||||||
|
- Amber accent brightens appropriately (#C6820E → #D4941E)
|
||||||
|
- Error/warning/success colors increase luminance correctly
|
||||||
|
- Shadows shift from warm semi-transparent to opaque — correct for dark backgrounds
|
||||||
|
|
||||||
|
**What needs fixing:**
|
||||||
|
- Sidebar contrast: `--sidebar-bg` #141210 vs `--bg-body` #1A1714 only ~6 units apart (was ~50 in light mode)
|
||||||
|
- Chart line visibility: thin 1-2px strokes need increased width
|
||||||
|
- Table row alternation: near-zero contrast between `--bg-surface` and `--bg-raised`
|
||||||
|
- `--text-faint`: essentially invisible at 1.4:1 contrast
|
||||||
|
- `--text-muted`: 2.9:1 — below AA minimum
|
||||||
303
audit/admin-lifecycle-findings.md
Normal file
303
audit/admin-lifecycle-findings.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# Cameleer3 Admin UI UX Audit
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
**Auditor:** Claude (automated)
|
||||||
|
**URL:** https://desktop-fb5vgj9.siegeln.internal/
|
||||||
|
**Login:** admin/admin (OIDC-authenticated)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The Cameleer3 UI is generally well-built with consistent styling, good information density, and a clear layout. However, there are several **Critical** bugs that prevent core CRUD operations from working, and a few **Important** UX issues that reduce clarity and usability.
|
||||||
|
|
||||||
|
**Critical issues:** 3
|
||||||
|
**Important issues:** 7
|
||||||
|
**Nice-to-have improvements:** 8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Users & Roles (`/server/admin/rbac`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Clean master-detail layout: user list on the left, detail panel on the right
|
||||||
|
- Summary cards at top (Users: 2, Groups: 1, Roles: 4) provide quick overview
|
||||||
|
- Tab structure (Users / Groups / Roles) is intuitive
|
||||||
|
- User detail shows all relevant info: status, ID, created date, provider, password, group membership, effective roles
|
||||||
|
- Inline role/group management with "+ Add" dropdown and "x" remove buttons
|
||||||
|
- Search bar for filtering users/groups/roles
|
||||||
|
- Delete button correctly disabled for the admin user (last-admin guard)
|
||||||
|
- Group detail shows Top-level, children count, member count, and assigned roles
|
||||||
|
- Local/OIDC toggle on the user creation form
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
#### CRITICAL: User creation fails silently in OIDC mode
|
||||||
|
- **Location:** "+ Add user" button and create user form
|
||||||
|
- **Details:** When OIDC is enabled, the backend returns HTTP 400 with an **empty response body** when attempting to create a local user. The UI shows a generic "Failed to create user" toast with no explanation.
|
||||||
|
- **Root Cause:** `UserAdminController.createUser()` line 92-93 returns `ResponseEntity.badRequest().build()` (no body) when `oidcEnabled` is true.
|
||||||
|
- **Impact:** The UI still shows the "+ Add user" button and the full creation form even though the operation will always fail. Users fill out the form, click Create, and get a useless error.
|
||||||
|
- **Fix:** Either (a) hide the "+ Add user" button when OIDC is enabled, or (b) show a clear inline message like "Local user creation is disabled when OIDC is enabled", or (c) return a proper error body from the API.
|
||||||
|
- **Screenshots:** `09-user-create-filled.png`, `10-user-create-result.png`
|
||||||
|
|
||||||
|
#### IMPORTANT: Unicode escape shown literally in role descriptions
|
||||||
|
- **Location:** Roles tab, role description text
|
||||||
|
- **Details:** Role descriptions display `\u00b7` literally instead of rendering the middle dot character (middle dot).
|
||||||
|
- **Example:** "Full administrative access \u00b7 0 assignments" should be "Full administrative access - 0 assignments"
|
||||||
|
- **Screenshot:** `14-roles-tab.png`
|
||||||
|
|
||||||
|
#### IMPORTANT: No "Confirm password" field in user creation
|
||||||
|
- **Location:** "+ Add user" form
|
||||||
|
- **Details:** The form has Username*, Display name, Email, Password* but no password confirmation field. This increases the risk of typos in passwords.
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: Create button disabled until valid with no inline validation messages
|
||||||
|
- **Location:** User creation form
|
||||||
|
- **Details:** The "Create" button is disabled until form is valid, but there are no visible inline error messages explaining what is required. The asterisks on "Username *" and "Password *" help, but there's no indication of password policy requirements (min 12 chars, 3-of-4 character classes).
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: "Select a user to view details" placeholder
|
||||||
|
- **Location:** Right panel when no user selected
|
||||||
|
- **Details:** The placeholder text is fine but could be more visually styled (e.g., centered, with an icon).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Audit Log (`/server/admin/audit`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Comprehensive filter system: date range (1h/6h/Today/24h/7d/Custom), user filter, category dropdown, action/target search
|
||||||
|
- Category dropdown includes all relevant categories: INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT
|
||||||
|
- Custom date range with From/To date pickers
|
||||||
|
- Table columns: Timestamp, User, Category, Action, Target, Result
|
||||||
|
- Color-coded result badges (SUCCESS in green, FAILURE in red)
|
||||||
|
- Shows my failed user creation attempts correctly logged as FAILURE
|
||||||
|
- Row count indicator ("179 events") with AUTO/MANUAL refresh
|
||||||
|
- Pagination with configurable rows per page
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
#### IMPORTANT: No export functionality
|
||||||
|
- **Location:** Audit log page
|
||||||
|
- **Details:** There is no Export/Download button for audit log data. Compliance requirements typically mandate the ability to export audit logs as CSV or JSON.
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: Audit detail row expansion
|
||||||
|
- **Location:** Table rows are clickable (cursor: pointer) but clicking doesn't reveal additional details
|
||||||
|
- **Details:** For entries like "HTTP POST /api/v1/admin/users FAILURE", it would be helpful to see the error response body or request details in an expanded row.
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: Date range filter is independent of the global time selector
|
||||||
|
- **Location:** Top bar time selector vs. audit log's own time filter
|
||||||
|
- **Details:** The audit log has its own "Last 1h / 6h / Today / 24h / 7d / Custom" filter, which is separate from the global time range in the header bar. While this provides independence, it could confuse users who expect the global time selector to affect the audit log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OIDC Config (`/server/admin/oidc`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Well-organized sections: Behavior, Provider Settings, Claim Mapping, Default Roles, Danger Zone
|
||||||
|
- Each field has a descriptive label and help text (e.g., "RFC 8707 resource indicator sent in the authorization request")
|
||||||
|
- "Test Connection" button at the top for verification
|
||||||
|
- "Save" button is clearly visible
|
||||||
|
- **Excellent** delete protection: "Confirm Deletion" dialog requires typing "delete oidc" to confirm, warns that "All users signed in via OIDC will lose access"
|
||||||
|
- Enabled/Auto Sign-Up checkboxes with clear descriptions
|
||||||
|
- Default Roles management with add/remove
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
#### IMPORTANT: No unsaved changes indicator
|
||||||
|
- **Location:** Form fields
|
||||||
|
- **Details:** If a user modifies a field but navigates away without saving, there is no "You have unsaved changes" warning. This is particularly dangerous for the OIDC configuration since changes could lock users out.
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: Client Secret field is plain text
|
||||||
|
- **Location:** Client Secret textbox
|
||||||
|
- **Details:** The Client Secret is a regular text input, not a password/masked field. Since it's sensitive, it should be masked by default with a "show/hide" toggle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environments (`/server/admin/environments`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Clean list with search and "+ Add environment" button
|
||||||
|
- Master-detail layout consistent with Users & Roles
|
||||||
|
- Environment detail shows: ID, Tier badge (NON-PROD), slug, created date
|
||||||
|
- Sub-tabs for "Production environment" and "Docker Containers"
|
||||||
|
- Default Resource Limits section with configurable values
|
||||||
|
- JAR Retention section with "Edit Policy" button
|
||||||
|
- "Edit Defaults" button for container defaults
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: Slug is shown but not labeled clearly
|
||||||
|
- **Location:** Environment detail panel
|
||||||
|
- **Details:** The slug "default" appears below the display name "Default" but could benefit from a "Slug:" label for clarity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Database (`/server/admin/database`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Clear "Connected" status at the top with green styling
|
||||||
|
- Shows PostgreSQL version string: "PostgreSQL 16.13 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit"
|
||||||
|
- Connection Pool section with Active/Idle/Max counts
|
||||||
|
- Tables section listing all database tables with rows and sizes
|
||||||
|
- Consistent styling with the rest of the admin section
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
No significant issues found. The page is read-only and informational, which is appropriate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ClickHouse (`/server/admin/clickhouse`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Clear "Connected" status with version number (26.3.5.12)
|
||||||
|
- Uptime display: "1 hour, 44 minutes and 29 seconds"
|
||||||
|
- Key metrics: Disk Usage (156.33 MiB), Memory (1.47 GiB), Compression Ratio (0.104x), Rows (4,875,598), Parts (55), Uncompressed Size (424.02 MiB)
|
||||||
|
- Tables section listing all ClickHouse tables with engine, rows, and sizes
|
||||||
|
- Consistent card-based layout
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
No significant issues found. Well-presented status page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deployments Tab (`/server/apps`)
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Table layout showing app name, environment, status, and created date
|
||||||
|
- "+ Create App" button clearly visible
|
||||||
|
- Clicking an app navigates to a detail page with Configuration and Overrides tabs
|
||||||
|
- Configuration has sub-tabs: Monitoring, Variables, Traces & Taps, Route Recording
|
||||||
|
- App detail shows environment (DEFAULT), tier (ORACLE), status
|
||||||
|
- "Create App" full page form with clear Identity & Security, Configuration sections
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
#### CRITICAL: Direct URL /server/deployments returns 404 error
|
||||||
|
- **Location:** `/server/deployments` URL
|
||||||
|
- **Details:** Navigating directly to `/server/deployments` shows "Unexpected Application Error! 404 Not Found" with a React Router development error ("Hey developer -- You can provide a way better UX than this..."). The Deployments tab is actually at `/server/apps`.
|
||||||
|
- **Impact:** Users who bookmark or share the URL will see an unhandled error page instead of a redirect to the correct URL.
|
||||||
|
- **Screenshot:** `20-deployments-tab.png` (first attempt)
|
||||||
|
|
||||||
|
#### IMPORTANT: Create App page shows full configuration before app exists
|
||||||
|
- **Location:** `/server/apps/new`
|
||||||
|
- **Details:** The Create Application page shows Monitoring configuration, Variables, Traces & Taps, and Route Recording sub-tabs with values already populated. This is overwhelming for initial creation -- a simpler wizard-style flow (name + environment first, then configure) would be more intuitive.
|
||||||
|
|
||||||
|
#### NICE-TO-HAVE: App deletion flow not easily discoverable
|
||||||
|
- **Location:** App detail page
|
||||||
|
- **Details:** There is no visible "Delete App" button on the app detail page. The deletion mechanism is not apparent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. SaaS Platform Pages
|
||||||
|
|
||||||
|
### Platform Dashboard (`/platform`)
|
||||||
|
|
||||||
|
#### What Works Well
|
||||||
|
- Clean tenant overview: "Example Tenant" with LOW tier badge
|
||||||
|
- Three summary cards: Tier (LOW), Status (ACTIVE), License (Active, expires 8.4.2027)
|
||||||
|
- Tenant Information section with Slug, Status, Created date
|
||||||
|
- Server Management section with "Open Server Dashboard" button
|
||||||
|
- Sidebar navigation: Dashboard, License, Open Server Dashboard
|
||||||
|
|
||||||
|
#### Issues Found
|
||||||
|
|
||||||
|
##### IMPORTANT: "Slug" label missing space
|
||||||
|
- **Location:** Tenant Information section
|
||||||
|
- **Details:** Shows "Slugdefault" instead of "Slug: default" -- the label and value run together without separation.
|
||||||
|
|
||||||
|
##### NICE-TO-HAVE: "Open Server Dashboard" button appears 3 times
|
||||||
|
- **Location:** Page header, Server Management section, sidebar bottom
|
||||||
|
- **Details:** The same action appears in three places on a single page view. One prominent button would suffice.
|
||||||
|
|
||||||
|
### Platform License (`/platform/license`)
|
||||||
|
|
||||||
|
#### What Works Well
|
||||||
|
- Clear Validity section: Issued, Expires, Days remaining (365 days badge)
|
||||||
|
- Features section with Enabled/Disabled badges for each feature
|
||||||
|
- Limits section: Max Agents, Retention Days, Max Environments
|
||||||
|
- License Token section with "Show token" button for security
|
||||||
|
|
||||||
|
#### Issues Found
|
||||||
|
|
||||||
|
##### IMPORTANT: Labels and values lack spacing
|
||||||
|
- **Location:** Validity section, Limits section
|
||||||
|
- **Details:** "Issued8. April 2026" and "Max Agents3" -- labels and values run together without separators. Should be "Issued: 8. April 2026" and "Max Agents: 3".
|
||||||
|
- **Screenshot:** `02-platform-license.png`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cross-Cutting UX Issues
|
||||||
|
|
||||||
|
### CRITICAL: Sporadic auto-navigation to /server/exchanges
|
||||||
|
- **Location:** Occurs across all admin pages
|
||||||
|
- **Details:** While interacting with admin pages (Users & Roles, Environments, etc.), the browser occasionally auto-navigates back to `/server/exchanges`. This appears to be triggered by the real-time exchange data stream (SSE). Even when auto-refresh is set to MANUAL, the exchange list continues updating and can cause route changes.
|
||||||
|
- **Impact:** Users actively editing admin forms can lose their work mid-interaction. This was observed repeatedly during the audit.
|
||||||
|
- **Root Cause:** Likely a React state update from the SSE exchange stream that triggers a route navigation when the exchange list data changes.
|
||||||
|
|
||||||
|
### IMPORTANT: Error toast messages lack detail
|
||||||
|
- **Location:** Global toast system
|
||||||
|
- **Details:** Error toasts show generic messages like "Failed to create user" without the specific API error reason. The server returns empty 400 bodies in some cases, and even when it returns error details, they may not be surfaced in the toast.
|
||||||
|
|
||||||
|
### NICE-TO-HAVE: Global time range selector persists on admin pages
|
||||||
|
- **Location:** Top header bar on admin pages (Audit Log, ClickHouse, Database, OIDC, etc.)
|
||||||
|
- **Details:** The global time range selector (1h/3h/6h/Today/24h/7d) and the status filter buttons (OK/Warn/Error/Running) appear on every page including admin pages where they are not relevant. This adds visual clutter.
|
||||||
|
|
||||||
|
### NICE-TO-HAVE: Environment dropdown in header on admin pages
|
||||||
|
- **Location:** Top header bar, "All Envs" dropdown
|
||||||
|
- **Details:** The environment selector appears on admin pages where it has no effect (e.g., Users & Roles, OIDC config). It should be hidden or grayed out on pages where it's not applicable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| # | Severity | Page | Issue |
|
||||||
|
|---|----------|------|-------|
|
||||||
|
| 1 | **CRITICAL** | Users & Roles | User creation fails silently in OIDC mode -- form shown but always returns 400 with empty body |
|
||||||
|
| 2 | **CRITICAL** | Deployments | Direct URL `/server/deployments` returns unhandled 404 error page |
|
||||||
|
| 3 | **CRITICAL** | Cross-cutting | Sporadic auto-navigation to `/server/exchanges` interrupts admin page interactions |
|
||||||
|
| 4 | **IMPORTANT** | Users & Roles | Unicode escape `\u00b7` shown literally in role descriptions |
|
||||||
|
| 5 | **IMPORTANT** | Users & Roles | No password confirmation field in user creation form |
|
||||||
|
| 6 | **IMPORTANT** | Audit Log | No export/download functionality for compliance |
|
||||||
|
| 7 | **IMPORTANT** | OIDC | No unsaved changes warning on form navigation |
|
||||||
|
| 8 | **IMPORTANT** | Deployments | Create App page shows all config options before app exists (overwhelming) |
|
||||||
|
| 9 | **IMPORTANT** | Platform Dashboard | Label-value spacing missing ("Slugdefault", "Issued8. April 2026", "Max Agents3") |
|
||||||
|
| 10 | **IMPORTANT** | Cross-cutting | Error toasts lack specific error details from API responses |
|
||||||
|
| 11 | Nice-to-have | Users & Roles | No inline validation messages on creation form (just disabled button) |
|
||||||
|
| 12 | Nice-to-have | Users & Roles | "Select a user to view details" placeholder could be more visual |
|
||||||
|
| 13 | Nice-to-have | Audit Log | Clickable rows don't expand to show additional event detail |
|
||||||
|
| 14 | Nice-to-have | Audit Log | Separate time filter from global time selector could confuse users |
|
||||||
|
| 15 | Nice-to-have | OIDC | Client Secret field should be masked by default |
|
||||||
|
| 16 | Nice-to-have | Environments | Slug display could use explicit label |
|
||||||
|
| 17 | Nice-to-have | Deployments | Delete app flow not easily discoverable |
|
||||||
|
| 18 | Nice-to-have | Cross-cutting | Global time range and status filter buttons shown on irrelevant admin pages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots Index
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `01-platform-dashboard.png` | SaaS Platform dashboard |
|
||||||
|
| `02-platform-license.png` | License page with features and limits |
|
||||||
|
| `03-server-exchanges-overview.png` | Server exchanges main view |
|
||||||
|
| `05-users-roles-page.png` | Users & Roles list view |
|
||||||
|
| `06-user-detail-admin.png` | Admin user detail panel |
|
||||||
|
| `07-add-user-dialog.png` | Add user form (showing along with detail) |
|
||||||
|
| `09-user-create-filled.png` | User creation form filled out |
|
||||||
|
| `10-user-create-result.png` | Error toast after failed user creation |
|
||||||
|
| `11-rbac-after-create.png` | RBAC page after failed creation (still 2 users) |
|
||||||
|
| `13-groups-tab.png` | Groups tab with Admins group |
|
||||||
|
| `14-roles-tab.png` | Roles tab showing unicode escape bug |
|
||||||
|
| `15-audit-log.png` | Audit log with failed user creation events |
|
||||||
|
| `16-clickhouse.png` | ClickHouse status page |
|
||||||
|
| `17-database.png` | Database status page |
|
||||||
|
| `18-environments.png` | Environments list |
|
||||||
|
| `19-oidc.png` | OIDC configuration page |
|
||||||
|
| `19-oidc-full.png` | OIDC full page (scrolled) |
|
||||||
|
| `20-deployments-tab.png` | Deployments tab (via tab click) |
|
||||||
|
| `21-environment-detail.png` | Default environment detail |
|
||||||
|
| `22-create-app.png` | Create Application form |
|
||||||
|
| `23-app-detail.png` | Sample app detail page |
|
||||||
|
| `24-runtime-tab.png` | Runtime tab with agents |
|
||||||
|
| `25-dashboard-tab.png` | Dashboard with metrics and charts |
|
||||||
|
| `26-oidc-delete-confirm.png` | OIDC delete confirmation dialog (well done) |
|
||||||
354
audit/design-consistency-findings.md
Normal file
354
audit/design-consistency-findings.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# Design Consistency Audit — Cameleer3 UI
|
||||||
|
|
||||||
|
**Audited**: 2026-04-09
|
||||||
|
**Scope**: All pages under `ui/src/pages/`
|
||||||
|
**Base path**: `C:/Users/Hendrik/Documents/projects/cameleer3-server/ui/src/`
|
||||||
|
|
||||||
|
|
||||||
|
## Shared Layout Infrastructure
|
||||||
|
|
||||||
|
### LayoutShell (`components/LayoutShell.tsx`)
|
||||||
|
All pages render inside `<main className={css.mainContent}>` which applies:
|
||||||
|
```css
|
||||||
|
.mainContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This is a flex column container with **no padding/margin**. Each page is responsible for its own content spacing.
|
||||||
|
|
||||||
|
### Shared CSS Modules (`styles/`)
|
||||||
|
| Module | Class | Pattern |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| `section-card.module.css` | `.section` | Card with `padding: 16px 20px`, border, shadow, `margin-bottom: 16px` |
|
||||||
|
| `table-section.module.css` | `.tableSection` | Card wrapper for tables, no padding (overflow hidden), with `.tableHeader` (12px 16px padding) |
|
||||||
|
| `chart-card.module.css` | `.chartCard` | Card with `padding: 16px` |
|
||||||
|
| `log-panel.module.css` | `.logCard` | Card for log viewers, max-height 420px |
|
||||||
|
| `refresh-indicator.module.css` | `.refreshIndicator` | Auto-refresh dot indicator |
|
||||||
|
| `rate-colors.module.css` | `.rateGood/.rateWarn/.rateBad` | Semantic color helpers |
|
||||||
|
|
||||||
|
|
||||||
|
## Per-Page Findings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. Exchanges Page (`pages/Exchanges/`)
|
||||||
|
|
||||||
|
**Files**: `ExchangesPage.tsx`, `ExchangesPage.module.css`, `ExchangeHeader.tsx`, `ExchangeHeader.module.css`, `RouteControlBar.tsx`, `RouteControlBar.module.css`
|
||||||
|
|
||||||
|
**Container pattern**: NO wrapper padding. Uses `height: 100%` split-view layout that fills the entire `mainContent` area.
|
||||||
|
|
||||||
|
**Content wrapper**:
|
||||||
|
```css
|
||||||
|
.splitView { display: flex; height: 100%; overflow: hidden; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Table**: The exchange list is rendered by `Dashboard.tsx` (in `pages/Dashboard/`), which uses:
|
||||||
|
```css
|
||||||
|
.content { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; background: var(--bg-body); }
|
||||||
|
```
|
||||||
|
- Custom `.tableHeader` with `padding: 8px 12px` (slightly tighter than shared `tableStyles.tableHeader` which uses `12px 16px`)
|
||||||
|
- `DataTable` rendered with `flush` and `fillHeight` props
|
||||||
|
- **NO card wrapper** around the table — it's full-bleed against the background
|
||||||
|
- **Does NOT import shared `table-section.module.css`** — rolls its own `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta`
|
||||||
|
|
||||||
|
**Shared modules used**: NONE. All custom.
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Full-bleed table with no card, no container padding. Custom table header styling duplicates shared module patterns with slightly different padding values (8px 12px vs 12px 16px).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Dashboard Tab (`pages/DashboardTab/`)
|
||||||
|
|
||||||
|
**Files**: `DashboardPage.tsx`, `DashboardL1.tsx`, `DashboardL2.tsx`, `DashboardL3.tsx`, `DashboardTab.module.css`
|
||||||
|
|
||||||
|
**Container pattern**:
|
||||||
|
```css
|
||||||
|
.content { display: flex; flex-direction: column; gap: 20px; flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
|
||||||
|
```
|
||||||
|
- **No top/left/right padding** — content is full-width inside `mainContent`
|
||||||
|
- Only `padding-bottom: 20px` and `gap: 20px` between sections
|
||||||
|
|
||||||
|
**Tables**: Wrapped in shared `tableStyles.tableSection` (card with border, shadow, border-radius). Imports `table-section.module.css`.
|
||||||
|
|
||||||
|
**Charts**: Wrapped in design-system `<Card>` component.
|
||||||
|
|
||||||
|
**Custom sections**: `errorsSection` and `diagramSection` duplicate the card pattern:
|
||||||
|
```css
|
||||||
|
.errorsSection {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
This is identical to `tableStyles.tableSection` but defined separately in `DashboardTab.module.css`.
|
||||||
|
|
||||||
|
**Shared modules used**: `table-section.module.css`, `refresh-indicator.module.css`, `rate-colors.module.css`
|
||||||
|
|
||||||
|
**INCONSISTENCY**: No container padding means KPI strip and tables sit flush against the sidebar/edge. The `.errorsSection` duplicates `tableStyles.tableSection` exactly — should import the shared module instead of copy-pasting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Runtime Tab — Agent Health (`pages/AgentHealth/`)
|
||||||
|
|
||||||
|
**Files**: `AgentHealth.tsx`, `AgentHealth.module.css`
|
||||||
|
|
||||||
|
**Container pattern**:
|
||||||
|
```css
|
||||||
|
.content { flex: 1; overflow-y: auto; padding: 20px 24px 40px; min-width: 0; background: var(--bg-body); }
|
||||||
|
```
|
||||||
|
- **Has explicit padding**: `20px 24px 40px` (top, sides, bottom)
|
||||||
|
|
||||||
|
**Tables**: Uses design-system `DataTable` inside a DS `Card` component for agent group cards. The group cards use custom `.groupGrid` grid layout. No `tableStyles.tableSection` wrapper.
|
||||||
|
|
||||||
|
**Cards/sections**: Custom card patterns like `.configBar`, `.eventCard`:
|
||||||
|
```css
|
||||||
|
.configBar {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shared modules used**: `log-panel.module.css`
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Uses `padding: 20px 24px 40px` — different from DashboardTab (no padding) and Exchanges (no padding). Custom card patterns duplicate the standard card styling. Does not use `table-section.module.css` or `section-card.module.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Runtime Tab — Agent Instance (`pages/AgentInstance/`)
|
||||||
|
|
||||||
|
**Files**: `AgentInstance.tsx`, `AgentInstance.module.css`
|
||||||
|
|
||||||
|
**Container pattern**:
|
||||||
|
```css
|
||||||
|
.content { flex: 1; overflow-y: auto; padding: 20px 24px 40px; min-width: 0; background: var(--bg-body); }
|
||||||
|
```
|
||||||
|
- Matches AgentHealth padding exactly (consistent within Runtime tab)
|
||||||
|
|
||||||
|
**Cards/sections**: Custom `.processCard`, `.timelineCard` duplicate the card pattern. Uses `chart-card.module.css` for chart wrappers.
|
||||||
|
|
||||||
|
**Shared modules used**: `log-panel.module.css`, `chart-card.module.css`
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Consistent with AgentHealth but inconsistent with DashboardTab and Exchanges. Custom card patterns (processCard, timelineCard) duplicate shared module patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Apps Tab (`pages/AppsTab/`)
|
||||||
|
|
||||||
|
**Files**: `AppsTab.tsx`, `AppsTab.module.css`
|
||||||
|
|
||||||
|
**Container pattern**:
|
||||||
|
```css
|
||||||
|
.container { padding: 16px; overflow-y: auto; flex: 1; }
|
||||||
|
```
|
||||||
|
- **Has padding**: `16px` all around
|
||||||
|
|
||||||
|
**Content structure**: Three sub-views (`AppListView`, `AppDetailView`, `CreateAppView`) all wrapped in `.container`.
|
||||||
|
|
||||||
|
**Tables**: App list uses `DataTable` directly — no `tableStyles.tableSection` wrapper. Deployment table uses custom `.table` with manual `<table>` HTML (not DataTable).
|
||||||
|
|
||||||
|
**Form controls**: Directly on page background with custom grid layout (`.configGrid`). Uses `SectionHeader` from design-system for visual grouping, but forms are not in cards/sections — they sit flat against the `.container` background.
|
||||||
|
|
||||||
|
**Custom elements**:
|
||||||
|
- `.editBanner` / `.editBannerActive` — custom banner pattern
|
||||||
|
- `.configGrid` — 2-column label/input grid
|
||||||
|
- `.table` — fully custom `<table>` styling (not DataTable)
|
||||||
|
|
||||||
|
**Shared modules used**: NONE. All custom.
|
||||||
|
|
||||||
|
**INCONSISTENCY (user-reported)**: Controls "meshed into background" — correct. Form controls use `SectionHeader` for labels but no `section-card` wrapper. The Tabs component provides visual grouping but the content below tabs is flat. Config grids, toggles, and inputs sit directly on `var(--bg-body)` background via the 16px-padded container. No card/section separation between different config groups. Also uses a manual `<table>` element instead of DataTable for deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Admin — RBAC Page (`pages/Admin/RbacPage.tsx`, `UsersTab.tsx`, `GroupsTab.tsx`, `RolesTab.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: AdminLayout provides `padding: 20px 24px 40px`. RbacPage renders a bare `<div>` (no extra wrapper class).
|
||||||
|
|
||||||
|
**Content**: Uses `StatCard` strip, `Tabs`, then tab content. Detail views use `SplitPane` (from design-system). User/Group/Role detail sections use `SectionHeader` without card wrappers.
|
||||||
|
|
||||||
|
**Stat strip**: Custom grid — `grid-template-columns: repeat(3, 1fr)` with `gap: 10px; margin-bottom: 16px`
|
||||||
|
|
||||||
|
**Shared modules used**: NONE. Uses `UserManagement.module.css` (custom).
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Detail sections use `SectionHeader` labels but content is flat (no `section-card` wrapper). Similar to AppsTab pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Admin — Audit Log (`pages/Admin/AuditLogPage.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: Inherits AdminLayout padding (`20px 24px 40px`). Renders a bare `<div>`.
|
||||||
|
|
||||||
|
**Table**: Properly uses shared `tableStyles.tableSection` with `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta`.
|
||||||
|
|
||||||
|
**Shared modules used**: `table-section.module.css`
|
||||||
|
|
||||||
|
**STATUS**: CONSISTENT with shared patterns for the table section. Good.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Admin — OIDC Config (`pages/Admin/OidcConfigPage.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: Inherits AdminLayout padding. Adds `.page { max-width: 640px; margin: 0 auto; }` — centered narrow layout.
|
||||||
|
|
||||||
|
**Sections**: Uses shared `sectionStyles.section` from `section-card.module.css` for every form group. Uses `SectionHeader` inside each section card.
|
||||||
|
|
||||||
|
**Shared modules used**: `section-card.module.css`
|
||||||
|
|
||||||
|
**STATUS**: GOOD. This is the correct pattern — form groups wrapped in section cards. Should be the model for other form pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Admin — Database (`pages/Admin/DatabaseAdminPage.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: Inherits AdminLayout padding. Renders bare `<div>`.
|
||||||
|
|
||||||
|
**Tables**: Uses `DataTable` directly with NO `tableStyles.tableSection` wrapper. Tables under custom `.section` divs with `.sectionHeading` text labels.
|
||||||
|
|
||||||
|
**Cards**: Uses DS `<Card>` for connection pool. Stat strip is a flex layout.
|
||||||
|
|
||||||
|
**Shared modules used**: NONE. All custom.
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Tables not wrapped in `tableStyles.tableSection`. Uses custom section headings instead of `SectionHeader`. Missing card wrappers around tables. Stat strip uses `flex` layout while other pages use `grid`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Admin — ClickHouse (`pages/Admin/ClickHouseAdminPage.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: Inherits AdminLayout padding. Renders bare `<div>`.
|
||||||
|
|
||||||
|
**Tables**: Uses shared `tableStyles.tableSection` combined with custom `.tableSection` for margin: `className={tableStyles.tableSection} ${styles.tableSection}`.
|
||||||
|
|
||||||
|
**Custom elements**: `.pipelineCard` duplicates card pattern (bg-surface, border, radius, shadow, padding).
|
||||||
|
|
||||||
|
**Shared modules used**: `table-section.module.css`
|
||||||
|
|
||||||
|
**PARTIAL**: Tables correctly use shared module. Pipeline card duplicates shared card pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Admin — Environments (`pages/Admin/EnvironmentsPage.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: Inherits AdminLayout padding. Renders via `SplitPane` (design-system).
|
||||||
|
|
||||||
|
**Content**: Uses `SectionHeader`, `SplitPane`, custom meta grids from `UserManagement.module.css`.
|
||||||
|
|
||||||
|
**Shared modules used**: Uses `UserManagement.module.css` (shared with RBAC pages)
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Does not use `section-card.module.css` for form sections. Config sections use `SectionHeader` without card wrappers. `SplitPane` provides some structure but detail content is flat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Admin — App Config Detail (`pages/Admin/AppConfigDetailPage.tsx`)
|
||||||
|
|
||||||
|
**Container pattern**: Adds `.page { max-width: 720px; margin: 0 auto; }` — centered layout.
|
||||||
|
|
||||||
|
**Sections**: Uses shared `sectionStyles.section` from `section-card.module.css`. Uses `SectionHeader` inside section cards. Custom header card duplicates the card pattern.
|
||||||
|
|
||||||
|
**Shared modules used**: `section-card.module.css`
|
||||||
|
|
||||||
|
**STATUS**: GOOD. Follows same pattern as OIDC page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Routes pages (`pages/Routes/`) — NOT ROUTED
|
||||||
|
|
||||||
|
These pages (`RoutesMetrics.tsx`, `RouteDetail.tsx`) exist but are NOT in `router.tsx`. They may be deprecated or used as sub-components. `RoutesMetrics` correctly uses shared `tableStyles.tableSection`. `RouteDetail` has many custom card patterns (`.headerCard`, `.diagramPane`, `.statsPane`, `.executionsTable`, `.routeFlowSection`) that duplicate the shared card pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Summary: Inconsistency Matrix
|
||||||
|
|
||||||
|
### Container Padding
|
||||||
|
|
||||||
|
| Page | Padding | Pattern |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **Exchanges** | NONE (full-bleed) | `height: 100%`, fills container |
|
||||||
|
| **Dashboard Tab** | NONE (gap only) | `gap: 20px`, `padding-bottom: 20px` only |
|
||||||
|
| **Runtime (AgentHealth)** | `20px 24px 40px` | Explicit padding |
|
||||||
|
| **Runtime (AgentInstance)** | `20px 24px 40px` | Explicit padding |
|
||||||
|
| **Apps Tab** | `16px` | Uniform padding |
|
||||||
|
| **Admin pages** | `20px 24px 40px` | Via AdminLayout |
|
||||||
|
|
||||||
|
**Finding**: Three different padding strategies. Exchanges and Dashboard have no padding; Runtime and Admin use 20px/24px; Apps uses 16px.
|
||||||
|
|
||||||
|
|
||||||
|
### Table Wrapper Pattern
|
||||||
|
|
||||||
|
| Page | Uses `tableStyles.tableSection`? | Card wrapper? |
|
||||||
|
|------|----------------------------------|---------------|
|
||||||
|
| **Exchanges (Dashboard.tsx)** | NO — custom `.tableHeader` | NO — full-bleed |
|
||||||
|
| **Dashboard L1/L2/L3** | YES | YES (shared) |
|
||||||
|
| **Runtime AgentHealth** | NO | YES (via DS `Card`) |
|
||||||
|
| **Apps Tab** | NO | NO — bare `<table>` |
|
||||||
|
| **Admin — Audit** | YES | YES (shared) |
|
||||||
|
| **Admin — ClickHouse** | YES | YES (shared) |
|
||||||
|
| **Admin — Database** | NO | NO |
|
||||||
|
|
||||||
|
**Finding**: 4 of 7 table-using pages do NOT use the shared `table-section.module.css`. The Exchanges page custom header has padding `8px 12px` vs shared `12px 16px`.
|
||||||
|
|
||||||
|
|
||||||
|
### Form/Control Wrapper Pattern
|
||||||
|
|
||||||
|
| Page | Form controls in cards? | Uses `section-card`? |
|
||||||
|
|------|------------------------|---------------------|
|
||||||
|
| **Apps Tab (detail)** | NO — flat against background | NO |
|
||||||
|
| **Apps Tab (create)** | NO — flat against background | NO |
|
||||||
|
| **Admin — OIDC** | YES | YES |
|
||||||
|
| **Admin — App Config** | YES | YES |
|
||||||
|
| **Admin — RBAC detail** | NO — flat against background | NO |
|
||||||
|
| **Admin — Environments** | NO — flat against background | NO |
|
||||||
|
| **Admin — Database** | PARTIAL (Card for pool) | NO |
|
||||||
|
| **Runtime — AgentHealth** | YES (custom `.configBar`) | NO (custom) |
|
||||||
|
|
||||||
|
**Finding**: Only OIDC and AppConfigDetail use `section-card.module.css` for form grouping. Most form pages render controls flat against the page background.
|
||||||
|
|
||||||
|
|
||||||
|
### Duplicated Card Pattern
|
||||||
|
|
||||||
|
The following CSS pattern appears in 8+ custom locations instead of importing `section-card.module.css` or `table-section.module.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Duplicated in**:
|
||||||
|
- `DashboardTab.module.css` → `.errorsSection`, `.diagramSection`
|
||||||
|
- `AgentHealth.module.css` → `.configBar`, `.eventCard`
|
||||||
|
- `AgentInstance.module.css` → `.processCard`, `.timelineCard`
|
||||||
|
- `ClickHouseAdminPage.module.css` → `.pipelineCard`
|
||||||
|
- `AppConfigDetailPage.module.css` → `.header`
|
||||||
|
- `RouteDetail.module.css` → `.headerCard`, `.diagramPane`, `.statsPane`, `.executionsTable`, `.routeFlowSection`
|
||||||
|
|
||||||
|
|
||||||
|
## Prioritized Fixes
|
||||||
|
|
||||||
|
### P0 — User-reported issues
|
||||||
|
1. **Exchanges table full-bleed**: `Dashboard.tsx` should wrap its table in `tableStyles.tableSection` and use the shared table header classes instead of custom ones. Custom `.tableHeader` padding (8px 12px) should match shared (12px 16px).
|
||||||
|
2. **Apps detail flat controls**: `AppsTab.tsx` config sections should wrap form groups in `sectionStyles.section` (from `section-card.module.css`), matching the OIDC page pattern.
|
||||||
|
3. **Apps deployment table**: Replace manual `<table>` with `DataTable` inside `tableStyles.tableSection`.
|
||||||
|
|
||||||
|
### P1 — Padding normalization
|
||||||
|
4. **Standardize container padding**: Choose ONE pattern for scrollable content areas. Recommended: `padding: 20px 24px 40px` (currently used by Runtime + Admin). Apply to DashboardTab's `.content`. Exchanges is an exception due to its split-view height-filling layout.
|
||||||
|
5. **DashboardTab.module.css**: Add side padding to `.content`.
|
||||||
|
|
||||||
|
### P2 — Shared module adoption
|
||||||
|
6. **Replace duplicated card patterns**: Import `section-card.module.css` or `table-section.module.css` instead of duplicating the card CSS in:
|
||||||
|
- `DashboardTab.module.css` (`.errorsSection` -> use `tableStyles.tableSection`)
|
||||||
|
- `AgentHealth.module.css` (`.configBar`, `.eventCard`)
|
||||||
|
- `AgentInstance.module.css` (`.processCard`, `.timelineCard`)
|
||||||
|
- `ClickHouseAdminPage.module.css` (`.pipelineCard`)
|
||||||
|
7. **Database admin**: Wrap tables in `tableStyles.tableSection`.
|
||||||
|
8. **Admin detail pages** (RBAC, Environments): Wrap form sections in `sectionStyles.section`.
|
||||||
599
audit/interaction-patterns-findings.md
Normal file
599
audit/interaction-patterns-findings.md
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
# Cameleer3 UI Interaction Patterns Audit
|
||||||
|
|
||||||
|
Audit date: 2026-04-09
|
||||||
|
Scope: All `.tsx` files under `ui/src/pages/` and `ui/src/components/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Delete / Destructive Operations
|
||||||
|
|
||||||
|
### 1.1 Delete User
|
||||||
|
- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 155-172, 358-365, 580-587)
|
||||||
|
- **Button location**: Detail pane header, top-right, inline with avatar and name
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Delete</Button>`
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Message: `Delete user "${name}"? This cannot be undone.`
|
||||||
|
- Confirm text: user's `displayName`
|
||||||
|
- Has `loading` prop bound to mutation
|
||||||
|
- **Self-delete guard**: Button is `disabled={isSelf}` (cannot delete yourself)
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "User deleted"
|
||||||
|
- **Toast on error**: `variant: 'error'`, `duration: 86_400_000`
|
||||||
|
|
||||||
|
### 1.2 Remove User From Group (via User detail)
|
||||||
|
- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 588-613)
|
||||||
|
- **Button location**: Tag `onRemove` handler on group tags in detail pane
|
||||||
|
- **Confirmation**: `AlertDialog` (simple confirm, no type-to-confirm)
|
||||||
|
- Title: "Remove group membership"
|
||||||
|
- Description: "Removing this group may also revoke inherited roles. Continue?"
|
||||||
|
- Confirm label: "Remove"
|
||||||
|
- Variant: `warning`
|
||||||
|
- **Toast on success**: `variant: 'success'`, title: "Group removed"
|
||||||
|
|
||||||
|
### 1.3 Remove Role From User (via User detail)
|
||||||
|
- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 504-528)
|
||||||
|
- **Button location**: Tag `onRemove` handler on role tags in detail pane
|
||||||
|
- **Confirmation**: NONE -- immediate mutation on tag remove click
|
||||||
|
- **Toast on success**: `variant: 'success'`, title: "Role removed"
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Removing a group shows an AlertDialog confirmation but removing a role does not, even though both can have cascading effects.
|
||||||
|
|
||||||
|
### 1.4 Delete Group
|
||||||
|
- **File**: `ui/src/pages/Admin/GroupsTab.tsx` (lines 140-155, 340-347, 434-441)
|
||||||
|
- **Button location**: Detail pane header, top-right
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Delete</Button>`
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Message: `Delete group "${name}"? This cannot be undone.`
|
||||||
|
- Confirm text: group's `name`
|
||||||
|
- Has `loading` prop
|
||||||
|
- **Built-in guard**: Button is `disabled={isBuiltinAdmins}`
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "Group deleted"
|
||||||
|
|
||||||
|
### 1.5 Remove Role From Group
|
||||||
|
- **File**: `ui/src/pages/Admin/GroupsTab.tsx` (lines 404-427, 442-455)
|
||||||
|
- **Button location**: Tag `onRemove` handler on role tags in group detail
|
||||||
|
- **Confirmation**: `AlertDialog` shown ONLY when the group has members (conditional)
|
||||||
|
- Title: "Remove role from group"
|
||||||
|
- Description: `Removing this role will affect ${members.length} member(s) who inherit it. Continue?`
|
||||||
|
- Confirm label: "Remove"
|
||||||
|
- Variant: `warning`
|
||||||
|
- **If group has no members**: Immediate mutation, no confirmation
|
||||||
|
- **Toast on success**: `variant: 'success'`, title: "Role removed"
|
||||||
|
|
||||||
|
### 1.6 Remove Member From Group
|
||||||
|
- **File**: `ui/src/pages/Admin/GroupsTab.tsx` (lines 366-372)
|
||||||
|
- **Button location**: Tag `onRemove` handler on member tags in group detail
|
||||||
|
- **Confirmation**: NONE -- immediate mutation on tag remove click
|
||||||
|
- **Toast on success**: `variant: 'success'`, title: "Member removed"
|
||||||
|
|
||||||
|
### 1.7 Delete Role
|
||||||
|
- **File**: `ui/src/pages/Admin/RolesTab.tsx` (lines 93-110, 261-265, 223-231)
|
||||||
|
- **Button location**: Detail pane header, top-right
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Delete</Button>`
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Message: `Delete role "${name}"? This cannot be undone.`
|
||||||
|
- Confirm text: role's `name`
|
||||||
|
- Has `loading` prop
|
||||||
|
- **System role guard**: Button hidden for system roles (`!role.system`)
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "Role deleted"
|
||||||
|
|
||||||
|
### 1.8 Delete Environment
|
||||||
|
- **File**: `ui/src/pages/Admin/EnvironmentsPage.tsx` (lines 101-112, 245-252, 319-327)
|
||||||
|
- **Button location**: Detail pane header, top-right
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Delete</Button>`
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Message: `Delete environment "${displayName}"? All apps and deployments in this environment will be removed. This cannot be undone.`
|
||||||
|
- Confirm text: environment's `slug` (NOT the display name)
|
||||||
|
- Has `loading` prop
|
||||||
|
- **Default guard**: Button is `disabled={isDefault}` (cannot delete default environment)
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "Environment deleted"
|
||||||
|
|
||||||
|
**NOTE**: The confirm text requires the slug but the message shows the display name. This is intentional (slug is the unique identifier) but differs from Users/Groups/Roles which use the display name.
|
||||||
|
|
||||||
|
### 1.9 Delete OIDC Configuration
|
||||||
|
- **File**: `ui/src/pages/Admin/OidcConfigPage.tsx` (lines 113-124, 253-264)
|
||||||
|
- **Button location**: Bottom of page in a "Danger Zone" section
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Delete OIDC Configuration</Button>`
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Message: `Delete OIDC configuration? All users signed in via OIDC will lose access.`
|
||||||
|
- Confirm text: `"delete oidc"` (static string)
|
||||||
|
- NO `loading` prop
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "Configuration deleted"
|
||||||
|
|
||||||
|
**INCONSISTENCY**: No `loading` prop on this ConfirmDialog, unlike all other delete confirmations.
|
||||||
|
|
||||||
|
### 1.10 Delete App
|
||||||
|
- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 533-539, 565, 589-596)
|
||||||
|
- **Button location**: App detail header, top-right, in `detailActions` div alongside "Upload JAR"
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Delete App</Button>`
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Message: `Delete app "${displayName}"? All versions and deployments will be removed. This cannot be undone.`
|
||||||
|
- Confirm text: app's `slug`
|
||||||
|
- Has `loading` prop
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "App deleted"
|
||||||
|
- **Post-delete**: Navigates to `/apps`
|
||||||
|
|
||||||
|
### 1.11 Stop Deployment
|
||||||
|
- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 526-531, 672)
|
||||||
|
- **Button location**: Inline in deployments table, right-aligned actions column
|
||||||
|
- **Button**: `<Button size="sm" variant="danger">Stop</Button>`
|
||||||
|
- **Confirmation**: NONE -- immediate mutation on click
|
||||||
|
- **Toast on success**: `variant: 'warning'`, title: "Deployment stopped"
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Stopping a deployment is a destructive operation that affects live services but has NO confirmation dialog. Route stop/suspend in RouteControlBar uses a ConfirmDialog, but deployment stop does not.
|
||||||
|
|
||||||
|
### 1.12 Stop/Suspend Route
|
||||||
|
- **File**: `ui/src/pages/Exchanges/RouteControlBar.tsx` (lines 43-154)
|
||||||
|
- **Button location**: Route control bar (segmented button group)
|
||||||
|
- **Button**: Custom segmented `<button>` elements (not design system Button)
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm) -- only for `stop` and `suspend` actions
|
||||||
|
- Title: `"Stop route?"` or `"Suspend route?"`
|
||||||
|
- Message: `This will ${action} route "${routeId}" on ${application}. This affects all live agents.`
|
||||||
|
- Confirm text: the action name (e.g., `"stop"` or `"suspend"`)
|
||||||
|
- Confirm label: `"Stop Route"` or `"Suspend Route"`
|
||||||
|
- Variant: `danger` for stop, `warning` for suspend
|
||||||
|
- Has `loading` prop
|
||||||
|
- **Start and Resume**: No confirmation (immediate action)
|
||||||
|
- **Toast patterns match others**
|
||||||
|
|
||||||
|
### 1.13 Delete Tap (Route Detail page)
|
||||||
|
- **File**: `ui/src/pages/Routes/RouteDetail.tsx` (lines 991-1001)
|
||||||
|
- **Button location**: Inline delete icon button in taps table row
|
||||||
|
- **Confirmation**: `ConfirmDialog` (type-to-confirm)
|
||||||
|
- Title: "Delete Tap"
|
||||||
|
- Message: `This will remove the tap "${attributeName}" from the configuration.`
|
||||||
|
- Confirm text: tap's `attributeName`
|
||||||
|
- Confirm label: "Delete"
|
||||||
|
- Variant: `danger`
|
||||||
|
- **No `loading` prop on this dialog**
|
||||||
|
|
||||||
|
**INCONSISTENCY**: No `loading` prop, unlike entity delete confirmations.
|
||||||
|
|
||||||
|
### 1.14 Delete Tap (TapConfigModal)
|
||||||
|
- **File**: `ui/src/components/TapConfigModal.tsx` (lines 117-122, 249-253)
|
||||||
|
- **Button location**: Inside the modal footer, left-aligned (only shown when editing)
|
||||||
|
- **Button**: `<Button variant="danger">Delete</Button>`
|
||||||
|
- **Confirmation**: NONE -- immediate call to `onDelete` then `onClose`
|
||||||
|
- **Toast**: Handled by parent component (ExchangesPage)
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Deleting a tap from the TapConfigModal has no confirmation, but deleting from the RouteDetail table shows a ConfirmDialog.
|
||||||
|
|
||||||
|
### 1.15 Kill Database Query
|
||||||
|
- **File**: `ui/src/pages/Admin/DatabaseAdminPage.tsx` (line 30)
|
||||||
|
- **Button location**: Inline in active queries table
|
||||||
|
- **Button**: `<Button variant="danger" size="sm">Kill</Button>`
|
||||||
|
- **Confirmation**: NONE -- immediate mutation
|
||||||
|
- **Toast**: None visible
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Killing a database query is a destructive action with no confirmation and no toast feedback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Button Placement & Order
|
||||||
|
|
||||||
|
### 2.1 Create Forms (Users, Groups, Roles, Environments)
|
||||||
|
|
||||||
|
All four entity create forms use an identical pattern:
|
||||||
|
|
||||||
|
| Page | File | Line | Left Button | Right Button |
|
||||||
|
|------|------|------|-------------|--------------|
|
||||||
|
| Users | `UsersTab.tsx` | 254-274 | Cancel (ghost) | Create (primary) |
|
||||||
|
| Groups | `GroupsTab.tsx` | 251-268 | Cancel (ghost) | Create (primary) |
|
||||||
|
| Roles | `RolesTab.tsx` | 142-159 | Cancel (ghost) | Create (primary) |
|
||||||
|
| Environments | `EnvironmentsPage.tsx` | 181-194 | Cancel (ghost) | Create (primary) |
|
||||||
|
|
||||||
|
- **Position**: Bottom of inline create form in the list pane
|
||||||
|
- **Container class**: `styles.createFormActions`
|
||||||
|
- **Order**: Cancel (left) | Create (right) -- **CONSISTENT**
|
||||||
|
- **Variants**: Cancel = `ghost`, Create = `primary` -- **CONSISTENT**
|
||||||
|
- **Size**: Both `sm` -- **CONSISTENT**
|
||||||
|
|
||||||
|
### 2.2 App Creation Page
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 282-287)
|
||||||
|
- **Position**: Top of page in `detailActions` header area
|
||||||
|
- **Order**: Cancel (ghost, left) | Create & Deploy / Create (primary, right)
|
||||||
|
- **Size**: Both `sm`
|
||||||
|
- **CONSISTENT** with the pattern (Cancel left, Submit right)
|
||||||
|
|
||||||
|
### 2.3 OIDC Config Page (Toolbar)
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/Admin/OidcConfigPage.tsx` (lines 130-137)
|
||||||
|
- **Position**: Top toolbar
|
||||||
|
- **Order**: Test Connection (secondary, left) | Save (primary, right)
|
||||||
|
- **No Cancel button** -- form is always editable
|
||||||
|
|
||||||
|
**NOTE**: This is the only admin page without a Cancel button or Edit mode toggle.
|
||||||
|
|
||||||
|
### 2.4 App Detail Header
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 560-566)
|
||||||
|
- **Position**: Top-right header area in `detailActions`
|
||||||
|
- **Order**: Upload JAR (primary) | Delete App (danger)
|
||||||
|
|
||||||
|
**NOTE**: The primary action (Upload) is on the LEFT and the destructive action (Delete) is on the RIGHT.
|
||||||
|
|
||||||
|
### 2.5 App Config Detail Page (AppConfigDetailPage)
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/Admin/AppConfigDetailPage.tsx` (lines 308-319)
|
||||||
|
- **Position**: Top toolbar
|
||||||
|
- **Read mode**: Back (ghost) ... Edit (secondary)
|
||||||
|
- **Edit mode**: Back (ghost) ... Save (default/no variant specified!) | Cancel (secondary)
|
||||||
|
- **Order when editing**: Save (left) | Cancel (right)
|
||||||
|
|
||||||
|
**INCONSISTENCY #1**: Save button has NO `variant` prop set -- it renders as default, not `primary`. Every other Save button uses `variant="primary"`.
|
||||||
|
|
||||||
|
**INCONSISTENCY #2**: Button order is REVERSED from every other form. Here it is Save (left) | Cancel (right). Everywhere else it is Cancel (left) | Save (right).
|
||||||
|
|
||||||
|
### 2.6 App Config Sub-Tab (AppsTab ConfigSubTab)
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 922-936)
|
||||||
|
- **Position**: Top banner bar (editBanner)
|
||||||
|
- **Read mode**: Banner text + Edit (secondary)
|
||||||
|
- **Edit mode**: Banner text + Cancel (ghost) | Save Configuration (primary)
|
||||||
|
- **Order when editing**: Cancel (left) | Save (right) -- **CONSISTENT**
|
||||||
|
|
||||||
|
### 2.7 Environment Default Resources / JAR Retention Sections
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/Admin/EnvironmentsPage.tsx` (lines 437-446, 505-514)
|
||||||
|
- **Position**: Bottom of section, right-aligned (`justifyContent: 'flex-end'`)
|
||||||
|
- **Read mode**: Edit Defaults / Edit Policy (secondary)
|
||||||
|
- **Edit mode**: Cancel (ghost) | Save (primary) -- **CONSISTENT**
|
||||||
|
- **Size**: Both `sm`
|
||||||
|
|
||||||
|
### 2.8 User Password Reset
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 407-431)
|
||||||
|
- **Position**: Inline in Security section
|
||||||
|
- **Order**: Cancel (ghost) | Set (primary)
|
||||||
|
- **CONSISTENT** pattern (Cancel left, Submit right)
|
||||||
|
|
||||||
|
### 2.9 Tap Modal (TapConfigModal)
|
||||||
|
|
||||||
|
- **File**: `ui/src/components/TapConfigModal.tsx` (lines 249-257)
|
||||||
|
- **Position**: Modal footer
|
||||||
|
- **Order (edit mode)**: Delete (danger, left, in `footerLeft`) | Cancel (secondary) | Save (primary)
|
||||||
|
- **Order (create mode)**: Cancel (secondary) | Save (primary)
|
||||||
|
- **No `size` prop specified** -- renders at default size
|
||||||
|
|
||||||
|
**NOTE**: Uses `variant="secondary"` for Cancel, not `variant="ghost"` like create forms.
|
||||||
|
|
||||||
|
### 2.10 Tap Modal (RouteDetail inline version)
|
||||||
|
|
||||||
|
- **File**: `ui/src/pages/Routes/RouteDetail.tsx` (lines 984-986)
|
||||||
|
- **Position**: Modal footer (`tapModalFooter`)
|
||||||
|
- **Order**: Cancel (secondary) | Save (primary)
|
||||||
|
- **No `size` prop specified**
|
||||||
|
- **CONSISTENT** with TapConfigModal
|
||||||
|
|
||||||
|
### 2.11 About Me Dialog
|
||||||
|
|
||||||
|
- **File**: `ui/src/components/AboutMeDialog.tsx` (lines 14, 72)
|
||||||
|
- **Uses `Modal` with built-in close button** (no explicit action buttons)
|
||||||
|
- **Close via**: Modal `onClose` handler (X button and backdrop click)
|
||||||
|
|
||||||
|
### 2.12 Login Page
|
||||||
|
|
||||||
|
- **File**: `ui/src/auth/LoginPage.tsx` (lines 176-184)
|
||||||
|
- **Single button**: Sign in (primary, full width, submit type)
|
||||||
|
- **Optional SSO button above**: Sign in with SSO (secondary)
|
||||||
|
|
||||||
|
### Summary of Button Order Patterns
|
||||||
|
|
||||||
|
| Location | Cancel Side | Submit Side | Consistent? |
|
||||||
|
|----------|------------|-------------|-------------|
|
||||||
|
| User create form | Left (ghost) | Right (primary) | YES |
|
||||||
|
| Group create form | Left (ghost) | Right (primary) | YES |
|
||||||
|
| Role create form | Left (ghost) | Right (primary) | YES |
|
||||||
|
| Env create form | Left (ghost) | Right (primary) | YES |
|
||||||
|
| App create page | Left (ghost) | Right (primary) | YES |
|
||||||
|
| Env Default Resources edit | Left (ghost) | Right (primary) | YES |
|
||||||
|
| Env JAR Retention edit | Left (ghost) | Right (primary) | YES |
|
||||||
|
| AppsTab config sub-tab edit | Left (ghost) | Right (primary) | YES |
|
||||||
|
| User password reset | Left (ghost) | Right (primary) | YES |
|
||||||
|
| TapConfigModal | Left (secondary) | Right (primary) | Variant mismatch |
|
||||||
|
| RouteDetail tap modal | Left (secondary) | Right (primary) | Variant mismatch |
|
||||||
|
| **AppConfigDetailPage** | **Left (NO variant)** | **Right (secondary)** | **REVERSED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Edit / Save Patterns
|
||||||
|
|
||||||
|
### 3.1 Users (UsersTab)
|
||||||
|
- **Edit mode**: No explicit toggle. Display name uses `InlineEdit` (click-to-edit). Everything else is managed via tag add/remove.
|
||||||
|
- **No Save/Cancel for the detail view** -- all changes are immediate mutations.
|
||||||
|
- **Unsaved changes indicator**: N/A (no batched editing)
|
||||||
|
- **On success**: Toast with `variant: 'success'`
|
||||||
|
- **On error**: Toast with `variant: 'error'`, `duration: 86_400_000` (effectively permanent)
|
||||||
|
|
||||||
|
### 3.2 Groups (GroupsTab)
|
||||||
|
- **Edit mode**: Name uses `InlineEdit`. All other changes (members, roles) are immediate mutations.
|
||||||
|
- **Pattern**: Same as Users -- no batched edit mode.
|
||||||
|
|
||||||
|
### 3.3 Roles (RolesTab)
|
||||||
|
- **Edit mode**: Read-only detail panel. No editing of role fields.
|
||||||
|
- **Only action**: Delete
|
||||||
|
|
||||||
|
### 3.4 Environments (EnvironmentsPage)
|
||||||
|
- **Edit mode (name)**: `InlineEdit`
|
||||||
|
- **Edit mode (production/enabled toggles)**: Immediate mutations per toggle change
|
||||||
|
- **Edit mode (Default Resources)**: Explicit Edit toggle (`setEditing(true)`)
|
||||||
|
- Cancel/Save buttons appear at bottom-right
|
||||||
|
- Resets form on cancel
|
||||||
|
- No unsaved changes indicator
|
||||||
|
- On success: Toast `variant: 'success'`
|
||||||
|
- **Edit mode (JAR Retention)**: Same pattern as Default Resources
|
||||||
|
- **On environment switch**: Both sub-sections auto-reset to read mode
|
||||||
|
|
||||||
|
### 3.5 OIDC Config (OidcConfigPage)
|
||||||
|
- **Edit mode**: ALWAYS editable (no toggle)
|
||||||
|
- **Save button**: Always visible in top toolbar
|
||||||
|
- **No Cancel button** -- cannot discard changes
|
||||||
|
- **No unsaved changes indicator**
|
||||||
|
- **On success**: Toast `variant: 'success'`
|
||||||
|
- **On error**: Toast `variant: 'error'` + inline `<Alert variant="error">` both shown
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Only page that is always editable with no way to discard changes. Also the only page that shows BOTH a toast AND an inline alert on error.
|
||||||
|
|
||||||
|
### 3.6 App Config Detail (AppConfigDetailPage)
|
||||||
|
- **Edit mode**: Explicit toggle via `Edit` button (Pencil icon) in toolbar
|
||||||
|
- **Toolbar in edit mode**: Save (unstyled!) | Cancel (secondary)
|
||||||
|
- **Save button text**: Shows "Saving..." while pending
|
||||||
|
- **No unsaved changes indicator**
|
||||||
|
- **On success**: Toast `variant: 'success'`, exits edit mode
|
||||||
|
- **On error**: Toast `variant: 'error'`, stays in edit mode
|
||||||
|
|
||||||
|
### 3.7 App Config Sub-Tab (AppsTab ConfigSubTab)
|
||||||
|
- **Edit mode**: Explicit toggle via banner + Edit button
|
||||||
|
- **Banner in read mode**: "Configuration is read-only. Enter edit mode to make changes."
|
||||||
|
- **Banner in edit mode**: "Editing configuration. Changes are not saved until you click Save." (styled differently with `editBannerActive`)
|
||||||
|
- **This IS an unsaved changes indicator** (the banner text changes)
|
||||||
|
- **Cancel/Save in edit banner**: Cancel (ghost) | Save Configuration (primary)
|
||||||
|
- **On success**: Toast `variant: 'success'`, exits edit mode, shows redeploy notice
|
||||||
|
- **On error**: Toast `variant: 'error'`, stays in edit mode
|
||||||
|
|
||||||
|
### 3.8 App Create Page
|
||||||
|
- **Edit mode**: N/A (always a creation form)
|
||||||
|
- **Multi-step indicator**: Shows step text like "Creating app...", "Uploading JAR..." during submission
|
||||||
|
- **On success**: Toast `variant: 'success'`, navigates to app detail page
|
||||||
|
- **On error**: Toast `variant: 'error'` with step context
|
||||||
|
|
||||||
|
### 3.9 Tap Editing (TapConfigModal + RouteDetail inline)
|
||||||
|
- **Edit mode**: Modal opens for edit or create
|
||||||
|
- **Save/Cancel**: In modal footer
|
||||||
|
- **On success**: Modal closes, parent handles toast
|
||||||
|
- **On error**: Parent handles toast
|
||||||
|
|
||||||
|
### Summary of Edit Patterns
|
||||||
|
|
||||||
|
| Page | Explicit Edit Toggle? | Unsaved Changes Indicator? | Consistent? |
|
||||||
|
|------|----------------------|---------------------------|-------------|
|
||||||
|
| Users | No (inline edits) | N/A | N/A |
|
||||||
|
| Groups | No (inline edits) | N/A | N/A |
|
||||||
|
| Roles | No (read-only) | N/A | N/A |
|
||||||
|
| Environments - name | No (InlineEdit) | N/A | OK |
|
||||||
|
| Environments - resources | YES | No | Missing |
|
||||||
|
| Environments - JAR retention | YES | No | Missing |
|
||||||
|
| OIDC Config | No (always editable) | No | Deviation |
|
||||||
|
| AppConfigDetailPage | YES | No | Missing |
|
||||||
|
| AppsTab ConfigSubTab | YES (banner) | YES (banner text) | Best pattern |
|
||||||
|
|
||||||
|
**INCONSISTENCY**: The AppsTab ConfigSubTab is the only one with a proper unsaved-changes indicator. AppConfigDetailPage (which edits the same data for a different entry point) has no such indicator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Toast / Notification Patterns
|
||||||
|
|
||||||
|
### 4.1 Toast Provider
|
||||||
|
- **File**: `ui/src/components/LayoutShell.tsx` (line 783)
|
||||||
|
- **Provider**: `<ToastProvider>` from `@cameleer/design-system` wraps the entire app layout
|
||||||
|
- **Hook**: `useToast()` returns `{ toast }` function
|
||||||
|
|
||||||
|
### 4.2 Toast Call Signature
|
||||||
|
All toast calls use the same shape:
|
||||||
|
```typescript
|
||||||
|
toast({
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
variant: 'success' | 'error' | 'warning',
|
||||||
|
duration?: number
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Toast Variants Used
|
||||||
|
|
||||||
|
| Variant | Used For | Duration |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| `success` | Successful operations | Default (auto-dismiss) |
|
||||||
|
| `error` | Failed operations | `86_400_000` (24 hours = effectively permanent) |
|
||||||
|
| `warning` | Destructive successes (delete, stop) AND partial failures | Mixed (see below) |
|
||||||
|
|
||||||
|
### 4.4 Duration Patterns
|
||||||
|
|
||||||
|
- **Success toasts**: No explicit duration (uses design system default) -- **CONSISTENT**
|
||||||
|
- **Error toasts**: Always `duration: 86_400_000` -- **CONSISTENT** (49 occurrences across 10 files)
|
||||||
|
- **Warning toasts for deletion success** (user/group/role/env/OIDC/app deleted): No explicit duration (auto-dismiss) -- **CONSISTENT**
|
||||||
|
- **Warning toasts for partial push failures**: `duration: 86_400_000` -- **CONSISTENT**
|
||||||
|
|
||||||
|
### 4.5 Naming Conventions for Toast Titles
|
||||||
|
|
||||||
|
**Success pattern**: Action-noun format
|
||||||
|
- "User created", "Group created", "Role created", "Environment created"
|
||||||
|
- "Display name updated", "Password updated", "Group renamed"
|
||||||
|
- "Config saved", "Configuration saved", "Tap configuration saved"
|
||||||
|
|
||||||
|
**Error pattern**: "Failed to [action]" format
|
||||||
|
- "Failed to create user", "Failed to delete group", "Failed to update password"
|
||||||
|
- "Save failed", "Upload failed", "Deploy failed" (shorter form)
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Error messages mix two patterns:
|
||||||
|
1. "Failed to [verb] [noun]" (e.g., "Failed to create user") -- used in RBAC pages
|
||||||
|
2. "[Noun] failed" (e.g., "Save failed", "Upload failed") -- used in AppsTab, AppConfigDetailPage
|
||||||
|
|
||||||
|
### 4.6 Warning Variant for Deletions
|
||||||
|
|
||||||
|
Successful deletions use `variant: 'warning'` consistently:
|
||||||
|
- "User deleted" (UsersTab:162)
|
||||||
|
- "Group deleted" (GroupsTab:147)
|
||||||
|
- "Role deleted" (RolesTab:100)
|
||||||
|
- "Environment deleted" (EnvironmentsPage:105)
|
||||||
|
- "Configuration deleted" (OidcConfigPage:119)
|
||||||
|
- "App deleted" (AppsTab:536)
|
||||||
|
- "Deployment stopped" (AppsTab:529)
|
||||||
|
|
||||||
|
**CONSISTENT** -- all destructive-but-successful operations use warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Loading / Empty States
|
||||||
|
|
||||||
|
### 5.1 Full-Page Loading States
|
||||||
|
|
||||||
|
| Page | Component | Size | Wrapper |
|
||||||
|
|------|-----------|------|---------|
|
||||||
|
| UsersTab | `<Spinner size="md" />` | md | Bare return |
|
||||||
|
| GroupsTab | `<Spinner size="md" />` | md | Bare return |
|
||||||
|
| RolesTab | `<Spinner size="md" />` | md | Bare return |
|
||||||
|
| EnvironmentsPage | `<Spinner size="md" />` | md | Bare return |
|
||||||
|
| AppListView | `<Spinner size="md" />` | md | Bare return |
|
||||||
|
| AppDetailView | `<Spinner size="md" />` | md | Bare return |
|
||||||
|
| AgentInstance | `<Spinner size="lg" />` | **lg** | Bare return |
|
||||||
|
| AppConfigDetailPage | `<Spinner size="lg" />` | **lg** | Wrapped in `div.loading` |
|
||||||
|
| DashboardPage | `<PageLoader />` | lg | Centered container |
|
||||||
|
| RuntimePage | `<PageLoader />` | lg | Centered container |
|
||||||
|
| OidcConfigPage | `return null` | N/A | Returns nothing |
|
||||||
|
|
||||||
|
**INCONSISTENCY #1**: Most admin pages use `<Spinner size="md" />` as a bare return. AgentInstance and AppConfigDetailPage use `size="lg"`. DashboardPage and RuntimePage use the `<PageLoader />` component which wraps `<Spinner size="lg" />` in a centered container.
|
||||||
|
|
||||||
|
**INCONSISTENCY #2**: OidcConfigPage returns `null` while loading (shows a blank page), unlike every other page.
|
||||||
|
|
||||||
|
**INCONSISTENCY #3**: SplitPane detail loading (GroupsTab line 317, RolesTab line 212) uses `<Spinner size="md" />` -- consistent within that context.
|
||||||
|
|
||||||
|
### 5.2 Section Loading States
|
||||||
|
|
||||||
|
- **RouteDetail charts**: `<Spinner size="sm" />` inline in chart containers (lines 713, 804)
|
||||||
|
- **AboutMeDialog**: `<Spinner size="md" />` in a `div.loading` wrapper
|
||||||
|
|
||||||
|
### 5.3 Empty States
|
||||||
|
|
||||||
|
| Context | Pattern | Component Used |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| SplitPane list (no search match) | `emptyMessage="No X match your search"` | EntityList built-in |
|
||||||
|
| SplitPane detail (nothing selected) | `emptyMessage="Select a X to view details"` | SplitPane built-in |
|
||||||
|
| Deployments table (none) | `<p className={styles.emptyNote}>No deployments yet.</p>` | Plain `<p>` |
|
||||||
|
| Versions list (none) | `<p className={styles.emptyNote}>No versions uploaded yet.</p>` | Plain `<p>` |
|
||||||
|
| Env vars (none, not editing) | `<p className={styles.emptyNote}>No environment variables configured.</p>` | Plain `<p>` |
|
||||||
|
| Traces/Taps (none) | `<p className={styles.emptyNote}>No processor traces or taps configured.</p>` | Plain `<p>` |
|
||||||
|
| Route recording (none) | `<p className={styles.emptyNote}>No routes found for this application.</p>` | Plain `<p>` |
|
||||||
|
| AgentInstance metrics | `<EmptyState title="No data" description="No X available" />` | EmptyState (DS component) |
|
||||||
|
| Log/Event panels | `<div className={logStyles.logEmpty}>No events...</div>` | Styled `<div>` |
|
||||||
|
| OIDC default roles | `<span className={styles.noRoles}>No default roles configured</span>` | `<span>` |
|
||||||
|
| Group members (none) | `<span className={styles.inheritedNote}>(no members)</span>` | `<span>` |
|
||||||
|
| AppConfigDetailPage (not found) | `<div>No configuration found for "{appId}".</div>` | Plain `<div>` |
|
||||||
|
| RouteDetail error patterns | `<div className={styles.emptyText}>No error patterns found...</div>` | Styled `<div>` |
|
||||||
|
| RouteDetail taps (none) | `<div className={styles.emptyState}>No taps configured...</div>` | Styled `<div>` |
|
||||||
|
|
||||||
|
**INCONSISTENCY**: Empty states use at least 5 different approaches:
|
||||||
|
1. Design system `EmptyState` component (only in AgentInstance)
|
||||||
|
2. `<p className={styles.emptyNote}>` (AppsTab)
|
||||||
|
3. `<span className={styles.inheritedNote}>` with parenthetical format "(none)" (RBAC pages)
|
||||||
|
4. `<div className={styles.emptyText}>` (RouteDetail)
|
||||||
|
5. Unstyled inline text (AppConfigDetailPage)
|
||||||
|
|
||||||
|
The design system provides an `EmptyState` component but it is only used in one place (AgentInstance).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Inconsistency Summary
|
||||||
|
|
||||||
|
### HIGH Priority (User-facing confusion)
|
||||||
|
|
||||||
|
1. **AppConfigDetailPage button order is reversed** (Save|Cancel instead of Cancel|Save) and Save button has no `variant="primary"`. File: `ui/src/pages/Admin/AppConfigDetailPage.tsx`, lines 311-315.
|
||||||
|
|
||||||
|
2. **Deployment Stop has no confirmation dialog**. Stopping a running deployment immediately executes with no confirmation, while stopping/suspending a route shows a ConfirmDialog. File: `ui/src/pages/AppsTab/AppsTab.tsx`, line 672.
|
||||||
|
|
||||||
|
3. **Tap deletion is inconsistent**. Deleting from TapConfigModal: no confirmation. Deleting from RouteDetail table: ConfirmDialog. File: `ui/src/components/TapConfigModal.tsx` line 117 vs `ui/src/pages/Routes/RouteDetail.tsx` line 992.
|
||||||
|
|
||||||
|
4. **Kill Query has no confirmation and no feedback**. File: `ui/src/pages/Admin/DatabaseAdminPage.tsx`, line 30.
|
||||||
|
|
||||||
|
### MEDIUM Priority (Pattern deviations)
|
||||||
|
|
||||||
|
5. **Cancel button variant inconsistency**. Create forms use `variant="ghost"` for Cancel. Modal dialogs (TapConfigModal, RouteDetail tap modal) use `variant="secondary"`. File: `ui/src/components/TapConfigModal.tsx` line 255, vs `ui/src/pages/Admin/UsersTab.tsx` line 258.
|
||||||
|
|
||||||
|
6. **Removing a role from a user has no confirmation** but removing a group from a user shows an AlertDialog. Both can cascade. File: `ui/src/pages/Admin/UsersTab.tsx`, lines 504-528 vs 588-613.
|
||||||
|
|
||||||
|
7. **OIDC Config is always editable with no Cancel/discard**. Every other editable form either has inline-edit (immediate save) or explicit edit mode with Cancel. File: `ui/src/pages/Admin/OidcConfigPage.tsx`.
|
||||||
|
|
||||||
|
8. **OIDC Config delete ConfirmDialog missing `loading` prop**. All other delete ConfirmDialogs pass `loading={mutation.isPending}`. File: `ui/src/pages/Admin/OidcConfigPage.tsx`, line 258.
|
||||||
|
|
||||||
|
9. **Loading state size inconsistency**. Most pages use `Spinner size="md"`, some use `size="lg"`, some use `PageLoader`, and OidcConfigPage returns `null`. No single standard.
|
||||||
|
|
||||||
|
10. **Error toast title format inconsistency**. RBAC pages use "Failed to [verb] [noun]" while AppsTab/AppConfigDetailPage use "[Noun] failed". Should pick one.
|
||||||
|
|
||||||
|
### LOW Priority (Minor deviations)
|
||||||
|
|
||||||
|
11. **Empty state presentation varies widely**. Five different approaches used. Should standardize on the design system `EmptyState` component or at least a consistent CSS class.
|
||||||
|
|
||||||
|
12. **ConfirmDialog confirmText varies between display name and slug**. Users/Groups/Roles use display name; Environments and Apps use slug. This is arguably intentional (slug is the technical identifier) but may confuse users.
|
||||||
|
|
||||||
|
13. **OIDC Config shows both toast and inline Alert on error**. No other page shows both simultaneously. File: `ui/src/pages/Admin/OidcConfigPage.tsx`, line 92 (toast) + line 139 (inline Alert).
|
||||||
|
|
||||||
|
14. **AppConfigDetailPage Save button text changes to "Saving..."** using string interpolation, while every other page uses the `loading` prop on Button (which shows a spinner). File: `ui/src/pages/Admin/AppConfigDetailPage.tsx`, line 313.
|
||||||
|
|
||||||
|
15. **Unsaved changes indicator** only present on AppsTab ConfigSubTab (banner text). AppConfigDetailPage, Environment resource sections, and JAR retention section have no indicator even though they use explicit edit mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ConfirmDialog Usage Matrix
|
||||||
|
|
||||||
|
| Object | File | Line | confirmText Source | Has `loading`? | Has `variant`? | Has `confirmLabel`? |
|
||||||
|
|--------|------|------|-------------------|----------------|----------------|---------------------|
|
||||||
|
| User | UsersTab.tsx | 580 | displayName | YES | No (default) | No (default) |
|
||||||
|
| Group | GroupsTab.tsx | 434 | name | YES | No (default) | No (default) |
|
||||||
|
| Role | RolesTab.tsx | 223 | name | YES | No (default) | No (default) |
|
||||||
|
| Environment | EnvironmentsPage.tsx | 319 | slug | YES | No (default) | No (default) |
|
||||||
|
| OIDC Config | OidcConfigPage.tsx | 258 | "delete oidc" | **NO** | No (default) | No (default) |
|
||||||
|
| App | AppsTab.tsx | 589 | slug | YES | No (default) | No (default) |
|
||||||
|
| Tap (RouteDetail) | RouteDetail.tsx | 992 | attributeName | **NO** | `danger` | `"Delete"` |
|
||||||
|
| Route Stop | RouteControlBar.tsx | 139 | action name | YES | `danger`/`warning` | `"Stop Route"` / `"Suspend Route"` |
|
||||||
|
|
||||||
|
**NOTE**: RouteControlBar and RouteDetail set explicit `variant` and `confirmLabel` on ConfirmDialog while all RBAC/admin pages use defaults. This creates visual differences in the confirmation dialogs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. AlertDialog Usage Matrix
|
||||||
|
|
||||||
|
| Context | File | Line | Title | Confirm Label | Variant |
|
||||||
|
|---------|------|------|-------|---------------|---------|
|
||||||
|
| Remove group from user | UsersTab.tsx | 588 | "Remove group membership" | "Remove" | `warning` |
|
||||||
|
| Remove role from group | GroupsTab.tsx | 442 | "Remove role from group" | "Remove" | `warning` |
|
||||||
|
|
||||||
|
AlertDialog is used consistently where present (both use `warning` variant and "Remove" label).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Files Examined
|
||||||
|
|
||||||
|
All `.tsx` files under `ui/src/pages/` and `ui/src/components/`:
|
||||||
|
|
||||||
|
- `ui/src/pages/Admin/UsersTab.tsx`
|
||||||
|
- `ui/src/pages/Admin/GroupsTab.tsx`
|
||||||
|
- `ui/src/pages/Admin/RolesTab.tsx`
|
||||||
|
- `ui/src/pages/Admin/EnvironmentsPage.tsx`
|
||||||
|
- `ui/src/pages/Admin/OidcConfigPage.tsx`
|
||||||
|
- `ui/src/pages/Admin/AppConfigDetailPage.tsx`
|
||||||
|
- `ui/src/pages/Admin/DatabaseAdminPage.tsx`
|
||||||
|
- `ui/src/pages/Admin/ClickHouseAdminPage.tsx`
|
||||||
|
- `ui/src/pages/Admin/AuditLogPage.tsx`
|
||||||
|
- `ui/src/pages/AppsTab/AppsTab.tsx`
|
||||||
|
- `ui/src/pages/Routes/RouteDetail.tsx`
|
||||||
|
- `ui/src/pages/Exchanges/ExchangesPage.tsx`
|
||||||
|
- `ui/src/pages/Exchanges/RouteControlBar.tsx`
|
||||||
|
- `ui/src/pages/AgentHealth/AgentHealth.tsx`
|
||||||
|
- `ui/src/pages/AgentInstance/AgentInstance.tsx`
|
||||||
|
- `ui/src/pages/DashboardTab/DashboardPage.tsx`
|
||||||
|
- `ui/src/pages/RuntimeTab/RuntimePage.tsx`
|
||||||
|
- `ui/src/components/TapConfigModal.tsx`
|
||||||
|
- `ui/src/components/AboutMeDialog.tsx`
|
||||||
|
- `ui/src/components/PageLoader.tsx`
|
||||||
|
- `ui/src/components/LayoutShell.tsx`
|
||||||
|
- `ui/src/auth/LoginPage.tsx`
|
||||||
267
audit/monitoring-pages-findings.md
Normal file
267
audit/monitoring-pages-findings.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# Cameleer3 Web UI - UX Audit Findings
|
||||||
|
|
||||||
|
**Date:** 2026-04-09
|
||||||
|
**URL:** https://desktop-fb5vgj9.siegeln.internal/server/
|
||||||
|
**Build:** 69dcce2
|
||||||
|
**Auditor:** Claude (automated browser audit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Exchange Detail (Split View)
|
||||||
|
|
||||||
|
**Screenshots:** `04-exchange-detail-ok.png`, `05-exchange-detail-err.png`, `27-exchange-err-error-tab.png`
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Split view layout (50/50) is clean and efficient -- table on left, detail on right
|
||||||
|
- Processor timeline visualization is excellent -- clear step sequence with color-coded status (green OK, red/amber error)
|
||||||
|
- Exchange detail tabs (Info, Headers, Input, Output, Error, Config, Timeline, Log) are comprehensive
|
||||||
|
- Error tab shows full Java stack trace with Copy button and exception message prominently displayed
|
||||||
|
- ERR rows in table have clear red status badge with icon
|
||||||
|
- Correlated exchanges section present (even when none found)
|
||||||
|
- JSON download button available on the detail view
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- **Exchange ID is raw hex, hard to scan.** The IDs like `96E395B0088AA6D-000000000001ED46` are 33+ characters wide. They push the table columns apart and are hard for humans to parse. Consider truncating with copy-on-click or showing a short hash.
|
||||||
|
- **Attributes column always shows "--".** Every single exchange row displays "--" in the Attributes column. If no attributes are captured, this column wastes horizontal space. Consider hiding it when empty or showing it only when relevant data exists.
|
||||||
|
- **Status shows "OK" but detail shows "COMPLETED".** The table status column shows "OK" / "ERR" but the detail panel shows "COMPLETED" / "FAILED". This terminology mismatch is confusing -- pick one convention.
|
||||||
|
|
||||||
|
**Nice-to-have:**
|
||||||
|
- **No breadcrumb update when exchange selected.** The breadcrumb still shows "All Applications" even when viewing a specific exchange detail. Should show: All Applications > sample-app > Exchange 96E39...
|
||||||
|
- **No action buttons on exchange detail.** No "Replay", "Trace", or "View Route" buttons in the detail view. Users would benefit from contextual actions.
|
||||||
|
- **Back navigation relies on de-selecting the row.** There is no explicit "Close" or "Back" button on the detail panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard Tab
|
||||||
|
|
||||||
|
**Screenshots:** `07-dashboard-full.png`, `08-dashboard-drilldown.png`
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- KPI strip is clean and scannable: Throughput (7/s), Success Rate (98.0%), P99 Latency (6695ms), SLA Compliance (38.0%), Active Errors (3)
|
||||||
|
- L1 (applications) -> L2 (routes) drill-down works via table row click
|
||||||
|
- L2 view shows comprehensive route performance table with throughput, success %, avg/P99, SLA %, sparkline
|
||||||
|
- Top Errors table with error velocity and "last seen" is very useful
|
||||||
|
- Charts: Throughput by Application, Error Rate, Volume vs SLA Compliance, 7-Day Pattern heatmap
|
||||||
|
- Color coding is consistent (amber for primary metrics, red for errors)
|
||||||
|
- Auto-refresh indicator shows "Auto-refresh: 30s"
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- **Application Health table row click is blocked by overlapping elements.** Playwright detected `_tableSection` and `_chartGrid` divs intercepting pointer events on the table row. While JavaScript `.click()` works, this means CSS `pointer-events` or `z-index` is wrong -- real mouse clicks may be unreliable depending on scroll position.
|
||||||
|
- **SLA Compliance 0.0% shows "BREACH" label** in L2 view but no explanation of what the SLA threshold is until you look closely at the latency chart. The SLA threshold (300ms) should be shown next to the KPI, not just in the chart.
|
||||||
|
- **7-Day Pattern heatmap is flat/empty.** The heatmap shows data only for the current day, making it look broken for a fresh deployment. Consider showing "Insufficient data" when less than 2 days of data exist.
|
||||||
|
- **"Application Volume vs SLA Compliance" bubble chart** truncates long application names (e.g., "complex-fulfil..." in L2). The chart has limited space for labels.
|
||||||
|
|
||||||
|
**Nice-to-have:**
|
||||||
|
- **No trend arrows on KPI values in L2.** The L1 dashboard shows up/down arrows (all "up"), but L2 KPIs show percentage change text instead. The two levels should be consistent.
|
||||||
|
- **P99 latency 6695ms is not formatted as seconds.** Values over 1000ms should display as "6.7s" for readability. The L2 view uses raw milliseconds (1345ms) which is also inconsistent with the L1 (6695ms) and the exchange list which does format durations.
|
||||||
|
- **Throughput numbers use locale-specific formatting.** In the route table: `1.050` (German locale?) vs `14.377` -- these look like decimal numbers rather than thousands. Consider using explicit thousands separator or always using K suffix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Runtime Tab
|
||||||
|
|
||||||
|
**Screenshots:** `09-runtime-tab.png`, `09-runtime-full.png`, `10-runtime-agent-detail.png`, `24-runtime-agent-detail-full.png`
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- KPI strip: Total Agents (3), Applications (1), Active Routes (30/0), Total TPS (4.8), Dead (0) -- clear at a glance
|
||||||
|
- Agent state indicators are clear: green "LIVE" badges, "3/3 LIVE" summary
|
||||||
|
- Instance table shows key metrics: State, Uptime, TPS, Errors, Heartbeat
|
||||||
|
- Clicking an agent row navigates to a rich detail view with 6 charts (CPU, Memory, Throughput, Error Rate, Thread Count, GC Pauses)
|
||||||
|
- Agent capabilities displayed as badges (LOGFORWARDING, DIAGRAMS, TRACING, METRICS)
|
||||||
|
- Application Log viewer with level filtering (Error/Warn/Info/Debug/Trace) and auto-scroll
|
||||||
|
- Timeline shows agent events (CONFIG_APPLIED, COMMAND_SUCCESS) with relative timestamps
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
- **GC Pauses chart X-axis is unreadable.** The chart renders ~60 full ISO-8601 timestamps (`2026-04-09T14:16:00Z` through `2026-04-09T15:15:00Z`) as X-axis labels. These overlap completely and form an unreadable block of text. All other charts use concise numeric labels (e.g., "12", "24"). The GC Pauses chart should use the same time formatting.
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- **Agent state shows "UNKNOWN" alongside "LIVE".** The detail view shows both "LIVE" and "UNKNOWN" state indicators. The "UNKNOWN" appears to be a secondary state field (perhaps container state?) but it is confusing to show two conflicting states without explanation.
|
||||||
|
- **Memory chart shows absolute MB values but no percentage on Y-axis.** The KPI shows "46% / 57 MB / 124 MB" which is great, but the chart Y-axis goes from 0-68 MB which doesn't match the 124 MB limit. The max heap should be indicated on the chart (e.g., as a reference line).
|
||||||
|
- **Throughput chart Y-axis scale is wildly mismatched.** The KPI shows 2.0 msg/s but the Y-axis goes to 1.2k msg/s, making the actual data appear as a flat line near zero. The Y-axis should auto-scale to the actual data range.
|
||||||
|
- **Error Rate chart Y-axis shows "err/h"** but the unit inconsistency with the KPI (which shows percentage "1.7%") is confusing.
|
||||||
|
|
||||||
|
**Nice-to-have:**
|
||||||
|
- **"DEAD 0" KPI in the overview is redundant** when "all healthy" text is already shown below it. Consider combining or removing the redundant label.
|
||||||
|
- **Application Log shows "0 entries"** in the overview but "100 entries" in the agent detail. The overview log may not aggregate across agents, which is misleading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Deployments Tab
|
||||||
|
|
||||||
|
**Screenshots:** `12-deployments-list.png`, `25-app-detail.png`, `11-deployments-tab.png`
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- App list is clean: Name, Environment (with colored badges DEFAULT/DEVELOPMENT), Updated, Created columns
|
||||||
|
- App detail page shows configuration tabs: Monitoring, Resources, Variables, Traces & Taps, Route Recording
|
||||||
|
- Read-only mode with explicit "Edit" button prevents accidental changes
|
||||||
|
- "Upload JAR" and "Delete App" action buttons are visible
|
||||||
|
- Create Application form (`/apps/new`) is comprehensive with Identity & Artifact section, deploy toggle, and monitoring sub-tabs
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- **Navigating to `/server/apps` redirected to `/server/apps/new`** on the initial visit, bypassing the apps list. This happened once but not consistently. The default route for the Deployments tab should always be the list view, not the create form.
|
||||||
|
- **No deployment status/progress visible in the list.** The apps list shows "RUNNING" status only in the detail view. The list should show the deployment status directly (RUNNING/STOPPED/FAILED badge per row).
|
||||||
|
- **"Updated: 59m ago" is relative time** which becomes stale if the page is left open. Consider showing absolute timestamp on hover.
|
||||||
|
|
||||||
|
**Nice-to-have:**
|
||||||
|
- **Configuration form select dropdowns** (Engine Level, Payload Capture, App Log Level, etc.) all use native HTML selects with a custom `"triangle"` indicator -- this is inconsistent with the design system's `Select` component used elsewhere.
|
||||||
|
- **"External URL" field shows `/default/.../`** placeholder which is cryptic. Should show the full resolved URL or explain the pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Command Palette (Ctrl+K)
|
||||||
|
|
||||||
|
**Screenshots:** `14-command-palette.png`, `15-command-palette-search.png`, `16-command-palette-keyboard.png`
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Opens instantly with Ctrl+K
|
||||||
|
- Shows categorized results: All (24), Applications (1), Exchanges (10), Routes (10), Agents (3)
|
||||||
|
- Search is fast and filters results in real-time (typed "error" -> filtered to 11 results)
|
||||||
|
- Search term highlighting (yellow background on matched text)
|
||||||
|
- Keyboard navigation works (ArrowDown moves selection)
|
||||||
|
- Rich result items: exchange IDs with status, routes with app name and exchange count, applications with agent count
|
||||||
|
- Escape closes the palette
|
||||||
|
- Category tabs allow filtering by type
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
**Nice-to-have:**
|
||||||
|
- **Exchange IDs in search results are full hex strings.** The same issue as the exchanges table -- `5EF55FC31352A9A-000000000001F07C` is hard to scan. Show a shorter preview.
|
||||||
|
- **No keyboard shortcut hints in results.** Results don't show "Enter to open" or "Tab to switch category" -- users must discover these by trial.
|
||||||
|
- **Category counts don't update when filtering.** When I typed "error", the category tabs still show the original counts (Applications, Exchanges 10, Routes 1, Agents) but some categories become empty. The empty categories should hide or dim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dark Mode
|
||||||
|
|
||||||
|
**Screenshots:** `17-dark-mode-exchanges.png`, `18-dark-mode-dashboard.png`, `19-dark-mode-runtime.png`
|
||||||
|
|
||||||
|
### What Works Well
|
||||||
|
- Dark mode applies cleanly across all pages
|
||||||
|
- Table rows have good contrast (light text on dark background)
|
||||||
|
- Status badges (OK green, ERR red) remain clearly visible
|
||||||
|
- Chart lines and data points are visible against dark backgrounds
|
||||||
|
- KPI cards have distinct dark card backgrounds with readable text
|
||||||
|
- The dark mode toggle is easy to find (moon icon in header)
|
||||||
|
- Theme preference persists in localStorage (`cameleer-theme`)
|
||||||
|
|
||||||
|
### Issues Found
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- **Chart backgrounds appear as opaque dark cards but chart lines may be harder to see.** The throughput and error rate charts use amber/orange lines on dark gray backgrounds -- this is acceptable but not ideal. Consider slightly brighter chart colors in dark mode.
|
||||||
|
- **Application Volume vs SLA chart** in dashboard: the bubble/bar labels may have low contrast in dark mode (hard to verify at screenshot resolution).
|
||||||
|
|
||||||
|
**Nice-to-have:**
|
||||||
|
- **Sidebar border/separator** between the sidebar and main content area is very subtle in dark mode. A slightly more visible divider would help.
|
||||||
|
- **Environment badges** (DEFAULT in gold, DEVELOPMENT in orange) are designed for light mode and may look less distinct against the dark background.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Cross-Cutting Interaction Issues
|
||||||
|
|
||||||
|
### Status Filter Buttons (OK/Warn/Error/Running)
|
||||||
|
|
||||||
|
**Screenshots:** `03-exchanges-error-filtered.png`
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- **Error filter works correctly** -- clicking the Error button filters to show only ERR exchanges (447 in the test). The button shows active/pressed state.
|
||||||
|
- **Filter state is not preserved in URL.** Navigating away and back loses the filter. Consider encoding active filters in the URL query string.
|
||||||
|
- **KPI strip does not update when filter is active.** When Error filter is active, the KPI strip still shows overall stats (Total 23.4K, Err% 1.9%). It should either update to show filtered stats or clearly indicate it shows overall stats.
|
||||||
|
|
||||||
|
### Column Sorting
|
||||||
|
|
||||||
|
**Screenshot:** `23-sorting-route.png`
|
||||||
|
|
||||||
|
- Sorting works correctly (Route column sorted alphabetically, "audit-log" rows grouped)
|
||||||
|
- Sort indicator arrow is visible on the column header
|
||||||
|
- **Sorting is client-side only (within the 50-row page).** With 23K+ exchanges, sorting only the visible page is misleading. Consider either fetching sorted data from the server or clearly labeling "sorted within current page."
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
- Pagination works: "1-25 of 50", page 1/2, rows per page selector (10/25/50/100)
|
||||||
|
- Next/Previous page buttons work
|
||||||
|
- **"50 of 23,485 exchanges" label is confusing.** The "50" refers to the server-side limit (max fetched), not the page size (25). This should read "Showing 1-25 of 23,485" or similar.
|
||||||
|
|
||||||
|
### Sidebar App Tree
|
||||||
|
|
||||||
|
**Screenshot:** `20-sidebar-expanded.png`
|
||||||
|
|
||||||
|
- Expand/collapse works for "sample app"
|
||||||
|
- Shows all 10 routes with exchange counts (audit-log 5.3k, file-processing 114.2k, etc.)
|
||||||
|
- Exchange counts use K-suffix formatting which is good
|
||||||
|
- **Add to starred button is present** (star icon on the app)
|
||||||
|
|
||||||
|
### Environment Selector
|
||||||
|
|
||||||
|
- Dropdown works: All Envs / default / development
|
||||||
|
- Switching environment correctly filters data (65K -> 3.5K exchanges)
|
||||||
|
- Selection persists in localStorage
|
||||||
|
|
||||||
|
### Time Range Pills
|
||||||
|
|
||||||
|
**Screenshot:** `21-time-range-3h.png`
|
||||||
|
|
||||||
|
- Time range pills work (1h, 3h, 6h, Today, 24h, 7d)
|
||||||
|
- Switching updates data and KPI strip correctly
|
||||||
|
- Custom date range is shown: "9. Apr. 16:14 -- now" with clickable start/end timestamps
|
||||||
|
- **Date formatting uses European style** ("9. Apr. 16:14") which is fine but inconsistent with ISO timestamps elsewhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Systematic Navigation Bug
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
|
||||||
|
During the audit, the browser consistently auto-redirected from any page to `/server/admin/rbac` (Users & Roles) after interactions involving the Playwright accessibility snapshot tool. This happened:
|
||||||
|
- After taking snapshots of the exchanges page
|
||||||
|
- After clicking exchange detail rows
|
||||||
|
- After interacting with filter buttons
|
||||||
|
- After attempting to click table rows
|
||||||
|
|
||||||
|
The redirect does **not** happen when using only JavaScript-based interactions (`page.evaluate`) without the Playwright snapshot/click methods. The root cause appears to be that the Playwright MCP accessibility snapshot tool triggers focus/click events on sidebar items (specifically "Users & Roles"), causing unintended navigation.
|
||||||
|
|
||||||
|
**While this is likely a tool interaction artifact rather than a real user-facing bug**, it reveals that:
|
||||||
|
1. The sidebar tree items may have overly aggressive focus/activation behavior (activating on focus rather than explicit click)
|
||||||
|
2. There may be no route guard preventing unexpected navigation when the user hasn't explicitly clicked a sidebar item
|
||||||
|
|
||||||
|
Recommend investigating whether keyboard focus on sidebar tree items triggers navigation (it should require Enter/click, not just focus).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Issues by Severity
|
||||||
|
|
||||||
|
### Critical (1)
|
||||||
|
1. **GC Pauses chart X-axis renders ~60 full ISO timestamps** -- completely unreadable (Runtime > Agent Detail)
|
||||||
|
|
||||||
|
### Important (10)
|
||||||
|
1. **Exchange ID columns are too wide** -- 33-char hex strings push table layout (Exchanges)
|
||||||
|
2. **Attributes column always shows "--"** -- wastes space (Exchanges)
|
||||||
|
3. **Status terminology mismatch** -- "OK/ERR" in table vs "COMPLETED/FAILED" in detail (Exchange Detail)
|
||||||
|
4. **Dashboard table row clicks intercepted by overlapping divs** -- z-index/pointer-events issue (Dashboard)
|
||||||
|
5. **SLA threshold not shown on KPI** -- have to find it in the chart (Dashboard L2)
|
||||||
|
6. **Agent state shows "UNKNOWN" alongside "LIVE"** -- confusing dual state (Runtime Agent Detail)
|
||||||
|
7. **Throughput chart Y-axis scale mismatch** -- 2 msg/s data on 1.2k scale, appears flat (Runtime Agent Detail)
|
||||||
|
8. **Error Rate chart unit mismatch** -- "err/h" on chart vs "%" on KPI (Runtime Agent Detail)
|
||||||
|
9. **Filter state not preserved in URL** (Exchanges)
|
||||||
|
10. **"50 of 23,485 exchanges" pagination label is confusing** (Exchanges)
|
||||||
|
|
||||||
|
### Nice-to-have (12)
|
||||||
|
1. No breadcrumb update when exchange selected
|
||||||
|
2. No action buttons (Replay/Trace) on exchange detail
|
||||||
|
3. No explicit Close/Back button on detail panel
|
||||||
|
4. P99 latency not formatted as seconds when >1000ms
|
||||||
|
5. Throughput numbers use locale-specific decimal formatting
|
||||||
|
6. 7-Day Pattern heatmap appears empty with limited data
|
||||||
|
7. Exchange IDs in command palette are full hex strings
|
||||||
|
8. No keyboard shortcut hints in command palette results
|
||||||
|
9. Sidebar border subtle in dark mode
|
||||||
|
10. Deployment list doesn't show status badges
|
||||||
|
11. "Updated: 59m ago" relative time goes stale
|
||||||
|
12. Category counts in command palette don't update when filtering
|
||||||
@@ -48,14 +48,10 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.opensearch.client</groupId>
|
<groupId>com.clickhouse</groupId>
|
||||||
<artifactId>opensearch-java</artifactId>
|
<artifactId>clickhouse-jdbc</artifactId>
|
||||||
<version>2.19.0</version>
|
<version>0.9.7</version>
|
||||||
</dependency>
|
<classifier>all</classifier>
|
||||||
<dependency>
|
|
||||||
<groupId>org.opensearch.client</groupId>
|
|
||||||
<artifactId>opensearch-rest-client</artifactId>
|
|
||||||
<version>2.19.0</version>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
@@ -90,6 +86,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.nimbusds</groupId>
|
<groupId>com.nimbusds</groupId>
|
||||||
<artifactId>nimbus-jose-jwt</artifactId>
|
<artifactId>nimbus-jose-jwt</artifactId>
|
||||||
@@ -121,11 +121,20 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.opensearch</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>opensearch-testcontainers</artifactId>
|
<artifactId>testcontainers-clickhouse</artifactId>
|
||||||
<version>2.1.1</version>
|
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.docker-java</groupId>
|
||||||
|
<artifactId>docker-java-core</artifactId>
|
||||||
|
<version>3.4.1</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.docker-java</groupId>
|
||||||
|
<artifactId>docker-java-transport-zerodep</artifactId>
|
||||||
|
<version>3.4.1</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.awaitility</groupId>
|
<groupId>org.awaitility</groupId>
|
||||||
<artifactId>awaitility</artifactId>
|
<artifactId>awaitility</artifactId>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.cameleer3.server.app.config.IngestionConfig;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +17,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
"com.cameleer3.server.app",
|
"com.cameleer3.server.app",
|
||||||
"com.cameleer3.server.core"
|
"com.cameleer3.server.core"
|
||||||
})
|
})
|
||||||
|
@EnableAsync
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableConfigurationProperties({IngestionConfig.class, AgentRegistryConfig.class})
|
@EnableConfigurationProperties({IngestionConfig.class, AgentRegistryConfig.class})
|
||||||
public class Cameleer3ServerApplication {
|
public class Cameleer3ServerApplication {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class AgentLifecycleMonitor {
|
|||||||
// Snapshot states before lifecycle check
|
// Snapshot states before lifecycle check
|
||||||
Map<String, AgentState> statesBefore = new HashMap<>();
|
Map<String, AgentState> statesBefore = new HashMap<>();
|
||||||
for (AgentInfo agent : registryService.findAll()) {
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
statesBefore.put(agent.id(), agent.state());
|
statesBefore.put(agent.instanceId(), agent.state());
|
||||||
}
|
}
|
||||||
|
|
||||||
registryService.checkLifecycle();
|
registryService.checkLifecycle();
|
||||||
@@ -47,12 +47,12 @@ public class AgentLifecycleMonitor {
|
|||||||
|
|
||||||
// Detect transitions and record events
|
// Detect transitions and record events
|
||||||
for (AgentInfo agent : registryService.findAll()) {
|
for (AgentInfo agent : registryService.findAll()) {
|
||||||
AgentState before = statesBefore.get(agent.id());
|
AgentState before = statesBefore.get(agent.instanceId());
|
||||||
if (before != null && before != agent.state()) {
|
if (before != null && before != agent.state()) {
|
||||||
String eventType = mapTransitionEvent(before, agent.state());
|
String eventType = mapTransitionEvent(before, agent.state());
|
||||||
if (eventType != null) {
|
if (eventType != null) {
|
||||||
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
|
agentEventService.recordEvent(agent.instanceId(), agent.applicationId(), eventType,
|
||||||
agent.name() + " " + before + " -> " + agent.state());
|
agent.displayName() + " " + before + " -> " + agent.state());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cameleer3.server.app.analytics;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
|
||||||
|
public class UsageFlushScheduler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(UsageFlushScheduler.class);
|
||||||
|
|
||||||
|
private final ClickHouseUsageTracker tracker;
|
||||||
|
|
||||||
|
public UsageFlushScheduler(ClickHouseUsageTracker tracker) {
|
||||||
|
this.tracker = tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${cameleer.usage.flush-interval-ms:5000}")
|
||||||
|
public void flush() {
|
||||||
|
try {
|
||||||
|
tracker.flush();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Usage event flush failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.cameleer3.server.app.analytics;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.analytics.UsageEvent;
|
||||||
|
import com.cameleer3.server.core.analytics.UsageTracker;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks authenticated UI user requests for usage analytics.
|
||||||
|
* Skips agent requests, health checks, data ingestion, and static assets.
|
||||||
|
*/
|
||||||
|
public class UsageTrackingInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final String START_ATTR = "usage.startNanos";
|
||||||
|
|
||||||
|
// Patterns for normalizing dynamic path segments
|
||||||
|
private static final Pattern EXCHANGE_ID = Pattern.compile(
|
||||||
|
"/[A-F0-9]{15,}-[A-F0-9]{16}(?=/|$)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern UUID = Pattern.compile(
|
||||||
|
"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern HEX_HASH = Pattern.compile(
|
||||||
|
"/[0-9a-f]{32,64}(?=/|$)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern NUMERIC_ID = Pattern.compile(
|
||||||
|
"(?<=/)(\\d{2,})(?=/|$)");
|
||||||
|
// Agent instance IDs like "cameleer3-sample-598867949d-g7nt4-1"
|
||||||
|
private static final Pattern INSTANCE_ID = Pattern.compile(
|
||||||
|
"(?<=/agents/)[^/]+(?=/)", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
private final UsageTracker usageTracker;
|
||||||
|
|
||||||
|
public UsageTrackingInterceptor(UsageTracker usageTracker) {
|
||||||
|
this.usageTracker = usageTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
request.setAttribute(START_ATTR, System.nanoTime());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
String username = extractUsername();
|
||||||
|
if (username == null) return; // unauthenticated or agent request
|
||||||
|
|
||||||
|
Long startNanos = (Long) request.getAttribute(START_ATTR);
|
||||||
|
long durationMs = startNanos != null ? (System.nanoTime() - startNanos) / 1_000_000 : 0;
|
||||||
|
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String queryString = request.getQueryString();
|
||||||
|
|
||||||
|
usageTracker.track(new UsageEvent(
|
||||||
|
Instant.now(),
|
||||||
|
username,
|
||||||
|
request.getMethod(),
|
||||||
|
path,
|
||||||
|
normalizePath(path),
|
||||||
|
response.getStatus(),
|
||||||
|
durationMs,
|
||||||
|
queryString
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractUsername() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || auth.getName() == null) return null;
|
||||||
|
String name = auth.getName();
|
||||||
|
// Only track UI users (user:admin), not agents
|
||||||
|
if (!name.startsWith("user:")) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizePath(String path) {
|
||||||
|
String normalized = EXCHANGE_ID.matcher(path).replaceAll("/{id}");
|
||||||
|
normalized = UUID.matcher(normalized).replaceAll("/{id}");
|
||||||
|
normalized = HEX_HASH.matcher(normalized).replaceAll("/{hash}");
|
||||||
|
normalized = INSTANCE_ID.matcher(normalized).replaceAll("{id}");
|
||||||
|
normalized = NUMERIC_ID.matcher(normalized).replaceAll("{id}");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ package com.cameleer3.server.app.config;
|
|||||||
import com.cameleer3.server.core.agent.AgentEventRepository;
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
import com.cameleer3.server.core.agent.AgentEventService;
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.RouteStateRegistry;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
|
* Creates the {@link AgentRegistryService}, {@link AgentEventService},
|
||||||
|
* and {@link RouteStateRegistry} beans.
|
||||||
* <p>
|
* <p>
|
||||||
* Follows the established pattern: core module plain class, app module bean config.
|
* Follows the established pattern: core module plain class, app module bean config.
|
||||||
*/
|
*/
|
||||||
@@ -27,4 +29,9 @@ public class AgentRegistryBeanConfig {
|
|||||||
public AgentEventService agentEventService(AgentEventRepository repository) {
|
public AgentEventService agentEventService(AgentEventRepository repository) {
|
||||||
return new AgentEventService(repository);
|
return new AgentEventService(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RouteStateRegistry routeStateRegistry() {
|
||||||
|
return new RouteStateRegistry();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(ClickHouseProperties.class)
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public class ClickHouseConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit primary PG DataSource. Required because adding a second DataSource
|
||||||
|
* (ClickHouse) prevents Spring Boot auto-configuration from creating the default one.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public DataSource dataSource(DataSourceProperties properties) {
|
||||||
|
return properties.initializeDataSourceBuilder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public JdbcTemplate jdbcTemplate(@Qualifier("dataSource") DataSource dataSource) {
|
||||||
|
return new JdbcTemplate(dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "clickHouseDataSource")
|
||||||
|
public DataSource clickHouseDataSource(ClickHouseProperties props) {
|
||||||
|
HikariDataSource ds = new HikariDataSource();
|
||||||
|
ds.setJdbcUrl(props.getUrl());
|
||||||
|
ds.setUsername(props.getUsername());
|
||||||
|
ds.setPassword(props.getPassword());
|
||||||
|
ds.setMaximumPoolSize(props.getPoolSize());
|
||||||
|
ds.setMinimumIdle(5);
|
||||||
|
ds.setConnectionTimeout(5000);
|
||||||
|
ds.setPoolName("clickhouse-pool");
|
||||||
|
return ds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "clickHouseJdbcTemplate")
|
||||||
|
public JdbcTemplate clickHouseJdbcTemplate(
|
||||||
|
@Qualifier("clickHouseDataSource") DataSource ds) {
|
||||||
|
return new JdbcTemplate(ds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "clickhouse")
|
||||||
|
public class ClickHouseProperties {
|
||||||
|
|
||||||
|
private String url = "jdbc:clickhouse://localhost:8123/cameleer";
|
||||||
|
private String username = "default";
|
||||||
|
private String password = "";
|
||||||
|
private int poolSize = 50;
|
||||||
|
|
||||||
|
public String getUrl() { return url; }
|
||||||
|
public void setUrl(String url) { this.url = url; }
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
|
||||||
|
public int getPoolSize() { return poolSize; }
|
||||||
|
public void setPoolSize(int poolSize) { this.poolSize = poolSize; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public class ClickHouseSchemaInitializer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClickHouseSchemaInitializer.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate clickHouseJdbc;
|
||||||
|
|
||||||
|
public ClickHouseSchemaInitializer(
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
this.clickHouseJdbc = clickHouseJdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void initializeSchema() {
|
||||||
|
try {
|
||||||
|
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||||
|
Resource script = resolver.getResource("classpath:clickhouse/init.sql");
|
||||||
|
|
||||||
|
String sql = script.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
log.info("Executing ClickHouse schema: {}", script.getFilename());
|
||||||
|
for (String statement : sql.split(";")) {
|
||||||
|
String trimmed = statement.trim();
|
||||||
|
// Skip empty segments and comment-only segments
|
||||||
|
String withoutComments = trimmed.lines()
|
||||||
|
.filter(line -> !line.stripLeading().startsWith("--"))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty())
|
||||||
|
.reduce("", (a, b) -> a + b);
|
||||||
|
if (!withoutComments.isEmpty()) {
|
||||||
|
clickHouseJdbc.execute(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("ClickHouse schema initialization complete");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("ClickHouse schema initialization failed — server will continue but ClickHouse features may not work", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.ingestion.BufferedLogEntry;
|
||||||
|
import com.cameleer3.server.core.ingestion.ChunkAccumulator;
|
||||||
|
import com.cameleer3.server.core.ingestion.MergedExecution;
|
||||||
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||||
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@@ -19,4 +23,22 @@ public class IngestionBeanConfig {
|
|||||||
public WriteBuffer<MetricsSnapshot> metricsBuffer(IngestionConfig config) {
|
public WriteBuffer<MetricsSnapshot> metricsBuffer(IngestionConfig config) {
|
||||||
return new WriteBuffer<>(config.getBufferCapacity());
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public WriteBuffer<MergedExecution> executionBuffer(IngestionConfig config) {
|
||||||
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public WriteBuffer<ChunkAccumulator.ProcessorBatch> processorBatchBuffer(IngestionConfig config) {
|
||||||
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public WriteBuffer<BufferedLogEntry> logBuffer(IngestionConfig config) {
|
||||||
|
return new WriteBuffer<>(config.getBufferCapacity());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.license.LicenseGate;
|
||||||
|
import com.cameleer3.server.core.license.LicenseInfo;
|
||||||
|
import com.cameleer3.server.core.license.LicenseValidator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class LicenseBeanConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
|
||||||
|
|
||||||
|
@Value("${license.token:}")
|
||||||
|
private String licenseToken;
|
||||||
|
|
||||||
|
@Value("${license.file:}")
|
||||||
|
private String licenseFile;
|
||||||
|
|
||||||
|
@Value("${license.public-key:}")
|
||||||
|
private String licensePublicKey;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LicenseGate licenseGate() {
|
||||||
|
LicenseGate gate = new LicenseGate();
|
||||||
|
|
||||||
|
String token = resolveLicenseToken();
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
log.info("No license configured — running in open mode (all features enabled)");
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (licensePublicKey == null || licensePublicKey.isBlank()) {
|
||||||
|
log.warn("License token provided but no public key configured (CAMELEER_LICENSE_PUBLIC_KEY). Running in open mode.");
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
LicenseValidator validator = new LicenseValidator(licensePublicKey);
|
||||||
|
LicenseInfo info = validator.validate(token);
|
||||||
|
gate.load(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLicenseToken() {
|
||||||
|
if (licenseToken != null && !licenseToken.isBlank()) {
|
||||||
|
return licenseToken;
|
||||||
|
}
|
||||||
|
if (licenseFile != null && !licenseFile.isBlank()) {
|
||||||
|
try {
|
||||||
|
return Files.readString(Path.of(licenseFile)).trim();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.cameleer3.server.app.config;
|
|
||||||
|
|
||||||
import org.apache.http.HttpHost;
|
|
||||||
import org.opensearch.client.RestClient;
|
|
||||||
import org.opensearch.client.json.jackson.JacksonJsonpMapper;
|
|
||||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
|
||||||
import org.opensearch.client.transport.rest_client.RestClientTransport;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class OpenSearchConfig {
|
|
||||||
|
|
||||||
@Value("${opensearch.url:http://localhost:9200}")
|
|
||||||
private String opensearchUrl;
|
|
||||||
|
|
||||||
@Bean(destroyMethod = "close")
|
|
||||||
public RestClient opensearchRestClient() {
|
|
||||||
return RestClient.builder(HttpHost.create(opensearchUrl)).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public OpenSearchClient openSearchClient(RestClient restClient) {
|
|
||||||
var transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
|
|
||||||
return new OpenSearchClient(transport);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.storage.PostgresClaimMappingRepository;
|
||||||
|
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
||||||
|
import com.cameleer3.server.core.rbac.ClaimMappingService;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the {@link ClaimMappingRepository} and {@link ClaimMappingService} beans.
|
||||||
|
* <p>
|
||||||
|
* Follows the established pattern: core module plain class, app module bean config.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RbacBeanConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ClaimMappingRepository claimMappingRepository(JdbcTemplate jdbcTemplate) {
|
||||||
|
return new PostgresClaimMappingRepository(jdbcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ClaimMappingService claimMappingService() {
|
||||||
|
return new ClaimMappingService();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.storage.PostgresAppRepository;
|
||||||
|
import com.cameleer3.server.app.storage.PostgresAppVersionRepository;
|
||||||
|
import com.cameleer3.server.app.storage.PostgresDeploymentRepository;
|
||||||
|
import com.cameleer3.server.app.storage.PostgresEnvironmentRepository;
|
||||||
|
import com.cameleer3.server.core.runtime.AppRepository;
|
||||||
|
import com.cameleer3.server.core.runtime.AppService;
|
||||||
|
import com.cameleer3.server.core.runtime.AppVersionRepository;
|
||||||
|
import com.cameleer3.server.core.runtime.DeploymentRepository;
|
||||||
|
import com.cameleer3.server.core.runtime.DeploymentService;
|
||||||
|
import com.cameleer3.server.core.runtime.EnvironmentRepository;
|
||||||
|
import com.cameleer3.server.core.runtime.EnvironmentService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates runtime management beans: repositories, services, and async executor.
|
||||||
|
* <p>
|
||||||
|
* Follows the established pattern: core module plain class, app module bean config.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RuntimeBeanConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public EnvironmentRepository environmentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
|
return new PostgresEnvironmentRepository(jdbc, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AppRepository appRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
|
return new PostgresAppRepository(jdbc, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) {
|
||||||
|
return new PostgresAppVersionRepository(jdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||||
|
return new PostgresDeploymentRepository(jdbc, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public EnvironmentService environmentService(EnvironmentRepository repo) {
|
||||||
|
return new EnvironmentService(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
|
||||||
|
@Value("${cameleer.runtime.jar-storage-path:/data/jars}") String jarStoragePath) {
|
||||||
|
return new AppService(appRepo, versionRepo, jarStoragePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
|
||||||
|
return new DeploymentService(deployRepo, appService, envService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean(name = "deploymentTaskExecutor")
|
||||||
|
public Executor deploymentTaskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(4);
|
||||||
|
executor.setMaxPoolSize(4);
|
||||||
|
executor.setQueueCapacity(25);
|
||||||
|
executor.setThreadNamePrefix("deploy-");
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,37 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.search.ClickHouseLogStore;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseAgentEventRepository;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseDiagramStore;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseMetricsQueryStore;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseMetricsStore;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseStatsStore;
|
||||||
import com.cameleer3.server.core.admin.AuditRepository;
|
import com.cameleer3.server.core.admin.AuditRepository;
|
||||||
import com.cameleer3.server.core.admin.AuditService;
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.detail.DetailService;
|
import com.cameleer3.server.core.detail.DetailService;
|
||||||
import com.cameleer3.server.core.indexing.SearchIndexer;
|
import com.cameleer3.server.core.indexing.SearchIndexer;
|
||||||
|
import com.cameleer3.server.app.ingestion.ExecutionFlushScheduler;
|
||||||
|
import com.cameleer3.server.app.search.ClickHouseSearchIndex;
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseExecutionStore;
|
||||||
|
import com.cameleer3.server.core.ingestion.BufferedLogEntry;
|
||||||
|
import com.cameleer3.server.core.ingestion.ChunkAccumulator;
|
||||||
import com.cameleer3.server.core.ingestion.IngestionService;
|
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||||
|
import com.cameleer3.server.core.ingestion.MergedExecution;
|
||||||
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||||
import com.cameleer3.server.core.storage.*;
|
import com.cameleer3.server.core.storage.*;
|
||||||
|
import com.cameleer3.server.core.storage.LogIndex;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class StorageBeanConfig {
|
public class StorageBeanConfig {
|
||||||
@@ -22,8 +43,8 @@ public class StorageBeanConfig {
|
|||||||
|
|
||||||
@Bean(destroyMethod = "shutdown")
|
@Bean(destroyMethod = "shutdown")
|
||||||
public SearchIndexer searchIndexer(ExecutionStore executionStore, SearchIndex searchIndex,
|
public SearchIndexer searchIndexer(ExecutionStore executionStore, SearchIndex searchIndex,
|
||||||
@Value("${opensearch.debounce-ms:2000}") long debounceMs,
|
@Value("${cameleer.indexer.debounce-ms:2000}") long debounceMs,
|
||||||
@Value("${opensearch.queue-size:10000}") int queueSize) {
|
@Value("${cameleer.indexer.queue-size:10000}") int queueSize) {
|
||||||
return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize);
|
return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,4 +62,128 @@ public class StorageBeanConfig {
|
|||||||
return new IngestionService(executionStore, diagramStore, metricsBuffer,
|
return new IngestionService(executionStore, diagramStore, metricsBuffer,
|
||||||
searchIndexer::onExecutionUpdated, bodySizeLimit);
|
searchIndexer::onExecutionUpdated, bodySizeLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MetricsStore clickHouseMetricsStore(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseMetricsStore(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MetricsQueryStore clickHouseMetricsQueryStore(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseMetricsQueryStore(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Execution Store ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ClickHouseExecutionStore clickHouseExecutionStore(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseExecutionStore(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ChunkAccumulator chunkAccumulator(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
WriteBuffer<MergedExecution> executionBuffer,
|
||||||
|
WriteBuffer<ChunkAccumulator.ProcessorBatch> processorBatchBuffer,
|
||||||
|
DiagramStore diagramStore,
|
||||||
|
AgentRegistryService registryService) {
|
||||||
|
return new ChunkAccumulator(
|
||||||
|
tenantProperties.getId(),
|
||||||
|
executionBuffer::offerOrWarn,
|
||||||
|
processorBatchBuffer::offerOrWarn,
|
||||||
|
diagramStore,
|
||||||
|
java.time.Duration.ofMinutes(5),
|
||||||
|
instanceId -> {
|
||||||
|
AgentInfo agent = registryService.findById(instanceId);
|
||||||
|
return agent != null && agent.environmentId() != null
|
||||||
|
? agent.environmentId() : "default";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ExecutionFlushScheduler executionFlushScheduler(
|
||||||
|
WriteBuffer<MergedExecution> executionBuffer,
|
||||||
|
WriteBuffer<ChunkAccumulator.ProcessorBatch> processorBatchBuffer,
|
||||||
|
WriteBuffer<BufferedLogEntry> logBuffer,
|
||||||
|
ClickHouseExecutionStore executionStore,
|
||||||
|
ClickHouseLogStore logStore,
|
||||||
|
ChunkAccumulator accumulator,
|
||||||
|
IngestionConfig config) {
|
||||||
|
return new ExecutionFlushScheduler(executionBuffer, processorBatchBuffer,
|
||||||
|
logBuffer, executionStore, logStore, accumulator, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SearchIndex clickHouseSearchIndex(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseSearchIndex(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ClickHouse Stats Store ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public StatsStore clickHouseStatsStore(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseStatsStore(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ClickHouse Diagram Store ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DiagramStore clickHouseDiagramStore(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseDiagramStore(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ClickHouse Agent Event Repository ─────────────────────────────
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AgentEventRepository clickHouseAgentEventRepository(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseAgentEventRepository(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ClickHouse Log Store ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ClickHouseLogStore clickHouseLogStore(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseLogStore(tenantProperties.getId(), clickHouseJdbc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Usage Analytics ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public ClickHouseUsageTracker clickHouseUsageTracker(
|
||||||
|
TenantProperties tenantProperties,
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
|
||||||
|
return new ClickHouseUsageTracker(tenantProperties.getId(), clickHouseJdbc,
|
||||||
|
new com.cameleer3.server.core.ingestion.WriteBuffer<>(5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public com.cameleer3.server.app.analytics.UsageTrackingInterceptor usageTrackingInterceptor(
|
||||||
|
ClickHouseUsageTracker usageTracker) {
|
||||||
|
return new com.cameleer3.server.app.analytics.UsageTrackingInterceptor(usageTracker);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnProperty(name = "clickhouse.enabled", havingValue = "true")
|
||||||
|
public com.cameleer3.server.app.analytics.UsageFlushScheduler usageFlushScheduler(
|
||||||
|
ClickHouseUsageTracker usageTracker) {
|
||||||
|
return new com.cameleer3.server.app.analytics.UsageFlushScheduler(usageTracker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "cameleer.tenant")
|
||||||
|
public class TenantProperties {
|
||||||
|
|
||||||
|
private String id = "default";
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.analytics.UsageTrackingInterceptor;
|
||||||
|
import com.cameleer3.server.app.interceptor.AuditInterceptor;
|
||||||
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
|
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
@@ -7,17 +9,20 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Web MVC configuration.
|
* Web MVC configuration.
|
||||||
* <p>
|
|
||||||
* Registers the {@link ProtocolVersionInterceptor} on data and agent endpoint paths,
|
|
||||||
* excluding health, API docs, and Swagger UI paths that do not require protocol versioning.
|
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
||||||
|
private final AuditInterceptor auditInterceptor;
|
||||||
|
private final UsageTrackingInterceptor usageTrackingInterceptor;
|
||||||
|
|
||||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor) {
|
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
||||||
|
AuditInterceptor auditInterceptor,
|
||||||
|
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) {
|
||||||
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
||||||
|
this.auditInterceptor = auditInterceptor;
|
||||||
|
this.usageTrackingInterceptor = usageTrackingInterceptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -33,5 +38,26 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
"/api/v1/agents/*/refresh"
|
"/api/v1/agents/*/refresh"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Usage analytics: tracks authenticated UI user requests
|
||||||
|
if (usageTrackingInterceptor != null) {
|
||||||
|
registry.addInterceptor(usageTrackingInterceptor)
|
||||||
|
.addPathPatterns("/api/v1/**")
|
||||||
|
.excludePathPatterns(
|
||||||
|
"/api/v1/data/**",
|
||||||
|
"/api/v1/agents/*/heartbeat",
|
||||||
|
"/api/v1/agents/*/events",
|
||||||
|
"/api/v1/health"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety-net audit: catches any unaudited POST/PUT/DELETE
|
||||||
|
registry.addInterceptor(auditInterceptor)
|
||||||
|
.addPathPatterns("/api/v1/**")
|
||||||
|
.excludePathPatterns(
|
||||||
|
"/api/v1/data/**",
|
||||||
|
"/api/v1/agents/*/heartbeat",
|
||||||
|
"/api/v1/health"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.server.app.agent.SseConnectionManager;
|
import com.cameleer3.server.app.agent.SseConnectionManager;
|
||||||
|
import com.cameleer3.server.app.dto.CommandAckRequest;
|
||||||
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
|
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
|
||||||
|
import com.cameleer3.server.app.dto.CommandGroupResponse;
|
||||||
import com.cameleer3.server.app.dto.CommandRequest;
|
import com.cameleer3.server.app.dto.CommandRequest;
|
||||||
import com.cameleer3.server.app.dto.CommandSingleResponse;
|
import com.cameleer3.server.app.dto.CommandSingleResponse;
|
||||||
|
import com.cameleer3.server.app.dto.ReplayRequest;
|
||||||
|
import com.cameleer3.server.app.dto.ReplayResponse;
|
||||||
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
import com.cameleer3.server.core.agent.AgentCommand;
|
import com.cameleer3.server.core.agent.AgentCommand;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.agent.AgentState;
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.agent.CommandReply;
|
||||||
import com.cameleer3.server.core.agent.CommandType;
|
import com.cameleer3.server.core.agent.CommandType;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -22,11 +32,19 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command push endpoints for sending commands to agents via SSE.
|
* Command push endpoints for sending commands to agents via SSE.
|
||||||
@@ -48,23 +66,30 @@ public class AgentCommandController {
|
|||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
private final SseConnectionManager connectionManager;
|
private final SseConnectionManager connectionManager;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public AgentCommandController(AgentRegistryService registryService,
|
public AgentCommandController(AgentRegistryService registryService,
|
||||||
SseConnectionManager connectionManager,
|
SseConnectionManager connectionManager,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
AgentEventService agentEventService,
|
||||||
|
AuditService auditService) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.connectionManager = connectionManager;
|
this.connectionManager = connectionManager;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/commands")
|
@PostMapping("/{id}/commands")
|
||||||
@Operation(summary = "Send command to a specific agent",
|
@Operation(summary = "Send command to a specific agent",
|
||||||
description = "Sends a config-update, deep-trace, or replay command to the specified agent")
|
description = "Sends a command to the specified agent via SSE")
|
||||||
@ApiResponse(responseCode = "202", description = "Command accepted")
|
@ApiResponse(responseCode = "202", description = "Command accepted")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||||
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
|
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
|
||||||
@RequestBody CommandRequest request) throws JsonProcessingException {
|
@RequestBody CommandRequest request,
|
||||||
|
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||||
AgentInfo agent = registryService.findById(id);
|
AgentInfo agent = registryService.findById(id);
|
||||||
if (agent == null) {
|
if (agent == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||||
@@ -76,33 +101,71 @@ public class AgentCommandController {
|
|||||||
|
|
||||||
String status = connectionManager.isConnected(id) ? "DELIVERED" : "PENDING";
|
String status = connectionManager.isConnected(id) ? "DELIVERED" : "PENDING";
|
||||||
|
|
||||||
|
auditService.log("send_agent_command", AuditCategory.AGENT, id,
|
||||||
|
java.util.Map.of("type", request.type(), "status", status),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||||
.body(new CommandSingleResponse(command.id(), status));
|
.body(new CommandSingleResponse(command.id(), status));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/groups/{group}/commands")
|
@PostMapping("/groups/{group}/commands")
|
||||||
@Operation(summary = "Send command to all agents in a group",
|
@Operation(summary = "Send command to all agents in a group",
|
||||||
description = "Sends a command to all LIVE agents in the specified group")
|
description = "Sends a command to all LIVE agents in the specified group and waits for responses")
|
||||||
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
@ApiResponse(responseCode = "200", description = "Commands dispatched and responses collected")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||||
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
|
public ResponseEntity<CommandGroupResponse> sendGroupCommand(@PathVariable String group,
|
||||||
@RequestBody CommandRequest request) throws JsonProcessingException {
|
@RequestParam(required = false) String environment,
|
||||||
|
@RequestBody CommandRequest request,
|
||||||
|
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||||
CommandType type = mapCommandType(request.type());
|
CommandType type = mapCommandType(request.type());
|
||||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||||
|
|
||||||
List<AgentInfo> agents = registryService.findAll().stream()
|
Map<String, CompletableFuture<CommandReply>> futures =
|
||||||
.filter(a -> a.state() == AgentState.LIVE)
|
registryService.addGroupCommandWithReplies(group, environment, type, payloadJson);
|
||||||
.filter(a -> group.equals(a.application()))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<String> commandIds = new ArrayList<>();
|
if (futures.isEmpty()) {
|
||||||
for (AgentInfo agent : agents) {
|
auditService.log("broadcast_group_command", AuditCategory.AGENT, group,
|
||||||
AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson);
|
java.util.Map.of("type", request.type(), "agentCount", 0),
|
||||||
commandIds.add(command.id());
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(new CommandGroupResponse(true, 0, 0, List.of(), List.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
// Wait with shared 10-second deadline
|
||||||
.body(new CommandBroadcastResponse(commandIds, agents.size()));
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
|
List<CommandGroupResponse.AgentResponse> responses = new ArrayList<>();
|
||||||
|
List<String> timedOut = new ArrayList<>();
|
||||||
|
|
||||||
|
for (var entry : futures.entrySet()) {
|
||||||
|
long remaining = deadline - System.currentTimeMillis();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
timedOut.add(entry.getKey());
|
||||||
|
entry.getValue().cancel(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS);
|
||||||
|
responses.add(new CommandGroupResponse.AgentResponse(
|
||||||
|
entry.getKey(), reply.status(), reply.message()));
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
timedOut.add(entry.getKey());
|
||||||
|
entry.getValue().cancel(false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
responses.add(new CommandGroupResponse.AgentResponse(
|
||||||
|
entry.getKey(), "ERROR", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean allSuccess = timedOut.isEmpty() &&
|
||||||
|
responses.stream().allMatch(r -> "SUCCESS".equals(r.status()));
|
||||||
|
|
||||||
|
auditService.log("broadcast_group_command", AuditCategory.AGENT, group,
|
||||||
|
java.util.Map.of("type", request.type(), "agentCount", futures.size(),
|
||||||
|
"responded", responses.size(), "timedOut", timedOut.size()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new CommandGroupResponse(
|
||||||
|
allSuccess, futures.size(), responses.size(), responses, timedOut));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/commands")
|
@PostMapping("/commands")
|
||||||
@@ -110,43 +173,143 @@ public class AgentCommandController {
|
|||||||
description = "Sends a command to all agents currently in LIVE state")
|
description = "Sends a command to all agents currently in LIVE state")
|
||||||
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||||
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestBody CommandRequest request) throws JsonProcessingException {
|
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestParam(required = false) String environment,
|
||||||
|
@RequestBody CommandRequest request,
|
||||||
|
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||||
CommandType type = mapCommandType(request.type());
|
CommandType type = mapCommandType(request.type());
|
||||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||||
|
|
||||||
List<AgentInfo> liveAgents = registryService.findByState(AgentState.LIVE);
|
List<AgentInfo> liveAgents = registryService.findByState(AgentState.LIVE);
|
||||||
|
if (environment != null) {
|
||||||
|
liveAgents = liveAgents.stream()
|
||||||
|
.filter(a -> environment.equals(a.environmentId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
List<String> commandIds = new ArrayList<>();
|
List<String> commandIds = new ArrayList<>();
|
||||||
for (AgentInfo agent : liveAgents) {
|
for (AgentInfo agent : liveAgents) {
|
||||||
AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson);
|
AgentCommand command = registryService.addCommand(agent.instanceId(), type, payloadJson);
|
||||||
commandIds.add(command.id());
|
commandIds.add(command.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditService.log("broadcast_all_command", AuditCategory.AGENT, null,
|
||||||
|
java.util.Map.of("type", request.type(), "agentCount", liveAgents.size()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||||
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
|
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/commands/{commandId}/ack")
|
@PostMapping("/{id}/commands/{commandId}/ack")
|
||||||
@Operation(summary = "Acknowledge command receipt",
|
@Operation(summary = "Acknowledge command receipt",
|
||||||
description = "Agent acknowledges that it has received and processed a command")
|
description = "Agent acknowledges that it has received and processed a command, with result status and message")
|
||||||
@ApiResponse(responseCode = "200", description = "Command acknowledged")
|
@ApiResponse(responseCode = "200", description = "Command acknowledged")
|
||||||
@ApiResponse(responseCode = "404", description = "Command not found")
|
@ApiResponse(responseCode = "404", description = "Command not found")
|
||||||
public ResponseEntity<Void> acknowledgeCommand(@PathVariable String id,
|
public ResponseEntity<Void> acknowledgeCommand(@PathVariable String id,
|
||||||
@PathVariable String commandId) {
|
@PathVariable String commandId,
|
||||||
|
@RequestBody(required = false) CommandAckRequest body) {
|
||||||
boolean acknowledged = registryService.acknowledgeCommand(id, commandId);
|
boolean acknowledged = registryService.acknowledgeCommand(id, commandId);
|
||||||
if (!acknowledged) {
|
if (!acknowledged) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Command not found: " + commandId);
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Command not found: " + commandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Complete any pending reply future (for synchronous request-reply commands like TEST_EXPRESSION)
|
||||||
|
registryService.completeReply(commandId,
|
||||||
|
body != null ? body.status() : "SUCCESS",
|
||||||
|
body != null ? body.message() : null,
|
||||||
|
body != null ? body.data() : null);
|
||||||
|
|
||||||
|
// Record command result in agent event log
|
||||||
|
if (body != null && body.status() != null) {
|
||||||
|
AgentInfo agent = registryService.findById(id);
|
||||||
|
String application = agent != null ? agent.applicationId() : "unknown";
|
||||||
|
agentEventService.recordEvent(id, application, "COMMAND_" + body.status(),
|
||||||
|
"Command " + commandId + ": " + body.message());
|
||||||
|
log.debug("Command {} ack from agent {}: {} - {}", commandId, id, body.status(), body.message());
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/replay")
|
||||||
|
@Operation(summary = "Replay an exchange on a specific agent (synchronous)",
|
||||||
|
description = "Sends a replay command and waits for the agent to complete the replay. "
|
||||||
|
+ "Returns the replay result including status, replayExchangeId, and duration.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Replay completed (check status for success/failure)")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Agent not found or not connected")
|
||||||
|
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||||
|
public ResponseEntity<ReplayResponse> replayExchange(@PathVariable String id,
|
||||||
|
@RequestBody ReplayRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
AgentInfo agent = registryService.findById(id);
|
||||||
|
if (agent == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build protocol-compliant replay payload
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("routeId", request.routeId());
|
||||||
|
Map<String, Object> exchange = new LinkedHashMap<>();
|
||||||
|
exchange.put("body", request.body() != null ? request.body() : "");
|
||||||
|
exchange.put("headers", request.headers() != null ? request.headers() : Map.of());
|
||||||
|
payload.put("exchange", exchange);
|
||||||
|
if (request.originalExchangeId() != null) {
|
||||||
|
payload.put("originalExchangeId", request.originalExchangeId());
|
||||||
|
}
|
||||||
|
payload.put("nonce", UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
String payloadJson;
|
||||||
|
try {
|
||||||
|
payloadJson = objectMapper.writeValueAsString(payload);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Failed to serialize replay payload", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(new ReplayResponse("FAILURE", "Failed to serialize request", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
|
||||||
|
id, CommandType.REPLAY, payloadJson);
|
||||||
|
|
||||||
|
Map<String, Object> auditDetails = new LinkedHashMap<>();
|
||||||
|
auditDetails.put("routeId", request.routeId());
|
||||||
|
if (request.originalExchangeId() != null) {
|
||||||
|
auditDetails.put("originalExchangeId", request.originalExchangeId());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
CommandReply reply = future.orTimeout(30, TimeUnit.SECONDS).join();
|
||||||
|
auditDetails.put("replyStatus", reply.status());
|
||||||
|
auditDetails.put("replyMessage", reply.message() != null ? reply.message() : "");
|
||||||
|
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
|
||||||
|
"SUCCESS".equals(reply.status()) ? AuditResult.SUCCESS : AuditResult.FAILURE, httpRequest);
|
||||||
|
return ResponseEntity.ok(new ReplayResponse(reply.status(), reply.message(), reply.data()));
|
||||||
|
} catch (CompletionException e) {
|
||||||
|
if (e.getCause() instanceof TimeoutException) {
|
||||||
|
auditDetails.put("error", "timeout");
|
||||||
|
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
|
||||||
|
AuditResult.FAILURE, httpRequest);
|
||||||
|
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
|
||||||
|
.body(new ReplayResponse("FAILURE", "Agent did not respond within 30 seconds", null));
|
||||||
|
}
|
||||||
|
auditDetails.put("error", e.getCause().getMessage());
|
||||||
|
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
|
||||||
|
AuditResult.FAILURE, httpRequest);
|
||||||
|
log.error("Error awaiting replay reply from agent {}", id, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(new ReplayResponse("FAILURE", "Internal error: " + e.getCause().getMessage(), null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CommandType mapCommandType(String typeStr) {
|
private CommandType mapCommandType(String typeStr) {
|
||||||
return switch (typeStr) {
|
return switch (typeStr) {
|
||||||
case "config-update" -> CommandType.CONFIG_UPDATE;
|
case "config-update" -> CommandType.CONFIG_UPDATE;
|
||||||
case "deep-trace" -> CommandType.DEEP_TRACE;
|
case "deep-trace" -> CommandType.DEEP_TRACE;
|
||||||
case "replay" -> CommandType.REPLAY;
|
case "replay" -> CommandType.REPLAY;
|
||||||
|
case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS;
|
||||||
|
case "test-expression" -> CommandType.TEST_EXPRESSION;
|
||||||
|
case "route-control" -> CommandType.ROUTE_CONTROL;
|
||||||
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay");
|
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression, route-control");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public class AgentEventsController {
|
|||||||
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||||
@RequestParam(required = false) String appId,
|
@RequestParam(required = false) String appId,
|
||||||
@RequestParam(required = false) String agentId,
|
@RequestParam(required = false) String agentId,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(defaultValue = "50") int limit) {
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
@@ -39,7 +40,7 @@ public class AgentEventsController {
|
|||||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
|
var events = agentEventService.queryEvents(appId, agentId, environment, fromInstant, toInstant, limit)
|
||||||
.stream()
|
.stream()
|
||||||
.map(AgentEventResponse::from)
|
.map(AgentEventResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -2,22 +2,23 @@ package com.cameleer3.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer3.server.app.dto.AgentMetricsResponse;
|
import com.cameleer3.server.app.dto.AgentMetricsResponse;
|
||||||
import com.cameleer3.server.app.dto.MetricBucket;
|
import com.cameleer3.server.app.dto.MetricBucket;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import com.cameleer3.server.core.storage.MetricsQueryStore;
|
||||||
|
import com.cameleer3.server.core.storage.model.MetricTimeSeries;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/agents/{agentId}/metrics")
|
@RequestMapping("/api/v1/agents/{agentId}/metrics")
|
||||||
public class AgentMetricsController {
|
public class AgentMetricsController {
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final MetricsQueryStore metricsQueryStore;
|
||||||
|
|
||||||
public AgentMetricsController(JdbcTemplate jdbc) {
|
public AgentMetricsController(MetricsQueryStore metricsQueryStore) {
|
||||||
this.jdbc = jdbc;
|
this.metricsQueryStore = metricsQueryStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -32,34 +33,18 @@ public class AgentMetricsController {
|
|||||||
if (to == null) to = Instant.now();
|
if (to == null) to = Instant.now();
|
||||||
|
|
||||||
List<String> metricNames = Arrays.asList(names.split(","));
|
List<String> metricNames = Arrays.asList(names.split(","));
|
||||||
long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1);
|
|
||||||
String intervalStr = intervalMs + " milliseconds";
|
|
||||||
|
|
||||||
Map<String, List<MetricBucket>> result = new LinkedHashMap<>();
|
Map<String, List<MetricTimeSeries.Bucket>> raw =
|
||||||
for (String name : metricNames) {
|
metricsQueryStore.queryTimeSeries(agentId, metricNames, from, to, buckets);
|
||||||
result.put(name.trim(), new ArrayList<>());
|
|
||||||
}
|
|
||||||
|
|
||||||
String sql = """
|
Map<String, List<MetricBucket>> result = raw.entrySet().stream()
|
||||||
SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket,
|
.collect(Collectors.toMap(
|
||||||
metric_name,
|
Map.Entry::getKey,
|
||||||
AVG(metric_value) AS avg_value
|
e -> e.getValue().stream()
|
||||||
FROM agent_metrics
|
.map(b -> new MetricBucket(b.time(), b.value()))
|
||||||
WHERE agent_id = ?
|
.toList(),
|
||||||
AND collected_at >= ? AND collected_at < ?
|
(a, b) -> a,
|
||||||
AND metric_name = ANY(?)
|
LinkedHashMap::new));
|
||||||
GROUP BY bucket, metric_name
|
|
||||||
ORDER BY bucket
|
|
||||||
""";
|
|
||||||
|
|
||||||
String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new);
|
|
||||||
jdbc.query(sql, rs -> {
|
|
||||||
String metricName = rs.getString("metric_name");
|
|
||||||
Instant bucket = rs.getTimestamp("bucket").toInstant();
|
|
||||||
double value = rs.getDouble("avg_value");
|
|
||||||
result.computeIfAbsent(metricName, k -> new ArrayList<>())
|
|
||||||
.add(new MetricBucket(bucket, value));
|
|
||||||
}, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
|
|
||||||
|
|
||||||
return new AgentMetricsResponse(result);
|
return new AgentMetricsResponse(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import com.cameleer3.server.app.dto.AgentRefreshResponse;
|
|||||||
import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
||||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||||
|
import com.cameleer3.common.model.HeartbeatRequest;
|
||||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||||
|
import com.cameleer3.server.app.security.JwtAuthenticationFilter;
|
||||||
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
import com.cameleer3.server.core.agent.AgentEventService;
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.agent.AgentState;
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.agent.RouteStateRegistry;
|
||||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
@@ -22,6 +28,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@@ -58,7 +65,9 @@ public class AgentRegistrationController {
|
|||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final Ed25519SigningService ed25519SigningService;
|
private final Ed25519SigningService ed25519SigningService;
|
||||||
private final AgentEventService agentEventService;
|
private final AgentEventService agentEventService;
|
||||||
|
private final AuditService auditService;
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
private final RouteStateRegistry routeStateRegistry;
|
||||||
|
|
||||||
public AgentRegistrationController(AgentRegistryService registryService,
|
public AgentRegistrationController(AgentRegistryService registryService,
|
||||||
AgentRegistryConfig config,
|
AgentRegistryConfig config,
|
||||||
@@ -66,14 +75,18 @@ public class AgentRegistrationController {
|
|||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
Ed25519SigningService ed25519SigningService,
|
Ed25519SigningService ed25519SigningService,
|
||||||
AgentEventService agentEventService,
|
AgentEventService agentEventService,
|
||||||
JdbcTemplate jdbc) {
|
AuditService auditService,
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
|
||||||
|
RouteStateRegistry routeStateRegistry) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.ed25519SigningService = ed25519SigningService;
|
this.ed25519SigningService = ed25519SigningService;
|
||||||
this.agentEventService = agentEventService;
|
this.agentEventService = agentEventService;
|
||||||
|
this.auditService = auditService;
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
this.routeStateRegistry = routeStateRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@@ -97,30 +110,41 @@ public class AgentRegistrationController {
|
|||||||
return ResponseEntity.status(401).build();
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.agentId() == null || request.agentId().isBlank()
|
if (request.instanceId() == null || request.instanceId().isBlank()
|
||||||
|| request.name() == null || request.name().isBlank()) {
|
|| request.displayName() == null || request.displayName().isBlank()) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
String application = request.application() != null ? request.application() : "default";
|
String application = request.applicationId() != null ? request.applicationId() : "default";
|
||||||
|
String environmentId = request.environmentId() != null ? request.environmentId() : "default";
|
||||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||||
|
|
||||||
AgentInfo agent = registryService.register(
|
AgentInfo agent = registryService.register(
|
||||||
request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
|
request.instanceId(), request.displayName(), application, environmentId,
|
||||||
log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
|
request.version(), routeIds, capabilities);
|
||||||
|
log.info("Agent registered: {} (name={}, application={})", request.instanceId(), request.displayName(), application);
|
||||||
|
|
||||||
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
agentEventService.recordEvent(request.instanceId(), application, "REGISTERED",
|
||||||
"Agent registered: " + request.name());
|
"Agent registered: " + request.displayName());
|
||||||
|
|
||||||
// Issue JWT tokens with AGENT role
|
auditService.log(request.instanceId(), "agent_register", AuditCategory.AGENT, request.instanceId(),
|
||||||
|
Map.of("application", application, "name", request.displayName()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
|
// Issue JWT tokens with AGENT role + environment
|
||||||
List<String> roles = List.of("AGENT");
|
List<String> roles = List.of("AGENT");
|
||||||
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
|
String accessToken = jwtService.createAccessToken(request.instanceId(), application, environmentId, roles);
|
||||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
|
String refreshToken = jwtService.createRefreshToken(request.instanceId(), application, environmentId, roles);
|
||||||
|
|
||||||
|
String sseEndpoint = ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||||
|
.path("/api/v1/agents/{id}/events")
|
||||||
|
.buildAndExpand(agent.instanceId())
|
||||||
|
.toUriString();
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRegistrationResponse(
|
return ResponseEntity.ok(new AgentRegistrationResponse(
|
||||||
agent.id(),
|
agent.instanceId(),
|
||||||
"/api/v1/agents/" + agent.id() + "/events",
|
sseEndpoint,
|
||||||
config.getHeartbeatIntervalMs(),
|
config.getHeartbeatIntervalMs(),
|
||||||
ed25519SigningService.getPublicKeyBase64(),
|
ed25519SigningService.getPublicKeyBase64(),
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -135,7 +159,8 @@ public class AgentRegistrationController {
|
|||||||
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
|
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
|
||||||
@ApiResponse(responseCode = "404", description = "Agent not found")
|
@ApiResponse(responseCode = "404", description = "Agent not found")
|
||||||
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
|
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
|
||||||
@RequestBody AgentRefreshRequest request) {
|
@RequestBody AgentRefreshRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
|
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
|
||||||
return ResponseEntity.status(401).build();
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
@@ -157,31 +182,96 @@ public class AgentRegistrationController {
|
|||||||
return ResponseEntity.status(401).build();
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify agent exists
|
// Preserve roles and application from refresh token
|
||||||
AgentInfo agent = registryService.findById(agentId);
|
|
||||||
if (agent == null) {
|
|
||||||
return ResponseEntity.notFound().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve roles from refresh token
|
|
||||||
List<String> roles = result.roles().isEmpty()
|
List<String> roles = result.roles().isEmpty()
|
||||||
? List.of("AGENT") : result.roles();
|
? List.of("AGENT") : result.roles();
|
||||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
String application = result.application() != null ? result.application() : "default";
|
||||||
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
|
|
||||||
|
// Try to get application + environment from registry (agent may not be registered after server restart)
|
||||||
|
String environment = result.environment() != null ? result.environment() : "default";
|
||||||
|
AgentInfo agent = registryService.findById(agentId);
|
||||||
|
if (agent != null) {
|
||||||
|
application = agent.applicationId();
|
||||||
|
environment = agent.environmentId();
|
||||||
|
}
|
||||||
|
|
||||||
|
String newAccessToken = jwtService.createAccessToken(agentId, application, environment, roles);
|
||||||
|
String newRefreshToken = jwtService.createRefreshToken(agentId, application, environment, roles);
|
||||||
|
|
||||||
|
auditService.log(agentId, "agent_token_refresh", AuditCategory.AUTH, agentId,
|
||||||
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/heartbeat")
|
@PostMapping("/{id}/heartbeat")
|
||||||
@Operation(summary = "Agent heartbeat ping",
|
@Operation(summary = "Agent heartbeat ping",
|
||||||
description = "Updates the agent's last heartbeat timestamp")
|
description = "Updates the agent's last heartbeat timestamp. Auto-registers the agent if not in registry (e.g. after server restart).")
|
||||||
@ApiResponse(responseCode = "200", description = "Heartbeat accepted")
|
@ApiResponse(responseCode = "200", description = "Heartbeat accepted")
|
||||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
public ResponseEntity<Void> heartbeat(@PathVariable String id,
|
||||||
public ResponseEntity<Void> heartbeat(@PathVariable String id) {
|
@RequestBody(required = false) HeartbeatRequest request,
|
||||||
boolean found = registryService.heartbeat(id);
|
HttpServletRequest httpRequest) {
|
||||||
|
Map<String, Object> capabilities = request != null ? request.getCapabilities() : null;
|
||||||
|
String heartbeatEnv = request != null ? request.getEnvironmentId() : null;
|
||||||
|
boolean found = registryService.heartbeat(id, capabilities);
|
||||||
if (!found) {
|
if (!found) {
|
||||||
|
// Auto-heal: re-register agent from heartbeat body + JWT claims after server restart
|
||||||
|
var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute(
|
||||||
|
JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||||
|
if (jwtResult != null) {
|
||||||
|
String application = jwtResult.application() != null ? jwtResult.application() : "default";
|
||||||
|
// Prefer environment from heartbeat body (most current), fall back to JWT claim
|
||||||
|
String env = heartbeatEnv != null ? heartbeatEnv
|
||||||
|
: jwtResult.environment() != null ? jwtResult.environment() : "default";
|
||||||
|
Map<String, Object> caps = capabilities != null ? capabilities : Map.of();
|
||||||
|
registryService.register(id, id, application, env, "unknown",
|
||||||
|
List.of(), caps);
|
||||||
|
registryService.heartbeat(id);
|
||||||
|
log.info("Auto-registered agent {} (app={}, env={}) from heartbeat after server restart", id, application, env);
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request != null && request.getRouteStates() != null && !request.getRouteStates().isEmpty()) {
|
||||||
|
AgentInfo agent = registryService.findById(id);
|
||||||
|
if (agent != null) {
|
||||||
|
for (var entry : request.getRouteStates().entrySet()) {
|
||||||
|
RouteStateRegistry.RouteState state = parseRouteState(entry.getValue());
|
||||||
|
if (state != null) {
|
||||||
|
routeStateRegistry.setState(agent.applicationId(), entry.getKey(), state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RouteStateRegistry.RouteState parseRouteState(String state) {
|
||||||
|
if (state == null) return null;
|
||||||
|
return switch (state) {
|
||||||
|
case "Started" -> RouteStateRegistry.RouteState.STARTED;
|
||||||
|
case "Stopped" -> RouteStateRegistry.RouteState.STOPPED;
|
||||||
|
case "Suspended" -> RouteStateRegistry.RouteState.SUSPENDED;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/deregister")
|
||||||
|
@Operation(summary = "Deregister agent",
|
||||||
|
description = "Removes the agent from the registry. Called by agents during graceful shutdown.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Agent deregistered")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||||
|
public ResponseEntity<Void> deregister(@PathVariable String id, HttpServletRequest httpRequest) {
|
||||||
|
AgentInfo agent = registryService.findById(id);
|
||||||
|
if (agent == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
String applicationId = agent.applicationId();
|
||||||
|
registryService.deregister(id);
|
||||||
|
agentEventService.recordEvent(id, applicationId, "DEREGISTERED", "Agent deregistered");
|
||||||
|
auditService.log(id, "agent_deregister", AuditCategory.AGENT, id, null, AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +283,8 @@ public class AgentRegistrationController {
|
|||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
@RequestParam(required = false) String application) {
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
List<AgentInfo> agents;
|
List<AgentInfo> agents;
|
||||||
|
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
@@ -210,7 +301,14 @@ public class AgentRegistrationController {
|
|||||||
// Apply application filter if specified
|
// Apply application filter if specified
|
||||||
if (application != null && !application.isBlank()) {
|
if (application != null && !application.isBlank()) {
|
||||||
agents = agents.stream()
|
agents = agents.stream()
|
||||||
.filter(a -> application.equals(a.application()))
|
.filter(a -> application.equals(a.applicationId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply environment filter if specified
|
||||||
|
if (environment != null && !environment.isBlank()) {
|
||||||
|
agents = agents.stream()
|
||||||
|
.filter(a -> environment.equals(a.environmentId()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,10 +319,10 @@ public class AgentRegistrationController {
|
|||||||
List<AgentInstanceResponse> response = finalAgents.stream()
|
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||||
.map(a -> {
|
.map(a -> {
|
||||||
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||||
double[] m = agentMetrics.get(a.application());
|
double[] m = agentMetrics.get(a.applicationId());
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
long appAgentCount = finalAgents.stream()
|
long appAgentCount = finalAgents.stream()
|
||||||
.filter(ag -> ag.application().equals(a.application())).count();
|
.filter(ag -> ag.applicationId().equals(a.applicationId())).count();
|
||||||
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||||
double errorRate = m[1];
|
double errorRate = m[1];
|
||||||
int activeRoutes = (int) m[2];
|
int activeRoutes = (int) m[2];
|
||||||
@@ -241,25 +339,33 @@ public class AgentRegistrationController {
|
|||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||||
try {
|
try {
|
||||||
|
// Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries
|
||||||
|
// that strip AggregateFunction column types, breaking -Merge combinators
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT application_name, " +
|
"SELECT application_id, " +
|
||||||
"SUM(total_count) AS total, " +
|
"countMerge(total_count) AS total, " +
|
||||||
"SUM(failed_count) AS failed, " +
|
"countIfMerge(failed_count) AS failed, " +
|
||||||
"COUNT(DISTINCT route_id) AS active_routes " +
|
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
"FROM stats_1m_route WHERE bucket >= " + lit(from1m) + " AND bucket < " + lit(now) +
|
||||||
"GROUP BY application_name",
|
" GROUP BY application_id",
|
||||||
rs -> {
|
rs -> {
|
||||||
long total = rs.getLong("total");
|
long total = rs.getLong("total");
|
||||||
long failed = rs.getLong("failed");
|
long failed = rs.getLong("failed");
|
||||||
double tps = total / 60.0;
|
double tps = total / 60.0;
|
||||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
int activeRoutes = rs.getInt("active_routes");
|
int activeRoutes = rs.getInt("active_routes");
|
||||||
result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
|
result.put(rs.getString("application_id"), new double[]{tps, errorRate, activeRoutes});
|
||||||
},
|
});
|
||||||
Timestamp.from(from1m), Timestamp.from(now));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("Could not query agent metrics: {}", e.getMessage());
|
log.debug("Could not query agent metrics: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format an Instant as a ClickHouse DateTime literal. */
|
||||||
|
private static String lit(Instant instant) {
|
||||||
|
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
|
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.server.app.agent.SseConnectionManager;
|
import com.cameleer3.server.app.agent.SseConnectionManager;
|
||||||
|
import com.cameleer3.server.app.security.JwtAuthenticationFilter;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.security.JwtService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -19,6 +22,9 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE endpoint for real-time event streaming to agents.
|
* SSE endpoint for real-time event streaming to agents.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -47,15 +53,26 @@ public class AgentSseController {
|
|||||||
+ "Commands (config-update, deep-trace, replay) are pushed as events. "
|
+ "Commands (config-update, deep-trace, replay) are pushed as events. "
|
||||||
+ "Ping keepalive comments sent every 15 seconds.")
|
+ "Ping keepalive comments sent every 15 seconds.")
|
||||||
@ApiResponse(responseCode = "200", description = "SSE stream opened")
|
@ApiResponse(responseCode = "200", description = "SSE stream opened")
|
||||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
@ApiResponse(responseCode = "404", description = "Agent not registered and cannot be auto-registered")
|
||||||
public SseEmitter events(
|
public SseEmitter events(
|
||||||
@PathVariable String id,
|
@PathVariable String id,
|
||||||
@Parameter(description = "Last received event ID (no replay, acknowledged only)")
|
@Parameter(description = "Last received event ID (no replay, acknowledged only)")
|
||||||
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
|
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
AgentInfo agent = registryService.findById(id);
|
AgentInfo agent = registryService.findById(id);
|
||||||
if (agent == null) {
|
if (agent == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
// Auto-heal: re-register agent from JWT claims after server restart
|
||||||
|
var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute(
|
||||||
|
JwtAuthenticationFilter.JWT_RESULT_ATTR);
|
||||||
|
if (jwtResult != null) {
|
||||||
|
String application = jwtResult.application() != null ? jwtResult.application() : "default";
|
||||||
|
String env = jwtResult.environment() != null ? jwtResult.environment() : "default";
|
||||||
|
registryService.register(id, id, application, env, "unknown", List.of(), Map.of());
|
||||||
|
log.info("Auto-registered agent {} (app={}, env={}) from SSE connect after server restart", id, application, env);
|
||||||
|
} else {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastEventId != null) {
|
if (lastEventId != null) {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ public class ApiExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(ResponseStatusException.class)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||||
|
String reason = ex.getReason();
|
||||||
return ResponseEntity.status(ex.getStatusCode())
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
.body(new ErrorResponse(ex.getReason() != null ? ex.getReason() : "Unknown error"));
|
.body(new ErrorResponse(reason != null ? reason : "Unknown error"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.runtime.App;
|
||||||
|
import com.cameleer3.server.core.runtime.AppService;
|
||||||
|
import com.cameleer3.server.core.runtime.AppVersion;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App CRUD and JAR upload endpoints.
|
||||||
|
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
|
||||||
|
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/apps")
|
||||||
|
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads")
|
||||||
|
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
||||||
|
public class AppController {
|
||||||
|
|
||||||
|
private final AppService appService;
|
||||||
|
|
||||||
|
public AppController(AppService appService) {
|
||||||
|
this.appService = appService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List apps by environment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "App list returned")
|
||||||
|
public ResponseEntity<List<App>> listApps(@RequestParam(required = false) UUID environmentId) {
|
||||||
|
if (environmentId != null) {
|
||||||
|
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(appService.listAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{appSlug}")
|
||||||
|
@Operation(summary = "Get app by slug")
|
||||||
|
@ApiResponse(responseCode = "200", description = "App found")
|
||||||
|
@ApiResponse(responseCode = "404", description = "App not found")
|
||||||
|
public ResponseEntity<App> getApp(@PathVariable String appSlug) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(appService.getBySlug(appSlug));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new app")
|
||||||
|
@ApiResponse(responseCode = "201", description = "App created")
|
||||||
|
@ApiResponse(responseCode = "400", description = "Slug already exists in environment")
|
||||||
|
public ResponseEntity<App> createApp(@RequestBody CreateAppRequest request) {
|
||||||
|
try {
|
||||||
|
UUID id = appService.createApp(request.environmentId(), request.slug(), request.displayName());
|
||||||
|
return ResponseEntity.status(201).body(appService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{appSlug}/versions")
|
||||||
|
@Operation(summary = "List app versions")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Version list returned")
|
||||||
|
public ResponseEntity<List<AppVersion>> listVersions(@PathVariable String appSlug) {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
return ResponseEntity.ok(appService.listVersions(app.id()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Operation(summary = "Upload a JAR for a new app version")
|
||||||
|
@ApiResponse(responseCode = "201", description = "JAR uploaded and version created")
|
||||||
|
@ApiResponse(responseCode = "404", description = "App not found")
|
||||||
|
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug,
|
||||||
|
@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
|
||||||
|
return ResponseEntity.status(201).body(version);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{appSlug}")
|
||||||
|
@Operation(summary = "Delete an app")
|
||||||
|
@ApiResponse(responseCode = "204", description = "App deleted")
|
||||||
|
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
appService.deleteApp(app.id());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{appSlug}/container-config")
|
||||||
|
@Operation(summary = "Update container config for an app")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Container config updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "App not found")
|
||||||
|
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
|
||||||
|
@RequestBody Map<String, Object> containerConfig) {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
appService.updateContainerConfig(app.id(), containerConfig);
|
||||||
|
return ResponseEntity.ok(appService.getById(app.id()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateAppRequest(UUID environmentId, String slug, String displayName) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AppSettingsRequest;
|
||||||
|
import com.cameleer3.server.core.admin.AppSettings;
|
||||||
|
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||||
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/app-settings")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
|
||||||
|
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
|
||||||
|
public class AppSettingsController {
|
||||||
|
|
||||||
|
private final AppSettingsRepository repository;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
|
public AppSettingsController(AppSettingsRepository repository, AuditService auditService) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all application settings")
|
||||||
|
public ResponseEntity<List<AppSettings>> getAll() {
|
||||||
|
return ResponseEntity.ok(repository.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{appId}")
|
||||||
|
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
|
||||||
|
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
|
||||||
|
AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(appId));
|
||||||
|
return ResponseEntity.ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{appId}")
|
||||||
|
@Operation(summary = "Create or update settings for an application")
|
||||||
|
public ResponseEntity<AppSettings> update(@PathVariable String appId,
|
||||||
|
@Valid @RequestBody AppSettingsRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
List<String> errors = request.validate();
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
AppSettings saved = repository.save(request.toSettings(appId));
|
||||||
|
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
|
||||||
|
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{appId}")
|
||||||
|
@Operation(summary = "Delete application settings (reverts to defaults)")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
|
||||||
|
repository.delete(appId);
|
||||||
|
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
||||||
|
Map.of(), AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.ApplicationConfig;
|
||||||
|
import com.cameleer3.server.app.dto.CommandGroupResponse;
|
||||||
|
import com.cameleer3.server.app.dto.ConfigUpdateResponse;
|
||||||
|
import com.cameleer3.server.app.dto.TestExpressionRequest;
|
||||||
|
import com.cameleer3.server.app.dto.TestExpressionResponse;
|
||||||
|
import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository;
|
||||||
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.agent.CommandReply;
|
||||||
|
import com.cameleer3.server.core.agent.CommandType;
|
||||||
|
import com.cameleer3.server.core.storage.DiagramStore;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-application configuration management.
|
||||||
|
* Agents fetch config at startup; the UI modifies config which is persisted and pushed to agents via SSE.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/config")
|
||||||
|
@Tag(name = "Application Config", description = "Per-application observability configuration")
|
||||||
|
public class ApplicationConfigController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class);
|
||||||
|
|
||||||
|
private final PostgresApplicationConfigRepository configRepository;
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final DiagramStore diagramStore;
|
||||||
|
|
||||||
|
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
|
||||||
|
AgentRegistryService registryService,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
AuditService auditService,
|
||||||
|
DiagramStore diagramStore) {
|
||||||
|
this.configRepository = configRepository;
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.auditService = auditService;
|
||||||
|
this.diagramStore = diagramStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all application configs",
|
||||||
|
description = "Returns stored configurations for all applications")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Configs returned")
|
||||||
|
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
|
||||||
|
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(configRepository.findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{application}")
|
||||||
|
@Operation(summary = "Get application config",
|
||||||
|
description = "Returns the current configuration for an application. Returns defaults if none stored.")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Config returned")
|
||||||
|
public ResponseEntity<ApplicationConfig> getConfig(@PathVariable String application,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
configRepository.findByApplication(application)
|
||||||
|
.orElse(defaultConfig(application)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{application}")
|
||||||
|
@Operation(summary = "Update application config",
|
||||||
|
description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||||
|
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
|
@RequestBody ApplicationConfig config,
|
||||||
|
Authentication auth,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
String updatedBy = auth != null ? auth.getName() : "system";
|
||||||
|
|
||||||
|
config.setApplication(application);
|
||||||
|
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
|
||||||
|
|
||||||
|
CommandGroupResponse pushResult = pushConfigToAgents(application, environment, saved);
|
||||||
|
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
|
||||||
|
saved.getVersion(), application, pushResult.total(), pushResult.responded());
|
||||||
|
|
||||||
|
auditService.log("update_app_config", AuditCategory.CONFIG, application,
|
||||||
|
Map.of("version", saved.getVersion(), "agentsPushed", pushResult.total(),
|
||||||
|
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{application}/processor-routes")
|
||||||
|
@Operation(summary = "Get processor to route mapping",
|
||||||
|
description = "Returns a map of processorId → routeId for all processors seen in this application")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
||||||
|
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application) {
|
||||||
|
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{application}/test-expression")
|
||||||
|
@Operation(summary = "Test a tap expression against sample data via a live agent")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Expression evaluated successfully")
|
||||||
|
@ApiResponse(responseCode = "404", description = "No live agent available for this application")
|
||||||
|
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||||
|
public ResponseEntity<TestExpressionResponse> testExpression(
|
||||||
|
@PathVariable String application,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
|
@RequestBody TestExpressionRequest request) {
|
||||||
|
// Find a LIVE agent for this application, optionally filtered by environment
|
||||||
|
var candidates = registryService.findAll().stream()
|
||||||
|
.filter(a -> application.equals(a.applicationId()))
|
||||||
|
.filter(a -> a.state() == AgentState.LIVE);
|
||||||
|
if (environment != null) {
|
||||||
|
candidates = candidates.filter(a -> environment.equals(a.environmentId()));
|
||||||
|
}
|
||||||
|
AgentInfo agent = candidates.findFirst().orElse(null);
|
||||||
|
|
||||||
|
if (agent == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(new TestExpressionResponse(null, "No live agent available for application: " + application));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build payload JSON
|
||||||
|
String payloadJson;
|
||||||
|
try {
|
||||||
|
payloadJson = objectMapper.writeValueAsString(Map.of(
|
||||||
|
"expression", request.expression() != null ? request.expression() : "",
|
||||||
|
"language", request.language() != null ? request.language() : "",
|
||||||
|
"body", request.body() != null ? request.body() : "",
|
||||||
|
"target", request.target() != null ? request.target() : ""
|
||||||
|
));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Failed to serialize test-expression payload", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(new TestExpressionResponse(null, "Failed to serialize request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command and await reply
|
||||||
|
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
|
||||||
|
agent.instanceId(), CommandType.TEST_EXPRESSION, payloadJson);
|
||||||
|
|
||||||
|
try {
|
||||||
|
CommandReply reply = future.orTimeout(5, TimeUnit.SECONDS).join();
|
||||||
|
if ("SUCCESS".equals(reply.status())) {
|
||||||
|
return ResponseEntity.ok(new TestExpressionResponse(reply.data(), null));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.ok(new TestExpressionResponse(null, reply.message()));
|
||||||
|
}
|
||||||
|
} catch (CompletionException e) {
|
||||||
|
if (e.getCause() instanceof TimeoutException) {
|
||||||
|
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
|
||||||
|
.body(new TestExpressionResponse(null, "Agent did not respond within 5 seconds"));
|
||||||
|
}
|
||||||
|
log.error("Error awaiting test-expression reply from agent {}", agent.instanceId(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(new TestExpressionResponse(null, "Internal error: " + e.getCause().getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandGroupResponse pushConfigToAgents(String application, String environment, ApplicationConfig config) {
|
||||||
|
String payloadJson;
|
||||||
|
try {
|
||||||
|
payloadJson = objectMapper.writeValueAsString(config);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("Failed to serialize config for push", e);
|
||||||
|
return new CommandGroupResponse(false, 0, 0, List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, CompletableFuture<CommandReply>> futures =
|
||||||
|
registryService.addGroupCommandWithReplies(application, environment, CommandType.CONFIG_UPDATE, payloadJson);
|
||||||
|
|
||||||
|
if (futures.isEmpty()) {
|
||||||
|
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait with shared 10-second deadline
|
||||||
|
long deadline = System.currentTimeMillis() + 10_000;
|
||||||
|
List<CommandGroupResponse.AgentResponse> responses = new ArrayList<>();
|
||||||
|
List<String> timedOut = new ArrayList<>();
|
||||||
|
|
||||||
|
for (var entry : futures.entrySet()) {
|
||||||
|
long remaining = deadline - System.currentTimeMillis();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
timedOut.add(entry.getKey());
|
||||||
|
entry.getValue().cancel(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS);
|
||||||
|
responses.add(new CommandGroupResponse.AgentResponse(
|
||||||
|
entry.getKey(), reply.status(), reply.message()));
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
timedOut.add(entry.getKey());
|
||||||
|
entry.getValue().cancel(false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
responses.add(new CommandGroupResponse.AgentResponse(
|
||||||
|
entry.getKey(), "ERROR", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean allSuccess = timedOut.isEmpty() &&
|
||||||
|
responses.stream().allMatch(r -> "SUCCESS".equals(r.status()));
|
||||||
|
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationConfig defaultConfig(String application) {
|
||||||
|
ApplicationConfig config = new ApplicationConfig();
|
||||||
|
config.setApplication(application);
|
||||||
|
config.setVersion(0);
|
||||||
|
config.setMetricsEnabled(true);
|
||||||
|
config.setSamplingRate(1.0);
|
||||||
|
config.setTracedProcessors(Map.of());
|
||||||
|
config.setApplicationLogLevel("INFO");
|
||||||
|
config.setAgentLogLevel("INFO");
|
||||||
|
config.setEngineLevel("REGULAR");
|
||||||
|
config.setPayloadCaptureMode("NONE");
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ import com.cameleer3.server.core.admin.AuditCategory;
|
|||||||
import com.cameleer3.server.core.admin.AuditRepository;
|
import com.cameleer3.server.core.admin.AuditRepository;
|
||||||
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
|
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
|
||||||
import com.cameleer3.server.core.admin.AuditRepository.AuditQuery;
|
import com.cameleer3.server.core.admin.AuditRepository.AuditQuery;
|
||||||
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -16,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/audit")
|
@RequestMapping("/api/v1/admin/audit")
|
||||||
@@ -26,19 +27,22 @@ import java.time.ZoneOffset;
|
|||||||
public class AuditLogController {
|
public class AuditLogController {
|
||||||
|
|
||||||
private final AuditRepository auditRepository;
|
private final AuditRepository auditRepository;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public AuditLogController(AuditRepository auditRepository) {
|
public AuditLogController(AuditRepository auditRepository, AuditService auditService) {
|
||||||
this.auditRepository = auditRepository;
|
this.auditRepository = auditRepository;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Search audit log entries with pagination")
|
@Operation(summary = "Search audit log entries with pagination")
|
||||||
public ResponseEntity<AuditLogPageResponse> getAuditLog(
|
public ResponseEntity<AuditLogPageResponse> getAuditLog(
|
||||||
|
HttpServletRequest httpRequest,
|
||||||
@RequestParam(required = false) String username,
|
@RequestParam(required = false) String username,
|
||||||
@RequestParam(required = false) String category,
|
@RequestParam(required = false) String category,
|
||||||
@RequestParam(required = false) String search,
|
@RequestParam(required = false) String search,
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to,
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
|
||||||
@RequestParam(defaultValue = "timestamp") String sort,
|
@RequestParam(defaultValue = "timestamp") String sort,
|
||||||
@RequestParam(defaultValue = "desc") String order,
|
@RequestParam(defaultValue = "desc") String order,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@@ -46,8 +50,8 @@ public class AuditLogController {
|
|||||||
|
|
||||||
size = Math.min(size, 100);
|
size = Math.min(size, 100);
|
||||||
|
|
||||||
Instant fromInstant = from != null ? from.atStartOfDay(ZoneOffset.UTC).toInstant() : null;
|
Instant fromInstant = from != null ? from : Instant.now().minus(java.time.Duration.ofDays(7));
|
||||||
Instant toInstant = to != null ? to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : null;
|
Instant toInstant = to != null ? to : Instant.now();
|
||||||
|
|
||||||
AuditCategory cat = null;
|
AuditCategory cat = null;
|
||||||
if (category != null && !category.isEmpty()) {
|
if (category != null && !category.isEmpty()) {
|
||||||
@@ -58,6 +62,8 @@ public class AuditLogController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditService.log("view_audit_log", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
|
||||||
|
|
||||||
AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size);
|
AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size);
|
||||||
AuditPage result = auditRepository.find(query);
|
AuditPage result = auditRepository.find(query);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.AgentSummary;
|
||||||
|
import com.cameleer3.server.app.dto.CatalogApp;
|
||||||
|
import com.cameleer3.server.app.dto.RouteSummary;
|
||||||
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.agent.RouteStateRegistry;
|
||||||
|
import com.cameleer3.server.core.runtime.*;
|
||||||
|
import com.cameleer3.server.core.storage.DiagramStore;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified catalog endpoint that merges App records (PostgreSQL) with live agent data
|
||||||
|
* and ClickHouse stats. Replaces the separate RouteCatalogController.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/catalog")
|
||||||
|
@Tag(name = "Catalog", description = "Unified application catalog")
|
||||||
|
public class CatalogController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CatalogController.class);
|
||||||
|
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final DiagramStore diagramStore;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final RouteStateRegistry routeStateRegistry;
|
||||||
|
private final AppService appService;
|
||||||
|
private final EnvironmentService envService;
|
||||||
|
private final DeploymentRepository deploymentRepo;
|
||||||
|
|
||||||
|
public CatalogController(AgentRegistryService registryService,
|
||||||
|
DiagramStore diagramStore,
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
|
||||||
|
RouteStateRegistry routeStateRegistry,
|
||||||
|
AppService appService,
|
||||||
|
EnvironmentService envService,
|
||||||
|
DeploymentRepository deploymentRepo) {
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.diagramStore = diagramStore;
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.routeStateRegistry = routeStateRegistry;
|
||||||
|
this.appService = appService;
|
||||||
|
this.envService = envService;
|
||||||
|
this.deploymentRepo = deploymentRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Get unified catalog",
|
||||||
|
description = "Returns all applications (managed + unmanaged) with live agent data, routes, and deployment status")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||||
|
public ResponseEntity<List<CatalogApp>> getCatalog(
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to) {
|
||||||
|
|
||||||
|
// 1. Resolve environment
|
||||||
|
Environment env = null;
|
||||||
|
if (environment != null && !environment.isBlank()) {
|
||||||
|
try {
|
||||||
|
env = envService.getBySlug(environment);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.ok(List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get managed apps from PostgreSQL
|
||||||
|
List<App> managedApps = env != null
|
||||||
|
? appService.listByEnvironment(env.id())
|
||||||
|
: appService.listAll();
|
||||||
|
Map<String, App> appsBySlug = managedApps.stream()
|
||||||
|
.collect(Collectors.toMap(App::slug, a -> a, (a, b) -> a));
|
||||||
|
|
||||||
|
// 3. Get active deployments for managed apps
|
||||||
|
Map<UUID, Deployment> activeDeployments = new HashMap<>();
|
||||||
|
for (App app : managedApps) {
|
||||||
|
UUID envId = env != null ? env.id() : app.environmentId();
|
||||||
|
deploymentRepo.findActiveByAppIdAndEnvironmentId(app.id(), envId)
|
||||||
|
.ifPresent(d -> activeDeployments.put(app.id(), d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get agents, filter by environment
|
||||||
|
List<AgentInfo> allAgents = registryService.findAll();
|
||||||
|
if (environment != null && !environment.isBlank()) {
|
||||||
|
allAgents = allAgents.stream()
|
||||||
|
.filter(a -> environment.equals(a.environmentId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
|
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
// 5. Collect routes per app from agents
|
||||||
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
|
Set<String> routes = new LinkedHashSet<>();
|
||||||
|
for (AgentInfo agent : entry.getValue()) {
|
||||||
|
if (agent.routeIds() != null) routes.addAll(agent.routeIds());
|
||||||
|
}
|
||||||
|
routesByApp.put(entry.getKey(), routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. ClickHouse exchange counts
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
|
||||||
|
Instant rangeTo = to != null ? Instant.parse(to) : now;
|
||||||
|
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||||
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
String envFilter = (environment != null && !environment.isBlank())
|
||||||
|
? " AND environment = " + lit(environment) : "";
|
||||||
|
jdbc.query(
|
||||||
|
"SELECT application_id, route_id, countMerge(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||||
|
"FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
|
||||||
|
envFilter + " GROUP BY application_id, route_id",
|
||||||
|
rs -> {
|
||||||
|
String key = rs.getString("application_id") + "/" + rs.getString("route_id");
|
||||||
|
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||||
|
Timestamp ts = rs.getTimestamp("last_seen");
|
||||||
|
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query route exchange counts: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge ClickHouse routes into routesByApp
|
||||||
|
for (var countEntry : routeExchangeCounts.entrySet()) {
|
||||||
|
String[] parts = countEntry.getKey().split("/", 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
routesByApp.computeIfAbsent(parts[0], k -> new LinkedHashSet<>()).add(parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Build unified catalog
|
||||||
|
Set<String> allSlugs = new LinkedHashSet<>(appsBySlug.keySet());
|
||||||
|
allSlugs.addAll(agentsByApp.keySet());
|
||||||
|
allSlugs.addAll(routesByApp.keySet());
|
||||||
|
|
||||||
|
String envSlug = env != null ? env.slug() : "";
|
||||||
|
List<CatalogApp> catalog = new ArrayList<>();
|
||||||
|
|
||||||
|
for (String slug : allSlugs) {
|
||||||
|
App app = appsBySlug.get(slug);
|
||||||
|
List<AgentInfo> agents = agentsByApp.getOrDefault(slug, List.of());
|
||||||
|
Set<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
|
||||||
|
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||||
|
.map(routeId -> {
|
||||||
|
String key = slug + "/" + routeId;
|
||||||
|
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||||
|
Instant lastSeen = routeLastSeen.get(key);
|
||||||
|
String fromUri = resolveFromEndpointUri(routeId, agentIds);
|
||||||
|
String state = routeStateRegistry.getState(slug, routeId).name().toLowerCase();
|
||||||
|
String routeState = "started".equals(state) ? null : state;
|
||||||
|
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Agent summaries
|
||||||
|
List<AgentSummary> agentSummaries = agents.stream()
|
||||||
|
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Agent health
|
||||||
|
String agentHealth = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
|
||||||
|
|
||||||
|
// Total exchanges
|
||||||
|
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||||
|
|
||||||
|
// Deployment summary (managed apps only)
|
||||||
|
CatalogApp.DeploymentSummary deploymentSummary = null;
|
||||||
|
DeploymentStatus deployStatus = null;
|
||||||
|
if (app != null) {
|
||||||
|
Deployment dep = activeDeployments.get(app.id());
|
||||||
|
if (dep != null) {
|
||||||
|
deployStatus = dep.status();
|
||||||
|
int healthy = 0, total = 0;
|
||||||
|
if (dep.replicaStates() != null) {
|
||||||
|
total = dep.replicaStates().size();
|
||||||
|
healthy = (int) dep.replicaStates().stream()
|
||||||
|
.filter(r -> "RUNNING".equals(r.get("status")))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
int version = 0;
|
||||||
|
try {
|
||||||
|
var versions = appService.listVersions(app.id());
|
||||||
|
version = versions.stream()
|
||||||
|
.filter(v -> v.id().equals(dep.appVersionId()))
|
||||||
|
.map(AppVersion::version)
|
||||||
|
.findFirst().orElse(0);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
deploymentSummary = new CatalogApp.DeploymentSummary(
|
||||||
|
dep.status().name(),
|
||||||
|
healthy + "/" + total,
|
||||||
|
version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composite health + tooltip
|
||||||
|
String health = compositeHealth(app != null ? deployStatus : null, agentHealth);
|
||||||
|
String healthTooltip = buildHealthTooltip(app != null, deployStatus, agentHealth, agents.size());
|
||||||
|
|
||||||
|
String displayName = app != null ? app.displayName() : slug;
|
||||||
|
String appEnvSlug = envSlug;
|
||||||
|
if (app != null && appEnvSlug.isEmpty()) {
|
||||||
|
try {
|
||||||
|
appEnvSlug = envService.getById(app.environmentId()).slug();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog.add(new CatalogApp(
|
||||||
|
slug, displayName, app != null, appEnvSlug,
|
||||||
|
health, healthTooltip, agents.size(), routeSummaries, agentSummaries,
|
||||||
|
totalExchanges, deploymentSummary
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
|
||||||
|
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
|
||||||
|
.flatMap(diagramStore::findByContentHash)
|
||||||
|
.map(RouteGraph::getRoot)
|
||||||
|
.map(root -> root.getEndpointUri())
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lit(Instant instant) {
|
||||||
|
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
|
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lit(String value) {
|
||||||
|
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||||
|
boolean hasDead = false;
|
||||||
|
boolean hasStale = false;
|
||||||
|
for (AgentInfo a : agents) {
|
||||||
|
if (a.state() == AgentState.DEAD) hasDead = true;
|
||||||
|
if (a.state() == AgentState.STALE) hasStale = true;
|
||||||
|
}
|
||||||
|
if (hasDead) return "dead";
|
||||||
|
if (hasStale) return "stale";
|
||||||
|
return "live";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String compositeHealth(DeploymentStatus deployStatus, String agentHealth) {
|
||||||
|
if (deployStatus == null) return agentHealth; // unmanaged or no deployment
|
||||||
|
return switch (deployStatus) {
|
||||||
|
case STARTING -> "running";
|
||||||
|
case STOPPING, DEGRADED -> "stale";
|
||||||
|
case STOPPED -> "dead";
|
||||||
|
case FAILED -> "error";
|
||||||
|
case RUNNING -> "offline".equals(agentHealth) ? "stale" : agentHealth;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildHealthTooltip(boolean managed, DeploymentStatus deployStatus, String agentHealth, int agentCount) {
|
||||||
|
if (!managed) {
|
||||||
|
return "Agents: " + agentHealth + " (" + agentCount + " connected)";
|
||||||
|
}
|
||||||
|
if (deployStatus == null) {
|
||||||
|
return "No deployment";
|
||||||
|
}
|
||||||
|
String depPart = "Deployment: " + deployStatus.name();
|
||||||
|
if (deployStatus == DeploymentStatus.RUNNING || deployStatus == DeploymentStatus.DEGRADED) {
|
||||||
|
return depPart + ", Agents: " + agentHealth + " (" + agentCount + " connected)";
|
||||||
|
}
|
||||||
|
return depPart;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.ingestion.ChunkAccumulator;
|
||||||
|
import com.cameleer3.common.model.ExecutionChunk;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingestion endpoint for execution chunk data (ClickHouse pipeline).
|
||||||
|
* <p>
|
||||||
|
* Accepts single or array {@link ExecutionChunk} payloads and feeds them
|
||||||
|
* into the {@link ChunkAccumulator}. Only active when
|
||||||
|
* {@code clickhouse.enabled=true} (conditional on the accumulator bean).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/data")
|
||||||
|
@ConditionalOnBean(ChunkAccumulator.class)
|
||||||
|
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
||||||
|
public class ChunkIngestionController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ChunkIngestionController.class);
|
||||||
|
|
||||||
|
private final ChunkAccumulator accumulator;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ChunkIngestionController(ChunkAccumulator accumulator) {
|
||||||
|
this.accumulator = accumulator;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
this.objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/executions")
|
||||||
|
@Operation(summary = "Ingest execution chunk")
|
||||||
|
public ResponseEntity<Void> ingestChunks(@RequestBody String body) {
|
||||||
|
try {
|
||||||
|
String trimmed = body.strip();
|
||||||
|
List<ExecutionChunk> chunks;
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
chunks = objectMapper.readValue(trimmed, new TypeReference<List<ExecutionChunk>>() {});
|
||||||
|
} else {
|
||||||
|
ExecutionChunk single = objectMapper.readValue(trimmed, ExecutionChunk.class);
|
||||||
|
chunks = List.of(single);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ExecutionChunk chunk : chunks) {
|
||||||
|
accumulator.onChunk(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.accepted().build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse execution chunk payload: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
||||||
|
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/claim-mappings")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Tag(name = "Claim Mapping Admin", description = "Manage OIDC claim-to-role/group mapping rules")
|
||||||
|
public class ClaimMappingAdminController {
|
||||||
|
|
||||||
|
private final ClaimMappingRepository repository;
|
||||||
|
|
||||||
|
public ClaimMappingAdminController(ClaimMappingRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all claim mapping rules")
|
||||||
|
public List<ClaimMappingRule> list() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get a claim mapping rule by ID")
|
||||||
|
public ResponseEntity<ClaimMappingRule> get(@PathVariable UUID id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
record CreateRuleRequest(String claim, String matchType, String matchValue,
|
||||||
|
String action, String target, int priority) {}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a claim mapping rule")
|
||||||
|
public ResponseEntity<ClaimMappingRule> create(@RequestBody CreateRuleRequest request) {
|
||||||
|
UUID id = repository.create(
|
||||||
|
request.claim(), request.matchType(), request.matchValue(),
|
||||||
|
request.action(), request.target(), request.priority());
|
||||||
|
return repository.findById(id)
|
||||||
|
.map(rule -> ResponseEntity.created(URI.create("/api/v1/admin/claim-mappings/" + id)).body(rule))
|
||||||
|
.orElse(ResponseEntity.internalServerError().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Update a claim mapping rule")
|
||||||
|
public ResponseEntity<ClaimMappingRule> update(@PathVariable UUID id, @RequestBody CreateRuleRequest request) {
|
||||||
|
if (repository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
repository.update(id, request.claim(), request.matchType(), request.matchValue(),
|
||||||
|
request.action(), request.target(), request.priority());
|
||||||
|
return repository.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.internalServerError().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Delete a claim mapping rule")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
||||||
|
if (repository.findById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
repository.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.ClickHousePerformanceResponse;
|
||||||
|
import com.cameleer3.server.app.dto.ClickHouseQueryInfo;
|
||||||
|
import com.cameleer3.server.app.dto.ClickHouseStatusResponse;
|
||||||
|
import com.cameleer3.server.app.dto.ClickHouseTableInfo;
|
||||||
|
import com.cameleer3.server.app.dto.IndexerPipelineResponse;
|
||||||
|
import com.cameleer3.server.core.indexing.SearchIndexerStats;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/clickhouse")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Tag(name = "ClickHouse Admin", description = "ClickHouse monitoring and diagnostics (ADMIN only)")
|
||||||
|
public class ClickHouseAdminController {
|
||||||
|
|
||||||
|
private final JdbcTemplate clickHouseJdbc;
|
||||||
|
private final SearchIndexerStats indexerStats;
|
||||||
|
private final String clickHouseUrl;
|
||||||
|
|
||||||
|
public ClickHouseAdminController(
|
||||||
|
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc,
|
||||||
|
SearchIndexerStats indexerStats,
|
||||||
|
@Value("${clickhouse.url:}") String clickHouseUrl) {
|
||||||
|
this.clickHouseJdbc = clickHouseJdbc;
|
||||||
|
this.indexerStats = indexerStats;
|
||||||
|
this.clickHouseUrl = clickHouseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/status")
|
||||||
|
@Operation(summary = "ClickHouse cluster status")
|
||||||
|
public ClickHouseStatusResponse getStatus() {
|
||||||
|
try {
|
||||||
|
var row = clickHouseJdbc.queryForMap(
|
||||||
|
"SELECT version() AS version, formatReadableTimeDelta(uptime()) AS uptime");
|
||||||
|
return new ClickHouseStatusResponse(true,
|
||||||
|
(String) row.get("version"),
|
||||||
|
(String) row.get("uptime"),
|
||||||
|
clickHouseUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ClickHouseStatusResponse(false, null, null, clickHouseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tables")
|
||||||
|
@Operation(summary = "List ClickHouse tables with sizes")
|
||||||
|
public List<ClickHouseTableInfo> getTables() {
|
||||||
|
return clickHouseJdbc.query("""
|
||||||
|
SELECT t.name, t.engine,
|
||||||
|
t.total_rows AS row_count,
|
||||||
|
formatReadableSize(t.total_bytes) AS data_size,
|
||||||
|
t.total_bytes AS data_size_bytes,
|
||||||
|
ifNull(p.partition_count, 0) AS partition_count
|
||||||
|
FROM system.tables t
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT table, countDistinct(partition) AS partition_count
|
||||||
|
FROM system.parts
|
||||||
|
WHERE database = currentDatabase() AND active
|
||||||
|
GROUP BY table
|
||||||
|
) p ON t.name = p.table
|
||||||
|
WHERE t.database = currentDatabase()
|
||||||
|
ORDER BY t.total_bytes DESC NULLS LAST
|
||||||
|
""",
|
||||||
|
(rs, rowNum) -> new ClickHouseTableInfo(
|
||||||
|
rs.getString("name"),
|
||||||
|
rs.getString("engine"),
|
||||||
|
rs.getLong("row_count"),
|
||||||
|
rs.getString("data_size"),
|
||||||
|
rs.getLong("data_size_bytes"),
|
||||||
|
rs.getInt("partition_count")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/performance")
|
||||||
|
@Operation(summary = "ClickHouse storage and performance metrics")
|
||||||
|
public ClickHousePerformanceResponse getPerformance() {
|
||||||
|
try {
|
||||||
|
var row = clickHouseJdbc.queryForMap("""
|
||||||
|
SELECT
|
||||||
|
formatReadableSize(sum(bytes_on_disk)) AS disk_size,
|
||||||
|
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
|
||||||
|
if(sum(data_uncompressed_bytes) > 0,
|
||||||
|
round(sum(bytes_on_disk) / sum(data_uncompressed_bytes), 3), 0) AS compression_ratio,
|
||||||
|
sum(rows) AS total_rows,
|
||||||
|
count() AS part_count
|
||||||
|
FROM system.parts
|
||||||
|
WHERE database = currentDatabase() AND active
|
||||||
|
""");
|
||||||
|
|
||||||
|
String memory = "N/A";
|
||||||
|
try {
|
||||||
|
memory = clickHouseJdbc.queryForObject(
|
||||||
|
"SELECT formatReadableSize(value) FROM system.metrics WHERE metric = 'MemoryTracking'",
|
||||||
|
String.class);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
int currentQueries = 0;
|
||||||
|
try {
|
||||||
|
Integer q = clickHouseJdbc.queryForObject(
|
||||||
|
"SELECT toInt32(value) FROM system.metrics WHERE metric = 'Query'",
|
||||||
|
Integer.class);
|
||||||
|
if (q != null) currentQueries = q;
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
return new ClickHousePerformanceResponse(
|
||||||
|
(String) row.get("disk_size"),
|
||||||
|
(String) row.get("uncompressed_size"),
|
||||||
|
((Number) row.get("compression_ratio")).doubleValue(),
|
||||||
|
((Number) row.get("total_rows")).longValue(),
|
||||||
|
((Number) row.get("part_count")).intValue(),
|
||||||
|
memory != null ? memory : "N/A",
|
||||||
|
currentQueries);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ClickHousePerformanceResponse("N/A", "N/A", 0, 0, 0, "N/A", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/queries")
|
||||||
|
@Operation(summary = "Active ClickHouse queries")
|
||||||
|
public List<ClickHouseQueryInfo> getQueries() {
|
||||||
|
try {
|
||||||
|
return clickHouseJdbc.query("""
|
||||||
|
SELECT
|
||||||
|
query_id,
|
||||||
|
round(elapsed, 2) AS elapsed_seconds,
|
||||||
|
formatReadableSize(memory_usage) AS memory,
|
||||||
|
read_rows,
|
||||||
|
substring(query, 1, 200) AS query
|
||||||
|
FROM system.processes
|
||||||
|
WHERE is_initial_query = 1
|
||||||
|
AND query NOT LIKE '%system.processes%'
|
||||||
|
ORDER BY elapsed DESC
|
||||||
|
""",
|
||||||
|
(rs, rowNum) -> new ClickHouseQueryInfo(
|
||||||
|
rs.getString("query_id"),
|
||||||
|
rs.getDouble("elapsed_seconds"),
|
||||||
|
rs.getString("memory"),
|
||||||
|
rs.getLong("read_rows"),
|
||||||
|
rs.getString("query")));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pipeline")
|
||||||
|
@Operation(summary = "Search indexer pipeline statistics")
|
||||||
|
public IndexerPipelineResponse getPipeline() {
|
||||||
|
return new IndexerPipelineResponse(
|
||||||
|
indexerStats.getQueueDepth(),
|
||||||
|
indexerStats.getMaxQueueSize(),
|
||||||
|
indexerStats.getFailedCount(),
|
||||||
|
indexerStats.getIndexedCount(),
|
||||||
|
indexerStats.getDebounceMs(),
|
||||||
|
indexerStats.getIndexingRate(),
|
||||||
|
indexerStats.getLastIndexedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ public class DatabaseAdminController {
|
|||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource, AuditService auditService) {
|
public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource,
|
||||||
|
AuditService auditService) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.dataSource = dataSource;
|
this.dataSource = dataSource;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
@@ -47,13 +48,12 @@ public class DatabaseAdminController {
|
|||||||
public ResponseEntity<DatabaseStatusResponse> getStatus() {
|
public ResponseEntity<DatabaseStatusResponse> getStatus() {
|
||||||
try {
|
try {
|
||||||
String version = jdbc.queryForObject("SELECT version()", String.class);
|
String version = jdbc.queryForObject("SELECT version()", String.class);
|
||||||
boolean timescaleDb = Boolean.TRUE.equals(
|
|
||||||
jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')", Boolean.class));
|
|
||||||
String schema = jdbc.queryForObject("SELECT current_schema()", String.class);
|
String schema = jdbc.queryForObject("SELECT current_schema()", String.class);
|
||||||
String host = extractHost(dataSource);
|
String host = extractHost(dataSource);
|
||||||
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb));
|
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.ok(new DatabaseStatusResponse(false, null, null, null, false));
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
.body(new DatabaseStatusResponse(false, null, null, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.runtime.DeploymentExecutor;
|
||||||
|
import com.cameleer3.server.core.runtime.*;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deployment management: deploy, stop, promote, and view logs.
|
||||||
|
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
|
||||||
|
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/apps/{appSlug}/deployments")
|
||||||
|
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs")
|
||||||
|
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
||||||
|
public class DeploymentController {
|
||||||
|
|
||||||
|
private final DeploymentService deploymentService;
|
||||||
|
private final DeploymentExecutor deploymentExecutor;
|
||||||
|
private final RuntimeOrchestrator orchestrator;
|
||||||
|
private final AppService appService;
|
||||||
|
|
||||||
|
public DeploymentController(DeploymentService deploymentService,
|
||||||
|
DeploymentExecutor deploymentExecutor,
|
||||||
|
RuntimeOrchestrator orchestrator,
|
||||||
|
AppService appService) {
|
||||||
|
this.deploymentService = deploymentService;
|
||||||
|
this.deploymentExecutor = deploymentExecutor;
|
||||||
|
this.orchestrator = orchestrator;
|
||||||
|
this.appService = appService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List deployments for an app")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Deployment list returned")
|
||||||
|
public ResponseEntity<List<Deployment>> listDeployments(@PathVariable String appSlug) {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{deploymentId}")
|
||||||
|
@Operation(summary = "Get deployment by ID")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Deployment found")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||||
|
public ResponseEntity<Deployment> getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create and start a new deployment")
|
||||||
|
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
|
||||||
|
public ResponseEntity<Deployment> deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId());
|
||||||
|
deploymentExecutor.executeAsync(deployment);
|
||||||
|
return ResponseEntity.accepted().body(deployment);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{deploymentId}/stop")
|
||||||
|
@Operation(summary = "Stop a running deployment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Deployment stopped")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||||
|
public ResponseEntity<Deployment> stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||||
|
try {
|
||||||
|
Deployment deployment = deploymentService.getById(deploymentId);
|
||||||
|
deploymentExecutor.stopDeployment(deployment);
|
||||||
|
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{deploymentId}/promote")
|
||||||
|
@Operation(summary = "Promote deployment to a different environment")
|
||||||
|
@ApiResponse(responseCode = "202", description = "Promotion accepted and starting")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||||
|
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId,
|
||||||
|
@RequestBody PromoteRequest request) {
|
||||||
|
try {
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
Deployment source = deploymentService.getById(deploymentId);
|
||||||
|
Deployment promoted = deploymentService.promote(app.id(), source.appVersionId(), request.targetEnvironmentId());
|
||||||
|
deploymentExecutor.executeAsync(promoted);
|
||||||
|
return ResponseEntity.accepted().body(promoted);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{deploymentId}/logs")
|
||||||
|
@Operation(summary = "Get container logs for a deployment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Logs returned")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Deployment not found or no container")
|
||||||
|
public ResponseEntity<List<String>> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
|
||||||
|
try {
|
||||||
|
Deployment deployment = deploymentService.getById(deploymentId);
|
||||||
|
if (deployment.containerId() == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
List<String> logs = orchestrator.getLogs(deployment.containerId(), 200).collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(logs);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeployRequest(UUID appVersionId, UUID environmentId) {}
|
||||||
|
public record PromoteRequest(UUID targetEnvironmentId) {}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ public class DetailController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{executionId}/processors/{index}/snapshot")
|
@GetMapping("/{executionId}/processors/{index}/snapshot")
|
||||||
@Operation(summary = "Get exchange snapshot for a specific processor")
|
@Operation(summary = "Get exchange snapshot for a specific processor by index")
|
||||||
@ApiResponse(responseCode = "200", description = "Snapshot data")
|
@ApiResponse(responseCode = "200", description = "Snapshot data")
|
||||||
@ApiResponse(responseCode = "404", description = "Snapshot not found")
|
@ApiResponse(responseCode = "404", description = "Snapshot not found")
|
||||||
public ResponseEntity<Map<String, String>> getProcessorSnapshot(
|
public ResponseEntity<Map<String, String>> getProcessorSnapshot(
|
||||||
@@ -69,4 +69,28 @@ public class DetailController {
|
|||||||
|
|
||||||
return ResponseEntity.ok(snapshot);
|
return ResponseEntity.ok(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{executionId}/processors/by-id/{processorId}/snapshot")
|
||||||
|
@Operation(summary = "Get exchange snapshot for a specific processor by processorId")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Snapshot data")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Snapshot not found")
|
||||||
|
public ResponseEntity<Map<String, String>> processorSnapshotById(
|
||||||
|
@PathVariable String executionId,
|
||||||
|
@PathVariable String processorId) {
|
||||||
|
return detailService.getProcessorSnapshot(executionId, processorId)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{executionId}/processors/by-seq/{seq}/snapshot")
|
||||||
|
@Operation(summary = "Get exchange snapshot for a processor by seq number")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Snapshot data")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Snapshot not found")
|
||||||
|
public ResponseEntity<Map<String, String>> processorSnapshotBySeq(
|
||||||
|
@PathVariable String executionId,
|
||||||
|
@PathVariable int seq) {
|
||||||
|
return detailService.getProcessorSnapshotBySeq(executionId, seq)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.common.graph.RouteGraph;
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.ingestion.IngestionService;
|
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||||
import com.cameleer3.server.core.ingestion.TaggedDiagram;
|
import com.cameleer3.server.core.ingestion.TaggedDiagram;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
@@ -9,8 +11,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -32,13 +32,15 @@ import java.util.List;
|
|||||||
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
||||||
public class DiagramController {
|
public class DiagramController {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(DiagramController.class);
|
|
||||||
|
|
||||||
private final IngestionService ingestionService;
|
private final IngestionService ingestionService;
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public DiagramController(IngestionService ingestionService, ObjectMapper objectMapper) {
|
public DiagramController(IngestionService ingestionService,
|
||||||
|
AgentRegistryService registryService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.ingestionService = ingestionService;
|
this.ingestionService = ingestionService;
|
||||||
|
this.registryService = registryService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +49,12 @@ public class DiagramController {
|
|||||||
description = "Accepts a single RouteGraph or an array of RouteGraphs")
|
description = "Accepts a single RouteGraph or an array of RouteGraphs")
|
||||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||||
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
||||||
String agentId = extractAgentId();
|
String instanceId = extractAgentId();
|
||||||
|
String applicationId = resolveApplicationId(instanceId);
|
||||||
List<RouteGraph> graphs = parsePayload(body);
|
List<RouteGraph> graphs = parsePayload(body);
|
||||||
|
|
||||||
for (RouteGraph graph : graphs) {
|
for (RouteGraph graph : graphs) {
|
||||||
ingestionService.ingestDiagram(new TaggedDiagram(agentId, graph));
|
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
@@ -62,6 +65,11 @@ public class DiagramController {
|
|||||||
return auth != null ? auth.getName() : "";
|
return auth != null ? auth.getName() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveApplicationId(String instanceId) {
|
||||||
|
AgentInfo agent = registryService.findById(instanceId);
|
||||||
|
return agent != null ? agent.applicationId() : "";
|
||||||
|
}
|
||||||
|
|
||||||
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
|
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
|
||||||
String trimmed = body.strip();
|
String trimmed = body.strip();
|
||||||
if (trimmed.startsWith("[")) {
|
if (trimmed.startsWith("[")) {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class DiagramRenderController {
|
|||||||
@ApiResponse(responseCode = "404", description = "Diagram not found")
|
@ApiResponse(responseCode = "404", description = "Diagram not found")
|
||||||
public ResponseEntity<?> renderDiagram(
|
public ResponseEntity<?> renderDiagram(
|
||||||
@PathVariable String contentHash,
|
@PathVariable String contentHash,
|
||||||
|
@RequestParam(defaultValue = "LR") String direction,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
|
||||||
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash);
|
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash);
|
||||||
@@ -76,7 +77,7 @@ public class DiagramRenderController {
|
|||||||
// without also accepting everything (*/*). This means "application/json"
|
// without also accepting everything (*/*). This means "application/json"
|
||||||
// must appear and wildcards must not dominate the preference.
|
// must appear and wildcards must not dominate the preference.
|
||||||
if (accept != null && isJsonPreferred(accept)) {
|
if (accept != null && isJsonPreferred(accept)) {
|
||||||
DiagramLayout layout = diagramRenderer.layoutJson(graph);
|
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.body(layout);
|
.body(layout);
|
||||||
@@ -96,9 +97,10 @@ public class DiagramRenderController {
|
|||||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
||||||
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
||||||
@RequestParam String application,
|
@RequestParam String application,
|
||||||
@RequestParam String routeId) {
|
@RequestParam String routeId,
|
||||||
|
@RequestParam(defaultValue = "LR") String direction) {
|
||||||
List<String> agentIds = registryService.findByApplication(application).stream()
|
List<String> agentIds = registryService.findByApplication(application).stream()
|
||||||
.map(AgentInfo::id)
|
.map(AgentInfo::instanceId)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (agentIds.isEmpty()) {
|
if (agentIds.isEmpty()) {
|
||||||
@@ -115,7 +117,7 @@ public class DiagramRenderController {
|
|||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get());
|
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get(), direction);
|
||||||
return ResponseEntity.ok(layout);
|
return ResponseEntity.ok(layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.runtime.Environment;
|
||||||
|
import com.cameleer3.server.core.runtime.EnvironmentService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/environments")
|
||||||
|
@Tag(name = "Environment Admin", description = "Environment management (ADMIN only)")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public class EnvironmentAdminController {
|
||||||
|
|
||||||
|
private final EnvironmentService environmentService;
|
||||||
|
|
||||||
|
public EnvironmentAdminController(EnvironmentService environmentService) {
|
||||||
|
this.environmentService = environmentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List all environments")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ResponseEntity<List<Environment>> listEnvironments() {
|
||||||
|
return ResponseEntity.ok(environmentService.listAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get environment by ID")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Environment found")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
|
public ResponseEntity<Environment> getEnvironment(@PathVariable UUID id) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(environmentService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Create a new environment")
|
||||||
|
@ApiResponse(responseCode = "201", description = "Environment created")
|
||||||
|
@ApiResponse(responseCode = "400", description = "Slug already exists")
|
||||||
|
public ResponseEntity<?> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
|
||||||
|
try {
|
||||||
|
UUID id = environmentService.create(request.slug(), request.displayName(), request.production());
|
||||||
|
return ResponseEntity.status(201).body(environmentService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Operation(summary = "Update an environment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Environment updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
|
public ResponseEntity<?> updateEnvironment(@PathVariable UUID id, @RequestBody UpdateEnvironmentRequest request) {
|
||||||
|
try {
|
||||||
|
environmentService.update(id, request.displayName(), request.production(), request.enabled());
|
||||||
|
return ResponseEntity.ok(environmentService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
if (e.getMessage().contains("not found")) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "Delete an environment")
|
||||||
|
@ApiResponse(responseCode = "204", description = "Environment deleted")
|
||||||
|
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
|
public ResponseEntity<?> deleteEnvironment(@PathVariable UUID id) {
|
||||||
|
try {
|
||||||
|
environmentService.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
if (e.getMessage().contains("not found")) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/default-container-config")
|
||||||
|
@Operation(summary = "Update default container config for an environment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Default container config updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
|
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable UUID id,
|
||||||
|
@RequestBody Map<String, Object> defaultContainerConfig) {
|
||||||
|
try {
|
||||||
|
environmentService.updateDefaultContainerConfig(id, defaultContainerConfig);
|
||||||
|
return ResponseEntity.ok(environmentService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/jar-retention")
|
||||||
|
@Operation(summary = "Update JAR retention policy for an environment")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Retention policy updated")
|
||||||
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
|
public ResponseEntity<?> updateJarRetention(@PathVariable UUID id,
|
||||||
|
@RequestBody JarRetentionRequest request) {
|
||||||
|
try {
|
||||||
|
environmentService.updateJarRetentionCount(id, request.jarRetentionCount());
|
||||||
|
return ResponseEntity.ok(environmentService.getById(id));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
if (e.getMessage().contains("not found")) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
|
||||||
|
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
|
||||||
|
public record JarRetentionRequest(Integer jarRetentionCount) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.AgentEvent;
|
||||||
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.agent.RouteStateRegistry;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingestion endpoint for agent lifecycle events.
|
||||||
|
* <p>
|
||||||
|
* Agents emit events (AGENT_STARTED, AGENT_STOPPED, etc.) which are
|
||||||
|
* stored in the event log. AGENT_STOPPED triggers a graceful shutdown
|
||||||
|
* transition in the registry.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/data")
|
||||||
|
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
||||||
|
public class EventIngestionController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(EventIngestionController.class);
|
||||||
|
|
||||||
|
private final AgentEventService agentEventService;
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final RouteStateRegistry routeStateRegistry;
|
||||||
|
|
||||||
|
public EventIngestionController(AgentEventService agentEventService,
|
||||||
|
AgentRegistryService registryService,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
RouteStateRegistry routeStateRegistry) {
|
||||||
|
this.agentEventService = agentEventService;
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.routeStateRegistry = routeStateRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/events")
|
||||||
|
@Operation(summary = "Ingest agent events")
|
||||||
|
public ResponseEntity<Void> ingestEvents(@RequestBody String body) {
|
||||||
|
String instanceId = extractInstanceId();
|
||||||
|
|
||||||
|
List<AgentEvent> events;
|
||||||
|
try {
|
||||||
|
String trimmed = body.strip();
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
events = objectMapper.readValue(trimmed, new TypeReference<List<AgentEvent>>() {});
|
||||||
|
} else {
|
||||||
|
events = List.of(objectMapper.readValue(trimmed, AgentEvent.class));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse event payload: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentInfo agent = registryService.findById(instanceId);
|
||||||
|
String applicationId = agent != null ? agent.applicationId() : "";
|
||||||
|
|
||||||
|
for (AgentEvent event : events) {
|
||||||
|
agentEventService.recordEvent(instanceId, applicationId,
|
||||||
|
event.getEventType(),
|
||||||
|
event.getDetails() != null ? event.getDetails().toString() : null);
|
||||||
|
|
||||||
|
if ("AGENT_STOPPED".equals(event.getEventType())) {
|
||||||
|
log.info("Agent {} reported graceful shutdown", instanceId);
|
||||||
|
registryService.shutdown(instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("ROUTE_STATE_CHANGED".equals(event.getEventType())) {
|
||||||
|
Map<String, String> details = event.getDetails();
|
||||||
|
if (details != null) {
|
||||||
|
String routeId = details.get("routeId");
|
||||||
|
String newState = details.get("newState");
|
||||||
|
if (routeId != null && newState != null) {
|
||||||
|
RouteStateRegistry.RouteState state = parseRouteState(newState);
|
||||||
|
if (state != null) {
|
||||||
|
routeStateRegistry.setState(applicationId, routeId, state);
|
||||||
|
log.debug("Route state changed: {}/{} -> {} (reason: {})",
|
||||||
|
applicationId, routeId, newState, details.get("reason"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.accepted().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RouteStateRegistry.RouteState parseRouteState(String state) {
|
||||||
|
if (state == null) return null;
|
||||||
|
return switch (state) {
|
||||||
|
case "Started" -> RouteStateRegistry.RouteState.STARTED;
|
||||||
|
case "Stopped" -> RouteStateRegistry.RouteState.STOPPED;
|
||||||
|
case "Suspended" -> RouteStateRegistry.RouteState.SUSPENDED;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractInstanceId() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return auth != null ? auth.getName() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.cameleer3.server.app.controller;
|
|||||||
import com.cameleer3.common.model.RouteExecution;
|
import com.cameleer3.common.model.RouteExecution;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer3.server.core.ingestion.ChunkAccumulator;
|
||||||
import com.cameleer3.server.core.ingestion.IngestionService;
|
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
@@ -10,8 +11,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.slf4j.Logger;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -23,18 +23,20 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ingestion endpoint for route execution data.
|
* Legacy ingestion endpoint for route execution data (PostgreSQL path).
|
||||||
* <p>
|
* <p>
|
||||||
* Accepts both single {@link RouteExecution} and arrays. Data is written
|
* Accepts both single {@link RouteExecution} and arrays. Data is written
|
||||||
* synchronously to PostgreSQL via {@link IngestionService}.
|
* synchronously to PostgreSQL via {@link IngestionService}.
|
||||||
|
* <p>
|
||||||
|
* Only active when ClickHouse is disabled — when ClickHouse is enabled,
|
||||||
|
* {@link ChunkIngestionController} takes over the {@code /executions} mapping.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/data")
|
@RequestMapping("/api/v1/data")
|
||||||
|
@ConditionalOnMissingBean(ChunkAccumulator.class)
|
||||||
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
||||||
public class ExecutionController {
|
public class ExecutionController {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ExecutionController.class);
|
|
||||||
|
|
||||||
private final IngestionService ingestionService;
|
private final IngestionService ingestionService;
|
||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@@ -52,12 +54,12 @@ public class ExecutionController {
|
|||||||
description = "Accepts a single RouteExecution or an array of RouteExecutions")
|
description = "Accepts a single RouteExecution or an array of RouteExecutions")
|
||||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||||
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
||||||
String agentId = extractAgentId();
|
String instanceId = extractAgentId();
|
||||||
String applicationName = resolveApplicationName(agentId);
|
String applicationId = resolveApplicationId(instanceId);
|
||||||
List<RouteExecution> executions = parsePayload(body);
|
List<RouteExecution> executions = parsePayload(body);
|
||||||
|
|
||||||
for (RouteExecution execution : executions) {
|
for (RouteExecution execution : executions) {
|
||||||
ingestionService.ingestExecution(agentId, applicationName, execution);
|
ingestionService.ingestExecution(instanceId, applicationId, execution);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.accepted().build();
|
return ResponseEntity.accepted().build();
|
||||||
@@ -68,9 +70,9 @@ public class ExecutionController {
|
|||||||
return auth != null ? auth.getName() : "";
|
return auth != null ? auth.getName() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveApplicationName(String agentId) {
|
private String resolveApplicationId(String instanceId) {
|
||||||
AgentInfo agent = registryService.findById(agentId);
|
AgentInfo agent = registryService.findById(instanceId);
|
||||||
return agent != null ? agent.application() : "";
|
return agent != null ? agent.applicationId() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import com.cameleer3.server.core.rbac.GroupDetail;
|
|||||||
import com.cameleer3.server.core.rbac.GroupRepository;
|
import com.cameleer3.server.core.rbac.GroupRepository;
|
||||||
import com.cameleer3.server.core.rbac.GroupSummary;
|
import com.cameleer3.server.core.rbac.GroupSummary;
|
||||||
import com.cameleer3.server.core.rbac.RbacService;
|
import com.cameleer3.server.core.rbac.RbacService;
|
||||||
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.PutMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -39,14 +42,14 @@ import java.util.UUID;
|
|||||||
public class GroupAdminController {
|
public class GroupAdminController {
|
||||||
|
|
||||||
private final GroupRepository groupRepository;
|
private final GroupRepository groupRepository;
|
||||||
private final RbacService rbacService;
|
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final RbacService rbacService;
|
||||||
|
|
||||||
public GroupAdminController(GroupRepository groupRepository, RbacService rbacService,
|
public GroupAdminController(GroupRepository groupRepository, AuditService auditService,
|
||||||
AuditService auditService) {
|
RbacService rbacService) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.rbacService = rbacService;
|
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.rbacService = rbacService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -156,6 +159,10 @@ public class GroupAdminController {
|
|||||||
if (groupRepository.findById(id).isEmpty()) {
|
if (groupRepository.findById(id).isEmpty()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
if (SystemRole.ADMIN_ID.equals(roleId) && rbacService.getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||||
|
"Cannot remove the ADMIN role: at least one admin user must exist");
|
||||||
|
}
|
||||||
groupRepository.removeRole(id, roleId);
|
groupRepository.removeRole(id, roleId);
|
||||||
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
|
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
|
||||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.license.LicenseGate;
|
||||||
|
import com.cameleer3.server.core.license.LicenseInfo;
|
||||||
|
import com.cameleer3.server.core.license.LicenseValidator;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/license")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@Tag(name = "License Admin", description = "License management")
|
||||||
|
public class LicenseAdminController {
|
||||||
|
|
||||||
|
private final LicenseGate licenseGate;
|
||||||
|
private final String licensePublicKey;
|
||||||
|
|
||||||
|
public LicenseAdminController(LicenseGate licenseGate,
|
||||||
|
@Value("${license.public-key:}") String licensePublicKey) {
|
||||||
|
this.licenseGate = licenseGate;
|
||||||
|
this.licensePublicKey = licensePublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Get current license info")
|
||||||
|
public ResponseEntity<LicenseInfo> getCurrent() {
|
||||||
|
return ResponseEntity.ok(licenseGate.getCurrent());
|
||||||
|
}
|
||||||
|
|
||||||
|
record UpdateLicenseRequest(String token) {}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Operation(summary = "Update license token at runtime")
|
||||||
|
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
|
||||||
|
if (licensePublicKey == null || licensePublicKey.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LicenseValidator validator = new LicenseValidator(licensePublicKey);
|
||||||
|
LicenseInfo info = validator.validate(request.token());
|
||||||
|
licenseGate.load(info);
|
||||||
|
return ResponseEntity.ok(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.common.model.LogBatch;
|
||||||
|
import com.cameleer3.server.core.ingestion.BufferedLogEntry;
|
||||||
|
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||||
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import com.cameleer3.server.app.config.TenantProperties;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/data")
|
||||||
|
@Tag(name = "Ingestion", description = "Data ingestion endpoints")
|
||||||
|
public class LogIngestionController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LogIngestionController.class);
|
||||||
|
|
||||||
|
private final WriteBuffer<BufferedLogEntry> logBuffer;
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
private final TenantProperties tenantProperties;
|
||||||
|
|
||||||
|
public LogIngestionController(WriteBuffer<BufferedLogEntry> logBuffer,
|
||||||
|
AgentRegistryService registryService,
|
||||||
|
TenantProperties tenantProperties) {
|
||||||
|
this.logBuffer = logBuffer;
|
||||||
|
this.registryService = registryService;
|
||||||
|
this.tenantProperties = tenantProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logs")
|
||||||
|
@Operation(summary = "Ingest application log entries",
|
||||||
|
description = "Accepts a batch of log entries from an agent. Entries are buffered and flushed periodically.")
|
||||||
|
@ApiResponse(responseCode = "202", description = "Logs accepted for indexing")
|
||||||
|
public ResponseEntity<Void> ingestLogs(@RequestBody LogBatch batch) {
|
||||||
|
String instanceId = extractAgentId();
|
||||||
|
String applicationId = resolveApplicationId(instanceId);
|
||||||
|
|
||||||
|
if (batch.getEntries() != null && !batch.getEntries().isEmpty()) {
|
||||||
|
log.debug("Received {} log entries from instance={}, app={}", batch.getEntries().size(), instanceId, applicationId);
|
||||||
|
String environment = resolveEnvironment(instanceId);
|
||||||
|
for (var entry : batch.getEntries()) {
|
||||||
|
logBuffer.offerOrWarn(new BufferedLogEntry(
|
||||||
|
tenantProperties.getId(), environment, instanceId, applicationId, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.accepted().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractAgentId() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return auth != null ? auth.getName() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveApplicationId(String instanceId) {
|
||||||
|
AgentInfo agent = registryService.findById(instanceId);
|
||||||
|
return agent != null ? agent.applicationId() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveEnvironment(String instanceId) {
|
||||||
|
AgentInfo agent = registryService.findById(instanceId);
|
||||||
|
return agent != null && agent.environmentId() != null ? agent.environmentId() : "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||||
|
import com.cameleer3.server.app.dto.LogSearchPageResponse;
|
||||||
|
import com.cameleer3.server.core.search.LogSearchRequest;
|
||||||
|
import com.cameleer3.server.core.search.LogSearchResponse;
|
||||||
|
import com.cameleer3.server.core.storage.LogIndex;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/logs")
|
||||||
|
@Tag(name = "Application Logs", description = "Query application logs")
|
||||||
|
public class LogQueryController {
|
||||||
|
|
||||||
|
private final LogIndex logIndex;
|
||||||
|
|
||||||
|
public LogQueryController(LogIndex logIndex) {
|
||||||
|
this.logIndex = logIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Search application log entries",
|
||||||
|
description = "Returns log entries with cursor-based pagination and level count aggregation. " +
|
||||||
|
"Supports free-text search, multi-level filtering, and optional application scoping.")
|
||||||
|
public ResponseEntity<LogSearchPageResponse> searchLogs(
|
||||||
|
@RequestParam(required = false) String q,
|
||||||
|
@RequestParam(required = false) String query,
|
||||||
|
@RequestParam(required = false) String level,
|
||||||
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||||
|
@RequestParam(required = false) String exchangeId,
|
||||||
|
@RequestParam(required = false) String logger,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(required = false) String cursor,
|
||||||
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
|
@RequestParam(defaultValue = "desc") String sort) {
|
||||||
|
|
||||||
|
// q takes precedence over deprecated query param
|
||||||
|
String searchText = q != null ? q : query;
|
||||||
|
|
||||||
|
// Parse CSV levels
|
||||||
|
List<String> levels = List.of();
|
||||||
|
if (level != null && !level.isEmpty()) {
|
||||||
|
levels = Arrays.stream(level.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
|
LogSearchRequest request = new LogSearchRequest(
|
||||||
|
searchText, levels, application, instanceId, exchangeId,
|
||||||
|
logger, environment, fromInstant, toInstant, cursor, limit, sort);
|
||||||
|
|
||||||
|
LogSearchResponse result = logIndex.search(request);
|
||||||
|
|
||||||
|
List<LogEntryResponse> entries = result.data().stream()
|
||||||
|
.map(r -> new LogEntryResponse(
|
||||||
|
r.timestamp(), r.level(), r.loggerName(),
|
||||||
|
r.message(), r.threadName(), r.stackTrace(),
|
||||||
|
r.exchangeId(), r.instanceId(), r.application(),
|
||||||
|
r.mdc()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new LogSearchPageResponse(
|
||||||
|
entries, result.nextCursor(), result.hasMore(), result.levelCounts()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,13 +44,23 @@ public class MetricsController {
|
|||||||
@Operation(summary = "Ingest agent metrics",
|
@Operation(summary = "Ingest agent metrics",
|
||||||
description = "Accepts an array of MetricsSnapshot objects")
|
description = "Accepts an array of MetricsSnapshot objects")
|
||||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid payload")
|
||||||
@ApiResponse(responseCode = "503", description = "Buffer full, retry later")
|
@ApiResponse(responseCode = "503", description = "Buffer full, retry later")
|
||||||
public ResponseEntity<Void> ingestMetrics(@RequestBody String body) throws JsonProcessingException {
|
public ResponseEntity<Void> ingestMetrics(@RequestBody String body) {
|
||||||
List<MetricsSnapshot> metrics = parsePayload(body);
|
List<MetricsSnapshot> metrics;
|
||||||
boolean accepted = ingestionService.acceptMetrics(metrics);
|
try {
|
||||||
|
metrics = parsePayload(body);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("Failed to parse metrics payload: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Received {} metric(s) from agent(s)", metrics.size());
|
||||||
|
|
||||||
|
boolean accepted = ingestionService.acceptMetrics(metrics);
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
log.warn("Metrics buffer full, returning 503");
|
log.warn("Metrics buffer full ({} items), returning 503",
|
||||||
|
ingestionService.getMetricsBufferDepth());
|
||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
.header("Retry-After", "5")
|
.header("Retry-After", "5")
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ public class OidcConfigAdminController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Get OIDC configuration")
|
@Operation(summary = "Get OIDC configuration")
|
||||||
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
|
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
|
||||||
public ResponseEntity<OidcAdminConfigResponse> getConfig() {
|
public ResponseEntity<OidcAdminConfigResponse> getConfig(HttpServletRequest httpRequest) {
|
||||||
|
auditService.log("view_oidc_config", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
|
||||||
Optional<OidcConfig> config = configRepository.find();
|
Optional<OidcConfig> config = configRepository.find();
|
||||||
if (config.isEmpty()) {
|
if (config.isEmpty()) {
|
||||||
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
|
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
|
||||||
@@ -97,10 +98,13 @@ public class OidcConfigAdminController {
|
|||||||
request.issuerUri() != null ? request.issuerUri() : "",
|
request.issuerUri() != null ? request.issuerUri() : "",
|
||||||
request.clientId() != null ? request.clientId() : "",
|
request.clientId() != null ? request.clientId() : "",
|
||||||
clientSecret,
|
clientSecret,
|
||||||
request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles",
|
request.rolesClaim() != null ? request.rolesClaim() : "roles",
|
||||||
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
|
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
|
||||||
request.autoSignup(),
|
request.autoSignup(),
|
||||||
request.displayNameClaim() != null ? request.displayNameClaim() : "name"
|
request.displayNameClaim() != null ? request.displayNameClaim() : "name",
|
||||||
|
request.userIdClaim() != null ? request.userIdClaim() : "sub",
|
||||||
|
request.audience() != null ? request.audience() : "",
|
||||||
|
request.additionalScopes() != null ? request.additionalScopes() : List.of()
|
||||||
);
|
);
|
||||||
|
|
||||||
configRepository.save(config);
|
configRepository.save(config);
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
|
||||||
|
|
||||||
import com.cameleer3.server.app.dto.IndexInfoResponse;
|
|
||||||
import com.cameleer3.server.app.dto.IndicesPageResponse;
|
|
||||||
import com.cameleer3.server.app.dto.OpenSearchStatusResponse;
|
|
||||||
import com.cameleer3.server.app.dto.PerformanceResponse;
|
|
||||||
import com.cameleer3.server.app.dto.PipelineStatsResponse;
|
|
||||||
import com.cameleer3.server.core.admin.AuditCategory;
|
|
||||||
import com.cameleer3.server.core.admin.AuditResult;
|
|
||||||
import com.cameleer3.server.core.admin.AuditService;
|
|
||||||
import com.cameleer3.server.core.indexing.SearchIndexerStats;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import org.opensearch.client.Request;
|
|
||||||
import org.opensearch.client.Response;
|
|
||||||
import org.opensearch.client.RestClient;
|
|
||||||
import org.opensearch.client.opensearch.OpenSearchClient;
|
|
||||||
import org.opensearch.client.opensearch.cluster.HealthResponse;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/v1/admin/opensearch")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@Tag(name = "OpenSearch Admin", description = "OpenSearch monitoring and management (ADMIN only)")
|
|
||||||
public class OpenSearchAdminController {
|
|
||||||
|
|
||||||
private final OpenSearchClient client;
|
|
||||||
private final RestClient restClient;
|
|
||||||
private final SearchIndexerStats indexerStats;
|
|
||||||
private final AuditService auditService;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final String opensearchUrl;
|
|
||||||
private final String indexPrefix;
|
|
||||||
|
|
||||||
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
|
|
||||||
SearchIndexerStats indexerStats, AuditService auditService,
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
@Value("${opensearch.url:http://localhost:9200}") String opensearchUrl,
|
|
||||||
@Value("${opensearch.index-prefix:executions-}") String indexPrefix) {
|
|
||||||
this.client = client;
|
|
||||||
this.restClient = restClient;
|
|
||||||
this.indexerStats = indexerStats;
|
|
||||||
this.auditService = auditService;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.opensearchUrl = opensearchUrl;
|
|
||||||
this.indexPrefix = indexPrefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/status")
|
|
||||||
@Operation(summary = "Get OpenSearch cluster status and version")
|
|
||||||
public ResponseEntity<OpenSearchStatusResponse> getStatus() {
|
|
||||||
try {
|
|
||||||
HealthResponse health = client.cluster().health();
|
|
||||||
String version = client.info().version().number();
|
|
||||||
return ResponseEntity.ok(new OpenSearchStatusResponse(
|
|
||||||
true,
|
|
||||||
health.status().name(),
|
|
||||||
version,
|
|
||||||
health.numberOfNodes(),
|
|
||||||
opensearchUrl));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.ok(new OpenSearchStatusResponse(
|
|
||||||
false, "UNREACHABLE", null, 0, opensearchUrl));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/pipeline")
|
|
||||||
@Operation(summary = "Get indexing pipeline statistics")
|
|
||||||
public ResponseEntity<PipelineStatsResponse> getPipeline() {
|
|
||||||
return ResponseEntity.ok(new PipelineStatsResponse(
|
|
||||||
indexerStats.getQueueDepth(),
|
|
||||||
indexerStats.getMaxQueueSize(),
|
|
||||||
indexerStats.getFailedCount(),
|
|
||||||
indexerStats.getIndexedCount(),
|
|
||||||
indexerStats.getDebounceMs(),
|
|
||||||
indexerStats.getIndexingRate(),
|
|
||||||
indexerStats.getLastIndexedAt()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/indices")
|
|
||||||
@Operation(summary = "Get OpenSearch indices with pagination")
|
|
||||||
public ResponseEntity<IndicesPageResponse> getIndices(
|
|
||||||
@RequestParam(defaultValue = "0") int page,
|
|
||||||
@RequestParam(defaultValue = "20") int size,
|
|
||||||
@RequestParam(defaultValue = "") String search) {
|
|
||||||
try {
|
|
||||||
Response response = restClient.performRequest(
|
|
||||||
new Request("GET", "/_cat/indices?format=json&h=index,health,docs.count,store.size,pri,rep&bytes=b"));
|
|
||||||
JsonNode indices;
|
|
||||||
try (InputStream is = response.getEntity().getContent()) {
|
|
||||||
indices = objectMapper.readTree(is);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<IndexInfoResponse> allIndices = new ArrayList<>();
|
|
||||||
for (JsonNode idx : indices) {
|
|
||||||
String name = idx.path("index").asText("");
|
|
||||||
if (!name.startsWith(indexPrefix)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!search.isEmpty() && !name.contains(search)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
allIndices.add(new IndexInfoResponse(
|
|
||||||
name,
|
|
||||||
parseLong(idx.path("docs.count").asText("0")),
|
|
||||||
humanSize(parseLong(idx.path("store.size").asText("0"))),
|
|
||||||
parseLong(idx.path("store.size").asText("0")),
|
|
||||||
idx.path("health").asText("unknown"),
|
|
||||||
parseInt(idx.path("pri").asText("0")),
|
|
||||||
parseInt(idx.path("rep").asText("0"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
allIndices.sort(Comparator.comparing(IndexInfoResponse::name));
|
|
||||||
|
|
||||||
long totalDocs = allIndices.stream().mapToLong(IndexInfoResponse::docCount).sum();
|
|
||||||
long totalBytes = allIndices.stream().mapToLong(IndexInfoResponse::sizeBytes).sum();
|
|
||||||
int totalIndices = allIndices.size();
|
|
||||||
int totalPages = Math.max(1, (int) Math.ceil((double) totalIndices / size));
|
|
||||||
|
|
||||||
int fromIndex = Math.min(page * size, totalIndices);
|
|
||||||
int toIndex = Math.min(fromIndex + size, totalIndices);
|
|
||||||
List<IndexInfoResponse> pageItems = allIndices.subList(fromIndex, toIndex);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new IndicesPageResponse(
|
|
||||||
pageItems, totalIndices, totalDocs,
|
|
||||||
humanSize(totalBytes), page, size, totalPages));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.ok(new IndicesPageResponse(
|
|
||||||
List.of(), 0, 0, "0 B", page, size, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/indices/{name}")
|
|
||||||
@Operation(summary = "Delete an OpenSearch index")
|
|
||||||
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
|
|
||||||
try {
|
|
||||||
if (!name.startsWith(indexPrefix)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete index outside application scope");
|
|
||||||
}
|
|
||||||
boolean exists = client.indices().exists(r -> r.index(name)).value();
|
|
||||||
if (!exists) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name);
|
|
||||||
}
|
|
||||||
client.indices().delete(r -> r.index(name));
|
|
||||||
auditService.log("delete_index", AuditCategory.INFRA, name, null, AuditResult.SUCCESS, request);
|
|
||||||
return ResponseEntity.ok().build();
|
|
||||||
} catch (ResponseStatusException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete index: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/performance")
|
|
||||||
@Operation(summary = "Get OpenSearch performance metrics")
|
|
||||||
public ResponseEntity<PerformanceResponse> getPerformance() {
|
|
||||||
try {
|
|
||||||
Response response = restClient.performRequest(
|
|
||||||
new Request("GET", "/_nodes/stats/jvm,indices"));
|
|
||||||
JsonNode root;
|
|
||||||
try (InputStream is = response.getEntity().getContent()) {
|
|
||||||
root = objectMapper.readTree(is);
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonNode nodes = root.path("nodes");
|
|
||||||
long heapUsed = 0, heapMax = 0;
|
|
||||||
long queryCacheHits = 0, queryCacheMisses = 0;
|
|
||||||
long requestCacheHits = 0, requestCacheMisses = 0;
|
|
||||||
long searchQueryTotal = 0, searchQueryTimeMs = 0;
|
|
||||||
long indexTotal = 0, indexTimeMs = 0;
|
|
||||||
|
|
||||||
var it = nodes.fields();
|
|
||||||
while (it.hasNext()) {
|
|
||||||
var entry = it.next();
|
|
||||||
JsonNode node = entry.getValue();
|
|
||||||
|
|
||||||
JsonNode jvm = node.path("jvm").path("mem");
|
|
||||||
heapUsed += jvm.path("heap_used_in_bytes").asLong(0);
|
|
||||||
heapMax += jvm.path("heap_max_in_bytes").asLong(0);
|
|
||||||
|
|
||||||
JsonNode indicesNode = node.path("indices");
|
|
||||||
JsonNode queryCache = indicesNode.path("query_cache");
|
|
||||||
queryCacheHits += queryCache.path("hit_count").asLong(0);
|
|
||||||
queryCacheMisses += queryCache.path("miss_count").asLong(0);
|
|
||||||
|
|
||||||
JsonNode requestCache = indicesNode.path("request_cache");
|
|
||||||
requestCacheHits += requestCache.path("hit_count").asLong(0);
|
|
||||||
requestCacheMisses += requestCache.path("miss_count").asLong(0);
|
|
||||||
|
|
||||||
JsonNode searchNode = indicesNode.path("search");
|
|
||||||
searchQueryTotal += searchNode.path("query_total").asLong(0);
|
|
||||||
searchQueryTimeMs += searchNode.path("query_time_in_millis").asLong(0);
|
|
||||||
|
|
||||||
JsonNode indexing = indicesNode.path("indexing");
|
|
||||||
indexTotal += indexing.path("index_total").asLong(0);
|
|
||||||
indexTimeMs += indexing.path("index_time_in_millis").asLong(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
double queryCacheHitRate = (queryCacheHits + queryCacheMisses) > 0
|
|
||||||
? (double) queryCacheHits / (queryCacheHits + queryCacheMisses) : 0.0;
|
|
||||||
double requestCacheHitRate = (requestCacheHits + requestCacheMisses) > 0
|
|
||||||
? (double) requestCacheHits / (requestCacheHits + requestCacheMisses) : 0.0;
|
|
||||||
double searchLatency = searchQueryTotal > 0
|
|
||||||
? (double) searchQueryTimeMs / searchQueryTotal : 0.0;
|
|
||||||
double indexingLatency = indexTotal > 0
|
|
||||||
? (double) indexTimeMs / indexTotal : 0.0;
|
|
||||||
|
|
||||||
return ResponseEntity.ok(new PerformanceResponse(
|
|
||||||
queryCacheHitRate, requestCacheHitRate,
|
|
||||||
searchLatency, indexingLatency,
|
|
||||||
heapUsed, heapMax));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return ResponseEntity.ok(new PerformanceResponse(0, 0, 0, 0, 0, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long parseLong(String s) {
|
|
||||||
try {
|
|
||||||
return Long.parseLong(s);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int parseInt(String s) {
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(s);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String humanSize(long bytes) {
|
|
||||||
if (bytes < 1024) return bytes + " B";
|
|
||||||
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
|
|
||||||
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package com.cameleer3.server.app.controller;
|
|||||||
import com.cameleer3.server.core.admin.AuditCategory;
|
import com.cameleer3.server.core.admin.AuditCategory;
|
||||||
import com.cameleer3.server.core.admin.AuditResult;
|
import com.cameleer3.server.core.admin.AuditResult;
|
||||||
import com.cameleer3.server.core.admin.AuditService;
|
import com.cameleer3.server.core.admin.AuditService;
|
||||||
import com.cameleer3.server.core.rbac.RbacService;
|
|
||||||
import com.cameleer3.server.core.rbac.RoleDetail;
|
import com.cameleer3.server.core.rbac.RoleDetail;
|
||||||
import com.cameleer3.server.core.rbac.RoleRepository;
|
import com.cameleer3.server.core.rbac.RoleRepository;
|
||||||
import com.cameleer3.server.core.rbac.SystemRole;
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
@@ -37,13 +36,10 @@ import java.util.UUID;
|
|||||||
public class RoleAdminController {
|
public class RoleAdminController {
|
||||||
|
|
||||||
private final RoleRepository roleRepository;
|
private final RoleRepository roleRepository;
|
||||||
private final RbacService rbacService;
|
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
public RoleAdminController(RoleRepository roleRepository, RbacService rbacService,
|
public RoleAdminController(RoleRepository roleRepository, AuditService auditService) {
|
||||||
AuditService auditService) {
|
|
||||||
this.roleRepository = roleRepository;
|
this.roleRepository = roleRepository;
|
||||||
this.rbacService = rbacService;
|
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package com.cameleer3.server.app.controller;
|
|||||||
import com.cameleer3.server.app.dto.AgentSummary;
|
import com.cameleer3.server.app.dto.AgentSummary;
|
||||||
import com.cameleer3.server.app.dto.AppCatalogEntry;
|
import com.cameleer3.server.app.dto.AppCatalogEntry;
|
||||||
import com.cameleer3.server.app.dto.RouteSummary;
|
import com.cameleer3.server.app.dto.RouteSummary;
|
||||||
|
import com.cameleer3.common.graph.RouteGraph;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.agent.AgentState;
|
import com.cameleer3.server.core.agent.AgentState;
|
||||||
|
import com.cameleer3.server.core.agent.RouteStateRegistry;
|
||||||
|
import com.cameleer3.server.core.storage.DiagramStore;
|
||||||
import com.cameleer3.server.core.storage.StatsStore;
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
@@ -14,6 +17,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
@@ -32,24 +36,43 @@ import java.util.stream.Collectors;
|
|||||||
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
||||||
public class RouteCatalogController {
|
public class RouteCatalogController {
|
||||||
|
|
||||||
private final AgentRegistryService registryService;
|
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RouteCatalogController.class);
|
||||||
private final JdbcTemplate jdbc;
|
|
||||||
|
|
||||||
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
|
private final AgentRegistryService registryService;
|
||||||
|
private final DiagramStore diagramStore;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final RouteStateRegistry routeStateRegistry;
|
||||||
|
|
||||||
|
public RouteCatalogController(AgentRegistryService registryService,
|
||||||
|
DiagramStore diagramStore,
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
|
||||||
|
RouteStateRegistry routeStateRegistry) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
|
this.diagramStore = diagramStore;
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
this.routeStateRegistry = routeStateRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/catalog")
|
@GetMapping("/catalog")
|
||||||
@Operation(summary = "Get route catalog",
|
@Operation(summary = "Get route catalog",
|
||||||
description = "Returns all applications with their routes, agents, and health status")
|
description = "Returns all applications with their routes, agents, and health status")
|
||||||
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||||
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
public ResponseEntity<List<AppCatalogEntry>> getCatalog(
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
List<AgentInfo> allAgents = registryService.findAll();
|
List<AgentInfo> allAgents = registryService.findAll();
|
||||||
|
|
||||||
|
// Filter agents by environment if specified
|
||||||
|
if (environment != null && !environment.isBlank()) {
|
||||||
|
allAgents = allAgents.stream()
|
||||||
|
.filter(a -> environment.equals(a.environmentId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Group agents by application name
|
// Group agents by application name
|
||||||
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
.collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
|
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
// Collect all distinct routes per app
|
// Collect all distinct routes per app
|
||||||
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
@@ -63,65 +86,69 @@ public class RouteCatalogController {
|
|||||||
routesByApp.put(entry.getKey(), routes);
|
routesByApp.put(entry.getKey(), routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query route-level stats for the last 24 hours
|
// Time range for exchange counts — use provided range or default to last 24h
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
Instant from24h = now.minus(24, ChronoUnit.HOURS);
|
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
|
||||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
Instant rangeTo = to != null ? Instant.parse(to) : now;
|
||||||
|
// Route exchange counts from AggregatingMergeTree (literal SQL — ClickHouse JDBC driver
|
||||||
// Route exchange counts from continuous aggregate
|
// wraps prepared statements in sub-queries that strip AggregateFunction column types)
|
||||||
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||||
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
try {
|
try {
|
||||||
|
String envFilter = (environment != null && !environment.isBlank())
|
||||||
|
? " AND environment = " + lit(environment) : "";
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
"SELECT application_id, route_id, countMerge(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
"FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
|
||||||
"GROUP BY application_name, route_id",
|
envFilter +
|
||||||
|
" GROUP BY application_id, route_id",
|
||||||
rs -> {
|
rs -> {
|
||||||
String key = rs.getString("application_name") + "/" + rs.getString("route_id");
|
String key = rs.getString("application_id") + "/" + rs.getString("route_id");
|
||||||
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||||
Timestamp ts = rs.getTimestamp("last_seen");
|
Timestamp ts = rs.getTimestamp("last_seen");
|
||||||
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||||
},
|
});
|
||||||
Timestamp.from(from24h), Timestamp.from(now));
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Continuous aggregate may not exist yet
|
log.warn("Failed to query route exchange counts: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-agent TPS from the last minute
|
// Merge route IDs from ClickHouse stats into routesByApp.
|
||||||
Map<String, Double> agentTps = new LinkedHashMap<>();
|
// After server restart, auto-healed agents have empty routeIds, but
|
||||||
try {
|
// ClickHouse still has execution data with the correct route IDs.
|
||||||
jdbc.query(
|
for (var countEntry : routeExchangeCounts.entrySet()) {
|
||||||
"SELECT application_name, SUM(total_count) AS cnt " +
|
String[] parts = countEntry.getKey().split("/", 2);
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
if (parts.length == 2) {
|
||||||
"GROUP BY application_name",
|
routesByApp.computeIfAbsent(parts[0], k -> new LinkedHashSet<>()).add(parts[1]);
|
||||||
rs -> {
|
}
|
||||||
// This gives per-app TPS; we'll distribute among agents below
|
|
||||||
},
|
|
||||||
Timestamp.from(from1m), Timestamp.from(now));
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Continuous aggregate may not exist yet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build catalog entries
|
// Build catalog entries — merge apps from agent registry + ClickHouse data
|
||||||
|
Set<String> allAppIds = new LinkedHashSet<>(agentsByApp.keySet());
|
||||||
|
allAppIds.addAll(routesByApp.keySet());
|
||||||
|
|
||||||
List<AppCatalogEntry> catalog = new ArrayList<>();
|
List<AppCatalogEntry> catalog = new ArrayList<>();
|
||||||
for (var entry : agentsByApp.entrySet()) {
|
for (String appId : allAppIds) {
|
||||||
String appId = entry.getKey();
|
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
|
||||||
List<AgentInfo> agents = entry.getValue();
|
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||||
|
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
||||||
List<RouteSummary> routeSummaries = routeIds.stream()
|
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||||
.map(routeId -> {
|
.map(routeId -> {
|
||||||
String key = appId + "/" + routeId;
|
String key = appId + "/" + routeId;
|
||||||
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
long count = routeExchangeCounts.getOrDefault(key, 0L);
|
||||||
Instant lastSeen = routeLastSeen.get(key);
|
Instant lastSeen = routeLastSeen.get(key);
|
||||||
return new RouteSummary(routeId, count, lastSeen);
|
String fromUri = resolveFromEndpointUri(routeId, agentIds);
|
||||||
|
String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase();
|
||||||
|
// Only include non-default states (stopped/suspended); null means started
|
||||||
|
String routeState = "started".equals(state) ? null : state;
|
||||||
|
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Agent summaries
|
// Agent summaries
|
||||||
List<AgentSummary> agentSummaries = agents.stream()
|
List<AgentSummary> agentSummaries = agents.stream()
|
||||||
.map(a -> new AgentSummary(a.id(), a.name(), a.state().name().toLowerCase(), 0.0))
|
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Health = worst state among agents
|
// Health = worst state among agents
|
||||||
@@ -137,6 +164,27 @@ public class RouteCatalogController {
|
|||||||
return ResponseEntity.ok(catalog);
|
return ResponseEntity.ok(catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the from() endpoint URI for a route by looking up its diagram. */
|
||||||
|
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
|
||||||
|
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
|
||||||
|
.flatMap(diagramStore::findByContentHash)
|
||||||
|
.map(RouteGraph::getRoot)
|
||||||
|
.map(root -> root.getEndpointUri())
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an Instant as a ClickHouse DateTime literal in UTC. */
|
||||||
|
private static String lit(Instant instant) {
|
||||||
|
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
|
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
|
||||||
|
private static String lit(String value) {
|
||||||
|
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
private String computeWorstHealth(List<AgentInfo> agents) {
|
private String computeWorstHealth(List<AgentInfo> agents) {
|
||||||
boolean hasDead = false;
|
boolean hasDead = false;
|
||||||
boolean hasStale = false;
|
boolean hasStale = false;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.cameleer3.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer3.server.app.dto.ProcessorMetrics;
|
import com.cameleer3.server.app.dto.ProcessorMetrics;
|
||||||
import com.cameleer3.server.app.dto.RouteMetrics;
|
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||||
|
import com.cameleer3.server.core.admin.AppSettings;
|
||||||
|
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -18,6 +21,7 @@ import java.time.Instant;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/routes")
|
@RequestMapping("/api/v1/routes")
|
||||||
@@ -25,9 +29,14 @@ import java.util.List;
|
|||||||
public class RouteMetricsController {
|
public class RouteMetricsController {
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
private final StatsStore statsStore;
|
||||||
|
private final AppSettingsRepository appSettingsRepository;
|
||||||
|
|
||||||
public RouteMetricsController(JdbcTemplate jdbc) {
|
public RouteMetricsController(@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc, StatsStore statsStore,
|
||||||
|
AppSettingsRepository appSettingsRepository) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
|
this.statsStore = statsStore;
|
||||||
|
this.appSettingsRepository = appSettingsRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/metrics")
|
@GetMapping("/metrics")
|
||||||
@@ -37,35 +46,33 @@ public class RouteMetricsController {
|
|||||||
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(required = false) String appId) {
|
@RequestParam(required = false) String appId,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
|
|
||||||
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||||
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||||
|
|
||||||
|
// Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries
|
||||||
|
// that strip AggregateFunction column types, breaking -Merge combinators
|
||||||
var sql = new StringBuilder(
|
var sql = new StringBuilder(
|
||||||
"SELECT application_name, route_id, " +
|
"SELECT application_id, route_id, " +
|
||||||
"SUM(total_count) AS total, " +
|
"countMerge(total_count) AS total, " +
|
||||||
"SUM(failed_count) AS failed, " +
|
"countIfMerge(failed_count) AS failed, " +
|
||||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
"CASE WHEN countMerge(total_count) > 0 THEN toFloat64(sumMerge(duration_sum)) / countMerge(total_count) ELSE 0 END AS avg_dur, " +
|
||||||
"COALESCE(MAX(p99_duration), 0) AS p99_dur " +
|
"COALESCE(quantileMerge(0.99)(p99_duration), 0) AS p99_dur " +
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ?");
|
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant));
|
||||||
var params = new ArrayList<Object>();
|
|
||||||
params.add(Timestamp.from(fromInstant));
|
|
||||||
params.add(Timestamp.from(toInstant));
|
|
||||||
|
|
||||||
if (appId != null) {
|
if (appId != null) {
|
||||||
sql.append(" AND application_name = ?");
|
sql.append(" AND application_id = " + lit(appId));
|
||||||
params.add(appId);
|
|
||||||
}
|
}
|
||||||
sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
|
if (environment != null) {
|
||||||
|
sql.append(" AND environment = " + lit(environment));
|
||||||
// Key struct for sparkline lookup
|
}
|
||||||
record RouteKey(String appId, String routeId) {}
|
sql.append(" GROUP BY application_id, route_id ORDER BY application_id, route_id");
|
||||||
List<RouteKey> routeKeys = new ArrayList<>();
|
|
||||||
|
|
||||||
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
String applicationName = rs.getString("application_name");
|
String applicationId = rs.getString("application_id");
|
||||||
String routeId = rs.getString("route_id");
|
String routeId = rs.getString("route_id");
|
||||||
long total = rs.getLong("total");
|
long total = rs.getLong("total");
|
||||||
long failed = rs.getLong("failed");
|
long failed = rs.getLong("failed");
|
||||||
@@ -76,10 +83,9 @@ public class RouteMetricsController {
|
|||||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||||
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||||
|
|
||||||
routeKeys.add(new RouteKey(applicationName, routeId));
|
return new RouteMetrics(routeId, applicationId, total, successRate,
|
||||||
return new RouteMetrics(routeId, applicationName, total, successRate,
|
avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
|
||||||
avgDur, p99Dur, errorRate, tps, List.of());
|
});
|
||||||
}, params.toArray());
|
|
||||||
|
|
||||||
// Fetch sparklines (12 buckets over the time window)
|
// Fetch sparklines (12 buckets over the time window)
|
||||||
if (!metrics.isEmpty()) {
|
if (!metrics.isEmpty()) {
|
||||||
@@ -89,24 +95,47 @@ public class RouteMetricsController {
|
|||||||
for (int i = 0; i < metrics.size(); i++) {
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
RouteMetrics m = metrics.get(i);
|
RouteMetrics m = metrics.get(i);
|
||||||
try {
|
try {
|
||||||
List<Double> sparkline = jdbc.query(
|
var sparkWhere = new StringBuilder(
|
||||||
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
|
||||||
"COALESCE(SUM(total_count), 0) AS cnt " +
|
" AND application_id = " + lit(m.appId()) + " AND route_id = " + lit(m.routeId()));
|
||||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
if (environment != null) {
|
||||||
"AND application_name = ? AND route_id = ? " +
|
sparkWhere.append(" AND environment = " + lit(environment));
|
||||||
"GROUP BY period ORDER BY period",
|
}
|
||||||
(rs, rowNum) -> rs.getDouble("cnt"),
|
String sparkSql = "SELECT toStartOfInterval(bucket, toIntervalSecond(" + bucketSeconds + ")) AS period, " +
|
||||||
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
"COALESCE(countMerge(total_count), 0) AS cnt " +
|
||||||
m.appId(), m.routeId());
|
sparkWhere + " GROUP BY period ORDER BY period";
|
||||||
|
List<Double> sparkline = jdbc.query(sparkSql,
|
||||||
|
(rs, rowNum) -> rs.getDouble("cnt"));
|
||||||
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||||
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||||
m.errorRate(), m.throughputPerSec(), sparkline));
|
m.errorRate(), m.throughputPerSec(), sparkline, m.slaCompliance()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Leave sparkline empty on error
|
// Leave sparkline empty on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with SLA compliance per route
|
||||||
|
if (!metrics.isEmpty()) {
|
||||||
|
// Determine SLA threshold (per-app or default)
|
||||||
|
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
||||||
|
int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "")
|
||||||
|
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||||
|
|
||||||
|
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||||
|
effectiveAppId, threshold, environment);
|
||||||
|
|
||||||
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
|
RouteMetrics m = metrics.get(i);
|
||||||
|
long[] counts = slaCounts.get(m.routeId());
|
||||||
|
double sla = (counts != null && counts[1] > 0)
|
||||||
|
? counts[0] * 100.0 / counts[1] : 100.0;
|
||||||
|
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||||
|
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||||
|
m.errorRate(), m.throughputPerSec(), m.sparkline(), sla));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(metrics);
|
return ResponseEntity.ok(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,47 +147,63 @@ public class RouteMetricsController {
|
|||||||
@RequestParam String routeId,
|
@RequestParam String routeId,
|
||||||
@RequestParam(required = false) String appId,
|
@RequestParam(required = false) String appId,
|
||||||
@RequestParam(required = false) Instant from,
|
@RequestParam(required = false) Instant from,
|
||||||
@RequestParam(required = false) Instant to) {
|
@RequestParam(required = false) Instant to,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
|
|
||||||
Instant toInstant = to != null ? to : Instant.now();
|
Instant toInstant = to != null ? to : Instant.now();
|
||||||
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
|
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
|
|
||||||
|
// Literal SQL for AggregatingMergeTree -Merge combinators.
|
||||||
|
// Aliases (tc, fc) must NOT shadow column names (total_count, failed_count) —
|
||||||
|
// ClickHouse 24.12 new analyzer resolves subsequent countMerge(total_count)
|
||||||
|
// to the alias (UInt64) instead of the AggregateFunction column.
|
||||||
var sql = new StringBuilder(
|
var sql = new StringBuilder(
|
||||||
"SELECT processor_id, processor_type, route_id, application_name, " +
|
"SELECT processor_id, processor_type, route_id, application_id, " +
|
||||||
"SUM(total_count) AS total_count, " +
|
"countMerge(total_count) AS tc, " +
|
||||||
"SUM(failed_count) AS failed_count, " +
|
"countIfMerge(failed_count) AS fc, " +
|
||||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
|
"CASE WHEN countMerge(total_count) > 0 THEN toFloat64(sumMerge(duration_sum)) / countMerge(total_count) ELSE 0 END AS avg_duration_ms, " +
|
||||||
"MAX(p99_duration) AS p99_duration_ms " +
|
"quantileMerge(0.99)(p99_duration) AS p99_duration_ms " +
|
||||||
"FROM stats_1m_processor_detail " +
|
"FROM stats_1m_processor_detail " +
|
||||||
"WHERE bucket >= ? AND bucket < ? AND route_id = ?");
|
"WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
|
||||||
var params = new ArrayList<Object>();
|
" AND route_id = " + lit(routeId));
|
||||||
params.add(Timestamp.from(fromInstant));
|
|
||||||
params.add(Timestamp.from(toInstant));
|
|
||||||
params.add(routeId);
|
|
||||||
|
|
||||||
if (appId != null) {
|
if (appId != null) {
|
||||||
sql.append(" AND application_name = ?");
|
sql.append(" AND application_id = " + lit(appId));
|
||||||
params.add(appId);
|
|
||||||
}
|
}
|
||||||
sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
|
if (environment != null) {
|
||||||
sql.append(" ORDER BY SUM(total_count) DESC");
|
sql.append(" AND environment = " + lit(environment));
|
||||||
|
}
|
||||||
|
sql.append(" GROUP BY processor_id, processor_type, route_id, application_id");
|
||||||
|
sql.append(" ORDER BY tc DESC");
|
||||||
|
|
||||||
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
long totalCount = rs.getLong("total_count");
|
long totalCount = rs.getLong("tc");
|
||||||
long failedCount = rs.getLong("failed_count");
|
long failedCount = rs.getLong("fc");
|
||||||
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
|
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
|
||||||
return new ProcessorMetrics(
|
return new ProcessorMetrics(
|
||||||
rs.getString("processor_id"),
|
rs.getString("processor_id"),
|
||||||
rs.getString("processor_type"),
|
rs.getString("processor_type"),
|
||||||
rs.getString("route_id"),
|
rs.getString("route_id"),
|
||||||
rs.getString("application_name"),
|
rs.getString("application_id"),
|
||||||
totalCount,
|
totalCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
rs.getDouble("avg_duration_ms"),
|
rs.getDouble("avg_duration_ms"),
|
||||||
rs.getDouble("p99_duration_ms"),
|
rs.getDouble("p99_duration_ms"),
|
||||||
errorRate);
|
errorRate);
|
||||||
}, params.toArray());
|
});
|
||||||
|
|
||||||
return ResponseEntity.ok(metrics);
|
return ResponseEntity.ok(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format an Instant as a ClickHouse DateTime literal. */
|
||||||
|
private static String lit(Instant instant) {
|
||||||
|
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
|
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
|
||||||
|
private static String lit(String value) {
|
||||||
|
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.admin.AppSettings;
|
||||||
|
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.search.ExecutionStats;
|
import com.cameleer3.server.core.search.ExecutionStats;
|
||||||
@@ -8,6 +10,8 @@ import com.cameleer3.server.core.search.SearchRequest;
|
|||||||
import com.cameleer3.server.core.search.SearchResult;
|
import com.cameleer3.server.core.search.SearchResult;
|
||||||
import com.cameleer3.server.core.search.SearchService;
|
import com.cameleer3.server.core.search.SearchService;
|
||||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||||
|
import com.cameleer3.server.core.search.TopError;
|
||||||
|
import com.cameleer3.server.core.storage.StatsStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -20,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search endpoints for querying route executions.
|
* Search endpoints for querying route executions.
|
||||||
@@ -34,10 +39,13 @@ public class SearchController {
|
|||||||
|
|
||||||
private final SearchService searchService;
|
private final SearchService searchService;
|
||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
|
private final AppSettingsRepository appSettingsRepository;
|
||||||
|
|
||||||
public SearchController(SearchService searchService, AgentRegistryService registryService) {
|
public SearchController(SearchService searchService, AgentRegistryService registryService,
|
||||||
|
AppSettingsRepository appSettingsRepository) {
|
||||||
this.searchService = searchService;
|
this.searchService = searchService;
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
|
this.appSettingsRepository = appSettingsRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/executions")
|
@GetMapping("/executions")
|
||||||
@@ -49,9 +57,10 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) String correlationId,
|
@RequestParam(required = false) String correlationId,
|
||||||
@RequestParam(required = false) String text,
|
@RequestParam(required = false) String text,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String agentId,
|
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||||
@RequestParam(required = false) String processorType,
|
@RequestParam(required = false) String processorType,
|
||||||
@RequestParam(required = false) String application,
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(required = false) String sortField,
|
@RequestParam(required = false) String sortField,
|
||||||
@@ -64,10 +73,11 @@ public class SearchController {
|
|||||||
null, null,
|
null, null,
|
||||||
correlationId,
|
correlationId,
|
||||||
text, null, null, null,
|
text, null, null, null,
|
||||||
routeId, agentId, processorType,
|
routeId, instanceId, processorType,
|
||||||
application, agentIds,
|
application, agentIds,
|
||||||
offset, limit,
|
offset, limit,
|
||||||
sortField, sortDir
|
sortField, sortDir,
|
||||||
|
environment
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(searchService.search(request));
|
return ResponseEntity.ok(searchService.search(request));
|
||||||
@@ -79,29 +89,38 @@ public class SearchController {
|
|||||||
@RequestBody SearchRequest request) {
|
@RequestBody SearchRequest request) {
|
||||||
// Resolve application to agentIds if application is specified but agentIds is not
|
// Resolve application to agentIds if application is specified but agentIds is not
|
||||||
SearchRequest resolved = request;
|
SearchRequest resolved = request;
|
||||||
if (request.application() != null && !request.application().isBlank()
|
if (request.applicationId() != null && !request.applicationId().isBlank()
|
||||||
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
&& (request.instanceIds() == null || request.instanceIds().isEmpty())) {
|
||||||
resolved = request.withAgentIds(resolveApplicationToAgentIds(request.application()));
|
resolved = request.withInstanceIds(resolveApplicationToAgentIds(request.applicationId()));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(searchService.search(resolved));
|
return ResponseEntity.ok(searchService.search(resolved));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/stats")
|
@GetMapping("/stats")
|
||||||
@Operation(summary = "Aggregate execution stats (P99 latency, active count)")
|
@Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
|
||||||
public ResponseEntity<ExecutionStats> stats(
|
public ResponseEntity<ExecutionStats> stats(
|
||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String application) {
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
|
ExecutionStats stats;
|
||||||
if (routeId == null && application == null) {
|
if (routeId == null && application == null) {
|
||||||
return ResponseEntity.ok(searchService.stats(from, end));
|
stats = searchService.stats(from, end, environment);
|
||||||
|
} else if (routeId == null) {
|
||||||
|
stats = searchService.statsForApp(from, end, application, environment);
|
||||||
|
} else {
|
||||||
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
|
stats = searchService.stats(from, end, routeId, agentIds, environment);
|
||||||
}
|
}
|
||||||
if (routeId == null) {
|
|
||||||
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
|
// Enrich with SLA compliance
|
||||||
}
|
int threshold = appSettingsRepository
|
||||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
.findByApplicationId(application != null ? application : "")
|
||||||
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||||
|
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
|
||||||
|
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/stats/timeseries")
|
@GetMapping("/stats/timeseries")
|
||||||
@@ -111,31 +130,84 @@ public class SearchController {
|
|||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(defaultValue = "24") int buckets,
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String application) {
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
if (routeId == null && application == null) {
|
if (routeId == null && application == null) {
|
||||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment));
|
||||||
}
|
}
|
||||||
if (routeId == null) {
|
if (routeId == null) {
|
||||||
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
|
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, environment));
|
||||||
}
|
}
|
||||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||||
if (routeId == null && agentIds == null) {
|
if (routeId == null && agentIds.isEmpty()) {
|
||||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds));
|
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds, environment));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/timeseries/by-app")
|
||||||
|
@Operation(summary = "Timeseries grouped by application")
|
||||||
|
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
|
||||||
|
@RequestParam Instant from,
|
||||||
|
@RequestParam(required = false) Instant to,
|
||||||
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
|
Instant end = to != null ? to : Instant.now();
|
||||||
|
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, environment));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/timeseries/by-route")
|
||||||
|
@Operation(summary = "Timeseries grouped by route for an application")
|
||||||
|
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
|
||||||
|
@RequestParam Instant from,
|
||||||
|
@RequestParam(required = false) Instant to,
|
||||||
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
|
@RequestParam String application,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
|
Instant end = to != null ? to : Instant.now();
|
||||||
|
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, environment));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/punchcard")
|
||||||
|
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
|
||||||
|
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
|
||||||
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(required = false) String environment) {
|
||||||
|
Instant to = Instant.now();
|
||||||
|
Instant from = to.minus(java.time.Duration.ofDays(7));
|
||||||
|
return ResponseEntity.ok(searchService.punchcard(from, to, application, environment));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/attributes/keys")
|
||||||
|
@Operation(summary = "Distinct attribute key names across all executions")
|
||||||
|
public ResponseEntity<List<String>> attributeKeys() {
|
||||||
|
return ResponseEntity.ok(searchService.distinctAttributeKeys());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/errors/top")
|
||||||
|
@Operation(summary = "Top N errors with velocity trend")
|
||||||
|
public ResponseEntity<List<TopError>> topErrors(
|
||||||
|
@RequestParam Instant from,
|
||||||
|
@RequestParam(required = false) Instant to,
|
||||||
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(required = false) String routeId,
|
||||||
|
@RequestParam(required = false) String environment,
|
||||||
|
@RequestParam(defaultValue = "5") int limit) {
|
||||||
|
Instant end = to != null ? to : Instant.now();
|
||||||
|
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, environment));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an application name to agent IDs.
|
* Resolve an application name to agent IDs.
|
||||||
* Returns null if application is null/blank (no filtering).
|
* Returns empty list if application is null/blank (no filtering).
|
||||||
*/
|
*/
|
||||||
private List<String> resolveApplicationToAgentIds(String application) {
|
private List<String> resolveApplicationToAgentIds(String application) {
|
||||||
if (application == null || application.isBlank()) {
|
if (application == null || application.isBlank()) {
|
||||||
return null;
|
return List.of();
|
||||||
}
|
}
|
||||||
return registryService.findByApplication(application).stream()
|
return registryService.findByApplication(application).stream()
|
||||||
.map(AgentInfo::id)
|
.map(AgentInfo::instanceId)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.storage.ClickHouseUsageTracker;
|
||||||
|
import com.cameleer3.server.core.analytics.UsageStats;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/admin/usage")
|
||||||
|
@ConditionalOnBean(ClickHouseUsageTracker.class)
|
||||||
|
@Tag(name = "Usage Analytics", description = "UI usage pattern analytics")
|
||||||
|
public class UsageAnalyticsController {
|
||||||
|
|
||||||
|
private final ClickHouseUsageTracker tracker;
|
||||||
|
|
||||||
|
public UsageAnalyticsController(ClickHouseUsageTracker tracker) {
|
||||||
|
this.tracker = tracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Query usage statistics",
|
||||||
|
description = "Returns aggregated API usage stats grouped by endpoint, user, or hour")
|
||||||
|
public ResponseEntity<List<UsageStats>> getUsage(
|
||||||
|
@RequestParam(required = false) String from,
|
||||||
|
@RequestParam(required = false) String to,
|
||||||
|
@RequestParam(required = false) String username,
|
||||||
|
@RequestParam(defaultValue = "endpoint") String groupBy) {
|
||||||
|
|
||||||
|
Instant fromInstant = from != null ? Instant.parse(from) : Instant.now().minus(7, ChronoUnit.DAYS);
|
||||||
|
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||||
|
|
||||||
|
List<UsageStats> stats = switch (groupBy) {
|
||||||
|
case "user" -> tracker.queryByUser(fromInstant, toInstant);
|
||||||
|
case "hour" -> tracker.queryByHour(fromInstant, toInstant, username);
|
||||||
|
default -> tracker.queryByEndpoint(fromInstant, toInstant, username);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ResponseEntity.ok(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import com.cameleer3.server.core.admin.AuditService;
|
|||||||
import com.cameleer3.server.core.rbac.RbacService;
|
import com.cameleer3.server.core.rbac.RbacService;
|
||||||
import com.cameleer3.server.core.rbac.SystemRole;
|
import com.cameleer3.server.core.rbac.SystemRole;
|
||||||
import com.cameleer3.server.core.rbac.UserDetail;
|
import com.cameleer3.server.core.rbac.UserDetail;
|
||||||
|
import com.cameleer3.server.core.security.PasswordPolicyValidator;
|
||||||
import com.cameleer3.server.core.security.UserInfo;
|
import com.cameleer3.server.core.security.UserInfo;
|
||||||
import com.cameleer3.server.core.security.UserRepository;
|
import com.cameleer3.server.core.security.UserRepository;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -14,6 +15,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -24,7 +26,9 @@ import org.springframework.web.bind.annotation.PutMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.security.SecurityProperties;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -32,6 +36,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin endpoints for user management.
|
* Admin endpoints for user management.
|
||||||
* Protected by {@code ROLE_ADMIN}.
|
* Protected by {@code ROLE_ADMIN}.
|
||||||
@@ -47,18 +52,22 @@ public class UserAdminController {
|
|||||||
private final RbacService rbacService;
|
private final RbacService rbacService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final boolean oidcEnabled;
|
||||||
|
|
||||||
public UserAdminController(RbacService rbacService, UserRepository userRepository,
|
public UserAdminController(RbacService rbacService, UserRepository userRepository,
|
||||||
AuditService auditService) {
|
AuditService auditService, SecurityProperties securityProperties) {
|
||||||
this.rbacService = rbacService;
|
this.rbacService = rbacService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
String issuer = securityProperties.getOidcIssuerUri();
|
||||||
|
this.oidcEnabled = issuer != null && !issuer.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all users with RBAC detail")
|
@Operation(summary = "List all users with RBAC detail")
|
||||||
@ApiResponse(responseCode = "200", description = "User list returned")
|
@ApiResponse(responseCode = "200", description = "User list returned")
|
||||||
public ResponseEntity<List<UserDetail>> listUsers() {
|
public ResponseEntity<List<UserDetail>> listUsers(HttpServletRequest httpRequest) {
|
||||||
|
auditService.log("view_users", AuditCategory.USER_MGMT, null, null, AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok(rbacService.listUsers());
|
return ResponseEntity.ok(rbacService.listUsers());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +86,13 @@ public class UserAdminController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@Operation(summary = "Create a local user")
|
@Operation(summary = "Create a local user")
|
||||||
@ApiResponse(responseCode = "200", description = "User created")
|
@ApiResponse(responseCode = "200", description = "User created")
|
||||||
public ResponseEntity<UserDetail> createUser(@RequestBody CreateUserRequest request,
|
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
|
||||||
|
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
|
if (oidcEnabled) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));
|
||||||
|
}
|
||||||
String userId = "user:" + request.username();
|
String userId = "user:" + request.username();
|
||||||
UserInfo user = new UserInfo(userId, "local",
|
UserInfo user = new UserInfo(userId, "local",
|
||||||
request.email() != null ? request.email() : "",
|
request.email() != null ? request.email() : "",
|
||||||
@@ -86,6 +100,11 @@ public class UserAdminController {
|
|||||||
Instant.now());
|
Instant.now());
|
||||||
userRepository.upsert(user);
|
userRepository.upsert(user);
|
||||||
if (request.password() != null && !request.password().isBlank()) {
|
if (request.password() != null && !request.password().isBlank()) {
|
||||||
|
List<String> violations = PasswordPolicyValidator.validate(request.password(), request.username());
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Password policy violation: " + String.join("; ", violations));
|
||||||
|
}
|
||||||
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||||
}
|
}
|
||||||
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
|
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
|
||||||
@@ -166,8 +185,14 @@ public class UserAdminController {
|
|||||||
@DeleteMapping("/{userId}")
|
@DeleteMapping("/{userId}")
|
||||||
@Operation(summary = "Delete user")
|
@Operation(summary = "Delete user")
|
||||||
@ApiResponse(responseCode = "204", description = "User deleted")
|
@ApiResponse(responseCode = "204", description = "User deleted")
|
||||||
|
@ApiResponse(responseCode = "409", description = "Cannot delete the last admin user")
|
||||||
public ResponseEntity<Void> deleteUser(@PathVariable String userId,
|
public ResponseEntity<Void> deleteUser(@PathVariable String userId,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
|
boolean isAdmin = rbacService.getEffectiveRolesForUser(userId).stream()
|
||||||
|
.anyMatch(r -> r.id().equals(SystemRole.ADMIN_ID));
|
||||||
|
if (isAdmin && rbacService.getEffectivePrincipalsForRole(SystemRole.ADMIN_ID).size() <= 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Cannot delete the last admin user");
|
||||||
|
}
|
||||||
userRepository.delete(userId);
|
userRepository.delete(userId);
|
||||||
auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
|
auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
|
||||||
null, AuditResult.SUCCESS, httpRequest);
|
null, AuditResult.SUCCESS, httpRequest);
|
||||||
@@ -177,11 +202,24 @@ public class UserAdminController {
|
|||||||
@PostMapping("/{userId}/password")
|
@PostMapping("/{userId}/password")
|
||||||
@Operation(summary = "Reset user password")
|
@Operation(summary = "Reset user password")
|
||||||
@ApiResponse(responseCode = "204", description = "Password reset")
|
@ApiResponse(responseCode = "204", description = "Password reset")
|
||||||
|
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode or policy violation")
|
||||||
public ResponseEntity<Void> resetPassword(
|
public ResponseEntity<Void> resetPassword(
|
||||||
@PathVariable String userId,
|
@PathVariable String userId,
|
||||||
@Valid @RequestBody SetPasswordRequest request,
|
@Valid @RequestBody SetPasswordRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
|
if (oidcEnabled) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
// Extract bare username from "user:username" format for policy check
|
||||||
|
String username = userId.startsWith("user:") ? userId.substring(5) : userId;
|
||||||
|
List<String> violations = PasswordPolicyValidator.validate(request.password(), username);
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Password policy violation: " + String.join("; ", violations));
|
||||||
|
}
|
||||||
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||||
|
// Revoke all existing tokens so the user must re-authenticate with the new password
|
||||||
|
userRepository.revokeTokensBefore(userId, Instant.now());
|
||||||
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
|
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,16 +9,16 @@ import java.time.Instant;
|
|||||||
@Schema(description = "Agent lifecycle event")
|
@Schema(description = "Agent lifecycle event")
|
||||||
public record AgentEventResponse(
|
public record AgentEventResponse(
|
||||||
@NotNull long id,
|
@NotNull long id,
|
||||||
@NotNull String agentId,
|
@NotNull String instanceId,
|
||||||
@NotNull String appId,
|
@NotNull String applicationId,
|
||||||
@NotNull String eventType,
|
@NotNull String eventType,
|
||||||
String detail,
|
String detail,
|
||||||
@NotNull Instant timestamp
|
@NotNull Instant timestamp
|
||||||
) {
|
) {
|
||||||
public static AgentEventResponse from(AgentEventRecord record) {
|
public static AgentEventResponse from(AgentEventRecord event) {
|
||||||
return new AgentEventResponse(
|
return new AgentEventResponse(
|
||||||
record.id(), record.agentId(), record.appId(),
|
event.id(), event.instanceId(), event.applicationId(),
|
||||||
record.eventType(), record.detail(), record.timestamp()
|
event.eventType(), event.detail(), event.timestamp()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import java.util.Map;
|
|||||||
|
|
||||||
@Schema(description = "Agent instance summary with runtime metrics")
|
@Schema(description = "Agent instance summary with runtime metrics")
|
||||||
public record AgentInstanceResponse(
|
public record AgentInstanceResponse(
|
||||||
@NotNull String id,
|
@NotNull String instanceId,
|
||||||
@NotNull String name,
|
@NotNull String displayName,
|
||||||
@NotNull String application,
|
@NotNull String applicationId,
|
||||||
|
String environmentId,
|
||||||
@NotNull String status,
|
@NotNull String status,
|
||||||
@NotNull List<String> routeIds,
|
@NotNull List<String> routeIds,
|
||||||
@NotNull Instant registeredAt,
|
@NotNull Instant registeredAt,
|
||||||
@@ -29,7 +30,8 @@ public record AgentInstanceResponse(
|
|||||||
public static AgentInstanceResponse from(AgentInfo info) {
|
public static AgentInstanceResponse from(AgentInfo info) {
|
||||||
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||||
return new AgentInstanceResponse(
|
return new AgentInstanceResponse(
|
||||||
info.id(), info.name(), info.application(),
|
info.instanceId(), info.displayName(), info.applicationId(),
|
||||||
|
info.environmentId(),
|
||||||
info.state().name(), info.routeIds(),
|
info.state().name(), info.routeIds(),
|
||||||
info.registeredAt(), info.lastHeartbeat(),
|
info.registeredAt(), info.lastHeartbeat(),
|
||||||
info.version(), info.capabilities(),
|
info.version(), info.capabilities(),
|
||||||
@@ -41,7 +43,8 @@ public record AgentInstanceResponse(
|
|||||||
|
|
||||||
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||||
return new AgentInstanceResponse(
|
return new AgentInstanceResponse(
|
||||||
id, name, application, status, routeIds, registeredAt, lastHeartbeat,
|
instanceId, displayName, applicationId, environmentId,
|
||||||
|
status, routeIds, registeredAt, lastHeartbeat,
|
||||||
version, capabilities,
|
version, capabilities,
|
||||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import java.util.Map;
|
|||||||
|
|
||||||
@Schema(description = "Agent registration payload")
|
@Schema(description = "Agent registration payload")
|
||||||
public record AgentRegistrationRequest(
|
public record AgentRegistrationRequest(
|
||||||
@NotNull String agentId,
|
@NotNull String instanceId,
|
||||||
@NotNull String name,
|
@NotNull String displayName,
|
||||||
@Schema(defaultValue = "default") String application,
|
@Schema(defaultValue = "default") String applicationId,
|
||||||
|
@Schema(defaultValue = "default") String environmentId,
|
||||||
String version,
|
String version,
|
||||||
List<String> routeIds,
|
List<String> routeIds,
|
||||||
Map<String, Object> capabilities
|
Map<String, Object> capabilities
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import jakarta.validation.constraints.NotNull;
|
|||||||
|
|
||||||
@Schema(description = "Agent registration result with JWT tokens and SSE endpoint")
|
@Schema(description = "Agent registration result with JWT tokens and SSE endpoint")
|
||||||
public record AgentRegistrationResponse(
|
public record AgentRegistrationResponse(
|
||||||
@NotNull String agentId,
|
@NotNull String instanceId,
|
||||||
@NotNull String sseEndpoint,
|
@NotNull String sseEndpoint,
|
||||||
long heartbeatIntervalMs,
|
long heartbeatIntervalMs,
|
||||||
@NotNull String serverPublicKey,
|
@NotNull String serverPublicKey,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.admin.AppSettings;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Per-application dashboard settings")
|
||||||
|
public record AppSettingsRequest(
|
||||||
|
@NotNull @Min(1)
|
||||||
|
@Schema(description = "SLA duration threshold in milliseconds")
|
||||||
|
Integer slaThresholdMs,
|
||||||
|
|
||||||
|
@NotNull @Min(0) @Max(100)
|
||||||
|
@Schema(description = "Error rate % threshold for warning (yellow) health dot")
|
||||||
|
Double healthErrorWarn,
|
||||||
|
|
||||||
|
@NotNull @Min(0) @Max(100)
|
||||||
|
@Schema(description = "Error rate % threshold for critical (red) health dot")
|
||||||
|
Double healthErrorCrit,
|
||||||
|
|
||||||
|
@NotNull @Min(0) @Max(100)
|
||||||
|
@Schema(description = "SLA compliance % threshold for warning (yellow) health dot")
|
||||||
|
Double healthSlaWarn,
|
||||||
|
|
||||||
|
@NotNull @Min(0) @Max(100)
|
||||||
|
@Schema(description = "SLA compliance % threshold for critical (red) health dot")
|
||||||
|
Double healthSlaCrit
|
||||||
|
) {
|
||||||
|
|
||||||
|
public AppSettings toSettings(String appId) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||||
|
healthSlaWarn, healthSlaCrit, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> validate() {
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
if (healthErrorWarn != null && healthErrorCrit != null
|
||||||
|
&& healthErrorWarn > healthErrorCrit) {
|
||||||
|
errors.add("healthErrorWarn must be <= healthErrorCrit");
|
||||||
|
}
|
||||||
|
if (healthSlaWarn != null && healthSlaCrit != null
|
||||||
|
&& healthSlaWarn < healthSlaCrit) {
|
||||||
|
errors.add("healthSlaWarn must be >= healthSlaCrit (higher SLA = healthier)");
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Schema(description = "Unified catalog entry combining app records with live agent data")
|
||||||
|
public record CatalogApp(
|
||||||
|
@Schema(description = "Application slug (universal identifier)") String slug,
|
||||||
|
@Schema(description = "Display name") String displayName,
|
||||||
|
@Schema(description = "True if a managed App record exists in the database") boolean managed,
|
||||||
|
@Schema(description = "Environment slug") String environmentSlug,
|
||||||
|
@Schema(description = "Composite health: deployment status + agent health") String health,
|
||||||
|
@Schema(description = "Human-readable tooltip explaining the health state") String healthTooltip,
|
||||||
|
@Schema(description = "Number of connected agents") int agentCount,
|
||||||
|
@Schema(description = "Live routes from agents") List<RouteSummary> routes,
|
||||||
|
@Schema(description = "Connected agent summaries") List<AgentSummary> agents,
|
||||||
|
@Schema(description = "Total exchange count from ClickHouse") long exchangeCount,
|
||||||
|
@Schema(description = "Active deployment info, null if no deployment") DeploymentSummary deployment
|
||||||
|
) {
|
||||||
|
public record DeploymentSummary(
|
||||||
|
String status,
|
||||||
|
String replicas,
|
||||||
|
int version
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(description = "ClickHouse storage and performance metrics")
|
||||||
|
public record ClickHousePerformanceResponse(
|
||||||
|
String diskSize,
|
||||||
|
String uncompressedSize,
|
||||||
|
double compressionRatio,
|
||||||
|
long totalRows,
|
||||||
|
int partCount,
|
||||||
|
String memoryUsage,
|
||||||
|
int currentQueries
|
||||||
|
) {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user