Compare commits
568 Commits
1fb93c3b6e
...
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
dafd7adb00 | ||
|
|
44eecfa5cd | ||
|
|
ff76751629 | ||
|
|
413839452c | ||
|
|
c33e899be7 | ||
|
|
180514a039 | ||
|
|
60fced56ed | ||
|
|
515c942623 | ||
|
|
3ccd4b6548 | ||
|
|
dad608e3a2 | ||
|
|
7479dd6daf | ||
|
|
e4dff0cad1 | ||
|
|
717367252c | ||
|
|
a06808a2a2 | ||
|
|
6b750df1c4 | ||
|
|
ea56bcf2d7 | ||
|
|
826466aa55 | ||
|
|
6a5dba4eba | ||
|
|
8ad0016a8e | ||
|
|
3c226de62f | ||
|
|
c8c62a98bb | ||
|
|
2ae2871822 | ||
|
|
a950feaef1 | ||
|
|
695969d759 | ||
|
|
a72b0954db | ||
|
|
4572230c9c | ||
|
|
752d7ec0e7 | ||
|
|
9ab38dfc59 | ||
|
|
907bcd5017 | ||
|
|
83caf4be5b | ||
|
|
1533bea2a6 | ||
|
|
94d1e81852 | ||
|
|
8e27f45a2b | ||
|
|
a86f56f588 | ||
|
|
651cf9de6e | ||
|
|
63d8078688 | ||
|
|
ee69dbedfc | ||
|
|
313d871948 | ||
|
|
f4d2693561 | ||
|
|
2051572ee2 | ||
|
|
cc433b4215 | ||
|
|
31b60c4e24 | ||
|
|
017a0c218e | ||
|
|
4ff01681d4 | ||
|
|
f2744e3094 | ||
|
|
ea5b5a685d | ||
|
|
045d9ea890 | ||
|
|
9613bddc60 | ||
|
|
2b111c603c | ||
|
|
82124c3145 | ||
|
|
17ef48e392 | ||
| 4085f42160 | |||
|
|
0fcbe83cc2 | ||
|
|
5a0a915cc6 | ||
| f01487ccb4 | |||
|
|
033cfcf5fc | ||
|
|
6d650cdf34 | ||
|
|
6f5b5b8655 | ||
|
|
653ef958ed | ||
|
|
48b17f83a3 | ||
|
|
9d08e74913 | ||
|
|
f42e6279e6 | ||
|
|
d025919f8d | ||
|
|
db6143f9da | ||
|
|
4821ddebba | ||
|
|
65001e0ed0 | ||
|
|
1881aca0e4 | ||
|
|
4842507ff3 | ||
|
|
708aae720c | ||
|
|
ebe97bd386 | ||
|
|
01295c84d8 | ||
|
|
eb0cc8c141 | ||
|
|
b06b3f52a8 | ||
| ecd76bda97 | |||
|
|
4bc48afbf8 | ||
|
|
038b663b8c | ||
|
|
329e4b0b16 | ||
|
|
7c949274c5 | ||
|
|
6b9988f43a | ||
|
|
0edbdea2eb | ||
|
|
b61c32729b | ||
|
|
9fbda7715c | ||
|
|
4d5a4842b9 | ||
|
|
321b8808cc | ||
|
|
c6da858c2f | ||
|
|
c6b2f7c331 | ||
|
|
0cea8af6bc | ||
|
|
1d6ae00b1c | ||
|
|
e8842e3bdc | ||
|
|
4d33592015 | ||
|
|
a0944a1c72 | ||
|
|
fa3bc592d1 | ||
|
|
950f16be7a | ||
|
|
a634bf9f9d | ||
|
|
2bcbff3ee6 | ||
|
|
fc412f7251 | ||
|
|
82117deaab | ||
|
|
247fdb01c0 | ||
|
|
b393d262cb | ||
|
|
ff3a046f5a | ||
|
|
88df324b4b | ||
|
|
c1cf8ae260 | ||
|
|
229463a2e8 | ||
|
|
15f20d22ad | ||
|
|
672544660f | ||
| 966db8545b | |||
|
|
c346babe33 | ||
| 8c2215ba58 | |||
|
|
c316e80d7f | ||
|
|
796be06a09 | ||
|
|
26f5a2ce3b | ||
|
|
d23b899f00 | ||
|
|
288c7a86b5 | ||
|
|
9f74e47ecf | ||
|
|
39f9925e71 | ||
|
|
af03ecdf42 | ||
|
|
0723f48e5b | ||
|
|
2634f60e59 | ||
|
|
3c0e615fb7 | ||
|
|
589da1b6d6 | ||
|
|
41e2038190 | ||
|
|
ea687a342c | ||
|
|
cea16b38ed | ||
|
|
a344be3a49 | ||
|
|
565b548ac1 | ||
|
|
7dbfaf0932 | ||
|
|
f7d7302694 | ||
|
|
c48e0bdfde | ||
|
|
5932b5d969 | ||
|
|
527e2cf017 | ||
|
|
9fd02c4edb | ||
|
|
85ebe76111 | ||
|
|
adf4b44d78 | ||
|
|
84b93d74c7 | ||
|
|
a55fc3c10d | ||
|
|
55ed3be71a | ||
|
|
41a9a975fd | ||
|
|
0eeae70369 | ||
|
|
8a637df65c | ||
|
|
5bed108d3b | ||
|
|
ccc3f9fd92 | ||
|
|
5ee78f7673 | ||
|
|
8c605d7523 | ||
|
|
4ea6814bb3 | ||
|
|
7fd8a787d0 | ||
|
|
0b56590e3f | ||
|
|
7dec8fbaff | ||
|
|
e466dc5861 | ||
|
|
cc39ca3084 | ||
|
|
48d944354a | ||
|
|
61a9549853 | ||
|
|
5ad0c75da8 | ||
|
|
8e6f8e2693 | ||
|
|
f660e88a17 | ||
|
|
035356288f | ||
|
|
adf13f0430 | ||
| 6f7c92f793 | |||
|
|
520590fbf4 | ||
|
|
7b9dc32d6a | ||
|
|
8961a5a63c | ||
|
|
a108b57591 | ||
|
|
7553139cf2 | ||
|
|
7778793e7b | ||
|
|
b64edaa16f | ||
|
|
31b8695420 | ||
|
|
dbf53aa8e8 | ||
|
|
9f9e677103 | ||
|
|
86f905e672 | ||
|
|
a6f94e8a70 | ||
|
|
463cab1196 | ||
|
|
6676e209c7 | ||
|
|
465f210aee | ||
|
|
50bb22d6f6 | ||
|
|
0d82304cf0 | ||
|
|
103b14d1df | ||
|
|
84f4c505a2 | ||
|
|
b024f83c26 | ||
|
|
0c47ac9b1a | ||
|
|
377908cc61 | ||
|
|
9d2e6f30a7 | ||
| a1e1c8f6ff | |||
|
|
554d6822c0 | ||
|
|
3438216fd9 | ||
|
|
a4de2a7b79 | ||
|
|
484c5887c3 | ||
|
|
cb600be1f1 | ||
|
|
3641dffecc | ||
|
|
7c2058ecb2 | ||
|
|
393d19e3f4 | ||
|
|
4cdf2ac012 | ||
|
|
f156a2aab0 | ||
|
|
d4df47215b | ||
|
|
cdf4c93630 | ||
|
|
6794e4c234 | ||
|
|
96a5b00b99 | ||
|
|
cf804638d7 | ||
|
|
868cf84c4e | ||
|
|
d1940b98e5 | ||
|
|
9e6e1b350a | ||
|
|
cccd3f07be | ||
|
|
c3cfb39f81 | ||
|
|
d78b283567 | ||
|
|
4253751ef1 | ||
|
|
3f98467ba5 | ||
|
|
c1f2ddb3f5 | ||
|
|
86e016874a | ||
|
|
64b03a4e2f | ||
|
|
6f415cb017 | ||
|
|
1dfe53abee | ||
|
|
fc2daddf54 | ||
|
|
c73b512cd9 | ||
|
|
b40c197c21 | ||
|
|
3eb83f97d3 | ||
|
|
9c2391e5d4 | ||
|
|
d229365eaf | ||
|
|
88da1a0dd8 | ||
|
|
a44a0c970b | ||
|
|
a2cbd115ee | ||
|
|
ce0eb58b0c | ||
|
|
48bdb46760 | ||
|
|
f7ed91ef9c | ||
|
|
5576b50a3a | ||
|
|
a1280609f6 | ||
|
|
9dffa9ea81 | ||
|
|
129b97183a | ||
|
|
28536cc807 | ||
|
|
9ef4ae57b2 | ||
|
|
c228c3201b | ||
|
|
f9a35e1627 | ||
|
|
74687ba9ed | ||
|
|
acf78a10f1 | ||
|
|
539b85f307 | ||
|
|
45f0241079 | ||
|
|
0215fd96ae | ||
|
|
387e2e66b2 | ||
|
|
b3b4e62d34 | ||
|
|
c5a5c28fe0 | ||
|
|
ac9e8ae4dd | ||
|
|
51a02700dd | ||
|
|
b7c35037e6 | ||
|
|
cb788def43 | ||
|
|
2bfbbbbf0c | ||
|
|
f223117a00 | ||
|
|
b594ac6f4a | ||
|
|
2da2b76771 | ||
|
|
74a2181247 | ||
|
|
ea44a88f7d | ||
|
|
a1909baad6 | ||
|
|
5746886a0b | ||
|
|
af0af9ce38 | ||
|
|
0372be2334 | ||
|
|
61f39021b3 | ||
|
|
4cd7ed9e9a | ||
|
|
4bf7b0bc40 | ||
|
|
29c1f456a7 | ||
|
|
6c50b7cdfe | ||
|
|
57b744af0c | ||
|
|
d99650015b |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
**/target/
|
||||
.git/
|
||||
.gitea/
|
||||
.idea/
|
||||
*.iml
|
||||
docs/
|
||||
*.md
|
||||
!pom.xml
|
||||
.planning/
|
||||
.claude/
|
||||
@@ -2,15 +2,22 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, 'feature/**', 'fix/**', 'feat/**']
|
||||
tags-ignore:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
delete:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'delete'
|
||||
container:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
image: gitea.siegeln.net/cameleer/cameleer-build:1
|
||||
credentials:
|
||||
username: cameleer
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -38,5 +45,349 @@ jobs:
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: ${{ runner.os }}-maven-
|
||||
|
||||
- name: Build UI
|
||||
working-directory: ui
|
||||
run: |
|
||||
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}' >> .npmrc
|
||||
npm ci
|
||||
npm run build
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Test
|
||||
run: mvn clean verify --batch-mode
|
||||
run: mvn clean verify -DskipITs -U --batch-mode
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
container:
|
||||
image: gitea.siegeln.net/cameleer/cameleer-docker-builder:1
|
||||
credentials:
|
||||
username: cameleer
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Login to registry
|
||||
run: echo "$REGISTRY_TOKEN" | docker login gitea.siegeln.net -u cameleer --password-stdin
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Compute branch slug
|
||||
run: |
|
||||
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/-$//'
|
||||
}
|
||||
if [ "$GITHUB_REF_NAME" = "main" ]; then
|
||||
echo "BRANCH_SLUG=main" >> "$GITHUB_ENV"
|
||||
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
|
||||
else
|
||||
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
|
||||
echo "BRANCH_SLUG=$SLUG" >> "$GITHUB_ENV"
|
||||
echo "IMAGE_TAGS=branch-$SLUG" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- name: Set up QEMU for cross-platform builds
|
||||
run: docker run --rm --privileged gitea.siegeln.net/cameleer/binfmt:1 --install all
|
||||
- name: Build and push server
|
||||
run: |
|
||||
docker buildx create --use --name cibuilder
|
||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer3-server:${{ github.sha }}"
|
||||
for TAG in $IMAGE_TAGS; do
|
||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer3-server:$TAG"
|
||||
done
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||
$TAGS \
|
||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server:buildcache \
|
||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server:buildcache,mode=max \
|
||||
--provenance=false \
|
||||
--push .
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Build and push UI
|
||||
run: |
|
||||
TAGS="-t gitea.siegeln.net/cameleer/cameleer3-server-ui:${{ github.sha }}"
|
||||
for TAG in $IMAGE_TAGS; do
|
||||
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer3-server-ui:$TAG"
|
||||
done
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-f ui/Dockerfile \
|
||||
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
|
||||
$TAGS \
|
||||
--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 \
|
||||
--provenance=false \
|
||||
--push ui/
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Cleanup local Docker
|
||||
run: docker system prune -af --filter "until=24h"
|
||||
if: always()
|
||||
- name: Cleanup old container images
|
||||
run: |
|
||||
API="https://gitea.siegeln.net/api/v1"
|
||||
AUTH="Authorization: token ${REGISTRY_TOKEN}"
|
||||
CURRENT_SHA="${{ github.sha }}"
|
||||
# Build list of tags to keep
|
||||
KEEP_TAGS="latest buildcache $CURRENT_SHA"
|
||||
if [ "$BRANCH_SLUG" != "main" ]; then
|
||||
KEEP_TAGS="$KEEP_TAGS branch-$BRANCH_SLUG"
|
||||
fi
|
||||
for PKG in cameleer3-server cameleer3-server-ui; do
|
||||
curl -sf -H "$AUTH" "$API/packages/cameleer/container/$PKG" | \
|
||||
jq -r '.[] | "\(.id) \(.version)"' | \
|
||||
while read id version; do
|
||||
SHOULD_KEEP=false
|
||||
for KEEP in $KEEP_TAGS; do
|
||||
if [ "$version" = "$KEEP" ]; then
|
||||
SHOULD_KEEP=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$SHOULD_KEEP" = "false" ]; then
|
||||
# Only clean up images for this branch
|
||||
if [ "$BRANCH_SLUG" = "main" ] || echo "$version" | grep -q "branch-$BRANCH_SLUG"; then
|
||||
echo "Deleting old image tag: $PKG:$version"
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/packages/cameleer/container/$PKG/$version"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
if: always()
|
||||
|
||||
deploy:
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: alpine/k8s:1.32.3
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Configure kubectl
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
|
||||
env:
|
||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
||||
- name: Deploy
|
||||
run: |
|
||||
kubectl create namespace cameleer --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl create secret docker-registry gitea-registry \
|
||||
--namespace=cameleer \
|
||||
--docker-server=gitea.siegeln.net \
|
||||
--docker-username=cameleer \
|
||||
--docker-password="$REGISTRY_TOKEN" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl create secret generic cameleer-auth \
|
||||
--namespace=cameleer \
|
||||
--from-literal=CAMELEER_AUTH_TOKEN="$CAMELEER_AUTH_TOKEN" \
|
||||
--from-literal=CAMELEER_UI_USER="${CAMELEER_UI_USER:-admin}" \
|
||||
--from-literal=CAMELEER_UI_PASSWORD="${CAMELEER_UI_PASSWORD:-admin}" \
|
||||
--from-literal=CAMELEER_JWT_SECRET="${CAMELEER_JWT_SECRET}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl create secret generic postgres-credentials \
|
||||
--namespace=cameleer \
|
||||
--from-literal=POSTGRES_USER="$POSTGRES_USER" \
|
||||
--from-literal=POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
|
||||
--from-literal=POSTGRES_DB="${POSTGRES_DB:-cameleer}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl create secret generic opensearch-credentials \
|
||||
--namespace=cameleer \
|
||||
--from-literal=OPENSEARCH_USER="${OPENSEARCH_USER:-admin}" \
|
||||
--from-literal=OPENSEARCH_PASSWORD="$OPENSEARCH_PASSWORD" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl create secret generic authentik-credentials \
|
||||
--namespace=cameleer \
|
||||
--from-literal=PG_USER="${AUTHENTIK_PG_USER:-authentik}" \
|
||||
--from-literal=PG_PASSWORD="${AUTHENTIK_PG_PASSWORD}" \
|
||||
--from-literal=AUTHENTIK_SECRET_KEY="${AUTHENTIK_SECRET_KEY}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl apply -f deploy/postgres.yaml
|
||||
kubectl -n cameleer rollout status statefulset/postgres --timeout=120s
|
||||
|
||||
kubectl apply -f deploy/opensearch.yaml
|
||||
kubectl -n cameleer rollout status statefulset/opensearch --timeout=180s
|
||||
|
||||
kubectl apply -f deploy/authentik.yaml
|
||||
kubectl -n cameleer rollout status deployment/authentik-server --timeout=180s
|
||||
|
||||
kubectl apply -k deploy/overlays/main
|
||||
kubectl -n cameleer set image deployment/cameleer3-server \
|
||||
server=gitea.siegeln.net/cameleer/cameleer3-server:${{ github.sha }}
|
||||
kubectl -n cameleer rollout status deployment/cameleer3-server --timeout=120s
|
||||
|
||||
kubectl -n cameleer set image deployment/cameleer3-ui \
|
||||
ui=gitea.siegeln.net/cameleer/cameleer3-server-ui:${{ github.sha }}
|
||||
kubectl -n cameleer rollout status deployment/cameleer3-ui --timeout=120s
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
CAMELEER_AUTH_TOKEN: ${{ secrets.CAMELEER_AUTH_TOKEN }}
|
||||
CAMELEER_JWT_SECRET: ${{ secrets.CAMELEER_JWT_SECRET }}
|
||||
CAMELEER_UI_USER: ${{ secrets.CAMELEER_UI_USER }}
|
||||
CAMELEER_UI_PASSWORD: ${{ secrets.CAMELEER_UI_PASSWORD }}
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
OPENSEARCH_USER: ${{ secrets.OPENSEARCH_USER }}
|
||||
OPENSEARCH_PASSWORD: ${{ secrets.OPENSEARCH_PASSWORD }}
|
||||
AUTHENTIK_PG_USER: ${{ secrets.AUTHENTIK_PG_USER }}
|
||||
AUTHENTIK_PG_PASSWORD: ${{ secrets.AUTHENTIK_PG_PASSWORD }}
|
||||
AUTHENTIK_SECRET_KEY: ${{ secrets.AUTHENTIK_SECRET_KEY }}
|
||||
|
||||
deploy-feature:
|
||||
needs: docker
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref != 'refs/heads/main' && github.event_name == 'push'
|
||||
container:
|
||||
image: alpine/k8s:1.32.3
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Configure kubectl
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
|
||||
env:
|
||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
||||
- name: Compute branch variables
|
||||
run: |
|
||||
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/-$//'
|
||||
}
|
||||
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
|
||||
NS="cam-${SLUG}"
|
||||
SCHEMA="cam_$(echo $SLUG | tr '-' '_')"
|
||||
echo "BRANCH_SLUG=$SLUG" >> "$GITHUB_ENV"
|
||||
echo "BRANCH_NS=$NS" >> "$GITHUB_ENV"
|
||||
echo "BRANCH_SCHEMA=$SCHEMA" >> "$GITHUB_ENV"
|
||||
- name: Create namespace
|
||||
run: kubectl create namespace "$BRANCH_NS" --dry-run=client -o yaml | kubectl apply -f -
|
||||
- name: Copy secrets from cameleer namespace
|
||||
run: |
|
||||
for SECRET in gitea-registry postgres-credentials opensearch-credentials cameleer-auth; do
|
||||
kubectl get secret "$SECRET" -n cameleer -o json \
|
||||
| jq 'del(.metadata.namespace, .metadata.resourceVersion, .metadata.uid, .metadata.creationTimestamp, .metadata.managedFields)' \
|
||||
| kubectl apply -n "$BRANCH_NS" -f -
|
||||
done
|
||||
- name: Substitute placeholders and deploy
|
||||
run: |
|
||||
# Work on a copy preserving the directory structure so ../../base resolves
|
||||
mkdir -p /tmp/feature-deploy/deploy/overlays
|
||||
cp -r deploy/base /tmp/feature-deploy/deploy/base
|
||||
cp -r deploy/overlays/feature /tmp/feature-deploy/deploy/overlays/feature
|
||||
# Substitute all BRANCH_* placeholders
|
||||
for f in /tmp/feature-deploy/deploy/overlays/feature/*.yaml; do
|
||||
sed -i \
|
||||
-e "s|BRANCH_NAMESPACE|${BRANCH_NS}|g" \
|
||||
-e "s|BRANCH_SCHEMA|${BRANCH_SCHEMA}|g" \
|
||||
-e "s|BRANCH_SLUG|${BRANCH_SLUG}|g" \
|
||||
-e "s|BRANCH_SHA|${{ github.sha }}|g" \
|
||||
"$f"
|
||||
done
|
||||
kubectl apply -k /tmp/feature-deploy/deploy/overlays/feature
|
||||
- name: Wait for init-job
|
||||
run: |
|
||||
kubectl -n "$BRANCH_NS" wait --for=condition=complete job/init-schema --timeout=60s || \
|
||||
echo "Warning: init-schema job did not complete in time"
|
||||
- name: Wait for server rollout
|
||||
run: kubectl -n "$BRANCH_NS" rollout status deployment/cameleer3-server --timeout=120s
|
||||
- name: Wait for UI rollout
|
||||
run: kubectl -n "$BRANCH_NS" rollout status deployment/cameleer3-ui --timeout=60s
|
||||
- name: Print deployment URLs
|
||||
run: |
|
||||
echo "===================================="
|
||||
echo "Feature branch deployed!"
|
||||
echo "API: http://${BRANCH_SLUG}-api.cameleer.siegeln.net"
|
||||
echo "UI: http://${BRANCH_SLUG}.cameleer.siegeln.net"
|
||||
echo "===================================="
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
cleanup-branch:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
|
||||
container:
|
||||
image: alpine/k8s:1.32.3
|
||||
steps:
|
||||
- name: Configure kubectl
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
|
||||
env:
|
||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
|
||||
- name: Compute branch variables
|
||||
run: |
|
||||
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/-$//'
|
||||
}
|
||||
SLUG=$(sanitize_branch "${{ github.event.ref }}")
|
||||
NS="cam-${SLUG}"
|
||||
SCHEMA="cam_$(echo $SLUG | tr '-' '_')"
|
||||
echo "BRANCH_SLUG=$SLUG" >> "$GITHUB_ENV"
|
||||
echo "BRANCH_NS=$NS" >> "$GITHUB_ENV"
|
||||
echo "BRANCH_SCHEMA=$SCHEMA" >> "$GITHUB_ENV"
|
||||
- name: Delete namespace
|
||||
run: kubectl delete namespace "$BRANCH_NS" --ignore-not-found
|
||||
- name: Drop PostgreSQL schema
|
||||
run: |
|
||||
kubectl run cleanup-schema-${BRANCH_SLUG} \
|
||||
--namespace=cameleer \
|
||||
--image=postgres:16 \
|
||||
--restart=Never \
|
||||
--env="PGPASSWORD=$(kubectl get secret postgres-credentials -n cameleer -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)" \
|
||||
--command -- sh -c "psql -h postgres -U $(kubectl get secret postgres-credentials -n cameleer -o jsonpath='{.data.POSTGRES_USER}' | base64 -d) -d cameleer3 -c 'DROP SCHEMA IF EXISTS ${BRANCH_SCHEMA} CASCADE'"
|
||||
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 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
|
||||
run: |
|
||||
API="https://gitea.siegeln.net/api/v1"
|
||||
AUTH="Authorization: token ${REGISTRY_TOKEN}"
|
||||
for PKG in cameleer3-server cameleer3-server-ui; do
|
||||
# Delete branch-specific tag
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/packages/cameleer/container/$PKG/branch-${BRANCH_SLUG}" || true
|
||||
done
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
89
.gitea/workflows/sonarqube.yml
Normal file
89
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
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: Build and Test Java
|
||||
run: mvn clean verify -DskipITs -U --batch-mode
|
||||
|
||||
- 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: Install sonar-scanner
|
||||
run: |
|
||||
SONAR_SCANNER_VERSION=6.2.1.4610
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
aarch64|arm64) PLATFORM="linux-aarch64" ;;
|
||||
*) PLATFORM="linux-x64" ;;
|
||||
esac
|
||||
curl -sSLo sonar-scanner.zip "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_VERSION}-${PLATFORM}.zip"
|
||||
unzip -q sonar-scanner.zip
|
||||
ln -s "$(pwd)/sonar-scanner-${SONAR_SCANNER_VERSION}-${PLATFORM}/bin/sonar-scanner" /usr/local/bin/sonar-scanner
|
||||
|
||||
- name: SonarQube Analysis
|
||||
run: |
|
||||
sonar-scanner \
|
||||
-Dsonar.host.url="$SONAR_HOST_URL" \
|
||||
-Dsonar.token="$SONAR_TOKEN" \
|
||||
-Dsonar.projectKey=cameleer3-server \
|
||||
-Dsonar.projectName="Cameleer3 Server" \
|
||||
-Dsonar.sources=cameleer3-server-core/src/main/java,cameleer3-server-app/src/main/java,ui/src \
|
||||
-Dsonar.tests=cameleer3-server-core/src/test/java,cameleer3-server-app/src/test/java \
|
||||
-Dsonar.java.binaries=cameleer3-server-core/target/classes,cameleer3-server-app/target/classes \
|
||||
-Dsonar.java.test.binaries=cameleer3-server-core/target/test-classes,cameleer3-server-app/target/test-classes \
|
||||
-Dsonar.java.libraries="$HOME/.m2/repository/**/*.jar" \
|
||||
-Dsonar.typescript.eslint.reportPaths=ui/eslint-report.json \
|
||||
-Dsonar.eslint.reportPaths=ui/eslint-report.json \
|
||||
-Dsonar.exclusions="ui/node_modules/**,ui/dist/**,**/target/**"
|
||||
env:
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ logs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
.worktrees/
|
||||
|
||||
@@ -27,13 +27,13 @@ Requirements for initial release. Each maps to roadmap phases. Tracked as Gitea
|
||||
|
||||
### Agent Management
|
||||
|
||||
- [ ] **AGNT-01**: Agent registers via `POST /api/v1/agents/register` with bootstrap token, receives JWT + server public key (#13)
|
||||
- [ ] **AGNT-02**: Server maintains agent registry with LIVE/STALE/DEAD lifecycle based on heartbeat timing (#14)
|
||||
- [ ] **AGNT-03**: Agent sends heartbeat via `POST /api/v1/agents/{id}/heartbeat` every 30s (#15)
|
||||
- [ ] **AGNT-04**: Server pushes `config-update` events to agents via SSE with Ed25519 signature (#16)
|
||||
- [ ] **AGNT-05**: Server pushes `deep-trace` commands to agents via SSE for specific correlationIds (#17)
|
||||
- [ ] **AGNT-06**: Server pushes `replay` commands to agents via SSE with signed replay tokens (#18)
|
||||
- [ ] **AGNT-07**: SSE connection includes `ping` keepalive and supports `Last-Event-ID` reconnection (#19)
|
||||
- [x] **AGNT-01**: Agent registers via `POST /api/v1/agents/register` with bootstrap token, receives JWT + server public key (#13)
|
||||
- [x] **AGNT-02**: Server maintains agent registry with LIVE/STALE/DEAD lifecycle based on heartbeat timing (#14)
|
||||
- [x] **AGNT-03**: Agent sends heartbeat via `POST /api/v1/agents/{id}/heartbeat` every 30s (#15)
|
||||
- [x] **AGNT-04**: Server pushes `config-update` events to agents via SSE with Ed25519 signature (#16)
|
||||
- [x] **AGNT-05**: Server pushes `deep-trace` commands to agents via SSE for specific correlationIds (#17)
|
||||
- [x] **AGNT-06**: Server pushes `replay` commands to agents via SSE with signed replay tokens (#18)
|
||||
- [x] **AGNT-07**: SSE connection includes `ping` keepalive and supports `Last-Event-ID` reconnection (#19)
|
||||
|
||||
### Route Diagrams
|
||||
|
||||
@@ -43,11 +43,11 @@ Requirements for initial release. Each maps to roadmap phases. Tracked as Gitea
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] **SECU-01**: All API endpoints (except health and register) require valid JWT Bearer token (#23)
|
||||
- [ ] **SECU-02**: JWT refresh flow via `POST /api/v1/agents/{id}/refresh` (#24)
|
||||
- [ ] **SECU-03**: Server generates Ed25519 keypair; public key delivered at registration (#25)
|
||||
- [ ] **SECU-04**: All config-update and replay SSE payloads are signed with server's Ed25519 private key (#26)
|
||||
- [ ] **SECU-05**: Bootstrap token from `CAMELEER_AUTH_TOKEN` env var validates initial agent registration (#27)
|
||||
- [x] **SECU-01**: All API endpoints (except health and register) require valid JWT Bearer token (#23)
|
||||
- [x] **SECU-02**: JWT refresh flow via `POST /api/v1/agents/{id}/refresh` (#24)
|
||||
- [x] **SECU-03**: Server generates Ed25519 keypair; public key delivered at registration (#25)
|
||||
- [x] **SECU-04**: All config-update and replay SSE payloads are signed with server's Ed25519 private key (#26)
|
||||
- [x] **SECU-05**: Bootstrap token from `CAMELEER_AUTH_TOKEN` env var validates initial agent registration (#27)
|
||||
|
||||
### REST API
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||
|
||||
- [ ] **Phase 1: Ingestion Pipeline + API Foundation** - ClickHouse schema, batch write buffer, ingestion endpoints, API scaffolding
|
||||
- [ ] **Phase 2: Transaction Search + Diagrams** - Structured search, full-text search, diagram versioning and rendering
|
||||
- [ ] **Phase 3: Agent Registry + SSE Push** - Agent lifecycle management, heartbeat monitoring, SSE config/command push
|
||||
- [x] **Phase 3: Agent Registry + SSE Push** - Agent lifecycle management, heartbeat monitoring, SSE config/command push (completed 2026-03-11)
|
||||
- [ ] **Phase 4: Security** - JWT authentication, Ed25519 signing, bootstrap token registration, endpoint protection
|
||||
|
||||
## Phase Details
|
||||
@@ -61,11 +61,11 @@ Plans:
|
||||
1. An agent can register via POST with a bootstrap token and receive a JWT (security enforcement deferred to Phase 4, but the registration flow and token issuance work end-to-end)
|
||||
2. Server correctly transitions agents through LIVE/STALE/DEAD states based on heartbeat timing, and the agent list endpoint reflects current states
|
||||
3. Server pushes config-update, deep-trace, and replay events to a specific agent's SSE stream, with ping keepalive and Last-Event-ID reconnection support
|
||||
**Plans**: TBD
|
||||
**Plans:** 2/2 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] 03-01: Agent registration, heartbeat lifecycle, and registry endpoints
|
||||
- [ ] 03-02: SSE connection management and command push (config-update, deep-trace, replay, ping, reconnection)
|
||||
- [ ] 03-01-PLAN.md -- Agent domain types, registry service, registration/heartbeat/list endpoints, lifecycle monitor
|
||||
- [ ] 03-02-PLAN.md -- SSE connection management, command push (config-update, deep-trace, replay), ping keepalive, acknowledgement, integration tests
|
||||
|
||||
### Phase 4: Security
|
||||
**Goal**: All server communication is authenticated and integrity-protected, with JWT for API access and Ed25519 signatures for pushed configuration
|
||||
@@ -76,10 +76,12 @@ Plans:
|
||||
2. Agents can refresh expired JWTs via the refresh endpoint without re-registering
|
||||
3. Server generates an Ed25519 keypair at startup, delivers the public key during registration, and all config-update and replay SSE payloads carry a valid Ed25519 signature
|
||||
4. Bootstrap token from CAMELEER_AUTH_TOKEN environment variable is required for initial agent registration
|
||||
**Plans**: TBD
|
||||
**Plans:** 2/3 plans executed
|
||||
|
||||
Plans:
|
||||
- [ ] 04-01: JWT authentication filter, refresh flow, Ed25519 keypair generation and config signing, bootstrap token validation
|
||||
- [x] 04-01-PLAN.md -- Security service foundation: JwtService, Ed25519SigningService, BootstrapTokenValidator, Maven deps, config
|
||||
- [ ] 04-02-PLAN.md -- Spring Security filter chain, JWT auth filter, registration/refresh integration, existing test adaptation
|
||||
- [ ] 04-03-PLAN.md -- Ed25519 signing of SSE command payloads (config-update, deep-trace, replay)
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -91,5 +93,5 @@ Note: Phases 2 and 3 both depend only on Phase 1 and could execute in parallel.
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Ingestion Pipeline + API Foundation | 3/3 | Complete | 2026-03-11 |
|
||||
| 2. Transaction Search + Diagrams | 3/4 | Gap Closure | |
|
||||
| 3. Agent Registry + SSE Push | 0/2 | Not started | - |
|
||||
| 4. Security | 0/1 | Not started | - |
|
||||
| 3. Agent Registry + SSE Push | 2/2 | Complete | 2026-03-11 |
|
||||
| 4. Security | 2/3 | In Progress| |
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: completed
|
||||
stopped_at: Completed 02-04-PLAN.md (Phase 02 gap closure complete)
|
||||
last_updated: "2026-03-11T16:43:52.661Z"
|
||||
last_activity: 2026-03-11 -- Completed 02-04 (Diagram hash linking, Surefire fix, test stability)
|
||||
status: executing
|
||||
stopped_at: Completed 04-02-PLAN.md
|
||||
last_updated: "2026-03-11T20:08:12.754Z"
|
||||
last_activity: 2026-03-11 -- Completed 04-02 (Security filter chain wiring)
|
||||
progress:
|
||||
total_phases: 4
|
||||
completed_phases: 2
|
||||
total_plans: 7
|
||||
completed_plans: 7
|
||||
completed_phases: 4
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
@@ -21,14 +21,14 @@ progress:
|
||||
See: .planning/PROJECT.md (updated 2026-03-11)
|
||||
|
||||
**Core value:** Users can reliably search and find any transaction across all connected Camel instances -- by any combination of state, time, duration, or content -- even at millions of transactions per day with 30-day retention.
|
||||
**Current focus:** Phase 2: Transaction Search + Diagrams
|
||||
**Current focus:** Phase 4: Security
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 2 of 4 (Transaction Search + Diagrams) -- COMPLETE
|
||||
Plan: 4 of 4 in current phase (gap closure)
|
||||
Status: Phase 02 Complete (including gap closure)
|
||||
Last activity: 2026-03-11 -- Completed 02-04 (Diagram hash linking, Surefire fix, test stability)
|
||||
Phase: 4 of 4 (Security)
|
||||
Plan: 2 of 3 in current phase (Security filter chain wiring)
|
||||
Status: Phase 04 in progress, Plan 02 complete
|
||||
Last activity: 2026-03-11 -- Completed 04-02 (Security filter chain wiring)
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -57,6 +57,11 @@ Progress: [██████████] 100%
|
||||
| Phase 02 P02 | 14min | 2 tasks | 10 files |
|
||||
| Phase 02 P03 | 12min | 2 tasks | 9 files |
|
||||
| Phase 02 P04 | 22min | 1 tasks | 5 files |
|
||||
| Phase 03 P01 | 15min | 2 tasks | 15 files |
|
||||
| Phase 03 P02 | 32min | 2 tasks | 7 files |
|
||||
| Phase 04 P01 | 12min | 1 tasks | 15 files |
|
||||
| Phase 04 P03 | 17min | 1 tasks | 4 files |
|
||||
| Phase 04 P02 | 26min | 2 tasks | 25 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -92,6 +97,22 @@ Recent decisions affecting current work:
|
||||
- [Phase 02]: DiagramRepository injected via constructor into ClickHouseExecutionRepository for diagram hash lookup during batch insert
|
||||
- [Phase 02]: Awaitility ignoreExceptions pattern adopted for all ClickHouse polling assertions
|
||||
- [Phase 02]: Surefire and Failsafe both need reuseForks=false for ELK classloader isolation
|
||||
- [Phase 03]: AgentInfo as Java record with wither-style methods for immutable ConcurrentHashMap swapping
|
||||
- [Phase 03]: Dead threshold measured from staleTransitionTime, not lastHeartbeat
|
||||
- [Phase 03]: spring.mvc.async.request-timeout=-1 set proactively for SSE support in Plan 02
|
||||
- [Phase 03]: SSE events path excluded from ProtocolVersionInterceptor for EventSource client compatibility
|
||||
- [Phase 03]: SseConnectionManager uses reference-equality in emitter callbacks to avoid removing newer emitters
|
||||
- [Phase 03]: java.net.http.HttpClient async API for SSE integration tests (no webflux dependency)
|
||||
- [Phase 04]: HMAC-SHA256 with ephemeral 256-bit secret for JWT signing (Ed25519 reserved for config signing)
|
||||
- [Phase 04]: Nimbus JOSE+JWT 9.47 for JWT library (mature, explicit MACSigner/MACVerifier API)
|
||||
- [Phase 04]: JDK 17 built-in Ed25519 KeyPairGenerator (no Bouncy Castle dependency needed)
|
||||
- [Phase 04]: TestSecurityConfig as @Configuration in test sources for automatic @SpringBootTest scanning
|
||||
- [Phase 04]: InitializingBean pattern for fail-fast bootstrap token validation on startup
|
||||
- [Phase 04]: Signed payload parsed to JsonNode for correct SseEmitter serialization (avoids double-quoting)
|
||||
- [Phase 04]: SseSigningIT adapted to Plan 02 security layer (bootstrap token + JWT auth)
|
||||
- [Phase 04]: Added /error to SecurityConfig permitAll for proper Spring Boot error forwarding through security
|
||||
- [Phase 04]: Excluded register and refresh paths from ProtocolVersionInterceptor (auth endpoints not data endpoints)
|
||||
- [Phase 04]: Refresh endpoint in permitAll with self-authentication via refresh token (not JWT access token)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -106,6 +127,6 @@ None yet.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-11T16:36:49Z
|
||||
Stopped at: Completed 02-04-PLAN.md (Phase 02 gap closure complete)
|
||||
Resume file: .planning/phases/02-transaction-search-diagrams/02-04-SUMMARY.md
|
||||
Last session: 2026-03-11T19:40:20.248Z
|
||||
Stopped at: Completed 04-02-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
267
.planning/phases/03-agent-registry-sse-push/03-01-PLAN.md
Normal file
267
.planning/phases/03-agent-registry-sse-push/03-01-PLAN.md
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: 03-agent-registry-sse-push
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentInfo.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentState.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentCommand.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandStatus.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentEventListener.java
|
||||
- cameleer3-server-core/src/test/java/com/cameleer3/server/core/agent/AgentRegistryServiceTest.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java
|
||||
- cameleer3-server-app/src/main/resources/application.yml
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AGNT-01
|
||||
- AGNT-02
|
||||
- AGNT-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Agent can register via POST /api/v1/agents/register with agentId, name, group, version, routeIds, capabilities and receive a response containing SSE endpoint URL and server config"
|
||||
- "Re-registration with the same agentId resumes existing identity (transitions back to LIVE, updates metadata)"
|
||||
- "Agent can send heartbeat via POST /api/v1/agents/{id}/heartbeat and receive 200 (or 404 if unknown)"
|
||||
- "Server transitions agents LIVE->STALE after 90s without heartbeat, STALE->DEAD 5 minutes after staleTransitionTime"
|
||||
- "Agent list endpoint GET /api/v1/agents returns all agents, filterable by ?status=LIVE|STALE|DEAD"
|
||||
artifacts:
|
||||
- path: "cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java"
|
||||
provides: "Agent registration, heartbeat, lifecycle transitions, find/filter"
|
||||
- path: "cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentInfo.java"
|
||||
provides: "Agent record with id, name, group, version, routeIds, capabilities, state, timestamps"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java"
|
||||
provides: "POST /register, POST /{id}/heartbeat, GET /agents endpoints"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java"
|
||||
provides: "@Scheduled lifecycle transitions LIVE->STALE->DEAD"
|
||||
key_links:
|
||||
- from: "AgentRegistrationController"
|
||||
to: "AgentRegistryService"
|
||||
via: "constructor injection"
|
||||
pattern: "registryService\\.register|registryService\\.heartbeat"
|
||||
- from: "AgentLifecycleMonitor"
|
||||
to: "AgentRegistryService"
|
||||
via: "@Scheduled lifecycle check"
|
||||
pattern: "registry\\.transitionState"
|
||||
- from: "AgentRegistryBeanConfig"
|
||||
to: "AgentRegistryService"
|
||||
via: "@Bean factory method"
|
||||
pattern: "new AgentRegistryService"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the agent registry domain model, registration/heartbeat REST endpoints, and lifecycle monitoring.
|
||||
|
||||
Purpose: Agents need to register with the server, send periodic heartbeats, and the server must track their LIVE/STALE/DEAD states. This is the foundation that the SSE push layer (Plan 02) builds on.
|
||||
Output: Core domain types (AgentInfo, AgentState, AgentCommand, CommandStatus, CommandType), AgentRegistryService in core module, registration/heartbeat/list controllers in app module, lifecycle monitor, unit + integration tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-agent-registry-sse-push/03-CONTEXT.md
|
||||
@.planning/phases/03-agent-registry-sse-push/03-RESEARCH.md
|
||||
|
||||
@cameleer3-server-core/src/main/java/com/cameleer3/server/core/ingestion/IngestionService.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/IngestionBeanConfig.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/IngestionConfig.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/ingestion/ClickHouseFlushScheduler.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java
|
||||
@cameleer3-server-app/src/main/resources/application.yml
|
||||
@cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
|
||||
|
||||
<interfaces>
|
||||
<!-- Established codebase patterns the executor must follow -->
|
||||
|
||||
Pattern: Core module plain class, app module bean config:
|
||||
- IngestionService is a plain Java class (no Spring annotations) in core module
|
||||
- IngestionBeanConfig is @Configuration in app module that creates the bean
|
||||
- IngestionConfig is @ConfigurationProperties in app module for YAML binding
|
||||
|
||||
Pattern: Controller accepts raw String body:
|
||||
- Controllers use @RequestBody String body, parse with ObjectMapper
|
||||
- Return ResponseEntity with serialized JSON string
|
||||
|
||||
Pattern: @Scheduled for periodic tasks:
|
||||
- ClickHouseFlushScheduler uses @Scheduled(fixedDelayString = "${ingestion.flush-interval-ms:1000}")
|
||||
- @EnableScheduling already on Cameleer3ServerApplication
|
||||
|
||||
Pattern: @EnableConfigurationProperties registration:
|
||||
- Cameleer3ServerApplication has @EnableConfigurationProperties(IngestionConfig.class)
|
||||
- New config classes must be added to this annotation
|
||||
|
||||
Pattern: ProtocolVersionInterceptor:
|
||||
- WebConfig registers interceptor for "/api/v1/data/**", "/api/v1/agents/**"
|
||||
- Agent endpoints already covered -- agents must send X-Cameleer-Protocol-Version:1 header
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Core domain types and AgentRegistryService with unit tests</name>
|
||||
<files>
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentInfo.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentState.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentCommand.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandStatus.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentEventListener.java,
|
||||
cameleer3-server-core/src/test/java/com/cameleer3/server/core/agent/AgentRegistryServiceTest.java
|
||||
</files>
|
||||
<behavior>
|
||||
- register: new agent ID creates AgentInfo with state LIVE, returns AgentInfo
|
||||
- register: same agent ID re-registers (updates metadata, transitions to LIVE, updates lastHeartbeat and registeredAt)
|
||||
- heartbeat: known agent updates lastHeartbeat and transitions STALE back to LIVE, returns true
|
||||
- heartbeat: unknown agent returns false
|
||||
- lifecycle: LIVE agent with lastHeartbeat > staleThresholdMs transitions to STALE (staleTransitionTime recorded)
|
||||
- lifecycle: STALE agent where now - staleTransitionTime > deadThresholdMs transitions to DEAD
|
||||
- lifecycle: DEAD agent remains DEAD (no auto-purge)
|
||||
- findAll: returns all agents regardless of state
|
||||
- findByState: filters agents by AgentState
|
||||
- findById: returns null for unknown ID
|
||||
- addCommand: creates AgentCommand with PENDING status, returns command ID
|
||||
- acknowledgeCommand: transitions command from PENDING/DELIVERED to ACKNOWLEDGED
|
||||
- expireCommands: removes commands older than expiryMs with PENDING status
|
||||
- findPendingCommands: returns PENDING commands for given agentId
|
||||
</behavior>
|
||||
<action>
|
||||
Create the agent domain model in the core module (package com.cameleer3.server.core.agent):
|
||||
|
||||
1. **AgentState enum**: LIVE, STALE, DEAD
|
||||
|
||||
2. **CommandType enum**: CONFIG_UPDATE, DEEP_TRACE, REPLAY
|
||||
|
||||
3. **CommandStatus enum**: PENDING, DELIVERED, ACKNOWLEDGED, EXPIRED
|
||||
|
||||
4. **AgentInfo**: Mutable class (not record -- needs state transitions) with fields:
|
||||
- id (String), name (String), group (String), version (String)
|
||||
- routeIds (List<String>), capabilities (Map<String, Object>)
|
||||
- state (AgentState), registeredAt (Instant), lastHeartbeat (Instant)
|
||||
- staleTransitionTime (Instant, nullable -- set when transitioning to STALE)
|
||||
- Use synchronized methods or volatile fields for thread safety since ConcurrentHashMap only protects the map, not the values.
|
||||
- Actually, prefer immutable-style: store as records in the ConcurrentHashMap and use computeIfPresent to atomically swap. AgentInfo can be a record with wither-style methods (withState, withLastHeartbeat, etc.).
|
||||
|
||||
5. **AgentCommand**: Record with fields: id (String, UUID), type (CommandType), payload (String -- raw JSON), targetAgentId (String), createdAt (Instant), status (CommandStatus). Provide withStatus method.
|
||||
|
||||
6. **AgentEventListener**: Interface with methods `onCommandReady(String agentId, AgentCommand command)` -- this allows the SSE layer (Plan 02) to be notified when a command is added. The core module defines the interface; the app module implements it.
|
||||
|
||||
7. **AgentRegistryService**: Plain class (no Spring annotations), constructor takes staleThresholdMs (long), deadThresholdMs (long), commandExpiryMs (long). Uses ConcurrentHashMap<String, AgentInfo> for agents and ConcurrentHashMap<String, List<AgentCommand>> (or ConcurrentHashMap<String, ConcurrentLinkedQueue<AgentCommand>>) for pending commands per agent.
|
||||
|
||||
Methods:
|
||||
- `register(String id, String name, String group, String version, List<String> routeIds, Map<String, Object> capabilities)` -> AgentInfo
|
||||
- `heartbeat(String id)` -> boolean
|
||||
- `transitionState(String id, AgentState newState)` -> void (used by lifecycle monitor)
|
||||
- `checkLifecycle()` -> void (iterates all agents, applies LIVE->STALE and STALE->DEAD based on thresholds)
|
||||
- `findById(String id)` -> AgentInfo (nullable)
|
||||
- `findAll()` -> List<AgentInfo>
|
||||
- `findByState(AgentState state)` -> List<AgentInfo>
|
||||
- `addCommand(String agentId, CommandType type, String payload)` -> AgentCommand (creates with PENDING, calls eventListener.onCommandReady if set)
|
||||
- `acknowledgeCommand(String agentId, String commandId)` -> boolean
|
||||
- `findPendingCommands(String agentId)` -> List<AgentCommand>
|
||||
- `markDelivered(String agentId, String commandId)` -> void
|
||||
- `expireOldCommands()` -> void (sweep commands older than commandExpiryMs)
|
||||
- `setEventListener(AgentEventListener listener)` -> void (optional, for SSE integration)
|
||||
|
||||
Write tests FIRST (RED), then implement (GREEN). Test class: AgentRegistryServiceTest.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>mvn test -pl cameleer3-server-core -Dtest=AgentRegistryServiceTest</automated>
|
||||
</verify>
|
||||
<done>All unit tests pass: registration (new + re-register), heartbeat (known + unknown), lifecycle transitions (LIVE->STALE->DEAD, heartbeat revives STALE), findAll/findByState/findById, command add/acknowledge/expire. AgentEventListener interface defined.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Registration/heartbeat/list controllers, config, lifecycle monitor, integration tests</name>
|
||||
<files>
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java,
|
||||
cameleer3-server-app/src/main/resources/application.yml,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
|
||||
</files>
|
||||
<action>
|
||||
Wire the agent registry into the Spring Boot app and create REST endpoints:
|
||||
|
||||
1. **AgentRegistryConfig** (@ConfigurationProperties prefix "agent-registry"):
|
||||
- heartbeatIntervalMs (long, default 30000)
|
||||
- staleThresholdMs (long, default 90000)
|
||||
- deadThresholdMs (long, default 300000) -- this is 5 minutes from staleTransitionTime, NOT from lastHeartbeat
|
||||
- pingIntervalMs (long, default 15000)
|
||||
- commandExpiryMs (long, default 60000)
|
||||
- lifecycleCheckIntervalMs (long, default 10000)
|
||||
Follow IngestionConfig pattern: plain class with getters/setters.
|
||||
|
||||
2. **AgentRegistryBeanConfig** (@Configuration):
|
||||
- @Bean AgentRegistryService: `new AgentRegistryService(config.getStaleThresholdMs(), config.getDeadThresholdMs(), config.getCommandExpiryMs())`
|
||||
Follow IngestionBeanConfig pattern.
|
||||
|
||||
3. **Update Cameleer3ServerApplication**: Add AgentRegistryConfig.class to @EnableConfigurationProperties.
|
||||
|
||||
4. **Update application.yml**: Add agent-registry section with all defaults (see RESEARCH.md code example). Also add `spring.mvc.async.request-timeout: -1` for SSE support (Plan 02 needs it, but set it now).
|
||||
|
||||
5. **AgentLifecycleMonitor** (@Component):
|
||||
- Inject AgentRegistryService
|
||||
- @Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}") calls registryService.checkLifecycle() and registryService.expireOldCommands()
|
||||
- Follow ClickHouseFlushScheduler pattern but simpler (no SmartLifecycle needed -- agent state is ephemeral)
|
||||
|
||||
6. **AgentRegistrationController** (@RestController, @RequestMapping("/api/v1/agents")):
|
||||
- Inject AgentRegistryService, ObjectMapper
|
||||
- `POST /register`: Accept raw String body, parse JSON with ObjectMapper. Extract: agentId (required), name (required), group (default "default"), version, routeIds (default empty list), capabilities (default empty map). Call registryService.register(). Build response JSON: { agentId, sseEndpoint: "/api/v1/agents/{agentId}/events", heartbeatIntervalMs: from config, serverPublicKey: null (Phase 4 placeholder) }. Return 200.
|
||||
- `POST /{id}/heartbeat`: Call registryService.heartbeat(id). Return 200 if true, 404 if false.
|
||||
- `GET /`: Accept optional @RequestParam status. If status provided, parse to AgentState and call findByState. Otherwise call findAll. Serialize with ObjectMapper, return 200. Handle invalid status with 400.
|
||||
- Add @Tag(name = "Agent Management") and @Operation annotations for OpenAPI.
|
||||
|
||||
7. **AgentRegistrationControllerIT** (extends AbstractClickHouseIT):
|
||||
- Test register new agent: POST /api/v1/agents/register with valid payload, assert 200, response contains agentId and sseEndpoint
|
||||
- Test re-register same agent: register twice with same ID, assert second returns 200, state is LIVE
|
||||
- Test heartbeat known agent: register then heartbeat, assert 200
|
||||
- Test heartbeat unknown agent: heartbeat without register, assert 404
|
||||
- Test list all agents: register 2 agents, GET /api/v1/agents, assert both returned
|
||||
- Test list by status filter: register agent, GET /api/v1/agents?status=LIVE, assert filtered correctly
|
||||
- Test invalid status filter: GET /api/v1/agents?status=INVALID, assert 400
|
||||
- All requests must include X-Cameleer-Protocol-Version:1 header (ProtocolVersionInterceptor covers /api/v1/agents/**)
|
||||
- Use TestRestTemplate (already available from AbstractClickHouseIT's @SpringBootTest)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>mvn test -pl cameleer3-server-core,cameleer3-server-app -Dtest="Agent*"</automated>
|
||||
</verify>
|
||||
<done>POST /register returns 200 with agentId + sseEndpoint + heartbeatIntervalMs. POST /{id}/heartbeat returns 200 for known agents, 404 for unknown. GET /agents returns all agents with optional ?status= filter. AgentLifecycleMonitor runs on schedule. All integration tests pass. mvn clean verify passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
mvn clean verify -- full suite green (existing Phase 1+2 tests still pass, new agent tests pass)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Agent registration flow works end-to-end via REST
|
||||
- Heartbeat updates agent state correctly
|
||||
- Lifecycle monitor transitions LIVE->STALE->DEAD based on configured thresholds
|
||||
- Agent list endpoint with optional status filter returns correct results
|
||||
- All 7+ integration tests pass
|
||||
- Existing test suite unbroken
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-agent-registry-sse-push/03-01-SUMMARY.md`
|
||||
</output>
|
||||
133
.planning/phases/03-agent-registry-sse-push/03-01-SUMMARY.md
Normal file
133
.planning/phases/03-agent-registry-sse-push/03-01-SUMMARY.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
phase: 03-agent-registry-sse-push
|
||||
plan: 01
|
||||
subsystem: agent-registry
|
||||
tags: [concurrenthashmap, lifecycle, heartbeat, rest-api, spring-scheduled]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-ingestion-pipeline
|
||||
provides: IngestionBeanConfig pattern, @Scheduled pattern, ProtocolVersionInterceptor
|
||||
provides:
|
||||
- AgentRegistryService with register/heartbeat/lifecycle/command management
|
||||
- AgentInfo record with wither-style immutable state transitions
|
||||
- AgentCommand record with delivery status tracking
|
||||
- AgentEventListener interface for SSE bridge (Plan 02)
|
||||
- POST /api/v1/agents/register endpoint
|
||||
- POST /api/v1/agents/{id}/heartbeat endpoint
|
||||
- GET /api/v1/agents endpoint with ?status= filter
|
||||
- AgentLifecycleMonitor with LIVE->STALE->DEAD transitions
|
||||
- AgentRegistryConfig with all timing properties
|
||||
affects: [03-02-sse-push, 04-security]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [immutable-record-with-wither, compute-if-present-atomic-swap, agent-lifecycle-state-machine]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentInfo.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentState.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentCommand.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandStatus.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/CommandType.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentEventListener.java
|
||||
- cameleer3-server-core/src/test/java/com/cameleer3/server/core/agent/AgentRegistryServiceTest.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
|
||||
modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java
|
||||
- cameleer3-server-app/src/main/resources/application.yml
|
||||
|
||||
key-decisions:
|
||||
- "AgentInfo as Java record with wither-style methods for immutable ConcurrentHashMap swapping"
|
||||
- "Dead threshold measured from staleTransitionTime, not lastHeartbeat (matches requirement precisely)"
|
||||
- "spring.mvc.async.request-timeout=-1 set now for SSE support in Plan 02"
|
||||
|
||||
patterns-established:
|
||||
- "Immutable record + ConcurrentHashMap.compute for thread-safe state transitions"
|
||||
- "AgentEventListener interface in core module as bridge to SSE layer in app module"
|
||||
|
||||
requirements-completed: [AGNT-01, AGNT-02, AGNT-03]
|
||||
|
||||
# Metrics
|
||||
duration: 15min
|
||||
completed: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 3 Plan 1: Agent Registry Summary
|
||||
|
||||
**In-memory agent registry with ConcurrentHashMap, LIVE/STALE/DEAD lifecycle via @Scheduled, and REST endpoints for registration/heartbeat/listing**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 15 min
|
||||
- **Started:** 2026-03-11T17:26:34Z
|
||||
- **Completed:** 2026-03-11T17:41:24Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 15
|
||||
|
||||
## Accomplishments
|
||||
- Agent registry domain model with 5 types (AgentInfo, AgentState, AgentCommand, CommandStatus, CommandType)
|
||||
- Full lifecycle management: register, heartbeat, LIVE->STALE->DEAD transitions with configurable thresholds
|
||||
- Command queue with PENDING/DELIVERED/ACKNOWLEDGED/EXPIRED status tracking and event listener bridge
|
||||
- REST endpoints: POST /register, POST /{id}/heartbeat, GET /agents with ?status= filter
|
||||
- 23 unit tests + 7 integration tests all passing
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1 (RED): Failing tests for agent registry** - `4cd7ed9` (test)
|
||||
2. **Task 1 (GREEN): Implement agent registry service** - `61f3902` (feat)
|
||||
3. **Task 2: Controllers, config, lifecycle monitor, integration tests** - `0372be2` (feat)
|
||||
|
||||
_Note: Task 1 used TDD with separate RED/GREEN commits_
|
||||
|
||||
## Files Created/Modified
|
||||
- `AgentInfo.java` - Immutable record with wither-style methods for atomic state transitions
|
||||
- `AgentState.java` - LIVE, STALE, DEAD lifecycle enum
|
||||
- `AgentCommand.java` - Command record with delivery status tracking
|
||||
- `CommandStatus.java` - PENDING, DELIVERED, ACKNOWLEDGED, EXPIRED enum
|
||||
- `CommandType.java` - CONFIG_UPDATE, DEEP_TRACE, REPLAY enum
|
||||
- `AgentRegistryService.java` - Core registry: register, heartbeat, lifecycle, commands
|
||||
- `AgentEventListener.java` - Interface for SSE bridge (Plan 02 integration point)
|
||||
- `AgentRegistryConfig.java` - @ConfigurationProperties for all timing settings
|
||||
- `AgentRegistryBeanConfig.java` - @Configuration wiring AgentRegistryService
|
||||
- `AgentLifecycleMonitor.java` - @Scheduled lifecycle check and command expiry
|
||||
- `AgentRegistrationController.java` - REST endpoints for agents
|
||||
- `AgentRegistryServiceTest.java` - 23 unit tests
|
||||
- `AgentRegistrationControllerIT.java` - 7 integration tests
|
||||
- `Cameleer3ServerApplication.java` - Added AgentRegistryConfig to @EnableConfigurationProperties
|
||||
- `application.yml` - Added agent-registry config section and spring.mvc.async.request-timeout
|
||||
|
||||
## Decisions Made
|
||||
- Used Java record with wither-style methods for AgentInfo instead of mutable class -- ConcurrentHashMap.compute provides atomic swapping without needing synchronized fields
|
||||
- Dead threshold measured from staleTransitionTime field (not lastHeartbeat) to match the "5 minutes after going STALE" requirement precisely
|
||||
- Set spring.mvc.async.request-timeout=-1 proactively for SSE support needed in Plan 02
|
||||
- Command queue uses ConcurrentLinkedQueue per agent for lock-free command management
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- DiagramRenderControllerIT has a pre-existing flaky failure (EmptyResultDataAccess in seedDiagram) unrelated to Phase 3 changes. Logged in deferred-items.md.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- AgentRegistryService ready for SSE integration via AgentEventListener interface
|
||||
- Plan 02 (SSE Push) can wire SseConnectionManager as AgentEventListener implementation
|
||||
- All agent endpoints under /api/v1/agents/ already covered by ProtocolVersionInterceptor
|
||||
|
||||
---
|
||||
*Phase: 03-agent-registry-sse-push*
|
||||
*Completed: 2026-03-11*
|
||||
251
.planning/phases/03-agent-registry-sse-push/03-02-PLAN.md
Normal file
251
.planning/phases/03-agent-registry-sse-push/03-02-PLAN.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
phase: 03-agent-registry-sse-push
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["03-01"]
|
||||
files_modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AGNT-04
|
||||
- AGNT-05
|
||||
- AGNT-06
|
||||
- AGNT-07
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Registered agent can open SSE stream at GET /api/v1/agents/{id}/events and receive events"
|
||||
- "Server pushes config-update events to a specific agent's SSE stream via POST /api/v1/agents/{id}/commands"
|
||||
- "Server pushes deep-trace commands to a specific agent's SSE stream with correlationId in payload"
|
||||
- "Server pushes replay commands to a specific agent's SSE stream"
|
||||
- "Server can target commands to all agents in a group via POST /api/v1/agents/groups/{group}/commands"
|
||||
- "Server can broadcast commands to all live agents via POST /api/v1/agents/commands"
|
||||
- "SSE stream receives ping keepalive comments every 15 seconds"
|
||||
- "SSE events include event ID for Last-Event-ID reconnection support (no replay of missed events)"
|
||||
- "Agent can acknowledge command receipt via POST /api/v1/agents/{id}/commands/{commandId}/ack"
|
||||
artifacts:
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java"
|
||||
provides: "Per-agent SseEmitter management, event sending, ping keepalive"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java"
|
||||
provides: "GET /{id}/events SSE endpoint"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java"
|
||||
provides: "POST command endpoints (single, group, broadcast) + ack endpoint"
|
||||
key_links:
|
||||
- from: "AgentCommandController"
|
||||
to: "SseConnectionManager"
|
||||
via: "sendEvent for command delivery"
|
||||
pattern: "connectionManager\\.sendEvent"
|
||||
- from: "AgentCommandController"
|
||||
to: "AgentRegistryService"
|
||||
via: "addCommand + findByState/findByGroup"
|
||||
pattern: "registryService\\.addCommand"
|
||||
- from: "SseConnectionManager"
|
||||
to: "AgentEventListener"
|
||||
via: "implements interface, receives command notifications"
|
||||
pattern: "implements AgentEventListener"
|
||||
- from: "AgentSseController"
|
||||
to: "SseConnectionManager"
|
||||
via: "connect() returns SseEmitter"
|
||||
pattern: "connectionManager\\.connect"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build SSE connection management and command push infrastructure for real-time agent communication.
|
||||
|
||||
Purpose: The server needs to push config-update, deep-trace, and replay commands to connected agents in real time via Server-Sent Events. This completes the bidirectional communication channel (agents POST data to server, server pushes commands via SSE).
|
||||
Output: SseConnectionManager, SSE endpoint, command controller (single/group/broadcast targeting), command acknowledgement, ping keepalive, Last-Event-ID support, integration tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-agent-registry-sse-push/03-CONTEXT.md
|
||||
@.planning/phases/03-agent-registry-sse-push/03-RESEARCH.md
|
||||
@.planning/phases/03-agent-registry-sse-push/03-01-SUMMARY.md
|
||||
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
@cameleer3-server-app/src/main/resources/application.yml
|
||||
@cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 (must exist before this plan executes) -->
|
||||
|
||||
From cameleer3-server-core/.../agent/AgentInfo.java:
|
||||
```java
|
||||
// Record or class with fields:
|
||||
// id, name, group, version, routeIds, capabilities, state, registeredAt, lastHeartbeat, staleTransitionTime
|
||||
// Methods: withState(), withLastHeartbeat(), etc.
|
||||
```
|
||||
|
||||
From cameleer3-server-core/.../agent/AgentState.java:
|
||||
```java
|
||||
public enum AgentState { LIVE, STALE, DEAD }
|
||||
```
|
||||
|
||||
From cameleer3-server-core/.../agent/CommandType.java:
|
||||
```java
|
||||
public enum CommandType { CONFIG_UPDATE, DEEP_TRACE, REPLAY }
|
||||
```
|
||||
|
||||
From cameleer3-server-core/.../agent/CommandStatus.java:
|
||||
```java
|
||||
public enum CommandStatus { PENDING, DELIVERED, ACKNOWLEDGED, EXPIRED }
|
||||
```
|
||||
|
||||
From cameleer3-server-core/.../agent/AgentCommand.java:
|
||||
```java
|
||||
// Record: id (UUID string), type (CommandType), payload (String JSON), targetAgentId, createdAt, status
|
||||
// Method: withStatus()
|
||||
```
|
||||
|
||||
From cameleer3-server-core/.../agent/AgentEventListener.java:
|
||||
```java
|
||||
public interface AgentEventListener {
|
||||
void onCommandReady(String agentId, AgentCommand command);
|
||||
}
|
||||
```
|
||||
|
||||
From cameleer3-server-core/.../agent/AgentRegistryService.java:
|
||||
```java
|
||||
// Key methods:
|
||||
// register(id, name, group, version, routeIds, capabilities) -> AgentInfo
|
||||
// heartbeat(id) -> boolean
|
||||
// findById(id) -> AgentInfo
|
||||
// findAll() -> List<AgentInfo>
|
||||
// findByState(state) -> List<AgentInfo>
|
||||
// addCommand(agentId, type, payload) -> AgentCommand
|
||||
// acknowledgeCommand(agentId, commandId) -> boolean
|
||||
// markDelivered(agentId, commandId) -> void
|
||||
// setEventListener(listener) -> void
|
||||
```
|
||||
|
||||
From cameleer3-server-app/.../config/AgentRegistryConfig.java:
|
||||
```java
|
||||
// @ConfigurationProperties(prefix = "agent-registry")
|
||||
// getPingIntervalMs(), getCommandExpiryMs(), etc.
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: SseConnectionManager, SSE controller, and command controller</name>
|
||||
<files>
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
</files>
|
||||
<action>
|
||||
Build the SSE infrastructure and command delivery system:
|
||||
|
||||
1. **SseConnectionManager** (@Component, implements AgentEventListener):
|
||||
- ConcurrentHashMap<String, SseEmitter> emitters for per-agent connections
|
||||
- Inject AgentRegistryConfig for ping interval, inject AgentRegistryService (call setEventListener(this) in @PostConstruct)
|
||||
- `connect(String agentId)`: Create SseEmitter(Long.MAX_VALUE). Register onCompletion/onTimeout/onError callbacks that remove the emitter ONLY if the current map value is the same instance (reference equality via == check to avoid Pitfall 3 from research). Replace existing emitter with put(), complete() old one if exists. Return new emitter.
|
||||
- `sendEvent(String agentId, String eventId, String eventType, Object data)`: Get emitter from map, send SseEmitter.event().id(eventId).name(eventType).data(data, MediaType.APPLICATION_JSON). Catch IOException, remove emitter, return false. Return true on success.
|
||||
- `sendPingToAll()`: Iterate emitters, send comment("ping") to each. Remove on IOException.
|
||||
- `isConnected(String agentId)`: Check if emitter exists in map.
|
||||
- `onCommandReady(String agentId, AgentCommand command)`: Attempt sendEvent with command.id() as eventId, command.type().name().toLowerCase().replace('_', '-') as event name (config-update, deep-trace, replay), command.payload() as data. If successful, call registryService.markDelivered(agentId, command.id()). If agent not connected, command stays PENDING (caller can re-send or it expires).
|
||||
- @Scheduled(fixedDelayString = "${agent-registry.ping-interval-ms:15000}") pingAll(): calls sendPingToAll()
|
||||
|
||||
2. **Update AgentRegistryBeanConfig**: After creating AgentRegistryService bean, the SseConnectionManager (auto-scanned as @Component) will call setEventListener in @PostConstruct. No change needed in bean config if SseConnectionManager handles it. BUT -- to avoid circular dependency, SseConnectionManager should inject AgentRegistryService and call setEventListener(this) in @PostConstruct.
|
||||
|
||||
3. **AgentSseController** (@RestController, @RequestMapping("/api/v1/agents")):
|
||||
- Inject SseConnectionManager, AgentRegistryService
|
||||
- `GET /{id}/events` (produces TEXT_EVENT_STREAM_VALUE): Check agent exists via registryService.findById(id). If null, return 404 (throw ResponseStatusException). Read Last-Event-ID header (optional) -- log it at debug level but do NOT replay missed events (per locked decision). Call connectionManager.connect(id), return the SseEmitter.
|
||||
- Add @Tag(name = "Agent SSE") and @Operation annotations.
|
||||
|
||||
4. **AgentCommandController** (@RestController, @RequestMapping("/api/v1/agents")):
|
||||
- Inject AgentRegistryService, SseConnectionManager, ObjectMapper
|
||||
- `POST /{id}/commands`: Accept raw String body. Parse JSON: { "type": "config-update|deep-trace|replay", "payload": {...} }. Map type string to CommandType enum (config-update -> CONFIG_UPDATE, deep-trace -> DEEP_TRACE, replay -> REPLAY). Call registryService.addCommand(id, type, payloadJsonString). The AgentEventListener.onCommandReady in SseConnectionManager handles delivery. Return 202 with { commandId, status: "PENDING" or "DELIVERED" depending on whether agent is connected }.
|
||||
- `POST /groups/{group}/commands`: Same body parsing. Find all LIVE agents in group via registryService.findAll() filtered by group. For each, call registryService.addCommand(). Return 202 with { commandIds: [...], targetCount: N }.
|
||||
- `POST /commands`: Broadcast to all LIVE agents. Same pattern as group but uses registryService.findByState(LIVE). Return 202 with count.
|
||||
- `POST /{id}/commands/{commandId}/ack`: Call registryService.acknowledgeCommand(id, commandId). Return 200 if true, 404 if false.
|
||||
- Add @Tag(name = "Agent Commands") and @Operation annotations.
|
||||
|
||||
5. **Update WebConfig**: The SSE endpoint GET /api/v1/agents/{id}/events is already covered by the interceptor pattern "/api/v1/agents/**". Agents send the protocol version header on all requests (per research recommendation), so no exclusion needed. However, if the SSE GET causes issues because browsers/clients may not easily add custom headers to EventSource, add the SSE events path to excludePathPatterns: `/api/v1/agents/*/events`. This is a practical consideration -- add the exclusion to be safe.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>mvn compile -pl cameleer3-server-core,cameleer3-server-app</automated>
|
||||
</verify>
|
||||
<done>SseConnectionManager, AgentSseController, and AgentCommandController compile. SSE endpoint returns SseEmitter. Command endpoints accept type/payload and deliver via SSE. Ping keepalive scheduled. WebConfig updated if needed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Integration tests for SSE, commands, and full flow</name>
|
||||
<files>
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
|
||||
</files>
|
||||
<action>
|
||||
Write integration tests covering SSE connection, command delivery, ping, and acknowledgement:
|
||||
|
||||
**SSE Test Strategy** (from RESEARCH.md): Testing SSE with TestRestTemplate is non-trivial. Use one of these approaches:
|
||||
- Option A (preferred): Use raw HttpURLConnection or java.net.http.HttpClient to open the SSE stream in a separate thread, read lines, and assert event format.
|
||||
- Option B: Use Spring WebClient (from spring-boot-starter-webflux test dependency) -- BUT do not add webflux as a main dependency, only as test scope if needed.
|
||||
- Option C: Test at the service layer by calling SseConnectionManager.connect() directly, then sendEvent(), and reading from the SseEmitter via a custom handler.
|
||||
|
||||
Recommend Option A (HttpClient) for true end-to-end testing without adding dependencies.
|
||||
|
||||
1. **AgentSseControllerIT** (extends AbstractClickHouseIT):
|
||||
- Test SSE connect for registered agent: Register agent, open GET /{id}/events with Accept: text/event-stream. Assert 200 and content-type is text/event-stream.
|
||||
- Test SSE connect for unknown agent: GET /unknown-id/events, assert 404.
|
||||
- Test config-update delivery: Register agent, open SSE stream (background thread), POST /{id}/commands with {"type":"config-update","payload":{"key":"value"}}. Use Awaitility to assert SSE stream received event with name "config-update" and correct data.
|
||||
- Test deep-trace delivery: Same pattern with {"type":"deep-trace","payload":{"correlationId":"test-123"}}.
|
||||
- Test replay delivery: Same pattern with {"type":"replay","payload":{"exchangeId":"ex-456"}}.
|
||||
- Test ping keepalive: Open SSE stream, wait for ping comment (may need to set ping interval low in test config or use Awaitility with timeout). Assert ":ping" comment received.
|
||||
- Test Last-Event-ID header: Open SSE with Last-Event-ID header set. Assert connection succeeds (no replay, just acknowledges).
|
||||
- All POST requests include X-Cameleer-Protocol-Version:1 header. SSE GET may need the header excluded in WebConfig (test will reveal if this is an issue).
|
||||
- Use Awaitility with ignoreExceptions() for async assertions (established pattern).
|
||||
|
||||
2. **AgentCommandControllerIT** (extends AbstractClickHouseIT):
|
||||
- Test single agent command: Register agent, POST /{id}/commands, assert 202 with commandId.
|
||||
- Test group command: Register 2 agents in same group, POST /groups/{group}/commands, assert 202 with targetCount=2.
|
||||
- Test broadcast command: Register 3 agents, POST /commands, assert 202 with count of LIVE agents.
|
||||
- Test command ack: Send command, POST /{id}/commands/{commandId}/ack, assert 200.
|
||||
- Test ack unknown command: POST /{id}/commands/unknown-id/ack, assert 404.
|
||||
- Test command to unregistered agent: POST /nonexistent/commands, assert 404.
|
||||
|
||||
**Test configuration**: If ping interval needs to be shorter for tests, add to test application.yml or use @TestPropertySource with agent-registry.ping-interval-ms=1000.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>mvn test -pl cameleer3-server-core,cameleer3-server-app -Dtest="Agent*"</automated>
|
||||
</verify>
|
||||
<done>All SSE integration tests pass: connect/disconnect, config-update/deep-trace/replay delivery via SSE, ping keepalive received, Last-Event-ID accepted, command targeting (single/group/broadcast), command acknowledgement. mvn clean verify passes with all existing tests still green.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
mvn clean verify -- full suite green (all Phase 1, 2, and 3 tests pass)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SSE endpoint returns working event stream for registered agents
|
||||
- config-update, deep-trace, and replay commands delivered via SSE in real time
|
||||
- Group and broadcast targeting works correctly
|
||||
- Ping keepalive sent every 15 seconds
|
||||
- Last-Event-ID header accepted (no replay, per decision)
|
||||
- Command acknowledgement endpoint works
|
||||
- All integration tests pass
|
||||
- Full mvn clean verify passes
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-agent-registry-sse-push/03-02-SUMMARY.md`
|
||||
</output>
|
||||
116
.planning/phases/03-agent-registry-sse-push/03-02-SUMMARY.md
Normal file
116
.planning/phases/03-agent-registry-sse-push/03-02-SUMMARY.md
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
phase: 03-agent-registry-sse-push
|
||||
plan: 02
|
||||
subsystem: agent-sse
|
||||
tags: [sse, server-sent-events, sseemitter, command-push, ping-keepalive, spring-scheduled]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-agent-registry-sse-push
|
||||
provides: AgentRegistryService, AgentEventListener, AgentCommand, CommandType, AgentRegistryConfig
|
||||
provides:
|
||||
- SseConnectionManager with per-agent SseEmitter management and event delivery
|
||||
- AgentSseController GET /api/v1/agents/{id}/events SSE endpoint
|
||||
- AgentCommandController with single/group/broadcast command targeting
|
||||
- Command acknowledgement endpoint POST /{id}/commands/{commandId}/ack
|
||||
- Ping keepalive every 15 seconds via @Scheduled
|
||||
- Last-Event-ID header support (no replay)
|
||||
affects: [04-security]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [sse-emitter-per-agent, reference-equality-removal, async-command-delivery-via-event-listener]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
|
||||
modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
- cameleer3-server-app/src/test/resources/application-test.yml
|
||||
|
||||
key-decisions:
|
||||
- "SSE events path excluded from ProtocolVersionInterceptor for EventSource client compatibility"
|
||||
- "SseConnectionManager uses reference-equality (==) in onCompletion/onTimeout/onError to avoid removing a newer emitter"
|
||||
- "java.net.http.HttpClient async API for SSE integration tests to avoid test thread blocking"
|
||||
|
||||
patterns-established:
|
||||
- "AgentEventListener bridge: core module fires event, app module @Component delivers via SSE"
|
||||
- "CountDownLatch + async HttpClient for SSE integration test assertions"
|
||||
|
||||
requirements-completed: [AGNT-04, AGNT-05, AGNT-06, AGNT-07]
|
||||
|
||||
# Metrics
|
||||
duration: 32min
|
||||
completed: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 3 Plan 2: SSE Push Summary
|
||||
|
||||
**SSE connection manager with per-agent SseEmitter, config-update/deep-trace/replay command delivery, group/broadcast targeting, ping keepalive, and command acknowledgement**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 32 min
|
||||
- **Started:** 2026-03-11T17:44:10Z
|
||||
- **Completed:** 2026-03-11T18:16:10Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 7
|
||||
|
||||
## Accomplishments
|
||||
- SseConnectionManager with ConcurrentHashMap-based per-agent SSE emitter management, ping keepalive, and AgentEventListener bridge
|
||||
- Three command targeting levels: single agent, group, and broadcast to all LIVE agents
|
||||
- 7 SSE integration tests (connect, 404 unknown, config-update/deep-trace/replay delivery, ping, Last-Event-ID) + 6 command controller tests
|
||||
- All 71 tests pass with mvn clean verify
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: SseConnectionManager, SSE controller, and command controller** - `5746886` (feat)
|
||||
2. **Task 2: Integration tests for SSE, commands, and full flow** - `a1909ba` (test)
|
||||
|
||||
## Files Created/Modified
|
||||
- `SseConnectionManager.java` - Per-agent SseEmitter management, event delivery, ping keepalive via @Scheduled
|
||||
- `AgentSseController.java` - GET /{id}/events SSE endpoint with Last-Event-ID support
|
||||
- `AgentCommandController.java` - POST command endpoints (single/group/broadcast) + ack endpoint
|
||||
- `AgentSseControllerIT.java` - 7 SSE integration tests using async HttpClient
|
||||
- `AgentCommandControllerIT.java` - 6 command controller integration tests
|
||||
- `WebConfig.java` - Added SSE events path to interceptor exclusion list
|
||||
- `application-test.yml` - Added 1s ping interval for faster SSE test assertions
|
||||
|
||||
## Decisions Made
|
||||
- Excluded SSE events path from ProtocolVersionInterceptor -- EventSource clients cannot easily add custom headers, so the SSE endpoint is exempted from protocol version checking
|
||||
- Used reference equality (==) in SseEmitter callbacks to avoid removing a newer emitter when an old one completes -- directly addresses Pitfall 3 from research
|
||||
- Used java.net.http.HttpClient async API for SSE integration tests instead of adding spring-boot-starter-webflux -- avoids new dependencies and tests true end-to-end behavior
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
- Surefire fork JVM hangs ~30s after SSE tests complete due to async HttpClient threads holding JVM open -- not a test failure, just slow shutdown. Surefire eventually kills the fork.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Full bidirectional agent communication complete: agents POST data, server pushes commands via SSE
|
||||
- Phase 4 (Security) can add JWT auth to all endpoints and Ed25519 config signing
|
||||
- All agent endpoints under /api/v1/agents/ ready for auth layer
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 5 created files exist on disk
|
||||
- Commit `5746886` found in git log (Task 1)
|
||||
- Commit `a1909ba` found in git log (Task 2)
|
||||
- `mvn clean verify` passes with 71 tests, 0 failures
|
||||
|
||||
---
|
||||
*Phase: 03-agent-registry-sse-push*
|
||||
*Completed: 2026-03-11*
|
||||
95
.planning/phases/03-agent-registry-sse-push/03-CONTEXT.md
Normal file
95
.planning/phases/03-agent-registry-sse-push/03-CONTEXT.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Phase 3: Agent Registry + SSE Push - Context
|
||||
|
||||
**Gathered:** 2026-03-11
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Server tracks connected agents through their full lifecycle (LIVE/STALE/DEAD) and can push configuration updates, deep-trace commands, and replay commands to specific agents (or groups/all) in real time via SSE. JWT auth enforcement and Ed25519 signing are Phase 4 — this phase builds the registration flow, heartbeat lifecycle, SSE streams, and command push infrastructure.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Agent lifecycle timing
|
||||
- Heartbeat interval: 30 seconds
|
||||
- STALE threshold: 90 seconds (3 missed heartbeats)
|
||||
- DEAD threshold: 5 minutes after going STALE
|
||||
- DEAD agents kept indefinitely (no auto-purge)
|
||||
- Agent list endpoint returns all agents (LIVE, STALE, DEAD) with `?status=` filter parameter
|
||||
|
||||
### SSE command model
|
||||
- Generic command endpoint: `POST /api/v1/agents/{id}/commands` with `{"type": "config-update|deep-trace|replay", "payload": {...}}`
|
||||
- Three targeting levels: single agent (`/agents/{id}/commands`), group (`/agents/groups/{group}/commands`), all live agents (`/agents/commands`)
|
||||
- Agent self-declares group name at registration (e.g., "order-service-prod")
|
||||
- Command delivery tracking: server tracks each command as PENDING until agent acknowledges (via dedicated ack mechanism)
|
||||
- Pending commands expire after 60 seconds if undelivered
|
||||
|
||||
### Registration handshake
|
||||
- Agent provides its own persistent ID at registration (from agent config)
|
||||
- Rich registration payload: agent ID, name, group, version, list of route IDs, capabilities
|
||||
- Re-registration with same ID resumes existing identity (agent restart scenario)
|
||||
- Heartbeat is just a ping — no metadata update (agent re-registers if routes/version change)
|
||||
- Registration response includes: SSE endpoint URL, current server config (heartbeat interval, etc.), server public key placeholder (Phase 4)
|
||||
|
||||
### SSE reconnection behavior
|
||||
- Last-Event-ID supported but does NOT replay missed events — only future events delivered on reconnect
|
||||
- Pending commands are NOT auto-pushed on reconnect — caller must re-send if needed
|
||||
- SSE ping/keepalive interval: 15 seconds
|
||||
|
||||
### Claude's Discretion
|
||||
- In-memory vs persistent storage for agent registry (in-memory is fine for v1, ClickHouse later if needed)
|
||||
- Command acknowledgement mechanism details (heartbeat piggyback vs dedicated endpoint)
|
||||
- SSE implementation approach (Spring SseEmitter, WebFlux, or other)
|
||||
- Thread scheduling for lifecycle state transitions (scheduled executor, Spring @Scheduled)
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- HA/LB group targeting enables fleet-wide operations like config rollouts across all instances of a service
|
||||
- Agent-provided persistent IDs mean the agent controls its identity — useful for containerized deployments where hostname changes but agent config persists
|
||||
- 60-second command expiry is aggressive — commands are time-sensitive operations (deep-trace, config-update) that lose relevance quickly
|
||||
|
||||
</specifics>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `ProtocolVersionInterceptor` already registered for `/api/v1/agents/**` paths — interceptor infrastructure ready
|
||||
- `WebConfig` already has the path pattern for agent endpoints
|
||||
- `IngestionService` pattern (core module plain class, wired as bean by config in app module) — reuse for AgentRegistryService
|
||||
- `WriteBuffer<T>` pattern — potential reuse for command queuing if needed
|
||||
- `ObjectMapper` with `JavaTimeModule` already configured for Instant serialization
|
||||
|
||||
### Established Patterns
|
||||
- Core module: interfaces + domain logic; App module: Spring Boot + implementations
|
||||
- Controllers accept raw String body; services handle deserialization
|
||||
- Spring `@Scheduled` used by `ClickHouseFlushScheduler` — pattern for heartbeat monitor scheduling
|
||||
- `application.yml` for configurable intervals — add agent registry config section
|
||||
|
||||
### Integration Points
|
||||
- New endpoints under `/api/v1/agents/` path (already in interceptor registry)
|
||||
- Agent ID from registration becomes the `agentId` field used in existing ingestion endpoints
|
||||
- SSE stream is a new connection type — first use of server-push in the codebase
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Server-side agent tags/labels for more flexible grouping — future enhancement
|
||||
- Auto-push pending commands on reconnect — evaluate after v1 usage patterns emerge
|
||||
- Last-Event-ID replay of missed events — complexity vs value tradeoff, defer to v2
|
||||
- Agent capability negotiation (feature flags for what commands an agent supports) — future phase
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-agent-registry-sse-push*
|
||||
*Context gathered: 2026-03-11*
|
||||
514
.planning/phases/03-agent-registry-sse-push/03-RESEARCH.md
Normal file
514
.planning/phases/03-agent-registry-sse-push/03-RESEARCH.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# Phase 3: Agent Registry + SSE Push - Research
|
||||
|
||||
**Researched:** 2026-03-11
|
||||
**Domain:** Agent lifecycle management, Server-Sent Events (SSE), in-memory registry
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This phase adds agent registration, heartbeat-based lifecycle management (LIVE/STALE/DEAD), and real-time command push via SSE to the Cameleer3 server. The technology stack is straightforward: Spring MVC's `SseEmitter` for server-push, `ConcurrentHashMap` for the in-memory agent registry, and `@Scheduled` for periodic lifecycle checks (same pattern already used by `ClickHouseFlushScheduler`).
|
||||
|
||||
The main architectural challenge is managing per-agent SSE connections reliably -- handling disconnections, timeouts, and cleanup without leaking threads or emitters. The command delivery model (PENDING with 60s expiry, acknowledgement) adds a second concurrent data structure to manage alongside the registry itself.
|
||||
|
||||
**Primary recommendation:** Use Spring MVC `SseEmitter` (already on classpath via `spring-boot-starter-web`). No new dependencies required. Follow the established core-module-plain-class / app-module-Spring-bean pattern. Agent registry service in core, SSE connection manager and controllers in app.
|
||||
|
||||
<user_constraints>
|
||||
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- Heartbeat interval: 30 seconds
|
||||
- STALE threshold: 90 seconds (3 missed heartbeats)
|
||||
- DEAD threshold: 5 minutes after going STALE
|
||||
- DEAD agents kept indefinitely (no auto-purge)
|
||||
- Agent list endpoint returns all agents (LIVE, STALE, DEAD) with `?status=` filter parameter
|
||||
- Generic command endpoint: `POST /api/v1/agents/{id}/commands` with `{"type": "config-update|deep-trace|replay", "payload": {...}}`
|
||||
- Three targeting levels: single agent, group, all live agents
|
||||
- Agent self-declares group name at registration
|
||||
- Command delivery tracking: PENDING until acknowledged, 60s expiry
|
||||
- Agent provides its own persistent ID at registration
|
||||
- Rich registration payload: agent ID, name, group, version, list of route IDs, capabilities
|
||||
- Re-registration with same ID resumes existing identity
|
||||
- Heartbeat is just a ping -- no metadata update
|
||||
- Registration response includes: SSE endpoint URL, current server config, server public key placeholder
|
||||
- Last-Event-ID supported but does NOT replay missed events
|
||||
- Pending commands NOT auto-pushed on reconnect
|
||||
- SSE ping/keepalive interval: 15 seconds
|
||||
|
||||
### Claude's Discretion
|
||||
- In-memory vs persistent storage for agent registry (in-memory is fine for v1)
|
||||
- Command acknowledgement mechanism details (heartbeat piggyback vs dedicated endpoint)
|
||||
- SSE implementation approach (Spring SseEmitter, WebFlux, or other)
|
||||
- Thread scheduling for lifecycle state transitions
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Server-side agent tags/labels for more flexible grouping
|
||||
- Auto-push pending commands on reconnect
|
||||
- Last-Event-ID replay of missed events
|
||||
- Agent capability negotiation
|
||||
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| AGNT-01 (#13) | Agent registers via POST /api/v1/agents/register with bootstrap token, receives JWT + server public key | Registration controller + service; JWT/security enforcement deferred to Phase 4 but flow must work end-to-end |
|
||||
| AGNT-02 (#14) | Server maintains agent registry with LIVE/STALE/DEAD lifecycle based on heartbeat timing | In-memory ConcurrentHashMap registry + @Scheduled lifecycle monitor |
|
||||
| AGNT-03 (#15) | Agent sends heartbeat via POST /api/v1/agents/{id}/heartbeat every 30s | Heartbeat endpoint updates lastHeartbeat timestamp, transitions STALE back to LIVE |
|
||||
| AGNT-04 (#16) | Server pushes config-update events to agents via SSE (Ed25519 signature deferred to Phase 4) | SseEmitter per-agent connection + command push infrastructure |
|
||||
| AGNT-05 (#17) | Server pushes deep-trace commands to agents via SSE for specific correlationIds | Same SSE command push mechanism with deep-trace type |
|
||||
| AGNT-06 (#18) | Server pushes replay commands to agents via SSE (signed replay tokens deferred to Phase 4) | Same SSE command push mechanism with replay type |
|
||||
| AGNT-07 (#19) | SSE connection includes ping keepalive and supports Last-Event-ID reconnection | 15s ping via @Scheduled, Last-Event-ID header read on connect |
|
||||
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Spring MVC SseEmitter | 6.2.x (via Boot 3.4.3) | Server-Sent Events | Already on classpath, servlet-based (matches existing stack), no WebFlux needed |
|
||||
| ConcurrentHashMap | JDK 17 | Agent registry storage | Thread-safe, O(1) lookup by agent ID, no external dependency |
|
||||
| Spring @Scheduled | 6.2.x (via Boot 3.4.3) | Lifecycle monitor + SSE keepalive | Already enabled in application, proven pattern in ClickHouseFlushScheduler |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| Jackson ObjectMapper | 2.17.3 (managed) | Command serialization/deserialization | Already configured with JavaTimeModule, used throughout codebase |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| SseEmitter (MVC) | WebFlux Flux<ServerSentEvent> | Would require adding spring-boot-starter-webflux and mixing reactive/servlet stacks -- unnecessary complexity for this use case |
|
||||
| ConcurrentHashMap | Redis/ClickHouse persistence | Over-engineering for v1; in-memory is sufficient since agent state is ephemeral and rebuilt on reconnect |
|
||||
| @Scheduled | ScheduledExecutorService | @Scheduled already works, already enabled; raw executor only needed for complex scheduling |
|
||||
|
||||
**Installation:**
|
||||
No new dependencies required. Everything is already on the classpath.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/
|
||||
├── agent/
|
||||
│ ├── AgentInfo.java # Record: id, name, group, version, routeIds, capabilities, state, timestamps
|
||||
│ ├── AgentState.java # Enum: LIVE, STALE, DEAD
|
||||
│ ├── AgentRegistryService.java # Plain class: register, heartbeat, findById, findAll, lifecycle transitions
|
||||
│ ├── AgentCommand.java # Record: id, type, payload, targetAgentId, createdAt, status
|
||||
│ └── CommandStatus.java # Enum: PENDING, DELIVERED, ACKNOWLEDGED, EXPIRED
|
||||
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/
|
||||
├── config/
|
||||
│ ├── AgentRegistryConfig.java # @ConfigurationProperties(prefix = "agent-registry")
|
||||
│ └── AgentRegistryBeanConfig.java # @Configuration: wires AgentRegistryService as bean
|
||||
├── controller/
|
||||
│ ├── AgentRegistrationController.java # POST /register, POST /{id}/heartbeat, GET /agents
|
||||
│ ├── AgentCommandController.java # POST /{id}/commands, POST /groups/{group}/commands, POST /commands
|
||||
│ └── AgentSseController.java # GET /{id}/events (SSE stream)
|
||||
├── agent/
|
||||
│ ├── SseConnectionManager.java # @Component: ConcurrentHashMap<agentId, SseEmitter>, ping scheduler
|
||||
│ └── AgentLifecycleMonitor.java # @Component: @Scheduled lifecycle check (like ClickHouseFlushScheduler)
|
||||
```
|
||||
|
||||
### Pattern 1: Core Module Plain Class + App Module Bean Config
|
||||
**What:** Domain logic in core module as plain Java classes; Spring wiring in app module via @Configuration
|
||||
**When to use:** Always -- this is the established codebase pattern
|
||||
**Example:**
|
||||
```java
|
||||
// Core module: plain class, no Spring annotations
|
||||
public class AgentRegistryService {
|
||||
private final ConcurrentHashMap<String, AgentInfo> agents = new ConcurrentHashMap<>();
|
||||
|
||||
public AgentInfo register(String id, String name, String group, String version,
|
||||
List<String> routeIds, Map<String, Object> capabilities) {
|
||||
AgentInfo existing = agents.get(id);
|
||||
if (existing != null) {
|
||||
// Re-registration: update metadata, transition back to LIVE
|
||||
AgentInfo updated = existing.withState(AgentState.LIVE)
|
||||
.withLastHeartbeat(Instant.now());
|
||||
agents.put(id, updated);
|
||||
return updated;
|
||||
}
|
||||
AgentInfo agent = new AgentInfo(id, name, group, version, routeIds,
|
||||
capabilities, AgentState.LIVE, Instant.now(), Instant.now());
|
||||
agents.put(id, agent);
|
||||
return agent;
|
||||
}
|
||||
|
||||
public boolean heartbeat(String id) {
|
||||
return agents.computeIfPresent(id, (k, v) ->
|
||||
v.withState(AgentState.LIVE).withLastHeartbeat(Instant.now())) != null;
|
||||
}
|
||||
}
|
||||
|
||||
// App module: bean config
|
||||
@Configuration
|
||||
public class AgentRegistryBeanConfig {
|
||||
@Bean
|
||||
public AgentRegistryService agentRegistryService() {
|
||||
return new AgentRegistryService();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: SseEmitter Per-Agent Connection
|
||||
**What:** Each agent has one SseEmitter stored in ConcurrentHashMap, managed by a dedicated component
|
||||
**When to use:** For all SSE connections to agents
|
||||
**Example:**
|
||||
```java
|
||||
@Component
|
||||
public class SseConnectionManager {
|
||||
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
|
||||
public SseEmitter connect(String agentId) {
|
||||
// Use Long.MAX_VALUE timeout -- we manage keepalive ourselves
|
||||
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
|
||||
|
||||
emitter.onCompletion(() -> emitters.remove(agentId));
|
||||
emitter.onTimeout(() -> emitters.remove(agentId));
|
||||
emitter.onError(e -> emitters.remove(agentId));
|
||||
|
||||
// Replace any existing emitter (agent reconnect)
|
||||
SseEmitter old = emitters.put(agentId, emitter);
|
||||
if (old != null) {
|
||||
old.complete(); // Close stale connection
|
||||
}
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
public boolean sendEvent(String agentId, String eventId, String eventType, Object data) {
|
||||
SseEmitter emitter = emitters.get(agentId);
|
||||
if (emitter == null) return false;
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.id(eventId)
|
||||
.name(eventType)
|
||||
.data(data, MediaType.APPLICATION_JSON));
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
emitters.remove(agentId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void sendPingToAll() {
|
||||
emitters.forEach((id, emitter) -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().comment("ping"));
|
||||
} catch (IOException e) {
|
||||
emitters.remove(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Lifecycle Monitor via @Scheduled
|
||||
**What:** Periodic task checks all agents' lastHeartbeat timestamps and transitions states
|
||||
**When to use:** For LIVE->STALE and STALE->DEAD transitions
|
||||
**Example:**
|
||||
```java
|
||||
@Component
|
||||
public class AgentLifecycleMonitor {
|
||||
private final AgentRegistryService registry;
|
||||
private final AgentRegistryConfig config;
|
||||
|
||||
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
||||
public void checkLifecycle() {
|
||||
Instant now = Instant.now();
|
||||
for (AgentInfo agent : registry.findAll()) {
|
||||
Duration sinceHeartbeat = Duration.between(agent.lastHeartbeat(), now);
|
||||
if (agent.state() == AgentState.LIVE
|
||||
&& sinceHeartbeat.toMillis() > config.getStaleThresholdMs()) {
|
||||
registry.transitionState(agent.id(), AgentState.STALE);
|
||||
} else if (agent.state() == AgentState.STALE
|
||||
&& sinceHeartbeat.toMillis() > config.getStaleThresholdMs() + config.getDeadThresholdMs()) {
|
||||
registry.transitionState(agent.id(), AgentState.DEAD);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Mixing WebFlux and MVC:** Do not add spring-boot-starter-webflux. The project uses servlet-based MVC. Adding WebFlux creates classpath conflicts and ambiguity.
|
||||
- **Sharing SseEmitter across threads without protection:** Always use ConcurrentHashMap and handle IOException on every send. A failed send means the client disconnected.
|
||||
- **Storing SseEmitter in the core module:** SseEmitter is a Spring MVC class. Keep it in the app module only. The core module should define interfaces for "push event to agent" that the app module implements.
|
||||
- **Not setting SseEmitter timeout:** Default timeout is server-dependent (often 30s). Use `Long.MAX_VALUE` and manage keepalive yourself.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| SSE protocol | Custom HTTP streaming | Spring SseEmitter | Handles text/event-stream format, event IDs, retry fields automatically |
|
||||
| Thread-safe map | Synchronized HashMap | ConcurrentHashMap | Lock-free reads, segmented writes, battle-tested |
|
||||
| Periodic scheduling | Manual Thread/Timer | @Scheduled + @EnableScheduling | Already configured, integrates with Spring lifecycle |
|
||||
| JSON serialization | Manual string building | ObjectMapper (already configured) | Handles Instant, unknown fields, all edge cases |
|
||||
| Async request timeout | Manual thread management | spring.mvc.async.request-timeout config | Spring handles Tomcat async timeout correctly |
|
||||
|
||||
**Key insight:** SSE in Spring MVC is a well-supported, first-class feature. The SseEmitter API handles the wire protocol; your job is managing the lifecycle of emitters (create, store, cleanup, send).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: SseEmitter Default Timeout Kills Long-Lived Connections
|
||||
**What goes wrong:** Emitter times out after 30s (Tomcat default), client gets disconnected
|
||||
**Why it happens:** Not setting explicit timeout on SseEmitter constructor
|
||||
**How to avoid:** Always use `new SseEmitter(Long.MAX_VALUE)`. Also set `spring.mvc.async.request-timeout=-1` in application.yml to disable the MVC-level async timeout
|
||||
**Warning signs:** Clients disconnecting every 30 seconds, reconnection storms
|
||||
|
||||
### Pitfall 2: IOException on Send Not Handled
|
||||
**What goes wrong:** Client disconnects but server keeps trying to send, gets IOException, does not clean up
|
||||
**Why it happens:** Not wrapping every `emitter.send()` in try-catch
|
||||
**How to avoid:** Every send must catch IOException, remove the emitter from the map, and log at debug level (not error -- disconnects are normal)
|
||||
**Warning signs:** Growing emitter map, increasing IOExceptions in logs
|
||||
|
||||
### Pitfall 3: Race Condition on Agent Reconnect
|
||||
**What goes wrong:** Agent disconnects and reconnects rapidly; old emitter and new emitter both exist briefly
|
||||
**Why it happens:** `onCompletion` callback of old emitter fires after new emitter is stored, removing the new one
|
||||
**How to avoid:** Use `ConcurrentHashMap.put()` which returns the old value. Only remove in callbacks if the emitter in the map is still the same instance (reference equality check)
|
||||
**Warning signs:** Agent SSE stream stops working after reconnect
|
||||
|
||||
### Pitfall 4: Tomcat Thread Exhaustion with SSE
|
||||
**What goes wrong:** Each SSE connection holds a Tomcat thread (with default sync mode)
|
||||
**Why it happens:** MVC SseEmitter uses Servlet 3.1 async support but the async processing still occupies a thread from the pool during the initial request
|
||||
**How to avoid:** Spring Boot's default Tomcat thread pool (200 threads) is sufficient for dozens to low hundreds of agents. If scaling beyond that, configure `server.tomcat.threads.max`. For thousands of agents, consider WebFlux (but that is a v2 concern)
|
||||
**Warning signs:** Thread pool exhaustion, connection refused errors
|
||||
|
||||
### Pitfall 5: Command Expiry Not Cleaned Up
|
||||
**What goes wrong:** Expired PENDING commands accumulate in memory
|
||||
**Why it happens:** No scheduled task to clean them up
|
||||
**How to avoid:** The lifecycle monitor (or a separate @Scheduled task) should also sweep expired commands every check cycle
|
||||
**Warning signs:** Memory growth over time, stale commands in API responses
|
||||
|
||||
### Pitfall 6: SSE Endpoint Blocked by ProtocolVersionInterceptor
|
||||
**What goes wrong:** SSE GET request rejected because it lacks `X-Cameleer-Protocol-Version` header
|
||||
**Why it happens:** WebConfig already registers the interceptor for `/api/v1/agents/**` which includes the SSE endpoint
|
||||
**How to avoid:** Either add the protocol header requirement to agents (recommended -- agents already send it for POST requests) or exclude the SSE endpoint path from the interceptor
|
||||
**Warning signs:** 400 errors on SSE connect attempts
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Registration Controller
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@Tag(name = "Agent Management", description = "Agent registration and lifecycle endpoints")
|
||||
public class AgentRegistrationController {
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Register an agent")
|
||||
public ResponseEntity<String> register(@RequestBody String body) throws JsonProcessingException {
|
||||
// Parse registration payload
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
String agentId = node.get("agentId").asText();
|
||||
String name = node.get("name").asText();
|
||||
String group = node.has("group") ? node.get("group").asText() : "default";
|
||||
// ... extract other fields
|
||||
|
||||
AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities);
|
||||
|
||||
// Build registration response
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("agentId", agent.id());
|
||||
response.put("sseEndpoint", "/api/v1/agents/" + agentId + "/events");
|
||||
response.put("heartbeatIntervalMs", 30000);
|
||||
response.put("serverPublicKey", null); // Phase 4
|
||||
// JWT token placeholder -- Phase 4 will add real JWT
|
||||
response.put("token", "placeholder-" + agentId);
|
||||
|
||||
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/heartbeat")
|
||||
@Operation(summary = "Agent heartbeat ping")
|
||||
public ResponseEntity<Void> heartbeat(@PathVariable String id) {
|
||||
boolean found = registryService.heartbeat(id);
|
||||
if (!found) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all agents")
|
||||
public ResponseEntity<String> listAgents(
|
||||
@RequestParam(required = false) String status) throws JsonProcessingException {
|
||||
List<AgentInfo> agents;
|
||||
if (status != null) {
|
||||
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
|
||||
agents = registryService.findByState(stateFilter);
|
||||
} else {
|
||||
agents = registryService.findAll();
|
||||
}
|
||||
return ResponseEntity.ok(objectMapper.writeValueAsString(agents));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SSE Controller
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@Tag(name = "Agent SSE", description = "Server-Sent Events for agent communication")
|
||||
public class AgentSseController {
|
||||
|
||||
private final SseConnectionManager connectionManager;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
@GetMapping(value = "/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@Operation(summary = "SSE event stream for an agent")
|
||||
public SseEmitter subscribe(
|
||||
@PathVariable String id,
|
||||
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
|
||||
|
||||
AgentInfo agent = registryService.findById(id);
|
||||
if (agent == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not registered");
|
||||
}
|
||||
|
||||
// Last-Event-ID acknowledged but no replay (per decision)
|
||||
if (lastEventId != null) {
|
||||
log.debug("Agent {} reconnected with Last-Event-ID: {} (no replay)", id, lastEventId);
|
||||
}
|
||||
|
||||
return connectionManager.connect(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command Acknowledgement Endpoint (Recommended: Dedicated Endpoint)
|
||||
```java
|
||||
@PostMapping("/{id}/commands/{commandId}/ack")
|
||||
@Operation(summary = "Acknowledge command receipt")
|
||||
public ResponseEntity<Void> acknowledgeCommand(
|
||||
@PathVariable String id,
|
||||
@PathVariable String commandId) {
|
||||
boolean acknowledged = registryService.acknowledgeCommand(id, commandId);
|
||||
if (!acknowledged) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
```
|
||||
|
||||
### Application Configuration Addition
|
||||
```yaml
|
||||
# application.yml additions
|
||||
agent-registry:
|
||||
heartbeat-interval-ms: 30000
|
||||
stale-threshold-ms: 90000
|
||||
dead-threshold-ms: 300000 # 5 minutes after last heartbeat (not after going stale)
|
||||
ping-interval-ms: 15000
|
||||
command-expiry-ms: 60000
|
||||
lifecycle-check-interval-ms: 10000
|
||||
|
||||
spring:
|
||||
mvc:
|
||||
async:
|
||||
request-timeout: -1 # Disable async timeout for SSE
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Polling for agent status | SSE push for commands | Always SSE for server-push | Immediate delivery, lower latency |
|
||||
| WebFlux for SSE | MVC SseEmitter | Spring 4.2+ | MVC SseEmitter is sufficient for moderate scale; no need for reactive stack |
|
||||
| Custom HTTP streaming | SseEmitter.event() builder | Spring 4.2+ | Wire protocol handled automatically |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `ResponseBodyEmitter` directly for SSE: Use `SseEmitter` which extends it with SSE-specific features
|
||||
- `DeferredResult` for server push: Only for single-value responses, not streams
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Command acknowledgement: dedicated endpoint vs heartbeat piggyback**
|
||||
- What we know: Dedicated endpoint is simpler, more explicit, and decoupled from heartbeat
|
||||
- What's unclear: Whether agent-side implementation prefers one approach
|
||||
- Recommendation: Use dedicated `POST /{id}/commands/{commandId}/ack` endpoint. Cleaner separation of concerns, easier to test, and does not complicate the heartbeat path
|
||||
|
||||
2. **Dead threshold calculation: from last heartbeat or from STALE transition?**
|
||||
- What we know: CONTEXT.md says "5 minutes after going STALE"
|
||||
- What's unclear: Whether to track staleTransitionTime separately or compute from lastHeartbeat
|
||||
- Recommendation: Track `staleTransitionTime` in AgentInfo. Dead threshold = 5 minutes after `staleTransitionTime`. This matches the stated requirement precisely
|
||||
|
||||
3. **Async timeout vs SseEmitter timeout**
|
||||
- What we know: Both `spring.mvc.async.request-timeout` and `new SseEmitter(timeout)` affect SSE lifetime
|
||||
- What's unclear: Interaction between the two
|
||||
- Recommendation: Set `SseEmitter(Long.MAX_VALUE)` AND `spring.mvc.async.request-timeout=-1`. Belt and suspenders -- both disabled ensures no premature timeout
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | JUnit 5 + Spring Boot Test (via spring-boot-starter-test) |
|
||||
| Config file | pom.xml (Surefire + Failsafe configured) |
|
||||
| Quick run command | `mvn test -pl cameleer3-server-core -Dtest=AgentRegistryServiceTest` |
|
||||
| Full suite command | `mvn clean verify` |
|
||||
|
||||
### Phase Requirements to Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| AGNT-01 | Agent registers and gets response | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentRegistrationControllerIT#registerAgent*` | No - Wave 0 |
|
||||
| AGNT-02 | Lifecycle transitions LIVE/STALE/DEAD | unit | `mvn test -pl cameleer3-server-core -Dtest=AgentRegistryServiceTest#lifecycle*` | No - Wave 0 |
|
||||
| AGNT-03 | Heartbeat updates timestamp, returns 200/404 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentRegistrationControllerIT#heartbeat*` | No - Wave 0 |
|
||||
| AGNT-04 | Config-update pushed via SSE | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#configUpdate*` | No - Wave 0 |
|
||||
| AGNT-05 | Deep-trace command pushed via SSE | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#deepTrace*` | No - Wave 0 |
|
||||
| AGNT-06 | Replay command pushed via SSE | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#replay*` | No - Wave 0 |
|
||||
| AGNT-07 | SSE ping keepalive + Last-Event-ID | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#pingKeepalive*` | No - Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `mvn test -pl cameleer3-server-core,cameleer3-server-app -Dtest="Agent*"` (agent-related tests only)
|
||||
- **Per wave merge:** `mvn clean verify`
|
||||
- **Phase gate:** Full suite green before /gsd:verify-work
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `cameleer3-server-core/.../agent/AgentRegistryServiceTest.java` -- covers AGNT-02, AGNT-03 (unit tests for registry logic)
|
||||
- [ ] `cameleer3-server-app/.../controller/AgentRegistrationControllerIT.java` -- covers AGNT-01, AGNT-03
|
||||
- [ ] `cameleer3-server-app/.../controller/AgentSseControllerIT.java` -- covers AGNT-04, AGNT-05, AGNT-06, AGNT-07
|
||||
- [ ] `cameleer3-server-app/.../controller/AgentCommandControllerIT.java` -- covers command targeting (single, group, all)
|
||||
- [ ] No new framework install needed -- JUnit 5 + Spring Boot Test + Awaitility already in place
|
||||
|
||||
### SSE Test Strategy
|
||||
Testing SSE with `TestRestTemplate` requires special handling. Use Spring's `WebClient` from WebFlux test support or raw `HttpURLConnection` to read the SSE stream. Alternatively, test at the service layer (SseConnectionManager) with direct emitter interaction. The integration test should:
|
||||
1. Register agent via POST
|
||||
2. Open SSE connection (separate thread)
|
||||
3. Send command via POST
|
||||
4. Assert SSE stream received the event
|
||||
5. Verify with Awaitility for async assertions
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [SseEmitter Javadoc (Spring Framework 7.0.5)](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.html) - Full API reference
|
||||
- [Asynchronous Requests :: Spring Framework](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html) - Official async request handling docs
|
||||
- [Task Execution and Scheduling :: Spring Boot](https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html) - Official scheduling docs
|
||||
- Existing codebase: ClickHouseFlushScheduler, IngestionService, IngestionBeanConfig, WebConfig patterns
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Spring Boot SSE SseEmitter tutorial](https://nitinkc.github.io/microservices/sse-springboot/) - Complete guide with patterns
|
||||
- [SseEmitter timeout issue #4021](https://github.com/spring-projects/spring-boot/issues/4021) - Timeout handling gotchas
|
||||
- [SseEmitter response closed #19652](https://github.com/spring-projects/spring-framework/issues/19652) - Thread safety discussion
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Various Medium articles on SSE patterns - used for cross-referencing community patterns only
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - SseEmitter is built into Spring MVC, already on classpath, well-documented API
|
||||
- Architecture: HIGH - follows established codebase patterns (core plain class, app bean config, @Scheduled)
|
||||
- Pitfalls: HIGH - well-known issues documented in Spring GitHub issues and multiple sources
|
||||
- SSE test strategy: MEDIUM - SSE testing with TestRestTemplate is non-trivial, may need adaptation
|
||||
|
||||
**Research date:** 2026-03-11
|
||||
**Valid until:** 2026-04-11 (stable stack, no fast-moving dependencies)
|
||||
81
.planning/phases/03-agent-registry-sse-push/03-VALIDATION.md
Normal file
81
.planning/phases/03-agent-registry-sse-push/03-VALIDATION.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
phase: 3
|
||||
slug: agent-registry-sse-push
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 3 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | JUnit 5 + Spring Boot Test + Testcontainers ClickHouse 25.3 |
|
||||
| **Config file** | cameleer3-server-app/pom.xml (Surefire + Failsafe configured) |
|
||||
| **Quick run command** | `mvn test -pl cameleer3-server-core -Dtest=AgentRegistryServiceTest` |
|
||||
| **Full suite command** | `mvn clean verify` |
|
||||
| **Estimated runtime** | ~50 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `mvn test -pl cameleer3-server-core,cameleer3-server-app -Dtest="Agent*"`
|
||||
- **After every plan wave:** Run `mvn clean verify`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 50 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 03-01-01 | 01 | 1 | AGNT-01 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentRegistrationControllerIT#registerAgent*` | ❌ W0 | ⬜ pending |
|
||||
| 03-01-02 | 01 | 1 | AGNT-02 | unit | `mvn test -pl cameleer3-server-core -Dtest=AgentRegistryServiceTest#lifecycle*` | ❌ W0 | ⬜ pending |
|
||||
| 03-01-03 | 01 | 1 | AGNT-03 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentRegistrationControllerIT#heartbeat*` | ❌ W0 | ⬜ pending |
|
||||
| 03-02-01 | 02 | 1 | AGNT-04 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#configUpdate*` | ❌ W0 | ⬜ pending |
|
||||
| 03-02-02 | 02 | 1 | AGNT-05 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#deepTrace*` | ❌ W0 | ⬜ pending |
|
||||
| 03-02-03 | 02 | 1 | AGNT-06 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#replay*` | ❌ W0 | ⬜ pending |
|
||||
| 03-02-04 | 02 | 1 | AGNT-07 | integration | `mvn test -pl cameleer3-server-app -Dtest=AgentSseControllerIT#pingKeepalive*` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `AgentRegistryServiceTest.java` — unit test stubs for AGNT-02 (lifecycle transitions), AGNT-03 (heartbeat updates)
|
||||
- [ ] `AgentRegistrationControllerIT.java` — integration test stubs for AGNT-01 (registration), AGNT-03 (heartbeat)
|
||||
- [ ] `AgentSseControllerIT.java` — integration test stubs for AGNT-04, AGNT-05, AGNT-06, AGNT-07
|
||||
- [ ] `AgentCommandControllerIT.java` — integration test stubs for command targeting (single, group, all)
|
||||
|
||||
*Existing infrastructure covers test framework and Testcontainers setup.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| SSE connection survives proxy/LB | AGNT-07 | Requires real network infrastructure | Deploy behind nginx/HAProxy, verify SSE keepalive and reconnection |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 50s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
171
.planning/phases/03-agent-registry-sse-push/03-VERIFICATION.md
Normal file
171
.planning/phases/03-agent-registry-sse-push/03-VERIFICATION.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
phase: 03-agent-registry-sse-push
|
||||
verified: 2026-03-11T19:30:00Z
|
||||
status: passed
|
||||
score: 14/14 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 3: Agent Registry + SSE Push Verification Report
|
||||
|
||||
**Phase Goal:** Agent lifecycle management (LIVE/STALE/DEAD), SSE push for config/commands
|
||||
**Verified:** 2026-03-11
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (Plan 01)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Agent can register via POST /api/v1/agents/register and receive agentId + sseEndpoint + heartbeatIntervalMs | VERIFIED | `AgentRegistrationController.register()` returns all three fields; IT test `registerNewAgent_returns200WithAgentIdAndSseEndpoint` asserts them |
|
||||
| 2 | Re-registration with same agentId resumes LIVE state, updates metadata | VERIFIED | `AgentRegistryService.register()` uses `agents.compute()` with existing-check; IT test `reRegisterSameAgent_returns200WithLiveState` passes |
|
||||
| 3 | Agent can send heartbeat via POST /{id}/heartbeat — 200 for known, 404 for unknown | VERIFIED | `AgentRegistrationController.heartbeat()` returns 404 if `registryService.heartbeat()` returns false; both paths covered by IT tests |
|
||||
| 4 | Server transitions LIVE->STALE after 90s, STALE->DEAD 5min after staleTransitionTime | VERIFIED | `AgentRegistryService.checkLifecycle()` implements both transitions with threshold comparison; unit tests `liveAgentBeyondStaleThreshold_transitionsToStale` and `staleAgentBeyondDeadThreshold_transitionsToDead` pass with 1ms thresholds |
|
||||
| 5 | GET /api/v1/agents returns all agents, filterable by ?status= | VERIFIED | `AgentRegistrationController.listAgents()` calls `findByState()` or `findAll()`; IT tests cover filter, all-list, and invalid-status=400 |
|
||||
|
||||
### Observable Truths (Plan 02)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 6 | Registered agent can open SSE stream at GET /{id}/events and receive events | VERIFIED | `AgentSseController.events()` calls `connectionManager.connect()` returning `SseEmitter(Long.MAX_VALUE)`; IT test `sseConnect_registeredAgent_returnsEventStream` asserts 200 |
|
||||
| 7 | Server pushes config-update events to agent's SSE stream | VERIFIED | `AgentCommandController` -> `registryService.addCommand()` -> `SseConnectionManager.onCommandReady()` -> `sendEvent()` with event name `config-update`; IT test `configUpdateDelivery_receivedViaSseStream` asserts `event:config-update` and data in stream |
|
||||
| 8 | Server pushes deep-trace commands with correlationId in payload | VERIFIED | Same pipeline with `deep-trace` event type; IT test `deepTraceDelivery_receivedViaSseStream` asserts `event:deep-trace` and `test-123` in stream |
|
||||
| 9 | Server pushes replay commands | VERIFIED | Same pipeline with `replay` event type; IT test `replayDelivery_receivedViaSseStream` asserts `event:replay` and `ex-456` in stream |
|
||||
| 10 | Commands can target all agents in a group via POST /groups/{group}/commands | VERIFIED | `AgentCommandController.sendGroupCommand()` filters LIVE agents by group; IT test `sendGroupCommand_returns202WithTargetCount` asserts targetCount=2 for 2 agents in group |
|
||||
| 11 | Commands can be broadcast to all live agents via POST /commands | VERIFIED | `AgentCommandController.broadcastCommand()` uses `findByState(LIVE)`; IT test `broadcastCommand_returns202WithLiveAgentCount` asserts targetCount >= 1 |
|
||||
| 12 | SSE stream receives ping keepalive comment every 15s (1s in tests) | VERIFIED | `SseConnectionManager.pingAll()` sends `SseEmitter.event().comment("ping")`; scheduled at `${agent-registry.ping-interval-ms:15000}`; test config sets 1000ms; IT test `pingKeepalive_receivedViaSseStream` asserts `:ping` in stream |
|
||||
| 13 | SSE events include event ID for Last-Event-ID reconnection (no replay) | VERIFIED | `SseConnectionManager.sendEvent()` sets `.id(eventId)` where eventId is command UUID; `AgentSseController` accepts `Last-Event-ID` header and logs at debug (no replay per decision); IT test `lastEventIdHeader_connectionSucceeds` asserts 200 |
|
||||
| 14 | Agent can acknowledge command via POST /{id}/commands/{commandId}/ack | VERIFIED | `AgentCommandController.acknowledgeCommand()` calls `registryService.acknowledgeCommand()`; IT tests cover 200 on success and 404 on unknown command |
|
||||
|
||||
**Score: 14/14 truths verified**
|
||||
|
||||
---
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
### Plan 01 Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java` | Registration, heartbeat, lifecycle, find/filter, commands | VERIFIED | 281 lines; full implementation with ConcurrentHashMap, compute-based atomic swaps, eventListener bridge |
|
||||
| `cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentInfo.java` | Immutable record with all fields and wither methods | VERIFIED | 63 lines; record with 10 fields and 5 wither-style methods |
|
||||
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java` | POST /register, POST /{id}/heartbeat, GET /agents | VERIFIED | 153 lines; all three endpoints implemented with OpenAPI annotations |
|
||||
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java` | @Scheduled LIVE->STALE->DEAD transitions | VERIFIED | 37 lines; calls `registryService.checkLifecycle()` and `expireOldCommands()` on schedule |
|
||||
|
||||
### Plan 02 Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java` | Per-agent SseEmitter management, event sending, ping | VERIFIED | 158 lines; implements AgentEventListener, reference-equality removal, @PostConstruct registration |
|
||||
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java` | GET /{id}/events SSE endpoint | VERIFIED | 67 lines; checks agent exists, delegates to connectionManager.connect() |
|
||||
| `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentCommandController.java` | POST commands (single/group/broadcast) + ack | VERIFIED | 182 lines; all four endpoints implemented |
|
||||
|
||||
### Supporting Artifacts (confirmed present)
|
||||
|
||||
| Artifact | Status |
|
||||
|----------|--------|
|
||||
| `AgentState.java` (LIVE, STALE, DEAD) | VERIFIED |
|
||||
| `AgentCommand.java` (record with withStatus) | VERIFIED |
|
||||
| `CommandStatus.java` (PENDING/DELIVERED/ACKNOWLEDGED/EXPIRED) | VERIFIED |
|
||||
| `CommandType.java` (CONFIG_UPDATE/DEEP_TRACE/REPLAY) | VERIFIED |
|
||||
| `AgentEventListener.java` (interface) | VERIFIED |
|
||||
| `AgentRegistryConfig.java` (@ConfigurationProperties) | VERIFIED — all 6 timing properties with defaults |
|
||||
| `AgentRegistryBeanConfig.java` (@Configuration) | VERIFIED — creates AgentRegistryService with config values |
|
||||
| `application.yml` | VERIFIED — agent-registry section present; `spring.mvc.async.request-timeout: -1` present |
|
||||
| `application-test.yml` | VERIFIED — `agent-registry.ping-interval-ms: 1000` for fast SSE test assertions |
|
||||
| `Cameleer3ServerApplication.java` | VERIFIED — `AgentRegistryConfig.class` added to `@EnableConfigurationProperties` |
|
||||
|
||||
---
|
||||
|
||||
## Key Link Verification
|
||||
|
||||
### Plan 01 Key Links
|
||||
|
||||
| From | To | Via | Status | Evidence |
|
||||
|------|----|-----|--------|---------|
|
||||
| `AgentRegistrationController` | `AgentRegistryService` | Constructor injection | WIRED | Line 45-51: constructor accepts `registryService`; lines 88, 106, 125 call `registryService.register()`, `.heartbeat()`, `.findByState()`/`.findAll()` |
|
||||
| `AgentLifecycleMonitor` | `AgentRegistryService` | @Scheduled lifecycle check | WIRED | Line 27-35: `@Scheduled` method calls `registryService.checkLifecycle()` and `registryService.expireOldCommands()` |
|
||||
| `AgentRegistryBeanConfig` | `AgentRegistryService` | @Bean factory method | WIRED | Line 17: `new AgentRegistryService(config.getStaleThresholdMs(), ...)` |
|
||||
|
||||
### Plan 02 Key Links
|
||||
|
||||
| From | To | Via | Status | Evidence |
|
||||
|------|----|-----|--------|---------|
|
||||
| `AgentCommandController` | `SseConnectionManager` | sendEvent for command delivery | WIRED | Line 76: `connectionManager.isConnected(id)` for status reporting; actual delivery goes via event listener chain |
|
||||
| `AgentCommandController` | `AgentRegistryService` | addCommand + findByState | WIRED | Lines 74, 95-103, 122-127: `registryService.addCommand()`, `registryService.findAll()`, `registryService.findByState()`, `registryService.acknowledgeCommand()` |
|
||||
| `SseConnectionManager` | `AgentEventListener` | implements interface | WIRED | Line 27: `implements AgentEventListener`; line 137: `@Override onCommandReady()` |
|
||||
| `SseConnectionManager` | `AgentRegistryService` | @PostConstruct setEventListener | WIRED | Line 41-44: `registryService.setEventListener(this)` in `@PostConstruct init()` |
|
||||
| `AgentSseController` | `SseConnectionManager` | connect() returns SseEmitter | WIRED | Line 65: `return connectionManager.connect(id)` |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|-------------|--------|---------|
|
||||
| AGNT-01 (#13) | 03-01 | Agent registers via POST /api/v1/agents/register, receives JWT + server public key | SATISFIED | Registration endpoint works; `serverPublicKey` placeholder returns `null` (JWT/key deferred to Phase 4 per plan, endpoint structure present) |
|
||||
| AGNT-02 (#14) | 03-01 | Server maintains agent registry with LIVE/STALE/DEAD lifecycle based on heartbeat timing | SATISFIED | `AgentRegistryService.checkLifecycle()` + `AgentLifecycleMonitor` implement full LIVE->STALE->DEAD with configurable thresholds |
|
||||
| AGNT-03 (#15) | 03-01 | Agent sends heartbeat via POST /api/v1/agents/{id}/heartbeat every 30s | SATISFIED | Endpoint implemented; server advertises `heartbeatIntervalMs: 30000` in registration response |
|
||||
| AGNT-04 (#16) | 03-02 | Server pushes config-update events to agents via SSE with Ed25519 signature | SATISFIED* | SSE push for config-update implemented; Ed25519 signature deferred to Phase 4 (SECU-04); command payload pushed as raw JSON |
|
||||
| AGNT-05 (#17) | 03-02 | Server pushes deep-trace commands to agents via SSE for specific correlationIds | SATISFIED | `deep-trace` event type implemented; correlationId included in payload JSON |
|
||||
| AGNT-06 (#18) | 03-02 | Server pushes replay commands to agents via SSE with signed replay tokens | SATISFIED* | `replay` event type implemented; signing deferred to Phase 4 (SECU-04) |
|
||||
| AGNT-07 (#19) | 03-02 | SSE connection includes ping keepalive and supports Last-Event-ID reconnection | SATISFIED | Ping comment every 15s (1s in tests); Last-Event-ID header accepted; event IDs set on all events |
|
||||
|
||||
_* AGNT-04 and AGNT-06 require Ed25519 signing per the requirement text. The signing is explicitly deferred to Phase 4 (SECU-03/SECU-04). The SSE push infrastructure is complete and functional. The signing gap is tracked in Phase 4's scope, not a Phase 3 failure._
|
||||
|
||||
**No orphaned requirements** — all 7 AGNT requirements mapped to this phase appear in plan frontmatter and are accounted for.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns Found
|
||||
|
||||
| File | Pattern | Severity | Impact |
|
||||
|------|---------|----------|--------|
|
||||
| `AgentRegistrationController.java` line 96 | `serverPublicKey: null` placeholder | INFO | Intentional Phase 4 placeholder; no functional impact on Phase 3 goals |
|
||||
|
||||
No TODOs, FIXMEs, empty implementations, or stub returns found in any Phase 3 implementation files.
|
||||
|
||||
---
|
||||
|
||||
## Commit Verification
|
||||
|
||||
| Commit | Plan | Description | Verified |
|
||||
|--------|------|-------------|---------|
|
||||
| `4cd7ed9` | 03-01 | Failing tests (TDD RED) | Yes — in git log |
|
||||
| `61f3902` | 03-01 | Agent registry service implementation (TDD GREEN) | Yes — in git log |
|
||||
| `0372be2` | 03-01 | Controllers, config, lifecycle monitor | Yes — in git log |
|
||||
| `5746886` | 03-02 | SseConnectionManager, SSE controller, command controller | Yes — in git log |
|
||||
| `a1909ba` | 03-02 | SSE + command integration tests | Yes — in git log |
|
||||
|
||||
---
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
None. All automated checks pass. The SSE delivery path (command via HTTP -> SSE event on stream) is verified by integration tests using async `java.net.http.HttpClient` with `CountDownLatch` + `Awaitility` assertions.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 goal is fully achieved. The implementation delivers:
|
||||
|
||||
1. **Agent lifecycle management** — `AgentRegistryService` (plain Java, core module) implements full LIVE/STALE/DEAD state machine with configurable thresholds. `AgentLifecycleMonitor` drives periodic checks via `@Scheduled`. 23 unit tests cover all lifecycle transitions.
|
||||
|
||||
2. **REST endpoints** — Registration (POST /register), heartbeat (POST /{id}/heartbeat), and listing (GET /agents with ?status= filter) are fully implemented with OpenAPI documentation. 7 integration tests verify all paths including 400 for invalid filter.
|
||||
|
||||
3. **SSE push** — `SseConnectionManager` manages per-agent `SseEmitter` instances, implements `AgentEventListener` interface for zero-coupling event delivery from core to app layer. Ping keepalive at 15s (configurable). SSE events path excluded from `ProtocolVersionInterceptor` for EventSource client compatibility.
|
||||
|
||||
4. **Command targeting** — Single agent, group, and broadcast targeting all implemented. Command acknowledgement endpoint complete. Command queue with PENDING/DELIVERED/ACKNOWLEDGED/EXPIRED status tracking.
|
||||
|
||||
5. **Tests** — 23 unit tests + 7 + 13 integration tests (7 SSE + 6 command controller) = 43 tests covering Phase 3 code. Full suite of 71 tests passes per Summary.
|
||||
|
||||
The `serverPublicKey: null` placeholder and unsigned SSE payloads are intentional — Ed25519 signing is Phase 4 scope (SECU-03, SECU-04). The SSE transport infrastructure is complete and ready to carry signed payloads in Phase 4.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-11_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -0,0 +1,5 @@
|
||||
# Phase 3 Deferred Items
|
||||
|
||||
## Pre-existing Test Flakiness
|
||||
|
||||
- **DiagramRenderControllerIT.seedDiagram** - EmptyResultDataAccess error (expects 1 row, gets 0). This is a pre-existing ClickHouse timing issue not caused by Phase 3 changes. The test relies on data being flushed and available before the assertion, which can fail under timing pressure.
|
||||
203
.planning/phases/04-security/04-01-PLAN.md
Normal file
203
.planning/phases/04-security/04-01-PLAN.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
phase: 04-security
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- cameleer3-server-app/pom.xml
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
|
||||
- cameleer3-server-app/src/main/resources/application.yml
|
||||
- cameleer3-server-app/src/test/resources/application-test.yml
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SECU-03
|
||||
- SECU-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Ed25519 keypair is generated at server startup and public key is available as Base64"
|
||||
- "JwtService can create access tokens (1h expiry) and refresh tokens (7d expiry) with agentId and group claims"
|
||||
- "JwtService can validate tokens and extract agentId, distinguishing access vs refresh type"
|
||||
- "BootstrapTokenValidator accepts CAMELEER_AUTH_TOKEN and optionally CAMELEER_AUTH_TOKEN_PREVIOUS using constant-time comparison"
|
||||
- "Server fails fast on startup if CAMELEER_AUTH_TOKEN is not set"
|
||||
artifacts:
|
||||
- path: "cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java"
|
||||
provides: "JWT service interface with createAccessToken, createRefreshToken, validateAndExtractAgentId"
|
||||
- path: "cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java"
|
||||
provides: "Ed25519 signing interface with sign(payload) and getPublicKeyBase64()"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java"
|
||||
provides: "Nimbus JOSE+JWT HMAC-SHA256 implementation"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java"
|
||||
provides: "JDK 17 Ed25519 KeyPairGenerator implementation"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java"
|
||||
provides: "Constant-time bootstrap token validation with dual-token rotation"
|
||||
key_links:
|
||||
- from: "JwtServiceImpl"
|
||||
to: "Nimbus JOSE+JWT MACSigner/MACVerifier"
|
||||
via: "HMAC-SHA256 signing with ephemeral 256-bit secret"
|
||||
pattern: "MACSigner|MACVerifier|SignedJWT"
|
||||
- from: "Ed25519SigningServiceImpl"
|
||||
to: "JDK KeyPairGenerator/Signature"
|
||||
via: "Ed25519 algorithm from java.security"
|
||||
pattern: "KeyPairGenerator\\.getInstance.*Ed25519"
|
||||
- from: "BootstrapTokenValidator"
|
||||
to: "SecurityProperties"
|
||||
via: "reads token values from config properties"
|
||||
pattern: "MessageDigest\\.isEqual"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the security service foundation: interfaces in core module, implementations in app module, Maven dependencies, and configuration properties. This provides all cryptographic building blocks (JWT creation/validation, Ed25519 signing, bootstrap token validation) that the filter chain and endpoint integration plans depend on.
|
||||
|
||||
Purpose: Establishes the security primitives before they are wired into Spring Security and controllers.
|
||||
Output: Working JwtService, Ed25519SigningService, BootstrapTokenValidator with passing unit tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/04-security/04-CONTEXT.md
|
||||
@.planning/phases/04-security/04-RESEARCH.md
|
||||
@.planning/phases/04-security/04-VALIDATION.md
|
||||
|
||||
@cameleer3-server-app/pom.xml
|
||||
@cameleer3-server-app/src/main/resources/application.yml
|
||||
@cameleer3-server-app/src/test/resources/application-test.yml
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing patterns to follow: core module = interfaces/domain, app module = Spring implementations -->
|
||||
|
||||
From core/agent/AgentRegistryService.java:
|
||||
```java
|
||||
// Plain class in core module, wired as bean by app module config
|
||||
public class AgentRegistryService {
|
||||
public AgentInfo register(String id, String name, String group, ...);
|
||||
public AgentInfo findById(String id);
|
||||
}
|
||||
```
|
||||
|
||||
From app/config/AgentRegistryConfig.java:
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "agent-registry")
|
||||
public class AgentRegistryConfig { ... }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Core interfaces + app implementations + Maven deps</name>
|
||||
<files>
|
||||
cameleer3-server-app/pom.xml,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java,
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java,
|
||||
cameleer3-server-app/src/main/resources/application.yml,
|
||||
cameleer3-server-app/src/test/resources/application-test.yml,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
|
||||
</files>
|
||||
<behavior>
|
||||
JwtService tests:
|
||||
- createAccessToken(agentId, group) returns a signed JWT string with sub=agentId, claim "group"=group, claim "type"="access", expiry ~1h from now
|
||||
- createRefreshToken(agentId, group) returns a signed JWT string with sub=agentId, claim "type"="refresh", expiry ~7d from now
|
||||
- validateAndExtractAgentId(validAccessToken) returns the agentId
|
||||
- validateAndExtractAgentId(expiredToken) throws exception
|
||||
- validateAndExtractAgentId(refreshToken) throws exception (wrong type for access validation)
|
||||
- validateRefreshToken(validRefreshToken) returns the agentId
|
||||
- validateRefreshToken(accessToken) throws exception (wrong type)
|
||||
|
||||
Ed25519SigningService tests:
|
||||
- getPublicKeyBase64() returns non-null Base64 string
|
||||
- sign(payload) returns Base64 signature string
|
||||
- Signature verifies against public key using JDK Signature.getInstance("Ed25519")
|
||||
- Different payloads produce different signatures
|
||||
- Tampered payload fails verification
|
||||
|
||||
BootstrapTokenValidator tests:
|
||||
- validate(correctToken) returns true
|
||||
- validate(wrongToken) returns false
|
||||
- validate(previousToken) returns true when CAMELEER_AUTH_TOKEN_PREVIOUS is set
|
||||
- validate(null) returns false
|
||||
- Uses constant-time comparison (MessageDigest.isEqual)
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add Maven dependencies to cameleer3-server-app/pom.xml:
|
||||
- `spring-boot-starter-security` (managed version)
|
||||
- `com.nimbusds:nimbus-jose-jwt:9.47` (explicit, may not be transitive without OAuth2 resource server)
|
||||
- `spring-security-test` scope test (managed version)
|
||||
|
||||
2. Create core module interfaces:
|
||||
- `JwtService` interface: `createAccessToken(String agentId, String group)`, `createRefreshToken(String agentId, String group)`, `validateAndExtractAgentId(String token)` (access only), `validateRefreshToken(String token)` (refresh only). Returns String tokens, throws `InvalidTokenException` (new checked or runtime exception in core).
|
||||
- `Ed25519SigningService` interface: `sign(String payload)` returns Base64 signature string, `getPublicKeyBase64()` returns Base64-encoded X.509 SubjectPublicKeyInfo DER public key.
|
||||
|
||||
3. Create app module implementations:
|
||||
- `SecurityProperties` as `@ConfigurationProperties(prefix = "security")` with fields: `accessTokenExpiryMs` (default 3600000), `refreshTokenExpiryMs` (default 604800000), `bootstrapToken` (from env CAMELEER_AUTH_TOKEN), `bootstrapTokenPrevious` (from env CAMELEER_AUTH_TOKEN_PREVIOUS, nullable).
|
||||
- `JwtServiceImpl`: Generate random 256-bit HMAC secret in constructor (`new SecureRandom().nextBytes(secret)`). Use Nimbus `MACSigner`/`MACVerifier` with `JWSAlgorithm.HS256`. Claims: `sub`=agentId, `group`=group, `type`="access"|"refresh", `iat`=now, `exp`=now+expiry. Validation checks: signature valid, not expired, correct `type` claim.
|
||||
- `Ed25519SigningServiceImpl`: Generate `KeyPair` via `KeyPairGenerator.getInstance("Ed25519")` in constructor. `sign()` uses `Signature.getInstance("Ed25519")`, `initSign(privateKey)`, returns Base64-encoded signature bytes. `getPublicKeyBase64()` returns `Base64.getEncoder().encodeToString(publicKey.getEncoded())`.
|
||||
- `BootstrapTokenValidator`: Constructor takes `SecurityProperties`. `validate(String provided)` returns boolean. Uses `MessageDigest.isEqual(provided.getBytes(UTF_8), expected.getBytes(UTF_8))`. If first token fails and previousToken is non-null, tries previousToken. Returns false for null/blank input.
|
||||
- `SecurityBeanConfig` as `@Configuration` with `@EnableConfigurationProperties(SecurityProperties.class)`. Creates beans for `JwtServiceImpl`, `Ed25519SigningServiceImpl`, `BootstrapTokenValidator`. Add `@PostConstruct` or `InitializingBean` validation: if `SecurityProperties.bootstrapToken` is null or blank, throw `IllegalStateException("CAMELEER_AUTH_TOKEN environment variable must be set")`.
|
||||
|
||||
4. Update application.yml: Add `security.access-token-expiry-ms: 3600000`, `security.refresh-token-expiry-ms: 604800000`. Map env vars: `security.bootstrap-token: ${CAMELEER_AUTH_TOKEN:}`, `security.bootstrap-token-previous: ${CAMELEER_AUTH_TOKEN_PREVIOUS:}`.
|
||||
|
||||
5. Update application-test.yml: Add `security.bootstrap-token: test-bootstrap-token`, `security.bootstrap-token-previous: old-bootstrap-token`. Also set `CAMELEER_AUTH_TOKEN: test-bootstrap-token` as an env override if needed.
|
||||
|
||||
6. IMPORTANT: Adding spring-boot-starter-security will break ALL existing tests immediately (401 on all endpoints). To prevent this during Plan 01 (before the security filter chain is configured in Plan 02), add a temporary test security config class `src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java` annotated `@TestConfiguration` that creates a `SecurityFilterChain` permitting all requests. This keeps existing tests green while security services are built. Plan 02 will replace this with real security config and update tests.
|
||||
|
||||
7. Write unit tests per the behavior spec above. Tests should NOT require Spring context -- construct implementations directly with test SecurityProperties.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest="JwtServiceTest,Ed25519SigningServiceTest,BootstrapTokenValidatorTest" -Dsurefire.reuseForks=false</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- JwtService creates and validates access/refresh JWTs with correct claims and expiry
|
||||
- Ed25519SigningService generates keypair, signs payloads, signatures verify with public key
|
||||
- BootstrapTokenValidator uses constant-time comparison, supports dual-token rotation
|
||||
- Server startup fails if CAMELEER_AUTH_TOKEN is not set (tested via SecurityBeanConfig @PostConstruct)
|
||||
- All existing tests still pass (TestSecurityConfig permits all requests temporarily)
|
||||
- Maven compiles with new dependencies
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
mvn clean verify
|
||||
All new unit tests pass. All existing integration tests still pass (no 401 regressions).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- JwtServiceImpl creates signed JWTs with correct HMAC-SHA256, validates them, and rejects expired/wrong-type tokens
|
||||
- Ed25519SigningServiceImpl generates ephemeral keypair, signs payloads with verifiable signatures
|
||||
- BootstrapTokenValidator performs constant-time comparison with dual-token support
|
||||
- SecurityProperties loaded from application.yml with env var mapping
|
||||
- Startup fails fast when CAMELEER_AUTH_TOKEN is missing
|
||||
- Existing test suite remains green via TestSecurityConfig permit-all
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-security/04-01-SUMMARY.md`
|
||||
</output>
|
||||
145
.planning/phases/04-security/04-01-SUMMARY.md
Normal file
145
.planning/phases/04-security/04-01-SUMMARY.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: 04-security
|
||||
plan: 01
|
||||
subsystem: auth
|
||||
tags: [jwt, ed25519, hmac-sha256, nimbus-jose-jwt, spring-security, bootstrap-token]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-ingestion
|
||||
provides: "Maven multi-module structure, Spring Boot app scaffold, application.yml patterns"
|
||||
- phase: 03-agent-registry
|
||||
provides: "Agent registration flow, AgentRegistryService, SSE connection manager"
|
||||
provides:
|
||||
- "JwtService interface and HMAC-SHA256 implementation for access/refresh token lifecycle"
|
||||
- "Ed25519SigningService interface and JDK 17 implementation for payload signing"
|
||||
- "BootstrapTokenValidator with constant-time comparison and dual-token rotation"
|
||||
- "SecurityProperties configuration binding with env var mapping"
|
||||
- "TestSecurityConfig permit-all for existing test compatibility"
|
||||
affects: [04-02, 04-03]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: [nimbus-jose-jwt 9.47, spring-boot-starter-security, spring-security-test]
|
||||
patterns: [ephemeral HMAC secret per server instance, ephemeral Ed25519 keypair per startup, constant-time token comparison, InitializingBean fail-fast validation]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/JwtService.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/Ed25519SigningService.java
|
||||
- cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/InvalidTokenException.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtServiceImpl.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/Ed25519SigningServiceImpl.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/BootstrapTokenValidator.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtServiceTest.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/Ed25519SigningServiceTest.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenValidatorTest.java
|
||||
modified:
|
||||
- cameleer3-server-app/pom.xml
|
||||
- cameleer3-server-app/src/main/resources/application.yml
|
||||
- cameleer3-server-app/src/test/resources/application-test.yml
|
||||
|
||||
key-decisions:
|
||||
- "HMAC-SHA256 with ephemeral 256-bit secret for JWT signing (simpler than Ed25519 for tokens, Ed25519 reserved for config signing)"
|
||||
- "Nimbus JOSE+JWT chosen for JWT library (mature, well-maintained, explicit API)"
|
||||
- "JDK 17 built-in Ed25519 KeyPairGenerator (no Bouncy Castle dependency needed)"
|
||||
- "TestSecurityConfig as @Configuration in test sources for automatic component scanning by @SpringBootTest"
|
||||
- "InitializingBean pattern for fail-fast bootstrap token validation on startup"
|
||||
|
||||
patterns-established:
|
||||
- "Core module interfaces (JwtService, Ed25519SigningService) with app module implementations"
|
||||
- "SecurityProperties @ConfigurationProperties with env var mapping via ${ENV_VAR:default}"
|
||||
- "SecurityBeanConfig wires all security beans with explicit @Bean methods"
|
||||
|
||||
requirements-completed: [SECU-03, SECU-05]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 4 Plan 01: Security Service Foundation Summary
|
||||
|
||||
**HMAC-SHA256 JWT service with access/refresh token lifecycle, JDK 17 Ed25519 signing for config payloads, and constant-time bootstrap token validation with dual-token rotation**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-03-11T18:56:17Z
|
||||
- **Completed:** 2026-03-11T19:08:55Z
|
||||
- **Tasks:** 1 (TDD: RED + GREEN)
|
||||
- **Files modified:** 15
|
||||
|
||||
## Accomplishments
|
||||
- JwtService creates and validates access JWTs (1h expiry) and refresh JWTs (7d expiry) with agentId, group, and type claims
|
||||
- Ed25519SigningService generates ephemeral keypair, signs payloads with verifiable signatures using JDK 17 built-in crypto
|
||||
- BootstrapTokenValidator uses MessageDigest.isEqual for constant-time comparison with dual-token rotation support
|
||||
- Server fails fast on startup if CAMELEER_AUTH_TOKEN env var is not set
|
||||
- All 71 tests pass (18 new security + 29 existing unit + 24 existing integration) with TestSecurityConfig permit-all
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically (TDD flow):
|
||||
|
||||
1. **Task 1 RED: Failing tests for security services** - `51a0270` (test)
|
||||
2. **Task 1 GREEN: Implement security service foundation** - `ac9e8ae` (feat)
|
||||
|
||||
_No REFACTOR commit needed -- implementations are clean and minimal._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `cameleer3-server-core/.../security/JwtService.java` - JWT service interface with create/validate methods
|
||||
- `cameleer3-server-core/.../security/Ed25519SigningService.java` - Ed25519 signing interface with sign/getPublicKeyBase64
|
||||
- `cameleer3-server-core/.../security/InvalidTokenException.java` - Runtime exception for invalid/expired/wrong-type tokens
|
||||
- `cameleer3-server-app/.../security/JwtServiceImpl.java` - Nimbus JOSE+JWT HMAC-SHA256 implementation
|
||||
- `cameleer3-server-app/.../security/Ed25519SigningServiceImpl.java` - JDK 17 Ed25519 KeyPairGenerator implementation
|
||||
- `cameleer3-server-app/.../security/BootstrapTokenValidator.java` - Constant-time bootstrap token validation
|
||||
- `cameleer3-server-app/.../security/SecurityProperties.java` - Config properties for token expiry and bootstrap tokens
|
||||
- `cameleer3-server-app/.../security/SecurityBeanConfig.java` - Bean wiring with fail-fast startup validation
|
||||
- `cameleer3-server-app/.../security/TestSecurityConfig.java` - Temporary permit-all for existing test compatibility
|
||||
- `cameleer3-server-app/pom.xml` - Added nimbus-jose-jwt, spring-boot-starter-security, spring-security-test
|
||||
- `cameleer3-server-app/.../application.yml` - Security config section with env var mapping
|
||||
- `cameleer3-server-app/.../application-test.yml` - Test bootstrap token values
|
||||
- `cameleer3-server-app/.../security/JwtServiceTest.java` - 7 unit tests for JWT creation/validation
|
||||
- `cameleer3-server-app/.../security/Ed25519SigningServiceTest.java` - 5 unit tests for signing/verification
|
||||
- `cameleer3-server-app/.../security/BootstrapTokenValidatorTest.java` - 6 unit tests for token matching
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **HMAC-SHA256 for JWT signing:** Simpler than using Ed25519 for tokens; ephemeral 256-bit secret generated per server instance. Ed25519 reserved for config/command payload signing where agents need the public key.
|
||||
- **Nimbus JOSE+JWT:** Mature library with explicit MACSigner/MACVerifier API. Chose explicit version 9.47 since it may not be transitively available without spring-boot-starter-oauth2-resource-server.
|
||||
- **JDK 17 built-in Ed25519:** No external crypto library needed -- `KeyPairGenerator.getInstance("Ed25519")` available since JDK 15.
|
||||
- **@Configuration (not @TestConfiguration) for TestSecurityConfig:** Ensures automatic component scanning by @SpringBootTest without requiring @Import on every IT class.
|
||||
- **InitializingBean for fail-fast:** Validates CAMELEER_AUTH_TOKEN is set before any request processing begins.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Security primitives are ready for Plan 02 (Spring Security filter chain, JWT auth filter, registration/refresh integration)
|
||||
- JwtService, Ed25519SigningService, and BootstrapTokenValidator are all wired as Spring beans
|
||||
- TestSecurityConfig will be replaced by real SecurityFilterChain in Plan 02
|
||||
- Plan 03 will integrate Ed25519 signing into SSE command push
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 12 created files verified present on disk
|
||||
- Both commits (51a0270, ac9e8ae) verified in git log
|
||||
- Full `mvn clean verify` passed: 71 tests, 0 failures
|
||||
|
||||
---
|
||||
*Phase: 04-security*
|
||||
*Completed: 2026-03-11*
|
||||
293
.planning/phases/04-security/04-02-PLAN.md
Normal file
293
.planning/phases/04-security/04-02-PLAN.md
Normal file
@@ -0,0 +1,293 @@
|
||||
---
|
||||
phase: 04-security
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["04-01"]
|
||||
files_modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SECU-01
|
||||
- SECU-02
|
||||
- SECU-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All API endpoints except health, register, and docs reject requests without valid JWT"
|
||||
- "POST /register requires bootstrap token in Authorization header, returns JWT + refresh token + Ed25519 public key"
|
||||
- "POST /agents/{id}/refresh accepts refresh token and returns new access JWT"
|
||||
- "SSE endpoint accepts JWT via ?token= query parameter"
|
||||
- "Health endpoint and Swagger UI remain publicly accessible"
|
||||
artifacts:
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java"
|
||||
provides: "OncePerRequestFilter extracting JWT from header or query param"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java"
|
||||
provides: "SecurityFilterChain with permitAll for public paths, authenticated for rest"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java"
|
||||
provides: "Updated register endpoint with bootstrap token validation, JWT issuance, public key"
|
||||
key_links:
|
||||
- from: "JwtAuthenticationFilter"
|
||||
to: "JwtService.validateAndExtractAgentId"
|
||||
via: "Filter delegates JWT validation to service"
|
||||
pattern: "jwtService\\.validateAndExtractAgentId"
|
||||
- from: "SecurityConfig"
|
||||
to: "JwtAuthenticationFilter"
|
||||
via: "addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)"
|
||||
pattern: "addFilterBefore"
|
||||
- from: "AgentRegistrationController.register"
|
||||
to: "BootstrapTokenValidator.validate"
|
||||
via: "Validates bootstrap token before processing registration"
|
||||
pattern: "bootstrapTokenValidator\\.validate"
|
||||
- from: "AgentRegistrationController.register"
|
||||
to: "JwtService.createAccessToken + createRefreshToken"
|
||||
via: "Issues tokens in registration response"
|
||||
pattern: "jwtService\\.create(Access|Refresh)Token"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire Spring Security into the application: JWT authentication filter, SecurityFilterChain configuration, bootstrap token validation on registration, JWT issuance in registration response, refresh endpoint, and SSE query-parameter authentication. Update existing tests to work with security enabled.
|
||||
|
||||
Purpose: Protects all endpoints with JWT authentication while keeping public endpoints accessible and providing the full agent registration-to-authentication flow.
|
||||
Output: Working security filter chain with protected/public endpoints, registration returns JWT + public key, refresh flow works, all tests pass.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/04-security/04-CONTEXT.md
|
||||
@.planning/phases/04-security/04-RESEARCH.md
|
||||
@.planning/phases/04-security/04-VALIDATION.md
|
||||
@.planning/phases/04-security/04-01-SUMMARY.md
|
||||
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
@cameleer3-server-app/src/test/java/com/cameleer3/server/app/AbstractClickHouseIT.java
|
||||
@cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 (will exist after execution): -->
|
||||
|
||||
From core/security/JwtService.java:
|
||||
```java
|
||||
public interface JwtService {
|
||||
String createAccessToken(String agentId, String group);
|
||||
String createRefreshToken(String agentId, String group);
|
||||
String validateAndExtractAgentId(String token); // access tokens only
|
||||
String validateRefreshToken(String token); // refresh tokens only
|
||||
}
|
||||
```
|
||||
|
||||
From core/security/Ed25519SigningService.java:
|
||||
```java
|
||||
public interface Ed25519SigningService {
|
||||
String sign(String payload);
|
||||
String getPublicKeyBase64();
|
||||
}
|
||||
```
|
||||
|
||||
From app/security/BootstrapTokenValidator.java:
|
||||
```java
|
||||
public class BootstrapTokenValidator {
|
||||
public boolean validate(String provided);
|
||||
}
|
||||
```
|
||||
|
||||
From app/security/SecurityProperties.java:
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "security")
|
||||
public class SecurityProperties {
|
||||
long accessTokenExpiryMs; // default 3600000
|
||||
long refreshTokenExpiryMs; // default 604800000
|
||||
String bootstrapToken; // from CAMELEER_AUTH_TOKEN env
|
||||
String bootstrapTokenPrevious; // from CAMELEER_AUTH_TOKEN_PREVIOUS env, nullable
|
||||
}
|
||||
```
|
||||
|
||||
From core/agent/AgentRegistryService.java:
|
||||
```java
|
||||
public class AgentRegistryService {
|
||||
public AgentInfo register(String id, String name, String group, String version, List<String> routeIds, Map<String, Object> capabilities);
|
||||
public AgentInfo findById(String id);
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: SecurityFilterChain + JwtAuthenticationFilter + registration/refresh integration</name>
|
||||
<files>
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentSseController.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
</files>
|
||||
<action>
|
||||
1. Create `JwtAuthenticationFilter extends OncePerRequestFilter` (NOT annotated @Component -- constructed in SecurityConfig to avoid double registration):
|
||||
- Constructor takes `JwtService` and `AgentRegistryService`
|
||||
- `doFilterInternal`: extract token via `extractToken(request)`, if token present: call `jwtService.validateAndExtractAgentId(token)`, verify agent exists via `agentRegistry.findById(agentId)`, if valid set `UsernamePasswordAuthenticationToken(agentId, null, List.of())` in `SecurityContextHolder`. If any exception, log debug and do NOT set auth (Spring Security rejects). Always call `chain.doFilter(request, response)`.
|
||||
- `extractToken(request)`: first check `Authorization` header for `Bearer ` prefix, then check `request.getParameter("token")` for SSE query param. Return null if neither.
|
||||
|
||||
2. Create `SecurityConfig` as `@Configuration @EnableWebSecurity`:
|
||||
- Single `@Bean SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService)`:
|
||||
- `csrf(AbstractHttpConfigurer::disable)` -- REST API, no browser forms
|
||||
- `sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))`
|
||||
- `authorizeHttpRequests`: permitAll for `/api/v1/health`, `/api/v1/agents/register`, `/api/v1/api-docs/**`, `/api/v1/swagger-ui/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/swagger-ui.html`. `anyRequest().authenticated()`.
|
||||
- `addFilterBefore(new JwtAuthenticationFilter(jwtService, registryService), UsernamePasswordAuthenticationFilter.class)`
|
||||
- Also disable default form login and httpBasic: `.formLogin(AbstractHttpConfigurer::disable).httpBasic(AbstractHttpConfigurer::disable)`
|
||||
|
||||
3. Update `AgentRegistrationController.register()`:
|
||||
- Add `BootstrapTokenValidator`, `JwtService`, `Ed25519SigningService` as constructor dependencies
|
||||
- Before processing registration body, extract bootstrap token from `Authorization: Bearer <token>` header (use `@RequestHeader("Authorization")` or extract from HttpServletRequest). If missing or invalid (`bootstrapTokenValidator.validate()` returns false), return `401 Unauthorized` with no detail body.
|
||||
- After successful registration, generate tokens: `jwtService.createAccessToken(agentId, group)` and `jwtService.createRefreshToken(agentId, group)`
|
||||
- Update response map: replace `"serverPublicKey", null` with `"serverPublicKey", ed25519SigningService.getPublicKeyBase64()`. Add `"accessToken"` and `"refreshToken"` fields.
|
||||
|
||||
4. Add a new refresh endpoint in `AgentRegistrationController` (or a new controller -- keep it in the same controller since it's agent auth flow):
|
||||
- `POST /api/v1/agents/{id}/refresh` with request body containing `{"refreshToken": "..."}`.
|
||||
- Validate refresh token via `jwtService.validateRefreshToken(token)`, extract agentId, verify it matches path `{id}`, verify agent exists.
|
||||
- Return new access token: `{"accessToken": "..."}`.
|
||||
- Return 401 for invalid/expired refresh token, 404 for unknown agent.
|
||||
- NOTE: This endpoint must be AUTHENTICATED (requires valid JWT OR the refresh token itself). Per the user decision, the refresh endpoint uses the refresh token for auth, so add `/api/v1/agents/*/refresh` to permitAll in SecurityConfig, and validate the refresh token in the controller itself.
|
||||
|
||||
5. Update `AgentSseController.events()`:
|
||||
- The SSE endpoint uses `?token=<jwt>` query parameter. The `JwtAuthenticationFilter` already handles this (extracts from query param). No changes needed to the controller itself -- Spring Security handles auth via the filter.
|
||||
- However, verify the SSE endpoint path `/api/v1/agents/{id}/events` is NOT in permitAll (it should require JWT auth).
|
||||
|
||||
6. Update `WebConfig` if needed: The `ProtocolVersionInterceptor` excluded paths should align with Spring Security public paths. The SSE events path is already excluded from protocol version check (Phase 3 decision). Verify no conflicts.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean compile -pl cameleer3-server-app</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- SecurityConfig creates stateless filter chain with correct public/protected path split
|
||||
- JwtAuthenticationFilter extracts JWT from header or query param, validates, sets SecurityContext
|
||||
- Registration endpoint requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
|
||||
- Refresh endpoint issues new access token from valid refresh token
|
||||
- Application compiles with all security wiring
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Security integration tests + existing test adaptation</name>
|
||||
<files>
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/OpenApiIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/HealthControllerIT.java
|
||||
</files>
|
||||
<action>
|
||||
1. Replace the Plan 01 temporary `TestSecurityConfig` (permit-all) with real security active in tests. Remove the permit-all override so tests run with actual security enforcement.
|
||||
|
||||
2. Create `TestSecurityHelper` utility class in test root:
|
||||
- Autowire `JwtService` and `AgentRegistryService`
|
||||
- `registerTestAgent(String agentId)`: calls `registryService.register(agentId, "test", "test-group", "1.0", List.of(), Map.of())` and returns `jwtService.createAccessToken(agentId, "test-group")`
|
||||
- `authHeaders(String jwt)`: returns HttpHeaders with `Authorization: Bearer <jwt>` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json`
|
||||
- `bootstrapHeaders()`: returns HttpHeaders with `Authorization: Bearer test-bootstrap-token` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json`
|
||||
- Make it a Spring `@Component` so it can be autowired in test classes
|
||||
|
||||
3. Update ALL existing IT classes (17 files) to use JWT authentication:
|
||||
- Autowire `TestSecurityHelper`
|
||||
- In `@BeforeEach` or at test start, call `helper.registerTestAgent("test-agent-<testclass>")` to get a JWT
|
||||
- Replace all `protocolHeaders()` calls with headers that include the JWT Bearer token
|
||||
- For HealthControllerIT and OpenApiIT: verify these still work WITHOUT JWT (they're public endpoints)
|
||||
- For AgentRegistrationControllerIT: update `registerAgent()` helper to use bootstrap token header, verify response now includes `accessToken`, `refreshToken`, `serverPublicKey` (non-null)
|
||||
|
||||
4. Create new security-specific integration tests:
|
||||
|
||||
`SecurityFilterIT` (extends AbstractClickHouseIT):
|
||||
- Test: GET /api/v1/agents without JWT returns 401 or 403
|
||||
- Test: GET /api/v1/agents with valid JWT returns 200
|
||||
- Test: GET /api/v1/health without JWT returns 200 (public)
|
||||
- Test: POST /api/v1/data/executions without JWT returns 401 or 403
|
||||
- Test: Request with expired JWT returns 401 or 403
|
||||
- Test: Request with malformed JWT returns 401 or 403
|
||||
|
||||
`BootstrapTokenIT` (extends AbstractClickHouseIT):
|
||||
- Test: POST /register without bootstrap token returns 401
|
||||
- Test: POST /register with wrong bootstrap token returns 401
|
||||
- Test: POST /register with correct bootstrap token returns 200 with tokens
|
||||
- Test: POST /register with previous bootstrap token returns 200 (dual-token rotation)
|
||||
|
||||
`RegistrationSecurityIT` (extends AbstractClickHouseIT):
|
||||
- Test: Registration response contains non-null `serverPublicKey` (Base64 string)
|
||||
- Test: Registration response contains `accessToken` and `refreshToken`
|
||||
- Test: Access token from registration can be used to access protected endpoints
|
||||
|
||||
`JwtRefreshIT` (extends AbstractClickHouseIT):
|
||||
- Test: POST /agents/{id}/refresh with valid refresh token returns new access token
|
||||
- Test: POST /agents/{id}/refresh with expired refresh token returns 401
|
||||
- Test: POST /agents/{id}/refresh with access token (wrong type) returns 401
|
||||
- Test: POST /agents/{id}/refresh with mismatched agent ID returns 401
|
||||
- Test: New access token from refresh can access protected endpoints
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- All 17 existing ITs pass with JWT authentication
|
||||
- SecurityFilterIT: protected endpoints reject unauthenticated requests, public endpoints remain open
|
||||
- BootstrapTokenIT: registration requires valid bootstrap token, supports dual-token rotation
|
||||
- RegistrationSecurityIT: registration returns public key + tokens
|
||||
- JwtRefreshIT: refresh flow issues new access tokens, rejects invalid refresh tokens
|
||||
- Full `mvn clean verify` is green
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
mvn clean verify
|
||||
All existing tests pass with JWT auth. New security ITs validate protected/public endpoint split, bootstrap token flow, registration security, and refresh flow.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Protected endpoints return 401/403 without JWT, 200 with valid JWT
|
||||
- Public endpoints (health, register, docs) remain accessible without JWT
|
||||
- Registration requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
|
||||
- Refresh endpoint issues new access JWT from valid refresh token
|
||||
- SSE endpoint accepts JWT via query parameter
|
||||
- All 17 existing ITs adapted and passing
|
||||
- 4 new security ITs passing
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-security/04-02-SUMMARY.md`
|
||||
</output>
|
||||
165
.planning/phases/04-security/04-02-SUMMARY.md
Normal file
165
.planning/phases/04-security/04-02-SUMMARY.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
phase: 04-security
|
||||
plan: 02
|
||||
subsystem: auth
|
||||
tags: [spring-security, jwt-filter, security-filter-chain, bootstrap-token, refresh-token, stateless-auth]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 04-security
|
||||
provides: "JwtService, Ed25519SigningService, BootstrapTokenValidator, SecurityProperties beans"
|
||||
- phase: 03-agent-registry
|
||||
provides: "AgentRegistryService, AgentRegistrationController, SseConnectionManager, SSE endpoints"
|
||||
provides:
|
||||
- "SecurityFilterChain with stateless JWT authentication and public/protected endpoint split"
|
||||
- "JwtAuthenticationFilter extracting JWT from Authorization header or query param"
|
||||
- "Registration endpoint with bootstrap token validation, JWT + refresh token + public key issuance"
|
||||
- "Refresh endpoint issuing new access JWT from valid refresh token"
|
||||
- "TestSecurityHelper for JWT-authenticated integration tests"
|
||||
affects: [04-03]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [OncePerRequestFilter for JWT extraction, SecurityFilterChain with permitAll/authenticated split, error path permit for proper Spring Boot error forwarding]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java
|
||||
modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java
|
||||
|
||||
key-decisions:
|
||||
- "Added /error to SecurityConfig permitAll to allow Spring Boot error page forwarding through security"
|
||||
- "Excluded register and refresh paths from ProtocolVersionInterceptor (auth endpoints, not data endpoints)"
|
||||
- "SSE authentication via ?token= query parameter handled transparently by JwtAuthenticationFilter"
|
||||
- "Refresh endpoint in permitAll (uses refresh token for self-authentication, not JWT access token)"
|
||||
|
||||
patterns-established:
|
||||
- "TestSecurityHelper @Component for registering test agents and creating auth headers in ITs"
|
||||
- "Bootstrap token in Authorization: Bearer header for registration (same header format as JWT)"
|
||||
- "SecurityFilterChain permits /error for proper error page rendering in authenticated context"
|
||||
|
||||
requirements-completed: [SECU-01, SECU-02, SECU-05]
|
||||
|
||||
# Metrics
|
||||
duration: 26min
|
||||
completed: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 4 Plan 02: Security Filter Chain and Endpoint Protection Summary
|
||||
|
||||
**Spring Security filter chain with JWT authentication on all protected endpoints, bootstrap token validation on registration, refresh token flow, and 91 passing tests including 18 new security ITs**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 26 min
|
||||
- **Started:** 2026-03-11T19:11:48Z
|
||||
- **Completed:** 2026-03-11T19:38:07Z
|
||||
- **Tasks:** 2
|
||||
- **Files modified:** 25
|
||||
|
||||
## Accomplishments
|
||||
- SecurityFilterChain enforces JWT authentication on all endpoints except health, register, refresh, and docs
|
||||
- JwtAuthenticationFilter extracts JWT from Authorization header or ?token= query param (SSE support)
|
||||
- Registration endpoint requires bootstrap token, returns accessToken + refreshToken + serverPublicKey (Ed25519)
|
||||
- Refresh endpoint issues new access JWT from valid refresh token with agent ID verification
|
||||
- All 15 existing ITs adapted to use JWT authentication via TestSecurityHelper
|
||||
- 4 new security ITs (SecurityFilterIT, BootstrapTokenIT, RegistrationSecurityIT, JwtRefreshIT) with 18 tests
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: SecurityFilterChain + JwtAuthenticationFilter + registration/refresh integration** - `387e2e6` (feat)
|
||||
2. **Task 2: Security integration tests + existing test adaptation** - `539b85f` (test)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `...security/JwtAuthenticationFilter.java` - OncePerRequestFilter extracting JWT from header or query param
|
||||
- `...security/SecurityConfig.java` - SecurityFilterChain with public/protected endpoint split
|
||||
- `...controller/AgentRegistrationController.java` - Updated with bootstrap token validation, JWT issuance, refresh endpoint
|
||||
- `...config/WebConfig.java` - Excluded register/refresh from ProtocolVersionInterceptor
|
||||
- `...TestSecurityHelper.java` - Test utility for JWT-authenticated requests
|
||||
- `...security/SecurityFilterIT.java` - 6 tests for protected/public endpoint access control
|
||||
- `...security/BootstrapTokenIT.java` - 4 tests for bootstrap token validation on registration
|
||||
- `...security/RegistrationSecurityIT.java` - 3 tests for registration security response
|
||||
- `...security/JwtRefreshIT.java` - 5 tests for refresh token flow
|
||||
- 15 existing IT files updated with JWT authentication headers
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Added /error to permitAll:** Spring Boot forwards exceptions to /error endpoint; without permitting it, controllers returning 404 via ResponseStatusException would result in 403 to the client.
|
||||
- **Excluded register/refresh from ProtocolVersionInterceptor:** These are auth/token-renewal endpoints that agents call without full protocol handshake context. Protocol version enforcement is for data/management endpoints.
|
||||
- **Refresh endpoint uses permitAll + self-authentication:** The refresh endpoint validates the refresh token directly rather than requiring a separate JWT access token, simplifying the agent token renewal flow.
|
||||
- **SSE query param authentication transparent:** JwtAuthenticationFilter checks both Authorization header and ?token= query param, so no SSE controller changes needed.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Added /error to SecurityConfig permitAll**
|
||||
- **Found during:** Task 2 (test execution)
|
||||
- **Issue:** Controllers using ResponseStatusException(NOT_FOUND) forward to /error endpoint, which was blocked by Spring Security, resulting in 403 instead of 404
|
||||
- **Fix:** Added "/error" to the permitAll requestMatchers list
|
||||
- **Files modified:** SecurityConfig.java
|
||||
- **Verification:** All 91 tests pass, 404 responses correctly returned
|
||||
|
||||
**2. [Rule 3 - Blocking] Excluded register/refresh from ProtocolVersionInterceptor**
|
||||
- **Found during:** Task 2 (JwtRefreshIT tests returning 400)
|
||||
- **Issue:** Refresh endpoint matched /api/v1/agents/** interceptor pattern, rejecting requests without X-Cameleer-Protocol-Version header with 400
|
||||
- **Fix:** Added /api/v1/agents/register and /api/v1/agents/*/refresh to interceptor excludePathPatterns
|
||||
- **Files modified:** WebConfig.java
|
||||
- **Verification:** All JwtRefreshIT and BootstrapTokenIT tests pass
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 auto-fixed (2 blocking)
|
||||
**Impact on plan:** Both fixes necessary for correct Spring Security + Spring MVC interceptor integration. No scope creep.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None beyond the auto-fixed blocking issues above.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Full Spring Security filter chain active with JWT auth on all protected endpoints
|
||||
- TestSecurityHelper available for all future integration tests
|
||||
- Ready for Plan 03: Ed25519 signing of SSE command payloads
|
||||
- Registration flow complete: bootstrap token -> register -> receive JWT + public key -> use JWT for all API calls -> refresh when expired
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 7 created files verified present on disk
|
||||
- Both commits (387e2e6, 539b85f) verified in git log
|
||||
- Full `mvn clean verify` passed: 91 tests, 0 failures
|
||||
|
||||
---
|
||||
*Phase: 04-security*
|
||||
*Completed: 2026-03-11*
|
||||
186
.planning/phases/04-security/04-03-PLAN.md
Normal file
186
.planning/phases/04-security/04-03-PLAN.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
phase: 04-security
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["04-01"]
|
||||
files_modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SECU-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "All config-update, deep-trace, and replay SSE events carry a valid Ed25519 signature in the data JSON"
|
||||
- "Signature is computed over the payload JSON without the signature field, then added as a 'signature' field"
|
||||
- "Agent can verify the signature using the public key received at registration"
|
||||
artifacts:
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java"
|
||||
provides: "Component that signs SSE command payloads before delivery"
|
||||
- path: "cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java"
|
||||
provides: "Updated onCommandReady with signing before sendEvent"
|
||||
key_links:
|
||||
- from: "SseConnectionManager.onCommandReady"
|
||||
to: "SsePayloadSigner.signPayload"
|
||||
via: "Signs payload before SSE delivery"
|
||||
pattern: "ssePayloadSigner\\.signPayload"
|
||||
- from: "SsePayloadSigner"
|
||||
to: "Ed25519SigningService.sign"
|
||||
via: "Delegates signing to Ed25519 service"
|
||||
pattern: "ed25519SigningService\\.sign"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add Ed25519 signature to all SSE command payloads (config-update, deep-trace, replay) before delivery. The signature is computed over the data JSON and included as a `signature` field in the event data, enabling agents to verify payload integrity using the server's public key.
|
||||
|
||||
Purpose: Ensures all pushed configuration and commands are integrity-protected, so agents can trust the payloads they receive.
|
||||
Output: All SSE command events carry verifiable Ed25519 signatures.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/04-security/04-CONTEXT.md
|
||||
@.planning/phases/04-security/04-RESEARCH.md
|
||||
@.planning/phases/04-security/04-01-SUMMARY.md
|
||||
|
||||
@cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 (will exist after execution): -->
|
||||
|
||||
From core/security/Ed25519SigningService.java:
|
||||
```java
|
||||
public interface Ed25519SigningService {
|
||||
String sign(String payload); // Returns Base64-encoded signature
|
||||
String getPublicKeyBase64(); // Returns Base64-encoded X.509 public key
|
||||
}
|
||||
```
|
||||
|
||||
From app/agent/SseConnectionManager.java:
|
||||
```java
|
||||
@Component
|
||||
public class SseConnectionManager implements AgentEventListener {
|
||||
// Key method to modify:
|
||||
public void onCommandReady(String agentId, AgentCommand command) {
|
||||
String eventType = command.type().name().toLowerCase().replace('_', '-');
|
||||
boolean sent = sendEvent(agentId, command.id(), eventType, command.payload());
|
||||
// command.payload() is a String (JSON)
|
||||
}
|
||||
|
||||
public boolean sendEvent(String agentId, String eventId, String eventType, Object data) {
|
||||
// data is sent via SseEmitter.event().data(data, MediaType.APPLICATION_JSON)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
From core/agent/AgentCommand.java:
|
||||
```java
|
||||
public record AgentCommand(String id, CommandType type, String payload, String agentId, Instant createdAt, CommandStatus status) {
|
||||
// payload is a JSON string
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: SsePayloadSigner + signing integration in SseConnectionManager</name>
|
||||
<files>
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java,
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java,
|
||||
cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java
|
||||
</files>
|
||||
<behavior>
|
||||
SsePayloadSigner unit tests:
|
||||
- signPayload(jsonString) returns a new JSON string containing all original fields plus a "signature" field
|
||||
- The "signature" field is a Base64-encoded Ed25519 signature
|
||||
- The signature is computed over the ORIGINAL JSON string (without signature field)
|
||||
- Signature verifies against the public key from Ed25519SigningService
|
||||
- null or empty payload returns the payload unchanged (defensive)
|
||||
|
||||
SseSigningIT integration test:
|
||||
- Register an agent (with bootstrap token), get public key from response
|
||||
- Open SSE connection (with JWT query param)
|
||||
- Send a config-update command to the agent
|
||||
- Receive the SSE event and verify it contains a "signature" field
|
||||
- Verify the signature against the public key using JDK Ed25519 Signature.getInstance("Ed25519")
|
||||
</behavior>
|
||||
<action>
|
||||
1. Create `SsePayloadSigner` as a `@Component`:
|
||||
- Constructor takes `Ed25519SigningService` and `ObjectMapper`
|
||||
- `signPayload(String jsonPayload)` method:
|
||||
a. The payload JSON string IS the data to sign (sign the exact string)
|
||||
b. Compute signature: `ed25519SigningService.sign(jsonPayload)` returns Base64 signature
|
||||
c. Parse the JSON payload, add `"signature": signatureBase64` field, serialize back
|
||||
d. Return the signed JSON string
|
||||
- Handle edge cases: if payload is null or empty, return as-is with a log warning
|
||||
|
||||
2. Update `SseConnectionManager`:
|
||||
- Add `SsePayloadSigner` as a constructor dependency
|
||||
- In `onCommandReady()`, sign the payload before sending:
|
||||
```java
|
||||
String signedPayload = ssePayloadSigner.signPayload(command.payload());
|
||||
boolean sent = sendEvent(agentId, command.id(), eventType, signedPayload);
|
||||
```
|
||||
- The `sendEvent` method already sends `data` as `MediaType.APPLICATION_JSON`. Since `signedPayload` is already a JSON string, the SseEmitter will serialize it. IMPORTANT: Since the payload is already a JSON string and SseEmitter will try to JSON-serialize it (wrapping in quotes), we need to send it as a pre-serialized value. Change `sendEvent` to use `.data(signedPayload)` without MediaType for signed payloads, OR parse it to a JsonNode/Map first so Jackson serializes it correctly. The cleanest approach: parse the signed JSON string into a `JsonNode` via `objectMapper.readTree(signedPayload)` and pass that as the data object -- Jackson will serialize the tree correctly.
|
||||
|
||||
3. Write `SsePayloadSignerTest` (unit test, no Spring context):
|
||||
- Create a real `Ed25519SigningServiceImpl` and `ObjectMapper` for testing
|
||||
- Test cases per behavior spec above
|
||||
- Verify signature by using JDK `Signature.getInstance("Ed25519")` with the public key
|
||||
|
||||
4. Write `SseSigningIT` (extends AbstractClickHouseIT):
|
||||
- Register agent using bootstrap token (from application-test.yml)
|
||||
- Extract `serverPublicKey` from registration response
|
||||
- Get JWT from registration response
|
||||
- Open SSE connection via `java.net.http.HttpClient` async API (same pattern as AgentSseControllerIT) with `?token=<jwt>`
|
||||
- Use the agent command endpoint to push a config-update command to the agent
|
||||
- Read the SSE event from the stream
|
||||
- Parse the event data JSON, extract the `signature` field
|
||||
- Reconstruct the unsigned payload (remove signature field, serialize)
|
||||
- Verify signature using `Signature.getInstance("Ed25519")` with the public key decoded from Base64
|
||||
- NOTE: This test depends on Plan 02's bootstrap token and JWT auth being in place. If Plan 03 executes before Plan 02, the test will need the TestSecurityHelper or a different auth approach. Since both are Wave 2 but independent, document this: "If Plan 02 is not yet complete, use TestSecurityHelper from Plan 01's temporary permit-all config."
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest="SsePayloadSignerTest,SseSigningIT" -Dsurefire.reuseForks=false</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- SsePayloadSigner signs JSON payloads with Ed25519 and adds signature field
|
||||
- SseConnectionManager signs all command payloads before SSE delivery
|
||||
- Unit tests verify signature roundtrip (sign + verify with public key)
|
||||
- Integration test verifies end-to-end: command sent -> SSE event received with valid signature
|
||||
- Existing SSE tests still pass (ping events are not signed, only command events)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
mvn clean verify
|
||||
SsePayloadSigner unit tests pass. SseSigningIT integration test verifies end-to-end Ed25519 signing of SSE command events.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All SSE command events (config-update, deep-trace, replay) include a "signature" field
|
||||
- Signature verifies against the server's Ed25519 public key
|
||||
- Signature is computed over the payload JSON without the signature field
|
||||
- Ping keepalive events are NOT signed (they are SSE comments, not data events)
|
||||
- Existing SSE functionality unchanged (connection, ping, delivery tracking)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-security/04-03-SUMMARY.md`
|
||||
</output>
|
||||
134
.planning/phases/04-security/04-03-SUMMARY.md
Normal file
134
.planning/phases/04-security/04-03-SUMMARY.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
phase: 04-security
|
||||
plan: 03
|
||||
subsystem: auth
|
||||
tags: [ed25519, sse-signing, payload-integrity, server-sent-events]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 04-security
|
||||
provides: "Ed25519SigningService interface and implementation from Plan 01"
|
||||
- phase: 03-agent-registry
|
||||
provides: "SseConnectionManager, AgentCommand, SSE event delivery"
|
||||
provides:
|
||||
- "SsePayloadSigner component for Ed25519 signing of SSE command payloads"
|
||||
- "All SSE command events (config-update, deep-trace, replay) carry verifiable signature field"
|
||||
affects: []
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [sign-then-serialize for SSE payloads, JsonNode passthrough for correct SseEmitter serialization]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SsePayloadSigner.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/agent/SsePayloadSignerTest.java
|
||||
- cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SseSigningIT.java
|
||||
modified:
|
||||
- cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/SseConnectionManager.java
|
||||
|
||||
key-decisions:
|
||||
- "Signed payload parsed to JsonNode before passing to SseEmitter to avoid double-quoting raw JSON strings"
|
||||
- "SseSigningIT uses bootstrap token + JWT auth (adapts to Plan 02 security layer introduced during parallel execution)"
|
||||
|
||||
patterns-established:
|
||||
- "Sign-then-serialize: signature computed over original payload string, then payload parsed and signature field added"
|
||||
- "Defensive null/blank handling in SsePayloadSigner returns payload unchanged with warning log"
|
||||
|
||||
requirements-completed: [SECU-04]
|
||||
|
||||
# Metrics
|
||||
duration: 17min
|
||||
completed: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 4 Plan 03: SSE Payload Signing Summary
|
||||
|
||||
**Ed25519 signature injection into all SSE command events (config-update, deep-trace, replay) with end-to-end verification tests using JDK Signature API**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 17 min
|
||||
- **Started:** 2026-03-11T19:12:25Z
|
||||
- **Completed:** 2026-03-11T19:29:30Z
|
||||
- **Tasks:** 1 (TDD: RED + GREEN)
|
||||
- **Files modified:** 4
|
||||
|
||||
## Accomplishments
|
||||
- SsePayloadSigner signs JSON payloads with Ed25519 and adds Base64-encoded signature field
|
||||
- SseConnectionManager signs all command payloads before SSE delivery, parses to JsonNode for correct serialization
|
||||
- 7 unit tests verify signature roundtrip, edge cases (null/empty/blank), and Base64 encoding
|
||||
- 2 integration tests verify end-to-end: command sent with bootstrap+JWT auth, SSE event received with valid Ed25519 signature
|
||||
- Ping keepalive events remain unsigned (they are SSE comments, not data events)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically (TDD flow):
|
||||
|
||||
1. **Task 1 RED: Failing tests for SSE payload signing** - `b3b4e62` (test)
|
||||
2. **Task 1 GREEN: Implement SSE payload signing** - `0215fd9` (feat)
|
||||
|
||||
_No REFACTOR commit needed -- implementation is clean and minimal._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `cameleer3-server-app/.../agent/SsePayloadSigner.java` - Component that signs JSON payloads with Ed25519 and adds signature field
|
||||
- `cameleer3-server-app/.../agent/SseConnectionManager.java` - Updated onCommandReady to sign payload before SSE delivery
|
||||
- `cameleer3-server-app/.../agent/SsePayloadSignerTest.java` - 7 unit tests for signing behavior and edge cases
|
||||
- `cameleer3-server-app/.../security/SseSigningIT.java` - 2 integration tests for end-to-end signature verification
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **JsonNode passthrough for SseEmitter:** The signed payload string is parsed to a Jackson JsonNode before passing to SseEmitter.event().data(). This avoids the double-quoting problem where a raw JSON string would be wrapped in additional quotes by Jackson's message converter.
|
||||
- **Adapted to Plan 02 security layer:** SseSigningIT was updated to use bootstrap token for registration and JWT query param for SSE connection, since Plan 02 (Spring Security filter chain) was committed during parallel execution of this plan.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Updated SseSigningIT for Plan 02 security requirements**
|
||||
- **Found during:** Task 1 GREEN phase (integration test execution)
|
||||
- **Issue:** Plan 02 was committed in parallel, introducing real SecurityConfig that requires bootstrap token + JWT. The original test plan assumed TestSecurityConfig permit-all would be active.
|
||||
- **Fix:** Updated SseSigningIT to register with bootstrap token, extract JWT from response, and use JWT query param for SSE connection.
|
||||
- **Files modified:** SseSigningIT.java
|
||||
- **Verification:** Both integration tests pass with full auth flow
|
||||
- **Committed in:** 0215fd9 (GREEN phase commit)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 blocking)
|
||||
**Impact on plan:** Necessary adaptation to parallel plan execution. No scope creep.
|
||||
|
||||
## Pre-existing Failures (Out of Scope)
|
||||
|
||||
8 integration test failures pre-exist from Plan 02's security integration (not caused by this plan's changes):
|
||||
- AgentSseControllerIT: 1 failure (unknownAgent expected 404, gets 403)
|
||||
- AgentCommandControllerIT: 2 failures (unauthenticated requests get 403 instead of 404)
|
||||
- JwtRefreshIT: 5 failures (all tests, likely missing bootstrap token in setup)
|
||||
|
||||
Logged to `deferred-items.md` in this phase directory.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None specific to this plan's scope.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- All SSE command events now carry verifiable Ed25519 signatures
|
||||
- Security phase implementation is complete (Plans 01, 02, 03)
|
||||
- Pre-existing test failures from Plan 02 need resolution (documented in deferred-items.md)
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- All 4 created/modified files verified present on disk
|
||||
- Both commits (b3b4e62, 0215fd9) verified in git log
|
||||
- Unit tests: 7 pass, Integration tests: 2 pass
|
||||
|
||||
---
|
||||
*Phase: 04-security*
|
||||
*Completed: 2026-03-11*
|
||||
98
.planning/phases/04-security/04-CONTEXT.md
Normal file
98
.planning/phases/04-security/04-CONTEXT.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Phase 4: Security - Context
|
||||
|
||||
**Gathered:** 2026-03-11
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
All agent-server communication is authenticated and integrity-protected. JWT for API access control, Ed25519 signatures for pushed configuration/commands, bootstrap token for initial agent registration. This phase secures the communication channel between agents and server — not user/UI auth (deferred to v2 with web UI).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Bootstrap token flow
|
||||
- Single shared token from `CAMELEER_AUTH_TOKEN` env var — no config file fallback
|
||||
- Agent passes bootstrap token via `Authorization: Bearer <token>` header on `POST /register`
|
||||
- Server returns `401 Unauthorized` when token is missing or invalid — no detail about what's wrong
|
||||
- Server fails fast on startup if `CAMELEER_AUTH_TOKEN` is not set — prevents running insecure
|
||||
- Hot rotation via dual-token overlap: support `CAMELEER_AUTH_TOKEN_PREVIOUS` env var, server accepts both during rotation window. Remove old var when all agents updated
|
||||
|
||||
### JWT lifecycle
|
||||
- Access JWT expires after 1 hour
|
||||
- Separate refresh token with 7-day expiry, issued alongside access JWT at registration
|
||||
- Agent calls `POST /api/v1/agents/{id}/refresh` with refresh token to get new access JWT
|
||||
- JWT claims: `sub` = agentId, custom claim for group
|
||||
- Registration response includes both access JWT and refresh token (replaces current `serverPublicKey: null` placeholder with actual public key)
|
||||
|
||||
### Ed25519 signing
|
||||
- Ephemeral keypair generated fresh each server startup — no persistence needed
|
||||
- Agents receive public key during registration; must re-register after server restart to get new key
|
||||
- Signature included as a `signature` field in the SSE event data JSON — agent verifies payload minus signature field
|
||||
- All command types signed (config-update, deep-trace, replay) — uniform security model
|
||||
|
||||
### Endpoint protection
|
||||
- Public (no JWT): `GET /health`, `POST /register` (uses bootstrap token), OpenAPI/Swagger UI docs
|
||||
- Protected (JWT required): all other endpoints including ingestion (`/data/**`), search, agent management, commands
|
||||
- SSE connections authenticated via JWT as query parameter: `/agents/{id}/events?token=<jwt>` (EventSource API doesn't support custom headers)
|
||||
- Spring Security filter chain (`spring-boot-starter-security`) with custom `JwtAuthenticationFilter`
|
||||
|
||||
### Claude's Discretion
|
||||
- JWT signing algorithm (HMAC with server secret vs Ed25519 for JWT too)
|
||||
- Nimbus JOSE+JWT vs jjwt vs other JWT library
|
||||
- Ed25519 implementation library (Bouncy Castle vs JDK built-in)
|
||||
- Spring Security configuration details (SecurityFilterChain bean, permit patterns)
|
||||
- Refresh token storage mechanism (in-memory map, agent registry, or stateless)
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "This phase and version really is about securing the communication channel between agent and server" — scope is agent-server auth, not user-facing auth
|
||||
- Bootstrap token rotation without downtime was explicitly called out as important
|
||||
- Agents already re-register on restart (Phase 3 design), so ephemeral Ed25519 keys align naturally
|
||||
|
||||
</specifics>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `ProtocolVersionInterceptor` + `WebConfig`: Path-based request filtering pattern — Spring Security filter chain replaces this for auth
|
||||
- `AgentRegistrationController.register()`: Already returns `serverPublicKey: null` — fill with real Ed25519 public key
|
||||
- `SseConnectionManager.sendEvent()`: SSE delivery point — signing hooks into data before this call
|
||||
- `AgentRegistryConfig`: Configuration properties pattern — extend for security settings (token expiry, etc.)
|
||||
- `AgentRegistryService`: Agent lookup by ID — used for JWT validation (verify agent exists)
|
||||
|
||||
### Established Patterns
|
||||
- Core module: interfaces + domain logic; App module: Spring Boot + implementations
|
||||
- `application.yml` for all configurable values with sensible defaults
|
||||
- `AgentEventListener` interface decouples core from app module — signing logic can live in app module
|
||||
|
||||
### Integration Points
|
||||
- `POST /register` needs bootstrap token validation before reaching current registration logic
|
||||
- `SseConnectionManager.connect()` needs JWT validation from query parameter
|
||||
- `SseConnectionManager.onCommandReady()` needs to sign payload before delivery
|
||||
- All existing controllers need JWT auth enforced — Spring Security filter handles this transparently
|
||||
- `WebConfig` excluded paths need to align with Spring Security permit patterns
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- User/UI authentication — belongs with web UI in v2
|
||||
- Role-based access control (admin vs agent vs viewer) — future phase
|
||||
- Token revocation list — evaluate after v1 usage patterns
|
||||
- Mutual TLS as additional transport security — infrastructure concern, not application layer
|
||||
- Key rotation API endpoint — adds attack surface, stick with restart-based rotation for v1
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 04-security*
|
||||
*Context gathered: 2026-03-11*
|
||||
500
.planning/phases/04-security/04-RESEARCH.md
Normal file
500
.planning/phases/04-security/04-RESEARCH.md
Normal file
@@ -0,0 +1,500 @@
|
||||
# Phase 4: Security - Research
|
||||
|
||||
**Researched:** 2026-03-11
|
||||
**Domain:** Spring Security JWT authentication, Ed25519 signing, bootstrap token validation
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This phase adds authentication and integrity protection to the Cameleer3 server. The implementation uses Spring Security 6.4.3 (managed by Spring Boot 3.4.3) with a custom `OncePerRequestFilter` for JWT validation, JDK 17 built-in Ed25519 for signing SSE payloads, and environment variable-based bootstrap tokens for agent registration. The approach is deliberately simple -- no OAuth2 resource server, no external identity provider, just symmetric HMAC JWTs for access control and Ed25519 signatures for payload integrity.
|
||||
|
||||
The existing codebase has clear integration points: `AgentRegistrationController.register()` already returns `serverPublicKey: null` as a placeholder, `SseConnectionManager.onCommandReady()` is the signing hook for SSE events, and `WebConfig` already defines excluded paths that align with the public endpoint list. Spring Security's `SecurityFilterChain` replaces the need for hand-rolled authorization logic -- endpoints are protected by default, with explicit `permitAll()` for health, register, and docs.
|
||||
|
||||
**Primary recommendation:** Use Nimbus JOSE+JWT (transitive via `spring-boot-starter-security`) with HMAC-SHA256 for JWTs, JDK built-in `KeyPairGenerator.getInstance("Ed25519")` for signing keypairs, and a single `SecurityFilterChain` bean with a custom `JwtAuthenticationFilter extends OncePerRequestFilter` added before `UsernamePasswordAuthenticationFilter`.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- Single shared token from `CAMELEER_AUTH_TOKEN` env var -- no config file fallback
|
||||
- Agent passes bootstrap token via `Authorization: Bearer <token>` header on `POST /register`
|
||||
- Server returns `401 Unauthorized` when token is missing or invalid -- no detail about what's wrong
|
||||
- Server fails fast on startup if `CAMELEER_AUTH_TOKEN` is not set -- prevents running insecure
|
||||
- Hot rotation via dual-token overlap: support `CAMELEER_AUTH_TOKEN_PREVIOUS` env var, server accepts both during rotation window
|
||||
- Access JWT expires after 1 hour
|
||||
- Separate refresh token with 7-day expiry, issued alongside access JWT at registration
|
||||
- Agent calls `POST /api/v1/agents/{id}/refresh` with refresh token to get new access JWT
|
||||
- JWT claims: `sub` = agentId, custom claim for group
|
||||
- Registration response includes both access JWT and refresh token (replaces current `serverPublicKey: null` placeholder with actual public key)
|
||||
- Ephemeral keypair generated fresh each server startup -- no persistence needed
|
||||
- Agents receive public key during registration; must re-register after server restart to get new key
|
||||
- Signature included as a `signature` field in the SSE event data JSON -- agent verifies payload minus signature field
|
||||
- All command types signed (config-update, deep-trace, replay) -- uniform security model
|
||||
- Public (no JWT): `GET /health`, `POST /register` (uses bootstrap token), OpenAPI/Swagger UI docs
|
||||
- Protected (JWT required): all other endpoints including ingestion (`/data/**`), search, agent management, commands
|
||||
- SSE connections authenticated via JWT as query parameter: `/agents/{id}/events?token=<jwt>` (EventSource API doesn't support custom headers)
|
||||
- Spring Security filter chain (`spring-boot-starter-security`) with custom `JwtAuthenticationFilter`
|
||||
|
||||
### Claude's Discretion
|
||||
- JWT signing algorithm (HMAC with server secret vs Ed25519 for JWT too)
|
||||
- Nimbus JOSE+JWT vs jjwt vs other JWT library
|
||||
- Ed25519 implementation library (Bouncy Castle vs JDK built-in)
|
||||
- Spring Security configuration details (SecurityFilterChain bean, permit patterns)
|
||||
- Refresh token storage mechanism (in-memory map, agent registry, or stateless)
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- User/UI authentication -- belongs with web UI in v2
|
||||
- Role-based access control (admin vs agent vs viewer) -- future phase
|
||||
- Token revocation list -- evaluate after v1 usage patterns
|
||||
- Mutual TLS as additional transport security -- infrastructure concern, not application layer
|
||||
- Key rotation API endpoint -- adds attack surface, stick with restart-based rotation for v1
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| SECU-01 (#23) | All API endpoints (except health and register) require valid JWT Bearer token | Spring Security `SecurityFilterChain` with `permitAll()` for public paths, custom `JwtAuthenticationFilter` for JWT validation |
|
||||
| SECU-02 (#24) | JWT refresh flow via `POST /api/v1/agents/{id}/refresh` | Nimbus JOSE+JWT for JWT creation/validation, stateless refresh tokens with longer expiry |
|
||||
| SECU-03 (#25) | Server generates Ed25519 keypair; public key delivered at registration | JDK 17 built-in `KeyPairGenerator.getInstance("Ed25519")`, Base64-encoded public key in registration response |
|
||||
| SECU-04 (#26) | All config-update and replay SSE payloads signed with Ed25519 private key | JDK 17 `Signature.getInstance("Ed25519")`, signing hook in `SseConnectionManager.onCommandReady()` |
|
||||
| SECU-05 (#27) | Bootstrap token from `CAMELEER_AUTH_TOKEN` env var validates initial agent registration | `@Value` injection with startup validation, checked before registration logic |
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| spring-boot-starter-security | 3.4.3 (managed) | Security filter chain, authentication framework | Spring Boot's standard security starter; brings Spring Security 6.4.3 |
|
||||
| nimbus-jose-jwt | 9.37+ (transitive via spring-security-oauth2-jose) | JWT creation, signing, parsing, verification | Spring Security's own JWT library; already in the Spring ecosystem |
|
||||
| JDK Ed25519 | JDK 17 built-in | Ed25519 keypair generation and signing | Native support since Java 15 via `java.security.KeyPairGenerator` and `java.security.Signature`; no external dependency needed |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| spring-boot-starter-test | 3.4.3 (managed) | MockMvc with security context, `@WithMockUser` support | Already present; tests gain security testing support automatically |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Nimbus JOSE+JWT | JJWT (io.jsonwebtoken) | JJWT is simpler API but doesn't support JWE; Nimbus is already a Spring Security transitive dependency so adding it explicitly costs zero |
|
||||
| JDK Ed25519 | Bouncy Castle | Bouncy Castle adds ~5MB dependency for something JDK 17 does natively; only needed if targeting Java < 15 |
|
||||
| HMAC-SHA256 for JWT | Ed25519 for JWT too | HMAC is simpler for server-only JWT creation/validation (no key distribution needed); Ed25519 for JWT only matters if a third party validates JWTs |
|
||||
|
||||
**Discretion Recommendations:**
|
||||
- **JWT signing algorithm:** Use HMAC-SHA256 (HS256). The server both creates and validates JWTs -- no external party needs to verify them. HMAC is simpler (one shared secret vs keypair), and the 256-bit secret can be generated randomly at startup (ephemeral, like the Ed25519 key). This keeps JWT signing separate from Ed25519 payload signing -- cleaner separation of concerns.
|
||||
- **JWT library:** Use Nimbus JOSE+JWT. It is Spring Security's transitive dependency, so it costs nothing extra. Adding `spring-boot-starter-security` brings `spring-security-oauth2-jose` which includes Nimbus. Alternatively, add `com.nimbusds:nimbus-jose-jwt` directly if not pulling the full OAuth2 stack.
|
||||
- **Ed25519 library:** Use JDK built-in. Zero external dependencies, native performance, well-tested in JDK 17+.
|
||||
- **Refresh token storage:** Use stateless signed refresh tokens (also HMAC-signed JWTs with different claims/expiry). This avoids any in-memory storage for refresh tokens and scales naturally. The refresh token is just a JWT with `type=refresh`, `sub=agentId`, and 7-day expiry. On refresh, validate the refresh JWT, check agent still exists, issue new access JWT.
|
||||
|
||||
**Installation (add to cameleer3-server-app pom.xml):**
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>9.47</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Note: If `spring-boot-starter-security` brings Nimbus transitively (via `spring-security-oauth2-jose`), the explicit Nimbus dependency is optional. However, since we are NOT using Spring Security's OAuth2 resource server (we have a custom filter), adding Nimbus explicitly ensures it is available. Check if the starter alone suffices; if not, add Nimbus directly.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
cameleer3-server-core/src/main/java/com/cameleer3/server/core/
|
||||
security/
|
||||
JwtService.java # Interface: createAccessToken, createRefreshToken, validateToken, extractAgentId
|
||||
Ed25519SigningService.java # Interface: sign(payload) -> signature, getPublicKeyBase64()
|
||||
|
||||
cameleer3-server-app/src/main/java/com/cameleer3/server/app/
|
||||
security/
|
||||
JwtServiceImpl.java # Nimbus JOSE+JWT HMAC implementation
|
||||
Ed25519SigningServiceImpl.java # JDK Ed25519 keypair + signing implementation
|
||||
JwtAuthenticationFilter.java # OncePerRequestFilter: extract JWT, validate, set SecurityContext
|
||||
BootstrapTokenValidator.java # Validates bootstrap token(s) from env vars
|
||||
SecurityConfig.java # SecurityFilterChain bean, permit patterns
|
||||
config/
|
||||
SecurityProperties.java # @ConfigurationProperties for token expiry, etc.
|
||||
```
|
||||
|
||||
### Pattern 1: SecurityFilterChain with Custom JWT Filter
|
||||
**What:** Single `SecurityFilterChain` bean that permits public paths and requires authentication everywhere else, with a custom `JwtAuthenticationFilter` added before Spring's `UsernamePasswordAuthenticationFilter`.
|
||||
**When to use:** Always -- this is the sole security configuration.
|
||||
**Example:**
|
||||
```java
|
||||
// Source: Spring Security 6.4 official docs + Spring Boot 3.4 patterns
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtFilter) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable()) // REST API, no browser forms
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/health").permitAll()
|
||||
.requestMatchers("/api/v1/agents/register").permitAll()
|
||||
.requestMatchers("/api/v1/api-docs/**").permitAll()
|
||||
.requestMatchers("/api/v1/swagger-ui/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**").permitAll()
|
||||
.requestMatchers("/v3/api-docs/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: JwtAuthenticationFilter (OncePerRequestFilter)
|
||||
**What:** Extracts JWT from `Authorization: Bearer <token>` header (or `token` query param for SSE), validates it, and sets a Spring Security `Authentication` object in the `SecurityContextHolder`.
|
||||
**When to use:** Every authenticated request.
|
||||
**Example:**
|
||||
```java
|
||||
// Custom filter pattern for Spring Security 6.x
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final AgentRegistryService agentRegistry;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain chain) throws ServletException, IOException {
|
||||
String token = extractToken(request);
|
||||
if (token != null) {
|
||||
try {
|
||||
String agentId = jwtService.validateAndExtractAgentId(token);
|
||||
// Verify agent still exists
|
||||
if (agentRegistry.findById(agentId) != null) {
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
agentId, null, List.of());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Invalid token -- do not set authentication, Spring Security will reject
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractToken(HttpServletRequest request) {
|
||||
// 1. Check Authorization header
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
return header.substring(7);
|
||||
}
|
||||
// 2. Check query parameter (for SSE EventSource)
|
||||
return request.getParameter("token");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Ed25519 Payload Signing in SSE Delivery
|
||||
**What:** Before sending an SSE event, serialize the payload JSON, compute an Ed25519 signature, add the `signature` field to the JSON, then send.
|
||||
**When to use:** Every SSE command delivery (config-update, deep-trace, replay).
|
||||
**Example:**
|
||||
```java
|
||||
// JDK 17 built-in Ed25519 signing
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
||||
KeyPair keyPair = keyGen.generateKeyPair();
|
||||
|
||||
// Signing
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(keyPair.getPrivate());
|
||||
signer.update(payloadJson.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] signatureBytes = signer.sign();
|
||||
String signatureBase64 = Base64.getEncoder().encodeToString(signatureBytes);
|
||||
|
||||
// Public key for registration response
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(
|
||||
keyPair.getPublic().getEncoded()); // X.509 SubjectPublicKeyInfo DER encoding
|
||||
```
|
||||
|
||||
### Pattern 4: Bootstrap Token Validation
|
||||
**What:** Check `Authorization: Bearer <token>` on `POST /register` against `CAMELEER_AUTH_TOKEN` (and optionally `CAMELEER_AUTH_TOKEN_PREVIOUS`).
|
||||
**When to use:** Only on the registration endpoint.
|
||||
**Example:**
|
||||
```java
|
||||
// Startup validation in a @Component or @Bean init
|
||||
@Value("${CAMELEER_AUTH_TOKEN:#{null}}")
|
||||
private String bootstrapToken;
|
||||
|
||||
@PostConstruct
|
||||
void validateBootstrapToken() {
|
||||
if (bootstrapToken == null || bootstrapToken.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"CAMELEER_AUTH_TOKEN environment variable must be set");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Registering JwtAuthenticationFilter as a @Bean without @Component exclusion:** If marked as `@Component`, Spring Boot will register it as a global servlet filter AND in the security chain, running it twice. Either do NOT annotate it as `@Component` (construct it manually in the `SecurityConfig` bean) or use `FilterRegistrationBean` to disable auto-registration.
|
||||
- **Checking JWT on every request including permitAll paths:** The filter runs on all requests, but should gracefully skip validation for public paths (just call `chain.doFilter` if no token present -- Spring Security's authorization rules handle the rest).
|
||||
- **Storing refresh tokens in-memory:** Unnecessarily complex and lost on restart. Stateless signed refresh tokens are sufficient.
|
||||
- **Using Ed25519 for JWT signing:** Adds complexity (key distribution, asymmetric operations) for no benefit when only the server creates and validates JWTs.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| JWT creation/validation | Custom token format or Base64 JSON | Nimbus JOSE+JWT `SignedJWT` + `MACSigner`/`MACVerifier` | Handles algorithm validation, claim parsing, expiry checks, type-safe builders |
|
||||
| Request authentication | Custom servlet filter checking headers manually | Spring Security `SecurityFilterChain` + `OncePerRequestFilter` | Handles CORS, CSRF disabling, session management, exception handling, path matching |
|
||||
| Ed25519 signing | Hand-rolled crypto or custom signature format | JDK `java.security.Signature` + `java.security.KeyPairGenerator` | Audited, constant-time, handles DER encoding properly |
|
||||
| Constant-time token comparison | `String.equals()` for bootstrap token | `MessageDigest.isEqual()` | Prevents timing attacks on bootstrap token validation |
|
||||
| Public key encoding | Custom byte formatting | `PublicKey.getEncoded()` + Base64 | Standard X.509 SubjectPublicKeyInfo DER format, interoperable with any Ed25519 library |
|
||||
|
||||
**Key insight:** Cryptographic code has an extraordinary surface area for subtle bugs (timing attacks, encoding mismatches, algorithm confusion). Every piece should use battle-tested library methods.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Double Filter Registration
|
||||
**What goes wrong:** Annotating `JwtAuthenticationFilter` with `@Component` causes Spring Boot to auto-register it as a global servlet filter AND Spring Security adds it to the filter chain, resulting in the filter executing twice per request.
|
||||
**Why it happens:** Spring Boot auto-detects `@Component` classes that extend `Filter` and registers them globally.
|
||||
**How to avoid:** Do NOT annotate the filter as `@Component`. Instead, construct it in `SecurityConfig` and pass it to `addFilterBefore()`. If you must use `@Component`, add a `FilterRegistrationBean` that disables auto-registration.
|
||||
**Warning signs:** Filter logging messages appear twice per request; 401 responses on valid tokens (filter runs before SecurityFilterChain on second pass).
|
||||
|
||||
### Pitfall 2: Spring Security Blocking Existing Tests
|
||||
**What goes wrong:** Adding `spring-boot-starter-security` immediately makes all endpoints return 401/403 in existing integration tests.
|
||||
**Why it happens:** Spring Security's default configuration denies all requests. Existing tests don't include JWT tokens.
|
||||
**How to avoid:** Two approaches: (1) Add `@WithMockUser` or test-specific security configuration for existing tests, or (2) set a test-profile application-test.yml property with a known bootstrap token and have test helpers generate valid JWTs. Prefer option (2) for realistic security testing.
|
||||
**Warning signs:** All existing ITs start failing with 401 after adding the security starter.
|
||||
|
||||
### Pitfall 3: SSE Token in URL Logged/Cached
|
||||
**What goes wrong:** JWT passed as query parameter `?token=<jwt>` appears in server access logs, proxy logs, and browser history.
|
||||
**Why it happens:** Query parameters are part of the URL, which is logged by default.
|
||||
**How to avoid:** Use short-lived access JWTs (1 hour is fine). Consider filtering the `token` parameter from access logs. The EventSource API limitation makes this unavoidable -- document it as a known tradeoff.
|
||||
**Warning signs:** JWT tokens visible in plain text in log files.
|
||||
|
||||
### Pitfall 4: Timing Attack on Bootstrap Token
|
||||
**What goes wrong:** Using `String.equals()` for bootstrap token comparison leaks token length/prefix via timing side-channel.
|
||||
**Why it happens:** `String.equals()` short-circuits on first mismatch.
|
||||
**How to avoid:** Use `MessageDigest.isEqual(a.getBytes(), b.getBytes())` for constant-time comparison.
|
||||
**Warning signs:** None visible in normal operation -- this is a preventive measure.
|
||||
|
||||
### Pitfall 5: Ed25519 Signature Field Ordering
|
||||
**What goes wrong:** Agent cannot verify signature because JSON field ordering differs between signing and verification.
|
||||
**Why it happens:** JSON object field order is not guaranteed. If the server signs a different serialization than the agent verifies, signatures won't match.
|
||||
**How to avoid:** Sign the JSON payload WITHOUT the `signature` field (sign the payload as-is before adding the signature). Document clearly: "signature is computed over the `data` field value of the SSE event, excluding the `signature` key". Use a canonical approach: sign the payload JSON string, then wrap it in an outer object with `data` and `signature` fields.
|
||||
**Warning signs:** Signature verification fails intermittently or consistently on the agent side.
|
||||
|
||||
### Pitfall 6: Forgetting to Exclude Actuator/Springdoc Paths
|
||||
**What goes wrong:** Health endpoint returns 401 because the SecurityFilterChain doesn't match the actuator path format.
|
||||
**Why it happens:** Actuator's base path is configured as `/api/v1` in this project (see `management.endpoints.web.base-path`), so health is at `/api/v1/health`. Springdoc paths may also vary depending on configuration.
|
||||
**How to avoid:** Ensure `requestMatchers` covers: `/api/v1/health`, `/api/v1/api-docs/**`, `/api/v1/swagger-ui/**`, `/swagger-ui/**`, `/v3/api-docs/**` (springdoc internal redirects).
|
||||
**Warning signs:** Health checks fail, Swagger UI returns 401.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### JWT Creation with Nimbus JOSE+JWT (HMAC-SHA256)
|
||||
```java
|
||||
// Source: https://connect2id.com/products/nimbus-jose-jwt/examples/jwt-with-hmac
|
||||
import com.nimbusds.jose.*;
|
||||
import com.nimbusds.jose.crypto.*;
|
||||
import com.nimbusds.jwt.*;
|
||||
import java.util.Date;
|
||||
|
||||
// Generate a random 256-bit secret at startup
|
||||
byte[] secret = new byte[32];
|
||||
new java.security.SecureRandom().nextBytes(secret);
|
||||
|
||||
// Create access token
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||
.subject(agentId) // sub = agentId
|
||||
.claim("group", group) // custom claim
|
||||
.claim("type", "access") // distinguish from refresh
|
||||
.issueTime(new Date())
|
||||
.expirationTime(new Date(System.currentTimeMillis() + 3600_000)) // 1 hour
|
||||
.build();
|
||||
|
||||
SignedJWT jwt = new SignedJWT(
|
||||
new JWSHeader(JWSAlgorithm.HS256),
|
||||
claims);
|
||||
jwt.sign(new MACSigner(secret));
|
||||
String tokenString = jwt.serialize();
|
||||
|
||||
// Validate token
|
||||
SignedJWT parsed = SignedJWT.parse(tokenString);
|
||||
boolean valid = parsed.verify(new MACVerifier(secret));
|
||||
// Then check: claims.getExpirationTime().after(new Date())
|
||||
// Then check: claims.getStringClaim("type").equals("access")
|
||||
```
|
||||
|
||||
### Ed25519 Keypair Generation and Signing (JDK 17)
|
||||
```java
|
||||
// Source: https://howtodoinjava.com/java15/java-eddsa-example/
|
||||
import java.security.*;
|
||||
import java.util.Base64;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
// Generate ephemeral keypair at startup
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
||||
KeyPair keyPair = keyGen.generateKeyPair();
|
||||
|
||||
// Export public key as Base64 (X.509 SubjectPublicKeyInfo DER)
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(
|
||||
keyPair.getPublic().getEncoded());
|
||||
|
||||
// Sign a payload
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(keyPair.getPrivate());
|
||||
signer.update(payloadJson.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] sig = signer.sign();
|
||||
String signatureBase64 = Base64.getEncoder().encodeToString(sig);
|
||||
```
|
||||
|
||||
### SecurityFilterChain Configuration
|
||||
```java
|
||||
// Source: Spring Security 6.4 reference docs
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http,
|
||||
JwtService jwtService,
|
||||
AgentRegistryService registry) throws Exception {
|
||||
JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(jwtService, registry);
|
||||
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/api/v1/health",
|
||||
"/api/v1/agents/register",
|
||||
"/api/v1/api-docs/**",
|
||||
"/api/v1/swagger-ui/**",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs/**"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Bootstrap Token Validation with Constant-Time Comparison
|
||||
```java
|
||||
import java.security.MessageDigest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public boolean validateBootstrapToken(String provided) {
|
||||
byte[] providedBytes = provided.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] expectedBytes = bootstrapToken.getBytes(StandardCharsets.UTF_8);
|
||||
boolean match = MessageDigest.isEqual(providedBytes, expectedBytes);
|
||||
|
||||
if (!match && previousBootstrapToken != null) {
|
||||
byte[] previousBytes = previousBootstrapToken.getBytes(StandardCharsets.UTF_8);
|
||||
match = MessageDigest.isEqual(providedBytes, previousBytes);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `WebSecurityConfigurerAdapter` | `SecurityFilterChain` bean | Spring Security 5.7 / Spring Boot 3.0 | Must use lambda-style `HttpSecurity` configuration |
|
||||
| `antMatchers()` | `requestMatchers()` | Spring Security 6.0 | Method name changed; old code won't compile |
|
||||
| Ed25519 via Bouncy Castle | JDK built-in Ed25519 | Java 15 (JEP 339) | No external dependency needed for EdDSA |
|
||||
| Session-based auth | Stateless JWT | Architectural pattern | `SessionCreationPolicy.STATELESS` mandatory for REST APIs |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `WebSecurityConfigurerAdapter`: Removed in Spring Security 6.0. Use `SecurityFilterChain` bean instead.
|
||||
- `antMatchers()` / `mvcMatchers()`: Replaced by `requestMatchers()` in Spring Security 6.0.
|
||||
- `authorizeRequests()`: Replaced by `authorizeHttpRequests()` in Spring Security 6.0.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Nimbus JOSE+JWT transitive availability**
|
||||
- What we know: `spring-boot-starter-security` brings Spring Security 6.4.3. If `spring-security-oauth2-jose` is on the classpath, Nimbus is available transitively.
|
||||
- What's unclear: Whether the base `spring-boot-starter-security` (without OAuth2 resource server) includes Nimbus.
|
||||
- Recommendation: Add `com.nimbusds:nimbus-jose-jwt` explicitly as a dependency. This costs nothing if already transitive and ensures availability if not. Version 9.47 is current and compatible.
|
||||
|
||||
2. **Existing test adaptation scope**
|
||||
- What we know: 21 existing integration tests use `TestRestTemplate` without any auth headers. All will fail when security is enabled.
|
||||
- What's unclear: Exact effort to adapt all tests.
|
||||
- Recommendation: Create a test utility class that generates valid test JWTs and bootstrap tokens. Set `CAMELEER_AUTH_TOKEN=test-token` in `application-test.yml`. Add JWT header to all test HTTP calls via a shared helper method.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | JUnit 5 + Spring Boot Test (spring-boot-starter-test) |
|
||||
| Config file | `cameleer3-server-app/src/test/resources/application-test.yml` |
|
||||
| Quick run command | `mvn test -pl cameleer3-server-app -Dtest=Security*Test -Dsurefire.reuseForks=false` |
|
||||
| Full suite command | `mvn clean verify` |
|
||||
|
||||
### Phase Requirements to Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SECU-01 | Protected endpoints reject requests without JWT; public endpoints accessible | integration | `mvn test -pl cameleer3-server-app -Dtest=SecurityFilterIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
| SECU-02 | Refresh endpoint issues new access JWT from valid refresh token | integration | `mvn test -pl cameleer3-server-app -Dtest=JwtRefreshIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
| SECU-03 | Ed25519 keypair generated at startup; public key in registration response | integration | `mvn test -pl cameleer3-server-app -Dtest=RegistrationSecurityIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
| SECU-04 | SSE payloads carry valid Ed25519 signature | integration | `mvn test -pl cameleer3-server-app -Dtest=SseSigningIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
| SECU-05 | Bootstrap token required for registration; rejects invalid/missing tokens | integration | `mvn test -pl cameleer3-server-app -Dtest=BootstrapTokenIT -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
| N/A | JWT creation, validation, expiry logic | unit | `mvn test -pl cameleer3-server-app -Dtest=JwtServiceTest -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
| N/A | Ed25519 signing and verification roundtrip | unit | `mvn test -pl cameleer3-server-app -Dtest=Ed25519SigningServiceTest -Dsurefire.reuseForks=false` | No -- Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `mvn test -pl cameleer3-server-app -Dsurefire.reuseForks=false`
|
||||
- **Per wave merge:** `mvn clean verify`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `SecurityFilterIT.java` -- covers SECU-01 (protected/public endpoint access)
|
||||
- [ ] `JwtRefreshIT.java` -- covers SECU-02 (refresh flow)
|
||||
- [ ] `RegistrationSecurityIT.java` -- covers SECU-03 + SECU-05 (bootstrap token + public key)
|
||||
- [ ] `SseSigningIT.java` -- covers SECU-04 (Ed25519 SSE signing)
|
||||
- [ ] `BootstrapTokenIT.java` -- covers SECU-05 (bootstrap token validation)
|
||||
- [ ] `JwtServiceTest.java` -- unit test for JWT creation/validation
|
||||
- [ ] `Ed25519SigningServiceTest.java` -- unit test for Ed25519 signing roundtrip
|
||||
- [ ] Update `application-test.yml` with `CAMELEER_AUTH_TOKEN: test-token` and security-related test config
|
||||
- [ ] Update ALL existing ITs to include JWT auth headers (21 test files affected)
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Spring Security 6.4 Official Docs](https://docs.spring.io/spring-security/reference/servlet/architecture.html) - SecurityFilterChain configuration, filter ordering
|
||||
- [Spring Security OAuth2 Resource Server JWT](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html) - JWT handling patterns
|
||||
- [Nimbus JOSE+JWT Official Site](https://connect2id.com/products/nimbus-jose-jwt) - Library capabilities, HMAC examples
|
||||
- [Nimbus JOSE+JWT HMAC Examples](https://connect2id.com/products/nimbus-jose-jwt/examples/jwt-with-hmac) - JWT creation/verification code
|
||||
- [Java EdDSA (Ed25519) - HowToDoInJava](https://howtodoinjava.com/java15/java-eddsa-example/) - JDK built-in Ed25519 API
|
||||
- [JDK 17 X509EncodedKeySpec](https://docs.oracle.com/en/java/javase/17/docs/api//java.base/java/security/spec/X509EncodedKeySpec.html) - Public key encoding format
|
||||
- Spring Boot 3.4.3 BOM - Confirms Spring Security 6.4.3 managed version
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Baeldung Custom Filter](https://www.baeldung.com/spring-security-custom-filter) - Custom filter registration patterns, double-registration pitfall
|
||||
- [Bootiful Spring Boot 3.4: Security](https://spring.io/blog/2024/11/24/bootiful-34-security/) - Spring Boot 3.4 security features overview
|
||||
- [Bootify REST API with JWT](https://bootify.io/spring-security/rest-api-spring-security-with-jwt.html) - JWT filter pattern validation
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None -- all findings verified against primary sources
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - Spring Security 6.4.3 confirmed managed by Spring Boot 3.4.3, Nimbus well-documented, JDK Ed25519 verified for Java 17
|
||||
- Architecture: HIGH - SecurityFilterChain pattern is the documented standard for Spring Security 6.x, existing codebase has clear integration points
|
||||
- Pitfalls: HIGH - Double filter registration and test breakage are well-documented issues with Spring Security adoption; Ed25519 signing concerns are from domain knowledge
|
||||
|
||||
**Research date:** 2026-03-11
|
||||
**Valid until:** 2026-04-11 (stable -- Spring Boot 3.4.x LTS, JDK 17 LTS)
|
||||
86
.planning/phases/04-security/04-VALIDATION.md
Normal file
86
.planning/phases/04-security/04-VALIDATION.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
phase: 4
|
||||
slug: security
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-11
|
||||
---
|
||||
|
||||
# Phase 4 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | JUnit 5 + Spring Boot Test + Spring Security Test |
|
||||
| **Config file** | cameleer3-server-app/src/test/resources/application-test.yml |
|
||||
| **Quick run command** | `mvn test -pl cameleer3-server-app -Dtest="Security*,Jwt*,Bootstrap*,Ed25519*" -Dsurefire.reuseForks=false` |
|
||||
| **Full suite command** | `mvn clean verify` |
|
||||
| **Estimated runtime** | ~60 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `mvn test -pl cameleer3-server-app -Dsurefire.reuseForks=false`
|
||||
- **After every plan wave:** Run `mvn clean verify`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 04-01-01 | 01 | 1 | SECU-03 | unit | `mvn test -pl cameleer3-server-app -Dtest=Ed25519SigningServiceTest -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
| 04-01-02 | 01 | 1 | SECU-01 | unit | `mvn test -pl cameleer3-server-app -Dtest=JwtServiceTest -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
| 04-01-03 | 01 | 1 | SECU-05 | integration | `mvn test -pl cameleer3-server-app -Dtest=BootstrapTokenIT -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
| 04-01-04 | 01 | 1 | SECU-01 | integration | `mvn test -pl cameleer3-server-app -Dtest=SecurityFilterIT -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
| 04-01-05 | 01 | 1 | SECU-02 | integration | `mvn test -pl cameleer3-server-app -Dtest=JwtRefreshIT -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
| 04-01-06 | 01 | 1 | SECU-04 | integration | `mvn test -pl cameleer3-server-app -Dtest=SseSigningIT -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
| 04-01-07 | 01 | 1 | N/A | integration | `mvn test -pl cameleer3-server-app -Dtest=RegistrationSecurityIT -Dsurefire.reuseForks=false` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `Ed25519SigningServiceTest.java` — unit test stubs for Ed25519 signing roundtrip (SECU-03)
|
||||
- [ ] `JwtServiceTest.java` — unit test stubs for JWT creation/validation/expiry (SECU-01, SECU-02)
|
||||
- [ ] `BootstrapTokenIT.java` — integration test stubs for bootstrap token validation (SECU-05)
|
||||
- [ ] `SecurityFilterIT.java` — integration test stubs for protected/public endpoint access (SECU-01)
|
||||
- [ ] `JwtRefreshIT.java` — integration test stubs for refresh flow (SECU-02)
|
||||
- [ ] `SseSigningIT.java` — integration test stubs for Ed25519 SSE signing (SECU-04)
|
||||
- [ ] `RegistrationSecurityIT.java` — integration test stubs for registration with bootstrap + public key (SECU-03, SECU-05)
|
||||
- [ ] Update `application-test.yml` with `CAMELEER_AUTH_TOKEN: test-token`
|
||||
- [ ] Update ALL existing ITs to include JWT auth headers (21 test files affected)
|
||||
|
||||
*Existing infrastructure covers test framework and Testcontainers setup.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| JWT token leakage in SSE query param logs | SECU-01 | Requires production log inspection | Check access logs don't log query parameters containing JWT tokens |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
118
.planning/phases/04-security/04-VERIFICATION.md
Normal file
118
.planning/phases/04-security/04-VERIFICATION.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
phase: 04-security
|
||||
verified: 2026-03-11T20:50:00Z
|
||||
status: passed
|
||||
score: 10/10 must-haves verified
|
||||
gaps: []
|
||||
human_verification: []
|
||||
---
|
||||
|
||||
# Phase 4: Security Verification Report
|
||||
|
||||
**Phase Goal:** All server communication is authenticated and integrity-protected, with JWT for API access and Ed25519 signatures for pushed configuration
|
||||
**Verified:** 2026-03-11T20:50:00Z
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
All truths drawn from PLAN frontmatter must_haves across plans 01, 02, and 03.
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Ed25519 keypair generated at startup; public key available as Base64 | VERIFIED | `Ed25519SigningServiceImpl` generates keypair via `KeyPairGenerator.getInstance("Ed25519")` in constructor; `getPublicKeyBase64()` returns Base64-encoded X.509 DER bytes |
|
||||
| 2 | JwtService creates access tokens (1h) and refresh tokens (7d) with agentId, group, and type claims | VERIFIED | `JwtServiceImpl.createToken()` sets `sub`, `group`, `type`, `iat`, `exp` claims using Nimbus `MACSigner`/`HS256`; expiry from `SecurityProperties` |
|
||||
| 3 | JwtService validates tokens and extracts agentId, distinguishing access vs refresh type | VERIFIED | `validateToken()` checks signature, expiration, and `type` claim; throws `InvalidTokenException` on any violation |
|
||||
| 4 | BootstrapTokenValidator uses constant-time comparison and supports dual-token rotation | VERIFIED | Uses `MessageDigest.isEqual()` for both primary and previous token; null/blank guarded |
|
||||
| 5 | Server fails fast on startup if CAMELEER_AUTH_TOKEN is not set | VERIFIED | `SecurityBeanConfig` registers an `InitializingBean` that throws `IllegalStateException` if `bootstrapToken` is null or blank |
|
||||
| 6 | All API endpoints except health, register, and docs reject requests without valid JWT | VERIFIED | `SecurityConfig` permits `/api/v1/health`, `/api/v1/agents/register`, `/api/v1/agents/*/refresh`, Swagger docs, and `/error`; all other requests require authentication; `SecurityFilterIT` (6 tests) confirms |
|
||||
| 7 | POST /register requires bootstrap token; returns JWT + refresh token + Ed25519 public key | VERIFIED | `AgentRegistrationController.register()` extracts and validates bootstrap token from `Authorization: Bearer` header, calls `jwtService.createAccessToken/createRefreshToken` and `ed25519SigningService.getPublicKeyBase64()`; `RegistrationSecurityIT` (3 tests) confirms |
|
||||
| 8 | POST /agents/{id}/refresh accepts refresh token and returns new access JWT | VERIFIED | `AgentRegistrationController.refresh()` calls `jwtService.validateRefreshToken()`, verifies agent ID match, issues new access token; `JwtRefreshIT` (5 tests) confirms |
|
||||
| 9 | All config-update, deep-trace, and replay SSE events carry a valid Ed25519 signature | VERIFIED | `SseConnectionManager.onCommandReady()` calls `ssePayloadSigner.signPayload(command.payload())` before `sendEvent()`; `SseSigningIT` (2 tests) verify end-to-end signature against public key |
|
||||
| 10 | Signature computed over original payload JSON, added as "signature" field | VERIFIED | `SsePayloadSigner.signPayload()` signs original string, parses JSON, adds `"signature"` field via `ObjectNode.put()`, re-serializes; `SsePayloadSignerTest` (7 tests) confirms including roundtrip verification |
|
||||
|
||||
**Score:** 10/10 truths verified
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Provides | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `cameleer3-server-core/.../security/JwtService.java` | JWT interface: createAccessToken, createRefreshToken, validateAndExtractAgentId, validateRefreshToken | VERIFIED | 49 lines, substantive interface with 4 methods |
|
||||
| `cameleer3-server-core/.../security/Ed25519SigningService.java` | Ed25519 interface: sign(payload), getPublicKeyBase64() | VERIFIED | 29 lines, substantive interface with 2 methods |
|
||||
| `cameleer3-server-app/.../security/JwtServiceImpl.java` | Nimbus JOSE+JWT HMAC-SHA256 implementation | VERIFIED | 120 lines; uses `MACSigner`/`MACVerifier`, ephemeral 256-bit secret, correct claims |
|
||||
| `cameleer3-server-app/.../security/Ed25519SigningServiceImpl.java` | JDK 17 Ed25519 KeyPairGenerator implementation | VERIFIED | 54 lines; `KeyPairGenerator.getInstance("Ed25519")`, `Signature.getInstance("Ed25519")`, Base64-encoded output |
|
||||
| `cameleer3-server-app/.../security/BootstrapTokenValidator.java` | Constant-time bootstrap token validation with dual-token rotation | VERIFIED | 50 lines; `MessageDigest.isEqual()`, checks current and previous token, null/blank guard |
|
||||
| `cameleer3-server-app/.../security/SecurityProperties.java` | Config binding with env var mapping | VERIFIED | 48 lines; `@ConfigurationProperties(prefix="security")`; all 4 fields with defaults |
|
||||
| `cameleer3-server-app/.../security/SecurityBeanConfig.java` | Bean wiring with fail-fast validation | VERIFIED | 43 lines; `@EnableConfigurationProperties`, all 3 service beans, `InitializingBean` check |
|
||||
| `cameleer3-server-app/.../security/JwtAuthenticationFilter.java` | OncePerRequestFilter extracting JWT from header or query param | VERIFIED | 72 lines; extracts from `Authorization: Bearer` then `?token=` query param; sets `SecurityContextHolder` |
|
||||
| `cameleer3-server-app/.../security/SecurityConfig.java` | SecurityFilterChain with permitAll for public paths, authenticated for rest | VERIFIED | 54 lines; stateless, CSRF disabled, correct permitAll list, `addFilterBefore` JwtAuthenticationFilter |
|
||||
| `cameleer3-server-app/.../controller/AgentRegistrationController.java` | Updated register endpoint with bootstrap token validation, JWT issuance, public key; refresh endpoint | VERIFIED | 230 lines; both `/register` and `/{id}/refresh` endpoints fully wired |
|
||||
| `cameleer3-server-app/.../agent/SsePayloadSigner.java` | Component that signs SSE command payloads | VERIFIED | 77 lines; `@Component`, signs then adds field, defensive null/blank handling |
|
||||
| `cameleer3-server-app/.../agent/SseConnectionManager.java` | Updated onCommandReady with signing before sendEvent | VERIFIED | `onCommandReady()` calls `ssePayloadSigner.signPayload()`, parses to `JsonNode` to avoid double-quoting |
|
||||
| `cameleer3-server-app/.../resources/application.yml` | Security config with env var mapping | VERIFIED | `security.bootstrap-token: ${CAMELEER_AUTH_TOKEN:}` and `security.bootstrap-token-previous: ${CAMELEER_AUTH_TOKEN_PREVIOUS:}` present |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `JwtServiceImpl` | Nimbus JOSE+JWT MACSigner/MACVerifier | HMAC-SHA256 signing with ephemeral 256-bit secret | VERIFIED | `new MACSigner(secret)`, `new MACVerifier(secret)`, `SignedJWT` — all present |
|
||||
| `Ed25519SigningServiceImpl` | JDK KeyPairGenerator/Signature | Ed25519 algorithm from java.security | VERIFIED | `KeyPairGenerator.getInstance("Ed25519")` and `Signature.getInstance("Ed25519")` confirmed |
|
||||
| `BootstrapTokenValidator` | SecurityProperties | reads token values from config properties | VERIFIED | `MessageDigest.isEqual()` used; reads `properties.getBootstrapToken()` and `properties.getBootstrapTokenPrevious()` |
|
||||
| `JwtAuthenticationFilter` | `JwtService.validateAndExtractAgentId` | Filter delegates JWT validation to service | VERIFIED | `jwtService.validateAndExtractAgentId(token)` on line 46 of filter |
|
||||
| `SecurityConfig` | `JwtAuthenticationFilter` | addFilterBefore | VERIFIED | `addFilterBefore(new JwtAuthenticationFilter(jwtService, registryService), UsernamePasswordAuthenticationFilter.class)` |
|
||||
| `AgentRegistrationController.register` | `BootstrapTokenValidator.validate` | Validates bootstrap token before processing | VERIFIED | `bootstrapTokenValidator.validate(bootstrapToken)` before any processing |
|
||||
| `AgentRegistrationController.register` | `JwtService.createAccessToken + createRefreshToken` | Issues tokens in registration response | VERIFIED | `jwtService.createAccessToken(agentId, group)` and `jwtService.createRefreshToken(agentId, group)` both called |
|
||||
| `SseConnectionManager.onCommandReady` | `SsePayloadSigner.signPayload` | Signs payload before SSE delivery | VERIFIED | `ssePayloadSigner.signPayload(command.payload())` on line 146 of SseConnectionManager |
|
||||
| `SsePayloadSigner` | `Ed25519SigningService.sign` | Delegates signing to Ed25519 service | VERIFIED | `ed25519SigningService.sign(jsonPayload)` on line 60 of SsePayloadSigner |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| SECU-01 (#23) | Plan 02 | All API endpoints (except health and register) require valid JWT Bearer token | SATISFIED | `SecurityConfig` enforces authentication on all non-public paths; `SecurityFilterIT` tests confirm 401/403 without JWT |
|
||||
| SECU-02 (#24) | Plan 02 | JWT refresh flow via `POST /api/v1/agents/{id}/refresh` | SATISFIED | `AgentRegistrationController.refresh()` endpoint; `JwtRefreshIT` (5 tests) cover valid/invalid/wrong-type/mismatch/chain cases |
|
||||
| SECU-03 (#25) | Plan 01 | Server generates Ed25519 keypair; public key delivered at registration | SATISFIED | `Ed25519SigningServiceImpl` generates keypair at construction; `register()` returns `serverPublicKey` from `getPublicKeyBase64()`; `RegistrationSecurityIT` confirms |
|
||||
| SECU-04 (#26) | Plan 03 | All config-update and replay SSE payloads are signed with server's Ed25519 private key | SATISFIED | `SsePayloadSigner` signs all command payloads; `SseConnectionManager.onCommandReady()` calls it; `SseSigningIT` verifies end-to-end signature |
|
||||
| SECU-05 (#27) | Plans 01+02 | Bootstrap token from `CAMELEER_AUTH_TOKEN` env var validates initial agent registration | SATISFIED | `SecurityBeanConfig` fails fast if missing; `BootstrapTokenValidator` checks with constant-time comparison; `BootstrapTokenIT` (4 tests) confirm |
|
||||
|
||||
All 5 SECU requirements satisfied. No orphaned or unaccounted requirements.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
No anti-patterns detected in the security implementation files.
|
||||
|
||||
Scanned: `JwtServiceImpl.java`, `Ed25519SigningServiceImpl.java`, `BootstrapTokenValidator.java`, `SecurityBeanConfig.java`, `JwtAuthenticationFilter.java`, `SecurityConfig.java`, `AgentRegistrationController.java`, `SsePayloadSigner.java`, `SseConnectionManager.java`.
|
||||
|
||||
- No TODO/FIXME/placeholder comments
|
||||
- No stub returns (empty arrays, null without reason, etc.)
|
||||
- No console.log-only implementations
|
||||
- No disabled wiring
|
||||
|
||||
One note: `deferred-items.md` documented 8 test failures at end of Plan 03. All are resolved — `AgentSseControllerIT`, `AgentCommandControllerIT`, and `JwtRefreshIT` all pass (verified by running full suite: 91 tests, 0 failures).
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None. All security properties are verifiable programmatically:
|
||||
|
||||
- JWT token signing and validation: covered by unit tests
|
||||
- Bootstrap token constant-time comparison: code inspection confirms `MessageDigest.isEqual()`
|
||||
- Ed25519 signature verification: `SseSigningIT` verifies end-to-end using `Signature.getInstance("Ed25519")` with public key
|
||||
- SecurityFilterChain endpoint protection: `SecurityFilterIT` exercises the full HTTP stack
|
||||
|
||||
### Test Suite Result
|
||||
|
||||
Full `mvn verify` with `CAMELEER_AUTH_TOKEN=test-bootstrap-token`:
|
||||
|
||||
| Suite | Tests | Result |
|
||||
|-------|-------|--------|
|
||||
| Unit tests (JwtServiceTest, Ed25519SigningServiceTest, BootstrapTokenValidatorTest, SsePayloadSignerTest, ElkDiagramRendererTest) | 36 | PASS |
|
||||
| Security ITs (SecurityFilterIT, BootstrapTokenIT, RegistrationSecurityIT, JwtRefreshIT, SseSigningIT) | 20 | PASS |
|
||||
| All other controller/storage ITs | 35 | PASS |
|
||||
| **Total** | **91** | **PASS** |
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-11T20:50:00Z_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
15
.planning/phases/04-security/deferred-items.md
Normal file
15
.planning/phases/04-security/deferred-items.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Phase 04 — Deferred Items
|
||||
|
||||
## Pre-existing Test Failures (from Plan 02 security integration)
|
||||
|
||||
These tests fail because Plan 02 introduced real Spring Security but did not update all existing integration tests to pass JWT auth headers. The security filter returns 403 before controllers can return the expected error codes.
|
||||
|
||||
1. **AgentSseControllerIT.sseConnect_unknownAgent_returns404** — expects 404, gets 403 (security blocks unauthenticated request)
|
||||
2. **AgentCommandControllerIT.sendCommandToUnregisteredAgent_returns404** — expects 404, gets 403
|
||||
3. **AgentCommandControllerIT.acknowledgeUnknownCommand_returns404** — expects 404, gets 403
|
||||
4. **JwtRefreshIT (all 5 tests)** — all failing, likely needs bootstrap token for agent registration step
|
||||
|
||||
**Root cause:** Plan 02 emptied TestSecurityConfig and activated real SecurityConfig, but did not update pre-existing ITs to include JWT auth or adjust expected status codes for unauthenticated requests.
|
||||
|
||||
**Discovered during:** Plan 03 execution (04-03)
|
||||
**Scope:** Out of scope for Plan 03 (pre-existing, not caused by signing changes)
|
||||
@@ -43,7 +43,8 @@ Agents (50+) Users / UI
|
||||
| **SSE Channel Manager** | core (interface) + app (impl) | Manage SSE connections, push config/commands | Agent Registry |
|
||||
| **Diagram Service** | core | Version diagrams, link to transactions, trigger rendering | Diagram Store |
|
||||
| **Diagram Renderer** | core | Server-side rendering of route definitions to visual output | Diagram Service |
|
||||
| **Auth Service** | core | JWT validation, Ed25519 signing, bootstrap token flow | All controllers |
|
||||
| **Auth Service** | core | JWT validation with RBAC (AGENT/VIEWER/OPERATOR/ADMIN), Ed25519 signing, bootstrap token flow, OIDC token exchange | All controllers |
|
||||
| **User Repository** | core (interface) + app (ClickHouse) | Persist users from local login and OIDC, role management | Auth controllers, admin API |
|
||||
| **REST Controllers** | app | HTTP endpoints for transactions, agents, diagrams, config | All core services |
|
||||
| **SSE Controller** | app | SSE endpoint, connection lifecycle | SSE Channel Manager |
|
||||
| **Config Controller** | app | Config CRUD, push triggers | SSE Channel Manager, Config store |
|
||||
|
||||
BIN
.playwright-mcp/page-2026-03-24T18-41-08-535Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-24T18-41-08-535Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
.playwright-mcp/page-2026-03-24T18-41-32-291Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-24T18-41-32-291Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
.playwright-mcp/page-2026-03-24T19-34-41-650Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-24T19-34-41-650Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
.playwright-mcp/page-2026-03-24T19-51-20-426Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-24T19-51-20-426Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
.playwright-mcp/page-2026-03-24T19-53-00-560Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-24T19-53-00-560Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
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
|
||||
30
CLAUDE.md
30
CLAUDE.md
@@ -36,7 +36,31 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||
- Spring Boot 3.4.3 parent POM
|
||||
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
|
||||
- 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
|
||||
- Storage: ClickHouse for structured data, text index for full-text search
|
||||
- Security: JWT auth, Ed25519 config signing, bootstrap token for registration
|
||||
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search and application log storage
|
||||
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
||||
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
|
||||
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
- 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
|
||||
- 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: `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
|
||||
- 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`
|
||||
- Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials`
|
||||
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health`
|
||||
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
|
||||
|
||||
## 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.
|
||||
|
||||
## Disabled Skills
|
||||
|
||||
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
||||
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-17 AS build
|
||||
WORKDIR /build
|
||||
|
||||
# Configure Gitea Maven Registry for cameleer3-common dependency
|
||||
ARG REGISTRY_TOKEN
|
||||
RUN mkdir -p ~/.m2 && \
|
||||
echo '<settings><servers><server><id>gitea</id><username>cameleer</username><password>'${REGISTRY_TOKEN}'</password></server></servers></settings>' > ~/.m2/settings.xml
|
||||
|
||||
COPY pom.xml .
|
||||
COPY cameleer3-server-core/pom.xml cameleer3-server-core/
|
||||
COPY cameleer3-server-app/pom.xml cameleer3-server-app/
|
||||
# Cache deps — only re-downloaded when POMs change
|
||||
RUN mvn dependency:go-offline -B || true
|
||||
COPY . .
|
||||
RUN mvn clean package -DskipTests -U -B
|
||||
|
||||
FROM eclipse-temurin:17-jre
|
||||
WORKDIR /app
|
||||
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
|
||||
ENTRYPOINT exec java -jar /app/server.jar
|
||||
376
HOWTO.md
376
HOWTO.md
@@ -4,64 +4,236 @@
|
||||
|
||||
- Java 17+
|
||||
- Maven 3.9+
|
||||
- Node.js 22+ and npm
|
||||
- Docker & Docker Compose
|
||||
- Access to the Gitea Maven registry (for `cameleer3-common` dependency)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build UI first (required for embedded mode)
|
||||
cd ui && npm ci && npm run build && cd ..
|
||||
|
||||
# Backend
|
||||
mvn clean compile # compile only
|
||||
mvn clean verify # compile + run all tests (needs Docker for integration tests)
|
||||
```
|
||||
|
||||
## Infrastructure Setup
|
||||
|
||||
Start ClickHouse:
|
||||
Start PostgreSQL and OpenSearch:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts ClickHouse 25.3 and automatically runs the schema init scripts (`clickhouse/init/01-schema.sql`, `clickhouse/init/02-search-columns.sql`).
|
||||
This starts TimescaleDB (PostgreSQL 16) and OpenSearch 2.19. The database schema is applied automatically via Flyway migrations on server startup.
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|------------|------|------------------|
|
||||
| ClickHouse | 8123 | HTTP API (JDBC) |
|
||||
| ClickHouse | 9000 | Native protocol |
|
||||
| Service | Port | Purpose |
|
||||
|------------|------|----------------------|
|
||||
| PostgreSQL | 5432 | JDBC (Spring JDBC) |
|
||||
| OpenSearch | 9200 | REST API (full-text) |
|
||||
|
||||
ClickHouse credentials: `cameleer` / `cameleer_dev`, database `cameleer3`.
|
||||
PostgreSQL credentials: `cameleer` / `cameleer_dev`, database `cameleer3`.
|
||||
|
||||
## Run the Server
|
||||
|
||||
```bash
|
||||
mvn clean package -DskipTests
|
||||
java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||
CAMELEER_AUTH_TOKEN=my-secret-token java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
The server starts on **port 8081**.
|
||||
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.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication (Phase 4)
|
||||
|
||||
All endpoints except health, registration, and docs require a JWT Bearer token. The typical flow:
|
||||
|
||||
```bash
|
||||
# 1. Register agent (requires bootstrap token)
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer my-secret-token" \
|
||||
-d '{"agentId":"agent-1","name":"Order Service","group":"order-service-prod","version":"1.0.0","routeIds":["route-1"],"capabilities":["deep-trace","replay"]}'
|
||||
# Response includes: accessToken, refreshToken, serverPublicKey (Ed25519, Base64)
|
||||
|
||||
# 2. Use access token for all subsequent requests
|
||||
TOKEN="<accessToken from registration>"
|
||||
|
||||
# 3. Refresh when access token expires (1h default)
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/refresh \
|
||||
-H "Authorization: Bearer <refreshToken>"
|
||||
# Response: { "accessToken": "new-jwt" }
|
||||
```
|
||||
|
||||
**UI Login (for browser access):**
|
||||
```bash
|
||||
# Login with UI credentials (returns JWT tokens)
|
||||
curl -s -X POST http://localhost:8081/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}'
|
||||
# Response: { "accessToken": "...", "refreshToken": "..." }
|
||||
|
||||
# Refresh UI token
|
||||
curl -s -X POST http://localhost:8081/api/v1/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"refreshToken":"<refreshToken>"}'
|
||||
```
|
||||
|
||||
UI credentials are configured via `CAMELEER_UI_USER` / `CAMELEER_UI_PASSWORD` env vars (default: `admin` / `admin`).
|
||||
|
||||
**Public endpoints (no JWT required):** `GET /api/v1/health`, `POST /api/v1/agents/register` (uses bootstrap token), `POST /api/v1/auth/**`, OpenAPI/Swagger docs.
|
||||
|
||||
**Protected endpoints (JWT required):** All other endpoints including ingestion, search, agent management, commands.
|
||||
|
||||
**SSE connections:** Authenticated via query parameter: `/agents/{id}/events?token=<jwt>` (EventSource API doesn't support custom headers).
|
||||
|
||||
**Ed25519 signatures:** All SSE command payloads (config-update, deep-trace, replay) include a `signature` field. Agents verify payload integrity using the `serverPublicKey` received during registration. The server generates a new ephemeral keypair on each startup — agents must re-register to get the new key.
|
||||
|
||||
### RBAC (Role-Based Access Control)
|
||||
|
||||
JWTs carry a `roles` claim. Endpoints are restricted by role:
|
||||
|
||||
| Role | Access |
|
||||
|------|--------|
|
||||
| `AGENT` | Data ingestion (`/data/**` — executions, diagrams, metrics, logs), heartbeat, SSE events, command ack |
|
||||
| `VIEWER` | Search, execution detail, diagrams, agent list |
|
||||
| `OPERATOR` | VIEWER + send commands to agents |
|
||||
| `ADMIN` | OPERATOR + user management (`/admin/**`) |
|
||||
|
||||
The env-var local user gets `ADMIN` role. Agents get `AGENT` role at registration.
|
||||
|
||||
### OIDC Login (Optional)
|
||||
|
||||
OIDC configuration is stored in PostgreSQL and managed via the admin API or UI. The SPA checks if OIDC is available:
|
||||
|
||||
```bash
|
||||
# 1. SPA checks if OIDC is available (returns 404 if not configured)
|
||||
curl -s http://localhost:8081/api/v1/auth/oidc/config
|
||||
# Returns: { "issuer": "...", "clientId": "...", "authorizationEndpoint": "..." }
|
||||
|
||||
# 2. After OIDC redirect, SPA sends the authorization code
|
||||
curl -s -X POST http://localhost:8081/api/v1/auth/oidc/callback \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"code":"auth-code-from-provider","redirectUri":"http://localhost:5173/callback"}'
|
||||
# Returns: { "accessToken": "...", "refreshToken": "..." }
|
||||
```
|
||||
|
||||
Local login remains available as fallback even when OIDC is enabled.
|
||||
|
||||
### OIDC Admin Configuration (ADMIN only)
|
||||
|
||||
OIDC settings are managed at runtime via the admin API. No server restart needed.
|
||||
|
||||
```bash
|
||||
# Get current OIDC config
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/v1/admin/oidc
|
||||
|
||||
# Save OIDC config (client_secret: send "********" to keep existing, or new value to update)
|
||||
curl -s -X PUT http://localhost:8081/api/v1/admin/oidc \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"enabled": true,
|
||||
"issuerUri": "http://authentik:9000/application/o/cameleer/",
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"rolesClaim": "realm_access.roles",
|
||||
"defaultRoles": ["VIEWER"]
|
||||
}'
|
||||
|
||||
# Test OIDC provider connectivity
|
||||
curl -s -X POST http://localhost:8081/api/v1/admin/oidc/test \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Delete OIDC config (disables OIDC)
|
||||
curl -s -X DELETE http://localhost:8081/api/v1/admin/oidc \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**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)
|
||||
|
||||
Authentik 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
|
||||
2. **Create provider**: Admin Interface → Providers → Create → OAuth2/OpenID Provider
|
||||
- Name: `Cameleer`
|
||||
- Authorization flow: `default-provider-authorization-explicit-consent`
|
||||
- Client type: `Confidential`
|
||||
- Redirect URIs: `http://192.168.50.86:30090/callback` (or your UI URL)
|
||||
- Note the **Client ID** and **Client Secret**
|
||||
3. **Create application**: Admin Interface → Applications → Create
|
||||
- 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_ISSUER=http://authentik:9000/application/o/cameleer/
|
||||
CAMELEER_OIDC_CLIENT_ID=<client-id-from-step-2>
|
||||
CAMELEER_OIDC_CLIENT_SECRET=<client-secret-from-step-2>
|
||||
```
|
||||
|
||||
### User Management (ADMIN only)
|
||||
|
||||
```bash
|
||||
# List all users
|
||||
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8081/api/v1/admin/users
|
||||
|
||||
# Update user roles
|
||||
curl -s -X PUT http://localhost:8081/api/v1/admin/users/{userId}/roles \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"roles":["VIEWER","OPERATOR"]}'
|
||||
|
||||
# Delete user
|
||||
curl -s -X DELETE http://localhost:8081/api/v1/admin/users/{userId} \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Ingestion (POST, returns 202 Accepted)
|
||||
|
||||
```bash
|
||||
# Post route execution data
|
||||
# Post route execution data (JWT required)
|
||||
curl -s -X POST http://localhost:8081/api/v1/data/executions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Protocol-Version: 1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"agentId":"agent-1","routeId":"route-1","executionId":"exec-1","status":"COMPLETED","startTime":"2026-03-11T00:00:00Z","endTime":"2026-03-11T00:00:01Z","processorExecutions":[]}'
|
||||
|
||||
# Post route diagram
|
||||
curl -s -X POST http://localhost:8081/api/v1/data/diagrams \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Protocol-Version: 1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"agentId":"agent-1","routeId":"route-1","version":1,"nodes":[],"edges":[]}'
|
||||
|
||||
# Post agent metrics
|
||||
curl -s -X POST http://localhost:8081/api/v1/data/metrics \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Protocol-Version: 1" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-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.
|
||||
@@ -83,29 +255,36 @@ open http://localhost:8081/api/v1/swagger-ui.html
|
||||
|
||||
```bash
|
||||
# Search by status (GET with basic filters)
|
||||
curl -s "http://localhost:8081/api/v1/search/executions?status=COMPLETED&limit=10"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8081/api/v1/search/executions?status=COMPLETED&limit=10"
|
||||
|
||||
# Search by time range
|
||||
curl -s "http://localhost:8081/api/v1/search/executions?timeFrom=2026-03-11T00:00:00Z&timeTo=2026-03-12T00:00:00Z"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
"http://localhost:8081/api/v1/search/executions?timeFrom=2026-03-11T00:00:00Z&timeTo=2026-03-12T00:00:00Z"
|
||||
|
||||
# Advanced search (POST with full-text)
|
||||
curl -s -X POST http://localhost:8081/api/v1/search/executions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"FAILED","text":"NullPointerException","limit":20}'
|
||||
|
||||
# Transaction detail (nested processor tree)
|
||||
curl -s http://localhost:8081/api/v1/executions/{executionId}
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8081/api/v1/executions/{executionId}
|
||||
|
||||
# Processor exchange snapshot
|
||||
curl -s http://localhost:8081/api/v1/executions/{executionId}/processors/{index}/snapshot
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
http://localhost:8081/api/v1/executions/{executionId}/processors/{index}/snapshot
|
||||
|
||||
# Render diagram as SVG
|
||||
curl -s http://localhost:8081/api/v1/diagrams/{contentHash}/render \
|
||||
-H "Accept: image/svg+xml"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: image/svg+xml" \
|
||||
http://localhost:8081/api/v1/diagrams/{contentHash}/render
|
||||
|
||||
# Render diagram as JSON layout
|
||||
curl -s http://localhost:8081/api/v1/diagrams/{contentHash}/render \
|
||||
-H "Accept: application/json"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
http://localhost:8081/api/v1/diagrams/{contentHash}/render
|
||||
```
|
||||
|
||||
**Search response format:** `{ "data": [...], "total": N, "offset": 0, "limit": 50 }`
|
||||
@@ -114,6 +293,61 @@ curl -s http://localhost:8081/api/v1/diagrams/{contentHash}/render \
|
||||
|
||||
**Additional POST filters:** `durationMin`, `durationMax`, `text` (global full-text), `textInBody`, `textInHeaders`, `textInErrors`
|
||||
|
||||
### Agent Registry & SSE (Phase 3)
|
||||
|
||||
```bash
|
||||
# Register an agent (uses bootstrap token, not JWT — see Authentication section above)
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer my-secret-token" \
|
||||
-d '{"agentId":"agent-1","name":"Order Service","group":"order-service-prod","version":"1.0.0","routeIds":["route-1","route-2"],"capabilities":["deep-trace","replay"]}'
|
||||
|
||||
# Heartbeat (call every 30s)
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/heartbeat \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# List agents (optionally filter by status)
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8081/api/v1/agents"
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8081/api/v1/agents?status=LIVE"
|
||||
|
||||
# Connect to SSE event stream (JWT via query parameter)
|
||||
curl -s -N "http://localhost:8081/api/v1/agents/agent-1/events?token=$TOKEN"
|
||||
|
||||
# Send command to single agent
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/commands \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"type":"config-update","payload":{"samplingRate":0.5}}'
|
||||
|
||||
# Send command to agent group
|
||||
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":"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
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/commands \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"type":"config-update","payload":{"samplingRate":1.0}}'
|
||||
|
||||
# Acknowledge command delivery
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/commands/{commandId}/ack \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
**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`, `route-control` commands pushed in real time. Server sends ping keepalive every 15s.
|
||||
|
||||
**Command expiry:** Unacknowledged commands expire after 60 seconds.
|
||||
|
||||
### Backpressure
|
||||
|
||||
When the write buffer is full (default capacity: 50,000), ingestion endpoints return **503 Service Unavailable**. Already-buffered data is not lost.
|
||||
@@ -126,13 +360,54 @@ Key settings in `cameleer3-server-app/src/main/resources/application.yml`:
|
||||
|---------|---------|-------------|
|
||||
| `server.port` | 8081 | Server port |
|
||||
| `ingestion.buffer-capacity` | 50000 | Max items in write buffer |
|
||||
| `ingestion.batch-size` | 5000 | Items per ClickHouse batch insert |
|
||||
| `ingestion.batch-size` | 5000 | Items per batch insert |
|
||||
| `ingestion.flush-interval-ms` | 1000 | Buffer flush interval (ms) |
|
||||
| `ingestion.data-ttl-days` | 30 | ClickHouse TTL for auto-deletion |
|
||||
| `agent-registry.heartbeat-interval-seconds` | 30 | Expected heartbeat interval |
|
||||
| `agent-registry.stale-threshold-seconds` | 90 | Time before agent marked STALE |
|
||||
| `agent-registry.dead-threshold-seconds` | 300 | Time after STALE before DEAD |
|
||||
| `agent-registry.command-expiry-seconds` | 60 | Pending command TTL |
|
||||
| `agent-registry.keepalive-interval-seconds` | 15 | SSE ping keepalive interval |
|
||||
| `security.access-token-expiry-ms` | 3600000 | JWT access token lifetime (1h) |
|
||||
| `security.refresh-token-expiry-ms` | 604800000 | Refresh token lifetime (7d) |
|
||||
| `security.bootstrap-token` | `${CAMELEER_AUTH_TOKEN}` | Bootstrap token for agent registration (required) |
|
||||
| `security.bootstrap-token-previous` | `${CAMELEER_AUTH_TOKEN_PREVIOUS}` | Previous bootstrap token for rotation (optional) |
|
||||
| `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-origin` | `http://localhost:5173` | CORS allowed origin for UI (`CAMELEER_UI_ORIGIN` env var) |
|
||||
| `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.issuer-uri` | | OIDC provider issuer URL (`CAMELEER_OIDC_ISSUER`) |
|
||||
| `security.oidc.client-id` | | OAuth2 client ID (`CAMELEER_OIDC_CLIENT_ID`) |
|
||||
| `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.default-roles` | `VIEWER` | Default roles for new OIDC users (`CAMELEER_OIDC_DEFAULT_ROLES`) |
|
||||
| `opensearch.log-index-prefix` | `logs-` | OpenSearch index prefix for application logs (`CAMELEER_LOG_INDEX_PREFIX`) |
|
||||
| `opensearch.log-retention-days` | `7` | Days before log indices are deleted (`CAMELEER_LOG_RETENTION_DAYS`) |
|
||||
|
||||
## Web UI Development
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm install
|
||||
npm run dev # Vite dev server on http://localhost:5173 (proxies /api to :8081)
|
||||
npm run build # Production build to ui/dist/
|
||||
```
|
||||
|
||||
Login with `admin` / `admin` (or whatever `CAMELEER_UI_USER` / `CAMELEER_UI_PASSWORD` are set to).
|
||||
|
||||
The UI uses runtime configuration via `public/config.js`. In Kubernetes, a ConfigMap overrides this file to set the correct API base URL.
|
||||
|
||||
### Regenerate API Types
|
||||
|
||||
When the backend OpenAPI spec changes:
|
||||
```bash
|
||||
cd ui
|
||||
npm run generate-api # Requires backend running on :8081
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
Integration tests use Testcontainers (starts ClickHouse automatically — requires Docker):
|
||||
Integration tests use Testcontainers (starts PostgreSQL and OpenSearch automatically — requires Docker):
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
@@ -145,12 +420,63 @@ mvn test -pl cameleer3-server-core
|
||||
mvn test -pl cameleer3-server-app -Dtest=ExecutionControllerIT
|
||||
```
|
||||
|
||||
## Verify ClickHouse Data
|
||||
## Verify Database Data
|
||||
|
||||
After posting data and waiting for the flush interval (1s default):
|
||||
|
||||
```bash
|
||||
docker exec -it cameleer3-server-clickhouse-1 clickhouse-client \
|
||||
--user cameleer --password cameleer_dev -d cameleer3 \
|
||||
-q "SELECT count() FROM route_executions"
|
||||
docker exec -it cameleer3-server-postgres-1 psql -U cameleer -d cameleer3 \
|
||||
-c "SELECT count(*) FROM route_executions"
|
||||
```
|
||||
|
||||
## Kubernetes Deployment
|
||||
|
||||
The full stack is deployed to k3s via CI/CD on push to `main`. K8s manifests are in `deploy/`.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
cameleer namespace:
|
||||
PostgreSQL (StatefulSet, 10Gi PVC) ← postgres:5432 (ClusterIP)
|
||||
OpenSearch (StatefulSet, 10Gi PVC) ← opensearch:9200 (ClusterIP)
|
||||
cameleer3-server (Deployment) ← NodePort 30081
|
||||
cameleer3-ui (Deployment, Nginx) ← NodePort 30090
|
||||
Authentik Server (Deployment) ← NodePort 30950
|
||||
Authentik Worker (Deployment)
|
||||
Authentik PostgreSQL (StatefulSet, 1Gi) ← ClusterIP
|
||||
Authentik Redis (Deployment) ← ClusterIP
|
||||
```
|
||||
|
||||
### Access (from your network)
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Web UI | `http://192.168.50.86:30090` |
|
||||
| Server API | `http://192.168.50.86:30081/api/v1/health` |
|
||||
| Swagger UI | `http://192.168.50.86:30081/api/v1/swagger-ui.html` |
|
||||
| Authentik | `http://192.168.50.86:30950` |
|
||||
|
||||
### 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).
|
||||
|
||||
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`.
|
||||
|
||||
### Manual K8s Commands
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl -n cameleer get pods
|
||||
|
||||
# View server logs
|
||||
kubectl -n cameleer logs -f deploy/cameleer3-server
|
||||
|
||||
# View PostgreSQL logs
|
||||
kubectl -n cameleer logs -f statefulset/postgres
|
||||
|
||||
# View OpenSearch logs
|
||||
kubectl -n cameleer logs -f statefulset/opensearch
|
||||
|
||||
# Restart server
|
||||
kubectl -n cameleer rollout restart deployment/cameleer3-server
|
||||
```
|
||||
|
||||
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
|
||||
@@ -36,10 +36,26 @@
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.clickhouse</groupId>
|
||||
<artifactId>clickhouse-jdbc</artifactId>
|
||||
<version>0.9.7</version>
|
||||
<classifier>all</classifier>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.opensearch.client</groupId>
|
||||
<artifactId>opensearch-java</artifactId>
|
||||
<version>2.19.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.opensearch.client</groupId>
|
||||
<artifactId>opensearch-rest-client</artifactId>
|
||||
<version>2.19.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
@@ -66,15 +82,48 @@
|
||||
<artifactId>org.eclipse.xtext.xbase.lib</artifactId>
|
||||
<version>2.37.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>9.47</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>oauth2-oidc-sdk</artifactId>
|
||||
<version>11.23.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-clickhouse</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.opensearch</groupId>
|
||||
<artifactId>opensearch-testcontainers</artifactId>
|
||||
<version>2.1.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -90,6 +139,28 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-ui-dist</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals>
|
||||
<goal>copy-resources</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/classes/static</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>${project.basedir}/../ui/dist</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
@@ -103,7 +174,7 @@
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<configuration>
|
||||
<forkCount>1</forkCount>
|
||||
<reuseForks>false</reuseForks>
|
||||
<reuseForks>true</reuseForks>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cameleer3.server.app;
|
||||
|
||||
import com.cameleer3.server.app.config.AgentRegistryConfig;
|
||||
import com.cameleer3.server.app.config.IngestionConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -16,7 +17,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
"com.cameleer3.server.core"
|
||||
})
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(IngestionConfig.class)
|
||||
@EnableConfigurationProperties({IngestionConfig.class, AgentRegistryConfig.class})
|
||||
public class Cameleer3ServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.cameleer3.server.app.agent;
|
||||
|
||||
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.AgentState;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Periodic task that checks agent lifecycle and expires old commands.
|
||||
* <p>
|
||||
* Runs on a configurable fixed delay (default 10 seconds). Transitions
|
||||
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
|
||||
* expired pending commands. Records lifecycle events for state transitions.
|
||||
*/
|
||||
@Component
|
||||
public class AgentLifecycleMonitor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final AgentEventService agentEventService;
|
||||
|
||||
public AgentLifecycleMonitor(AgentRegistryService registryService,
|
||||
AgentEventService agentEventService) {
|
||||
this.registryService = registryService;
|
||||
this.agentEventService = agentEventService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
|
||||
public void checkLifecycle() {
|
||||
try {
|
||||
// Snapshot states before lifecycle check
|
||||
Map<String, AgentState> statesBefore = new HashMap<>();
|
||||
for (AgentInfo agent : registryService.findAll()) {
|
||||
statesBefore.put(agent.id(), agent.state());
|
||||
}
|
||||
|
||||
registryService.checkLifecycle();
|
||||
registryService.expireOldCommands();
|
||||
|
||||
// Detect transitions and record events
|
||||
for (AgentInfo agent : registryService.findAll()) {
|
||||
AgentState before = statesBefore.get(agent.id());
|
||||
if (before != null && before != agent.state()) {
|
||||
String eventType = mapTransitionEvent(before, agent.state());
|
||||
if (eventType != null) {
|
||||
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
|
||||
agent.name() + " " + before + " -> " + agent.state());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error during agent lifecycle check", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String mapTransitionEvent(AgentState from, AgentState to) {
|
||||
if (from == AgentState.LIVE && to == AgentState.STALE) return "WENT_STALE";
|
||||
if (from == AgentState.STALE && to == AgentState.DEAD) return "WENT_DEAD";
|
||||
if (from == AgentState.STALE && to == AgentState.LIVE) return "RECOVERED";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.cameleer3.server.app.agent;
|
||||
|
||||
import com.cameleer3.server.app.config.AgentRegistryConfig;
|
||||
import com.cameleer3.server.core.agent.AgentCommand;
|
||||
import com.cameleer3.server.core.agent.AgentEventListener;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Manages per-agent SSE connections and delivers commands via Server-Sent Events.
|
||||
* <p>
|
||||
* Implements {@link AgentEventListener} so the core {@link AgentRegistryService}
|
||||
* can notify this component when a command is ready for delivery, without depending
|
||||
* on Spring or SSE classes.
|
||||
*/
|
||||
@Component
|
||||
public class SseConnectionManager implements AgentEventListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SseConnectionManager.class);
|
||||
|
||||
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
private final AgentRegistryService registryService;
|
||||
private final AgentRegistryConfig config;
|
||||
private final SsePayloadSigner ssePayloadSigner;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SseConnectionManager(AgentRegistryService registryService, AgentRegistryConfig config,
|
||||
SsePayloadSigner ssePayloadSigner, ObjectMapper objectMapper) {
|
||||
this.registryService = registryService;
|
||||
this.config = config;
|
||||
this.ssePayloadSigner = ssePayloadSigner;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
registryService.setEventListener(this);
|
||||
log.info("SseConnectionManager registered as AgentEventListener");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSE connection for the given agent.
|
||||
* Replaces any existing connection (completing the old emitter).
|
||||
*
|
||||
* @param agentId the agent identifier
|
||||
* @return the new SseEmitter
|
||||
*/
|
||||
public SseEmitter connect(String agentId) {
|
||||
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
|
||||
|
||||
SseEmitter old = emitters.put(agentId, emitter);
|
||||
if (old != null) {
|
||||
log.debug("Replacing existing SSE connection for agent {}", agentId);
|
||||
old.complete();
|
||||
}
|
||||
|
||||
// Remove from map only if the emitter is still the current one (reference equality)
|
||||
emitter.onCompletion(() -> {
|
||||
emitters.remove(agentId, emitter);
|
||||
log.debug("SSE connection completed for agent {}", agentId);
|
||||
});
|
||||
emitter.onTimeout(() -> {
|
||||
emitters.remove(agentId, emitter);
|
||||
log.debug("SSE connection timed out for agent {}", agentId);
|
||||
});
|
||||
emitter.onError(ex -> {
|
||||
emitters.remove(agentId, emitter);
|
||||
log.debug("SSE connection error for agent {}: {}", agentId, ex.getMessage());
|
||||
});
|
||||
|
||||
log.info("SSE connection established for agent {}", agentId);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific agent's SSE stream.
|
||||
*
|
||||
* @param agentId the target agent
|
||||
* @param eventId the event ID (for Last-Event-ID reconnection)
|
||||
* @param eventType the SSE event name
|
||||
* @param data the event data (serialized as JSON)
|
||||
* @return true if the event was sent successfully, false if the agent is not connected or send failed
|
||||
*/
|
||||
public boolean sendEvent(String agentId, String eventId, String eventType, Object data) {
|
||||
SseEmitter emitter = emitters.get(agentId);
|
||||
if (emitter == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.id(eventId)
|
||||
.name(eventType)
|
||||
.data(data, MediaType.APPLICATION_JSON));
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to send SSE event to agent {}: {}", agentId, e.getMessage());
|
||||
emitters.remove(agentId, emitter);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a ping keepalive comment to all connected agents.
|
||||
*/
|
||||
public void sendPingToAll() {
|
||||
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
|
||||
String agentId = entry.getKey();
|
||||
SseEmitter emitter = entry.getValue();
|
||||
try {
|
||||
emitter.send(SseEmitter.event().comment("ping"));
|
||||
} catch (IOException e) {
|
||||
log.debug("Ping failed for agent {}, removing connection", agentId);
|
||||
emitters.remove(agentId, emitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent has an active SSE connection.
|
||||
*/
|
||||
public boolean isConnected(String agentId) {
|
||||
return emitters.containsKey(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the registry when a command is ready for an agent.
|
||||
* Attempts to deliver via SSE; if successful, marks as DELIVERED.
|
||||
* If the agent is not connected, the command stays PENDING.
|
||||
*/
|
||||
@Override
|
||||
public void onCommandReady(String agentId, AgentCommand command) {
|
||||
String eventType = command.type().name().toLowerCase().replace('_', '-');
|
||||
String signedPayload = ssePayloadSigner.signPayload(command.payload());
|
||||
// Parse to JsonNode so SseEmitter serializes the tree correctly (avoids double-quoting a raw string)
|
||||
Object data;
|
||||
try {
|
||||
data = objectMapper.readTree(signedPayload);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse signed payload as JSON, sending raw string", e);
|
||||
data = signedPayload;
|
||||
}
|
||||
boolean sent = sendEvent(agentId, command.id(), eventType, data);
|
||||
if (sent) {
|
||||
registryService.markDelivered(agentId, command.id());
|
||||
log.debug("Command {} ({}) delivered to agent {} via SSE", command.id(), eventType, agentId);
|
||||
} else {
|
||||
log.debug("Agent {} not connected, command {} stays PENDING", agentId, command.id());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled ping keepalive to all connected agents.
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${agent-registry.ping-interval-ms:15000}")
|
||||
void pingAll() {
|
||||
if (!emitters.isEmpty()) {
|
||||
sendPingToAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.cameleer3.server.app.agent;
|
||||
|
||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Signs SSE command payloads with Ed25519 before delivery.
|
||||
* <p>
|
||||
* The signature is computed over the original JSON payload string (without the
|
||||
* signature field). The resulting Base64-encoded signature is added as a
|
||||
* {@code "signature"} field to the JSON before returning.
|
||||
* <p>
|
||||
* Agents verify the signature by:
|
||||
* <ol>
|
||||
* <li>Extracting and removing the {@code "signature"} field from the received JSON</li>
|
||||
* <li>Serializing the remaining fields back to a JSON string</li>
|
||||
* <li>Verifying the signature against that string using the server's Ed25519 public key</li>
|
||||
* </ol>
|
||||
* In practice, agents should verify against the original payload — the signature is
|
||||
* computed over the exact JSON string as received by the server.
|
||||
*/
|
||||
@Component
|
||||
public class SsePayloadSigner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SsePayloadSigner.class);
|
||||
|
||||
private final Ed25519SigningService ed25519SigningService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SsePayloadSigner(Ed25519SigningService ed25519SigningService, ObjectMapper objectMapper) {
|
||||
this.ed25519SigningService = ed25519SigningService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the given JSON payload and returns a new JSON string with a {@code "signature"} field added.
|
||||
* <p>
|
||||
* The signature is computed over the original payload string (before adding the signature field).
|
||||
*
|
||||
* @param jsonPayload the JSON string to sign
|
||||
* @return the signed JSON string with a "signature" field, or the original payload if null/empty/blank
|
||||
*/
|
||||
public String signPayload(String jsonPayload) {
|
||||
if (jsonPayload == null) {
|
||||
log.warn("Attempted to sign null payload, returning null");
|
||||
return null;
|
||||
}
|
||||
if (jsonPayload.isEmpty() || jsonPayload.isBlank()) {
|
||||
log.warn("Attempted to sign empty/blank payload, returning as-is");
|
||||
return jsonPayload;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Sign the original payload string
|
||||
String signatureBase64 = ed25519SigningService.sign(jsonPayload);
|
||||
|
||||
// 2. Parse payload, add signature field, serialize back
|
||||
JsonNode node = objectMapper.readTree(jsonPayload);
|
||||
if (node instanceof ObjectNode objectNode) {
|
||||
objectNode.put("signature", signatureBase64);
|
||||
return objectMapper.writeValueAsString(objectNode);
|
||||
} else {
|
||||
// Payload is not a JSON object (e.g., array or primitive) -- cannot add field
|
||||
log.warn("Payload is not a JSON object, returning unsigned: {}", jsonPayload);
|
||||
return jsonPayload;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to sign payload, returning unsigned", e);
|
||||
return jsonPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentEventRepository;
|
||||
import com.cameleer3.server.core.agent.AgentEventService;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
|
||||
* <p>
|
||||
* Follows the established pattern: core module plain class, app module bean config.
|
||||
*/
|
||||
@Configuration
|
||||
public class AgentRegistryBeanConfig {
|
||||
|
||||
@Bean
|
||||
public AgentRegistryService agentRegistryService(AgentRegistryConfig config) {
|
||||
return new AgentRegistryService(
|
||||
config.getStaleThresholdMs(),
|
||||
config.getDeadThresholdMs(),
|
||||
config.getCommandExpiryMs()
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AgentEventService agentEventService(AgentEventRepository repository) {
|
||||
return new AgentEventService(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Configuration properties for the agent registry.
|
||||
* Bound from the {@code agent-registry.*} namespace in application.yml.
|
||||
* <p>
|
||||
* Registered via {@code @EnableConfigurationProperties} on the application class.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "agent-registry")
|
||||
public class AgentRegistryConfig {
|
||||
|
||||
private long heartbeatIntervalMs = 30_000;
|
||||
private long staleThresholdMs = 90_000;
|
||||
private long deadThresholdMs = 300_000;
|
||||
private long pingIntervalMs = 15_000;
|
||||
private long commandExpiryMs = 60_000;
|
||||
private long lifecycleCheckIntervalMs = 10_000;
|
||||
|
||||
public long getHeartbeatIntervalMs() {
|
||||
return heartbeatIntervalMs;
|
||||
}
|
||||
|
||||
public void setHeartbeatIntervalMs(long heartbeatIntervalMs) {
|
||||
this.heartbeatIntervalMs = heartbeatIntervalMs;
|
||||
}
|
||||
|
||||
public long getStaleThresholdMs() {
|
||||
return staleThresholdMs;
|
||||
}
|
||||
|
||||
public void setStaleThresholdMs(long staleThresholdMs) {
|
||||
this.staleThresholdMs = staleThresholdMs;
|
||||
}
|
||||
|
||||
public long getDeadThresholdMs() {
|
||||
return deadThresholdMs;
|
||||
}
|
||||
|
||||
public void setDeadThresholdMs(long deadThresholdMs) {
|
||||
this.deadThresholdMs = deadThresholdMs;
|
||||
}
|
||||
|
||||
public long getPingIntervalMs() {
|
||||
return pingIntervalMs;
|
||||
}
|
||||
|
||||
public void setPingIntervalMs(long pingIntervalMs) {
|
||||
this.pingIntervalMs = pingIntervalMs;
|
||||
}
|
||||
|
||||
public long getCommandExpiryMs() {
|
||||
return commandExpiryMs;
|
||||
}
|
||||
|
||||
public void setCommandExpiryMs(long commandExpiryMs) {
|
||||
this.commandExpiryMs = commandExpiryMs;
|
||||
}
|
||||
|
||||
public long getLifecycleCheckIntervalMs() {
|
||||
return lifecycleCheckIntervalMs;
|
||||
}
|
||||
|
||||
public void setLifecycleCheckIntervalMs(long lifecycleCheckIntervalMs) {
|
||||
this.lifecycleCheckIntervalMs = lifecycleCheckIntervalMs;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* ClickHouse configuration.
|
||||
* <p>
|
||||
* Spring Boot auto-configures the DataSource from {@code spring.datasource.*} properties.
|
||||
* This class exposes a JdbcTemplate bean for repository implementations.
|
||||
*/
|
||||
@Configuration
|
||||
public class ClickHouseConfig {
|
||||
|
||||
@Bean
|
||||
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
|
||||
return new JdbcTemplate(dataSource);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,22 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.common.graph.RouteGraph;
|
||||
import com.cameleer3.common.model.RouteExecution;
|
||||
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Creates the write buffer and ingestion service beans.
|
||||
* Creates the write buffer bean for metrics.
|
||||
* <p>
|
||||
* The {@link WriteBuffer} instances are shared between the
|
||||
* {@link IngestionService} (producer side) and the flush scheduler (consumer side).
|
||||
* The {@link WriteBuffer} instance is shared between the
|
||||
* {@link com.cameleer3.server.core.ingestion.IngestionService} (producer side)
|
||||
* and the flush scheduler (consumer side).
|
||||
*/
|
||||
@Configuration
|
||||
public class IngestionBeanConfig {
|
||||
|
||||
@Bean
|
||||
public WriteBuffer<RouteExecution> executionBuffer(IngestionConfig config) {
|
||||
return new WriteBuffer<>(config.getBufferCapacity());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WriteBuffer<RouteGraph> diagramBuffer(IngestionConfig config) {
|
||||
return new WriteBuffer<>(config.getBufferCapacity());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WriteBuffer<MetricsSnapshot> metricsBuffer(IngestionConfig config) {
|
||||
return new WriteBuffer<>(config.getBufferCapacity());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IngestionService ingestionService(WriteBuffer<RouteExecution> executionBuffer,
|
||||
WriteBuffer<RouteGraph> diagramBuffer,
|
||||
WriteBuffer<MetricsSnapshot> metricsBuffer) {
|
||||
return new IngestionService(executionBuffer, diagramBuffer, metricsBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
public class IngestionConfig {
|
||||
|
||||
private int bufferCapacity = 50_000;
|
||||
private int batchSize = 5_000;
|
||||
private int batchSize = 100;
|
||||
private long flushIntervalMs = 1_000;
|
||||
|
||||
public int getBufferCapacity() {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.Paths;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.media.ArraySchema;
|
||||
import io.swagger.v3.oas.models.media.Schema;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Configuration
|
||||
@SecurityScheme(name = "bearer", type = SecuritySchemeType.HTTP,
|
||||
scheme = "bearer", bearerFormat = "JWT")
|
||||
public class OpenApiConfig {
|
||||
|
||||
/**
|
||||
* Core domain models that always have all fields populated.
|
||||
* Mark all their properties as required so the generated TypeScript
|
||||
* types are non-optional.
|
||||
*/
|
||||
private static final Set<String> ALL_FIELDS_REQUIRED = Set.of(
|
||||
"ExecutionSummary", "ExecutionDetail", "ExecutionStats",
|
||||
"StatsTimeseries", "TimeseriesBucket",
|
||||
"SearchResultExecutionSummary", "UserInfo",
|
||||
"ProcessorNode",
|
||||
"AppCatalogEntry", "RouteSummary", "AgentSummary",
|
||||
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
|
||||
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
|
||||
);
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info().title("Cameleer3 Server API").version("1.0"))
|
||||
.addSecurityItem(new SecurityRequirement().addList("bearer"))
|
||||
.servers(List.of(new Server().url("/api/v1").description("Relative")));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OpenApiCustomizer pathPrefixStripper() {
|
||||
return openApi -> {
|
||||
var original = openApi.getPaths();
|
||||
if (original == null) return;
|
||||
String prefix = "/api/v1";
|
||||
var stripped = new Paths();
|
||||
for (var entry : original.entrySet()) {
|
||||
String path = entry.getKey();
|
||||
stripped.addPathItem(
|
||||
path.startsWith(prefix) ? path.substring(prefix.length()) : path,
|
||||
entry.getValue());
|
||||
}
|
||||
openApi.setPaths(stripped);
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
@SuppressWarnings("unchecked")
|
||||
public OpenApiCustomizer schemaCustomizer() {
|
||||
return openApi -> {
|
||||
var schemas = openApi.getComponents().getSchemas();
|
||||
if (schemas == null) return;
|
||||
|
||||
// Add children to ProcessorNode if missing (recursive self-reference)
|
||||
if (schemas.containsKey("ProcessorNode")) {
|
||||
Schema<Object> processorNode = schemas.get("ProcessorNode");
|
||||
if (processorNode.getProperties() != null
|
||||
&& !processorNode.getProperties().containsKey("children")) {
|
||||
Schema<?> selfRef = new Schema<>().$ref("#/components/schemas/ProcessorNode");
|
||||
ArraySchema childrenArray = new ArraySchema().items(selfRef);
|
||||
processorNode.addProperty("children", childrenArray);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all fields as required for core domain models
|
||||
for (String schemaName : ALL_FIELDS_REQUIRED) {
|
||||
if (schemas.containsKey(schemaName)) {
|
||||
Schema<Object> schema = schemas.get(schemaName);
|
||||
if (schema.getProperties() != null) {
|
||||
schema.setRequired(new ArrayList<>(schema.getProperties().keySet()));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.app.search.ClickHouseSearchEngine;
|
||||
import com.cameleer3.server.core.detail.DetailService;
|
||||
import com.cameleer3.server.core.search.SearchEngine;
|
||||
import com.cameleer3.server.core.search.SearchService;
|
||||
import com.cameleer3.server.core.storage.ExecutionRepository;
|
||||
import com.cameleer3.server.core.storage.SearchIndex;
|
||||
import com.cameleer3.server.core.storage.StatsStore;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
/**
|
||||
* Creates beans for the search and detail layers.
|
||||
* Creates beans for the search layer.
|
||||
*/
|
||||
@Configuration
|
||||
public class SearchBeanConfig {
|
||||
|
||||
@Bean
|
||||
public SearchEngine searchEngine(JdbcTemplate jdbcTemplate) {
|
||||
return new ClickHouseSearchEngine(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SearchService searchService(SearchEngine searchEngine) {
|
||||
return new SearchService(searchEngine);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DetailService detailService(ExecutionRepository executionRepository) {
|
||||
return new DetailService(executionRepository);
|
||||
public SearchService searchService(SearchIndex searchIndex, StatsStore statsStore) {
|
||||
return new SearchService(searchIndex, statsStore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.core.admin.AuditRepository;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import com.cameleer3.server.core.detail.DetailService;
|
||||
import com.cameleer3.server.core.indexing.SearchIndexer;
|
||||
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||
import com.cameleer3.server.core.ingestion.WriteBuffer;
|
||||
import com.cameleer3.server.core.storage.*;
|
||||
import com.cameleer3.server.core.storage.model.MetricsSnapshot;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class StorageBeanConfig {
|
||||
|
||||
@Bean
|
||||
public DetailService detailService(ExecutionStore executionStore) {
|
||||
return new DetailService(executionStore);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
public SearchIndexer searchIndexer(ExecutionStore executionStore, SearchIndex searchIndex,
|
||||
@Value("${opensearch.debounce-ms:2000}") long debounceMs,
|
||||
@Value("${opensearch.queue-size:10000}") int queueSize) {
|
||||
return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuditService auditService(AuditRepository auditRepository) {
|
||||
return new AuditService(auditRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IngestionService ingestionService(ExecutionStore executionStore,
|
||||
DiagramStore diagramStore,
|
||||
WriteBuffer<MetricsSnapshot> metricsBuffer,
|
||||
SearchIndexer searchIndexer,
|
||||
@Value("${cameleer.body-size-limit:16384}") int bodySizeLimit) {
|
||||
return new IngestionService(executionStore, diagramStore, metricsBuffer,
|
||||
searchIndexer::onExecutionUpdated, bodySizeLimit);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.app.interceptor.AuditInterceptor;
|
||||
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
@@ -7,17 +8,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 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
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
||||
private final AuditInterceptor auditInterceptor;
|
||||
|
||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor) {
|
||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
||||
AuditInterceptor auditInterceptor) {
|
||||
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
||||
this.auditInterceptor = auditInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -28,7 +29,19 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
"/api/v1/health",
|
||||
"/api/v1/api-docs/**",
|
||||
"/api/v1/swagger-ui/**",
|
||||
"/api/v1/swagger-ui.html"
|
||||
"/api/v1/swagger-ui.html",
|
||||
"/api/v1/agents/*/events",
|
||||
"/api/v1/agents/register",
|
||||
"/api/v1/agents/*/refresh"
|
||||
);
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
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.CommandRequest;
|
||||
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.AgentEventService;
|
||||
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.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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 org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
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.
|
||||
* <p>
|
||||
* Supports three targeting levels:
|
||||
* <ul>
|
||||
* <li>Single agent: POST /api/v1/agents/{id}/commands</li>
|
||||
* <li>Group: POST /api/v1/agents/groups/{group}/commands</li>
|
||||
* <li>Broadcast: POST /api/v1/agents/commands</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@Tag(name = "Agent Commands", description = "Command push endpoints for agent communication")
|
||||
public class AgentCommandController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentCommandController.class);
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final SseConnectionManager connectionManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentEventService agentEventService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public AgentCommandController(AgentRegistryService registryService,
|
||||
SseConnectionManager connectionManager,
|
||||
ObjectMapper objectMapper,
|
||||
AgentEventService agentEventService,
|
||||
AuditService auditService) {
|
||||
this.registryService = registryService;
|
||||
this.connectionManager = connectionManager;
|
||||
this.objectMapper = objectMapper;
|
||||
this.agentEventService = agentEventService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/commands")
|
||||
@Operation(summary = "Send command to a specific agent",
|
||||
description = "Sends a command to the specified agent via SSE")
|
||||
@ApiResponse(responseCode = "202", description = "Command accepted")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
|
||||
@RequestBody CommandRequest request,
|
||||
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||
AgentInfo agent = registryService.findById(id);
|
||||
if (agent == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||
}
|
||||
|
||||
CommandType type = mapCommandType(request.type());
|
||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||
AgentCommand command = registryService.addCommand(id, type, payloadJson);
|
||||
|
||||
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)
|
||||
.body(new CommandSingleResponse(command.id(), status));
|
||||
}
|
||||
|
||||
@PostMapping("/groups/{group}/commands")
|
||||
@Operation(summary = "Send command to all agents in a group",
|
||||
description = "Sends a command to all LIVE agents in the specified group")
|
||||
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
|
||||
@RequestBody CommandRequest request,
|
||||
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||
CommandType type = mapCommandType(request.type());
|
||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||
|
||||
List<AgentInfo> agents = registryService.findAll().stream()
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
.filter(a -> group.equals(a.application()))
|
||||
.toList();
|
||||
|
||||
List<String> commandIds = new ArrayList<>();
|
||||
for (AgentInfo agent : agents) {
|
||||
AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson);
|
||||
commandIds.add(command.id());
|
||||
}
|
||||
|
||||
auditService.log("broadcast_group_command", AuditCategory.AGENT, group,
|
||||
java.util.Map.of("type", request.type(), "agentCount", agents.size()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||
.body(new CommandBroadcastResponse(commandIds, agents.size()));
|
||||
}
|
||||
|
||||
@PostMapping("/commands")
|
||||
@Operation(summary = "Broadcast command to all live agents",
|
||||
description = "Sends a command to all agents currently in LIVE state")
|
||||
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestBody CommandRequest request,
|
||||
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||
CommandType type = mapCommandType(request.type());
|
||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||
|
||||
List<AgentInfo> liveAgents = registryService.findByState(AgentState.LIVE);
|
||||
|
||||
List<String> commandIds = new ArrayList<>();
|
||||
for (AgentInfo agent : liveAgents) {
|
||||
AgentCommand command = registryService.addCommand(agent.id(), type, payloadJson);
|
||||
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)
|
||||
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/commands/{commandId}/ack")
|
||||
@Operation(summary = "Acknowledge command receipt",
|
||||
description = "Agent acknowledges that it has received and processed a command, with result status and message")
|
||||
@ApiResponse(responseCode = "200", description = "Command acknowledged")
|
||||
@ApiResponse(responseCode = "404", description = "Command not found")
|
||||
public ResponseEntity<Void> acknowledgeCommand(@PathVariable String id,
|
||||
@PathVariable String commandId,
|
||||
@RequestBody(required = false) CommandAckRequest body) {
|
||||
boolean acknowledged = registryService.acknowledgeCommand(id, commandId);
|
||||
if (!acknowledged) {
|
||||
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.application() : "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();
|
||||
}
|
||||
|
||||
@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) {
|
||||
return switch (typeStr) {
|
||||
case "config-update" -> CommandType.CONFIG_UPDATE;
|
||||
case "deep-trace" -> CommandType.DEEP_TRACE;
|
||||
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,
|
||||
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression, route-control");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.AgentEventResponse;
|
||||
import com.cameleer3.server.core.agent.AgentEventService;
|
||||
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.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.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents/events-log")
|
||||
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
|
||||
public class AgentEventsController {
|
||||
|
||||
private final AgentEventService agentEventService;
|
||||
|
||||
public AgentEventsController(AgentEventService agentEventService) {
|
||||
this.agentEventService = agentEventService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Query agent events",
|
||||
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
|
||||
@ApiResponse(responseCode = "200", description = "Events returned")
|
||||
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||
@RequestParam(required = false) String appId,
|
||||
@RequestParam(required = false) String agentId,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||
|
||||
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
|
||||
.stream()
|
||||
.map(AgentEventResponse::from)
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.AgentMetricsResponse;
|
||||
import com.cameleer3.server.app.dto.MetricBucket;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents/{agentId}/metrics")
|
||||
public class AgentMetricsController {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AgentMetricsController(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public AgentMetricsResponse getMetrics(
|
||||
@PathVariable String agentId,
|
||||
@RequestParam String names,
|
||||
@RequestParam(required = false) Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "60") int buckets) {
|
||||
|
||||
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||
if (to == null) to = Instant.now();
|
||||
|
||||
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<>();
|
||||
for (String name : metricNames) {
|
||||
result.put(name.trim(), new ArrayList<>());
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket,
|
||||
metric_name,
|
||||
AVG(metric_value) AS avg_value
|
||||
FROM agent_metrics
|
||||
WHERE agent_id = ?
|
||||
AND collected_at >= ? AND collected_at < ?
|
||||
AND metric_name = ANY(?)
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.config.AgentRegistryConfig;
|
||||
import com.cameleer3.server.app.dto.AgentInstanceResponse;
|
||||
import com.cameleer3.server.app.dto.AgentRefreshRequest;
|
||||
import com.cameleer3.server.app.dto.AgentRefreshResponse;
|
||||
import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
||||
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.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import com.cameleer3.server.core.security.Ed25519SigningService;
|
||||
import com.cameleer3.server.core.security.InvalidTokenException;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
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.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent registration, heartbeat, listing, and token refresh endpoints.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@Tag(name = "Agent Management", description = "Agent registration and lifecycle endpoints")
|
||||
public class AgentRegistrationController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentRegistrationController.class);
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final AgentRegistryConfig config;
|
||||
private final BootstrapTokenValidator bootstrapTokenValidator;
|
||||
private final JwtService jwtService;
|
||||
private final Ed25519SigningService ed25519SigningService;
|
||||
private final AgentEventService agentEventService;
|
||||
private final AuditService auditService;
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AgentRegistrationController(AgentRegistryService registryService,
|
||||
AgentRegistryConfig config,
|
||||
BootstrapTokenValidator bootstrapTokenValidator,
|
||||
JwtService jwtService,
|
||||
Ed25519SigningService ed25519SigningService,
|
||||
AgentEventService agentEventService,
|
||||
AuditService auditService,
|
||||
JdbcTemplate jdbc) {
|
||||
this.registryService = registryService;
|
||||
this.config = config;
|
||||
this.bootstrapTokenValidator = bootstrapTokenValidator;
|
||||
this.jwtService = jwtService;
|
||||
this.ed25519SigningService = ed25519SigningService;
|
||||
this.agentEventService = agentEventService;
|
||||
this.auditService = auditService;
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Register an agent",
|
||||
description = "Registers a new agent or re-registers an existing one. "
|
||||
+ "Requires bootstrap token in Authorization header.")
|
||||
@ApiResponse(responseCode = "200", description = "Agent registered successfully")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid registration payload",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@ApiResponse(responseCode = "401", description = "Missing or invalid bootstrap token")
|
||||
public ResponseEntity<AgentRegistrationResponse> register(
|
||||
@RequestBody AgentRegistrationRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
// Validate bootstrap token
|
||||
String authHeader = httpRequest.getHeader("Authorization");
|
||||
String bootstrapToken = null;
|
||||
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
|
||||
bootstrapToken = authHeader.substring(BEARER_PREFIX.length());
|
||||
}
|
||||
if (bootstrapToken == null || !bootstrapTokenValidator.validate(bootstrapToken)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
if (request.agentId() == null || request.agentId().isBlank()
|
||||
|| request.name() == null || request.name().isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
String application = request.application() != null ? request.application() : "default";
|
||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||
|
||||
AgentInfo agent = registryService.register(
|
||||
request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
|
||||
|
||||
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
||||
"Agent registered: " + request.name());
|
||||
|
||||
auditService.log(request.agentId(), "agent_register", AuditCategory.AGENT, request.agentId(),
|
||||
Map.of("application", application, "name", request.name()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
// Issue JWT tokens with AGENT role
|
||||
List<String> roles = List.of("AGENT");
|
||||
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
|
||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
|
||||
|
||||
return ResponseEntity.ok(new AgentRegistrationResponse(
|
||||
agent.id(),
|
||||
"/api/v1/agents/" + agent.id() + "/events",
|
||||
config.getHeartbeatIntervalMs(),
|
||||
ed25519SigningService.getPublicKeyBase64(),
|
||||
accessToken,
|
||||
refreshToken
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/refresh")
|
||||
@Operation(summary = "Refresh access token",
|
||||
description = "Issues a new access JWT from a valid refresh token")
|
||||
@ApiResponse(responseCode = "200", description = "New access token issued")
|
||||
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not found")
|
||||
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
|
||||
@RequestBody AgentRefreshRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Validate refresh token
|
||||
JwtService.JwtValidationResult result;
|
||||
try {
|
||||
result = jwtService.validateRefreshToken(request.refreshToken());
|
||||
} catch (InvalidTokenException e) {
|
||||
log.debug("Refresh token validation failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
String agentId = result.subject();
|
||||
|
||||
// Verify agent ID in path matches token
|
||||
if (!id.equals(agentId)) {
|
||||
log.debug("Refresh token agent ID mismatch: path={}, token={}", id, agentId);
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
// Verify agent exists
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
if (agent == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Preserve roles from refresh token
|
||||
List<String> roles = result.roles().isEmpty()
|
||||
? List.of("AGENT") : result.roles();
|
||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
||||
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
|
||||
|
||||
auditService.log(agentId, "agent_token_refresh", AuditCategory.AUTH, agentId,
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/heartbeat")
|
||||
@Operation(summary = "Agent heartbeat ping",
|
||||
description = "Updates the agent's last heartbeat timestamp")
|
||||
@ApiResponse(responseCode = "200", description = "Heartbeat accepted")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||
public ResponseEntity<Void> heartbeat(@PathVariable String id) {
|
||||
boolean found = registryService.heartbeat(id);
|
||||
if (!found) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all agents",
|
||||
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
|
||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String application) {
|
||||
List<AgentInfo> agents;
|
||||
|
||||
if (status != null) {
|
||||
try {
|
||||
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
|
||||
agents = registryService.findByState(stateFilter);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
} else {
|
||||
agents = registryService.findAll();
|
||||
}
|
||||
|
||||
// Apply application filter if specified
|
||||
if (application != null && !application.isBlank()) {
|
||||
agents = agents.stream()
|
||||
.filter(a -> application.equals(a.application()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Enrich with runtime metrics from continuous aggregates
|
||||
Map<String, double[]> agentMetrics = queryAgentMetrics();
|
||||
final List<AgentInfo> finalAgents = agents;
|
||||
|
||||
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||
.map(a -> {
|
||||
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||
double[] m = agentMetrics.get(a.application());
|
||||
if (m != null) {
|
||||
long appAgentCount = finalAgents.stream()
|
||||
.filter(ag -> ag.application().equals(a.application())).count();
|
||||
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||
double errorRate = m[1];
|
||||
int activeRoutes = (int) m[2];
|
||||
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||
}
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private Map<String, double[]> queryAgentMetrics() {
|
||||
Map<String, double[]> result = new HashMap<>();
|
||||
Instant now = Instant.now();
|
||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT application_name, " +
|
||||
"SUM(total_count) AS total, " +
|
||||
"SUM(failed_count) AS failed, " +
|
||||
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY application_name",
|
||||
rs -> {
|
||||
long total = rs.getLong("total");
|
||||
long failed = rs.getLong("failed");
|
||||
double tps = total / 60.0;
|
||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||
int activeRoutes = rs.getInt("active_routes");
|
||||
result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
|
||||
},
|
||||
Timestamp.from(from1m), Timestamp.from(now));
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not query agent metrics: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.agent.SseConnectionManager;
|
||||
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.Parameter;
|
||||
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.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
/**
|
||||
* SSE endpoint for real-time event streaming to agents.
|
||||
* <p>
|
||||
* Agents connect to {@code GET /api/v1/agents/{id}/events} to receive
|
||||
* config-update, deep-trace, and replay commands as Server-Sent Events.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@Tag(name = "Agent SSE", description = "Server-Sent Events endpoint for agent communication")
|
||||
public class AgentSseController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentSseController.class);
|
||||
|
||||
private final SseConnectionManager connectionManager;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public AgentSseController(SseConnectionManager connectionManager,
|
||||
AgentRegistryService registryService) {
|
||||
this.connectionManager = connectionManager;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@Operation(summary = "Open SSE event stream",
|
||||
description = "Opens a Server-Sent Events stream for the specified agent. "
|
||||
+ "Commands (config-update, deep-trace, replay) are pushed as events. "
|
||||
+ "Ping keepalive comments sent every 15 seconds.")
|
||||
@ApiResponse(responseCode = "200", description = "SSE stream opened")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||
public SseEmitter events(
|
||||
@PathVariable String id,
|
||||
@Parameter(description = "Last received event ID (no replay, acknowledged only)")
|
||||
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
|
||||
|
||||
AgentInfo agent = registryService.findById(id);
|
||||
if (agent == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||
}
|
||||
|
||||
if (lastEventId != null) {
|
||||
log.debug("Agent {} reconnecting with Last-Event-ID: {} (no replay)", id, lastEventId);
|
||||
}
|
||||
|
||||
return connectionManager.connect(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Global exception handler that ensures error responses use the typed {@link ErrorResponse} schema.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
String reason = ex.getReason();
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
.body(new ErrorResponse(reason != null ? reason : "Unknown error"));
|
||||
}
|
||||
}
|
||||
@@ -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.findByAppId(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,208 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.common.model.ApplicationConfig;
|
||||
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.AgentCommand;
|
||||
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.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<ApplicationConfig> updateConfig(@PathVariable String application,
|
||||
@RequestBody ApplicationConfig config,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
String updatedBy = auth != null ? auth.getName() : "system";
|
||||
|
||||
config.setApplication(application);
|
||||
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
|
||||
|
||||
int pushed = pushConfigToAgents(application, saved);
|
||||
log.info("Config v{} saved for '{}', pushed to {} agent(s)", saved.getVersion(), application, pushed);
|
||||
|
||||
auditService.log("update_app_config", AuditCategory.CONFIG, application,
|
||||
Map.of("version", saved.getVersion(), "agentsPushed", pushed),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@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,
|
||||
@RequestBody TestExpressionRequest request) {
|
||||
// Find a LIVE agent for this application
|
||||
AgentInfo agent = registryService.findAll().stream()
|
||||
.filter(a -> application.equals(a.application()))
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
.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.id(), 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.id(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new TestExpressionResponse(null, "Internal error: " + e.getCause().getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
private int pushConfigToAgents(String application, ApplicationConfig config) {
|
||||
String payloadJson;
|
||||
try {
|
||||
payloadJson = objectMapper.writeValueAsString(config);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Failed to serialize config for push", e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<AgentInfo> agents = registryService.findAll().stream()
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
.filter(a -> application.equals(a.application()))
|
||||
.toList();
|
||||
|
||||
for (AgentInfo agent : agents) {
|
||||
registryService.addCommand(agent.id(), CommandType.CONFIG_UPDATE, payloadJson);
|
||||
}
|
||||
return agents.size();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.AuditLogPageResponse;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditRepository;
|
||||
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
|
||||
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.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/audit")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Audit Log", description = "Audit log viewer (ADMIN only)")
|
||||
public class AuditLogController {
|
||||
|
||||
private final AuditRepository auditRepository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public AuditLogController(AuditRepository auditRepository, AuditService auditService) {
|
||||
this.auditRepository = auditRepository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Search audit log entries with pagination")
|
||||
public ResponseEntity<AuditLogPageResponse> getAuditLog(
|
||||
HttpServletRequest httpRequest,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
|
||||
@RequestParam(defaultValue = "timestamp") String sort,
|
||||
@RequestParam(defaultValue = "desc") String order,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
|
||||
size = Math.min(size, 100);
|
||||
|
||||
Instant fromInstant = from != null ? from : Instant.now().minus(java.time.Duration.ofDays(7));
|
||||
Instant toInstant = to != null ? to : Instant.now();
|
||||
|
||||
AuditCategory cat = null;
|
||||
if (category != null && !category.isEmpty()) {
|
||||
try {
|
||||
cat = AuditCategory.valueOf(category.toUpperCase());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// invalid category is treated as no filter
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
AuditPage result = auditRepository.find(query);
|
||||
|
||||
int totalPages = Math.max(1, (int) Math.ceil((double) result.totalCount() / size));
|
||||
return ResponseEntity.ok(new AuditLogPageResponse(
|
||||
result.items(), result.totalCount(), page, size, totalPages));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.ActiveQueryResponse;
|
||||
import com.cameleer3.server.app.dto.ConnectionPoolResponse;
|
||||
import com.cameleer3.server.app.dto.DatabaseStatusResponse;
|
||||
import com.cameleer3.server.app.dto.TableSizeResponse;
|
||||
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.ingestion.IngestionService;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import com.zaxxer.hikari.HikariPoolMXBean;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/database")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Database Admin", description = "Database monitoring and management (ADMIN only)")
|
||||
public class DatabaseAdminController {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final DataSource dataSource;
|
||||
private final AuditService auditService;
|
||||
private final IngestionService ingestionService;
|
||||
|
||||
public DatabaseAdminController(JdbcTemplate jdbc, DataSource dataSource,
|
||||
AuditService auditService, IngestionService ingestionService) {
|
||||
this.jdbc = jdbc;
|
||||
this.dataSource = dataSource;
|
||||
this.auditService = auditService;
|
||||
this.ingestionService = ingestionService;
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
@Operation(summary = "Get database connection status and version")
|
||||
public ResponseEntity<DatabaseStatusResponse> getStatus() {
|
||||
try {
|
||||
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 host = extractHost(dataSource);
|
||||
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new DatabaseStatusResponse(false, null, null, null, false));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/pool")
|
||||
@Operation(summary = "Get HikariCP connection pool stats")
|
||||
public ResponseEntity<ConnectionPoolResponse> getPool() {
|
||||
HikariDataSource hds = (HikariDataSource) dataSource;
|
||||
HikariPoolMXBean pool = hds.getHikariPoolMXBean();
|
||||
return ResponseEntity.ok(new ConnectionPoolResponse(
|
||||
pool.getActiveConnections(), pool.getIdleConnections(),
|
||||
pool.getThreadsAwaitingConnection(), hds.getConnectionTimeout(),
|
||||
hds.getMaximumPoolSize()));
|
||||
}
|
||||
|
||||
@GetMapping("/tables")
|
||||
@Operation(summary = "Get table sizes and row counts")
|
||||
public ResponseEntity<List<TableSizeResponse>> getTables() {
|
||||
var tables = jdbc.query("""
|
||||
SELECT relname AS table_name,
|
||||
n_live_tup AS row_count,
|
||||
pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
|
||||
pg_total_relation_size(relid) AS data_size_bytes,
|
||||
pg_size_pretty(pg_indexes_size(relid)) AS index_size,
|
||||
pg_indexes_size(relid) AS index_size_bytes
|
||||
FROM pg_stat_user_tables
|
||||
WHERE schemaname = current_schema()
|
||||
ORDER BY pg_total_relation_size(relid) DESC
|
||||
""", (rs, row) -> new TableSizeResponse(
|
||||
rs.getString("table_name"), rs.getLong("row_count"),
|
||||
rs.getString("data_size"), rs.getString("index_size"),
|
||||
rs.getLong("data_size_bytes"), rs.getLong("index_size_bytes")));
|
||||
return ResponseEntity.ok(tables);
|
||||
}
|
||||
|
||||
@GetMapping("/queries")
|
||||
@Operation(summary = "Get active queries")
|
||||
public ResponseEntity<List<ActiveQueryResponse>> getQueries() {
|
||||
var queries = jdbc.query("""
|
||||
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
|
||||
state, query
|
||||
FROM pg_stat_activity
|
||||
WHERE state != 'idle' AND pid != pg_backend_pid() AND datname = current_database()
|
||||
ORDER BY query_start ASC
|
||||
""", (rs, row) -> new ActiveQueryResponse(
|
||||
rs.getInt("pid"), rs.getDouble("duration_seconds"),
|
||||
rs.getString("state"), rs.getString("query")));
|
||||
return ResponseEntity.ok(queries);
|
||||
}
|
||||
|
||||
@PostMapping("/queries/{pid}/kill")
|
||||
@Operation(summary = "Terminate a query by PID")
|
||||
public ResponseEntity<Void> killQuery(@PathVariable int pid, HttpServletRequest request) {
|
||||
var exists = jdbc.queryForObject(
|
||||
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE pid = ? AND pid != pg_backend_pid())",
|
||||
Boolean.class, pid);
|
||||
if (!Boolean.TRUE.equals(exists)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active query with PID " + pid);
|
||||
}
|
||||
jdbc.queryForObject("SELECT pg_terminate_backend(?)", Boolean.class, pid);
|
||||
auditService.log("kill_query", AuditCategory.INFRA, "PID " + pid, null, AuditResult.SUCCESS, request);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/metrics-pipeline")
|
||||
@Operation(summary = "Get metrics ingestion pipeline diagnostics")
|
||||
public ResponseEntity<Map<String, Object>> getMetricsPipeline() {
|
||||
int bufferDepth = ingestionService.getMetricsBufferDepth();
|
||||
|
||||
Long totalRows = jdbc.queryForObject(
|
||||
"SELECT count(*) FROM agent_metrics", Long.class);
|
||||
List<String> agentIds = jdbc.queryForList(
|
||||
"SELECT DISTINCT agent_id FROM agent_metrics ORDER BY agent_id", String.class);
|
||||
Instant latestCollected = jdbc.queryForObject(
|
||||
"SELECT max(collected_at) FROM agent_metrics", Instant.class);
|
||||
List<String> metricNames = jdbc.queryForList(
|
||||
"SELECT DISTINCT metric_name FROM agent_metrics ORDER BY metric_name", String.class);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"bufferDepth", bufferDepth,
|
||||
"totalRows", totalRows != null ? totalRows : 0,
|
||||
"distinctAgents", agentIds,
|
||||
"distinctMetrics", metricNames,
|
||||
"latestCollectedAt", latestCollected != null ? latestCollected.toString() : "none"
|
||||
));
|
||||
}
|
||||
|
||||
private String extractHost(DataSource ds) {
|
||||
try {
|
||||
if (ds instanceof HikariDataSource hds) {
|
||||
return hds.getJdbcUrl();
|
||||
}
|
||||
return "unknown";
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.storage.ClickHouseExecutionRepository;
|
||||
import com.cameleer3.server.core.detail.DetailService;
|
||||
import com.cameleer3.server.core.detail.ExecutionDetail;
|
||||
import com.cameleer3.server.core.storage.ExecutionStore;
|
||||
import com.cameleer3.server.core.storage.ExecutionStore.ProcessorRecord;
|
||||
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.web.bind.annotation.GetMapping;
|
||||
@@ -11,14 +13,16 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Endpoints for retrieving execution details and processor snapshots.
|
||||
* <p>
|
||||
* The detail endpoint returns a nested processor tree reconstructed from
|
||||
* flat parallel arrays stored in ClickHouse. The snapshot endpoint returns
|
||||
* per-processor exchange data (bodies and headers).
|
||||
* individual processor records stored in PostgreSQL. The snapshot endpoint
|
||||
* returns per-processor exchange data (bodies and headers).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/executions")
|
||||
@@ -26,16 +30,18 @@ import java.util.Map;
|
||||
public class DetailController {
|
||||
|
||||
private final DetailService detailService;
|
||||
private final ClickHouseExecutionRepository executionRepository;
|
||||
private final ExecutionStore executionStore;
|
||||
|
||||
public DetailController(DetailService detailService,
|
||||
ClickHouseExecutionRepository executionRepository) {
|
||||
ExecutionStore executionStore) {
|
||||
this.detailService = detailService;
|
||||
this.executionRepository = executionRepository;
|
||||
this.executionStore = executionStore;
|
||||
}
|
||||
|
||||
@GetMapping("/{executionId}")
|
||||
@Operation(summary = "Get execution detail with nested processor tree")
|
||||
@ApiResponse(responseCode = "200", description = "Execution detail found")
|
||||
@ApiResponse(responseCode = "404", description = "Execution not found")
|
||||
public ResponseEntity<ExecutionDetail> getDetail(@PathVariable String executionId) {
|
||||
return detailService.getDetail(executionId)
|
||||
.map(ResponseEntity::ok)
|
||||
@@ -43,12 +49,36 @@ public class DetailController {
|
||||
}
|
||||
|
||||
@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 = "404", description = "Snapshot not found")
|
||||
public ResponseEntity<Map<String, String>> getProcessorSnapshot(
|
||||
@PathVariable String executionId,
|
||||
@PathVariable int index) {
|
||||
return executionRepository.findProcessorSnapshot(executionId, index)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
List<ProcessorRecord> processors = executionStore.findProcessors(executionId);
|
||||
if (index < 0 || index >= processors.size()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ProcessorRecord p = processors.get(index);
|
||||
Map<String, String> snapshot = new LinkedHashMap<>();
|
||||
if (p.inputBody() != null) snapshot.put("inputBody", p.inputBody());
|
||||
if (p.outputBody() != null) snapshot.put("outputBody", p.outputBody());
|
||||
if (p.inputHeaders() != null) snapshot.put("inputHeaders", p.inputHeaders());
|
||||
if (p.outputHeaders() != null) snapshot.put("outputHeaders", p.outputHeaders());
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
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.TaggedDiagram;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -10,8 +13,9 @@ 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.HttpStatus;
|
||||
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;
|
||||
@@ -22,8 +26,8 @@ import java.util.List;
|
||||
/**
|
||||
* Ingestion endpoint for route diagrams.
|
||||
* <p>
|
||||
* Accepts both single {@link RouteGraph} and arrays. Data is buffered
|
||||
* and flushed to ClickHouse by the flush scheduler.
|
||||
* Accepts both single {@link RouteGraph} and arrays. Data is written
|
||||
* synchronously to PostgreSQL via {@link IngestionService}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/data")
|
||||
@@ -33,10 +37,14 @@ public class DiagramController {
|
||||
private static final Logger log = LoggerFactory.getLogger(DiagramController.class);
|
||||
|
||||
private final IngestionService ingestionService;
|
||||
private final AgentRegistryService registryService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DiagramController(IngestionService ingestionService, ObjectMapper objectMapper) {
|
||||
public DiagramController(IngestionService ingestionService,
|
||||
AgentRegistryService registryService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.ingestionService = ingestionService;
|
||||
this.registryService = registryService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -44,27 +52,28 @@ public class DiagramController {
|
||||
@Operation(summary = "Ingest route diagram data",
|
||||
description = "Accepts a single RouteGraph or an array of RouteGraphs")
|
||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||
@ApiResponse(responseCode = "503", description = "Buffer full, retry later")
|
||||
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
|
||||
String agentId = extractAgentId();
|
||||
String applicationName = resolveApplicationName(agentId);
|
||||
List<RouteGraph> graphs = parsePayload(body);
|
||||
boolean accepted;
|
||||
|
||||
if (graphs.size() == 1) {
|
||||
accepted = ingestionService.acceptDiagram(graphs.get(0));
|
||||
} else {
|
||||
accepted = ingestionService.acceptDiagrams(graphs);
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
log.warn("Diagram buffer full, returning 503");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.header("Retry-After", "5")
|
||||
.build();
|
||||
for (RouteGraph graph : graphs) {
|
||||
ingestionService.ingestDiagram(new TaggedDiagram(agentId, applicationName, graph));
|
||||
}
|
||||
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
private String extractAgentId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth != null ? auth.getName() : "";
|
||||
}
|
||||
|
||||
private String resolveApplicationName(String agentId) {
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
return agent != null ? agent.application() : "";
|
||||
}
|
||||
|
||||
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
|
||||
String trimmed = body.strip();
|
||||
if (trimmed.startsWith("[")) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
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.diagram.DiagramLayout;
|
||||
import com.cameleer3.server.core.diagram.DiagramRenderer;
|
||||
import com.cameleer3.server.core.storage.DiagramRepository;
|
||||
import com.cameleer3.server.core.storage.DiagramStore;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -13,8 +17,10 @@ import org.springframework.http.ResponseEntity;
|
||||
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 java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -33,25 +39,33 @@ public class DiagramRenderController {
|
||||
|
||||
private static final MediaType SVG_MEDIA_TYPE = MediaType.valueOf("image/svg+xml");
|
||||
|
||||
private final DiagramRepository diagramRepository;
|
||||
private final DiagramStore diagramStore;
|
||||
private final DiagramRenderer diagramRenderer;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public DiagramRenderController(DiagramRepository diagramRepository,
|
||||
DiagramRenderer diagramRenderer) {
|
||||
this.diagramRepository = diagramRepository;
|
||||
public DiagramRenderController(DiagramStore diagramStore,
|
||||
DiagramRenderer diagramRenderer,
|
||||
AgentRegistryService registryService) {
|
||||
this.diagramStore = diagramStore;
|
||||
this.diagramRenderer = diagramRenderer;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
@GetMapping("/{contentHash}/render")
|
||||
@Operation(summary = "Render a route diagram",
|
||||
description = "Returns SVG (default) or JSON layout based on Accept header")
|
||||
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully")
|
||||
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully",
|
||||
content = {
|
||||
@Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")),
|
||||
@Content(mediaType = "application/json", schema = @Schema(implementation = DiagramLayout.class))
|
||||
})
|
||||
@ApiResponse(responseCode = "404", description = "Diagram not found")
|
||||
public ResponseEntity<?> renderDiagram(
|
||||
@PathVariable String contentHash,
|
||||
@RequestParam(defaultValue = "LR") String direction,
|
||||
HttpServletRequest request) {
|
||||
|
||||
Optional<RouteGraph> graphOpt = diagramRepository.findByContentHash(contentHash);
|
||||
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash);
|
||||
if (graphOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
@@ -63,7 +77,7 @@ public class DiagramRenderController {
|
||||
// without also accepting everything (*/*). This means "application/json"
|
||||
// must appear and wildcards must not dominate the preference.
|
||||
if (accept != null && isJsonPreferred(accept)) {
|
||||
DiagramLayout layout = diagramRenderer.layoutJson(graph);
|
||||
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(layout);
|
||||
@@ -76,6 +90,37 @@ public class DiagramRenderController {
|
||||
.body(svg);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Find diagram by application and route ID",
|
||||
description = "Resolves application to agent IDs and finds the latest diagram for the route")
|
||||
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
||||
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
||||
@RequestParam String application,
|
||||
@RequestParam String routeId,
|
||||
@RequestParam(defaultValue = "LR") String direction) {
|
||||
List<String> agentIds = registryService.findByApplication(application).stream()
|
||||
.map(AgentInfo::id)
|
||||
.toList();
|
||||
|
||||
if (agentIds.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Optional<String> contentHash = diagramStore.findContentHashForRouteByAgents(routeId, agentIds);
|
||||
if (contentHash.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Optional<RouteGraph> graphOpt = diagramStore.findByContentHash(contentHash.get());
|
||||
if (graphOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get(), direction);
|
||||
return ResponseEntity.ok(layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if JSON is the explicitly preferred format.
|
||||
* <p>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.common.model.RouteExecution;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.ingestion.IngestionService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
@@ -10,8 +12,9 @@ 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.HttpStatus;
|
||||
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;
|
||||
@@ -22,9 +25,8 @@ import java.util.List;
|
||||
/**
|
||||
* Ingestion endpoint for route execution data.
|
||||
* <p>
|
||||
* Accepts both single {@link RouteExecution} and arrays. Data is buffered
|
||||
* in a {@link com.cameleer3.server.core.ingestion.WriteBuffer} and flushed
|
||||
* to ClickHouse by the flush scheduler.
|
||||
* Accepts both single {@link RouteExecution} and arrays. Data is written
|
||||
* synchronously to PostgreSQL via {@link IngestionService}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/data")
|
||||
@@ -34,10 +36,14 @@ public class ExecutionController {
|
||||
private static final Logger log = LoggerFactory.getLogger(ExecutionController.class);
|
||||
|
||||
private final IngestionService ingestionService;
|
||||
private final AgentRegistryService registryService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ExecutionController(IngestionService ingestionService, ObjectMapper objectMapper) {
|
||||
public ExecutionController(IngestionService ingestionService,
|
||||
AgentRegistryService registryService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.ingestionService = ingestionService;
|
||||
this.registryService = registryService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -45,27 +51,28 @@ public class ExecutionController {
|
||||
@Operation(summary = "Ingest route execution data",
|
||||
description = "Accepts a single RouteExecution or an array of RouteExecutions")
|
||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||
@ApiResponse(responseCode = "503", description = "Buffer full, retry later")
|
||||
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
||||
String agentId = extractAgentId();
|
||||
String applicationName = resolveApplicationName(agentId);
|
||||
List<RouteExecution> executions = parsePayload(body);
|
||||
boolean accepted;
|
||||
|
||||
if (executions.size() == 1) {
|
||||
accepted = ingestionService.acceptExecution(executions.get(0));
|
||||
} else {
|
||||
accepted = ingestionService.acceptExecutions(executions);
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
log.warn("Execution buffer full, returning 503");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.header("Retry-After", "5")
|
||||
.build();
|
||||
for (RouteExecution execution : executions) {
|
||||
ingestionService.ingestExecution(agentId, applicationName, execution);
|
||||
}
|
||||
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
private String extractAgentId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth != null ? auth.getName() : "";
|
||||
}
|
||||
|
||||
private String resolveApplicationName(String agentId) {
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
return agent != null ? agent.application() : "";
|
||||
}
|
||||
|
||||
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
||||
String trimmed = body.strip();
|
||||
if (trimmed.startsWith("[")) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
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.rbac.GroupDetail;
|
||||
import com.cameleer3.server.core.rbac.GroupRepository;
|
||||
import com.cameleer3.server.core.rbac.GroupSummary;
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
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.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.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Admin endpoints for group management.
|
||||
* Protected by {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/groups")
|
||||
@Tag(name = "Group Admin", description = "Group management (ADMIN only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class GroupAdminController {
|
||||
|
||||
private final GroupRepository groupRepository;
|
||||
private final RbacService rbacService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public GroupAdminController(GroupRepository groupRepository, RbacService rbacService,
|
||||
AuditService auditService) {
|
||||
this.groupRepository = groupRepository;
|
||||
this.rbacService = rbacService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all groups with hierarchy and effective roles")
|
||||
@ApiResponse(responseCode = "200", description = "Group list returned")
|
||||
public ResponseEntity<List<GroupDetail>> listGroups() {
|
||||
List<GroupSummary> summaries = groupRepository.findAll();
|
||||
List<GroupDetail> details = new ArrayList<>();
|
||||
for (GroupSummary summary : summaries) {
|
||||
groupRepository.findById(summary.id()).ifPresent(details::add);
|
||||
}
|
||||
return ResponseEntity.ok(details);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get group by ID with effective roles")
|
||||
@ApiResponse(responseCode = "200", description = "Group found")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<GroupDetail> getGroup(@PathVariable UUID id) {
|
||||
return groupRepository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new group")
|
||||
@ApiResponse(responseCode = "200", description = "Group created")
|
||||
public ResponseEntity<Map<String, UUID>> createGroup(@RequestBody CreateGroupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID id = groupRepository.create(request.name(), request.parentGroupId());
|
||||
auditService.log("create_group", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(Map.of("id", id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update group name or parent")
|
||||
@ApiResponse(responseCode = "200", description = "Group updated")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
@ApiResponse(responseCode = "409", description = "Cycle detected in group hierarchy")
|
||||
public ResponseEntity<Void> updateGroup(@PathVariable UUID id,
|
||||
@RequestBody UpdateGroupRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
Optional<GroupDetail> existing = groupRepository.findById(id);
|
||||
if (existing.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Cycle detection: walk ancestor chain of proposed parent and check if it includes 'id'
|
||||
if (request.parentGroupId() != null) {
|
||||
List<GroupSummary> ancestors = groupRepository.findAncestorChain(request.parentGroupId());
|
||||
for (GroupSummary ancestor : ancestors) {
|
||||
if (ancestor.id().equals(id)) {
|
||||
return ResponseEntity.status(409).build();
|
||||
}
|
||||
}
|
||||
// Also check that the proposed parent itself is not the group being updated
|
||||
if (request.parentGroupId().equals(id)) {
|
||||
return ResponseEntity.status(409).build();
|
||||
}
|
||||
}
|
||||
|
||||
groupRepository.update(id, request.name(), request.parentGroupId());
|
||||
auditService.log("update_group", AuditCategory.RBAC, id.toString(),
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete group")
|
||||
@ApiResponse(responseCode = "204", description = "Group deleted")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (groupRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.delete(id);
|
||||
auditService.log("delete_group", AuditCategory.RBAC, id.toString(),
|
||||
null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/roles/{roleId}")
|
||||
@Operation(summary = "Assign a role to a group")
|
||||
@ApiResponse(responseCode = "200", description = "Role assigned to group")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<Void> assignRoleToGroup(@PathVariable UUID id,
|
||||
@PathVariable UUID roleId,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (groupRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.addRole(id, roleId);
|
||||
auditService.log("assign_role_to_group", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/roles/{roleId}")
|
||||
@Operation(summary = "Remove a role from a group")
|
||||
@ApiResponse(responseCode = "204", description = "Role removed from group")
|
||||
@ApiResponse(responseCode = "404", description = "Group not found")
|
||||
public ResponseEntity<Void> removeRoleFromGroup(@PathVariable UUID id,
|
||||
@PathVariable UUID roleId,
|
||||
HttpServletRequest httpRequest) {
|
||||
if (groupRepository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
groupRepository.removeRole(id, roleId);
|
||||
auditService.log("remove_role_from_group", AuditCategory.RBAC, id.toString(),
|
||||
Map.of("roleId", roleId), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
public record CreateGroupRequest(String name, UUID parentGroupId) {}
|
||||
public record UpdateGroupRequest(String name, UUID parentGroupId) {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.common.model.LogBatch;
|
||||
import com.cameleer3.server.app.search.OpenSearchLogIndex;
|
||||
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 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 OpenSearchLogIndex logIndex;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public LogIngestionController(OpenSearchLogIndex logIndex,
|
||||
AgentRegistryService registryService) {
|
||||
this.logIndex = logIndex;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
@PostMapping("/logs")
|
||||
@Operation(summary = "Ingest application log entries",
|
||||
description = "Accepts a batch of log entries from an agent. Entries are indexed in OpenSearch.")
|
||||
@ApiResponse(responseCode = "202", description = "Logs accepted for indexing")
|
||||
public ResponseEntity<Void> ingestLogs(@RequestBody LogBatch batch) {
|
||||
String agentId = extractAgentId();
|
||||
String application = resolveApplicationName(agentId);
|
||||
|
||||
if (batch.getEntries() != null && !batch.getEntries().isEmpty()) {
|
||||
log.debug("Received {} log entries from agent={}, app={}", batch.getEntries().size(), agentId, application);
|
||||
logIndex.indexBatch(agentId, application, batch.getEntries());
|
||||
}
|
||||
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
private String extractAgentId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth != null ? auth.getName() : "";
|
||||
}
|
||||
|
||||
private String resolveApplicationName(String agentId) {
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
return agent != null ? agent.application() : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||
import com.cameleer3.server.app.search.OpenSearchLogIndex;
|
||||
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.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/logs")
|
||||
@Tag(name = "Application Logs", description = "Query application logs stored in OpenSearch")
|
||||
public class LogQueryController {
|
||||
|
||||
private final OpenSearchLogIndex logIndex;
|
||||
|
||||
public LogQueryController(OpenSearchLogIndex logIndex) {
|
||||
this.logIndex = logIndex;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Search application log entries",
|
||||
description = "Returns log entries for a given application, optionally filtered by agent, level, time range, and text query")
|
||||
public ResponseEntity<List<LogEntryResponse>> searchLogs(
|
||||
@RequestParam String application,
|
||||
@RequestParam(required = false) String agentId,
|
||||
@RequestParam(required = false) String level,
|
||||
@RequestParam(required = false) String query,
|
||||
@RequestParam(required = false) String exchangeId,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(defaultValue = "200") int limit) {
|
||||
|
||||
limit = Math.min(limit, 1000);
|
||||
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||
|
||||
List<LogEntryResponse> entries = logIndex.search(
|
||||
application, agentId, level, query, exchangeId, fromInstant, toInstant, limit);
|
||||
|
||||
return ResponseEntity.ok(entries);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import java.util.List;
|
||||
* Ingestion endpoint for agent metrics.
|
||||
* <p>
|
||||
* Accepts an array of {@link MetricsSnapshot}. Data is buffered
|
||||
* and flushed to ClickHouse by the flush scheduler.
|
||||
* and flushed to PostgreSQL by the flush scheduler.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/data")
|
||||
@@ -44,13 +44,23 @@ public class MetricsController {
|
||||
@Operation(summary = "Ingest agent metrics",
|
||||
description = "Accepts an array of MetricsSnapshot objects")
|
||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid payload")
|
||||
@ApiResponse(responseCode = "503", description = "Buffer full, retry later")
|
||||
public ResponseEntity<Void> ingestMetrics(@RequestBody String body) throws JsonProcessingException {
|
||||
List<MetricsSnapshot> metrics = parsePayload(body);
|
||||
boolean accepted = ingestionService.acceptMetrics(metrics);
|
||||
public ResponseEntity<Void> ingestMetrics(@RequestBody String body) {
|
||||
List<MetricsSnapshot> 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) {
|
||||
log.warn("Metrics buffer full, returning 503");
|
||||
log.warn("Metrics buffer full ({} items), returning 503",
|
||||
ingestionService.getMetricsBufferDepth());
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.header("Retry-After", "5")
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import com.cameleer3.server.app.dto.OidcAdminConfigRequest;
|
||||
import com.cameleer3.server.app.dto.OidcAdminConfigResponse;
|
||||
import com.cameleer3.server.app.dto.OidcTestResult;
|
||||
import com.cameleer3.server.app.security.OidcTokenExchanger;
|
||||
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.security.OidcConfig;
|
||||
import com.cameleer3.server.core.security.OidcConfigRepository;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
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.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.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.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Admin endpoints for managing OIDC provider configuration.
|
||||
* Protected by {@code ROLE_ADMIN} via SecurityConfig URL patterns ({@code /api/v1/admin/**}).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/oidc")
|
||||
@Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class OidcConfigAdminController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class);
|
||||
|
||||
private final OidcConfigRepository configRepository;
|
||||
private final OidcTokenExchanger tokenExchanger;
|
||||
private final AuditService auditService;
|
||||
|
||||
public OidcConfigAdminController(OidcConfigRepository configRepository,
|
||||
OidcTokenExchanger tokenExchanger,
|
||||
AuditService auditService) {
|
||||
this.configRepository = configRepository;
|
||||
this.tokenExchanger = tokenExchanger;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get OIDC configuration")
|
||||
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
|
||||
public ResponseEntity<OidcAdminConfigResponse> getConfig(HttpServletRequest httpRequest) {
|
||||
auditService.log("view_oidc_config", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
|
||||
Optional<OidcConfig> config = configRepository.find();
|
||||
if (config.isEmpty()) {
|
||||
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
|
||||
}
|
||||
return ResponseEntity.ok(OidcAdminConfigResponse.from(config.get()));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@Operation(summary = "Save OIDC configuration")
|
||||
@ApiResponse(responseCode = "200", description = "Configuration saved")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid configuration",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<OidcAdminConfigResponse> saveConfig(@RequestBody OidcAdminConfigRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
// Resolve client_secret: if masked or empty, preserve existing
|
||||
String clientSecret = request.clientSecret();
|
||||
if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) {
|
||||
Optional<OidcConfig> existing = configRepository.find();
|
||||
clientSecret = existing.map(OidcConfig::clientSecret).orElse("");
|
||||
}
|
||||
|
||||
if (request.enabled() && (request.issuerUri() == null || request.issuerUri().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"issuerUri is required when OIDC is enabled");
|
||||
}
|
||||
if (request.enabled() && (request.clientId() == null || request.clientId().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"clientId is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
OidcConfig config = new OidcConfig(
|
||||
request.enabled(),
|
||||
request.issuerUri() != null ? request.issuerUri() : "",
|
||||
request.clientId() != null ? request.clientId() : "",
|
||||
clientSecret,
|
||||
request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles",
|
||||
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
|
||||
request.autoSignup(),
|
||||
request.displayNameClaim() != null ? request.displayNameClaim() : "name"
|
||||
);
|
||||
|
||||
configRepository.save(config);
|
||||
tokenExchanger.invalidateCache();
|
||||
|
||||
auditService.log("update_oidc", AuditCategory.CONFIG, "oidc", Map.of(), AuditResult.SUCCESS, httpRequest);
|
||||
log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri());
|
||||
return ResponseEntity.ok(OidcAdminConfigResponse.from(config));
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
@Operation(summary = "Test OIDC provider connectivity")
|
||||
@ApiResponse(responseCode = "200", description = "Provider reachable")
|
||||
@ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<OidcTestResult> testConnection(HttpServletRequest httpRequest) {
|
||||
Optional<OidcConfig> config = configRepository.find();
|
||||
if (config.isEmpty() || !config.get().enabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"OIDC is not configured or disabled");
|
||||
}
|
||||
|
||||
try {
|
||||
tokenExchanger.invalidateCache();
|
||||
String authEndpoint = tokenExchanger.getAuthorizationEndpoint();
|
||||
auditService.log("test_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint));
|
||||
} catch (Exception e) {
|
||||
log.warn("OIDC connectivity test failed: {}", e.getMessage());
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Failed to reach OIDC provider: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(summary = "Delete OIDC configuration")
|
||||
@ApiResponse(responseCode = "204", description = "Configuration deleted")
|
||||
public ResponseEntity<Void> deleteConfig(HttpServletRequest httpRequest) {
|
||||
configRepository.delete();
|
||||
tokenExchanger.invalidateCache();
|
||||
auditService.log("delete_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest);
|
||||
log.info("OIDC configuration deleted");
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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;
|
||||
private final String logIndexPrefix;
|
||||
|
||||
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,
|
||||
@Value("${opensearch.log-index-prefix:logs-}") String logIndexPrefix) {
|
||||
this.client = client;
|
||||
this.restClient = restClient;
|
||||
this.indexerStats = indexerStats;
|
||||
this.auditService = auditService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.opensearchUrl = opensearchUrl;
|
||||
this.indexPrefix = indexPrefix;
|
||||
this.logIndexPrefix = logIndexPrefix;
|
||||
}
|
||||
|
||||
@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.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(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,
|
||||
@RequestParam(defaultValue = "executions") String prefix) {
|
||||
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);
|
||||
}
|
||||
|
||||
String filterPrefix = "logs".equals(prefix) ? logIndexPrefix : indexPrefix;
|
||||
|
||||
List<IndexInfoResponse> allIndices = new ArrayList<>();
|
||||
for (JsonNode idx : indices) {
|
||||
String name = idx.path("index").asText("");
|
||||
if (!name.startsWith(filterPrefix)) {
|
||||
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.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(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) && !name.startsWith(logIndexPrefix)) {
|
||||
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.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.rbac.RbacService;
|
||||
import com.cameleer3.server.core.rbac.RbacStats;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Admin endpoint for RBAC statistics.
|
||||
* Protected by {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/rbac")
|
||||
@Tag(name = "RBAC Stats", description = "RBAC statistics (ADMIN only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class RbacStatsController {
|
||||
|
||||
private final RbacService rbacService;
|
||||
|
||||
public RbacStatsController(RbacService rbacService) {
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
@Operation(summary = "Get RBAC statistics for the dashboard")
|
||||
@ApiResponse(responseCode = "200", description = "RBAC stats returned")
|
||||
public ResponseEntity<RbacStats> getStats() {
|
||||
return ResponseEntity.ok(rbacService.getStats());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user