Compare commits
554 Commits
5c8307f58a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8f018f2c6 | ||
|
|
b151951448 | ||
|
|
2e2e81b3e1 | ||
|
|
b873c0e4ae | ||
|
|
9dc16674e2 | ||
|
|
e6e2688b56 | ||
|
|
28aa74b4b0 | ||
|
|
8e37441782 | ||
|
|
6a0e7c947f | ||
|
|
3c1a2d9c41 | ||
|
|
92c86ec6ba | ||
|
|
5ef039a6bc | ||
|
|
96b8f25747 | ||
|
|
42ab5ead26 | ||
|
|
5aaa62dca7 | ||
|
|
d583971afd | ||
|
|
d660a45bb5 | ||
|
|
d1f3b9ffcd | ||
|
|
d93321275c | ||
|
|
629b9d9ca5 | ||
|
|
7e3b1108e2 | ||
|
|
b3fbbbacfe | ||
|
|
3a100fa1f1 | ||
|
|
fbeb93046d | ||
|
|
0cce8a2011 | ||
|
|
7a53f5bee1 | ||
|
|
ea30ceb1f1 | ||
|
|
cd33777d75 | ||
|
|
c73a489075 | ||
|
|
7ddb572f5d | ||
|
|
1a3101066e | ||
|
|
043bcb65d8 | ||
|
|
d31fccbe0e | ||
|
|
41bc522b5b | ||
|
|
75bd0c29f3 | ||
|
|
3ffa3f5793 | ||
|
|
59e55f8740 | ||
|
|
f1359d63ba | ||
|
|
bbfcd44407 | ||
|
|
5da5a5597b | ||
|
|
b1ef6a85d6 | ||
|
|
a795794f94 | ||
|
|
4e27e05512 | ||
|
|
71b6f8f181 | ||
|
|
38684dd903 | ||
|
|
716bc651c4 | ||
|
|
27f12e4659 | ||
|
|
a7c6ffe4dd | ||
|
|
ae5c5c24eb | ||
|
|
e8ec50e0fc | ||
|
|
1f8667c7da | ||
|
|
bed41dcbdf | ||
|
|
6694ab84a1 | ||
|
|
f721e97ff1 | ||
|
|
d9f9fa0743 | ||
|
|
7b72fac679 | ||
|
|
726c780416 | ||
|
|
69effa446a | ||
|
|
c5bca9db44 | ||
|
|
6565538b3b | ||
|
|
ecb704f24e | ||
|
|
86532f5e08 | ||
|
|
5c60d44283 | ||
|
|
3eacb7e580 | ||
|
|
4151098c12 | ||
|
|
93f8e85568 | ||
|
|
92c272bbea | ||
|
|
ed4e41f7dc | ||
|
|
d7d77769ff | ||
|
|
362df8f766 | ||
|
|
43f0f3d092 | ||
|
|
ff851db5d1 | ||
|
|
b4bfa4ba49 | ||
|
|
dacbb5f15e | ||
|
|
34b7957132 | ||
|
|
cc46a389e7 | ||
|
|
9dc39d06af | ||
|
|
43c4c5102a | ||
|
|
0992c73842 | ||
|
|
0808853d45 | ||
|
|
f071e89fc2 | ||
|
|
b546ae3759 | ||
|
|
a2fb1f38ee | ||
|
|
2195ccfa1a | ||
|
|
b28f266cbe | ||
|
|
736ddf647d | ||
|
|
2188d6645e | ||
|
|
151bf3d322 | ||
|
|
076a6cd567 | ||
|
|
c93796554a | ||
|
|
f94974c438 | ||
|
|
78788a89ba | ||
|
|
a9c50208cf | ||
|
|
31e1420cdc | ||
|
|
4e34fa6da9 | ||
|
|
e655af178b | ||
|
|
6d7c3037fc | ||
|
|
fc855f52f9 | ||
|
|
932508f935 | ||
|
|
bc33b909cb | ||
|
|
bde78c51e0 | ||
|
|
cd34d99982 | ||
|
|
3004be3c9d | ||
|
|
78783ad20c | ||
|
|
be123d7081 | ||
|
|
2096c853ee | ||
|
|
2c51caa928 | ||
|
|
b88ed51286 | ||
|
|
5c5492d26e | ||
|
|
9f61eea9f6 | ||
|
|
1a963f9e66 | ||
|
|
fc38c80804 | ||
|
|
31b6e38459 | ||
|
|
0194af8a64 | ||
|
|
96714ab068 | ||
|
|
7c3758298f | ||
|
|
d40590acef | ||
|
|
0bdf40e1c6 | ||
|
|
9dcbc5a951 | ||
|
|
3c7af3aa93 | ||
|
|
1b41ee512f | ||
|
|
f7377aba96 | ||
|
|
dd969b5184 | ||
|
|
86d8a44d4f | ||
|
|
86d729b837 | ||
|
|
cf09c93110 | ||
|
|
63d9566ee4 | ||
|
|
e37906d92e | ||
|
|
ced3333430 | ||
|
|
4265f5175a | ||
|
|
d0bbfbb744 | ||
|
|
c85ee384c9 | ||
|
|
642a8587b5 | ||
|
|
4716023abc | ||
|
|
ba3b172223 | ||
|
|
34d7b187af | ||
|
|
487dc6d1e7 | ||
|
|
8b7671d310 | ||
|
|
24e57f558e | ||
|
|
c4ad3bc2c4 | ||
|
|
fa6b0a241d | ||
|
|
82c9b5cf53 | ||
|
|
bb1144f392 | ||
|
|
48c6f9277c | ||
|
|
56da89fb0e | ||
|
|
8442ac82f1 | ||
|
|
283894a197 | ||
|
|
41c2191280 | ||
|
|
f3dba93d81 | ||
|
|
62aa56b007 | ||
|
|
72250c7c75 | ||
|
|
2dfc47d67e | ||
|
|
798c2c4373 | ||
|
|
e97c03587d | ||
|
|
004a624f23 | ||
|
|
15b6e8614c | ||
|
|
80376c90b3 | ||
|
|
111e5d546f | ||
|
|
43418d46fd | ||
|
|
e4f2d49e96 | ||
|
|
898ad1785b | ||
|
|
8aa5db39fd | ||
|
|
db0b77ef8f | ||
|
|
0d123d8264 | ||
|
|
4abba96515 | ||
|
|
c49fae8776 | ||
|
|
9a750eb2b1 | ||
|
|
4c2a7574e4 | ||
|
|
f115b0a307 | ||
|
|
c34c06d28d | ||
|
|
48042bde47 | ||
|
|
7fb207cfce | ||
|
|
11b330c268 | ||
|
|
fb53c8be90 | ||
|
|
b29dc33708 | ||
|
|
7cb79dacd5 | ||
|
|
14362cbc0e | ||
|
|
91f4202e88 | ||
|
|
e5bb8e65e8 | ||
|
|
f86dc265eb | ||
|
|
ae31a19275 | ||
|
|
e72b68b4a3 | ||
|
|
6b08ce6b6a | ||
|
|
e0a3ff5ca9 | ||
|
|
b3baf603ee | ||
|
|
ad2bbab7b6 | ||
|
|
a3a1ec4430 | ||
|
|
c0c44adaaa | ||
|
|
8a4e196864 | ||
|
|
34e2614e36 | ||
|
|
ac8ef371ff | ||
|
|
e37aecab18 | ||
|
|
ecc1423a4f | ||
|
|
39fcf58d1b | ||
|
|
497be5fac9 | ||
|
|
855e764911 | ||
|
|
e151984ce2 | ||
|
|
11431bbf4e | ||
|
|
fcac514d9f | ||
|
|
da4dcdca32 | ||
|
|
3cce3b2871 | ||
|
|
a2c5307713 | ||
|
|
ef7ec776eb | ||
|
|
9fd829eceb | ||
|
|
96cd79dec5 | ||
|
|
fde1673bdd | ||
|
|
f781874eee | ||
|
|
9333b7a9c3 | ||
|
|
6db0056329 | ||
|
|
e0fedde560 | ||
|
|
f7441ccba5 | ||
|
|
27d1c5ba9f | ||
|
|
8354ab4df4 | ||
|
|
88d0619184 | ||
|
|
6111494460 | ||
|
|
73e3749960 | ||
|
|
f57bdfa151 | ||
|
|
34b519eebb | ||
|
|
66fb265f22 | ||
|
|
ec7326cfe1 | ||
|
|
67ed5e542d | ||
|
|
6ec27fdbf2 | ||
|
|
9513675d85 | ||
|
|
8e2329be53 | ||
|
|
19214bfd66 | ||
|
|
53e61c6dcd | ||
|
|
728f698f9e | ||
|
|
511a7de627 | ||
|
|
9d82f15c53 | ||
|
|
b0918fd946 | ||
|
|
7b31b462a0 | ||
|
|
021faedfa3 | ||
|
|
0b30c5e66c | ||
|
|
824f8a7ff2 | ||
|
|
5914ec6cd5 | ||
|
|
30c63bbef6 | ||
|
|
7be1a296c6 | ||
|
|
e524786ac0 | ||
|
|
0ee2b1538a | ||
|
|
dd6e2f8bd7 | ||
|
|
f66f32ee9d | ||
|
|
de308b7397 | ||
|
|
8402e57323 | ||
|
|
1212f6ddfb | ||
|
|
ac2299226a | ||
|
|
607dab4f26 | ||
|
|
3b8f9b595e | ||
|
|
84a0280c52 | ||
|
|
dc36e59d17 | ||
|
|
9bb689b7e6 | ||
|
|
d01a50a4b1 | ||
|
|
51e75187ed | ||
|
|
e37fd3bbe4 | ||
|
|
11fa490599 | ||
|
|
27ef21a4f0 | ||
|
|
b3643ddee9 | ||
|
|
68b7660ce3 | ||
|
|
2d61911d98 | ||
|
|
9f642901ab | ||
|
|
add7400b78 | ||
|
|
65cc5200ea | ||
|
|
ede93a7774 | ||
|
|
bc020e9f64 | ||
|
|
bad4659d5b | ||
|
|
e3b33ef596 | ||
|
|
39255f2c9e | ||
|
|
030991cb9a | ||
|
|
fa9b554f50 | ||
|
|
788714ecec | ||
|
|
08ca17c876 | ||
|
|
c157e9cbca | ||
|
|
9005a05bd7 | ||
|
|
98081ae5eb | ||
|
|
c99e35438c | ||
|
|
1241a14ea5 | ||
|
|
0712d18824 | ||
|
|
71040dcd33 | ||
|
|
0923d9b051 | ||
|
|
909301a4de | ||
|
|
d548ce4199 | ||
|
|
0188a46afb | ||
|
|
d6be61cdcf | ||
|
|
6e6525a416 | ||
|
|
6a6b3e8cee | ||
|
|
09ac22f692 | ||
|
|
5a476ac97d | ||
|
|
4f2a963834 | ||
|
|
aa7bd79c51 | ||
|
|
7701a34d7f | ||
|
|
d35e3f4705 | ||
|
|
5d71a371d6 | ||
|
|
f75aef2a4a | ||
|
|
5264528940 | ||
|
|
084183f3a4 | ||
|
|
e05d3e1554 | ||
|
|
06f868abeb | ||
|
|
aed428312f | ||
|
|
32851ca9fb | ||
|
|
cbee0b534f | ||
|
|
8f44d907a5 | ||
|
|
24ce8ccd20 | ||
|
|
786993d8ca | ||
|
|
2b9788bdb0 | ||
|
|
91b5ce990f | ||
|
|
936b4ccc51 | ||
|
|
9e3f15ce4e | ||
|
|
7523f47468 | ||
|
|
6de8b33dd1 | ||
|
|
79c01c85fa | ||
|
|
735cab2018 | ||
|
|
b4e8b74afb | ||
|
|
4b06933576 | ||
|
|
89a6b90ca6 | ||
|
|
f9b9cf0383 | ||
|
|
2de4d03d81 | ||
|
|
d2c2fd92cc | ||
|
|
032df7f401 | ||
|
|
474f09ce88 | ||
|
|
e920dd1b3f | ||
|
|
5ddf8bbc3c | ||
|
|
14cde7b3ee | ||
|
|
581162cdb8 | ||
|
|
dc27fc5500 | ||
|
|
51649c874b | ||
|
|
4d7836540a | ||
|
|
3419e18d7f | ||
|
|
a9b71b9d23 | ||
|
|
e8a18c0025 | ||
|
|
3e9a988aaf | ||
|
|
01f05e4399 | ||
|
|
7c17e484c1 | ||
|
|
ea39418738 | ||
|
|
7f88ed0ed2 | ||
|
|
44659a9dd7 | ||
|
|
87d7da0198 | ||
|
|
9675c1f896 | ||
|
|
9736476a0c | ||
|
|
03d420c984 | ||
|
|
6b52719079 | ||
|
|
a5b7d62969 | ||
|
|
ef9e3699b2 | ||
|
|
440367b69d | ||
|
|
801a5a43f5 | ||
|
|
9c23068a4f | ||
|
|
d359b7b734 | ||
|
|
bd37ff807e | ||
|
|
40d2342086 | ||
|
|
adf3bf8301 | ||
|
|
1b5ccd4dec | ||
|
|
b5d8f9aed3 | ||
|
|
c8171b0a1e | ||
|
|
7e15ef3725 | ||
|
|
e3a3802f5b | ||
|
|
93e319e9fb | ||
|
|
6626d2a8f9 | ||
|
|
3dbc470158 | ||
|
|
e5d0386cfb | ||
|
|
ff071af2a0 | ||
|
|
fcdcbc51e3 | ||
|
|
7b8f8d4b5a | ||
|
|
f385c612f5 | ||
|
|
9166d9dade | ||
|
|
7ae5bc0fd5 | ||
|
|
242ed1101e | ||
|
|
8b2e9ac328 | ||
|
|
084d09e9bd | ||
|
|
646143ce5a | ||
|
|
00d802f965 | ||
|
|
ebb7575f2c | ||
|
|
d0539d0f2f | ||
|
|
8e92a93aa8 | ||
|
|
f794347827 | ||
|
|
1af160eed0 | ||
|
|
eb118ebf92 | ||
|
|
dbb476cc3b | ||
|
|
9345efc3f0 | ||
|
|
c4e993e3f8 | ||
|
|
a58d1aa403 | ||
|
|
d7ed5ce8c5 | ||
|
|
512088ab93 | ||
|
|
32b5e0223d | ||
|
|
9354cbf775 | ||
|
|
756d068b4f | ||
|
|
c02a7bd8a6 | ||
|
|
b6d3fad6ab | ||
|
|
27479ee553 | ||
|
|
82a5d62f44 | ||
|
|
bc23c6815a | ||
|
|
7dd2dc89a9 | ||
|
|
57462899f6 | ||
|
|
f23b872c54 | ||
|
|
55f7195edd | ||
|
|
b14be8583d | ||
|
|
67ad7c236b | ||
|
|
f89ce46631 | ||
|
|
fc71117bf2 | ||
|
|
ea752088f6 | ||
|
|
edadf39445 | ||
| 1c3cec2c06 | |||
|
|
746daaef6d | ||
|
|
441d5740bd | ||
|
|
ee5241a7bc | ||
|
|
e3ab428b91 | ||
| c7ab569b2b | |||
| 645973141c | |||
|
|
68692ade4e | ||
|
|
49908d72d0 | ||
|
|
1b5c2a156c | ||
|
|
159d07efd5 | ||
|
|
06431be40d | ||
|
|
9f3e5bbf9f | ||
|
|
a66b76001b | ||
|
|
3188054462 | ||
|
|
5fd65e8a38 | ||
|
|
34d2529e04 | ||
|
|
928556aa89 | ||
|
|
720493f26b | ||
|
|
ab13254636 | ||
|
|
104a506b6f | ||
|
|
92290b9035 | ||
|
|
b5d855d117 | ||
|
|
1bd57da627 | ||
|
|
f9c03c30d9 | ||
|
|
f2b225106d | ||
|
|
29d3ec60d0 | ||
|
|
bbf038d228 | ||
|
|
c967d80aed | ||
|
|
11c0c1df38 | ||
|
|
f849fd729a | ||
|
|
85949dbf8e | ||
|
|
6fba87fdd9 | ||
|
|
c7236ef7e8 | ||
|
|
307af5c901 | ||
|
|
625906f75a | ||
|
|
129072e0f0 | ||
|
|
dbc4e59e24 | ||
|
|
cf476ea986 | ||
|
|
c989af42f5 | ||
|
|
d3247ef090 | ||
|
|
90c7f9d8ec | ||
|
|
c43d39fd7f | ||
|
|
8aca75118c | ||
|
|
6bf2692faa | ||
|
|
2d85ef310a | ||
|
|
774a0ba6db | ||
|
|
566a8bf84e | ||
|
|
3567845235 | ||
|
|
c4d8da6d0d | ||
|
|
fa8010cf91 | ||
|
|
16de384831 | ||
|
|
a01e6cb88e | ||
|
|
a58cd16f01 | ||
|
|
f514667ef9 | ||
|
|
9e712465af | ||
|
|
bf22d436fb | ||
|
|
f689b892de | ||
|
|
2f2338c973 | ||
|
|
10eb0ce5f9 | ||
|
|
32616504a6 | ||
|
|
4bce3724f2 | ||
|
|
322e2d9cb3 | ||
|
|
c1a8b9d936 | ||
|
|
c374600833 | ||
|
|
87b00a94c0 | ||
|
|
978f0297eb | ||
|
|
959986356b | ||
|
|
f126b40574 | ||
|
|
fa4027d027 | ||
|
|
9da9b323fc | ||
|
|
eb263ce7a4 | ||
|
|
aece5f7414 | ||
|
|
ddabda6f05 | ||
|
|
bcbceba31c | ||
|
|
3a2567b44d | ||
|
|
df0a9d6cf0 | ||
|
|
38363b2837 | ||
|
|
96f94475f6 | ||
|
|
3fd3336f6c | ||
|
|
eaba087d11 | ||
|
|
ed2cc234b8 | ||
|
|
ffd3fd1d7c | ||
|
|
23694b6555 | ||
|
|
8979aa8e43 | ||
|
|
c433bc021e | ||
|
|
f4ed1eb10c | ||
|
|
9c8663a0f1 | ||
|
|
d1632fca17 | ||
| fcf8aa8652 | |||
|
|
65177d3ff7 | ||
|
|
559d6a351c | ||
|
|
8fd11998e4 | ||
|
|
4ce649aa71 | ||
|
|
5ee3cc0104 | ||
|
|
b36712247b | ||
|
|
cf2cabd098 | ||
|
|
8ee02bd2e4 | ||
|
|
d9687725e5 | ||
|
|
6c3911ca47 | ||
|
|
30807d1ce1 | ||
|
|
82c28a2b6e | ||
|
|
86624d72dd | ||
|
|
9218664400 | ||
|
|
8fa5d9061a | ||
|
|
84002f5719 | ||
|
|
86b11c7e5f | ||
|
|
8003dcac39 | ||
|
|
778c44226e | ||
|
|
79891063dd | ||
|
|
2c9b0dc448 | ||
|
|
3133615044 | ||
|
|
2bc0f87325 | ||
|
|
4ee38d6f0b | ||
|
|
992d4f2a6b | ||
|
|
8f5f9641c7 | ||
|
|
7cdb53051f | ||
|
|
8b87b90cbb | ||
|
|
be45adb975 | ||
|
|
7c932c441f | ||
|
|
1eb402b3da | ||
|
|
963e824328 | ||
|
|
c0782e0039 | ||
|
|
44d66e2d6c | ||
|
|
f9b475db8f | ||
|
|
0770ff499b | ||
|
|
d834753a98 | ||
|
|
395011d0f4 | ||
|
|
9e1660f954 | ||
|
|
13ff930b5e | ||
|
|
5d1c837f49 | ||
|
|
1dd9662037 | ||
|
|
4626edb232 | ||
|
|
3c29b621ac | ||
|
|
755570d474 | ||
|
|
32aade553d | ||
|
|
f467db2ea0 | ||
|
|
35aad9b169 | ||
|
|
806d3e0b56 | ||
|
|
9f0e8328e5 | ||
|
|
65184c02c3 | ||
|
|
4245e24980 | ||
|
|
8dc1b4c67f | ||
|
|
2801e44d39 | ||
|
|
62ecb3eb24 | ||
|
|
fe9a9c2df2 | ||
|
|
5fe2617857 | ||
|
|
c8cc8774db | ||
|
|
1527f4ffe7 | ||
|
|
db1b3c40ed | ||
|
|
85df14c552 | ||
|
|
72e0f18d08 | ||
|
|
e890b1490a | ||
|
|
1c8f528c7a | ||
|
|
403cb5b85d | ||
|
|
d15de16c47 |
227
.claude/AGENTS.go.md
Normal file
227
.claude/AGENTS.go.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# AGENTS.go.md — Go Agent Rules
|
||||||
|
|
||||||
|
Applies to: `ai-compliance-sdk/` (Go/Gin service)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||||
|
|
||||||
|
**BEFORE every `git push`, run ALL of the following from the module root. A single failure blocks the push.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Format (gofmt is non-negotiable — unformatted code fails CI)
|
||||||
|
gofmt -l . | grep -q . && echo "FORMATTING ERRORS — run: gofmt -w ." && exit 1 || true
|
||||||
|
|
||||||
|
# 2. Vet (catches suspicious code that compiles but is likely wrong)
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# 3. Lint (golangci-lint aggregates 50+ linters — the de-facto standard)
|
||||||
|
golangci-lint run --timeout=5m ./...
|
||||||
|
|
||||||
|
# 4. Tests with race detector
|
||||||
|
go test -race -count=1 ./...
|
||||||
|
|
||||||
|
# 5. Build verification (catches import errors, missing implementations)
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-liner pre-push gate:**
|
||||||
|
```bash
|
||||||
|
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race -count=1 ./... && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why each check matters
|
||||||
|
|
||||||
|
| Check | Catches | Time |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `gofmt` | Formatting violations (CI rejects unformatted code) | <1s |
|
||||||
|
| `go vet` | Printf format mismatches, unreachable code, shadowed vars | <5s |
|
||||||
|
| `golangci-lint` | 50+ static analysis checks (errcheck, staticcheck, etc.) | 10-30s |
|
||||||
|
| `go test -race` | Race conditions (invisible without this flag) | 10-60s |
|
||||||
|
| `go build` | Import errors, interface mismatches | <5s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## golangci-lint Configuration
|
||||||
|
|
||||||
|
Config lives in `.golangci.yml` at the repo root. Minimum required linters:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck # unchecked errors are bugs
|
||||||
|
- gosimple # code simplification
|
||||||
|
- govet # go vet findings
|
||||||
|
- ineffassign # useless assignments
|
||||||
|
- staticcheck # advanced static analysis (SA*, S*, QF*)
|
||||||
|
- unused # unused code
|
||||||
|
- gofmt # formatting
|
||||||
|
- goimports # import organization
|
||||||
|
- gocritic # opinionated style checks
|
||||||
|
- noctx # HTTP requests without context
|
||||||
|
- bodyclose # unclosed HTTP response bodies
|
||||||
|
- exhaustive # exhaustive switch on enums
|
||||||
|
- wrapcheck # errors from external packages must be wrapped
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
check-blank: true # blank identifier for errors is a bug
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never suppress with `//nolint:` without a comment explaining why it's safe.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Structure (Hexagonal Architecture)
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-compliance-sdk/
|
||||||
|
├── cmd/
|
||||||
|
│ └── server/main.go # thin: parse flags, wire deps, call app.Run()
|
||||||
|
├── internal/
|
||||||
|
│ ├── app/ # dependency wiring
|
||||||
|
│ ├── domain/ # pure business logic, no framework deps
|
||||||
|
│ ├── ports/ # interfaces (repositories, external services)
|
||||||
|
│ ├── adapters/
|
||||||
|
│ │ ├── http/ # Gin handlers (≤30 LOC per handler)
|
||||||
|
│ │ ├── postgres/ # DB adapters implementing ports
|
||||||
|
│ │ └── external/ # third-party API clients
|
||||||
|
│ └── services/ # orchestration between domain + ports
|
||||||
|
└── pkg/ # exported, reusable packages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handler constraint — max 30 lines per handler:**
|
||||||
|
```go
|
||||||
|
func (h *RiskHandler) GetRisk(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
risk, err := h.service.Get(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, risk)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```go
|
||||||
|
// REQUIRED: wrap errors with context
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get risk %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// REQUIRED: define sentinel errors in domain package
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
var ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
|
// REQUIRED: check errors — never use _ for error returns
|
||||||
|
result, err := service.Do(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
// handle it
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`errcheck` linter enforces this — zero tolerance for unchecked errors.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
├── domain/
|
||||||
|
│ ├── risk.go
|
||||||
|
│ └── risk_test.go # unit: pure functions, no I/O
|
||||||
|
├── adapters/
|
||||||
|
│ ├── http/
|
||||||
|
│ │ ├── handler.go
|
||||||
|
│ │ └── handler_test.go # httptest-based, mock service
|
||||||
|
│ └── postgres/
|
||||||
|
│ ├── repo.go
|
||||||
|
│ └── repo_test.go # integration: testcontainers or real DB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test naming convention:**
|
||||||
|
```go
|
||||||
|
func TestRiskService_Get_ReturnsRisk(t *testing.T) {}
|
||||||
|
func TestRiskService_Get_NotFound_ReturnsError(t *testing.T) {}
|
||||||
|
func TestRiskService_Get_DBError_WrapsError(t *testing.T) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Table-driven tests are mandatory for functions with multiple cases:**
|
||||||
|
```go
|
||||||
|
func TestValidateInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", "ok", false},
|
||||||
|
{"empty", "", true},
|
||||||
|
{"too long", strings.Repeat("x", 300), true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateInput(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("got err=%v, wantErr=%v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pre-push: unit tests only (fast)
|
||||||
|
go test -race -count=1 -run "^TestUnit" ./...
|
||||||
|
|
||||||
|
# CI: all tests
|
||||||
|
go test -race -count=1 -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -func=coverage.out | grep total
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context Propagation
|
||||||
|
|
||||||
|
Every function that does I/O (DB, HTTP, file) **must** accept and pass `context.Context` as the first argument:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// REQUIRED
|
||||||
|
func (r *RiskRepo) Get(ctx context.Context, id uuid.UUID) (*Risk, error) {
|
||||||
|
return r.db.QueryRowContext(ctx, query, id).Scan(...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORBIDDEN — no context
|
||||||
|
func (r *RiskRepo) Get(id uuid.UUID) (*Risk, error) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
`noctx` linter enforces HTTP client context. Manual review required for DB calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls That Break CI
|
||||||
|
|
||||||
|
| Pitfall | Prevention |
|
||||||
|
|---------|------------|
|
||||||
|
| Unformatted code | `gofmt -w .` before commit |
|
||||||
|
| Unchecked error return from `rows.Close()` / `resp.Body.Close()` | `errcheck` + `bodyclose` linters |
|
||||||
|
| Goroutine leak (goroutine started but never stopped) | `-race` test flag |
|
||||||
|
| Shadowed `err` variable in nested scope | `govet -shadow` |
|
||||||
|
| HTTP response body not closed | `bodyclose` linter |
|
||||||
|
| `interface{}` instead of `any` (Go 1.18+) | `gocritic` |
|
||||||
|
| Missing context on DB/HTTP calls | `noctx` linter |
|
||||||
|
| Returning concrete type from constructor instead of interface | breaks testability |
|
||||||
157
.claude/AGENTS.python.md
Normal file
157
.claude/AGENTS.python.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# AGENTS.python.md — Python Agent Rules
|
||||||
|
|
||||||
|
Applies to: `backend-compliance/`, `ai-compliance-sdk/` (Python path), `compliance-tts-service/`, `document-crawler/`, `dsms-gateway/` (Python services)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||||
|
|
||||||
|
**BEFORE every `git push`, run ALL of the following from the service directory. A single failure blocks the push.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fast lint (Ruff — catches syntax errors, unused imports, style violations)
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# 2. Auto-fix safe issues, then re-check
|
||||||
|
ruff check --fix . && ruff check .
|
||||||
|
|
||||||
|
# 3. Type checking (mypy strict on new modules, standard on legacy)
|
||||||
|
mypy . --ignore-missing-imports --no-error-summary
|
||||||
|
|
||||||
|
# 4. Unit tests only (fast, no external deps)
|
||||||
|
pytest tests/unit/ -x -q --no-header
|
||||||
|
|
||||||
|
# 5. Verify the service starts (catches import errors, missing env vars with defaults)
|
||||||
|
python -c "import app" 2>/dev/null || python -c "import main" 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-liner pre-push gate (run from service root):**
|
||||||
|
```bash
|
||||||
|
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why each check matters
|
||||||
|
|
||||||
|
| Check | Catches | Time |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `ruff check` | Syntax errors, unused imports, undefined names | <2s |
|
||||||
|
| `mypy` | Type mismatches, wrong argument types | 5-15s |
|
||||||
|
| `pytest -x` | Logic errors, regressions | 10-60s |
|
||||||
|
| import check | Missing packages, circular imports | <1s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style (Ruff)
|
||||||
|
|
||||||
|
Config lives in `pyproject.toml`. Do **not** add per-file `# noqa` suppressions without a comment explaining why.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "TCH"]
|
||||||
|
ignore = ["E501"] # line length handled by formatter
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/*" = ["S101"] # assert is fine in tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blocked patterns:**
|
||||||
|
- `from module import *` — always name imports explicitly
|
||||||
|
- Bare `except:` — use `except Exception as e:` at minimum
|
||||||
|
- `print()` in production code — use `logger`
|
||||||
|
- Mutable default arguments: `def f(x=[])` → `def f(x=None)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Annotations
|
||||||
|
|
||||||
|
All new functions **must** have complete type annotations. Use `from __future__ import annotations` for forward references.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Required
|
||||||
|
async def get_tenant(tenant_id: str, db: AsyncSession) -> TenantModel | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
# Required for complex types
|
||||||
|
from typing import Sequence
|
||||||
|
def list_risks(filters: dict[str, str]) -> Sequence[RiskModel]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mypy rules:**
|
||||||
|
- `--disallow-untyped-defs` on new files
|
||||||
|
- `--strict` on new modules (not legacy)
|
||||||
|
- Never use `type: ignore` without a comment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FastAPI-Specific Rules
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Handlers stay thin — delegate to service layer
|
||||||
|
@router.get("/risks/{risk_id}", response_model=RiskResponse)
|
||||||
|
async def get_risk(risk_id: UUID, service: RiskService = Depends(get_risk_service)):
|
||||||
|
return await service.get(risk_id) # ≤5 lines per handler
|
||||||
|
|
||||||
|
# Always use response_model — never return raw dicts from endpoints
|
||||||
|
# Always validate input with Pydantic — no manual dict parsing
|
||||||
|
# Use HTTPException with specific status codes, never bare 500
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── unit/ # Pure logic tests, no DB/HTTP (run on every push)
|
||||||
|
├── integration/ # Requires running services (run in CI only)
|
||||||
|
└── contracts/ # OpenAPI snapshot tests (run on API changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unit test requirements:**
|
||||||
|
- Every new function → at least one happy-path test
|
||||||
|
- Every bug fix → regression test that would have caught it
|
||||||
|
- Mock all I/O: DB calls, HTTP calls, filesystem reads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests only (fast, for pre-push)
|
||||||
|
pytest tests/unit/ -x -q
|
||||||
|
|
||||||
|
# Run with coverage (for CI)
|
||||||
|
pytest tests/ --cov=. --cov-report=term-missing --cov-fail-under=70
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check new package license before adding
|
||||||
|
pip show <package> | grep -E "License|Home-page"
|
||||||
|
|
||||||
|
# After adding to requirements.txt — verify no GPL/AGPL
|
||||||
|
pip-licenses --fail-on="GPL;AGPL" 2>/dev/null || echo "Check licenses manually"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never add:**
|
||||||
|
- GPL/AGPL licensed packages
|
||||||
|
- Packages with known CVEs (`pip audit`)
|
||||||
|
- Packages that only exist for dev (`pytest`, `ruff`) to production requirements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls That Break CI
|
||||||
|
|
||||||
|
| Pitfall | Prevention |
|
||||||
|
|---------|------------|
|
||||||
|
| `const x = ...` inside dict literal (wrong language!) | Run ruff before push |
|
||||||
|
| Pydantic v1 syntax in v2 project | Use `model_config`, not `class Config` |
|
||||||
|
| Sync function called inside async without `run_in_executor` | mypy + async linter |
|
||||||
|
| Missing `await` on coroutine | mypy catches this |
|
||||||
|
| `datetime.utcnow()` (deprecated) | Use `datetime.now(timezone.utc)` |
|
||||||
|
| Bare `except:` swallowing errors silently | ruff B001/E722 catches this |
|
||||||
|
| Unused imports left in committed code | ruff F401 catches this |
|
||||||
186
.claude/AGENTS.typescript.md
Normal file
186
.claude/AGENTS.typescript.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# AGENTS.typescript.md — TypeScript/Next.js Agent Rules
|
||||||
|
|
||||||
|
Applies to: `pitch-deck/`, `admin-v2/` (Next.js apps in this repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NON-NEGOTIABLE: Pre-Push Checklist
|
||||||
|
|
||||||
|
**BEFORE every `git push`, run ALL of the following from the Next.js app directory. A single failure blocks the push.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Type check (catches the class of bug that broke ChatFAB.tsx — const inside object)
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# 2. Lint (ESLint with TypeScript-aware rules)
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# 3. Production build (THE most important check — passes lint/types but still fails build)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-liner pre-push gate:**
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why `npm run build` is mandatory:** Next.js performs additional checks during build (server component boundaries, missing env vars referenced in code, RSC/client component violations) that `tsc` and ESLint alone do not catch. The ChatFAB syntax error (`const` inside object literal) is exactly the kind of error caught only by build.
|
||||||
|
|
||||||
|
### Why each check matters
|
||||||
|
|
||||||
|
| Check | Catches | Time |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `tsc --noEmit` | Type errors, wrong prop types, missing members | 5-20s |
|
||||||
|
| `eslint` | React hooks rules, import order, unused vars | 5-15s |
|
||||||
|
| `next build` | Server/client boundary violations, missing deps, syntax errors in JSX, env var issues | 30-120s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Configuration
|
||||||
|
|
||||||
|
`tsconfig.json` must have strict mode enabled:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never use `// @ts-ignore` or `// @ts-expect-error` without a comment explaining why it's unavoidable.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESLint Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"plugin:@typescript-eslint/recommended-type-checked"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
"@typescript-eslint/await-thenable": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
"no-console": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`@typescript-eslint/no-floating-promises`** — catches `await`-less async calls that silently swallow errors.
|
||||||
|
**`react-hooks/exhaustive-deps`** — catches missing deps in `useEffect`/`useCallback` (source of stale closure bugs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js 15 Rules (App Router)
|
||||||
|
|
||||||
|
### Server vs Client boundary
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Server Component (default) — no 'use client' needed
|
||||||
|
// Can: fetch data, access DB, read env vars, import server-only packages
|
||||||
|
async function Page() {
|
||||||
|
const data = await fetchData() // direct async/await
|
||||||
|
return <ClientComponent data={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Component — must have 'use client' at top
|
||||||
|
'use client'
|
||||||
|
// Can: use hooks, handle events, access browser APIs
|
||||||
|
// Cannot: import server-only packages (nodemailer, fs, db pool)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common violation:** Importing `lib/email.ts` (which imports nodemailer) from a client component → use `lib/email-templates.ts` instead.
|
||||||
|
|
||||||
|
### Route Handler typing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Always type request and use NextResponse
|
||||||
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
return NextResponse.json({ data })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Server-only env vars: access directly
|
||||||
|
const secret = process.env.PITCH_ADMIN_SECRET // fine in server components
|
||||||
|
|
||||||
|
// Client env vars: must be prefixed NEXT_PUBLIC_
|
||||||
|
const url = process.env.NEXT_PUBLIC_API_URL // accessible in browser
|
||||||
|
|
||||||
|
// Never access server-only env vars in 'use client' components
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── (route-group)/
|
||||||
|
│ ├── page.tsx # Server Component — data fetching
|
||||||
|
│ └── _components/ # Colocated components for this route
|
||||||
|
│ ├── ClientThing.tsx # 'use client' when needed
|
||||||
|
│ └── ServerThing.tsx # Server by default
|
||||||
|
components/
|
||||||
|
│ └── ui/ # Shared presentational components
|
||||||
|
lib/
|
||||||
|
│ ├── server-only-module.ts # import 'server-only' at top
|
||||||
|
│ └── shared-module.ts # safe for both server and client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Push `'use client'` boundary as deep as possible (toward leaves)
|
||||||
|
- Never import server-only modules from client components
|
||||||
|
- Colocate `_components/` and `_hooks/` per route when they're route-specific
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Type check (fastest, run first)
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Unit tests (Vitest)
|
||||||
|
npx vitest run
|
||||||
|
|
||||||
|
# E2E tests (Playwright — CI only, requires running server)
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test every:**
|
||||||
|
- Custom hook (`usePresenterMode`, `useSlideNavigation`)
|
||||||
|
- Utility function (`lib/auth.ts` helpers, `lib/email-templates.ts`)
|
||||||
|
- API route handler (mock DB, assert response shape)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls That Break CI
|
||||||
|
|
||||||
|
| Pitfall | Prevention |
|
||||||
|
|---------|------------|
|
||||||
|
| `const x = ...` inside object literal | `tsc --noEmit` + `npm run build` |
|
||||||
|
| Server-only import in client component | `import 'server-only'` guard + ESLint |
|
||||||
|
| Missing `await` on async function call | `@typescript-eslint/no-floating-promises` |
|
||||||
|
| `useEffect` with missing dependency | `react-hooks/exhaustive-deps` error |
|
||||||
|
| `any` type hiding type errors | `@typescript-eslint/no-explicit-any` error |
|
||||||
|
| Unused variable left after refactor | `noUnusedLocals` in tsconfig |
|
||||||
|
| `process.env.SECRET` in client component | Next.js build error |
|
||||||
|
| Forgetting `export default` on page component | Next.js build error |
|
||||||
|
| Calling server action from server component | must use route handler instead |
|
||||||
|
| `jose` full import in Edge Runtime | Use specific subpath: `jose/jwt/verify` |
|
||||||
@@ -2,28 +2,72 @@
|
|||||||
|
|
||||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||||
|
|
||||||
### Zwei-Rechner-Setup
|
### Zwei-Rechner-Setup + Orca
|
||||||
|
|
||||||
| Geraet | Rolle | Aufgaben |
|
| Geraet | Rolle | Aufgaben |
|
||||||
|--------|-------|----------|
|
|--------|-------|----------|
|
||||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||||
|
| **Orca** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||||
|
|
||||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
||||||
|
|
||||||
### Entwicklungsworkflow
|
### Entwicklungsworkflow (CI/CD — Orca)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||||
# 2. Committen und pushen:
|
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||||
git push origin main && git push gitea main
|
git push origin main
|
||||||
|
|
||||||
# 3. Auf Mac Mini pullen und Container neu bauen:
|
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||||
|
# - Gitea Actions: Tests
|
||||||
|
# - Orca: Build → Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||||
|
**IMMER auf `main` pushen** — sowohl origin als auch gitea.
|
||||||
|
|
||||||
|
### TEMPORAER: Compliance-Repo Refactoring (Stand 2026-04-12)
|
||||||
|
|
||||||
|
**Das Compliance-Repo wird aktuell auf Production (gitea) refakturiert.**
|
||||||
|
|
||||||
|
- **Core + Lehrer:** Normal auf `main` pushen (origin + gitea) ✅
|
||||||
|
- **Compliance auf Mac Mini (origin):** Normal auf `main` pushen ✅
|
||||||
|
- **Compliance auf Production (gitea):** **NUR Feature Branches**, NICHT auf `main` pushen! ⚠️
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compliance-Repo — RICHTIG:
|
||||||
|
git push origin main # Mac Mini OK
|
||||||
|
git push gitea feature/mein-feature # Production: nur Feature Branch!
|
||||||
|
|
||||||
|
# Compliance-Repo — FALSCH (waehrend Refactoring):
|
||||||
|
# git push gitea main # NICHT MACHEN!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nach Abschluss des Refactorings:** Gesamten Compliance-Code einmalig von Production auf Mac Mini uebernehmen. User sagt Bescheid wann es soweit ist.
|
||||||
|
|
||||||
|
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||||
|
|
||||||
|
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||||
|
|
||||||
|
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||||
|
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||||
|
```bash
|
||||||
|
curl -sf https://api-dev.breakpilot.ai/health # Compliance Backend
|
||||||
|
curl -sf https://sdk-dev.breakpilot.ai/health # AI SDK
|
||||||
|
```
|
||||||
|
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||||
|
**"Deploy abgeschlossen! Du kannst jetzt testen."**
|
||||||
|
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||||
|
|
||||||
|
### Lokale Entwicklung (Mac Mini — optional, nur Dev/Tests)
|
||||||
|
|
||||||
|
```bash
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git pull --no-rebase origin main"
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache <service> && /usr/local/bin/docker compose up -d <service>"
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSH-Verbindung (fuer Docker/Tests)
|
### SSH-Verbindung (fuer lokale Docker/Tests)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||||
@@ -51,6 +95,14 @@ networks:
|
|||||||
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
name: breakpilot-network # Fixer Name, kein Auto-Prefix!
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Deployment-Modell
|
||||||
|
|
||||||
|
| Repo | Deployment | Trigger |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| **breakpilot-core** | Orca (automatisch) | Push auf gitea main |
|
||||||
|
| **breakpilot-compliance** | Orca (automatisch) | Push auf gitea main |
|
||||||
|
| **breakpilot-lehrer** | Mac Mini (lokal) | Manuell docker compose |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Haupt-URLs (via Nginx Reverse Proxy)
|
## Haupt-URLs (via Nginx Reverse Proxy)
|
||||||
@@ -161,7 +213,7 @@ networks:
|
|||||||
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# DB-Zugang
|
# DB-Zugang (lokal)
|
||||||
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,15 +237,45 @@ breakpilot-core/
|
|||||||
├── gitea/ # Gitea Config
|
├── gitea/ # Gitea Config
|
||||||
├── docs-src/ # MkDocs Quellen
|
├── docs-src/ # MkDocs Quellen
|
||||||
├── mkdocs.yml # MkDocs Config
|
├── mkdocs.yml # MkDocs Config
|
||||||
|
├── control-pipeline/ # RAG/Control Pipeline (Port 8098)
|
||||||
├── scripts/ # Helper Scripts
|
├── scripts/ # Helper Scripts
|
||||||
└── docker-compose.yml # Haupt-Compose (28+ Services)
|
└── docker-compose.yml # Haupt-Compose (28+ Services)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Control Pipeline (WICHTIG)
|
||||||
|
|
||||||
|
**Seit 2026-04-09 liegt die gesamte RAG/Control-Pipeline im Core-Repo** (`control-pipeline/`), NICHT mehr im Compliance-Repo. Alle Arbeiten an der Pipeline (Pass 0a/0b, BatchDedup, Control Generator, Enrichment) finden ausschliesslich hier statt.
|
||||||
|
|
||||||
|
- **Port:** 8098
|
||||||
|
- **Container:** bp-core-control-pipeline
|
||||||
|
- **DB:** Schreibt ins `compliance`-Schema der shared PostgreSQL
|
||||||
|
- **Das Compliance-Repo wird NICHT fuer Pipeline-Aenderungen benutzt**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container auf Mac Mini
|
||||||
|
ssh macmini "cd ~/Projekte/breakpilot-core && /usr/local/bin/docker compose build --no-cache control-pipeline && /usr/local/bin/docker compose up -d --no-deps control-pipeline"
|
||||||
|
|
||||||
|
# Health
|
||||||
|
ssh macmini "/usr/local/bin/docker exec bp-core-control-pipeline curl -sf http://127.0.0.1:8098/health"
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
ssh macmini "/usr/local/bin/docker logs -f bp-core-control-pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Haeufige Befehle
|
## Haeufige Befehle
|
||||||
|
|
||||||
### Docker
|
### Deployment (CI/CD — Standardweg)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Committen und pushen → Orca deployt automatisch:
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokale Docker-Befehle (Mac Mini — nur Dev/Tests)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Alle Core-Services starten
|
# Alle Core-Services starten
|
||||||
@@ -211,35 +293,90 @@ ssh macmini "/usr/local/bin/docker ps --filter name=bp-core"
|
|||||||
|
|
||||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||||
|
|
||||||
### Alle 3 Projekte starten
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Core (MUSS zuerst!)
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && /usr/local/bin/docker compose up -d"
|
|
||||||
# Warten auf Health:
|
|
||||||
ssh macmini "curl -sf http://127.0.0.1:8099/health"
|
|
||||||
|
|
||||||
# 2. Lehrer
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && /usr/local/bin/docker compose up -d"
|
|
||||||
|
|
||||||
# 3. Compliance
|
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-compliance && /usr/local/bin/docker compose up -d"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git push all main"
|
git push origin main
|
||||||
|
|
||||||
# Remotes:
|
# Remotes:
|
||||||
# origin: lokale Gitea (macmini:3003)
|
|
||||||
# gitea: gitea.meghsakha.com
|
|
||||||
# all: beide gleichzeitig
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pre-Push Checks (PFLICHT — VOR JEDEM PUSH)
|
||||||
|
|
||||||
|
> Full detail: `.claude/rules/pre-push-checks.md` | Stack rules: `AGENTS.python.md`, `AGENTS.go.md`, `AGENTS.typescript.md`
|
||||||
|
|
||||||
|
**NIEMALS pushen ohne diese Checks. CI-Failures blockieren das gesamte Deploy.**
|
||||||
|
|
||||||
|
### Python (backend-core, rag-service, embedding-service, control-pipeline)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
### Go (consent-service, billing-service)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/Next.js (pitch-deck, admin-v2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pitch-deck # or admin-v2
|
||||||
|
npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
> `npm run build` ist PFLICHT — `tsc` allein reicht nicht. Syntax-Fehler wie `const` inside object literal werden nur vom Build gefangen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code-Qualitaet Guardrails (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
> Vollstaendige Details: `.claude/rules/architecture.md`
|
||||||
|
> Ausnahmen: `.claude/rules/loc-exceptions.txt`
|
||||||
|
|
||||||
|
### File Size Budget
|
||||||
|
|
||||||
|
- **Hard Cap: 500 LOC** pro Datei
|
||||||
|
- Wenn eine Aenderung eine Datei ueber 500 LOC bringen wuerde: **erst splitten, dann aendern**
|
||||||
|
- Ausnahmen nur mit Begruendung in `loc-exceptions.txt` + `[guardrail-change]` Commit-Marker
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
|
||||||
|
- **Go:** Handler ≤40 LOC → Service-Layer → Repository-Pattern
|
||||||
|
- **Python:** Routes duenn → Business Logic in Services → Persistenz in Repositories
|
||||||
|
- **TypeScript/Next.js:** page.tsx duenn → _components/, _hooks/ auslagern
|
||||||
|
|
||||||
|
### FINGER WEG (laufende RAG Pipeline)
|
||||||
|
|
||||||
|
Diese Verzeichnisse duerfen NICHT refaktoriert werden:
|
||||||
|
- `control-pipeline/` — RAG/Control-Extraction Pipeline
|
||||||
|
- `rag-service/` — Semantische Suche
|
||||||
|
- `embedding-service/` — Text-Embeddings
|
||||||
|
- `voice-service/bqas/` — RAG Quality Assessment
|
||||||
|
|
||||||
|
### LOC-Check ausfuehren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
||||||
|
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commit-Marker
|
||||||
|
|
||||||
|
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
||||||
|
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
||||||
|
- `[interface-change]` — Public API Contracts geaendert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Kernprinzipien
|
## Kernprinzipien
|
||||||
|
|
||||||
### 1. Open Source Policy
|
### 1. Open Source Policy
|
||||||
|
|||||||
79
.claude/rules/architecture.md
Normal file
79
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Architecture Rule — BreakPilot Core
|
||||||
|
|
||||||
|
## File Size Budget
|
||||||
|
|
||||||
|
Hard default: **500 LOC max** per file.
|
||||||
|
Soft targets:
|
||||||
|
- Handler/Router/Service: 300-400 LOC
|
||||||
|
- Models/Schemas/Types: 200-300 LOC
|
||||||
|
- Utilities: 100-200 LOC
|
||||||
|
|
||||||
|
Ausnahmen nur in `.claude/rules/loc-exceptions.txt` mit Begruendung.
|
||||||
|
|
||||||
|
## Split-Trigger
|
||||||
|
|
||||||
|
Sofort splitten wenn:
|
||||||
|
- Datei ueberschreitet 500 LOC
|
||||||
|
- Datei wuerde nach Aenderung 500 LOC ueberschreiten
|
||||||
|
- Datei mischt Transport + Business Logic + Persistence
|
||||||
|
- Datei enthaelt mehrere unabhaengig testbare Verantwortlichkeiten
|
||||||
|
|
||||||
|
## Go (consent-service, billing-service)
|
||||||
|
|
||||||
|
- Handler duenn halten (≤40 LOC pro Handler-Funktion)
|
||||||
|
- Business Logic in Services/Use-Cases
|
||||||
|
- Transport/Request-Decoding getrennt von Domain-Logik
|
||||||
|
- Dateien im gleichen Package teilen Typen automatisch — kein Re-Export noetig
|
||||||
|
- Models nach Domain splitten (user, consent, school, document, etc.)
|
||||||
|
|
||||||
|
## Python (backend-core, night-scheduler)
|
||||||
|
|
||||||
|
- Routes duenn halten — Business Logic in Services
|
||||||
|
- Persistenz in Repositories/Data-Access-Module
|
||||||
|
- Pydantic Schemas nach Domain splitten
|
||||||
|
- Zirkulaere Imports vermeiden
|
||||||
|
|
||||||
|
## TypeScript / Next.js (admin-core, pitch-deck)
|
||||||
|
|
||||||
|
- page.tsx duenn halten — Server Actions, Queries, Components auslagern
|
||||||
|
- _components/ + _hooks/ Konvention fuer Route-lokale Extracts
|
||||||
|
- .ts Dateien mit JSX muessen .tsx heissen (Turbopack!)
|
||||||
|
- Monolithische types.ts frueh splitten
|
||||||
|
- types.ts + types/ Shadowing vermeiden
|
||||||
|
|
||||||
|
## Entscheidungsreihenfolge
|
||||||
|
|
||||||
|
1. Bestehendes kleines kohaeesives Modul wiederverwenden
|
||||||
|
2. Neues Modul in der Naehe erstellen
|
||||||
|
3. Ueberfuellte Datei splitten, neues Verhalten in richtiges Split-Modul
|
||||||
|
4. Nur als letzter Ausweg: Grosse bestehende Datei erweitern
|
||||||
|
|
||||||
|
## FINGER WEG (laufende RAG Pipeline)
|
||||||
|
|
||||||
|
Diese Verzeichnisse duerfen NICHT refaktoriert werden:
|
||||||
|
- `control-pipeline/` — RAG/Control-Extraction Pipeline
|
||||||
|
- `rag-service/` — Semantische Suche
|
||||||
|
- `embedding-service/` — Text-Embeddings
|
||||||
|
- `voice-service/bqas/` — RAG Quality Assessment
|
||||||
|
|
||||||
|
## Workflow (bei jeder Aenderung)
|
||||||
|
|
||||||
|
1. Datei lesen + LOC pruefen
|
||||||
|
2. Wenn nahe am Budget → erst splitten
|
||||||
|
3. Minimale kohaerente Aenderung
|
||||||
|
4. Verifikation (Tests + Lint)
|
||||||
|
5. Zusammenfassung: Was geaendert, was verifiziert, Restrisiko
|
||||||
|
|
||||||
|
## LOC-Check ausfuehren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/check-loc.sh --changed # nur geaenderte Dateien
|
||||||
|
bash scripts/check-loc.sh --all # alle Dateien (zeigt alle Violations)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit-Marker
|
||||||
|
|
||||||
|
- `[split-required]` — Aenderung beginnt mit Datei-Split
|
||||||
|
- `[guardrail-change]` — Aenderungen an .claude/**, scripts/check-loc.sh
|
||||||
|
- `[interface-change]` — Public API Contracts geaendert
|
||||||
|
- `[migration-approved]` — Schema-/Migrations-Aenderungen
|
||||||
35
.claude/rules/loc-exceptions.txt
Normal file
35
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# LOC Exceptions — BreakPilot Core
|
||||||
|
# Format: <glob> | owner=<person> | reason=<why> | review=<date>
|
||||||
|
#
|
||||||
|
# Jede Ausnahme braucht Begruendung und Review-Datum.
|
||||||
|
# Temporaere Ausnahmen muessen mit [guardrail-change] Commit-Marker versehen werden.
|
||||||
|
|
||||||
|
# Generated / Build Artifacts
|
||||||
|
**/node_modules/** | owner=infra | reason=npm packages | review=permanent
|
||||||
|
**/.next/** | owner=infra | reason=Next.js build output | review=permanent
|
||||||
|
**/__pycache__/** | owner=infra | reason=Python bytecode | review=permanent
|
||||||
|
**/venv/** | owner=infra | reason=Python virtualenv | review=permanent
|
||||||
|
|
||||||
|
# Test-Dateien (duerfen groesser sein fuer Table-Driven Tests)
|
||||||
|
**/*test*.py | owner=all | reason=Tests mit Table-Driven Patterns duerfen groesser sein | review=permanent
|
||||||
|
**/*test*.go | owner=all | reason=Go Tests mit Table-Driven Patterns | review=permanent
|
||||||
|
**/*test*.ts | owner=all | reason=TypeScript Tests | review=permanent
|
||||||
|
**/tests/** | owner=all | reason=Test-Verzeichnisse | review=permanent
|
||||||
|
|
||||||
|
# FINGER WEG — Laufende RAG Pipeline (NICHT anfassen!)
|
||||||
|
control-pipeline/** | owner=pipeline | reason=Laufende RAG Pipeline, parallele Jobs aktiv | review=permanent
|
||||||
|
rag-service/** | owner=pipeline | reason=Semantische Suche, produktiv | review=permanent
|
||||||
|
embedding-service/** | owner=pipeline | reason=Text-Embeddings, produktiv | review=permanent
|
||||||
|
voice-service/bqas/** | owner=pipeline | reason=RAG Quality Assessment, produktiv | review=permanent
|
||||||
|
|
||||||
|
# Seed/Helper Scripts (keine Service-Logik)
|
||||||
|
scripts/seed-demo-and-screenshot.py | owner=infra | reason=Einmaliges Seed-Script, kein Service-Code | review=permanent
|
||||||
|
pitch-deck/scripts/import-finanzplan.py | owner=pitch-deck | reason=583 LOC, einmaliges Excel-Import-Script (9 Sheet-Importer), hardcodierte Row/Col-Mappings fuer eine Finanzplan-.xlsm-Datei, keine wiederverwendbare Logik | review=2027-01
|
||||||
|
|
||||||
|
# PDF Templates (reine statische HTML/CSS Strings, keine Logik)
|
||||||
|
backend-core/services/pdf_templates.py | owner=all | reason=519 LOC, rein statische Jinja2-HTML-Templates + CSS, keine Logik | review=2026-07
|
||||||
|
|
||||||
|
# Pitch Deck — pure data files (static text, translations, no logic)
|
||||||
|
pitch-deck/lib/presenter/presenter-faq.ts | owner=pitch-deck | reason=973 LOC, pure static FAQ array (questions/answers/keywords), no logic | review=2027-01
|
||||||
|
pitch-deck/lib/presenter/presenter-script.ts | owner=pitch-deck | reason=608 LOC, pure static presenter script data + 3 trivial lookup functions | review=2027-01
|
||||||
|
pitch-deck/lib/i18n.ts | owner=pitch-deck | reason=620 LOC, pure DE/EN translation dictionaries + 3 small format helpers | review=2027-01
|
||||||
74
.claude/rules/pre-push-checks.md
Normal file
74
.claude/rules/pre-push-checks.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Pre-Push Checks (MANDATORY)
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
**NEVER push to any remote without first running and confirming ALL checks pass for every changed language stack.**
|
||||||
|
|
||||||
|
This rule exists because CI failures break the deploy pipeline for everyone and waste ~5 minutes per failed build. A 60-second local check prevents that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference by Stack
|
||||||
|
|
||||||
|
### Python (backend-compliance, ai-compliance-sdk, compliance-tts-service)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
ruff check . && mypy . --ignore-missing-imports --no-error-summary && pytest tests/ -x -q --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks on: syntax errors, type errors, failing tests.
|
||||||
|
|
||||||
|
### Go (ai-compliance-sdk Go path)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <service-dir>
|
||||||
|
gofmt -l . | grep -q . && exit 1; go vet ./... && golangci-lint run --timeout=5m && go test -race ./... && go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks on: formatting, vet findings, lint violations, test failures, build errors.
|
||||||
|
|
||||||
|
### TypeScript/Next.js (admin-compliance, developer-portal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <nextjs-app-dir>
|
||||||
|
npx tsc --noEmit && npm run lint && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Blocks on: type errors, lint violations, **build failures**.
|
||||||
|
|
||||||
|
> `npm run build` is mandatory — `tsc` passes but `next build` fails more often than you'd expect (server/client boundary violations, env var issues, JSX syntax errors).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Claude Must Do Before Every Push
|
||||||
|
|
||||||
|
1. Identify which services/apps were changed in this task
|
||||||
|
2. Run the appropriate gate command(s) from the table above
|
||||||
|
3. If any check fails: fix it, re-run, confirm green
|
||||||
|
4. Only then run `git push origin main`
|
||||||
|
|
||||||
|
**No exceptions.** A push that skips pre-push checks and breaks CI is worse than a delayed push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI vs Local Checks
|
||||||
|
|
||||||
|
| Stage | Where | What |
|
||||||
|
|-------|-------|------|
|
||||||
|
| Pre-push (local) | Claude runs | Lint + type check + unit tests + build |
|
||||||
|
| CI (Gitea Actions) | Automatic on push | Same + integration tests + contract tests |
|
||||||
|
| Deploy (Orca) | Automatic after CI | Docker build + health check |
|
||||||
|
|
||||||
|
Local checks catch 90% of CI failures in seconds. CI is the safety net, not the first line of defense.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failures That Were Caused by Skipping Pre-Push Checks
|
||||||
|
|
||||||
|
- `ChatFAB.tsx`: `const textLang` inside fetch object literal — caught by `tsc --noEmit` and `npm run build`
|
||||||
|
- `nodemailer` webpack error: server-only import in client component — caught by `npm run build`
|
||||||
|
- `jose` Edge Runtime error: full package import — caught by `npm run build`
|
||||||
|
- `main.py` `<en>` tags spoken: missing `import re` — caught by `python -c "import main"`
|
||||||
|
|
||||||
|
These all caused a broken deploy. Each would have been caught in <60 seconds locally.
|
||||||
65
.env.coolify.example
Normal file
65
.env.coolify.example
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# =========================================================
|
||||||
|
# BreakPilot Core — Coolify Environment Variables
|
||||||
|
# =========================================================
|
||||||
|
# Copy these into Coolify's environment variable UI
|
||||||
|
# for the breakpilot-core Docker Compose resource.
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
# --- External PostgreSQL (Coolify-managed) ---
|
||||||
|
POSTGRES_HOST=<coolify-postgres-hostname>
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=breakpilot
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||||
|
POSTGRES_DB=breakpilot_db
|
||||||
|
|
||||||
|
# --- Security ---
|
||||||
|
JWT_SECRET=CHANGE_ME_RANDOM_64_CHARS
|
||||||
|
JWT_REFRESH_SECRET=CHANGE_ME_ANOTHER_RANDOM_64_CHARS
|
||||||
|
INTERNAL_API_KEY=CHANGE_ME_INTERNAL_KEY
|
||||||
|
|
||||||
|
# --- External S3 Storage ---
|
||||||
|
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||||
|
S3_ACCESS_KEY=CHANGE_ME_S3_ACCESS_KEY
|
||||||
|
S3_SECRET_KEY=CHANGE_ME_S3_SECRET_KEY
|
||||||
|
S3_BUCKET=breakpilot-rag
|
||||||
|
S3_SECURE=true
|
||||||
|
|
||||||
|
# --- External Qdrant (Coolify-managed) ---
|
||||||
|
QDRANT_URL=http://<coolify-qdrant-hostname>:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
|
||||||
|
# --- SMTP (Real mail server) ---
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=noreply@breakpilot.ai
|
||||||
|
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||||
|
SMTP_FROM_NAME=BreakPilot
|
||||||
|
SMTP_FROM_ADDR=noreply@breakpilot.ai
|
||||||
|
|
||||||
|
# --- Session ---
|
||||||
|
SESSION_TTL_HOURS=24
|
||||||
|
|
||||||
|
# --- Frontend URLs (build args) ---
|
||||||
|
NEXT_PUBLIC_CORE_API_URL=https://api-core.breakpilot.ai
|
||||||
|
FRONTEND_URL=https://www.breakpilot.ai
|
||||||
|
|
||||||
|
# --- Stripe (Billing) ---
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
|
BILLING_SUCCESS_URL=https://www.breakpilot.ai/billing/success
|
||||||
|
BILLING_CANCEL_URL=https://www.breakpilot.ai/billing/cancel
|
||||||
|
TRIAL_PERIOD_DAYS=14
|
||||||
|
|
||||||
|
# --- Embedding Service ---
|
||||||
|
EMBEDDING_BACKEND=local
|
||||||
|
LOCAL_EMBEDDING_MODEL=BAAI/bge-m3
|
||||||
|
LOCAL_RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||||
|
PDF_EXTRACTION_BACKEND=pymupdf
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
COHERE_API_KEY=
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- Ollama (optional, for RAG embeddings) ---
|
||||||
|
OLLAMA_URL=
|
||||||
|
OLLAMA_EMBED_MODEL=bge-m3
|
||||||
@@ -46,11 +46,6 @@ ERPNEXT_DB_ROOT_PASSWORD=erpnext_root
|
|||||||
ERPNEXT_DB_PASSWORD=erpnext_secret
|
ERPNEXT_DB_PASSWORD=erpnext_secret
|
||||||
ERPNEXT_ADMIN_PASSWORD=admin
|
ERPNEXT_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
# Woodpecker CI
|
|
||||||
WOODPECKER_HOST=http://macmini:8090
|
|
||||||
WOODPECKER_ADMIN=pilotadmin
|
|
||||||
WOODPECKER_AGENT_SECRET=woodpecker-secret
|
|
||||||
|
|
||||||
# Gitea Runner
|
# Gitea Runner
|
||||||
GITEA_RUNNER_TOKEN=
|
GITEA_RUNNER_TOKEN=
|
||||||
|
|
||||||
|
|||||||
66
.gitea/workflows/build-pitch-deck.yml
Normal file
66
.gitea/workflows/build-pitch-deck.yml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Build + push pitch-deck Docker image to registry.meghsakha.com
|
||||||
|
# and trigger orca redeploy on every push to main that touches pitch-deck/.
|
||||||
|
#
|
||||||
|
# Requires Gitea Actions secret: ORCA_WEBHOOK_SECRET
|
||||||
|
# (must match the `secret` field in ~/.orca/webhooks.json on the orca master)
|
||||||
|
|
||||||
|
name: Build pitch-deck
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'pitch-deck/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push-deploy:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:27-cli
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git openssl curl
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
cd pitch-deck
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker build \
|
||||||
|
--build-arg GIT_SHA=${SHORT_SHA} \
|
||||||
|
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
|
||||||
|
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push to registry
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
|
||||||
|
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
|
||||||
|
echo "Pushed :latest + :${SHORT_SHA}"
|
||||||
|
|
||||||
|
- name: Trigger orca redeploy
|
||||||
|
env:
|
||||||
|
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||||
|
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
||||||
|
run: |
|
||||||
|
SHA=$(git rev-parse HEAD)
|
||||||
|
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: pitch-deck image build\"}}"
|
||||||
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
||||||
|
curl -sSf -k \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-GitHub-Event: push" \
|
||||||
|
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"$ORCA_WEBHOOK_URL" \
|
||||||
|
|| { echo "Orca redeploy failed"; exit 1; }
|
||||||
|
echo "Orca redeploy triggered"
|
||||||
@@ -138,3 +138,8 @@ jobs:
|
|||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||||
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-asyncio
|
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-asyncio
|
||||||
python -m pytest tests/bqas/ -v --tb=short || true
|
python -m pytest tests/bqas/ -v --tb=short || true
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Deploys now handled by per-service workflows (e.g. build-pitch-deck.yml)
|
||||||
|
# which trigger orca webhooks directly after building + pushing the image.
|
||||||
|
# ========================================
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
|||||||
secrets/
|
secrets/
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
# Woodpecker CI Main Pipeline
|
|
||||||
# BreakPilot Core - CI/CD Pipeline
|
|
||||||
#
|
|
||||||
# Plattform: ARM64 (Apple Silicon Mac Mini)
|
|
||||||
#
|
|
||||||
# Services:
|
|
||||||
# Go: consent-service
|
|
||||||
# Python: backend-core, voice-service (+ BQAS), embedding-service, night-scheduler
|
|
||||||
# Node.js: admin-core
|
|
||||||
#
|
|
||||||
# Strategie:
|
|
||||||
# - Lint bei PRs
|
|
||||||
# - Tests laufen bei JEDEM Push/PR
|
|
||||||
# - Test-Ergebnisse werden an Dashboard gesendet
|
|
||||||
# - Builds/Scans laufen nur bei Tags oder manuell
|
|
||||||
# - Deployment nur manuell (Sicherheit)
|
|
||||||
|
|
||||||
when:
|
|
||||||
- event: [push, pull_request, manual, tag]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
clone:
|
|
||||||
git:
|
|
||||||
image: woodpeckerci/plugin-git
|
|
||||||
settings:
|
|
||||||
depth: 1
|
|
||||||
extra_hosts:
|
|
||||||
- macmini:192.168.178.100
|
|
||||||
|
|
||||||
variables:
|
|
||||||
- &golang_image golang:1.23-alpine
|
|
||||||
- &python_image python:3.12-slim
|
|
||||||
- &nodejs_image node:20-alpine
|
|
||||||
- &docker_image docker:27-cli
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# ========================================
|
|
||||||
# STAGE 1: Lint (nur bei PRs)
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
go-lint:
|
|
||||||
image: golangci/golangci-lint:v1.55-alpine
|
|
||||||
commands:
|
|
||||||
- cd consent-service && golangci-lint run --timeout 5m ./...
|
|
||||||
when:
|
|
||||||
event: pull_request
|
|
||||||
|
|
||||||
python-lint:
|
|
||||||
image: *python_image
|
|
||||||
commands:
|
|
||||||
- pip install --quiet ruff
|
|
||||||
- |
|
|
||||||
for svc in backend-core voice-service night-scheduler embedding-service; do
|
|
||||||
if [ -d "$svc" ]; then
|
|
||||||
echo "=== Linting $svc ==="
|
|
||||||
ruff check "$svc/" --output-format=github || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
when:
|
|
||||||
event: pull_request
|
|
||||||
|
|
||||||
nodejs-lint:
|
|
||||||
image: *nodejs_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d "admin-core" ]; then
|
|
||||||
cd admin-core
|
|
||||||
npm ci --silent 2>/dev/null || npm install --silent
|
|
||||||
npx next lint || true
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
event: pull_request
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# STAGE 2: Unit Tests mit JSON-Ausgabe
|
|
||||||
# Ergebnisse werden im Workspace gespeichert (.ci-results/)
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
test-go-consent:
|
|
||||||
image: *golang_image
|
|
||||||
environment:
|
|
||||||
CGO_ENABLED: "0"
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set -euo pipefail
|
|
||||||
apk add --no-cache jq bash
|
|
||||||
mkdir -p .ci-results
|
|
||||||
|
|
||||||
if [ ! -d "consent-service" ]; then
|
|
||||||
echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json
|
|
||||||
echo "WARNUNG: consent-service Verzeichnis nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd consent-service
|
|
||||||
set +e
|
|
||||||
go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json
|
|
||||||
TEST_EXIT=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
JSON_FILE="../.ci-results/test-consent.json"
|
|
||||||
if grep -q '^{' "$JSON_FILE" 2>/dev/null; then
|
|
||||||
TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length')
|
|
||||||
PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length')
|
|
||||||
FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length')
|
|
||||||
SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length')
|
|
||||||
else
|
|
||||||
echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)"
|
|
||||||
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0")
|
|
||||||
[ -z "$COVERAGE" ] && COVERAGE=0
|
|
||||||
|
|
||||||
echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json
|
|
||||||
cat ../.ci-results/results-consent.json
|
|
||||||
|
|
||||||
# Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter
|
|
||||||
if [ "$FAILED" -gt "0" ]; then
|
|
||||||
echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben"
|
|
||||||
fi
|
|
||||||
|
|
||||||
test-python-voice:
|
|
||||||
image: *python_image
|
|
||||||
environment:
|
|
||||||
CI: "true"
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set -uo pipefail
|
|
||||||
mkdir -p .ci-results
|
|
||||||
|
|
||||||
if [ ! -d "voice-service" ]; then
|
|
||||||
echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json
|
|
||||||
echo "WARNUNG: voice-service Verzeichnis nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd voice-service
|
|
||||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
||||||
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report
|
|
||||||
|
|
||||||
set +e
|
|
||||||
python -m pytest tests/ -v --tb=short --ignore=tests/bqas --json-report --json-report-file=../.ci-results/test-voice.json
|
|
||||||
TEST_EXIT=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -f ../.ci-results/test-voice.json ]; then
|
|
||||||
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
|
|
||||||
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
|
|
||||||
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
|
|
||||||
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
|
|
||||||
else
|
|
||||||
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json
|
|
||||||
cat ../.ci-results/results-voice.json
|
|
||||||
|
|
||||||
if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi
|
|
||||||
|
|
||||||
test-bqas-golden:
|
|
||||||
image: *python_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set -uo pipefail
|
|
||||||
mkdir -p .ci-results
|
|
||||||
|
|
||||||
if [ ! -d "voice-service/tests/bqas" ]; then
|
|
||||||
echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json
|
|
||||||
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd voice-service
|
|
||||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
||||||
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report pytest-asyncio
|
|
||||||
|
|
||||||
set +e
|
|
||||||
python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json
|
|
||||||
TEST_EXIT=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -f ../.ci-results/test-bqas-golden.json ]; then
|
|
||||||
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
|
|
||||||
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
|
|
||||||
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
|
|
||||||
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
|
|
||||||
else
|
|
||||||
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json
|
|
||||||
cat ../.ci-results/results-bqas-golden.json
|
|
||||||
|
|
||||||
# BQAS tests may skip if Ollama not available - don't fail pipeline
|
|
||||||
if [ "$FAILED" -gt "0" ]; then exit 1; fi
|
|
||||||
|
|
||||||
test-bqas-rag:
|
|
||||||
image: *python_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set -uo pipefail
|
|
||||||
mkdir -p .ci-results
|
|
||||||
|
|
||||||
if [ ! -d "voice-service/tests/bqas" ]; then
|
|
||||||
echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json
|
|
||||||
echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd voice-service
|
|
||||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
|
||||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
|
||||||
pip install --quiet --no-cache-dir fastapi uvicorn pydantic pytest pytest-json-report pytest-asyncio
|
|
||||||
|
|
||||||
set +e
|
|
||||||
python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json
|
|
||||||
TEST_EXIT=$?
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -f ../.ci-results/test-bqas-rag.json ]; then
|
|
||||||
TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0")
|
|
||||||
PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0")
|
|
||||||
FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0")
|
|
||||||
SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0")
|
|
||||||
else
|
|
||||||
TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json
|
|
||||||
cat ../.ci-results/results-bqas-rag.json
|
|
||||||
|
|
||||||
# BQAS tests may skip if Ollama not available - don't fail pipeline
|
|
||||||
if [ "$FAILED" -gt "0" ]; then exit 1; fi
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# STAGE 3: Test-Ergebnisse an Dashboard senden
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
report-test-results:
|
|
||||||
image: curlimages/curl:8.10.1
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set -uo pipefail
|
|
||||||
echo "=== Sende Test-Ergebnisse an Dashboard ==="
|
|
||||||
echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}"
|
|
||||||
ls -la .ci-results/ || echo "Verzeichnis nicht gefunden"
|
|
||||||
|
|
||||||
PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}"
|
|
||||||
|
|
||||||
for f in .ci-results/results-*.json; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
echo "Sending: $f"
|
|
||||||
curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"pipeline_id\": \"${CI_PIPELINE_NUMBER}\",
|
|
||||||
\"commit\": \"${CI_COMMIT_SHA}\",
|
|
||||||
\"branch\": \"${CI_COMMIT_BRANCH}\",
|
|
||||||
\"repo\": \"breakpilot-core\",
|
|
||||||
\"status\": \"${PIPELINE_STATUS}\",
|
|
||||||
\"test_results\": $(cat "$f")
|
|
||||||
}" || echo "WARNUNG: Konnte $f nicht senden"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "=== Test-Ergebnisse gesendet ==="
|
|
||||||
when:
|
|
||||||
status: [success, failure]
|
|
||||||
depends_on:
|
|
||||||
- test-go-consent
|
|
||||||
- test-python-voice
|
|
||||||
- test-bqas-golden
|
|
||||||
- test-bqas-rag
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# STAGE 4: Build & Security (nur Tags/manuell)
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
build-consent-service:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d ./consent-service ]; then
|
|
||||||
docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service
|
|
||||||
docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest
|
|
||||||
echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}"
|
|
||||||
else
|
|
||||||
echo "consent-service Verzeichnis nicht gefunden - ueberspringe"
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
build-backend-core:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d ./backend-core ]; then
|
|
||||||
docker build -t breakpilot/backend-core:${CI_COMMIT_SHA:0:8} ./backend-core
|
|
||||||
docker tag breakpilot/backend-core:${CI_COMMIT_SHA:0:8} breakpilot/backend-core:latest
|
|
||||||
echo "Built breakpilot/backend-core:${CI_COMMIT_SHA:0:8}"
|
|
||||||
else
|
|
||||||
echo "backend-core Verzeichnis nicht gefunden - ueberspringe"
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
build-admin-core:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d ./admin-core ]; then
|
|
||||||
docker build -t breakpilot/admin-core:${CI_COMMIT_SHA:0:8} ./admin-core
|
|
||||||
docker tag breakpilot/admin-core:${CI_COMMIT_SHA:0:8} breakpilot/admin-core:latest
|
|
||||||
echo "Built breakpilot/admin-core:${CI_COMMIT_SHA:0:8}"
|
|
||||||
else
|
|
||||||
echo "admin-core Verzeichnis nicht gefunden - ueberspringe"
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
build-voice-service:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d ./voice-service ]; then
|
|
||||||
docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service
|
|
||||||
docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest
|
|
||||||
echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}"
|
|
||||||
else
|
|
||||||
echo "voice-service Verzeichnis nicht gefunden - ueberspringe"
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
build-embedding-service:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d ./embedding-service ]; then
|
|
||||||
docker build -t breakpilot/embedding-service:${CI_COMMIT_SHA:0:8} ./embedding-service
|
|
||||||
docker tag breakpilot/embedding-service:${CI_COMMIT_SHA:0:8} breakpilot/embedding-service:latest
|
|
||||||
echo "Built breakpilot/embedding-service:${CI_COMMIT_SHA:0:8}"
|
|
||||||
else
|
|
||||||
echo "embedding-service Verzeichnis nicht gefunden - ueberspringe"
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
build-night-scheduler:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -d ./night-scheduler ]; then
|
|
||||||
docker build -t breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8} ./night-scheduler
|
|
||||||
docker tag breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8} breakpilot/night-scheduler:latest
|
|
||||||
echo "Built breakpilot/night-scheduler:${CI_COMMIT_SHA:0:8}"
|
|
||||||
else
|
|
||||||
echo "night-scheduler Verzeichnis nicht gefunden - ueberspringe"
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
generate-sbom:
|
|
||||||
image: *golang_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
echo "Installing syft for ARM64..."
|
|
||||||
wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
|
||||||
for svc in consent-service backend-core voice-service embedding-service night-scheduler; do
|
|
||||||
if [ -d "./$svc" ]; then
|
|
||||||
syft dir:./$svc -o cyclonedx-json > sbom-$svc.json
|
|
||||||
echo "SBOM generated for $svc"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
vulnerability-scan:
|
|
||||||
image: *golang_image
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
echo "Installing grype for ARM64..."
|
|
||||||
wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
|
||||||
for f in sbom-*.json; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
echo "=== Scanning $f ==="
|
|
||||||
grype sbom:"$f" -o table --fail-on critical || true
|
|
||||||
done
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: manual
|
|
||||||
depends_on:
|
|
||||||
- generate-sbom
|
|
||||||
|
|
||||||
# ========================================
|
|
||||||
# STAGE 5: Deploy (nur manuell)
|
|
||||||
# ========================================
|
|
||||||
|
|
||||||
deploy-production:
|
|
||||||
image: *docker_image
|
|
||||||
commands:
|
|
||||||
- echo "Deploying breakpilot-core to production..."
|
|
||||||
- docker compose -f docker-compose.yml pull || true
|
|
||||||
- docker compose -f docker-compose.yml up -d --remove-orphans || true
|
|
||||||
when:
|
|
||||||
event: manual
|
|
||||||
depends_on:
|
|
||||||
- build-consent-service
|
|
||||||
- build-backend-core
|
|
||||||
- build-admin-core
|
|
||||||
- build-voice-service
|
|
||||||
- build-embedding-service
|
|
||||||
- build-night-scheduler
|
|
||||||
@@ -18,6 +18,9 @@ ARG NEXT_PUBLIC_API_URL
|
|||||||
# Set environment variables for build
|
# Set environment variables for build
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
|
# Ensure public directory exists
|
||||||
|
RUN mkdir -p public
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -30,8 +33,8 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup -S -g 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||||
|
|
||||||
# Copy built assets
|
# Copy built assets
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|||||||
@@ -1,912 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alerts Monitoring Admin Page (migrated from website/admin/alerts)
|
|
||||||
*
|
|
||||||
* Google Alerts & Feed-Ueberwachung Dashboard
|
|
||||||
* Provides inbox management, topic configuration, rule builder, and relevance profiles
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface AlertItem {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
snippet: string
|
|
||||||
topic_name: string
|
|
||||||
relevance_score: number | null
|
|
||||||
relevance_decision: string | null
|
|
||||||
status: string
|
|
||||||
fetched_at: string
|
|
||||||
published_at: string | null
|
|
||||||
matched_rule: string | null
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Topic {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
feed_url: string
|
|
||||||
feed_type: string
|
|
||||||
is_active: boolean
|
|
||||||
fetch_interval_minutes: number
|
|
||||||
last_fetched_at: string | null
|
|
||||||
alert_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Rule {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
topic_id: string | null
|
|
||||||
conditions: Array<{
|
|
||||||
field: string
|
|
||||||
operator: string
|
|
||||||
value: string | number
|
|
||||||
}>
|
|
||||||
action_type: string
|
|
||||||
action_config: Record<string, unknown>
|
|
||||||
priority: number
|
|
||||||
is_active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Profile {
|
|
||||||
priorities: string[]
|
|
||||||
exclusions: string[]
|
|
||||||
positive_examples: Array<{ title: string; url: string }>
|
|
||||||
negative_examples: Array<{ title: string; url: string }>
|
|
||||||
policies: {
|
|
||||||
keep_threshold: number
|
|
||||||
drop_threshold: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Stats {
|
|
||||||
total_alerts: number
|
|
||||||
new_alerts: number
|
|
||||||
kept_alerts: number
|
|
||||||
review_alerts: number
|
|
||||||
dropped_alerts: number
|
|
||||||
total_topics: number
|
|
||||||
active_topics: number
|
|
||||||
total_rules: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab type
|
|
||||||
type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation'
|
|
||||||
|
|
||||||
export default function AlertsPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard')
|
|
||||||
const [stats, setStats] = useState<Stats | null>(null)
|
|
||||||
const [alerts, setAlerts] = useState<AlertItem[]>([])
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([])
|
|
||||||
const [rules, setRules] = useState<Rule[]>([])
|
|
||||||
const [profile, setProfile] = useState<Profile | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [inboxFilter, setInboxFilter] = useState<string>('all')
|
|
||||||
|
|
||||||
const API_BASE = '/api/alerts'
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([
|
|
||||||
fetch(`${API_BASE}/stats`),
|
|
||||||
fetch(`${API_BASE}/inbox?limit=50`),
|
|
||||||
fetch(`${API_BASE}/topics`),
|
|
||||||
fetch(`${API_BASE}/rules`),
|
|
||||||
fetch(`${API_BASE}/profile`),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (statsRes.ok) setStats(await statsRes.json())
|
|
||||||
if (alertsRes.ok) {
|
|
||||||
const data = await alertsRes.json()
|
|
||||||
setAlerts(data.items || [])
|
|
||||||
}
|
|
||||||
if (topicsRes.ok) {
|
|
||||||
const data = await topicsRes.json()
|
|
||||||
setTopics(data.topics || data.items || [])
|
|
||||||
}
|
|
||||||
if (rulesRes.ok) {
|
|
||||||
const data = await rulesRes.json()
|
|
||||||
setRules(data.rules || data.items || [])
|
|
||||||
}
|
|
||||||
if (profileRes.ok) setProfile(await profileRes.json())
|
|
||||||
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
|
||||||
// Set demo data
|
|
||||||
setStats({
|
|
||||||
total_alerts: 147,
|
|
||||||
new_alerts: 23,
|
|
||||||
kept_alerts: 89,
|
|
||||||
review_alerts: 12,
|
|
||||||
dropped_alerts: 23,
|
|
||||||
total_topics: 5,
|
|
||||||
active_topics: 4,
|
|
||||||
total_rules: 8,
|
|
||||||
})
|
|
||||||
setAlerts([
|
|
||||||
{
|
|
||||||
id: 'demo_1',
|
|
||||||
title: 'Neue Studie zur digitalen Bildung an Schulen',
|
|
||||||
url: 'https://example.com/artikel1',
|
|
||||||
snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...',
|
|
||||||
topic_name: 'Digitale Bildung',
|
|
||||||
relevance_score: 0.85,
|
|
||||||
relevance_decision: 'KEEP',
|
|
||||||
status: 'new',
|
|
||||||
fetched_at: new Date().toISOString(),
|
|
||||||
published_at: null,
|
|
||||||
matched_rule: null,
|
|
||||||
tags: ['bildung', 'digital'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'demo_2',
|
|
||||||
title: 'Inklusion: Fortbildungen fuer Lehrkraefte',
|
|
||||||
url: 'https://example.com/artikel2',
|
|
||||||
snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...',
|
|
||||||
topic_name: 'Inklusion',
|
|
||||||
relevance_score: 0.72,
|
|
||||||
relevance_decision: 'KEEP',
|
|
||||||
status: 'new',
|
|
||||||
fetched_at: new Date(Date.now() - 3600000).toISOString(),
|
|
||||||
published_at: null,
|
|
||||||
matched_rule: null,
|
|
||||||
tags: ['inklusion'],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setTopics([
|
|
||||||
{
|
|
||||||
id: 'topic_1',
|
|
||||||
name: 'Digitale Bildung',
|
|
||||||
feed_url: 'https://google.com/alerts/feeds/123',
|
|
||||||
feed_type: 'rss',
|
|
||||||
is_active: true,
|
|
||||||
fetch_interval_minutes: 60,
|
|
||||||
last_fetched_at: new Date().toISOString(),
|
|
||||||
alert_count: 47,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'topic_2',
|
|
||||||
name: 'Inklusion',
|
|
||||||
feed_url: 'https://google.com/alerts/feeds/456',
|
|
||||||
feed_type: 'rss',
|
|
||||||
is_active: true,
|
|
||||||
fetch_interval_minutes: 60,
|
|
||||||
last_fetched_at: new Date(Date.now() - 1800000).toISOString(),
|
|
||||||
alert_count: 32,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setRules([
|
|
||||||
{
|
|
||||||
id: 'rule_1',
|
|
||||||
name: 'Stellenanzeigen ausschliessen',
|
|
||||||
topic_id: null,
|
|
||||||
conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }],
|
|
||||||
action_type: 'drop',
|
|
||||||
action_config: {},
|
|
||||||
priority: 10,
|
|
||||||
is_active: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setProfile({
|
|
||||||
priorities: ['Inklusion', 'digitale Bildung'],
|
|
||||||
exclusions: ['Stellenanzeigen', 'Werbung'],
|
|
||||||
positive_examples: [],
|
|
||||||
negative_examples: [],
|
|
||||||
policies: { keep_threshold: 0.7, drop_threshold: 0.3 },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData()
|
|
||||||
}, [fetchData])
|
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string | null) => {
|
|
||||||
if (!dateStr) return '-'
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMins = Math.floor(diffMs / 60000)
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'gerade eben'
|
|
||||||
if (diffMins < 60) return `vor ${diffMins} Min.`
|
|
||||||
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
|
||||||
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getScoreBadge = (score: number | null) => {
|
|
||||||
if (score === null) return null
|
|
||||||
const pct = Math.round(score * 100)
|
|
||||||
let cls = 'bg-slate-100 text-slate-600'
|
|
||||||
if (pct >= 70) cls = 'bg-green-100 text-green-800'
|
|
||||||
else if (pct >= 40) cls = 'bg-amber-100 text-amber-800'
|
|
||||||
else cls = 'bg-red-100 text-red-800'
|
|
||||||
return <span className={`px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>{pct}%</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDecisionBadge = (decision: string | null) => {
|
|
||||||
if (!decision) return null
|
|
||||||
const styles: Record<string, string> = {
|
|
||||||
KEEP: 'bg-green-100 text-green-800',
|
|
||||||
REVIEW: 'bg-amber-100 text-amber-800',
|
|
||||||
DROP: 'bg-red-100 text-red-800',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${styles[decision] || 'bg-slate-100'}`}>
|
|
||||||
{decision}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredAlerts = alerts.filter((alert) => {
|
|
||||||
if (inboxFilter === 'all') return true
|
|
||||||
if (inboxFilter === 'new') return alert.status === 'new'
|
|
||||||
if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP'
|
|
||||||
if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW'
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabs: { id: TabId; label: string; badge?: number }[] = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard' },
|
|
||||||
{ id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 },
|
|
||||||
{ id: 'topics', label: 'Topics' },
|
|
||||||
{ id: 'rules', label: 'Regeln' },
|
|
||||||
{ id: 'profile', label: 'Profil' },
|
|
||||||
{ id: 'audit', label: 'Audit' },
|
|
||||||
{ id: 'documentation', label: 'Dokumentation' },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title="Alerts Monitoring"
|
|
||||||
purpose="Google Alerts & Feed-Ueberwachung mit KI-gestuetzter Relevanzpruefung. Verwalten Sie Topics, konfigurieren Sie Filterregeln und nutzen Sie LLM-basiertes Scoring fuer automatische Kategorisierung."
|
|
||||||
audience={['Marketing', 'Admins', 'DSB']}
|
|
||||||
architecture={{
|
|
||||||
services: ['backend (FastAPI)', 'APScheduler', 'LLM Gateway'],
|
|
||||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
|
||||||
}}
|
|
||||||
relatedPages={[
|
|
||||||
{ name: 'Unified Inbox', href: '/communication/mail', description: 'E-Mail-Konten verwalten' },
|
|
||||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
|
||||||
]}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stats Overview */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-slate-900">{stats?.total_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Alerts gesamt</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-blue-600">{stats?.new_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Neue Alerts</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-green-600">{stats?.kept_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Relevant</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
|
||||||
<div className="text-3xl font-bold text-amber-600">{stats?.review_alerts || 0}</div>
|
|
||||||
<div className="text-sm text-slate-500">Zur Pruefung</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="bg-white rounded-lg shadow mb-6">
|
|
||||||
<div className="border-b border-slate-200 px-4">
|
|
||||||
<nav className="flex gap-4 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`pb-3 pt-4 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-green-600 text-green-600'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{tab.badge !== undefined && tab.badge > 0 && (
|
|
||||||
<span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-500 text-white">
|
|
||||||
{tab.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Dashboard Tab */}
|
|
||||||
{activeTab === 'dashboard' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-slate-50 rounded-xl p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Topics</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{topics.slice(0, 5).map((topic) => (
|
|
||||||
<div key={topic.id} className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-slate-900">{topic.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{topic.alert_count} Alerts</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
|
|
||||||
{topic.is_active ? 'Aktiv' : 'Pausiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{topics.length === 0 && (
|
|
||||||
<div className="text-sm text-slate-500 text-center py-4">Keine Topics konfiguriert</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-50 rounded-xl p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Letzte Alerts</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{alerts.slice(0, 5).map((alert) => (
|
|
||||||
<div key={alert.id} className="p-3 bg-white rounded-lg border border-slate-200">
|
|
||||||
<div className="font-medium text-slate-900 text-sm truncate">{alert.title}</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-xs text-slate-500">{alert.topic_name}</span>
|
|
||||||
{getScoreBadge(alert.relevance_score)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{alerts.length === 0 && (
|
|
||||||
<div className="text-sm text-slate-500 text-center py-4">Keine Alerts vorhanden</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
<strong>Hinweis:</strong> API nicht erreichbar. Demo-Daten werden angezeigt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inbox Tab */}
|
|
||||||
{activeTab === 'inbox' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{['all', 'new', 'keep', 'review'].map((filter) => (
|
|
||||||
<button
|
|
||||||
key={filter}
|
|
||||||
onClick={() => setInboxFilter(filter)}
|
|
||||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
|
||||||
inboxFilter === filter
|
|
||||||
? 'bg-green-600 text-white'
|
|
||||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{filter === 'all' && 'Alle'}
|
|
||||||
{filter === 'new' && 'Neu'}
|
|
||||||
{filter === 'keep' && 'Relevant'}
|
|
||||||
{filter === 'review' && 'Pruefung'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts Table */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-slate-50 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Alert</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Topic</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Score</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Decision</th>
|
|
||||||
<th className="text-left p-4 text-xs font-semibold text-slate-500 uppercase">Zeit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{filteredAlerts.map((alert) => (
|
|
||||||
<tr key={alert.id} className="hover:bg-slate-50">
|
|
||||||
<td className="p-4">
|
|
||||||
<a href={alert.url} target="_blank" rel="noopener noreferrer" className="font-medium text-slate-900 hover:text-green-600">
|
|
||||||
{alert.title}
|
|
||||||
</a>
|
|
||||||
<p className="text-sm text-slate-500 truncate max-w-md">{alert.snippet}</p>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-sm text-slate-600">{alert.topic_name}</td>
|
|
||||||
<td className="p-4">{getScoreBadge(alert.relevance_score)}</td>
|
|
||||||
<td className="p-4">{getDecisionBadge(alert.relevance_decision)}</td>
|
|
||||||
<td className="p-4 text-sm text-slate-500">{formatTimeAgo(alert.fetched_at)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{filteredAlerts.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
|
||||||
Keine Alerts gefunden
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Topics Tab */}
|
|
||||||
{activeTab === 'topics' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-slate-900">Feed Topics</h3>
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
|
||||||
+ Topic hinzufuegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{topics.map((topic) => (
|
|
||||||
<div key={topic.id} className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<div className="flex justify-between items-start mb-3">
|
|
||||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-6 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${topic.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
|
|
||||||
{topic.is_active ? 'Aktiv' : 'Pausiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-slate-900">{topic.name}</h4>
|
|
||||||
<p className="text-sm text-slate-500 truncate">{topic.feed_url}</p>
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t border-slate-100">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-semibold text-slate-900">{topic.alert_count}</span>
|
|
||||||
<span className="text-slate-500"> Alerts</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
{formatTimeAgo(topic.last_fetched_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{topics.length === 0 && (
|
|
||||||
<div className="col-span-full text-center py-8 text-slate-500">
|
|
||||||
Keine Topics konfiguriert
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rules Tab */}
|
|
||||||
{activeTab === 'rules' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="font-semibold text-slate-900">Filterregeln</h3>
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
|
||||||
+ Regel erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
|
|
||||||
{rules.map((rule) => (
|
|
||||||
<div key={rule.id} className="p-4 flex items-center gap-4">
|
|
||||||
<div className="text-slate-400 cursor-grab">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-slate-900">{rule.name}</div>
|
|
||||||
<div className="text-sm text-slate-500">
|
|
||||||
Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} "{rule.conditions[0]?.value}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-3 py-1 rounded text-xs font-semibold uppercase ${
|
|
||||||
rule.action_type === 'keep' ? 'bg-green-100 text-green-800' :
|
|
||||||
rule.action_type === 'drop' ? 'bg-red-100 text-red-800' :
|
|
||||||
rule.action_type === 'email' ? 'bg-blue-100 text-blue-800' :
|
|
||||||
'bg-purple-100 text-purple-800'
|
|
||||||
}`}>
|
|
||||||
{rule.action_type}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${
|
|
||||||
rule.is_active ? 'bg-green-500' : 'bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute w-5 h-5 bg-white rounded-full top-0.5 transition-all shadow ${
|
|
||||||
rule.is_active ? 'left-6' : 'left-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-slate-500">
|
|
||||||
Keine Regeln konfiguriert
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profile Tab */}
|
|
||||||
{activeTab === 'profile' && (
|
|
||||||
<div className="max-w-2xl space-y-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Relevanzprofil</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Prioritaeten (wichtige Themen)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={profile?.priorities?.join('\n') || ''}
|
|
||||||
placeholder="Ein Thema pro Zeile..."
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden hoeher bewertet.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Ausschluesse (unerwuenschte Themen)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
rows={4}
|
|
||||||
defaultValue={profile?.exclusions?.join('\n') || ''}
|
|
||||||
placeholder="Ein Thema pro Zeile..."
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">Alerts zu diesen Themen werden niedriger bewertet.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Schwellenwert KEEP
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
defaultValue={profile?.policies?.keep_threshold || 0.7}
|
|
||||||
>
|
|
||||||
<option value={0.8}>80% (sehr streng)</option>
|
|
||||||
<option value={0.7}>70% (empfohlen)</option>
|
|
||||||
<option value={0.6}>60% (weniger streng)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Schwellenwert DROP
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
|
||||||
defaultValue={profile?.policies?.drop_threshold || 0.3}
|
|
||||||
>
|
|
||||||
<option value={0.4}>40% (strenger)</option>
|
|
||||||
<option value={0.3}>30% (empfohlen)</option>
|
|
||||||
<option value={0.2}>20% (lockerer)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700">
|
|
||||||
Profil speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audit Tab */}
|
|
||||||
{activeTab === 'audit' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">Audit-relevante Informationen</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{/* Database Info */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
|
||||||
</svg>
|
|
||||||
Datenbank
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Tabellen</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">4 (topics, items, rules, profiles)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Indizes</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">URL-Hash, Topic-ID, Status</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-slate-600">Backups</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">PostgreSQL pg_dump</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Security */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
API Sicherheit
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Authentifizierung</span>
|
|
||||||
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Bearer Token (geplant)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Rate Limiting</span>
|
|
||||||
<span className="text-sm font-medium bg-amber-100 text-amber-800 px-2 py-0.5 rounded">Nicht implementiert</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-slate-600">Input Validation</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Pydantic Models</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logging */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Logging & Monitoring
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Structured Logging</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Python logging</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-slate-100">
|
|
||||||
<span className="text-sm text-slate-600">Metriken</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">Stats Endpoint</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm text-slate-600">Health Checks</span>
|
|
||||||
<span className="text-sm font-medium bg-green-100 text-green-800 px-2 py-0.5 rounded">/api/alerts/health</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Privacy Notes */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h4 className="text-sm font-semibold text-blue-800 mb-2">Datenschutz-Hinweise</h4>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
Alle Daten werden in Deutschland gespeichert (PostgreSQL)
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
Keine personenbezogenen Daten in Alerts (nur URLs und Snippets)
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
LLM-Verarbeitung kann on-premise mit Ollama/vLLM erfolgen
|
|
||||||
</li>
|
|
||||||
<li className="text-sm text-blue-700 flex items-start gap-2">
|
|
||||||
<svg className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
DSGVO-konforme Datenverarbeitung
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documentation Tab */}
|
|
||||||
{activeTab === 'documentation' && (
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 overflow-auto max-h-[calc(100vh-350px)]">
|
|
||||||
<div className="prose prose-slate max-w-none prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="not-prose mb-8 pb-6 border-b border-slate-200">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">BreakPilot Alerts Agent</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">Version: 1.0.0 | Stand: Januar 2026 | Autor: BreakPilot Development Team</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audit Box */}
|
|
||||||
<div className="not-prose bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
||||||
<h3 className="font-semibold text-blue-900 mb-2">Audit-Relevante Informationen</h3>
|
|
||||||
<p className="text-sm text-blue-800">
|
|
||||||
Dieses Dokument dient als technische Dokumentation fuer das Alert-Monitoring-System der BreakPilot Plattform.
|
|
||||||
Es ist fuer Audits durch Bildungstraeger und Datenschutzbeauftragte konzipiert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ziel des Systems */}
|
|
||||||
<h2>Ziel des Alert-Systems</h2>
|
|
||||||
<p>Das System ermoeglicht automatisierte Ueberwachung von Bildungsthemen mit:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Google Alerts Integration</strong>: RSS-Feeds von Google Alerts automatisch abrufen</li>
|
|
||||||
<li><strong>RSS/Atom Feeds</strong>: Beliebige Nachrichtenquellen einbinden</li>
|
|
||||||
<li><strong>KI-Relevanzpruefung</strong>: Automatische Bewertung der Relevanz durch LLM</li>
|
|
||||||
<li><strong>Regelbasierte Filterung</strong>: Flexible Regeln fuer automatische Sortierung</li>
|
|
||||||
<li><strong>Multi-Channel Actions</strong>: E-Mail, Webhook, Slack Benachrichtigungen</li>
|
|
||||||
<li><strong>Few-Shot Learning</strong>: Profil verbessert sich durch Nutzerfeedback</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Architecture Diagram */}
|
|
||||||
<h2>Systemarchitektur</h2>
|
|
||||||
<div className="not-prose bg-slate-900 rounded-lg p-4 overflow-x-auto">
|
|
||||||
<pre className="text-green-400 text-xs">{`
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ BreakPilot Alerts Frontend │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐│
|
|
||||||
│ │ Dashboard │ │ Inbox │ │ Topics │ │ Profile ││
|
|
||||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘│
|
|
||||||
└───────────────────────────────┬─────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Ingestion Layer │
|
|
||||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
||||||
│ │ RSS Fetcher │ │ Email Parser │ │ APScheduler │ │
|
|
||||||
│ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ │
|
|
||||||
│ └───────────────────┼───────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Deduplication (URL-Hash + SimHash) │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Processing Layer │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Rule Engine │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ LLM Relevance Scorer │ │
|
|
||||||
│ │ Output: { score, decision: KEEP/DROP/REVIEW } │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Action Layer │
|
|
||||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
||||||
│ │ Email Action │ │ Webhook Action │ │ Slack Action │ │
|
|
||||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
v
|
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Storage Layer │
|
|
||||||
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
|
||||||
│ │ PostgreSQL │ │ Valkey │ │ LLM Gateway │ │
|
|
||||||
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘`}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Endpoints */}
|
|
||||||
<h2>API Endpoints</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Endpoint</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Methode</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/inbox</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Inbox Items abrufen</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/ingest</td><td className="px-4 py-2">POST</td><td className="px-4 py-2 text-slate-600">Manuell Alert importieren</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/topics</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Topics verwalten</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/rules</td><td className="px-4 py-2">GET/POST</td><td className="px-4 py-2 text-slate-600">Regeln verwalten</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/profile</td><td className="px-4 py-2">GET/PUT</td><td className="px-4 py-2 text-slate-600">Profil abrufen/aktualisieren</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">/api/alerts/stats</td><td className="px-4 py-2">GET</td><td className="px-4 py-2 text-slate-600">Statistiken abrufen</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rule Engine */}
|
|
||||||
<h2>Rule Engine - Operatoren</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Operator</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beschreibung</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Beispiel</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">contains</td><td className="px-4 py-2">Text enthaelt</td><td className="px-4 py-2 text-slate-600">title contains "Inklusion"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">not_contains</td><td className="px-4 py-2">Text enthaelt nicht</td><td className="px-4 py-2 text-slate-600">title not_contains "Werbung"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">equals</td><td className="px-4 py-2">Exakte Uebereinstimmung</td><td className="px-4 py-2 text-slate-600">status equals "new"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">regex</td><td className="px-4 py-2">Regulaerer Ausdruck</td><td className="px-4 py-2 text-slate-600">title regex "\d{4}"</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-xs">gt / lt</td><td className="px-4 py-2">Groesser/Kleiner</td><td className="px-4 py-2 text-slate-600">relevance_score gt 0.8</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scoring */}
|
|
||||||
<h2>LLM Relevanz-Scoring</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Entscheidung</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Score-Bereich</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Bedeutung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr className="bg-green-50"><td className="px-4 py-2 font-semibold text-green-800">KEEP</td><td className="px-4 py-2">0.7 - 1.0</td><td className="px-4 py-2">Klar relevant, in Inbox anzeigen</td></tr>
|
|
||||||
<tr className="bg-amber-50"><td className="px-4 py-2 font-semibold text-amber-800">REVIEW</td><td className="px-4 py-2">0.4 - 0.7</td><td className="px-4 py-2">Unsicher, Nutzer entscheidet</td></tr>
|
|
||||||
<tr className="bg-red-50"><td className="px-4 py-2 font-semibold text-red-800">DROP</td><td className="px-4 py-2">0.0 - 0.4</td><td className="px-4 py-2">Irrelevant, automatisch archivieren</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact */}
|
|
||||||
<h2>Kontakt & Support</h2>
|
|
||||||
<div className="not-prose overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm border border-slate-200 rounded-lg">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Kontakt</th>
|
|
||||||
<th className="px-4 py-2 text-left font-semibold text-slate-700 border-b">Adresse</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2">Technischer Support</td><td className="px-4 py-2">support@breakpilot.de</td></tr>
|
|
||||||
<tr><td className="px-4 py-2">Datenschutzbeauftragter</td><td className="px-4 py-2">dsb@breakpilot.de</td></tr>
|
|
||||||
<tr><td className="px-4 py-2">Dokumentation</td><td className="px-4 py-2">docs.breakpilot.de</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="not-prose mt-8 pt-6 border-t border-slate-200 text-sm text-slate-500">
|
|
||||||
<p>Dokumentation erstellt: Januar 2026 | Version: 1.0.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,946 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified Inbox Mail Admin Page
|
|
||||||
* Migrated from website/admin/mail to admin-v2/communication/mail
|
|
||||||
*
|
|
||||||
* Admin interface for managing email accounts, viewing system status,
|
|
||||||
* and configuring AI analysis settings.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
|
|
||||||
// API Base URL for backend operations (accounts, sync, etc.)
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://macmini:8086'
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface EmailAccount {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
displayName: string
|
|
||||||
imapHost: string
|
|
||||||
imapPort: number
|
|
||||||
smtpHost: string
|
|
||||||
smtpPort: number
|
|
||||||
status: 'active' | 'inactive' | 'error' | 'syncing'
|
|
||||||
lastSync: string | null
|
|
||||||
emailCount: number
|
|
||||||
unreadCount: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MailStats {
|
|
||||||
totalAccounts: number
|
|
||||||
activeAccounts: number
|
|
||||||
totalEmails: number
|
|
||||||
unreadEmails: number
|
|
||||||
totalTasks: number
|
|
||||||
pendingTasks: number
|
|
||||||
overdueTasks: number
|
|
||||||
aiAnalyzedCount: number
|
|
||||||
lastSyncTime: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncStatus {
|
|
||||||
running: boolean
|
|
||||||
accountsInProgress: string[]
|
|
||||||
lastCompleted: string | null
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab definitions
|
|
||||||
type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
|
|
||||||
|
|
||||||
const tabs: { id: TabId; name: string }[] = [
|
|
||||||
{ id: 'overview', name: 'Uebersicht' },
|
|
||||||
{ id: 'accounts', name: 'Konten' },
|
|
||||||
{ id: 'ai-settings', name: 'KI-Einstellungen' },
|
|
||||||
{ id: 'templates', name: 'Vorlagen' },
|
|
||||||
{ id: 'logs', name: 'Audit-Log' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Main Component
|
|
||||||
export default function MailAdminPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
||||||
const [stats, setStats] = useState<MailStats | null>(null)
|
|
||||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
|
||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
// Fetch stats via our proxy API (avoids CORS/mixed-content issues)
|
|
||||||
const response = await fetch('/api/admin/mail')
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
setStats(data.stats)
|
|
||||||
setAccounts(data.accounts)
|
|
||||||
setSyncStatus(data.syncStatus)
|
|
||||||
setError(null)
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
throw new Error(errorData.details || `API returned ${response.status}`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch mail data:', err)
|
|
||||||
setError('Verbindung zum Mail-Service (Mailpit) fehlgeschlagen. Laeuft Mailpit auf Port 8025?')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData()
|
|
||||||
|
|
||||||
// Refresh every 10 seconds if syncing
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (syncStatus?.running) {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
}, 10000)
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [fetchData, syncStatus?.running])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title="Unified Inbox"
|
|
||||||
purpose="Verwalten Sie E-Mail-Konten, synchronisieren Sie Postfaecher und konfigurieren Sie die KI-gestuetzte E-Mail-Analyse fuer automatische Kategorisierung und Aufgabenerkennung."
|
|
||||||
audience={['Admins', 'Schulleitung']}
|
|
||||||
architecture={{
|
|
||||||
services: ['Mailpit (Dev Mail Catcher)', 'IMAP/SMTP Server (Prod)'],
|
|
||||||
databases: ['PostgreSQL', 'Vault (Credentials)'],
|
|
||||||
}}
|
|
||||||
relatedPages={[
|
|
||||||
{ name: 'Mail Wizard', href: '/communication/mail/wizard', description: 'Interaktives Setup und Testing' },
|
|
||||||
{ name: 'Voice Service', href: '/communication/matrix', description: 'Voice-First Interface' },
|
|
||||||
]}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Link to Wizard */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<Link
|
|
||||||
href="/communication/mail/wizard"
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
Mail Wizard starten
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Banner */}
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
|
||||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-red-700">{error}</span>
|
|
||||||
<button onClick={fetchData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
|
|
||||||
Erneut versuchen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="border-b border-slate-200 mb-6">
|
|
||||||
<nav className="-mb-px flex space-x-8">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
|
||||||
${activeTab === tab.id
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{tab.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<OverviewTab
|
|
||||||
stats={stats}
|
|
||||||
syncStatus={syncStatus}
|
|
||||||
loading={loading}
|
|
||||||
onRefresh={fetchData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'accounts' && (
|
|
||||||
<AccountsTab
|
|
||||||
accounts={accounts}
|
|
||||||
loading={loading}
|
|
||||||
onRefresh={fetchData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'ai-settings' && (
|
|
||||||
<AISettingsTab />
|
|
||||||
)}
|
|
||||||
{activeTab === 'templates' && (
|
|
||||||
<TemplatesTab />
|
|
||||||
)}
|
|
||||||
{activeTab === 'logs' && (
|
|
||||||
<AuditLogTab />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Overview Tab
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function OverviewTab({
|
|
||||||
stats,
|
|
||||||
syncStatus,
|
|
||||||
loading,
|
|
||||||
onRefresh
|
|
||||||
}: {
|
|
||||||
stats: MailStats | null
|
|
||||||
syncStatus: SyncStatus | null
|
|
||||||
loading: boolean
|
|
||||||
onRefresh: () => void
|
|
||||||
}) {
|
|
||||||
const triggerSync = async () => {
|
|
||||||
try {
|
|
||||||
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
onRefresh()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to trigger sync:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">System-Uebersicht</h2>
|
|
||||||
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onRefresh}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={triggerSync}
|
|
||||||
disabled={syncStatus?.running}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
{!loading && stats && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
title="E-Mail-Konten"
|
|
||||||
value={stats.totalAccounts}
|
|
||||||
subtitle={`${stats.activeAccounts} aktiv`}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="E-Mails gesamt"
|
|
||||||
value={stats.totalEmails}
|
|
||||||
subtitle={`${stats.unreadEmails} ungelesen`}
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Aufgaben"
|
|
||||||
value={stats.totalTasks}
|
|
||||||
subtitle={`${stats.pendingTasks} offen`}
|
|
||||||
color="yellow"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Ueberfaellig"
|
|
||||||
value={stats.overdueTasks}
|
|
||||||
color={stats.overdueTasks > 0 ? 'red' : 'green'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sync Status */}
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
||||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{syncStatus?.running ? (
|
|
||||||
<>
|
|
||||||
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-slate-600">
|
|
||||||
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
|
||||||
<span className="text-slate-600">Bereit</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{stats.lastSyncTime && (
|
|
||||||
<span className="text-sm text-slate-500 ml-auto">
|
|
||||||
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncStatus?.errors && syncStatus.errors.length > 0 && (
|
|
||||||
<div className="mt-4 p-4 bg-red-50 rounded-lg">
|
|
||||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
|
|
||||||
<ul className="text-sm text-red-700 space-y-1">
|
|
||||||
{syncStatus.errors.slice(0, 3).map((error, i) => (
|
|
||||||
<li key={i}>{error}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Stats */}
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
||||||
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
|
|
||||||
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
|
|
||||||
<p className="text-2xl font-bold text-slate-900">
|
|
||||||
{stats.totalEmails > 0
|
|
||||||
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
|
|
||||||
: '0%'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
subtitle,
|
|
||||||
color = 'blue'
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
value: number
|
|
||||||
subtitle?: string
|
|
||||||
color?: 'blue' | 'green' | 'yellow' | 'red'
|
|
||||||
}) {
|
|
||||||
const colorClasses = {
|
|
||||||
blue: 'text-blue-600',
|
|
||||||
green: 'text-green-600',
|
|
||||||
yellow: 'text-yellow-600',
|
|
||||||
red: 'text-red-600',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
|
|
||||||
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
|
|
||||||
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Accounts Tab
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function AccountsTab({
|
|
||||||
accounts,
|
|
||||||
loading,
|
|
||||||
onRefresh
|
|
||||||
}: {
|
|
||||||
accounts: EmailAccount[]
|
|
||||||
loading: boolean
|
|
||||||
onRefresh: () => void
|
|
||||||
}) {
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
|
||||||
|
|
||||||
const testConnection = async (accountId: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
alert('Verbindung erfolgreich!')
|
|
||||||
} else {
|
|
||||||
alert('Verbindungsfehler')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Verbindungsfehler')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors = {
|
|
||||||
active: 'bg-green-100 text-green-800',
|
|
||||||
inactive: 'bg-gray-100 text-gray-800',
|
|
||||||
error: 'bg-red-100 text-red-800',
|
|
||||||
syncing: 'bg-yellow-100 text-yellow-800',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabels = {
|
|
||||||
active: 'Aktiv',
|
|
||||||
inactive: 'Inaktiv',
|
|
||||||
error: 'Fehler',
|
|
||||||
syncing: 'Synchronisiert...',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
|
|
||||||
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddModal(true)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
Konto hinzufuegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Accounts Grid */}
|
|
||||||
{!loading && (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{accounts.length === 0 ? (
|
|
||||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
|
||||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
|
|
||||||
<p className="text-slate-500 mb-4">Fuegen Sie Ihr erstes E-Mail-Konto hinzu.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
accounts.map((account) => (
|
|
||||||
<div
|
|
||||||
key={account.id}
|
|
||||||
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">
|
|
||||||
{account.displayName || account.email}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-500">{account.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
|
|
||||||
{statusLabels[account.status]}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => testConnection(account.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-slate-600"
|
|
||||||
title="Verbindung testen"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
|
|
||||||
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
|
|
||||||
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
|
|
||||||
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
|
|
||||||
<p className="text-sm text-slate-700">
|
|
||||||
{account.lastSync
|
|
||||||
? new Date(account.lastSync).toLocaleString('de-DE')
|
|
||||||
: 'Nie'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add Account Modal */}
|
|
||||||
{showAddModal && (
|
|
||||||
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddAccountModal({
|
|
||||||
onClose,
|
|
||||||
onSuccess
|
|
||||||
}: {
|
|
||||||
onClose: () => void
|
|
||||||
onSuccess: () => void
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
email: '',
|
|
||||||
displayName: '',
|
|
||||||
imapHost: '',
|
|
||||||
imapPort: 993,
|
|
||||||
smtpHost: '',
|
|
||||||
smtpPort: 587,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
})
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: formData.email,
|
|
||||||
display_name: formData.displayName,
|
|
||||||
imap_host: formData.imapHost,
|
|
||||||
imap_port: formData.imapPort,
|
|
||||||
smtp_host: formData.smtpHost,
|
|
||||||
smtp_port: formData.smtpPort,
|
|
||||||
username: formData.username,
|
|
||||||
password: formData.password,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
onSuccess()
|
|
||||||
} else {
|
|
||||||
const data = await res.json()
|
|
||||||
setError(data.detail || 'Fehler beim Hinzufuegen des Kontos')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Netzwerkfehler')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div className="p-6 border-b border-slate-200">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufuegen</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="schulleitung@grundschule-xy.de"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.displayName}
|
|
||||||
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Schulleitung"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.imapHost}
|
|
||||||
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="imap.example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
required
|
|
||||||
value={formData.imapPort}
|
|
||||||
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Server</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.smtpHost}
|
|
||||||
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="smtp.example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
required
|
|
||||||
value={formData.smtpPort}
|
|
||||||
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Das Passwort wird verschluesselt in Vault gespeichert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? 'Speichern...' : 'Konto hinzufuegen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AI Settings Tab
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function AISettingsTab() {
|
|
||||||
const [settings, setSettings] = useState({
|
|
||||||
autoAnalyze: true,
|
|
||||||
autoCreateTasks: true,
|
|
||||||
analysisModel: 'breakpilot-teacher-8b',
|
|
||||||
confidenceThreshold: 0.7,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
|
|
||||||
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
|
|
||||||
{/* Auto-Analyze */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
|
|
||||||
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
settings.autoAnalyze ? 'bg-blue-600' : 'bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto-Create Tasks */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
|
|
||||||
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
settings.autoCreateTasks ? 'bg-blue-600' : 'bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
|
|
||||||
<select
|
|
||||||
value={settings.analysisModel}
|
|
||||||
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
|
|
||||||
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
|
|
||||||
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
|
|
||||||
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confidence Threshold */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
|
||||||
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0.5"
|
|
||||||
max="0.95"
|
|
||||||
step="0.05"
|
|
||||||
value={settings.confidenceThreshold}
|
|
||||||
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
|
|
||||||
className="w-full md:w-64"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
|
||||||
Mindest-Konfidenz fuer automatische Aufgabenerstellung
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sender Classification */}
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
|
||||||
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
|
|
||||||
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
|
|
||||||
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehoerde', priority: 'Hoch' },
|
|
||||||
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
|
|
||||||
{ domain: '@schultraeger.de', type: 'Schultraeger', priority: 'Mittel' },
|
|
||||||
].map((sender) => (
|
|
||||||
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
|
|
||||||
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
|
|
||||||
<p className="text-xs text-slate-500">{sender.type}</p>
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
||||||
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
|
|
||||||
}`}>
|
|
||||||
{sender.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Templates Tab
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function TemplatesTab() {
|
|
||||||
const [templates] = useState([
|
|
||||||
{ id: '1', name: 'Eingangsbestaetigung', category: 'Standard', usageCount: 45 },
|
|
||||||
{ id: '2', name: 'Terminbestaetigung', category: 'Termine', usageCount: 23 },
|
|
||||||
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
|
|
||||||
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
|
|
||||||
</div>
|
|
||||||
<button className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
Vorlage erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{templates.map((template) => (
|
|
||||||
<tr key={template.id} className="hover:bg-slate-50">
|
|
||||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">Bearbeiten</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Audit Log Tab
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function AuditLogTab() {
|
|
||||||
const [logs] = useState([
|
|
||||||
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefuegt' },
|
|
||||||
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
|
|
||||||
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const actionLabels: Record<string, string> = {
|
|
||||||
account_created: 'Konto erstellt',
|
|
||||||
email_analyzed: 'E-Mail analysiert',
|
|
||||||
task_created: 'Aufgabe erstellt',
|
|
||||||
sync_completed: 'Sync abgeschlossen',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
|
|
||||||
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{logs.map((log) => (
|
|
||||||
<tr key={log.id} className="hover:bg-slate-50">
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-500">
|
|
||||||
{new Date(log.timestamp).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
|
|
||||||
{actionLabels[log.action] || log.action}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,594 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Voice Service Admin Page (migrated from website/admin/voice)
|
|
||||||
*
|
|
||||||
* Displays:
|
|
||||||
* - Voice-First Architecture Overview
|
|
||||||
* - Developer Guide Content
|
|
||||||
* - Live Voice Demo (embedded from studio-v2)
|
|
||||||
* - Task State Machine Documentation
|
|
||||||
* - DSGVO Compliance Information
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
|
|
||||||
type TabType = 'overview' | 'demo' | 'tasks' | 'intents' | 'dsgvo' | 'api'
|
|
||||||
|
|
||||||
// Task State Machine data
|
|
||||||
const TASK_STATES = [
|
|
||||||
{ state: 'DRAFT', description: 'Task erstellt, noch nicht verarbeitet', color: 'bg-gray-100 text-gray-800', next: ['QUEUED', 'PAUSED'] },
|
|
||||||
{ state: 'QUEUED', description: 'In Warteschlange fuer Verarbeitung', color: 'bg-blue-100 text-blue-800', next: ['RUNNING', 'PAUSED'] },
|
|
||||||
{ state: 'RUNNING', description: 'Wird aktuell verarbeitet', color: 'bg-yellow-100 text-yellow-800', next: ['READY', 'PAUSED'] },
|
|
||||||
{ state: 'READY', description: 'Fertig, wartet auf User-Bestaetigung', color: 'bg-green-100 text-green-800', next: ['APPROVED', 'REJECTED', 'PAUSED'] },
|
|
||||||
{ state: 'APPROVED', description: 'Vom User bestaetigt', color: 'bg-emerald-100 text-emerald-800', next: ['COMPLETED'] },
|
|
||||||
{ state: 'REJECTED', description: 'Vom User abgelehnt', color: 'bg-red-100 text-red-800', next: ['DRAFT'] },
|
|
||||||
{ state: 'COMPLETED', description: 'Erfolgreich abgeschlossen', color: 'bg-teal-100 text-teal-800', next: [] },
|
|
||||||
{ state: 'EXPIRED', description: 'TTL ueberschritten', color: 'bg-orange-100 text-orange-800', next: [] },
|
|
||||||
{ state: 'PAUSED', description: 'Vom User pausiert', color: 'bg-purple-100 text-purple-800', next: ['DRAFT', 'QUEUED', 'RUNNING', 'READY'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Intent Types (22 types organized by group)
|
|
||||||
const INTENT_GROUPS = [
|
|
||||||
{
|
|
||||||
group: 'Notizen',
|
|
||||||
color: 'bg-blue-50 border-blue-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'student_observation', example: 'Notiz zu Max: heute wiederholt gestoert', description: 'Schuelerbeobachtungen' },
|
|
||||||
{ type: 'reminder', example: 'Erinner mich morgen an Konferenz', description: 'Erinnerungen setzen' },
|
|
||||||
{ type: 'homework_check', example: '7b Mathe Hausaufgabe kontrollieren', description: 'Hausaufgaben pruefen' },
|
|
||||||
{ type: 'conference_topic', example: 'Thema Lehrerkonferenz: iPad-Regeln', description: 'Konferenzthemen' },
|
|
||||||
{ type: 'correction_thought', example: 'Aufgabe 3: haeufiger Fehler erklaeren', description: 'Korrekturgedanken' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Content-Generierung',
|
|
||||||
color: 'bg-green-50 border-green-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'worksheet_generate', example: 'Erstelle 3 Lueckentexte zu Vokabeln', description: 'Arbeitsblaetter erstellen' },
|
|
||||||
{ type: 'quiz_generate', example: '10-Minuten Vokabeltest mit Loesungen', description: 'Quiz/Tests erstellen' },
|
|
||||||
{ type: 'quick_activity', example: '10 Minuten Einstieg, 5 Aufgaben', description: 'Schnelle Aktivitaeten' },
|
|
||||||
{ type: 'differentiation', example: 'Zwei Schwierigkeitsstufen: Basis und Plus', description: 'Differenzierung' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Kommunikation',
|
|
||||||
color: 'bg-yellow-50 border-yellow-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'parent_letter', example: 'Neutraler Elternbrief wegen Stoerungen', description: 'Elternbriefe erstellen' },
|
|
||||||
{ type: 'class_message', example: 'Nachricht an 8a: Hausaufgaben bis Mittwoch', description: 'Klassennachrichten' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Canvas-Editor',
|
|
||||||
color: 'bg-purple-50 border-purple-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'canvas_edit', example: 'Ueberschriften groesser, Zeilenabstand kleiner', description: 'Formatierung aendern' },
|
|
||||||
{ type: 'canvas_layout', example: 'Alles auf eine Seite, Drucklayout A4', description: 'Layout anpassen' },
|
|
||||||
{ type: 'canvas_element', example: 'Kasten fuer Merke hinzufuegen', description: 'Elemente hinzufuegen' },
|
|
||||||
{ type: 'canvas_image', example: 'Bild 2 nach links, Pfeil auf Aufgabe 3', description: 'Bilder positionieren' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'RAG & Korrektur',
|
|
||||||
color: 'bg-pink-50 border-pink-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'operator_checklist', example: 'Operatoren-Checkliste fuer diese Aufgabe', description: 'Operatoren abrufen' },
|
|
||||||
{ type: 'eh_passage', example: 'Erwartungshorizont-Passage zu diesem Thema', description: 'EH-Passagen suchen' },
|
|
||||||
{ type: 'feedback_suggestion', example: 'Kurze Feedbackformulierung vorschlagen', description: 'Feedback vorschlagen' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Follow-up (TaskOrchestrator)',
|
|
||||||
color: 'bg-teal-50 border-teal-200',
|
|
||||||
intents: [
|
|
||||||
{ type: 'task_summary', example: 'Fasse alle offenen Tasks zusammen', description: 'Task-Uebersicht' },
|
|
||||||
{ type: 'convert_note', example: 'Mach aus der Notiz von gestern einen Elternbrief', description: 'Notizen konvertieren' },
|
|
||||||
{ type: 'schedule_reminder', example: 'Erinner mich morgen an das Gespraech mit Max', description: 'Erinnerungen planen' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// DSGVO Data Categories
|
|
||||||
const DSGVO_CATEGORIES = [
|
|
||||||
{ category: 'Audio', processing: 'NUR transient im RAM, NIEMALS persistiert', storage: 'Keine', ttl: '-', icon: '🎤', risk: 'low' },
|
|
||||||
{ category: 'PII (Schuelernamen)', processing: 'NUR auf Lehrergeraet', storage: 'Client-side', ttl: '-', icon: '👤', risk: 'high' },
|
|
||||||
{ category: 'Pseudonyme', processing: 'Server erlaubt (student_ref, class_ref)', storage: 'Valkey Cache', ttl: '24h', icon: '🔢', risk: 'low' },
|
|
||||||
{ category: 'Transkripte', processing: 'NUR verschluesselt (AES-256-GCM)', storage: 'PostgreSQL', ttl: '7 Tage', icon: '📝', risk: 'medium' },
|
|
||||||
{ category: 'Task States', processing: 'TaskOrchestrator', storage: 'Valkey', ttl: '30 Tage', icon: '📋', risk: 'low' },
|
|
||||||
{ category: 'Audit Logs', processing: 'Nur truncated IDs, keine PII', storage: 'PostgreSQL', ttl: '90 Tage', icon: '📊', risk: 'low' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// API Endpoints
|
|
||||||
const API_ENDPOINTS = [
|
|
||||||
{ method: 'POST', path: '/api/v1/sessions', description: 'Voice Session erstellen' },
|
|
||||||
{ method: 'GET', path: '/api/v1/sessions/{id}', description: 'Session Status abrufen' },
|
|
||||||
{ method: 'DELETE', path: '/api/v1/sessions/{id}', description: 'Session beenden' },
|
|
||||||
{ method: 'GET', path: '/api/v1/sessions/{id}/tasks', description: 'Pending Tasks abrufen' },
|
|
||||||
{ method: 'POST', path: '/api/v1/tasks', description: 'Task erstellen' },
|
|
||||||
{ method: 'GET', path: '/api/v1/tasks/{id}', description: 'Task Status abrufen' },
|
|
||||||
{ method: 'PUT', path: '/api/v1/tasks/{id}/transition', description: 'Task State aendern' },
|
|
||||||
{ method: 'DELETE', path: '/api/v1/tasks/{id}', description: 'Task loeschen' },
|
|
||||||
{ method: 'WS', path: '/ws/voice', description: 'Voice Streaming (WebSocket)' },
|
|
||||||
{ method: 'GET', path: '/health', description: 'Health Check' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function VoiceMatrixPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
|
||||||
const [demoLoaded, setDemoLoaded] = useState(false)
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'overview', name: 'Architektur', icon: '🏗️' },
|
|
||||||
{ id: 'demo', name: 'Live Demo', icon: '🎤' },
|
|
||||||
{ id: 'tasks', name: 'Task States', icon: '📋' },
|
|
||||||
{ id: 'intents', name: 'Intents (22)', icon: '🎯' },
|
|
||||||
{ id: 'dsgvo', name: 'DSGVO', icon: '🔒' },
|
|
||||||
{ id: 'api', name: 'API', icon: '🔌' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title="Voice Service"
|
|
||||||
purpose="Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator. Konfigurieren und testen Sie den Voice-Service fuer Lehrer-Interaktionen per Sprache."
|
|
||||||
audience={['Entwickler', 'Admins']}
|
|
||||||
architecture={{
|
|
||||||
services: ['voice-service (Python, Port 8091)', 'studio-v2 (Next.js)', 'valkey (Cache)'],
|
|
||||||
databases: ['PostgreSQL', 'Valkey Cache'],
|
|
||||||
}}
|
|
||||||
relatedPages={[
|
|
||||||
{ name: 'Matrix & Jitsi', href: '/communication/matrix', description: 'Kommunikation Monitoring' },
|
|
||||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider vergleichen' },
|
|
||||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU fuer Voice-Service' },
|
|
||||||
]}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="mb-6 flex flex-wrap gap-3">
|
|
||||||
<a
|
|
||||||
href="https://macmini:3001/voice-test"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
|
||||||
</svg>
|
|
||||||
Voice Test (Studio)
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://macmini:8091/health"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Health Check
|
|
||||||
</a>
|
|
||||||
<Link
|
|
||||||
href="/development/docs"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
Developer Docs
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Overview */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-teal-600">8091</div>
|
|
||||||
<div className="text-sm text-slate-500">Port</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-blue-600">22</div>
|
|
||||||
<div className="text-sm text-slate-500">Task Types</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-purple-600">9</div>
|
|
||||||
<div className="text-sm text-slate-500">Task States</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-green-600">24kHz</div>
|
|
||||||
<div className="text-sm text-slate-500">Audio Rate</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-orange-600">80ms</div>
|
|
||||||
<div className="text-sm text-slate-500">Frame Size</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
|
||||||
<div className="text-3xl font-bold text-red-600">0</div>
|
|
||||||
<div className="text-sm text-slate-500">Audio Persist</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="bg-white rounded-lg shadow mb-6">
|
|
||||||
<div className="border-b border-slate-200 px-4">
|
|
||||||
<div className="flex gap-1 overflow-x-auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id as TabType)}
|
|
||||||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-teal-600 text-teal-600'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="mr-2">{tab.icon}</span>
|
|
||||||
{tab.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Overview Tab */}
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Voice-First Architektur</h3>
|
|
||||||
|
|
||||||
{/* Architecture Diagram */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
|
||||||
<pre className="text-slate-700">{`
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ LEHRERGERAET (PWA / App) │
|
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ VoiceCapture.tsx │ voice-encryption.ts │ voice-api.ts │ │
|
|
||||||
│ │ Mikrofon │ AES-256-GCM │ WebSocket Client │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
└───────────────────────────┬──────────────────────────────────────┘
|
|
||||||
│ WebSocket (wss://)
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
|
||||||
│ VOICE SERVICE (Port 8091) │
|
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ main.py │ streaming.py │ sessions.py │ tasks.py │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ task_orchestrator.py │ intent_router.py │ encryption │ │
|
|
||||||
│ └────────────────────────────────────────────────────────────┘ │
|
|
||||||
└───────────────────────────┬──────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌──────────────────┼──────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ PersonaPlex-7B │ │ Ollama Fallback │ │ Valkey Cache │
|
|
||||||
│ (A100 GPU) │ │ (Mac Mini) │ │ (Sessions) │
|
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
||||||
`}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Technology Stack */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-blue-800 mb-2">Voice Model (Produktion)</h4>
|
|
||||||
<p className="text-sm text-blue-700">PersonaPlex-7B (NVIDIA)</p>
|
|
||||||
<p className="text-xs text-blue-600 mt-1">Full-Duplex Speech-to-Speech</p>
|
|
||||||
<p className="text-xs text-blue-500">Lizenz: MIT + NVIDIA Open Model</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-green-800 mb-2">Agent Orchestration</h4>
|
|
||||||
<p className="text-sm text-green-700">TaskOrchestrator</p>
|
|
||||||
<p className="text-xs text-green-600 mt-1">Task State Machine</p>
|
|
||||||
<p className="text-xs text-green-500">Lizenz: Proprietary</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-purple-800 mb-2">Audio Codec</h4>
|
|
||||||
<p className="text-sm text-purple-700">Mimi (24kHz, 80ms)</p>
|
|
||||||
<p className="text-xs text-purple-600 mt-1">Low-Latency Streaming</p>
|
|
||||||
<p className="text-xs text-purple-500">Lizenz: MIT</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Files */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-3">Wichtige Dateien</h4>
|
|
||||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datei</th>
|
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/main.py</td><td className="px-4 py-2 text-sm text-slate-600">FastAPI Entry, WebSocket Handler</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/task_orchestrator.py</td><td className="px-4 py-2 text-sm text-slate-600">Task State Machine</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/intent_router.py</td><td className="px-4 py-2 text-sm text-slate-600">Intent Detection (22 Types)</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">voice-service/services/encryption_service.py</td><td className="px-4 py-2 text-sm text-slate-600">Namespace Key Management</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/components/voice/VoiceCapture.tsx</td><td className="px-4 py-2 text-sm text-slate-600">Frontend Mikrofon + Crypto</td></tr>
|
|
||||||
<tr><td className="px-4 py-2 font-mono text-sm">studio-v2/lib/voice/voice-encryption.ts</td><td className="px-4 py-2 text-sm text-slate-600">AES-256-GCM Client-side</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Demo Tab */}
|
|
||||||
{activeTab === 'demo' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Live Voice Demo</h3>
|
|
||||||
<a
|
|
||||||
href="https://macmini:3001/voice-test"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-teal-600 hover:text-teal-700 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
In neuem Tab oeffnen
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-100 rounded-lg p-4 text-sm text-slate-600 mb-4">
|
|
||||||
<p><strong>Hinweis:</strong> Die Demo erfordert, dass der Voice Service (Port 8091) und das Studio-v2 Frontend (Port 3001) laufen.</p>
|
|
||||||
<code className="block mt-2 bg-slate-200 p-2 rounded">docker compose up -d voice-service && cd studio-v2 && npm run dev</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Embedded Demo */}
|
|
||||||
<div className="relative bg-slate-900 rounded-lg overflow-hidden" style={{ height: '600px' }}>
|
|
||||||
{!demoLoaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setDemoLoaded(true)}
|
|
||||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
Voice Demo laden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{demoLoaded && (
|
|
||||||
<iframe
|
|
||||||
src="https://macmini:3001/voice-test?embed=true"
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Voice Demo"
|
|
||||||
allow="microphone"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task States Tab */}
|
|
||||||
{activeTab === 'tasks' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Task State Machine (TaskOrchestrator)</h3>
|
|
||||||
|
|
||||||
{/* State Diagram */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-6 font-mono text-sm overflow-x-auto">
|
|
||||||
<pre className="text-slate-700">{`
|
|
||||||
DRAFT → QUEUED → RUNNING → READY
|
|
||||||
│
|
|
||||||
┌───────────┴───────────┐
|
|
||||||
│ │
|
|
||||||
APPROVED REJECTED
|
|
||||||
│ │
|
|
||||||
COMPLETED DRAFT (revision)
|
|
||||||
|
|
||||||
Any State → EXPIRED (TTL)
|
|
||||||
Any State → PAUSED (User Interrupt)
|
|
||||||
`}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* States Table */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{TASK_STATES.map((state) => (
|
|
||||||
<div key={state.state} className={`${state.color} rounded-lg p-4`}>
|
|
||||||
<div className="font-semibold text-lg">{state.state}</div>
|
|
||||||
<p className="text-sm mt-1">{state.description}</p>
|
|
||||||
{state.next.length > 0 && (
|
|
||||||
<div className="mt-2 text-xs">
|
|
||||||
<span className="opacity-75">Naechste:</span>{' '}
|
|
||||||
{state.next.join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Intents Tab */}
|
|
||||||
{activeTab === 'intents' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Intent Types (22 unterstuetzte Typen)</h3>
|
|
||||||
|
|
||||||
{INTENT_GROUPS.map((group) => (
|
|
||||||
<div key={group.group} className={`${group.color} border rounded-lg p-4`}>
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-3">{group.group}</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{group.intents.map((intent) => (
|
|
||||||
<div key={intent.type} className="bg-white rounded-lg p-3 shadow-sm">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<code className="text-sm font-mono text-teal-700 bg-teal-50 px-2 py-0.5 rounded">
|
|
||||||
{intent.type}
|
|
||||||
</code>
|
|
||||||
<p className="text-sm text-slate-600 mt-1">{intent.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-slate-500 italic">
|
|
||||||
Beispiel: "{intent.example}"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DSGVO Tab */}
|
|
||||||
{activeTab === 'dsgvo' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">DSGVO-Compliance</h3>
|
|
||||||
|
|
||||||
{/* Key Principles */}
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-green-800 mb-2">Kernprinzipien</h4>
|
|
||||||
<ul className="list-disc list-inside text-sm text-green-700 space-y-1">
|
|
||||||
<li><strong>Audio NIEMALS persistiert</strong> - Nur transient im RAM</li>
|
|
||||||
<li><strong>Namespace-Verschluesselung</strong> - Key nur auf Lehrergeraet</li>
|
|
||||||
<li><strong>Keine Klartext-PII serverseitig</strong> - Nur verschluesselt oder pseudonymisiert</li>
|
|
||||||
<li><strong>TTL-basierte Auto-Loeschung</strong> - 7/30/90 Tage je nach Kategorie</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Categories Table */}
|
|
||||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verarbeitung</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Speicherort</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">TTL</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Risiko</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{DSGVO_CATEGORIES.map((cat) => (
|
|
||||||
<tr key={cat.category}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="mr-2">{cat.icon}</span>
|
|
||||||
<span className="font-medium">{cat.category}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.processing}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.storage}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{cat.ttl}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
cat.risk === 'low' ? 'bg-green-100 text-green-700' :
|
|
||||||
cat.risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{cat.risk.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audit Log Info */}
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-2">Audit Logs (ohne PII)</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-green-600 font-medium">Erlaubt:</span>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
|
||||||
<li>ref_id (truncated)</li>
|
|
||||||
<li>content_type</li>
|
|
||||||
<li>size_bytes</li>
|
|
||||||
<li>ttl_hours</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-red-600 font-medium">Verboten:</span>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 mt-1">
|
|
||||||
<li>user_name</li>
|
|
||||||
<li>content / transcript</li>
|
|
||||||
<li>email</li>
|
|
||||||
<li>student_name</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* API Tab */}
|
|
||||||
{activeTab === 'api' && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-900">Voice Service API (Port 8091)</h3>
|
|
||||||
|
|
||||||
{/* REST Endpoints */}
|
|
||||||
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead className="bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Methode</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Endpoint</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Beschreibung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{API_ENDPOINTS.map((ep, idx) => (
|
|
||||||
<tr key={idx}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
|
||||||
ep.method === 'GET' ? 'bg-green-100 text-green-700' :
|
|
||||||
ep.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
|
||||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
ep.method === 'DELETE' ? 'bg-red-100 text-red-700' :
|
|
||||||
'bg-purple-100 text-purple-700'
|
|
||||||
}`}>
|
|
||||||
{ep.method}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-sm">{ep.path}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-slate-600">{ep.description}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* WebSocket Protocol */}
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<h4 className="font-semibold text-slate-800 mb-3">WebSocket Protocol</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
|
||||||
<div className="font-medium text-slate-700 mb-2">Client → Server</div>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Int16 PCM Audio (24kHz, 80ms)</li>
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "config|end_turn|interrupt"}`}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
|
||||||
<div className="font-medium text-slate-700 mb-2">Server → Client</div>
|
|
||||||
<ul className="list-disc list-inside text-slate-600 space-y-1">
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">Binary</code>: Audio Response (base64)</li>
|
|
||||||
<li><code className="bg-slate-100 px-1 rounded">JSON</code>: {`{type: "transcript|intent|status|error"}`}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Example curl commands */}
|
|
||||||
<div className="bg-slate-900 rounded-lg p-4 text-sm">
|
|
||||||
<h4 className="font-semibold text-slate-300 mb-3">Beispiel: Session erstellen</h4>
|
|
||||||
<pre className="text-green-400 overflow-x-auto">{`curl -X POST https://macmini:8091/api/v1/sessions \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"namespace_id": "ns-12345678abcdef12345678abcdef12",
|
|
||||||
"key_hash": "sha256:dGVzdGtleWhhc2h0ZXN0a2V5aGFzaHRlc3Q=",
|
|
||||||
"device_type": "pwa"
|
|
||||||
}'`}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,635 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video & Chat Admin Page
|
|
||||||
*
|
|
||||||
* Matrix & Jitsi Monitoring Dashboard
|
|
||||||
* Provides system statistics, active calls, user metrics, and service health
|
|
||||||
* Migrated from website/app/admin/communication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
import { getModuleByHref } from '@/lib/navigation'
|
|
||||||
|
|
||||||
interface MatrixStats {
|
|
||||||
total_users: number
|
|
||||||
active_users: number
|
|
||||||
total_rooms: number
|
|
||||||
active_rooms: number
|
|
||||||
messages_today: number
|
|
||||||
messages_this_week: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JitsiStats {
|
|
||||||
active_meetings: number
|
|
||||||
total_participants: number
|
|
||||||
meetings_today: number
|
|
||||||
average_duration_minutes: number
|
|
||||||
peak_concurrent_users: number
|
|
||||||
total_minutes_today: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrafficStats {
|
|
||||||
matrix: {
|
|
||||||
bandwidth_in_mb: number
|
|
||||||
bandwidth_out_mb: number
|
|
||||||
messages_per_minute: number
|
|
||||||
media_uploads_today: number
|
|
||||||
media_size_mb: number
|
|
||||||
}
|
|
||||||
jitsi: {
|
|
||||||
bandwidth_in_mb: number
|
|
||||||
bandwidth_out_mb: number
|
|
||||||
video_streams_active: number
|
|
||||||
audio_streams_active: number
|
|
||||||
estimated_hourly_gb: number
|
|
||||||
}
|
|
||||||
total: {
|
|
||||||
bandwidth_in_mb: number
|
|
||||||
bandwidth_out_mb: number
|
|
||||||
estimated_monthly_gb: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommunicationStats {
|
|
||||||
matrix: MatrixStats
|
|
||||||
jitsi: JitsiStats
|
|
||||||
traffic?: TrafficStats
|
|
||||||
last_updated: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveMeeting {
|
|
||||||
room_name: string
|
|
||||||
display_name: string
|
|
||||||
participants: number
|
|
||||||
started_at: string
|
|
||||||
duration_minutes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentRoom {
|
|
||||||
room_id: string
|
|
||||||
name: string
|
|
||||||
member_count: number
|
|
||||||
last_activity: string
|
|
||||||
room_type: 'class' | 'parent' | 'staff' | 'general'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VideoChatPage() {
|
|
||||||
const [stats, setStats] = useState<CommunicationStats | null>(null)
|
|
||||||
const [activeMeetings, setActiveMeetings] = useState<ActiveMeeting[]>([])
|
|
||||||
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const moduleInfo = getModuleByHref('/communication/video-chat')
|
|
||||||
|
|
||||||
// Use local API proxy
|
|
||||||
const fetchStats = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/communication/stats')
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setStats(data)
|
|
||||||
setActiveMeetings(data.active_meetings || [])
|
|
||||||
setRecentRooms(data.recent_rooms || [])
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
|
||||||
// Set mock data for display purposes when API unavailable
|
|
||||||
setStats({
|
|
||||||
matrix: {
|
|
||||||
total_users: 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'offline'
|
|
||||||
},
|
|
||||||
jitsi: {
|
|
||||||
active_meetings: 0,
|
|
||||||
total_participants: 0,
|
|
||||||
meetings_today: 0,
|
|
||||||
average_duration_minutes: 0,
|
|
||||||
peak_concurrent_users: 0,
|
|
||||||
total_minutes_today: 0,
|
|
||||||
status: 'offline'
|
|
||||||
},
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStats()
|
|
||||||
}, [fetchStats])
|
|
||||||
|
|
||||||
// Auto-refresh every 15 seconds
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(fetchStats, 15000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [fetchStats])
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const baseClasses = 'px-3 py-1 rounded-full text-xs font-semibold uppercase'
|
|
||||||
switch (status) {
|
|
||||||
case 'online':
|
|
||||||
return `${baseClasses} bg-green-100 text-green-800`
|
|
||||||
case 'degraded':
|
|
||||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
|
||||||
case 'offline':
|
|
||||||
return `${baseClasses} bg-red-100 text-red-800`
|
|
||||||
default:
|
|
||||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRoomTypeBadge = (type: string) => {
|
|
||||||
const baseClasses = 'px-2 py-0.5 rounded text-xs font-medium'
|
|
||||||
switch (type) {
|
|
||||||
case 'class':
|
|
||||||
return `${baseClasses} bg-blue-100 text-blue-700`
|
|
||||||
case 'parent':
|
|
||||||
return `${baseClasses} bg-purple-100 text-purple-700`
|
|
||||||
case 'staff':
|
|
||||||
return `${baseClasses} bg-orange-100 text-orange-700`
|
|
||||||
default:
|
|
||||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (minutes: number) => {
|
|
||||||
if (minutes < 60) return `${Math.round(minutes)} Min.`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
const mins = Math.round(minutes % 60)
|
|
||||||
return `${hours}h ${mins}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string) => {
|
|
||||||
const date = new Date(dateStr)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMins = Math.floor(diffMs / 60000)
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'gerade eben'
|
|
||||||
if (diffMins < 60) return `vor ${diffMins} Min.`
|
|
||||||
if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.`
|
|
||||||
return `vor ${Math.floor(diffMins / 1440)} Tagen`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traffic estimation helpers for SysEleven planning
|
|
||||||
const calculateEstimatedTraffic = (direction: 'in' | 'out'): number => {
|
|
||||||
const messages = stats?.matrix?.messages_today || 0
|
|
||||||
const callMinutes = stats?.jitsi?.total_minutes_today || 0
|
|
||||||
const participants = stats?.jitsi?.total_participants || 0
|
|
||||||
|
|
||||||
const messageTrafficMB = messages * 0.002
|
|
||||||
const videoTrafficMB = callMinutes * participants * 0.011
|
|
||||||
|
|
||||||
if (direction === 'in') {
|
|
||||||
return messageTrafficMB * 0.3 + videoTrafficMB * 0.4
|
|
||||||
}
|
|
||||||
return messageTrafficMB * 0.7 + videoTrafficMB * 0.6
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateHourlyEstimate = (): number => {
|
|
||||||
const activeParticipants = stats?.jitsi?.total_participants || 0
|
|
||||||
return activeParticipants * 0.675
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateMonthlyEstimate = (): number => {
|
|
||||||
const dailyCallMinutes = stats?.jitsi?.total_minutes_today || 0
|
|
||||||
const avgParticipants = stats?.jitsi?.peak_concurrent_users || 1
|
|
||||||
const monthlyMinutes = dailyCallMinutes * 22
|
|
||||||
return (monthlyMinutes * avgParticipants * 11) / 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
const getResourceRecommendation = (): string => {
|
|
||||||
const peakUsers = stats?.jitsi?.peak_concurrent_users || 0
|
|
||||||
const monthlyGB = calculateMonthlyEstimate()
|
|
||||||
|
|
||||||
if (monthlyGB < 10 || peakUsers < 5) {
|
|
||||||
return 'Starter (1 vCPU, 2GB RAM, 100GB Traffic)'
|
|
||||||
} else if (monthlyGB < 50 || peakUsers < 20) {
|
|
||||||
return 'Standard (2 vCPU, 4GB RAM, 500GB Traffic)'
|
|
||||||
} else if (monthlyGB < 200 || peakUsers < 50) {
|
|
||||||
return 'Professional (4 vCPU, 8GB RAM, 2TB Traffic)'
|
|
||||||
} else {
|
|
||||||
return 'Enterprise (8+ vCPU, 16GB+ RAM, Unlimited Traffic)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title={moduleInfo?.module.name || 'Video & Chat'}
|
|
||||||
purpose={moduleInfo?.module.purpose || 'Matrix & Jitsi Monitoring Dashboard'}
|
|
||||||
audience={moduleInfo?.module.audience || ['Admins', 'DevOps']}
|
|
||||||
architecture={{
|
|
||||||
services: ['synapse (Matrix)', 'jitsi-meet', 'prosody', 'jvb'],
|
|
||||||
databases: ['PostgreSQL', 'synapse-db'],
|
|
||||||
}}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="flex gap-3 mb-6">
|
|
||||||
<Link
|
|
||||||
href="/communication/video-chat/wizard"
|
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
|
|
||||||
>
|
|
||||||
Test Wizard starten
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={fetchStats}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
{loading ? 'Lade...' : 'Aktualisieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Status Overview */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
||||||
{/* Matrix Status Card */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">Matrix (Synapse)</h3>
|
|
||||||
<p className="text-sm text-slate-500">E2EE Messaging</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={getStatusBadge(stats?.matrix.status || 'offline')}>
|
|
||||||
{stats?.matrix.status || 'offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_users || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Benutzer</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.active_users || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Aktiv</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.matrix.total_rooms || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Raeume</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-slate-500">Nachrichten heute</span>
|
|
||||||
<span className="font-medium">{stats?.matrix.messages_today || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm mt-1">
|
|
||||||
<span className="text-slate-500">Diese Woche</span>
|
|
||||||
<span className="font-medium">{stats?.matrix.messages_this_week || 0}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Jitsi Status Card */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">Jitsi Meet</h3>
|
|
||||||
<p className="text-sm text-slate-500">Videokonferenzen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={getStatusBadge(stats?.jitsi.status || 'offline')}>
|
|
||||||
{stats?.jitsi.status || 'offline'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{stats?.jitsi.active_meetings || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Live Calls</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.total_participants || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Teilnehmer</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stats?.jitsi.meetings_today || 0}</div>
|
|
||||||
<div className="text-xs text-slate-500">Calls heute</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-slate-500">Durchschnittliche Dauer</span>
|
|
||||||
<span className="font-medium">{formatDuration(stats?.jitsi.average_duration_minutes || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-sm mt-1">
|
|
||||||
<span className="text-slate-500">Peak gleichzeitig</span>
|
|
||||||
<span className="font-medium">{stats?.jitsi.peak_concurrent_users || 0} Nutzer</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Traffic & Bandwidth Statistics */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900">Traffic & Bandbreite</h3>
|
|
||||||
<p className="text-sm text-slate-500">SysEleven Ressourcenplanung</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="px-3 py-1 rounded-full text-xs font-semibold uppercase bg-emerald-100 text-emerald-800">
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Eingehend (heute)</div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">
|
|
||||||
{stats?.traffic?.total?.bandwidth_in_mb?.toFixed(1) || calculateEstimatedTraffic('in').toFixed(1)} MB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Ausgehend (heute)</div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">
|
|
||||||
{stats?.traffic?.total?.bandwidth_out_mb?.toFixed(1) || calculateEstimatedTraffic('out').toFixed(1)} MB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Stunde</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{stats?.traffic?.jitsi?.estimated_hourly_gb?.toFixed(2) || calculateHourlyEstimate().toFixed(2)} GB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4">
|
|
||||||
<div className="text-xs text-slate-500 mb-1">Geschaetzt/Monat</div>
|
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
|
||||||
{stats?.traffic?.total?.estimated_monthly_gb?.toFixed(1) || calculateMonthlyEstimate().toFixed(1)} GB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Matrix Traffic */}
|
|
||||||
<div className="border border-slate-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
|
||||||
<span className="text-sm font-medium text-slate-700">Matrix Messaging</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Nachrichten/Min</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.matrix?.messages_per_minute || Math.round((stats?.matrix?.messages_today || 0) / (new Date().getHours() || 1) / 60)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Media Uploads heute</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.matrix?.media_uploads_today || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Media Groesse</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.matrix?.media_size_mb?.toFixed(1) || '0.0'} MB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Jitsi Traffic */}
|
|
||||||
<div className="border border-slate-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
|
||||||
<span className="text-sm font-medium text-slate-700">Jitsi Video</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Video Streams aktiv</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.jitsi?.video_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Audio Streams aktiv</span>
|
|
||||||
<span className="font-medium">{stats?.traffic?.jitsi?.audio_streams_active || (stats?.jitsi?.total_participants || 0)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-slate-500">Bitrate geschaetzt</span>
|
|
||||||
<span className="font-medium">{((stats?.jitsi?.total_participants || 0) * 1.5).toFixed(1)} Mbps</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SysEleven Recommendation */}
|
|
||||||
<div className="mt-4 p-4 bg-emerald-50 border border-emerald-200 rounded-lg">
|
|
||||||
<h4 className="text-sm font-semibold text-emerald-800 mb-2">SysEleven Empfehlung</h4>
|
|
||||||
<div className="text-sm text-emerald-700">
|
|
||||||
<p>Basierend auf aktuellem Traffic: <strong>{getResourceRecommendation()}</strong></p>
|
|
||||||
<p className="mt-1 text-xs text-emerald-600">
|
|
||||||
Peak Teilnehmer: {stats?.jitsi?.peak_concurrent_users || 0} |
|
|
||||||
Durchschnittliche Call-Dauer: {stats?.jitsi?.average_duration_minutes?.toFixed(0) || 0} Min. |
|
|
||||||
Calls heute: {stats?.jitsi?.meetings_today || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Meetings */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-semibold text-slate-900">Aktive Meetings</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeMeetings.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-slate-500">
|
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<p>Keine aktiven Meetings</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-xs text-slate-500 uppercase border-b border-slate-200">
|
|
||||||
<th className="pb-3 pr-4">Meeting</th>
|
|
||||||
<th className="pb-3 pr-4">Teilnehmer</th>
|
|
||||||
<th className="pb-3 pr-4">Gestartet</th>
|
|
||||||
<th className="pb-3">Dauer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{activeMeetings.map((meeting, idx) => (
|
|
||||||
<tr key={idx} className="text-sm">
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<div className="font-medium text-slate-900">{meeting.display_name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{meeting.room_name}</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-4">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
|
||||||
{meeting.participants}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 pr-4 text-slate-500">{formatTimeAgo(meeting.started_at)}</td>
|
|
||||||
<td className="py-3 font-medium">{formatDuration(meeting.duration_minutes)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Chat Rooms & Usage Stats */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Aktive Chat-Raeume</h3>
|
|
||||||
|
|
||||||
{recentRooms.length === 0 ? (
|
|
||||||
<div className="text-center py-6 text-slate-500">
|
|
||||||
<p>Keine aktiven Raeume</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{recentRooms.slice(0, 5).map((room, idx) => (
|
|
||||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-slate-200 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-4 h-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-slate-900 text-sm">{room.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">{room.member_count} Mitglieder</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={getRoomTypeBadge(room.room_type)}>{room.room_type}</span>
|
|
||||||
<span className="text-xs text-slate-400">{formatTimeAgo(room.last_activity)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Statistics */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Nutzungsstatistiken</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Call-Minuten heute</span>
|
|
||||||
<span className="font-semibold">{stats?.jitsi.total_minutes_today || 0} Min.</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${Math.min((stats?.jitsi.total_minutes_today || 0) / 500 * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Aktive Chat-Raeume</span>
|
|
||||||
<span className="font-semibold">{stats?.matrix.active_rooms || 0} / {stats?.matrix.total_rooms || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-purple-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${stats?.matrix.total_rooms ? ((stats.matrix.active_rooms / stats.matrix.total_rooms) * 100) : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm mb-1">
|
|
||||||
<span className="text-slate-600">Aktive Nutzer</span>
|
|
||||||
<span className="font-semibold">{stats?.matrix.active_users || 0} / {stats?.matrix.total_users || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-green-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${stats?.matrix.total_users ? ((stats.matrix.active_users / stats.matrix.total_users) * 100) : 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="mt-6 pt-4 border-t border-slate-100">
|
|
||||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Schnellaktionen</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<a
|
|
||||||
href="http://localhost:8448/_synapse/admin"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors"
|
|
||||||
>
|
|
||||||
Synapse Admin
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="http://localhost:8443"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
Jitsi Meet
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Info */}
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-blue-900">Service Konfiguration</h4>
|
|
||||||
<p className="text-sm text-blue-800 mt-1">
|
|
||||||
<strong>Matrix Homeserver:</strong> http://localhost:8448 (Synapse)<br />
|
|
||||||
<strong>Jitsi Meet:</strong> http://localhost:8443<br />
|
|
||||||
<strong>Auto-Refresh:</strong> Alle 15 Sekunden
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-600 mt-2">
|
|
||||||
<strong>Fehler:</strong> {error} - Backend nicht erreichbar
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{stats?.last_updated && (
|
|
||||||
<p className="text-xs text-blue-600 mt-2">
|
|
||||||
Letzte Aktualisierung: {new Date(stats.last_updated).toLocaleString('de-DE')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,6 @@ export default function DashboardPage() {
|
|||||||
{ name: 'Jitsi Meet', status: 'unknown' },
|
{ name: 'Jitsi Meet', status: 'unknown' },
|
||||||
{ name: 'Mailpit', status: 'unknown' },
|
{ name: 'Mailpit', status: 'unknown' },
|
||||||
{ name: 'Gitea', status: 'unknown' },
|
{ name: 'Gitea', status: 'unknown' },
|
||||||
{ name: 'Woodpecker CI', status: 'unknown' },
|
|
||||||
{ name: 'Backend Core', status: 'unknown' },
|
{ name: 'Backend Core', status: 'unknown' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
type Tab = 'colors' | 'typography' | 'components' | 'logos' | 'voice'
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
|
||||||
{ id: 'colors', label: 'Farben' },
|
|
||||||
{ id: 'typography', label: 'Typografie' },
|
|
||||||
{ id: 'components', label: 'Komponenten' },
|
|
||||||
{ id: 'logos', label: 'Logos' },
|
|
||||||
{ id: 'voice', label: 'Voice & Tone' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const primaryColors = [
|
|
||||||
{ name: 'Primary 50', hex: '#f0f9ff', class: 'bg-primary-50' },
|
|
||||||
{ name: 'Primary 100', hex: '#e0f2fe', class: 'bg-primary-100' },
|
|
||||||
{ name: 'Primary 200', hex: '#bae6fd', class: 'bg-primary-200' },
|
|
||||||
{ name: 'Primary 300', hex: '#7dd3fc', class: 'bg-primary-300' },
|
|
||||||
{ name: 'Primary 400', hex: '#38bdf8', class: 'bg-primary-400' },
|
|
||||||
{ name: 'Primary 500', hex: '#0ea5e9', class: 'bg-primary-500' },
|
|
||||||
{ name: 'Primary 600', hex: '#0284c7', class: 'bg-primary-600' },
|
|
||||||
{ name: 'Primary 700', hex: '#0369a1', class: 'bg-primary-700' },
|
|
||||||
{ name: 'Primary 800', hex: '#075985', class: 'bg-primary-800' },
|
|
||||||
{ name: 'Primary 900', hex: '#0c4a6e', class: 'bg-primary-900' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const categoryColorSets = [
|
|
||||||
{
|
|
||||||
name: 'Kommunikation',
|
|
||||||
baseHex: '#22c55e',
|
|
||||||
swatches: [
|
|
||||||
{ name: '100', hex: '#dcfce7' },
|
|
||||||
{ name: '300', hex: '#86efac' },
|
|
||||||
{ name: '500', hex: '#22c55e' },
|
|
||||||
{ name: '700', hex: '#15803d' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Infrastruktur',
|
|
||||||
baseHex: '#f97316',
|
|
||||||
swatches: [
|
|
||||||
{ name: '100', hex: '#ffedd5' },
|
|
||||||
{ name: '300', hex: '#fdba74' },
|
|
||||||
{ name: '500', hex: '#f97316' },
|
|
||||||
{ name: '700', hex: '#c2410c' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Entwicklung',
|
|
||||||
baseHex: '#64748b',
|
|
||||||
swatches: [
|
|
||||||
{ name: '100', hex: '#f1f5f9' },
|
|
||||||
{ name: '300', hex: '#cbd5e1' },
|
|
||||||
{ name: '500', hex: '#64748b' },
|
|
||||||
{ name: '700', hex: '#334155' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function BrandbookPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('colors')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-1 mb-6 bg-white rounded-xl border border-slate-200 p-1">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'bg-primary-600 text-white'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Colors Tab */}
|
|
||||||
{activeTab === 'colors' && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Primary */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Primary: Sky Blue</h2>
|
|
||||||
<div className="grid grid-cols-5 md:grid-cols-10 gap-2">
|
|
||||||
{primaryColors.map((color) => (
|
|
||||||
<div key={color.hex} className="text-center">
|
|
||||||
<div
|
|
||||||
className="w-full aspect-square rounded-lg border border-slate-200 mb-1"
|
|
||||||
style={{ backgroundColor: color.hex }}
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-slate-500">{color.name.split(' ')[1]}</div>
|
|
||||||
<div className="text-xs text-slate-400 font-mono">{color.hex}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Colors */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Farben</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{categoryColorSets.map((set) => (
|
|
||||||
<div key={set.name} className="bg-white rounded-xl border border-slate-200 p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded-full"
|
|
||||||
style={{ backgroundColor: set.baseHex }}
|
|
||||||
/>
|
|
||||||
<h3 className="font-medium text-slate-900">{set.name}</h3>
|
|
||||||
<span className="text-xs text-slate-400 font-mono">{set.baseHex}</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{set.swatches.map((swatch) => (
|
|
||||||
<div key={swatch.hex} className="text-center">
|
|
||||||
<div
|
|
||||||
className="w-full aspect-square rounded-lg border border-slate-200 mb-1"
|
|
||||||
style={{ backgroundColor: swatch.hex }}
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-slate-400 font-mono">{swatch.name}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Semantic Colors */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Semantische Farben</h2>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ name: 'Success', hex: '#22c55e', bg: '#dcfce7' },
|
|
||||||
{ name: 'Warning', hex: '#f59e0b', bg: '#fef3c7' },
|
|
||||||
{ name: 'Error', hex: '#ef4444', bg: '#fee2e2' },
|
|
||||||
{ name: 'Info', hex: '#3b82f6', bg: '#dbeafe' },
|
|
||||||
].map((color) => (
|
|
||||||
<div key={color.name} className="p-4 rounded-xl border border-slate-200" style={{ backgroundColor: color.bg }}>
|
|
||||||
<div className="w-8 h-8 rounded-lg mb-2" style={{ backgroundColor: color.hex }} />
|
|
||||||
<div className="font-medium" style={{ color: color.hex }}>{color.name}</div>
|
|
||||||
<div className="text-xs text-slate-500 font-mono">{color.hex}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Typography Tab */}
|
|
||||||
{activeTab === 'typography' && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schriftart: Inter</h2>
|
|
||||||
<p className="text-slate-500 mb-6">
|
|
||||||
Inter ist eine Open-Source-Schriftart (OFL), optimiert fuer Bildschirme.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{[
|
|
||||||
{ name: 'Heading 1', class: 'text-4xl font-bold', size: '36px / 2.25rem' },
|
|
||||||
{ name: 'Heading 2', class: 'text-2xl font-semibold', size: '24px / 1.5rem' },
|
|
||||||
{ name: 'Heading 3', class: 'text-xl font-semibold', size: '20px / 1.25rem' },
|
|
||||||
{ name: 'Body Large', class: 'text-lg', size: '18px / 1.125rem' },
|
|
||||||
{ name: 'Body', class: 'text-base', size: '16px / 1rem' },
|
|
||||||
{ name: 'Body Small', class: 'text-sm', size: '14px / 0.875rem' },
|
|
||||||
{ name: 'Caption', class: 'text-xs', size: '12px / 0.75rem' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.name} className="flex items-baseline gap-4 border-b border-slate-100 pb-4">
|
|
||||||
<div className="w-32 text-sm text-slate-500">{item.name}</div>
|
|
||||||
<div className={`flex-1 text-slate-900 ${item.class}`}>
|
|
||||||
BreakPilot Core Admin
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-400 font-mono">{item.size}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Components Tab */}
|
|
||||||
{activeTab === 'components' && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Buttons</h2>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">Primary</button>
|
|
||||||
<button className="px-4 py-2 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50">Secondary</button>
|
|
||||||
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">Danger</button>
|
|
||||||
<button className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Success</button>
|
|
||||||
<button className="px-4 py-2 text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg">Ghost</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Cards</h2>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="p-4 bg-white rounded-xl border border-slate-200 shadow-sm">
|
|
||||||
<h3 className="font-medium text-slate-900">Default Card</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">Standard-Karte mit Rand</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-primary-50 rounded-xl border border-primary-200">
|
|
||||||
<h3 className="font-medium text-primary-900">Active Card</h3>
|
|
||||||
<p className="text-sm text-primary-600 mt-1">Hervorgehobene Karte</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-white rounded-xl border border-slate-200 shadow-md hover:shadow-lg transition-shadow">
|
|
||||||
<h3 className="font-medium text-slate-900">Hover Card</h3>
|
|
||||||
<p className="text-sm text-slate-500 mt-1">Karte mit Hover-Effekt</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Badges / Status</h2>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">Healthy</span>
|
|
||||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">Error</span>
|
|
||||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-medium">Warning</span>
|
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">Info</span>
|
|
||||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded-full text-xs font-medium">Default</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Logos Tab */}
|
|
||||||
{activeTab === 'logos' && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Logo-Varianten</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div className="p-8 bg-white rounded-xl border border-slate-200 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-3xl font-bold text-primary-600 mb-1">BreakPilot</div>
|
|
||||||
<div className="text-sm text-slate-500">Core Admin</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-8 bg-slate-900 rounded-xl flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-3xl font-bold text-white mb-1">BreakPilot</div>
|
|
||||||
<div className="text-sm text-slate-400">Core Admin</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schutzzone</h2>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
Um das Logo herum muss mindestens der Abstand der Buchstabenhoehe "B" als Freiraum gelassen werden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Voice & Tone Tab */}
|
|
||||||
{activeTab === 'voice' && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Sprachstil</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-green-600 mb-2">So schreiben wir</h3>
|
|
||||||
<ul className="space-y-2 text-sm text-slate-600">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-0.5">+</span>
|
|
||||||
<span>Klar und direkt</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-0.5">+</span>
|
|
||||||
<span>Technisch praezise, aber verstaendlich</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-0.5">+</span>
|
|
||||||
<span>Handlungsorientiert</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-green-500 mt-0.5">+</span>
|
|
||||||
<span>Deutsch als Hauptsprache</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-red-600 mb-2">Das vermeiden wir</h3>
|
|
||||||
<ul className="space-y-2 text-sm text-slate-600">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-red-500 mt-0.5">-</span>
|
|
||||||
<span>Unnoetige Anglizismen</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-red-500 mt-0.5">-</span>
|
|
||||||
<span>Marketing-Sprache</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-red-500 mt-0.5">-</span>
|
|
||||||
<span>Passive Formulierungen</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-red-500 mt-0.5">-</span>
|
|
||||||
<span>Abkuerzungen ohne Erklaerung</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
const quickLinks = [
|
|
||||||
{ name: 'Backend Core API', url: 'https://macmini:8000/docs', description: 'FastAPI Swagger Docs' },
|
|
||||||
{ name: 'Gitea', url: 'http://macmini:3003', description: 'Git Server' },
|
|
||||||
{ name: 'Woodpecker CI', url: 'http://macmini:8090', description: 'CI/CD Pipelines' },
|
|
||||||
{ name: 'MkDocs', url: 'http://macmini:8009', description: 'Projekt-Dokumentation' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function DocsPage() {
|
|
||||||
const [iframeUrl, setIframeUrl] = useState('http://macmini:8009')
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
||||||
{quickLinks.map((link) => (
|
|
||||||
<button
|
|
||||||
key={link.name}
|
|
||||||
onClick={() => {
|
|
||||||
setIframeUrl(link.url)
|
|
||||||
setIsLoading(true)
|
|
||||||
}}
|
|
||||||
className={`p-4 rounded-xl border text-left transition-all hover:shadow-md ${
|
|
||||||
iframeUrl === link.url
|
|
||||||
? 'bg-primary-50 border-primary-300'
|
|
||||||
: 'bg-white border-slate-200 hover:border-primary-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<h3 className="font-medium text-slate-900">{link.name}</h3>
|
|
||||||
<p className="text-sm text-slate-500">{link.description}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Iframe Viewer */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-50 border-b border-slate-200">
|
|
||||||
<span className="text-sm text-slate-600 truncate">{iframeUrl}</span>
|
|
||||||
<a
|
|
||||||
href={iframeUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-primary-600 hover:text-primary-700"
|
|
||||||
>
|
|
||||||
In neuem Tab oeffnen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative" style={{ height: '70vh' }}>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-50">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<iframe
|
|
||||||
src={iframeUrl}
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
onLoad={() => setIsLoading(false)}
|
|
||||||
title="Documentation Viewer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
|
||||||
<h3 className="font-medium text-blue-900 mb-1">Dokumentation bearbeiten</h3>
|
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
Die MkDocs-Dokumentation liegt unter <code className="px-1 py-0.5 bg-blue-100 rounded">/docs-src/</code>.
|
|
||||||
Aenderungen werden automatisch beim naechsten Build sichtbar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
|
||||||
import ReactFlow, {
|
|
||||||
Node,
|
|
||||||
Edge,
|
|
||||||
Controls,
|
|
||||||
Background,
|
|
||||||
MiniMap,
|
|
||||||
useNodesState,
|
|
||||||
useEdgesState,
|
|
||||||
MarkerType,
|
|
||||||
} from 'reactflow'
|
|
||||||
import 'reactflow/dist/style.css'
|
|
||||||
|
|
||||||
type CategoryFilter = 'all' | 'communication' | 'infrastructure' | 'development' | 'meta'
|
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
|
||||||
communication: '#22c55e',
|
|
||||||
infrastructure: '#f97316',
|
|
||||||
development: '#64748b',
|
|
||||||
meta: '#0ea5e9',
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialNodes: Node[] = [
|
|
||||||
// Meta
|
|
||||||
{ id: 'role-select', position: { x: 400, y: 0 }, data: { label: 'Rollenauswahl', category: 'meta' }, style: { background: '#e0f2fe', border: '2px solid #0ea5e9', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'dashboard', position: { x: 400, y: 100 }, data: { label: 'Dashboard', category: 'meta' }, style: { background: '#e0f2fe', border: '2px solid #0ea5e9', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
|
|
||||||
// Communication (Green)
|
|
||||||
{ id: 'video-chat', position: { x: 50, y: 250 }, data: { label: 'Video & Chat', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'voice-service', position: { x: 50, y: 350 }, data: { label: 'Voice Service', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'mail', position: { x: 50, y: 450 }, data: { label: 'Unified Inbox', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'alerts', position: { x: 50, y: 550 }, data: { label: 'Alerts Monitoring', category: 'communication' }, style: { background: '#dcfce7', border: '2px solid #22c55e', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
|
|
||||||
// Infrastructure (Orange)
|
|
||||||
{ id: 'gpu', position: { x: 300, y: 250 }, data: { label: 'GPU Infrastruktur', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'middleware', position: { x: 300, y: 350 }, data: { label: 'Middleware', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'security', position: { x: 300, y: 450 }, data: { label: 'Security Dashboard', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'sbom', position: { x: 300, y: 550 }, data: { label: 'SBOM', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'ci-cd', position: { x: 500, y: 250 }, data: { label: 'CI/CD Dashboard', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'tests', position: { x: 500, y: 350 }, data: { label: 'Test Dashboard', category: 'infrastructure' }, style: { background: '#ffedd5', border: '2px solid #f97316', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
|
|
||||||
// Development (Slate)
|
|
||||||
{ id: 'docs', position: { x: 700, y: 250 }, data: { label: 'Developer Docs', category: 'development' }, style: { background: '#f1f5f9', border: '2px solid #64748b', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'screen-flow', position: { x: 700, y: 350 }, data: { label: 'Screen Flow', category: 'development' }, style: { background: '#f1f5f9', border: '2px solid #64748b', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
{ id: 'brandbook', position: { x: 700, y: 450 }, data: { label: 'Brandbook', category: 'development' }, style: { background: '#f1f5f9', border: '2px solid #64748b', borderRadius: '12px', padding: '10px 16px' } },
|
|
||||||
]
|
|
||||||
|
|
||||||
const initialEdges: Edge[] = [
|
|
||||||
// Meta flow
|
|
||||||
{ id: 'e-role-dash', source: 'role-select', target: 'dashboard', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#0ea5e9' } },
|
|
||||||
|
|
||||||
// Dashboard to categories
|
|
||||||
{ id: 'e-dash-vc', source: 'dashboard', target: 'video-chat', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
{ id: 'e-dash-gpu', source: 'dashboard', target: 'gpu', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
|
||||||
{ id: 'e-dash-cicd', source: 'dashboard', target: 'ci-cd', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
|
||||||
{ id: 'e-dash-docs', source: 'dashboard', target: 'docs', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#64748b' } },
|
|
||||||
|
|
||||||
// Communication internal
|
|
||||||
{ id: 'e-vc-voice', source: 'video-chat', target: 'voice-service', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
{ id: 'e-voice-mail', source: 'voice-service', target: 'mail', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
{ id: 'e-mail-alerts', source: 'mail', target: 'alerts', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#22c55e' } },
|
|
||||||
|
|
||||||
// Infrastructure internal
|
|
||||||
{ id: 'e-gpu-mw', source: 'gpu', target: 'middleware', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
|
||||||
{ id: 'e-mw-sec', source: 'middleware', target: 'security', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
|
||||||
{ id: 'e-sec-sbom', source: 'security', target: 'sbom', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
|
||||||
{ id: 'e-cicd-tests', source: 'ci-cd', target: 'tests', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#f97316' } },
|
|
||||||
|
|
||||||
// Cross-category
|
|
||||||
{ id: 'e-sec-cicd', source: 'security', target: 'ci-cd', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#94a3b8', strokeDasharray: '5,5' } },
|
|
||||||
{ id: 'e-tests-docs', source: 'tests', target: 'docs', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: '#94a3b8', strokeDasharray: '5,5' } },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ScreenFlowPage() {
|
|
||||||
const [filter, setFilter] = useState<CategoryFilter>('all')
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
|
||||||
|
|
||||||
const filteredNodes = useMemo(() => {
|
|
||||||
if (filter === 'all') return nodes
|
|
||||||
return nodes.filter(n => n.data.category === filter || n.data.category === 'meta')
|
|
||||||
}, [nodes, filter])
|
|
||||||
|
|
||||||
const filteredEdges = useMemo(() => {
|
|
||||||
const nodeIds = new Set(filteredNodes.map(n => n.id))
|
|
||||||
return edges.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
||||||
}, [edges, filteredNodes])
|
|
||||||
|
|
||||||
const filters: { id: CategoryFilter; label: string; color: string }[] = [
|
|
||||||
{ id: 'all', label: 'Alle', color: '#0ea5e9' },
|
|
||||||
{ id: 'communication', label: 'Kommunikation', color: '#22c55e' },
|
|
||||||
{ id: 'infrastructure', label: 'Infrastruktur', color: '#f97316' },
|
|
||||||
{ id: 'development', label: 'Entwicklung', color: '#64748b' },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
{filters.map((f) => (
|
|
||||||
<button
|
|
||||||
key={f.id}
|
|
||||||
onClick={() => setFilter(f.id)}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
filter === f.id
|
|
||||||
? 'text-white'
|
|
||||||
: 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'
|
|
||||||
}`}
|
|
||||||
style={filter === f.id ? { backgroundColor: f.color } : undefined}
|
|
||||||
>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{filteredNodes.length}</div>
|
|
||||||
<div className="text-xs text-slate-500">Screens</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{filteredEdges.length}</div>
|
|
||||||
<div className="text-xs text-slate-500">Verbindungen</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">3</div>
|
|
||||||
<div className="text-xs text-slate-500">Kategorien</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-3 text-center">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">13</div>
|
|
||||||
<div className="text-xs text-slate-500">Module</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flow */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm" style={{ height: '65vh' }}>
|
|
||||||
<ReactFlow
|
|
||||||
nodes={filteredNodes}
|
|
||||||
edges={filteredEdges}
|
|
||||||
onNodesChange={onNodesChange}
|
|
||||||
onEdgesChange={onEdgesChange}
|
|
||||||
fitView
|
|
||||||
attributionPosition="bottom-left"
|
|
||||||
>
|
|
||||||
<Controls />
|
|
||||||
<Background />
|
|
||||||
<MiniMap
|
|
||||||
nodeColor={(node) => categoryColors[node.data?.category] || '#94a3b8'}
|
|
||||||
maskColor="rgba(0,0,0,0.1)"
|
|
||||||
/>
|
|
||||||
</ReactFlow>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="mt-4 flex items-center gap-6 text-sm text-slate-500">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded bg-green-100 border-2 border-green-500" />
|
|
||||||
<span>Kommunikation (4)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded bg-orange-100 border-2 border-orange-500" />
|
|
||||||
<span>Infrastruktur (6)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded bg-slate-100 border-2 border-slate-500" />
|
|
||||||
<span>Entwicklung (3)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-4 rounded bg-sky-100 border-2 border-sky-500" />
|
|
||||||
<span>Meta</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ContainerInfo, DockerStats } from '../types'
|
||||||
|
|
||||||
|
export function DeploymentsTab({
|
||||||
|
dockerStats,
|
||||||
|
containerFilter,
|
||||||
|
setContainerFilter,
|
||||||
|
filteredContainers,
|
||||||
|
onContainerAction,
|
||||||
|
actionLoading,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
dockerStats: DockerStats | null
|
||||||
|
containerFilter: 'all' | 'running' | 'stopped'
|
||||||
|
setContainerFilter: (filter: 'all' | 'running' | 'stopped') => void
|
||||||
|
filteredContainers: ContainerInfo[]
|
||||||
|
onContainerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => void
|
||||||
|
actionLoading: string | null
|
||||||
|
onRefresh: () => void
|
||||||
|
}) {
|
||||||
|
const getStateColor = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'running': return 'bg-green-100 text-green-800'
|
||||||
|
case 'exited':
|
||||||
|
case 'dead': return 'bg-red-100 text-red-800'
|
||||||
|
case 'paused': return 'bg-yellow-100 text-yellow-800'
|
||||||
|
case 'restarting': return 'bg-blue-100 text-blue-800'
|
||||||
|
default: return 'bg-slate-100 text-slate-600'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">Docker Container</h3>
|
||||||
|
{dockerStats && (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={containerFilter}
|
||||||
|
onChange={(e) => setContainerFilter(e.target.value as typeof containerFilter)}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="running">Laufend</option>
|
||||||
|
<option value="stopped">Gestoppt</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container List */}
|
||||||
|
{filteredContainers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">Keine Container gefunden</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredContainers.map((container) => (
|
||||||
|
<div
|
||||||
|
key={container.id}
|
||||||
|
className={`border rounded-xl p-4 transition-colors ${
|
||||||
|
container.state === 'running'
|
||||||
|
? 'border-green-200 bg-green-50/30'
|
||||||
|
: 'border-slate-200 bg-slate-50/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-semibold text-slate-900 truncate">{container.name}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStateColor(container.state)}`}>
|
||||||
|
{container.state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 mb-2">
|
||||||
|
<span className="font-mono">{container.image}</span>
|
||||||
|
{container.ports.length > 0 && (
|
||||||
|
<span className="ml-2 text-slate-400">
|
||||||
|
| {container.ports.slice(0, 2).join(', ')}
|
||||||
|
{container.ports.length > 2 && ` +${container.ports.length - 2}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{container.state === 'running' && (
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">CPU:</span>
|
||||||
|
<span className={`font-medium ${container.cpu_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
||||||
|
{container.cpu_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">RAM:</span>
|
||||||
|
<span className={`font-medium ${container.memory_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
|
||||||
|
{container.memory_usage}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400">({container.memory_percent.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-500">Net:</span>
|
||||||
|
<span className="text-slate-700">{container.network_rx} / {container.network_tx}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{container.state === 'running' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onContainerAction(container.id, 'restart')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === `${container.id}-restart` ? '...' : 'Restart'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onContainerAction(container.id, 'stop')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === `${container.id}-stop` ? '...' : 'Stop'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onContainerAction(container.id, 'start')}
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === `${container.id}-start` ? '...' : 'Start'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PipelineStatus, PipelineRun, SystemStats, DockerStats } from '../types'
|
||||||
|
|
||||||
|
function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) {
|
||||||
|
const getColor = () => {
|
||||||
|
if (percent > 90) return 'bg-red-500'
|
||||||
|
if (percent > 70) return 'bg-yellow-500'
|
||||||
|
if (color === 'green') return 'bg-green-500'
|
||||||
|
if (color === 'purple') return 'bg-purple-500'
|
||||||
|
return 'bg-blue-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${getColor()}`}
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({
|
||||||
|
pipelineStatus,
|
||||||
|
pipelineHistory,
|
||||||
|
systemStats,
|
||||||
|
dockerStats,
|
||||||
|
}: {
|
||||||
|
pipelineStatus: PipelineStatus | null
|
||||||
|
pipelineHistory: PipelineRun[]
|
||||||
|
systemStats: SystemStats | null
|
||||||
|
dockerStats: DockerStats | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={`w-3 h-3 rounded-full ${pipelineStatus?.gitea_connected ? 'bg-green-500' : 'bg-yellow-500'}`}></span>
|
||||||
|
<span className="text-sm font-medium">Gitea Status</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-bold ${pipelineStatus?.gitea_connected ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||||
|
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">http://macmini:3003</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Pipeline Runs</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-blue-700">{pipelineStatus?.total_runs || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500">{pipelineStatus?.successful_runs || 0} erfolgreich</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Container</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-purple-700">{dockerStats?.running_containers || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500">von {dockerStats?.total_containers || 0} laufend</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Letztes Update</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-slate-700">
|
||||||
|
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Resources */}
|
||||||
|
{systemStats && (
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
Server Ressourcen ({systemStats.hostname})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white rounded-lg p-3">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-600">CPU</span>
|
||||||
|
<span className={`font-bold ${systemStats.cpu.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||||
|
{systemStats.cpu.usage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={systemStats.cpu.usage_percent} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-3">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-600">RAM</span>
|
||||||
|
<span className={`font-bold ${systemStats.memory.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||||
|
{systemStats.memory.usage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={systemStats.memory.usage_percent} color="purple" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg p-3">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-600">Disk</span>
|
||||||
|
<span className={`font-bold ${systemStats.disk.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
|
||||||
|
{systemStats.disk.usage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar percent={systemStats.disk.usage_percent} color="green" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Pipeline Runs */}
|
||||||
|
{pipelineHistory.length > 0 && (
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-3">Letzte Pipeline Runs</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pipelineHistory.slice(0, 5).map((run) => (
|
||||||
|
<div key={run.id} className="flex items-center justify-between bg-white p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
run.status === 'success' ? 'bg-green-500' :
|
||||||
|
run.status === 'failed' ? 'bg-red-500' :
|
||||||
|
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
||||||
|
}`}></span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-800">{run.workflow || 'SBOM Pipeline'}</p>
|
||||||
|
<p className="text-xs text-slate-500">{run.branch} - {run.commit_sha.substring(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
run.status === 'success' ? 'text-green-600' :
|
||||||
|
run.status === 'failed' ? 'text-red-600' :
|
||||||
|
run.status === 'running' ? 'text-yellow-600' : 'text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{run.status === 'success' ? 'Erfolgreich' :
|
||||||
|
run.status === 'failed' ? 'Fehlgeschlagen' :
|
||||||
|
run.status === 'running' ? 'Laeuft...' : run.status}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{new Date(run.started_at).toLocaleString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PipelineRun } from '../types'
|
||||||
|
|
||||||
|
export function PipelinesTab({
|
||||||
|
pipelineHistory,
|
||||||
|
triggeringPipeline,
|
||||||
|
onTriggerPipeline,
|
||||||
|
}: {
|
||||||
|
pipelineHistory: PipelineRun[]
|
||||||
|
triggeringPipeline: boolean
|
||||||
|
onTriggerPipeline: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Pipeline Controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">Gitea Actions Pipelines</h3>
|
||||||
|
<p className="text-sm text-slate-600">Workflows werden bei Push auf main/develop automatisch ausgefuehrt</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onTriggerPipeline}
|
||||||
|
disabled={triggeringPipeline}
|
||||||
|
className="px-4 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{triggeringPipeline ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
Laeuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Pipeline starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Pipelines */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||||
|
<span className="font-medium text-green-800">SBOM Pipeline</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-green-700 mb-2">Generiert Software Bill of Materials</p>
|
||||||
|
<p className="text-xs text-green-600">5 Jobs: generate, scan, license, upload, summary</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
||||||
|
<span className="font-medium text-slate-600">Test Pipeline</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-2">Unit & Integration Tests</p>
|
||||||
|
<p className="text-xs text-slate-400">Geplant</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
|
||||||
|
<span className="font-medium text-slate-600">Security Pipeline</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 mb-2">SAST, SCA, Secrets Scan</p>
|
||||||
|
<p className="text-xs text-slate-400">Geplant</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline History */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
|
||||||
|
{pipelineHistory.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Workflow</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Branch</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Commit</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Gestartet</th>
|
||||||
|
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Dauer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{pipelineHistory.map((run) => (
|
||||||
|
<tr key={run.id} className="hover:bg-white">
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
run.status === 'success' ? 'bg-green-100 text-green-800' :
|
||||||
|
run.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||||
|
run.status === 'running' ? 'bg-yellow-100 text-yellow-800' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
run.status === 'success' ? 'bg-green-500' :
|
||||||
|
run.status === 'failed' ? 'bg-red-500' :
|
||||||
|
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
|
||||||
|
}`}></span>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-900">{run.workflow || 'SBOM Pipeline'}</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-600">{run.branch}</td>
|
||||||
|
<td className="py-2 px-3 text-sm font-mono text-slate-500">{run.commit_sha.substring(0, 8)}</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-500">{new Date(run.started_at).toLocaleString('de-DE')}</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-slate-500">
|
||||||
|
{run.duration_seconds ? `${run.duration_seconds}s` : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline Architecture */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-3">SBOM Pipeline Architektur</h4>
|
||||||
|
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
|
{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
|
||||||
|
│
|
||||||
|
├── 1. generate-sbom → Syft generiert CycloneDX SBOM
|
||||||
|
│
|
||||||
|
├── 2. vulnerability-scan → Grype scannt auf CVEs
|
||||||
|
│
|
||||||
|
├── 3. license-check → Prueft GPL/AGPL Lizenzen
|
||||||
|
│
|
||||||
|
├── 4. upload-dashboard → POST /api/v1/security/sbom/upload
|
||||||
|
│
|
||||||
|
└── 5. summary → Job Summary generieren`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function SchedulerTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Status Overview */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold">launchd Job</h4>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-80">Taeglich um 07:00 Uhr automatisch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold">Git Hook</h4>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-80">Quick Tests bei voice-service Aenderungen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold">Benachrichtigungen</h4>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-1 opacity-80">Desktop-Alerts bei Fehlern aktiviert</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4">Quick Actions (BQAS)</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<a href="/ai/test-quality" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Test Dashboard oeffnen
|
||||||
|
</a>
|
||||||
|
<span className="text-sm text-slate-500 self-center">Starte Tests direkt im BQAS Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Actions vs Local - Comparison */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4">GitHub Actions Alternative</h3>
|
||||||
|
<p className="text-slate-600 mb-4">
|
||||||
|
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-white">
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
|
||||||
|
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
|
||||||
|
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
{ feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'bg-emerald-100 text-emerald-700', local: 'Nicht moeglich', localColor: 'bg-amber-100 text-amber-700' },
|
||||||
|
{ feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'bg-amber-100 text-amber-700', local: '100% lokal', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'bg-red-100 text-red-700', local: 'Ja', localColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.feature} className="border-b border-slate-100">
|
||||||
|
<td className="py-3 px-4 text-slate-600">{row.feature}</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<span className={`${row.ghColor ? `px-2 py-1 rounded text-xs font-medium ${row.ghColor}` : 'text-slate-600'}`}>{row.gh}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${row.localColor}`}>{row.local}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration Details */}
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-slate-800 mb-4">Konfiguration</h3>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-700 mb-3">launchd Job</h4>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
|
||||||
|
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
|
||||||
|
Label: com.breakpilot.bqas
|
||||||
|
Schedule: 07:00 taeglich
|
||||||
|
Script: /voice-service/scripts/run_bqas.sh
|
||||||
|
Logs: /var/log/bqas/`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-700 mb-3">Umgebungsvariablen</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_SERVICE_URL</span>
|
||||||
|
<span className="text-slate-900">http://localhost:8091</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_REGRESSION_THRESHOLD</span>
|
||||||
|
<span className="text-slate-900">0.1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_NOTIFY_DESKTOP</span>
|
||||||
|
<span className="text-emerald-600 font-medium">true</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between p-2 bg-white rounded">
|
||||||
|
<span className="font-mono text-slate-600">BQAS_NOTIFY_SLACK</span>
|
||||||
|
<span className="text-slate-400">false</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Explanation */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Detaillierte Erklaerung
|
||||||
|
</h3>
|
||||||
|
<div className="prose prose-sm max-w-none text-slate-700">
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
|
||||||
|
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
|
||||||
|
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
|
||||||
|
</p>
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-2 mb-4">
|
||||||
|
<li><strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet</li>
|
||||||
|
<li><strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet</li>
|
||||||
|
<li><strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet</li>
|
||||||
|
<li><strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet</li>
|
||||||
|
</ul>
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
|
||||||
|
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
|
||||||
|
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
|
||||||
|
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
|
||||||
|
<li>Keine GitHub-Kosten fuer private Repositories</li>
|
||||||
|
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
|
||||||
|
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { PipelineStatus } from '../types'
|
||||||
|
|
||||||
|
export function SetupTab({ pipelineStatus }: { pipelineStatus: PipelineStatus | null }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800 mb-2">Erstkonfiguration - Gitea CI/CD</h3>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gitea Server Info */}
|
||||||
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-800 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</svg>
|
||||||
|
Gitea Server
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-500">Web-URL</p>
|
||||||
|
<p className="font-mono text-blue-700">http://macmini:3003</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-500">SSH</p>
|
||||||
|
<p className="font-mono text-blue-700">macmini:2222</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<p className="text-sm text-slate-500">Status</p>
|
||||||
|
<p className={`font-medium ${pipelineStatus?.gitea_connected ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||||
|
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Implementierte Komponenten */}
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-3">Implementierte Komponenten</h4>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-600">Komponente</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-600">Pfad</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-slate-600">Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Gitea Service</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">Gitea 1.22 mit Actions enabled</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Gitea Runner</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">act_runner fuer Job-Ausfuehrung</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">SBOM Workflow</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">.gitea/workflows/sbom.yaml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">5 Jobs: generate, scan, license, upload, summary</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Backend API</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">backend/security_api.py</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">SBOM Upload, Pipeline Status, History</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 px-3 font-medium">Runner Config</td>
|
||||||
|
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">gitea/runner-config.yaml</code></td>
|
||||||
|
<td className="py-2 px-3 text-slate-600">Labels: ubuntu-latest, self-hosted</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup Steps */}
|
||||||
|
<div className="bg-orange-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-orange-800 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||||
|
</svg>
|
||||||
|
Setup-Schritte
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">1. Gitea oeffnen</h5>
|
||||||
|
<code className="text-sm bg-slate-100 px-2 py-1 rounded">http://macmini:3003</code>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">2. Admin-Account erstellen</h5>
|
||||||
|
<p className="text-sm text-slate-600">Username: admin, Email: admin@breakpilot.de</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">3. Repository erstellen</h5>
|
||||||
|
<p className="text-sm text-slate-600">Name: breakpilot-pwa, Visibility: Private</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">4. Actions aktivieren</h5>
|
||||||
|
<p className="text-sm text-slate-600">Repository Settings → Actions → Enable Repository Actions</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">5. Runner Token erstellen & starten</h5>
|
||||||
|
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
||||||
|
{`export GITEA_RUNNER_TOKEN=<token>
|
||||||
|
docker compose up -d gitea-runner`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg">
|
||||||
|
<h5 className="font-medium text-slate-800 mb-1">6. Repository pushen</h5>
|
||||||
|
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
|
||||||
|
{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
|
||||||
|
git push gitea main`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<h4 className="font-medium text-purple-800 mb-3">Quick Links</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<a href="http://macmini:3003" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-purple-800">Gitea</p>
|
||||||
|
<p className="text-xs text-slate-500">Git Server & CI/CD</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="http://macmini:3003/admin/breakpilot-pwa/actions" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-purple-800">Pipeline Actions</p>
|
||||||
|
<p className="text-xs text-slate-500">Workflow Runs</p>
|
||||||
|
</div>
|
||||||
|
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
72
admin-core/app/(admin)/infrastructure/ci-cd/types.ts
Normal file
72
admin-core/app/(admin)/infrastructure/ci-cd/types.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Types for CI/CD Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PipelineStatus {
|
||||||
|
gitea_connected: boolean
|
||||||
|
gitea_url: string
|
||||||
|
last_sbom_update: string | null
|
||||||
|
total_runs: number
|
||||||
|
successful_runs: number
|
||||||
|
failed_runs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineRun {
|
||||||
|
id: string
|
||||||
|
workflow: string
|
||||||
|
branch: string
|
||||||
|
commit_sha: string
|
||||||
|
status: 'success' | 'failed' | 'running' | 'pending'
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
duration_seconds: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image: string
|
||||||
|
status: string
|
||||||
|
state: string
|
||||||
|
created: string
|
||||||
|
ports: string[]
|
||||||
|
cpu_percent: number
|
||||||
|
memory_usage: string
|
||||||
|
memory_limit: string
|
||||||
|
memory_percent: number
|
||||||
|
network_rx: string
|
||||||
|
network_tx: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStats {
|
||||||
|
hostname: string
|
||||||
|
platform: string
|
||||||
|
arch: string
|
||||||
|
uptime: number
|
||||||
|
cpu: {
|
||||||
|
model: string
|
||||||
|
cores: number
|
||||||
|
usage_percent: number
|
||||||
|
}
|
||||||
|
memory: {
|
||||||
|
total: string
|
||||||
|
used: string
|
||||||
|
free: string
|
||||||
|
usage_percent: number
|
||||||
|
}
|
||||||
|
disk: {
|
||||||
|
total: string
|
||||||
|
used: string
|
||||||
|
free: string
|
||||||
|
usage_percent: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerStats {
|
||||||
|
containers: ContainerInfo[]
|
||||||
|
total_containers: number
|
||||||
|
running_containers: number
|
||||||
|
stopped_containers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabType = 'overview' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GPU Infrastructure Admin Page
|
|
||||||
*
|
|
||||||
* vast.ai GPU Management for LLM Processing
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
||||||
|
|
||||||
interface VastStatus {
|
|
||||||
instance_id: number | null
|
|
||||||
status: string
|
|
||||||
gpu_name: string | null
|
|
||||||
dph_total: number | null
|
|
||||||
endpoint_base_url: string | null
|
|
||||||
last_activity: string | null
|
|
||||||
auto_shutdown_in_minutes: number | null
|
|
||||||
total_runtime_hours: number | null
|
|
||||||
total_cost_usd: number | null
|
|
||||||
account_credit: number | null
|
|
||||||
account_total_spend: number | null
|
|
||||||
session_runtime_minutes: number | null
|
|
||||||
session_cost_usd: number | null
|
|
||||||
message: string | null
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GPUInfrastructurePage() {
|
|
||||||
const [status, setStatus] = useState<VastStatus | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [message, setMessage] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const API_PROXY = '/api/admin/gpu'
|
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_PROXY)
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || `HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(data)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
|
||||||
setStatus({
|
|
||||||
instance_id: null,
|
|
||||||
status: 'error',
|
|
||||||
gpu_name: null,
|
|
||||||
dph_total: null,
|
|
||||||
endpoint_base_url: null,
|
|
||||||
last_activity: null,
|
|
||||||
auto_shutdown_in_minutes: null,
|
|
||||||
total_runtime_hours: null,
|
|
||||||
total_cost_usd: null,
|
|
||||||
account_credit: null,
|
|
||||||
account_total_spend: null,
|
|
||||||
session_runtime_minutes: null,
|
|
||||||
session_cost_usd: null,
|
|
||||||
message: 'Verbindung fehlgeschlagen'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStatus()
|
|
||||||
}, [fetchStatus])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(fetchStatus, 30000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [fetchStatus])
|
|
||||||
|
|
||||||
const powerOn = async () => {
|
|
||||||
setActionLoading('on')
|
|
||||||
setError(null)
|
|
||||||
setMessage(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_PROXY, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'on' }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage('Start angefordert')
|
|
||||||
setTimeout(fetchStatus, 3000)
|
|
||||||
setTimeout(fetchStatus, 10000)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
|
|
||||||
fetchStatus()
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const powerOff = async () => {
|
|
||||||
setActionLoading('off')
|
|
||||||
setError(null)
|
|
||||||
setMessage(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_PROXY, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'off' }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage('Stop angefordert')
|
|
||||||
setTimeout(fetchStatus, 3000)
|
|
||||||
setTimeout(fetchStatus, 10000)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
|
|
||||||
fetchStatus()
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusBadge = (s: string) => {
|
|
||||||
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
|
|
||||||
switch (s) {
|
|
||||||
case 'running':
|
|
||||||
return `${baseClasses} bg-green-100 text-green-800`
|
|
||||||
case 'stopped':
|
|
||||||
case 'exited':
|
|
||||||
return `${baseClasses} bg-red-100 text-red-800`
|
|
||||||
case 'loading':
|
|
||||||
case 'scheduling':
|
|
||||||
case 'creating':
|
|
||||||
case 'starting...':
|
|
||||||
case 'stopping...':
|
|
||||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
|
||||||
default:
|
|
||||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCreditColor = (credit: number | null) => {
|
|
||||||
if (credit === null) return 'text-slate-500'
|
|
||||||
if (credit < 5) return 'text-red-600'
|
|
||||||
if (credit < 15) return 'text-yellow-600'
|
|
||||||
return 'text-green-600'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Page Purpose */}
|
|
||||||
<PagePurpose
|
|
||||||
title="GPU Infrastruktur"
|
|
||||||
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
|
|
||||||
audience={['DevOps', 'Entwickler', 'System-Admins']}
|
|
||||||
architecture={{
|
|
||||||
services: ['vast.ai API', 'Ollama', 'VLLM'],
|
|
||||||
databases: ['PostgreSQL (Logs)'],
|
|
||||||
}}
|
|
||||||
relatedPages={[
|
|
||||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
|
||||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
|
||||||
{ name: 'Builds', href: '/infrastructure/builds', description: 'CI/CD Pipeline' },
|
|
||||||
]}
|
|
||||||
collapsible={true}
|
|
||||||
defaultCollapsed={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Status Cards */}
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">Status</div>
|
|
||||||
{loading ? (
|
|
||||||
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
|
|
||||||
Laden...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className={getStatusBadge(
|
|
||||||
actionLoading === 'on' ? 'starting...' :
|
|
||||||
actionLoading === 'off' ? 'stopping...' :
|
|
||||||
status?.status || 'unknown'
|
|
||||||
)}>
|
|
||||||
{actionLoading === 'on' ? 'starting...' :
|
|
||||||
actionLoading === 'off' ? 'stopping...' :
|
|
||||||
status?.status || 'unbekannt'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">GPU</div>
|
|
||||||
<div className="font-semibold text-slate-900">
|
|
||||||
{status?.gpu_name || '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
|
|
||||||
<div className="font-semibold text-slate-900">
|
|
||||||
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
|
|
||||||
<div className="font-semibold text-slate-900">
|
|
||||||
{status && status.auto_shutdown_in_minutes !== null
|
|
||||||
? `${status.auto_shutdown_in_minutes} min`
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">Budget</div>
|
|
||||||
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
|
|
||||||
{status && status.account_credit !== null
|
|
||||||
? `$${status.account_credit.toFixed(2)}`
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-slate-500 mb-2">Session</div>
|
|
||||||
<div className="font-semibold text-slate-900">
|
|
||||||
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
|
|
||||||
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
|
|
||||||
: '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
|
|
||||||
<button
|
|
||||||
onClick={powerOn}
|
|
||||||
disabled={actionLoading !== null || status?.status === 'running'}
|
|
||||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
Starten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={powerOff}
|
|
||||||
disabled={actionLoading !== null || status?.status !== 'running'}
|
|
||||||
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
Stoppen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={fetchStatus}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Extended Stats */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">Session Laufzeit</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status && status.session_runtime_minutes !== null
|
|
||||||
? `${Math.round(status.session_runtime_minutes)} Minuten`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">Session Kosten</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status && status.session_cost_usd !== null
|
|
||||||
? `$${status.session_cost_usd.toFixed(4)}`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
|
|
||||||
<span className="text-slate-600">Gesamtlaufzeit</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status && status.total_runtime_hours !== null
|
|
||||||
? `${status.total_runtime_hours.toFixed(1)} Stunden`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">Gesamtkosten</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status && status.total_cost_usd !== null
|
|
||||||
? `$${status.total_cost_usd.toFixed(2)}`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">vast.ai Ausgaben</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status && status.account_total_spend !== null
|
|
||||||
? `$${status.account_total_spend.toFixed(2)}`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">Instanz ID</span>
|
|
||||||
<span className="font-mono text-sm">
|
|
||||||
{status?.instance_id || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">GPU</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status?.gpu_name || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">Stundensatz</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-slate-600">Letzte Aktivitaet</span>
|
|
||||||
<span className="text-sm">
|
|
||||||
{status?.last_activity
|
|
||||||
? new Date(status.last_activity).toLocaleString('de-DE')
|
|
||||||
: '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{status?.endpoint_base_url && status.status === 'running' && (
|
|
||||||
<div className="pt-4 border-t border-slate-100">
|
|
||||||
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
|
|
||||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
|
|
||||||
{status.endpoint_base_url}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<svg className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-orange-900">Auto-Shutdown</h4>
|
|
||||||
<p className="text-sm text-orange-800 mt-1">
|
|
||||||
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
|
|
||||||
Der Status wird alle 30 Sekunden automatisch aktualisiert.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { MiddlewareConfig } from '../types'
|
||||||
|
import { getMiddlewareDescription } from './helpers'
|
||||||
|
|
||||||
|
interface ConfigTabProps {
|
||||||
|
configs: MiddlewareConfig[]
|
||||||
|
actionLoading: string | null
|
||||||
|
onToggle: (name: string, enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigTab({ configs, actionLoading, onToggle }: ConfigTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{configs.map(config => {
|
||||||
|
const info = getMiddlewareDescription(config.middleware_name)
|
||||||
|
return (
|
||||||
|
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<span>{info.icon}</span>
|
||||||
|
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(config.middleware_name, !config.enabled)}
|
||||||
|
disabled={actionLoading === config.middleware_name}
|
||||||
|
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === config.middleware_name
|
||||||
|
? '...'
|
||||||
|
: config.enabled
|
||||||
|
? 'Deaktivieren'
|
||||||
|
: 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Object.keys(config.config).length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-200">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||||
|
Konfiguration
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
||||||
|
{JSON.stringify(config.config, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { MiddlewareEvent } from '../types'
|
||||||
|
import { getEventTypeColor } from './helpers'
|
||||||
|
|
||||||
|
interface EventsTabProps {
|
||||||
|
events: MiddlewareEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsTab({ events }: EventsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Zeit
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Middleware
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Event
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
IP
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Pfad
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
Keine Events vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
events.map(event => (
|
||||||
|
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{new Date(event.created_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm capitalize">
|
||||||
|
{event.middleware_name.replace('_', ' ')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
||||||
|
>
|
||||||
|
{event.event_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
||||||
|
{event.ip_address || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
||||||
|
{event.request_method && event.request_path
|
||||||
|
? `${event.request_method} ${event.request_path}`
|
||||||
|
: '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { RateLimitIP } from '../types'
|
||||||
|
|
||||||
|
interface IpListTabProps {
|
||||||
|
ipList: RateLimitIP[]
|
||||||
|
actionLoading: string | null
|
||||||
|
newIP: string
|
||||||
|
newIPType: 'whitelist' | 'blacklist'
|
||||||
|
newIPReason: string
|
||||||
|
onNewIPChange: (value: string) => void
|
||||||
|
onNewIPTypeChange: (value: 'whitelist' | 'blacklist') => void
|
||||||
|
onNewIPReasonChange: (value: string) => void
|
||||||
|
onAddIP: (e: React.FormEvent) => void
|
||||||
|
onRemoveIP: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IpListTab({
|
||||||
|
ipList,
|
||||||
|
actionLoading,
|
||||||
|
newIP,
|
||||||
|
newIPType,
|
||||||
|
newIPReason,
|
||||||
|
onNewIPChange,
|
||||||
|
onNewIPTypeChange,
|
||||||
|
onNewIPReasonChange,
|
||||||
|
onAddIP,
|
||||||
|
onRemoveIP,
|
||||||
|
}: IpListTabProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Add IP Form */}
|
||||||
|
<form onSubmit={onAddIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newIP}
|
||||||
|
onChange={e => onNewIPChange(e.target.value)}
|
||||||
|
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
||||||
|
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newIPType}
|
||||||
|
onChange={e => onNewIPTypeChange(e.target.value as 'whitelist' | 'blacklist')}
|
||||||
|
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
<option value="whitelist">Whitelist</option>
|
||||||
|
<option value="blacklist">Blacklist</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newIPReason}
|
||||||
|
onChange={e => onNewIPReasonChange(e.target.value)}
|
||||||
|
placeholder="Grund (optional)"
|
||||||
|
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
||||||
|
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* IP List Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
IP-Adresse
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Typ
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Grund
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Hinzugefuegt
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
Aktion
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ipList.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||||
|
Keine IP-Eintraege vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
ipList.map(ip => (
|
||||||
|
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||||
|
ip.list_type === 'whitelist'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{new Date(ip.created_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveIP(ip.id)}
|
||||||
|
disabled={actionLoading === `remove-${ip.id}`}
|
||||||
|
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { MiddlewareConfig } from '../types'
|
||||||
|
import { getMiddlewareDescription } from './helpers'
|
||||||
|
|
||||||
|
interface OverviewTabProps {
|
||||||
|
configs: MiddlewareConfig[]
|
||||||
|
actionLoading: string | null
|
||||||
|
onToggle: (name: string, enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({ configs, actionLoading, onToggle }: OverviewTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{configs.map(config => {
|
||||||
|
const info = getMiddlewareDescription(config.middleware_name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={config.id}
|
||||||
|
className={`rounded-lg p-4 border ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-slate-50 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{info.icon}</span>
|
||||||
|
<span className="font-semibold text-slate-900 capitalize">
|
||||||
|
{config.middleware_name.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(config.middleware_name, !config.enabled)}
|
||||||
|
disabled={actionLoading === config.middleware_name}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
||||||
|
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{actionLoading === config.middleware_name
|
||||||
|
? '...'
|
||||||
|
: config.enabled
|
||||||
|
? 'Aktiv'
|
||||||
|
: 'Inaktiv'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
{config.updated_at && (
|
||||||
|
<div className="mt-2 text-xs text-slate-400">
|
||||||
|
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { MiddlewareStats } from '../types'
|
||||||
|
import { getMiddlewareDescription, getEventTypeColor } from './helpers'
|
||||||
|
|
||||||
|
interface StatsTabProps {
|
||||||
|
stats: MiddlewareStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsTab({ stats }: StatsTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{stats.map(stat => {
|
||||||
|
const info = getMiddlewareDescription(stat.middleware_name)
|
||||||
|
return (
|
||||||
|
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
||||||
|
<span>{info.icon}</span>
|
||||||
|
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
||||||
|
<div className="text-xs text-slate-500">Gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
||||||
|
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
||||||
|
<div className="text-xs text-slate-500">24 Stunden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stat.top_event_types.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
||||||
|
Top Event-Typen
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stat.top_event_types.slice(0, 3).map(et => (
|
||||||
|
<span
|
||||||
|
key={et.event_type}
|
||||||
|
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
||||||
|
>
|
||||||
|
{et.event_type} ({et.count})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stat.top_ips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{stat.top_ips
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(ip => `${ip.ip_address} (${ip.count})`)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
interface StatusOverviewProps {
|
||||||
|
configCount: number
|
||||||
|
whitelistCount: number
|
||||||
|
blacklistCount: number
|
||||||
|
eventCount: number
|
||||||
|
loading: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusOverview({
|
||||||
|
configCount,
|
||||||
|
whitelistCount,
|
||||||
|
blacklistCount,
|
||||||
|
eventCount,
|
||||||
|
loading,
|
||||||
|
onRefresh,
|
||||||
|
}: StatusOverviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : 'Aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="text-2xl font-bold text-slate-900">{configCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Middleware</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
||||||
|
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{eventCount}</div>
|
||||||
|
<div className="text-sm text-slate-600">Recent Events</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function getMiddlewareDescription(name: string): { icon: string; desc: string } {
|
||||||
|
const descriptions: Record<string, { icon: string; desc: string }> = {
|
||||||
|
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
||||||
|
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
||||||
|
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
||||||
|
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
||||||
|
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
||||||
|
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
||||||
|
}
|
||||||
|
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventTypeColor(eventType: string) {
|
||||||
|
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
}
|
||||||
|
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
||||||
|
return 'bg-yellow-100 text-yellow-800'
|
||||||
|
}
|
||||||
|
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
||||||
|
return 'bg-green-100 text-green-800'
|
||||||
|
}
|
||||||
|
return 'bg-slate-100 text-slate-800'
|
||||||
|
}
|
||||||
@@ -9,44 +9,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||||
|
import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats } from './types'
|
||||||
interface MiddlewareConfig {
|
import { StatusOverview } from './_components/StatusOverview'
|
||||||
id: string
|
import { OverviewTab } from './_components/OverviewTab'
|
||||||
middleware_name: string
|
import { ConfigTab } from './_components/ConfigTab'
|
||||||
enabled: boolean
|
import { IpListTab } from './_components/IpListTab'
|
||||||
config: Record<string, unknown>
|
import { EventsTab } from './_components/EventsTab'
|
||||||
updated_at: string | null
|
import { StatsTab } from './_components/StatsTab'
|
||||||
}
|
|
||||||
|
|
||||||
interface RateLimitIP {
|
|
||||||
id: string
|
|
||||||
ip_address: string
|
|
||||||
list_type: 'whitelist' | 'blacklist'
|
|
||||||
reason: string | null
|
|
||||||
expires_at: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiddlewareEvent {
|
|
||||||
id: string
|
|
||||||
middleware_name: string
|
|
||||||
event_type: string
|
|
||||||
ip_address: string | null
|
|
||||||
user_id: string | null
|
|
||||||
request_path: string | null
|
|
||||||
request_method: string | null
|
|
||||||
details: Record<string, unknown> | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiddlewareStats {
|
|
||||||
middleware_name: string
|
|
||||||
total_events: number
|
|
||||||
events_last_hour: number
|
|
||||||
events_last_24h: number
|
|
||||||
top_event_types: Array<{ event_type: string; count: number }>
|
|
||||||
top_ips: Array<{ ip_address: string; count: number }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MiddlewareAdminPage() {
|
export default function MiddlewareAdminPage() {
|
||||||
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
const [configs, setConfigs] = useState<MiddlewareConfig[]>([])
|
||||||
@@ -184,31 +153,6 @@ export default function MiddlewareAdminPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMiddlewareDescription = (name: string): { icon: string; desc: string } => {
|
|
||||||
const descriptions: Record<string, { icon: string; desc: string }> = {
|
|
||||||
request_id: { icon: '🆔', desc: 'Generiert eindeutige Request-IDs fuer Tracing' },
|
|
||||||
security_headers: { icon: '🛡️', desc: 'Fuegt Security-Header hinzu (CSP, HSTS, etc.)' },
|
|
||||||
cors: { icon: '🌐', desc: 'Cross-Origin Resource Sharing Konfiguration' },
|
|
||||||
rate_limiter: { icon: '⏱️', desc: 'Rate Limiting zum Schutz vor Missbrauch' },
|
|
||||||
pii_redactor: { icon: '🔒', desc: 'Redaktiert personenbezogene Daten in Logs' },
|
|
||||||
input_gate: { icon: '🚪', desc: 'Validiert und sanitisiert Eingaben' },
|
|
||||||
}
|
|
||||||
return descriptions[name] || { icon: '⚙️', desc: 'Middleware-Komponente' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEventTypeColor = (eventType: string) => {
|
|
||||||
if (eventType.includes('error') || eventType.includes('blocked') || eventType.includes('blacklist')) {
|
|
||||||
return 'bg-red-100 text-red-800'
|
|
||||||
}
|
|
||||||
if (eventType.includes('warning') || eventType.includes('rate_limit')) {
|
|
||||||
return 'bg-yellow-100 text-yellow-800'
|
|
||||||
}
|
|
||||||
if (eventType.includes('success') || eventType.includes('whitelist')) {
|
|
||||||
return 'bg-green-100 text-green-800'
|
|
||||||
}
|
|
||||||
return 'bg-slate-100 text-slate-800'
|
|
||||||
}
|
|
||||||
|
|
||||||
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
const whitelistCount = ipList.filter(ip => ip.list_type === 'whitelist').length
|
||||||
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
const blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
||||||
|
|
||||||
@@ -232,38 +176,14 @@ export default function MiddlewareAdminPage() {
|
|||||||
defaultCollapsed={true}
|
defaultCollapsed={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
<StatusOverview
|
||||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
configCount={configs.length}
|
||||||
<div className="flex justify-between items-center mb-6">
|
whitelistCount={whitelistCount}
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Middleware Status</h2>
|
blacklistCount={blacklistCount}
|
||||||
<button
|
eventCount={events.length}
|
||||||
onClick={fetchData}
|
loading={loading}
|
||||||
disabled={loading}
|
onRefresh={fetchData}
|
||||||
className="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
/>
|
||||||
>
|
|
||||||
{loading ? 'Laden...' : 'Aktualisieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{configs.length}</div>
|
|
||||||
<div className="text-sm text-slate-600">Middleware</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
|
||||||
<div className="text-2xl font-bold text-green-600">{whitelistCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Whitelist IPs</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-red-50 rounded-lg p-4 border border-red-200">
|
|
||||||
<div className="text-2xl font-bold text-red-600">{blacklistCount}</div>
|
|
||||||
<div className="text-sm text-slate-600">Blacklist IPs</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{events.length}</div>
|
|
||||||
<div className="text-sm text-slate-600">Recent Events</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
||||||
@@ -298,332 +218,28 @@ export default function MiddlewareAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Overview Tab */}
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<OverviewTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
||||||
{configs.map(config => {
|
|
||||||
const info = getMiddlewareDescription(config.middleware_name)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={config.id}
|
|
||||||
className={`rounded-lg p-4 border ${
|
|
||||||
config.enabled
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: 'bg-slate-50 border-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">{info.icon}</span>
|
|
||||||
<span className="font-semibold text-slate-900 capitalize">
|
|
||||||
{config.middleware_name.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
|
||||||
disabled={actionLoading === config.middleware_name}
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold transition-colors ${
|
|
||||||
config.enabled
|
|
||||||
? 'bg-green-200 text-green-800 hover:bg-green-300'
|
|
||||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{actionLoading === config.middleware_name
|
|
||||||
? '...'
|
|
||||||
: config.enabled
|
|
||||||
? 'Aktiv'
|
|
||||||
: 'Inaktiv'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
{config.updated_at && (
|
|
||||||
<div className="mt-2 text-xs text-slate-400">
|
|
||||||
Aktualisiert: {new Date(config.updated_at).toLocaleString('de-DE')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Config Tab */}
|
|
||||||
{activeTab === 'config' && (
|
{activeTab === 'config' && (
|
||||||
<div className="space-y-4">
|
<ConfigTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
||||||
{configs.map(config => {
|
|
||||||
const info = getMiddlewareDescription(config.middleware_name)
|
|
||||||
return (
|
|
||||||
<div key={config.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<div className="flex justify-between items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
|
||||||
<span>{info.icon}</span>
|
|
||||||
<span className="capitalize">{config.middleware_name.replace('_', ' ')}</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-slate-600">{info.desc}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{config.enabled ? 'Aktiviert' : 'Deaktiviert'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleMiddleware(config.middleware_name, !config.enabled)}
|
|
||||||
disabled={actionLoading === config.middleware_name}
|
|
||||||
className="px-4 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-100 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === config.middleware_name
|
|
||||||
? '...'
|
|
||||||
: config.enabled
|
|
||||||
? 'Deaktivieren'
|
|
||||||
: 'Aktivieren'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{Object.keys(config.config).length > 0 && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-200">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
|
||||||
Konfiguration
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs bg-white p-3 rounded border border-slate-200 overflow-x-auto">
|
|
||||||
{JSON.stringify(config.config, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* IP List Tab */}
|
|
||||||
{activeTab === 'ip-list' && (
|
{activeTab === 'ip-list' && (
|
||||||
<div>
|
<IpListTab
|
||||||
{/* Add IP Form */}
|
ipList={ipList}
|
||||||
<form onSubmit={addIP} className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
|
actionLoading={actionLoading}
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">IP hinzufuegen</h3>
|
newIP={newIP}
|
||||||
<div className="flex flex-wrap gap-3">
|
newIPType={newIPType}
|
||||||
<input
|
newIPReason={newIPReason}
|
||||||
type="text"
|
onNewIPChange={setNewIP}
|
||||||
value={newIP}
|
onNewIPTypeChange={setNewIPType}
|
||||||
onChange={e => setNewIP(e.target.value)}
|
onNewIPReasonChange={setNewIPReason}
|
||||||
placeholder="IP-Adresse (z.B. 192.168.1.1)"
|
onAddIP={addIP}
|
||||||
className="flex-1 min-w-[200px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
onRemoveIP={removeIP}
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
value={newIPType}
|
|
||||||
onChange={e => setNewIPType(e.target.value as 'whitelist' | 'blacklist')}
|
|
||||||
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
>
|
|
||||||
<option value="whitelist">Whitelist</option>
|
|
||||||
<option value="blacklist">Blacklist</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newIPReason}
|
|
||||||
onChange={e => setNewIPReason(e.target.value)}
|
|
||||||
placeholder="Grund (optional)"
|
|
||||||
className="flex-1 min-w-[150px] px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!newIP.trim() || actionLoading === 'add-ip'}
|
|
||||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{actionLoading === 'add-ip' ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* IP List Table */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
IP-Adresse
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Typ
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Grund
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Hinzugefuegt
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Aktion
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{ipList.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
|
||||||
Keine IP-Eintraege vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
ipList.map(ip => (
|
|
||||||
<tr key={ip.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 font-mono text-sm">{ip.ip_address}</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
|
||||||
ip.list_type === 'whitelist'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ip.list_type === 'whitelist' ? 'Whitelist' : 'Blacklist'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600">{ip.reason || '-'}</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{new Date(ip.created_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => removeIP(ip.id)}
|
|
||||||
disabled={actionLoading === `remove-${ip.id}`}
|
|
||||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{actionLoading === `remove-${ip.id}` ? '...' : 'Entfernen'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Events Tab */}
|
|
||||||
{activeTab === 'events' && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-200">
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Zeit
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Middleware
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Event
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
IP
|
|
||||||
</th>
|
|
||||||
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">
|
|
||||||
Pfad
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{events.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
|
||||||
Keine Events vorhanden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
events.map(event => (
|
|
||||||
<tr key={event.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-500">
|
|
||||||
{new Date(event.created_at).toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm capitalize">
|
|
||||||
{event.middleware_name.replace('_', ' ')}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 rounded text-xs font-semibold ${getEventTypeColor(event.event_type)}`}
|
|
||||||
>
|
|
||||||
{event.event_type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm font-mono text-slate-600">
|
|
||||||
{event.ip_address || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-sm text-slate-600 max-w-xs truncate">
|
|
||||||
{event.request_method && event.request_path
|
|
||||||
? `${event.request_method} ${event.request_path}`
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats Tab */}
|
|
||||||
{activeTab === 'stats' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{stats.map(stat => {
|
|
||||||
const info = getMiddlewareDescription(stat.middleware_name)
|
|
||||||
return (
|
|
||||||
<div key={stat.middleware_name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
|
||||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
|
|
||||||
<span>{info.icon}</span>
|
|
||||||
<span className="capitalize">{stat.middleware_name.replace('_', ' ')}</span>
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-slate-900">{stat.total_events}</div>
|
|
||||||
<div className="text-xs text-slate-500">Gesamt</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{stat.events_last_hour}</div>
|
|
||||||
<div className="text-xs text-slate-500">Letzte Stunde</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-orange-600">{stat.events_last_24h}</div>
|
|
||||||
<div className="text-xs text-slate-500">24 Stunden</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{stat.top_event_types.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">
|
|
||||||
Top Event-Typen
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{stat.top_event_types.slice(0, 3).map(et => (
|
|
||||||
<span
|
|
||||||
key={et.event_type}
|
|
||||||
className={`px-2 py-1 rounded text-xs ${getEventTypeColor(et.event_type)}`}
|
|
||||||
>
|
|
||||||
{et.event_type} ({et.count})
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stat.top_ips.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase mb-2">Top IPs</div>
|
|
||||||
<div className="text-xs text-slate-600">
|
|
||||||
{stat.top_ips
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(ip => `${ip.ip_address} (${ip.count})`)
|
|
||||||
.join(', ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'events' && <EventsTab events={events} />}
|
||||||
|
{activeTab === 'stats' && <StatsTab stats={stats} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
37
admin-core/app/(admin)/infrastructure/middleware/types.ts
Normal file
37
admin-core/app/(admin)/infrastructure/middleware/types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface MiddlewareConfig {
|
||||||
|
id: string
|
||||||
|
middleware_name: string
|
||||||
|
enabled: boolean
|
||||||
|
config: Record<string, unknown>
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitIP {
|
||||||
|
id: string
|
||||||
|
ip_address: string
|
||||||
|
list_type: 'whitelist' | 'blacklist'
|
||||||
|
reason: string | null
|
||||||
|
expires_at: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiddlewareEvent {
|
||||||
|
id: string
|
||||||
|
middleware_name: string
|
||||||
|
event_type: string
|
||||||
|
ip_address: string | null
|
||||||
|
user_id: string | null
|
||||||
|
request_path: string | null
|
||||||
|
request_method: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiddlewareStats {
|
||||||
|
middleware_name: string
|
||||||
|
total_events: number
|
||||||
|
events_last_hour: number
|
||||||
|
events_last_24h: number
|
||||||
|
top_event_types: Array<{ event_type: string; count: number }>
|
||||||
|
top_ips: Array<{ ip_address: string; count: number }>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Component } from '../types'
|
||||||
|
|
||||||
|
export const GO_MODULES: Component[] = [
|
||||||
|
{ type: 'library', name: 'gin-gonic/gin', version: '1.9+', category: 'go', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/gin-gonic/gin' },
|
||||||
|
{ type: 'library', name: 'gorm.io/gorm', version: '1.25+', category: 'go', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/go-gorm/gorm' },
|
||||||
|
{ type: 'library', name: 'golang-jwt/jwt', version: 'v5', category: 'go', description: 'JWT Library', license: 'MIT', sourceUrl: 'https://github.com/golang-jwt/jwt' },
|
||||||
|
{ type: 'library', name: 'stripe/stripe-go', version: 'v76', category: 'go', description: 'Stripe SDK', license: 'MIT', sourceUrl: 'https://github.com/stripe/stripe-go' },
|
||||||
|
{ type: 'library', name: 'spf13/viper', version: 'latest', category: 'go', description: 'Configuration', license: 'MIT', sourceUrl: 'https://github.com/spf13/viper' },
|
||||||
|
{ type: 'library', name: 'uber-go/zap', version: 'latest', category: 'go', description: 'Structured Logging', license: 'MIT', sourceUrl: 'https://github.com/uber-go/zap' },
|
||||||
|
{ type: 'library', name: 'swaggo/swag', version: 'latest', category: 'go', description: 'Swagger Docs', license: 'MIT', sourceUrl: 'https://github.com/swaggo/swag' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NODE_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: 'Next.js', version: '15.1', category: 'nodejs', description: 'React Framework', license: 'MIT', sourceUrl: 'https://github.com/vercel/next.js' },
|
||||||
|
{ type: 'library', name: 'React', version: '19', category: 'nodejs', description: 'UI Library', license: 'MIT', sourceUrl: 'https://github.com/facebook/react' },
|
||||||
|
{ type: 'library', name: 'Vue.js', version: '3.4', category: 'nodejs', description: 'UI Framework (Creator Studio)', license: 'MIT', sourceUrl: 'https://github.com/vuejs/core' },
|
||||||
|
{ type: 'library', name: 'Angular', version: '17', category: 'nodejs', description: 'UI Framework (Policy Vault)', license: 'MIT', sourceUrl: 'https://github.com/angular/angular' },
|
||||||
|
{ type: 'library', name: 'NestJS', version: '10', category: 'nodejs', description: 'Node.js Framework', license: 'MIT', sourceUrl: 'https://github.com/nestjs/nest' },
|
||||||
|
{ type: 'library', name: 'TypeScript', version: '5.x', category: 'nodejs', description: 'Type System', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/TypeScript' },
|
||||||
|
{ type: 'library', name: 'Tailwind CSS', version: '3.4', category: 'nodejs', description: 'Utility CSS', license: 'MIT', sourceUrl: 'https://github.com/tailwindlabs/tailwindcss' },
|
||||||
|
{ type: 'library', name: 'Prisma', version: '5.x', category: 'nodejs', description: 'ORM (Policy Vault)', license: 'Apache-2.0', sourceUrl: 'https://github.com/prisma/prisma' },
|
||||||
|
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
|
||||||
|
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
|
||||||
|
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
|
||||||
|
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
|
||||||
|
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
|
||||||
|
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
|
||||||
|
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
|
||||||
|
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UNITY_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: 'Unity Engine', version: '6000.0 (Unity 6)', category: 'unity', description: 'Game Engine', license: 'Unity EULA', sourceUrl: 'https://unity.com' },
|
||||||
|
{ type: 'library', name: 'Universal Render Pipeline (URP)', version: '17.x', category: 'unity', description: 'Render Pipeline', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@17.0' },
|
||||||
|
{ type: 'library', name: 'TextMeshPro', version: '3.2', category: 'unity', description: 'Advanced Text Rendering', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.2' },
|
||||||
|
{ type: 'library', name: 'Unity Mathematics', version: '1.3', category: 'unity', description: 'Math Library (SIMD)', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.mathematics@1.3' },
|
||||||
|
{ type: 'library', name: 'Newtonsoft.Json (Unity)', version: '3.2', category: 'unity', description: 'JSON Serialization', license: 'MIT', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2' },
|
||||||
|
{ type: 'library', name: 'Unity UI', version: '2.0', category: 'unity', description: 'UI System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.ugui@2.0' },
|
||||||
|
{ type: 'library', name: 'Unity Input System', version: '1.8', category: 'unity', description: 'New Input System', license: 'Unity Companion', sourceUrl: 'https://docs.unity3d.com/Packages/com.unity.inputsystem@1.8' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CSHARP_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: '.NET Standard', version: '2.1', category: 'csharp', description: 'Runtime', license: 'MIT', sourceUrl: 'https://github.com/dotnet/standard' },
|
||||||
|
{ type: 'library', name: 'UnityWebRequest', version: 'built-in', category: 'csharp', description: 'HTTP Client', license: 'Unity Companion', sourceUrl: '-' },
|
||||||
|
{ type: 'library', name: 'System.Text.Json', version: 'built-in', category: 'csharp', description: 'JSON Parsing', license: 'MIT', sourceUrl: 'https://github.com/dotnet/runtime' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Component } from '../types'
|
||||||
|
|
||||||
|
export const PYTHON_PACKAGES: Component[] = [
|
||||||
|
{ type: 'library', name: 'FastAPI', version: '0.109+', category: 'python', description: 'Web Framework', license: 'MIT', sourceUrl: 'https://github.com/tiangolo/fastapi' },
|
||||||
|
{ type: 'library', name: 'Uvicorn', version: '0.38+', category: 'python', description: 'ASGI Server', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/uvicorn' },
|
||||||
|
{ type: 'library', name: 'Starlette', version: '0.49+', category: 'python', description: 'ASGI Framework (FastAPI Basis)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/starlette' },
|
||||||
|
{ type: 'library', name: 'Pydantic', version: '2.x', category: 'python', description: 'Data Validation', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic' },
|
||||||
|
{ type: 'library', name: 'SQLAlchemy', version: '2.x', category: 'python', description: 'ORM', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/sqlalchemy' },
|
||||||
|
{ type: 'library', name: 'Alembic', version: '1.14+', category: 'python', description: 'DB Migrations (Classroom, Feedback Tables)', license: 'MIT', sourceUrl: 'https://github.com/sqlalchemy/alembic' },
|
||||||
|
{ type: 'library', name: 'psycopg2-binary', version: '2.9+', category: 'python', description: 'PostgreSQL Driver', license: 'LGPL-3.0', sourceUrl: 'https://github.com/psycopg/psycopg2' },
|
||||||
|
{ type: 'library', name: 'httpx', version: 'latest', category: 'python', description: 'Async HTTP Client', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/encode/httpx' },
|
||||||
|
{ type: 'library', name: 'PyJWT', version: 'latest', category: 'python', description: 'JWT Handling', license: 'MIT', sourceUrl: 'https://github.com/jpadilla/pyjwt' },
|
||||||
|
{ type: 'library', name: 'hvac', version: 'latest', category: 'python', description: 'Vault Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/hvac/hvac' },
|
||||||
|
{ type: 'library', name: 'python-multipart', version: 'latest', category: 'python', description: 'File Uploads', license: 'Apache-2.0', sourceUrl: 'https://github.com/andrew-d/python-multipart' },
|
||||||
|
{ type: 'library', name: 'aiofiles', version: 'latest', category: 'python', description: 'Async File I/O', license: 'Apache-2.0', sourceUrl: 'https://github.com/Tinche/aiofiles' },
|
||||||
|
{ type: 'library', name: 'openai', version: 'latest', category: 'python', description: 'OpenAI SDK', license: 'MIT', sourceUrl: 'https://github.com/openai/openai-python' },
|
||||||
|
{ type: 'library', name: 'anthropic', version: 'latest', category: 'python', description: 'Anthropic Claude SDK', license: 'MIT', sourceUrl: 'https://github.com/anthropics/anthropic-sdk-python' },
|
||||||
|
{ type: 'library', name: 'langchain', version: 'latest', category: 'python', description: 'LLM Framework', license: 'MIT', sourceUrl: 'https://github.com/langchain-ai/langchain' },
|
||||||
|
{ type: 'library', name: 'aioimaplib', version: 'latest', category: 'python', description: 'Async IMAP Client (Unified Inbox)', license: 'MIT', sourceUrl: 'https://github.com/bamthomas/aioimaplib' },
|
||||||
|
{ type: 'library', name: 'aiosmtplib', version: 'latest', category: 'python', description: 'Async SMTP Client (Mail Sending)', license: 'MIT', sourceUrl: 'https://github.com/cole/aiosmtplib' },
|
||||||
|
{ type: 'library', name: 'email-validator', version: 'latest', category: 'python', description: 'Email Validation', license: 'CC0-1.0', sourceUrl: 'https://github.com/JoshData/python-email-validator' },
|
||||||
|
{ type: 'library', name: 'cryptography', version: 'latest', category: 'python', description: 'Encryption (Mail Credentials)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pyca/cryptography' },
|
||||||
|
{ type: 'library', name: 'asyncpg', version: 'latest', category: 'python', description: 'Async PostgreSQL Driver', license: 'Apache-2.0', sourceUrl: 'https://github.com/MagicStack/asyncpg' },
|
||||||
|
{ type: 'library', name: 'python-dateutil', version: 'latest', category: 'python', description: 'Date Parsing (Deadline Extraction)', license: 'Apache-2.0', sourceUrl: 'https://github.com/dateutil/dateutil' },
|
||||||
|
{ type: 'library', name: 'faster-whisper', version: '1.0+', category: 'python', description: 'CTranslate2 Whisper (GPU-optimiert)', license: 'MIT', sourceUrl: 'https://github.com/SYSTRAN/faster-whisper' },
|
||||||
|
{ type: 'library', name: 'pyannote.audio', version: '3.x', category: 'python', description: 'Speaker Diarization', license: 'MIT', sourceUrl: 'https://github.com/pyannote/pyannote-audio' },
|
||||||
|
{ type: 'library', name: 'rq', version: '1.x', category: 'python', description: 'Redis Queue (Task Processing)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/rq/rq' },
|
||||||
|
{ type: 'library', name: 'ffmpeg-python', version: '0.2+', category: 'python', description: 'FFmpeg Python Bindings', license: 'Apache-2.0', sourceUrl: 'https://github.com/kkroening/ffmpeg-python' },
|
||||||
|
{ type: 'library', name: 'webvtt-py', version: '0.4+', category: 'python', description: 'WebVTT Subtitle Export', license: 'MIT', sourceUrl: 'https://github.com/glut23/webvtt-py' },
|
||||||
|
{ type: 'library', name: 'minio', version: '7.x', category: 'python', description: 'MinIO S3 Client', license: 'Apache-2.0', sourceUrl: 'https://github.com/minio/minio-py' },
|
||||||
|
{ type: 'library', name: 'structlog', version: '24.x', category: 'python', description: 'Structured Logging', license: 'Apache-2.0', sourceUrl: 'https://github.com/hynek/structlog' },
|
||||||
|
{ type: 'library', name: 'feedparser', version: '6.x', category: 'python', description: 'RSS/Atom Feed Parser (Alerts Agent)', license: 'BSD-2-Clause', sourceUrl: 'https://github.com/kurtmckee/feedparser' },
|
||||||
|
{ type: 'library', name: 'APScheduler', version: '3.x', category: 'python', description: 'AsyncIO Job Scheduler (Alerts Agent)', license: 'MIT', sourceUrl: 'https://github.com/agronholm/apscheduler' },
|
||||||
|
{ type: 'library', name: 'beautifulsoup4', version: '4.x', category: 'python', description: 'HTML Parser (Email Parsing, Compliance Scraper)', license: 'MIT', sourceUrl: 'https://code.launchpad.net/beautifulsoup' },
|
||||||
|
{ type: 'library', name: 'lxml', version: '5.x', category: 'python', description: 'XML/HTML Parser (EUR-Lex Scraping)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/lxml/lxml' },
|
||||||
|
{ type: 'library', name: 'PyMuPDF', version: '1.24+', category: 'python', description: 'PDF Parser (BSI-TR Extraction)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/pymupdf/PyMuPDF' },
|
||||||
|
{ type: 'library', name: 'pdfplumber', version: '0.11+', category: 'python', description: 'PDF Table Extraction (Compliance Docs)', license: 'MIT', sourceUrl: 'https://github.com/jsvine/pdfplumber' },
|
||||||
|
{ type: 'library', name: 'websockets', version: '14.x', category: 'python', description: 'WebSocket Support (Voice Streaming)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/python-websockets/websockets' },
|
||||||
|
{ type: 'library', name: 'soundfile', version: '0.13+', category: 'python', description: 'Audio File Processing (Voice Service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/bastibe/python-soundfile' },
|
||||||
|
{ type: 'library', name: 'scipy', version: '1.14+', category: 'python', description: 'Signal Processing (Audio)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/scipy/scipy' },
|
||||||
|
{ type: 'library', name: 'redis', version: '5.x', category: 'python', description: 'Valkey/Redis Client (Voice Sessions)', license: 'MIT', sourceUrl: 'https://github.com/redis/redis-py' },
|
||||||
|
{ type: 'library', name: 'pydantic-settings', version: '2.x', category: 'python', description: 'Settings Management (Voice Config)', license: 'MIT', sourceUrl: 'https://github.com/pydantic/pydantic-settings' },
|
||||||
|
{ type: 'library', name: 'pyspellchecker', version: '0.8.1+', category: 'python', description: 'Regel-basierte OCR-Korrektur (klausur-service Schritt 6)', license: 'MIT', sourceUrl: 'https://github.com/barrust/pyspellchecker' },
|
||||||
|
{ type: 'library', name: 'pytesseract', version: '0.3.10+', category: 'python', description: 'Tesseract OCR Engine Wrapper (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/madmaze/pytesseract' },
|
||||||
|
{ type: 'library', name: 'opencv-python-headless', version: '4.8+', category: 'python', description: 'Bildverarbeitung, Projektionsprofile, Inpainting (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opencv/opencv-python' },
|
||||||
|
{ type: 'library', name: 'rapidocr-onnxruntime', version: 'latest', category: 'python', description: 'Schnelles OCR ARM64 via ONNX (klausur-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/RapidAI/RapidOCR' },
|
||||||
|
{ type: 'library', name: 'onnxruntime', version: 'latest', category: 'python', description: 'ONNX-Inferenz fuer RapidOCR (klausur-service)', license: 'MIT', sourceUrl: 'https://github.com/microsoft/onnxruntime' },
|
||||||
|
{ type: 'library', name: 'eng-to-ipa', version: 'latest', category: 'python', description: 'IPA-Lautschrift-Lookup (klausur-service Vokabel-Pipeline)', license: 'MIT', sourceUrl: 'https://github.com/mphilli/English-to-IPA' },
|
||||||
|
{ type: 'library', name: 'sentence-transformers', version: '2.2+', category: 'python', description: 'Lokale Embeddings (klausur-service, rag-service)', license: 'Apache-2.0', sourceUrl: 'https://github.com/UKPLab/sentence-transformers' },
|
||||||
|
{ type: 'library', name: 'torch', version: '2.0+', category: 'python', description: 'ML-Framework CPU/MPS (TrOCR, klausur-service)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/pytorch/pytorch' },
|
||||||
|
{ type: 'library', name: 'transformers', version: '4.x', category: 'python', description: 'HuggingFace Transformers (TrOCR, Handschrift-HTR)', license: 'Apache-2.0', sourceUrl: 'https://github.com/huggingface/transformers' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Static SBOM component data
|
||||||
|
* Extracted from page.tsx to keep file sizes manageable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Component } from '../types'
|
||||||
|
|
||||||
|
// Infrastructure components from docker-compose.yml and project analysis
|
||||||
|
export const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||||
|
{ type: 'service', name: 'PostgreSQL', version: '16-alpine', category: 'database', port: '5432', description: 'Hauptdatenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||||
|
{ type: 'service', name: 'Synapse PostgreSQL', version: '16-alpine', category: 'database', port: '-', description: 'Matrix Datenbank', license: 'PostgreSQL', sourceUrl: 'https://github.com/postgres/postgres' },
|
||||||
|
{ type: 'service', name: 'ERPNext MariaDB', version: '10.6', category: 'database', port: '-', description: 'ERPNext Datenbank', license: 'GPL-2.0', sourceUrl: 'https://github.com/MariaDB/server' },
|
||||||
|
{ type: 'service', name: 'MongoDB', version: '7.0', category: 'database', port: '27017', description: 'LibreChat Datenbank', license: 'SSPL-1.0', sourceUrl: 'https://github.com/mongodb/mongo' },
|
||||||
|
{ type: 'service', name: 'Valkey', version: '8-alpine', category: 'cache', port: '6379', description: 'In-Memory Cache & Sessions (Redis OSS Fork)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||||
|
{ type: 'service', name: 'ERPNext Valkey Queue', version: 'alpine', category: 'cache', port: '-', description: 'Job Queue', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||||
|
{ type: 'service', name: 'ERPNext Valkey Cache', version: 'alpine', category: 'cache', port: '-', description: 'Cache Layer', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/valkey-io/valkey' },
|
||||||
|
{ type: 'service', name: 'Qdrant', version: '1.7.4', category: 'search', port: '6333', description: 'Vector Database (RAG/Embeddings)', license: 'Apache-2.0', sourceUrl: 'https://github.com/qdrant/qdrant' },
|
||||||
|
{ type: 'service', name: 'OpenSearch', version: '2.x', category: 'search', port: '9200', description: 'Volltext-Suche (Elasticsearch Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/opensearch-project/OpenSearch' },
|
||||||
|
{ type: 'service', name: 'Meilisearch', version: 'latest', category: 'search', port: '7700', description: 'Instant Search Engine', license: 'MIT', sourceUrl: 'https://github.com/meilisearch/meilisearch' },
|
||||||
|
{ type: 'service', name: 'MinIO', version: 'latest', category: 'storage', port: '9000/9001', description: 'S3-kompatibel Object Storage', license: 'AGPL-3.0', sourceUrl: 'https://github.com/minio/minio' },
|
||||||
|
{ type: 'service', name: 'IPFS (Kubo)', version: '0.24', category: 'storage', port: '5001', description: 'Dezentrales Speichersystem', license: 'MIT/Apache-2.0', sourceUrl: 'https://github.com/ipfs/kubo' },
|
||||||
|
{ type: 'service', name: 'DSMS Gateway', version: '1.0', category: 'storage', port: '8082', description: 'IPFS REST API', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
||||||
|
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
||||||
|
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
|
||||||
|
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
||||||
|
{ type: 'service', name: 'Jitsi Web', version: 'stable-9823', category: 'communication', port: '8443', description: 'Videokonferenz UI', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-meet' },
|
||||||
|
{ type: 'service', name: 'Jitsi Prosody (XMPP)', version: 'stable-9823', category: 'communication', port: '-', description: 'XMPP Server', license: 'MIT', sourceUrl: 'https://github.com/bjc/prosody' },
|
||||||
|
{ type: 'service', name: 'Jitsi Jicofo', version: 'stable-9823', category: 'communication', port: '-', description: 'Conference Focus Component', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jicofo' },
|
||||||
|
{ type: 'service', name: 'Jitsi JVB', version: 'stable-9823', category: 'communication', port: '10000/udp', description: 'Videobridge (WebRTC SFU)', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jitsi-videobridge' },
|
||||||
|
{ type: 'service', name: 'Jibri', version: 'stable-9823', category: 'communication', port: '-', description: 'Recording & Streaming Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/jitsi/jibri' },
|
||||||
|
{ type: 'service', name: 'Python Backend (FastAPI)', version: '3.12', category: 'application', port: '8000', description: 'Haupt-Backend API, Studio & Alerts Agent', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Klausur Service', version: '1.0', category: 'application', port: '8086', description: 'Abitur-Klausurkorrektur (BYOEH)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Compliance Module', version: '2.0', category: 'application', port: '8000', description: 'GRC Framework (19 Regulations, 558 Requirements, AI)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Transcription Worker', version: '1.0', category: 'application', port: '-', description: 'Whisper + pyannote Transkription', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Go Consent Service', version: '1.21', category: 'application', port: '8081', description: 'DSGVO Consent Management', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Go School Service', version: '1.21', category: 'application', port: '8084', description: 'Klausuren, Noten, Zeugnisse', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Go Billing Service', version: '1.21', category: 'application', port: '8083', description: 'Stripe Billing Integration', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Next.js Admin Frontend', version: '15.1', category: 'application', port: '3000', description: 'Admin Dashboard (React)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'H5P Content Service', version: 'latest', category: 'application', port: '8085', description: 'Interaktive Inhalte', license: 'MIT', sourceUrl: 'https://github.com/h5p/h5p-server' },
|
||||||
|
{ type: 'service', name: 'Policy Vault (NestJS)', version: '1.0', category: 'application', port: '3001', description: 'Richtlinien-Verwaltung API', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Policy Vault (Angular)', version: '17', category: 'application', port: '4200', description: 'Richtlinien-Verwaltung UI', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'LibreChat', version: 'latest', category: 'ai', port: '3080', description: 'Multi-LLM Chat Interface', license: 'MIT', sourceUrl: 'https://github.com/danny-avila/LibreChat' },
|
||||||
|
{ type: 'service', name: 'RAGFlow', version: 'latest', category: 'ai', port: '9380', description: 'RAG Pipeline Service', license: 'Apache-2.0', sourceUrl: 'https://github.com/infiniflow/ragflow' },
|
||||||
|
{ type: 'service', name: 'ERPNext', version: 'v15', category: 'erp', port: '8090', description: 'Open Source ERP System', license: 'GPL-3.0', sourceUrl: 'https://github.com/frappe/erpnext' },
|
||||||
|
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service with Actions CI/CD', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
|
||||||
|
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
|
||||||
|
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
||||||
|
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS Local Scheduler', version: '1.0', category: 'qa', port: '-', description: 'Lokale GitHub Actions Alternative (launchd)', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS LLM Judge', version: '1.0', category: 'qa', port: '-', description: 'Qwen2.5-32B basierte Test-Bewertung', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS RAG Judge', version: '1.0', category: 'qa', port: '-', description: 'RAG/Korrektur Evaluierung', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS Notifier', version: '1.0', category: 'qa', port: '-', description: 'Desktop/Slack/Email Benachrichtigungen', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
{ type: 'service', name: 'BQAS Regression Tracker', version: '1.0', category: 'qa', port: '-', description: 'Score-Historie und Regression-Erkennung', license: 'Proprietary', sourceUrl: '-' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SECURITY_TOOLS: Component[] = [
|
||||||
|
{ type: 'tool', name: 'Trivy', version: 'latest', category: 'security-tool', description: 'Container Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/aquasecurity/trivy' },
|
||||||
|
{ type: 'tool', name: 'Grype', version: 'latest', category: 'security-tool', description: 'SBOM Vulnerability Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/grype' },
|
||||||
|
{ type: 'tool', name: 'Syft', version: 'latest', category: 'security-tool', description: 'SBOM Generator', license: 'Apache-2.0', sourceUrl: 'https://github.com/anchore/syft' },
|
||||||
|
{ type: 'tool', name: 'Gitleaks', version: 'latest', category: 'security-tool', description: 'Secrets Detection in Git', license: 'MIT', sourceUrl: 'https://github.com/gitleaks/gitleaks' },
|
||||||
|
{ type: 'tool', name: 'TruffleHog', version: '3.x', category: 'security-tool', description: 'Secrets Scanner (Regex/Entropy)', license: 'AGPL-3.0', sourceUrl: 'https://github.com/trufflesecurity/trufflehog' },
|
||||||
|
{ type: 'tool', name: 'Semgrep', version: 'latest', category: 'security-tool', description: 'SAST - Static Analysis', license: 'LGPL-2.1', sourceUrl: 'https://github.com/semgrep/semgrep' },
|
||||||
|
{ type: 'tool', name: 'Bandit', version: 'latest', category: 'security-tool', description: 'Python Security Linter', license: 'Apache-2.0', sourceUrl: 'https://github.com/PyCQA/bandit' },
|
||||||
|
{ type: 'tool', name: 'Gosec', version: 'latest', category: 'security-tool', description: 'Go Security Scanner', license: 'Apache-2.0', sourceUrl: 'https://github.com/securego/gosec' },
|
||||||
|
{ type: 'tool', name: 'govulncheck', version: 'latest', category: 'security-tool', description: 'Go Vulnerability Check', license: 'BSD-3-Clause', sourceUrl: 'https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck' },
|
||||||
|
{ type: 'tool', name: 'golangci-lint', version: 'latest', category: 'security-tool', description: 'Go Linter (Security Rules)', license: 'GPL-3.0', sourceUrl: 'https://github.com/golangci/golangci-lint' },
|
||||||
|
{ type: 'tool', name: 'npm audit', version: 'built-in', category: 'security-tool', description: 'Node.js Vulnerability Check', license: 'Artistic-2.0', sourceUrl: 'https://docs.npmjs.com/cli/commands/npm-audit' },
|
||||||
|
{ type: 'tool', name: 'pip-audit', version: 'latest', category: 'security-tool', description: 'Python Dependency Audit', license: 'Apache-2.0', sourceUrl: 'https://github.com/pypa/pip-audit' },
|
||||||
|
{ type: 'tool', name: 'safety', version: 'latest', category: 'security-tool', description: 'Python Safety Check', license: 'MIT', sourceUrl: 'https://github.com/pyupio/safety' },
|
||||||
|
{ type: 'tool', name: 'CodeQL', version: 'latest', category: 'security-tool', description: 'GitHub Security Analysis', license: 'MIT', sourceUrl: 'https://github.com/github/codeql' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export { PYTHON_PACKAGES } from './sbom-data-python'
|
||||||
|
export { GO_MODULES, NODE_PACKAGES, UNITY_PACKAGES, CSHARP_PACKAGES } from './sbom-data-libs'
|
||||||
File diff suppressed because it is too large
Load Diff
31
admin-core/app/(admin)/infrastructure/sbom/types.ts
Normal file
31
admin-core/app/(admin)/infrastructure/sbom/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Types for SBOM page
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Component {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
purl?: string
|
||||||
|
licenses?: { license: { id: string } }[]
|
||||||
|
category?: string
|
||||||
|
port?: string
|
||||||
|
description?: string
|
||||||
|
license?: string
|
||||||
|
sourceUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SBOMData {
|
||||||
|
bomFormat?: string
|
||||||
|
specVersion?: string
|
||||||
|
version?: number
|
||||||
|
metadata?: {
|
||||||
|
timestamp?: string
|
||||||
|
tools?: { vendor: string; name: string; version: string }[]
|
||||||
|
component?: { type: string; name: string; version: string }
|
||||||
|
}
|
||||||
|
components?: Component[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
|
||||||
|
export type InfoTabType = 'audit' | 'documentation'
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Finding } from '../types'
|
||||||
|
|
||||||
|
export function FindingsTab({
|
||||||
|
findings,
|
||||||
|
severityFilter,
|
||||||
|
setSeverityFilter,
|
||||||
|
toolFilter,
|
||||||
|
setToolFilter,
|
||||||
|
getSeverityBadge,
|
||||||
|
}: {
|
||||||
|
findings: Finding[]
|
||||||
|
severityFilter: string | null
|
||||||
|
setSeverityFilter: (v: string | null) => void
|
||||||
|
toolFilter: string | null
|
||||||
|
setToolFilter: (v: string | null) => void
|
||||||
|
getSeverityBadge: (severity: string) => string
|
||||||
|
}) {
|
||||||
|
const filteredFindings = findings.filter(f => {
|
||||||
|
if (severityFilter && f.severity.toUpperCase() !== severityFilter.toUpperCase()) return false
|
||||||
|
if (toolFilter && f.tool.toLowerCase() !== toolFilter.toLowerCase()) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setSeverityFilter(null)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm ${!severityFilter ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].map(sev => (
|
||||||
|
<button
|
||||||
|
key={sev}
|
||||||
|
onClick={() => setSeverityFilter(sev)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm ${severityFilter === sev ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
{sev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="mx-2 border-l border-slate-300" />
|
||||||
|
{['gitleaks', 'semgrep', 'bandit', 'trivy', 'grype'].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setToolFilter(toolFilter === t ? null : t)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm capitalize ${toolFilter === t ? 'bg-orange-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredFindings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
Keine Findings mit diesem Filter gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Zeile</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredFindings.map((finding, idx) => (
|
||||||
|
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="text-sm text-slate-900">{finding.title}</div>
|
||||||
|
{finding.message && (
|
||||||
|
<div className="text-xs text-slate-500 mt-1">{finding.message}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500 font-mono max-w-xs truncate">
|
||||||
|
{finding.file || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">{finding.line || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{finding.found_at ? new Date(finding.found_at).toLocaleDateString('de-DE') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { HistoryItem } from '../types'
|
||||||
|
|
||||||
|
export function HistoryTab({ history }: { history: HistoryItem[] }) {
|
||||||
|
const getHistoryStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success': return 'bg-green-500'
|
||||||
|
case 'warning': return 'bg-yellow-500'
|
||||||
|
case 'error': return 'bg-red-500'
|
||||||
|
default: return 'bg-slate-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
Keine Scan-Historie vorhanden.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200" />
|
||||||
|
{history.map((item, idx) => (
|
||||||
|
<div key={idx} className="relative pl-10 pb-6">
|
||||||
|
<div className={`absolute left-2.5 w-3 h-3 rounded-full ${getHistoryStatusColor(item.status)}`} />
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="font-semibold text-slate-900">{item.title}</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{new Date(item.timestamp).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { MonitoringMetric, ActiveAlert } from '../types'
|
||||||
|
|
||||||
|
export function MonitoringTab({
|
||||||
|
monitoringMetrics,
|
||||||
|
activeAlerts,
|
||||||
|
}: {
|
||||||
|
monitoringMetrics: MonitoringMetric[]
|
||||||
|
activeAlerts: ActiveAlert[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Real-time Metrics */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Security Metriken
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
{monitoringMetrics.map((metric, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`rounded-lg p-4 border ${
|
||||||
|
metric.status === 'critical' ? 'bg-red-50 border-red-200' :
|
||||||
|
metric.status === 'warning' ? 'bg-yellow-50 border-yellow-200' :
|
||||||
|
'bg-green-50 border-green-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-xs text-slate-600">{metric.name}</span>
|
||||||
|
<span className={`text-xs ${
|
||||||
|
metric.trend === 'up' ? 'text-red-600' :
|
||||||
|
metric.trend === 'down' ? 'text-green-600' :
|
||||||
|
'text-slate-500'
|
||||||
|
}`}>
|
||||||
|
{metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-bold ${
|
||||||
|
metric.status === 'critical' ? 'text-red-700' :
|
||||||
|
metric.status === 'warning' ? 'text-yellow-700' :
|
||||||
|
'text-green-700'
|
||||||
|
}`}>
|
||||||
|
{metric.value}
|
||||||
|
<span className="text-sm font-normal ml-1">{metric.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Alerts */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
Aktive Alerts
|
||||||
|
{activeAlerts.filter(a => !a.acknowledged).length > 0 && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||||
|
{activeAlerts.filter(a => !a.acknowledged).length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{activeAlerts.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<span className="text-4xl block mb-2">✓</span>
|
||||||
|
<span className="text-green-700">Keine aktiven Security-Alerts</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeAlerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||||
|
alert.severity === 'critical' ? 'bg-red-50 border-red-200' :
|
||||||
|
alert.severity === 'high' ? 'bg-orange-50 border-orange-200' :
|
||||||
|
alert.severity === 'medium' ? 'bg-yellow-50 border-yellow-200' :
|
||||||
|
'bg-blue-50 border-blue-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-2 py-1 text-xs font-semibold rounded uppercase ${
|
||||||
|
alert.severity === 'critical' ? 'bg-red-100 text-red-800' :
|
||||||
|
alert.severity === 'high' ? 'bg-orange-100 text-orange-800' :
|
||||||
|
alert.severity === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-blue-100 text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{alert.severity}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-900">{alert.title}</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{alert.source} • {new Date(alert.timestamp).toLocaleString('de-DE')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!alert.acknowledged && (
|
||||||
|
<button className="px-3 py-1 text-xs bg-white border border-slate-300 rounded hover:bg-slate-50">
|
||||||
|
Bestaetigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Overview Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
Authentifizierung
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Aktive Sessions</span><span className="font-medium">24</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Fehlgeschlagene Logins (24h)</span><span className="font-medium text-green-600">0</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">2FA-Quote</span><span className="font-medium text-green-600">100%</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
SSL/TLS
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Zertifikate</span><span className="font-medium">5 aktiv</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Naechster Ablauf</span><span className="font-medium text-green-600">45 Tage</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">TLS Version</span><span className="font-medium">1.3</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<h4 className="font-medium text-slate-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
Firewall
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Blockierte IPs (24h)</span><span className="font-medium">12</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Rate Limit Hits</span><span className="font-medium text-yellow-600">7</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">WAF Status</span><span className="font-medium text-green-600">Aktiv</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to CI/CD */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-blue-900">Pipeline Security</div>
|
||||||
|
<div className="text-sm text-blue-700">Security-Scans in CI/CD Pipelines und Container-Status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/infrastructure/ci-cd" className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
CI/CD Dashboard →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function SecurityDocsSection() {
|
||||||
|
const [showFullDocs, setShowFullDocs] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Security Dokumentation
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFullDocs(!showFullDocs)}
|
||||||
|
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors flex items-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 transition-transform ${showFullDocs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
{showFullDocs ? 'Weniger anzeigen' : 'Vollstaendige Dokumentation'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Short Description */}
|
||||||
|
<div className="prose prose-slate max-w-none">
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Das Security Dashboard bietet einen zentralen Ueberblick ueber alle DevSecOps-Aktivitaeten.
|
||||||
|
Es integriert 6 Security-Tools fuer umfassende Code- und Infrastruktur-Sicherheit:
|
||||||
|
Secrets Detection, Static Analysis (SAST), Dependency Scanning und SBOM-Generierung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool Quick Reference */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mt-4">
|
||||||
|
{[
|
||||||
|
{ bg: 'bg-red-50', icon: '🔑', name: 'Gitleaks', label: 'Secrets', color: 'text-red-800', labelColor: 'text-red-600' },
|
||||||
|
{ bg: 'bg-blue-50', icon: '🔍', name: 'Semgrep', label: 'SAST', color: 'text-blue-800', labelColor: 'text-blue-600' },
|
||||||
|
{ bg: 'bg-yellow-50', icon: '🐍', name: 'Bandit', label: 'Python', color: 'text-yellow-800', labelColor: 'text-yellow-600' },
|
||||||
|
{ bg: 'bg-purple-50', icon: '🔒', name: 'Trivy', label: 'Container', color: 'text-purple-800', labelColor: 'text-purple-600' },
|
||||||
|
{ bg: 'bg-green-50', icon: '🐛', name: 'Grype', label: 'Dependencies', color: 'text-green-800', labelColor: 'text-green-600' },
|
||||||
|
{ bg: 'bg-orange-50', icon: '📦', name: 'Syft', label: 'SBOM', color: 'text-orange-800', labelColor: 'text-orange-600' },
|
||||||
|
].map((tool) => (
|
||||||
|
<div key={tool.name} className={`${tool.bg} p-3 rounded-lg text-center`}>
|
||||||
|
<span className="text-lg">{tool.icon}</span>
|
||||||
|
<p className={`text-xs font-medium ${tool.color} mt-1`}>{tool.name}</p>
|
||||||
|
<p className={`text-xs ${tool.labelColor}`}>{tool.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Documentation (Expandable) */}
|
||||||
|
{showFullDocs && (
|
||||||
|
<div className="mt-6 bg-slate-50 rounded-lg p-6 border border-slate-200">
|
||||||
|
<div className="prose prose-slate max-w-none prose-headings:text-slate-900 prose-p:text-slate-600 prose-li:text-slate-600">
|
||||||
|
<h3>1. Security Tools Uebersicht</h3>
|
||||||
|
<h4>🔑 Gitleaks - Secrets Detection</h4>
|
||||||
|
<p>Durchsucht die gesamte Git-Historie nach versehentlich eingecheckten Secrets wie API-Keys, Passwoertern und Tokens.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Scan-Bereich:</strong> Git-Historie, Commits, Branches</li>
|
||||||
|
<li><strong>Erkannte Secrets:</strong> AWS Keys, GitHub Tokens, Private Keys, Passwoerter</li>
|
||||||
|
<li><strong>Ausgabe:</strong> JSON-Report mit Fundstelle, Commit-Hash, Autor</li>
|
||||||
|
</ul>
|
||||||
|
<h4>🔍 Semgrep - Static Application Security Testing</h4>
|
||||||
|
<p>Fuehrt regelbasierte statische Code-Analyse durch, um Sicherheitsluecken und Anti-Patterns zu finden.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Unterstuetzte Sprachen:</strong> Python, JavaScript, TypeScript, Go, Java</li>
|
||||||
|
<li><strong>Regelsets:</strong> OWASP Top 10, CWE, Security Best Practices</li>
|
||||||
|
<li><strong>Findings:</strong> SQL Injection, XSS, Path Traversal, Insecure Deserialization</li>
|
||||||
|
</ul>
|
||||||
|
<h3>2. Severity-Klassifizierung</h3>
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead><tr className="border-b"><th className="text-left py-2">Severity</th><th className="text-left py-2">CVSS Score</th><th className="text-left py-2">Reaktionszeit</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-red-100 text-red-800 rounded text-xs font-semibold">CRITICAL</span></td><td>9.0 - 10.0</td><td>Sofort (24h)</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-orange-100 text-orange-800 rounded text-xs font-semibold">HIGH</span></td><td>7.0 - 8.9</td><td>1-3 Tage</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-yellow-100 text-yellow-800 rounded text-xs font-semibold">MEDIUM</span></td><td>4.0 - 6.9</td><td>1-2 Wochen</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="px-2 py-0.5 bg-green-100 text-green-800 rounded text-xs font-semibold">LOW</span></td><td>0.1 - 3.9</td><td>Naechster Sprint</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3>3. Scan-Workflow</h3>
|
||||||
|
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
|
{`1. Secrets Detection (Gitleaks)
|
||||||
|
2. Static Analysis (Semgrep + Bandit)
|
||||||
|
3. Dependency Scan (Trivy + Grype)
|
||||||
|
4. SBOM Generation (Syft)
|
||||||
|
5. Report & Dashboard`}
|
||||||
|
</pre>
|
||||||
|
<h3>4. API-Endpunkte</h3>
|
||||||
|
<table className="min-w-full text-sm font-mono">
|
||||||
|
<thead><tr className="border-b"><th className="text-left py-2">Methode</th><th className="text-left py-2">Endpoint</th><th className="text-left py-2 font-sans">Beschreibung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/tools</td><td className="font-sans">Tool-Status abrufen</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="bg-blue-100 text-blue-700 px-1 rounded">GET</span></td><td>/api/v1/security/findings</td><td className="font-sans">Alle Findings abrufen</td></tr>
|
||||||
|
<tr className="border-b"><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/all</td><td className="font-sans">Full Scan starten</td></tr>
|
||||||
|
<tr><td className="py-2"><span className="bg-green-100 text-green-700 px-1 rounded">POST</span></td><td>/api/v1/security/scan/[tool]</td><td className="font-sans">Einzelnes Tool scannen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ToolStatus, Finding, ScanType } from '../types'
|
||||||
|
|
||||||
|
export function SecurityOverviewTab({
|
||||||
|
tools,
|
||||||
|
findings,
|
||||||
|
scanning,
|
||||||
|
onRunScan,
|
||||||
|
onShowAllFindings,
|
||||||
|
toolDescriptions,
|
||||||
|
toolToScanType,
|
||||||
|
getSeverityBadge,
|
||||||
|
getStatusBadge,
|
||||||
|
}: {
|
||||||
|
tools: ToolStatus[]
|
||||||
|
findings: Finding[]
|
||||||
|
scanning: string | null
|
||||||
|
onRunScan: (scanType: ScanType) => void
|
||||||
|
onShowAllFindings: () => void
|
||||||
|
toolDescriptions: Record<string, { icon: string; desc: string }>
|
||||||
|
toolToScanType: Record<string, ScanType>
|
||||||
|
getSeverityBadge: (severity: string) => string
|
||||||
|
getStatusBadge: (installed: boolean) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Tools Grid */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">DevSecOps Tools</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{tools.map(tool => {
|
||||||
|
const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
|
||||||
|
return (
|
||||||
|
<div key={tool.name} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{info.icon}</span>
|
||||||
|
<span className="font-semibold text-slate-900">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={getStatusBadge(tool.installed)}>
|
||||||
|
{tool.installed ? 'Installiert' : 'Nicht installiert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 mb-3">{info.desc}</p>
|
||||||
|
<div className="flex justify-between items-center text-xs text-slate-500">
|
||||||
|
<span>{tool.version || '-'}</span>
|
||||||
|
<span>Letzter Scan: {tool.last_run || 'Nie'}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onRunScan(toolToScanType[tool.name.toLowerCase()] || 'all')}
|
||||||
|
disabled={scanning !== null || !tool.installed}
|
||||||
|
className={`mt-3 w-full px-3 py-1.5 text-sm border rounded transition-colors flex items-center justify-center gap-2 ${
|
||||||
|
scanning === toolToScanType[tool.name.toLowerCase()]
|
||||||
|
? 'bg-orange-100 border-orange-300 text-orange-700'
|
||||||
|
: 'bg-white border-slate-300 hover:bg-slate-50 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scanning === toolToScanType[tool.name.toLowerCase()] ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-600" />
|
||||||
|
<span>Scan laeuft...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Scan starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Findings */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Aktuelle Findings</h3>
|
||||||
|
{findings.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<span className="text-4xl block mb-2">🎉</span>
|
||||||
|
Keine Findings gefunden. Das ist gut!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Severity</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Tool</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Finding</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Datei</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-slate-500 uppercase">Gefunden</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{findings.slice(0, 10).map((finding, idx) => (
|
||||||
|
<tr key={`${finding.id}-${idx}`} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className={getSeverityBadge(finding.severity)}>{finding.severity}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-600">{finding.tool}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-900">{finding.title}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500 font-mono">{finding.file || '-'}</td>
|
||||||
|
<td className="py-3 px-4 text-sm text-slate-500">
|
||||||
|
{finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||||
|
}) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{findings.length > 10 && (
|
||||||
|
<button onClick={onShowAllFindings} className="mt-4 text-sm text-orange-600 hover:text-orange-700">
|
||||||
|
Alle {findings.length} Findings anzeigen →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ToolStatus, ScanType } from '../types'
|
||||||
|
|
||||||
|
export function ToolsTab({
|
||||||
|
tools,
|
||||||
|
scanning,
|
||||||
|
onRunScan,
|
||||||
|
toolDescriptions,
|
||||||
|
toolToScanType,
|
||||||
|
getStatusBadge,
|
||||||
|
}: {
|
||||||
|
tools: ToolStatus[]
|
||||||
|
scanning: string | null
|
||||||
|
onRunScan: (scanType: ScanType) => void
|
||||||
|
toolDescriptions: Record<string, { icon: string; desc: string }>
|
||||||
|
toolToScanType: Record<string, ScanType>
|
||||||
|
getStatusBadge: (installed: boolean) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{tools.map(tool => {
|
||||||
|
const info = toolDescriptions[tool.name.toLowerCase()] || { icon: '🔧', desc: 'Security Tool' }
|
||||||
|
return (
|
||||||
|
<div key={tool.name} className="bg-white border border-slate-200 rounded-lg p-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="text-2xl">{info.icon}</span>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">{tool.name}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">{info.desc}</p>
|
||||||
|
</div>
|
||||||
|
<span className={getStatusBadge(tool.installed)}>
|
||||||
|
{tool.installed ? 'Installiert' : 'Nicht installiert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Version:</span>
|
||||||
|
<span className="font-mono">{tool.version || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Letzter Scan:</span>
|
||||||
|
<span>{tool.last_run || 'Nie'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-500">Findings:</span>
|
||||||
|
<span className="font-semibold">{tool.last_findings}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onRunScan(toolToScanType[tool.name.toLowerCase()] || 'all')}
|
||||||
|
disabled={scanning !== null || !tool.installed}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${
|
||||||
|
scanning === toolToScanType[tool.name.toLowerCase()]
|
||||||
|
? 'bg-orange-200 text-orange-800'
|
||||||
|
: 'bg-orange-600 text-white hover:bg-orange-700 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scanning === toolToScanType[tool.name.toLowerCase()] ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-orange-700" />
|
||||||
|
<span>Scan laeuft...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Scan starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/api/v1/security/reports/${tool.name.toLowerCase()}`, '_blank')}
|
||||||
|
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
57
admin-core/app/(admin)/infrastructure/security/types.ts
Normal file
57
admin-core/app/(admin)/infrastructure/security/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Types for Security Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolStatus {
|
||||||
|
name: string
|
||||||
|
installed: boolean
|
||||||
|
version: string | null
|
||||||
|
last_run: string | null
|
||||||
|
last_findings: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
id: string
|
||||||
|
tool: string
|
||||||
|
severity: string
|
||||||
|
title: string
|
||||||
|
message: string | null
|
||||||
|
file: string | null
|
||||||
|
line: number | null
|
||||||
|
found_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeveritySummary {
|
||||||
|
critical: number
|
||||||
|
high: number
|
||||||
|
medium: number
|
||||||
|
low: number
|
||||||
|
info: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
timestamp: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanType = 'secrets' | 'sast' | 'deps' | 'containers' | 'sbom' | 'all'
|
||||||
|
|
||||||
|
export interface MonitoringMetric {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
status: 'ok' | 'warning' | 'critical'
|
||||||
|
trend: 'up' | 'down' | 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveAlert {
|
||||||
|
id: string
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||||
|
title: string
|
||||||
|
source: string
|
||||||
|
timestamp: string
|
||||||
|
acknowledged: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
|
||||||
|
import type { FailedTest, BacklogItem, BacklogPriority } from '../types'
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// FailedTestCard
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
function FailedTestCard({
|
||||||
|
test,
|
||||||
|
onStatusChange,
|
||||||
|
onPriorityChange,
|
||||||
|
priority = 'medium',
|
||||||
|
failureCount = 1,
|
||||||
|
}: {
|
||||||
|
test: FailedTest
|
||||||
|
onStatusChange: (testId: string, status: string) => void
|
||||||
|
onPriorityChange?: (testId: string, priority: string) => void
|
||||||
|
priority?: BacklogPriority
|
||||||
|
failureCount?: number
|
||||||
|
}) {
|
||||||
|
const errorTypeColors: Record<string, string> = {
|
||||||
|
assertion: 'bg-amber-100 text-amber-700',
|
||||||
|
nil_pointer: 'bg-red-100 text-red-700',
|
||||||
|
type_error: 'bg-purple-100 text-purple-700',
|
||||||
|
network: 'bg-blue-100 text-blue-700',
|
||||||
|
timeout: 'bg-orange-100 text-orange-700',
|
||||||
|
logic_error: 'bg-slate-100 text-slate-700',
|
||||||
|
unknown: 'bg-slate-100 text-slate-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
open: 'bg-red-100 text-red-700',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-700',
|
||||||
|
fixed: 'bg-emerald-100 text-emerald-700',
|
||||||
|
wont_fix: 'bg-slate-100 text-slate-700',
|
||||||
|
flaky: 'bg-purple-100 text-purple-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
critical: 'bg-red-500 text-white',
|
||||||
|
high: 'bg-orange-500 text-white',
|
||||||
|
medium: 'bg-yellow-500 text-white',
|
||||||
|
low: 'bg-slate-400 text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityLabels: Record<string, string> = {
|
||||||
|
critical: '!!! Kritisch',
|
||||||
|
high: '!! Hoch',
|
||||||
|
medium: '! Mittel',
|
||||||
|
low: 'Niedrig',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-4 hover:border-red-300 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[priority]}`}>
|
||||||
|
{priorityLabels[priority]}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${errorTypeColors[test.error_type] || errorTypeColors.unknown}`}>
|
||||||
|
{test.error_type.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">{test.service}</span>
|
||||||
|
{failureCount > 1 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-red-100 text-red-600 text-xs font-medium">
|
||||||
|
{failureCount}x fehlgeschlagen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-mono text-sm font-medium text-slate-900 truncate" title={test.name}>
|
||||||
|
{test.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 truncate" title={test.file_path}>
|
||||||
|
{test.file_path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 ml-2">
|
||||||
|
<select
|
||||||
|
value={test.status}
|
||||||
|
onChange={(e) => onStatusChange(test.id, e.target.value)}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer border-0 ${statusColors[test.status]}`}
|
||||||
|
>
|
||||||
|
<option value="open">Offen</option>
|
||||||
|
<option value="in_progress">In Arbeit</option>
|
||||||
|
<option value="fixed">Behoben</option>
|
||||||
|
<option value="wont_fix">Ignoriert</option>
|
||||||
|
<option value="flaky">Flaky</option>
|
||||||
|
</select>
|
||||||
|
{onPriorityChange && (
|
||||||
|
<select
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => onPriorityChange(test.id, e.target.value)}
|
||||||
|
className="px-2 py-1 rounded text-xs font-medium cursor-pointer border border-slate-200"
|
||||||
|
>
|
||||||
|
<option value="critical">Kritisch</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
<option value="medium">Mittel</option>
|
||||||
|
<option value="low">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-red-50 rounded-lg p-3 mb-3">
|
||||||
|
<p className="text-sm text-red-800 font-medium mb-1">Fehlermeldung:</p>
|
||||||
|
<p className="text-xs text-red-700 font-mono break-words">
|
||||||
|
{test.error_message || 'Keine Details verfuegbar'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{test.suggestion && (
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-emerald-800 font-medium mb-1">💡 Loesungsvorschlag:</p>
|
||||||
|
<p className="text-xs text-emerald-700">
|
||||||
|
{test.suggestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'}</span>
|
||||||
|
<button
|
||||||
|
className="text-orange-600 hover:text-orange-700 font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(test.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ID kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// BacklogTab
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
export function BacklogTab({
|
||||||
|
failedTests,
|
||||||
|
onStatusChange,
|
||||||
|
onPriorityChange,
|
||||||
|
isLoading,
|
||||||
|
backlogItems,
|
||||||
|
usePostgres = false,
|
||||||
|
}: {
|
||||||
|
failedTests: FailedTest[]
|
||||||
|
onStatusChange: (testId: string, status: string) => void
|
||||||
|
onPriorityChange?: (testId: string, priority: string) => void
|
||||||
|
isLoading: boolean
|
||||||
|
backlogItems?: BacklogItem[]
|
||||||
|
usePostgres?: boolean
|
||||||
|
}) {
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('open')
|
||||||
|
const [filterService, setFilterService] = useState<string>('all')
|
||||||
|
const [filterPriority, setFilterPriority] = useState<string>('all')
|
||||||
|
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
|
||||||
|
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
|
||||||
|
|
||||||
|
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
|
||||||
|
const items = usePostgres && backlogItems ? backlogItems : failedTests
|
||||||
|
|
||||||
|
// Gruppiere nach Service
|
||||||
|
const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))]
|
||||||
|
|
||||||
|
// Filtere Items
|
||||||
|
const filteredItems = items.filter(item => {
|
||||||
|
const status = 'status' in item ? item.status : 'open'
|
||||||
|
const service = 'service' in item ? item.service : ''
|
||||||
|
const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium'
|
||||||
|
|
||||||
|
if (filterStatus !== 'all' && status !== filterStatus) return false
|
||||||
|
if (filterService !== 'all' && service !== filterService) return false
|
||||||
|
if (filterPriority !== 'all' && priority !== filterPriority) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zaehle nach Status
|
||||||
|
const openCount = items.filter(t => t.status === 'open').length
|
||||||
|
const inProgressCount = items.filter(t => t.status === 'in_progress').length
|
||||||
|
const fixedCount = items.filter(t => t.status === 'fixed').length
|
||||||
|
const flakyCount = items.filter(t => t.status === 'flaky').length
|
||||||
|
|
||||||
|
// Zaehle nach Prioritaet (nur bei PostgreSQL)
|
||||||
|
const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0
|
||||||
|
const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konvertiere BacklogItem zu FailedTest fuer die Anzeige
|
||||||
|
const convertToFailedTest = (item: BacklogItem): FailedTest => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.test_name,
|
||||||
|
service: item.service,
|
||||||
|
file_path: item.test_file || '',
|
||||||
|
error_message: item.error_message || '',
|
||||||
|
error_type: item.error_type || 'unknown',
|
||||||
|
suggestion: item.fix_suggestion || '',
|
||||||
|
run_id: '',
|
||||||
|
last_failed: item.last_failed_at,
|
||||||
|
status: item.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-red-600">{openCount}</p>
|
||||||
|
<p className="text-sm text-red-700">Offene Fehler</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{inProgressCount}</p>
|
||||||
|
<p className="text-sm text-blue-700">In Arbeit</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">{fixedCount}</p>
|
||||||
|
<p className="text-sm text-emerald-700">Behoben</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-purple-600">{flakyCount}</p>
|
||||||
|
<p className="text-sm text-purple-700">Flaky</p>
|
||||||
|
</div>
|
||||||
|
{usePostgres && criticalCount + highCount > 0 && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
|
||||||
|
<p className="text-2xl font-bold text-orange-600">{criticalCount + highCount}</p>
|
||||||
|
<p className="text-sm text-orange-700">Kritisch/Hoch</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PostgreSQL Badge */}
|
||||||
|
{usePostgres && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-50 border border-emerald-200 rounded-lg w-fit">
|
||||||
|
<svg className="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-emerald-700 font-medium">Persistente Speicherung aktiv (PostgreSQL)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LLM Analysis Toggle */}
|
||||||
|
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
|
||||||
|
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={llmAutoAnalysis}
|
||||||
|
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{llmAutoAnalysis && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-violet-200">
|
||||||
|
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{([
|
||||||
|
{ value: 'local_only' as const, label: 'Nur lokales 32B LLM', badge: 'DSGVO', badgeColor: 'bg-emerald-100 text-emerald-700' },
|
||||||
|
{ value: 'claude_preferred' as const, label: 'Claude bevorzugt', badge: 'Qualitaet', badgeColor: 'bg-blue-100 text-blue-700' },
|
||||||
|
{ value: 'smart_routing' as const, label: 'Smart Routing', badge: 'Empfohlen', badgeColor: 'bg-amber-100 text-amber-700' },
|
||||||
|
]).map((option) => (
|
||||||
|
<label key={option.value} className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
llmRouting === option.value
|
||||||
|
? 'bg-violet-100 border-violet-300 text-violet-800'
|
||||||
|
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="llm-routing"
|
||||||
|
value={option.value}
|
||||||
|
checked={llmRouting === option.value}
|
||||||
|
onChange={() => setLlmRouting(option.value)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{option.label}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 ${option.badgeColor} rounded`}>{option.badge}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
|
||||||
|
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
|
||||||
|
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600 mr-2">Status:</label>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="open">Offen ({openCount})</option>
|
||||||
|
<option value="in_progress">In Arbeit ({inProgressCount})</option>
|
||||||
|
<option value="fixed">Behoben ({fixedCount})</option>
|
||||||
|
<option value="flaky">Flaky ({flakyCount})</option>
|
||||||
|
<option value="wont_fix">Ignoriert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600 mr-2">Service:</label>
|
||||||
|
<select
|
||||||
|
value={filterService}
|
||||||
|
onChange={(e) => setFilterService(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Services</option>
|
||||||
|
{services.map(s => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{usePostgres && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600 mr-2">Prioritaet:</label>
|
||||||
|
<select
|
||||||
|
value={filterPriority}
|
||||||
|
onChange={(e) => setFilterPriority(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle</option>
|
||||||
|
<option value="critical">Kritisch</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
<option value="medium">Mittel</option>
|
||||||
|
<option value="low">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto text-sm text-slate-500">
|
||||||
|
{filteredItems.length} von {items.length} Tests angezeigt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test-Liste */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-emerald-50 rounded-xl border border-emerald-200">
|
||||||
|
<svg className="w-12 h-12 mx-auto text-emerald-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-emerald-700 font-medium">
|
||||||
|
{filterStatus === 'open' ? 'Keine offenen Fehler! 🎉' : 'Keine Tests mit diesem Filter gefunden.'}
|
||||||
|
</p>
|
||||||
|
{filterStatus === 'open' && (
|
||||||
|
<p className="text-sm text-emerald-600 mt-2">
|
||||||
|
Alle Tests bestanden. Bereit fuer Go-Live!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const test = usePostgres && 'test_name' in item
|
||||||
|
? convertToFailedTest(item as BacklogItem)
|
||||||
|
: item as FailedTest
|
||||||
|
const itemPriority = usePostgres && 'priority' in item
|
||||||
|
? (item as BacklogItem).priority
|
||||||
|
: 'medium'
|
||||||
|
const failureCount = usePostgres && 'failure_count' in item
|
||||||
|
? (item as BacklogItem).failure_count
|
||||||
|
: 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FailedTestCard
|
||||||
|
key={test.id}
|
||||||
|
test={test}
|
||||||
|
onStatusChange={onStatusChange}
|
||||||
|
onPriorityChange={onPriorityChange}
|
||||||
|
priority={itemPriority}
|
||||||
|
failureCount={failureCount}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-blue-800 font-medium">Workflow fuer fehlgeschlagene Tests:</p>
|
||||||
|
<ol className="text-xs text-blue-700 mt-2 space-y-1 list-decimal list-inside">
|
||||||
|
<li>Markiere den Test als "In Arbeit" wenn du daran arbeitest</li>
|
||||||
|
<li>Analysiere die Fehlermeldung und den Loesungsvorschlag</li>
|
||||||
|
<li>Behebe den Fehler im Code</li>
|
||||||
|
<li>Fuehre den Test erneut aus (Button im Service-Tab)</li>
|
||||||
|
<li>Markiere als "Behoben" wenn der Test besteht</li>
|
||||||
|
{usePostgres && <li>Setze "Flaky" fuer sporadisch fehlschlagende Tests</li>}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { CoverageData } from '../types'
|
||||||
|
|
||||||
|
export function CoverageChart({ data }: { data: CoverageData[] }) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
Keine Coverage-Daten verfuegbar
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedData.map((item) => (
|
||||||
|
<div key={item.service}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-600 truncate max-w-[200px]">{item.display_name}</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
item.coverage_percent >= 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.coverage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
item.coverage_percent >= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${item.coverage_percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function FrameworkDistribution({ data }: { data: Record<string, number> }) {
|
||||||
|
const total = Object.values(data).reduce((a, b) => a + b, 0)
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
const frameworkLabels: Record<string, string> = {
|
||||||
|
go_test: 'Go Tests',
|
||||||
|
pytest: 'Python (pytest)',
|
||||||
|
jest: 'Jest (TS)',
|
||||||
|
vitest: 'Vitest (SDK)',
|
||||||
|
playwright: 'Playwright (E2E)',
|
||||||
|
bqas_golden: 'BQAS Golden',
|
||||||
|
bqas_rag: 'BQAS RAG',
|
||||||
|
bqas_synthetic: 'BQAS Synthetic',
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameworkColors: Record<string, string> = {
|
||||||
|
go_test: 'bg-cyan-500',
|
||||||
|
pytest: 'bg-yellow-500',
|
||||||
|
jest: 'bg-blue-500',
|
||||||
|
vitest: 'bg-orange-500',
|
||||||
|
playwright: 'bg-purple-500',
|
||||||
|
bqas_golden: 'bg-emerald-500',
|
||||||
|
bqas_rag: 'bg-teal-500',
|
||||||
|
bqas_synthetic: 'bg-amber-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(data)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([framework, count]) => (
|
||||||
|
<div key={framework} className="flex items-center gap-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${frameworkColors[framework] || 'bg-slate-400'}`} />
|
||||||
|
<span className="text-sm text-slate-600 flex-1">{frameworkLabels[framework] || framework}</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900">{count}</span>
|
||||||
|
<span className="text-xs text-slate-400">({((count / total) * 100).toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export function GuideTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-200 p-6">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
Was ist das Test Dashboard?
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-700 leading-relaxed">
|
||||||
|
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
|
||||||
|
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
|
||||||
|
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
|
||||||
|
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-cyan-50 rounded-lg border border-cyan-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🐹</span>
|
||||||
|
<h4 className="font-medium text-cyan-800">Go Unit Tests (~57)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-cyan-700">
|
||||||
|
consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🐍</span>
|
||||||
|
<h4 className="font-medium text-yellow-800">Python Tests (~50)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-yellow-700">
|
||||||
|
backend, voice-service, klausur-service, geo-service
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🎯</span>
|
||||||
|
<h4 className="font-medium text-emerald-800">BQAS Golden (97)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-emerald-700">
|
||||||
|
Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-teal-50 rounded-lg border border-teal-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">📚</span>
|
||||||
|
<h4 className="font-medium text-teal-800">BQAS RAG (~20)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-teal-700">
|
||||||
|
RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">📘</span>
|
||||||
|
<h4 className="font-medium text-blue-800">TypeScript Jest (~8)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
Website Unit Tests fuer React-Komponenten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">⚡</span>
|
||||||
|
<h4 className="font-medium text-orange-800">SDK Vitest (~43)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-orange-700">
|
||||||
|
AI Compliance SDK Unit Tests: Types, Export, Components, Reducer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🎭</span>
|
||||||
|
<h4 className="font-medium text-purple-800">SDK Playwright (~25)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-purple-700">
|
||||||
|
SDK E2E Tests: Navigation, Workflow, Command Bar, Export
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🌐</span>
|
||||||
|
<h4 className="font-medium text-slate-800">Website E2E (~5)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
End-to-End Tests fuer kritische User Flows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xl">🔗</span>
|
||||||
|
<h4 className="font-medium text-indigo-800">Integration Tests (~15)</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-indigo-700">
|
||||||
|
Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
|
||||||
|
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
|
||||||
|
{`┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Admin-v2 Test Dashboard │
|
||||||
|
│ /infrastructure/tests │
|
||||||
|
├────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Unit Tests │ │ SDK Tests │ │ BQAS │ │ E2E Tests │ │
|
||||||
|
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│ │
|
||||||
|
│ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Test Registry API │ │
|
||||||
|
│ │ /backend/api/tests/registry.py │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Tests bleiben wo sie sind:
|
||||||
|
- /consent-service/internal/**/*_test.go
|
||||||
|
- /backend/tests/test_*.py
|
||||||
|
- /voice-service/tests/bqas/
|
||||||
|
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
|
||||||
|
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CI/CD Workflow Anleitung */}
|
||||||
|
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
CI/CD Integration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-800 mb-2">🤖 Automatisch (bei jedem Push/PR)</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-blue-700">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Unit Tests</strong> - Go & Python Tests laufen automatisch</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Test-Ergebnisse</strong> - Werden ans Dashboard gesendet</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Backlog</strong> - Fehlgeschlagene Tests erscheinen hier</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-1">✓</span>
|
||||||
|
<span><strong>Linting</strong> - Code-Qualitaet bei PRs pruefen</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-800 mb-2">👆 Manuell (Button oder Tag)</h4>
|
||||||
|
<ul className="space-y-2 text-sm text-blue-700">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>Docker Builds</strong> - Container erstellen</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>SBOM/Scans</strong> - Sicherheitsanalyse ausfuehren</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>Deployment</strong> - In Produktion deployen</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-1">▶</span>
|
||||||
|
<span><strong>Pipeline starten</strong> - Im CI/CD Dashboard</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-blue-200">
|
||||||
|
<p className="text-sm text-blue-600">
|
||||||
|
<strong>Daten-Fluss:</strong> Gitea Actions → POST /api/tests/ci-result → PostgreSQL → Test Dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Link
|
||||||
|
href="/ai/test-quality"
|
||||||
|
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">BQAS Dashboard</p>
|
||||||
|
<p className="text-xs text-slate-500">Detaillierte BQAS-Metriken und Trend-Analyse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/infrastructure/ci-cd"
|
||||||
|
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">CI/CD Pipelines</p>
|
||||||
|
<p className="text-xs text-slate-500">Gitea Actions und automatische Test-Planung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
export function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
trend,
|
||||||
|
color = 'blue',
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
subtitle?: string
|
||||||
|
trend?: 'up' | 'down' | 'stable'
|
||||||
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'orange' | 'purple'
|
||||||
|
}) {
|
||||||
|
const colorClasses = {
|
||||||
|
blue: 'bg-blue-50 border-blue-200',
|
||||||
|
green: 'bg-emerald-50 border-emerald-200',
|
||||||
|
red: 'bg-red-50 border-red-200',
|
||||||
|
yellow: 'bg-amber-50 border-amber-200',
|
||||||
|
orange: 'bg-orange-50 border-orange-200',
|
||||||
|
purple: 'bg-purple-50 border-purple-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const trendIcons = {
|
||||||
|
up: (
|
||||||
|
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
down: (
|
||||||
|
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
stable: (
|
||||||
|
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600">{title}</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
||||||
|
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ServiceTestInfo, ServiceProgress } from '../types'
|
||||||
|
|
||||||
|
export function ServiceTestCard({
|
||||||
|
service,
|
||||||
|
onRun,
|
||||||
|
isRunning,
|
||||||
|
progress,
|
||||||
|
}: {
|
||||||
|
service: ServiceTestInfo
|
||||||
|
onRun: (service: string) => void
|
||||||
|
isRunning: boolean
|
||||||
|
progress?: ServiceProgress
|
||||||
|
}) {
|
||||||
|
const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0
|
||||||
|
|
||||||
|
const getLanguageIcon = (lang: string) => {
|
||||||
|
switch (lang) {
|
||||||
|
case 'go':
|
||||||
|
return '🐹'
|
||||||
|
case 'python':
|
||||||
|
return '🐍'
|
||||||
|
case 'typescript':
|
||||||
|
return '📘'
|
||||||
|
case 'mixed':
|
||||||
|
return '🔀'
|
||||||
|
default:
|
||||||
|
return '📦'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'passed':
|
||||||
|
return 'bg-emerald-100 text-emerald-700'
|
||||||
|
case 'failed':
|
||||||
|
return 'bg-red-100 text-red-700'
|
||||||
|
case 'running':
|
||||||
|
return 'bg-blue-100 text-blue-700'
|
||||||
|
default:
|
||||||
|
return 'bg-slate-100 text-slate-700'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:border-orange-300 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{getLanguageIcon(service.language)}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">{service.display_name}</h3>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{service.port ? `Port ${service.port}` : 'Library'} • {service.language}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(service.status)}`}>
|
||||||
|
{service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-slate-600">Pass Rate</span>
|
||||||
|
<span className="font-medium text-slate-900">{passRate.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${passRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-slate-900">{service.total_tests}</p>
|
||||||
|
<p className="text-xs text-slate-500">Tests</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-emerald-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-emerald-600">{service.passed_tests}</p>
|
||||||
|
<p className="text-xs text-slate-500">Bestanden</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-red-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-red-600">{service.failed_tests}</p>
|
||||||
|
<p className="text-xs text-slate-500">Fehler</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{service.coverage_percent && (
|
||||||
|
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-100">
|
||||||
|
<span className="text-slate-600">Coverage</span>
|
||||||
|
<span className={`font-medium ${service.coverage_percent >= 70 ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
|
{service.coverage_percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress-Anzeige wenn Tests laufen */}
|
||||||
|
{isRunning && progress && progress.status === 'running' && (
|
||||||
|
<div className="mb-3 p-3 bg-orange-50 rounded-lg border border-orange-200">
|
||||||
|
<div className="flex items-center justify-between text-xs text-orange-700 mb-2">
|
||||||
|
<span className="font-mono truncate max-w-[180px]">{progress.current_file || 'Starte...'}</span>
|
||||||
|
<span>{progress.files_done}/{progress.files_total} Dateien</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-orange-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${progress.files_total > 0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs">
|
||||||
|
<span className="text-emerald-600 font-medium">{progress.passed} bestanden</span>
|
||||||
|
<span className="text-red-600 font-medium">{progress.failed} fehler</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onRun(service.service)}
|
||||||
|
disabled={isRunning}
|
||||||
|
className={`w-full py-2 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
isRunning
|
||||||
|
? 'bg-orange-100 text-orange-600 cursor-wait'
|
||||||
|
: 'bg-orange-600 text-white hover:bg-orange-700 active:scale-98'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{progress && progress.status === 'running' ? `${progress.passed + progress.failed} Tests...` : 'Laeuft...'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Tests starten'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { TestRun } from '../types'
|
||||||
|
|
||||||
|
export function TestRunsTable({ runs }: { runs: TestRun[] }) {
|
||||||
|
if (runs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-slate-400">
|
||||||
|
Keine Test-Laeufe vorhanden
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-600">Service</th>
|
||||||
|
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
|
||||||
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
|
||||||
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
|
||||||
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
|
||||||
|
<th className="text-center py-3 px-4 font-medium text-slate-600">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.map((run) => (
|
||||||
|
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||||
|
<td className="py-3 px-4 font-mono text-xs text-slate-500">{run.id.slice(-8)}</td>
|
||||||
|
<td className="py-3 px-4 text-slate-900">{run.service}</td>
|
||||||
|
<td className="py-3 px-4 text-slate-600">
|
||||||
|
{new Date(run.started_at).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<span className="text-emerald-600">{run.passed_tests}</span>
|
||||||
|
<span className="text-slate-400"> / </span>
|
||||||
|
<span className="text-red-600">{run.failed_tests}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-500">
|
||||||
|
{run.duration_seconds.toFixed(1)}s
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
run.status === 'completed'
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: run.status === 'failed'
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: run.status === 'running'
|
||||||
|
? 'bg-blue-100 text-blue-700'
|
||||||
|
: 'bg-slate-100 text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Toast } from '../types'
|
||||||
|
|
||||||
|
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border animate-slide-in ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||||||
|
: toast.type === 'error'
|
||||||
|
? 'bg-red-50 border-red-200 text-red-800'
|
||||||
|
: toast.type === 'loading'
|
||||||
|
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||||
|
: 'bg-slate-50 border-slate-200 text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'loading' ? (
|
||||||
|
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : toast.type === 'success' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : toast.type === 'error' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{toast.message}</span>
|
||||||
|
{toast.type !== 'loading' && (
|
||||||
|
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import type {
|
||||||
|
ServiceTestInfo,
|
||||||
|
TestRegistryStats,
|
||||||
|
TestRun,
|
||||||
|
CoverageData,
|
||||||
|
TabType,
|
||||||
|
Toast,
|
||||||
|
FailedTest,
|
||||||
|
BacklogItem,
|
||||||
|
ServiceProgress,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const API_BASE = '/api/tests'
|
||||||
|
|
||||||
|
// Demo data for when API is not available
|
||||||
|
const DEMO_SERVICES: ServiceTestInfo[] = [
|
||||||
|
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
|
||||||
|
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
|
||||||
|
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEMO_STATS: TestRegistryStats = {
|
||||||
|
total_tests: 278,
|
||||||
|
total_passed: 263,
|
||||||
|
total_failed: 15,
|
||||||
|
total_skipped: 0,
|
||||||
|
overall_pass_rate: 94.6,
|
||||||
|
average_coverage: 78.5,
|
||||||
|
services_count: 11,
|
||||||
|
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
|
||||||
|
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestDashboard() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Toast state
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
const toastIdRef = useRef(0)
|
||||||
|
|
||||||
|
const addToast = useCallback((type: Toast['type'], message: string) => {
|
||||||
|
const id = ++toastIdRef.current
|
||||||
|
setToasts((prev) => [...prev, { id, type, message }])
|
||||||
|
if (type !== 'loading') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateToast = useCallback((id: number, type: Toast['type'], message: string) => {
|
||||||
|
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, type, message } : t)))
|
||||||
|
if (type !== 'loading') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Data states
|
||||||
|
const [services, setServices] = useState<ServiceTestInfo[]>([])
|
||||||
|
const [stats, setStats] = useState<TestRegistryStats | null>(null)
|
||||||
|
const [coverage, setCoverage] = useState<CoverageData[]>([])
|
||||||
|
const [testRuns, setTestRuns] = useState<TestRun[]>([])
|
||||||
|
const [failedTests, setFailedTests] = useState<FailedTest[]>([])
|
||||||
|
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
|
||||||
|
const [usePostgres, setUsePostgres] = useState(false)
|
||||||
|
|
||||||
|
// Running states
|
||||||
|
const [runningServices, setRunningServices] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Progress states fuer laufende Tests
|
||||||
|
const [serviceProgress, setServiceProgress] = useState<Record<string, ServiceProgress>>({})
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registryResponse = await fetch(`${API_BASE}/registry`)
|
||||||
|
if (registryResponse.ok) {
|
||||||
|
const data = await registryResponse.json()
|
||||||
|
setServices(data.services || DEMO_SERVICES)
|
||||||
|
setStats(data.stats || DEMO_STATS)
|
||||||
|
} else {
|
||||||
|
setServices(DEMO_SERVICES)
|
||||||
|
setStats(DEMO_STATS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverageResponse = await fetch(`${API_BASE}/coverage`)
|
||||||
|
if (coverageResponse.ok) {
|
||||||
|
const data = await coverageResponse.json()
|
||||||
|
setCoverage(data.services || [])
|
||||||
|
} else {
|
||||||
|
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
||||||
|
service: s.service,
|
||||||
|
display_name: s.display_name,
|
||||||
|
coverage_percent: s.coverage_percent!,
|
||||||
|
language: s.language,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
const runsResponse = await fetch(`${API_BASE}/runs`)
|
||||||
|
if (runsResponse.ok) {
|
||||||
|
const data = await runsResponse.json()
|
||||||
|
setTestRuns(data.runs || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lade fehlgeschlagene Tests fuer Backlog
|
||||||
|
const failedResponse = await fetch(`${API_BASE}/failed`)
|
||||||
|
if (failedResponse.ok) {
|
||||||
|
const data = await failedResponse.json()
|
||||||
|
setFailedTests(data.tests || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versuche PostgreSQL-Backlog zu laden (neue API)
|
||||||
|
try {
|
||||||
|
const backlogResponse = await fetch(`${API_BASE}/backlog`)
|
||||||
|
if (backlogResponse.ok) {
|
||||||
|
const data = await backlogResponse.json()
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
setBacklogItems(data.items)
|
||||||
|
setUsePostgres(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PostgreSQL nicht verfuegbar, nutze Legacy
|
||||||
|
setUsePostgres(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch test registry data:', err)
|
||||||
|
setServices(DEMO_SERVICES)
|
||||||
|
setStats(DEMO_STATS)
|
||||||
|
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
|
||||||
|
service: s.service,
|
||||||
|
display_name: s.display_name,
|
||||||
|
coverage_percent: s.coverage_percent!,
|
||||||
|
language: s.language,
|
||||||
|
})))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
// Update failed test status
|
||||||
|
const updateTestStatus = async (testId: string, status: string) => {
|
||||||
|
try {
|
||||||
|
const endpoint = usePostgres
|
||||||
|
? `${API_BASE}/backlog/${testId}/status`
|
||||||
|
: `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}`
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: usePostgres ? JSON.stringify({ status }) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (usePostgres) {
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setFailedTests(prev =>
|
||||||
|
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
addToast('success', `Test-Status auf "${status}" gesetzt`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update test status:', err)
|
||||||
|
setFailedTests(prev =>
|
||||||
|
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
if (usePostgres) {
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update failed test priority (nur PostgreSQL)
|
||||||
|
const updateTestPriority = async (testId: string, priority: string) => {
|
||||||
|
if (!usePostgres) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ priority }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
||||||
|
)
|
||||||
|
addToast('success', `Prioritaet auf "${priority}" gesetzt`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update test priority:', err)
|
||||||
|
setBacklogItems(prev =>
|
||||||
|
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests mit Progress-Polling
|
||||||
|
const runTests = async (service: string) => {
|
||||||
|
setRunningServices((prev) => new Set(prev).add(service))
|
||||||
|
const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`)
|
||||||
|
|
||||||
|
// Progress-Polling starten
|
||||||
|
let pollInterval: NodeJS.Timeout | null = null
|
||||||
|
const pollProgress = async () => {
|
||||||
|
try {
|
||||||
|
const progressResponse = await fetch(`${API_BASE}/progress/${service}`)
|
||||||
|
if (progressResponse.ok) {
|
||||||
|
const progress = await progressResponse.json()
|
||||||
|
setServiceProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[service]: progress,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (progress.status === 'running' && progress.files_total > 0) {
|
||||||
|
const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)`
|
||||||
|
updateToast(loadingToast, 'loading', toastMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore polling errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollInterval = setInterval(pollProgress, 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/run/${service}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
await pollProgress()
|
||||||
|
const finalProgress = serviceProgress[service]
|
||||||
|
const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen'
|
||||||
|
updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`)
|
||||||
|
await fetchData()
|
||||||
|
} else {
|
||||||
|
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to run tests:', err)
|
||||||
|
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
|
||||||
|
} finally {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
}
|
||||||
|
setRunningServices((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(service)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setServiceProgress((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[service]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
toasts,
|
||||||
|
removeToast,
|
||||||
|
services,
|
||||||
|
stats,
|
||||||
|
coverage,
|
||||||
|
testRuns,
|
||||||
|
failedTests,
|
||||||
|
backlogItems,
|
||||||
|
usePostgres,
|
||||||
|
runningServices,
|
||||||
|
serviceProgress,
|
||||||
|
fetchData,
|
||||||
|
updateTestStatus,
|
||||||
|
updateTestPriority,
|
||||||
|
runTests,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
102
admin-core/app/(admin)/infrastructure/tests/types.ts
Normal file
102
admin-core/app/(admin)/infrastructure/tests/types.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Types for Test Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ServiceTestInfo {
|
||||||
|
service: string
|
||||||
|
display_name: string
|
||||||
|
port?: number
|
||||||
|
language: string
|
||||||
|
total_tests: number
|
||||||
|
passed_tests: number
|
||||||
|
failed_tests: number
|
||||||
|
skipped_tests: number
|
||||||
|
pass_rate: number
|
||||||
|
coverage_percent?: number
|
||||||
|
last_run: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRegistryStats {
|
||||||
|
total_tests: number
|
||||||
|
total_passed: number
|
||||||
|
total_failed: number
|
||||||
|
total_skipped: number
|
||||||
|
overall_pass_rate: number
|
||||||
|
average_coverage: number
|
||||||
|
services_count: number
|
||||||
|
by_category: Record<string, number>
|
||||||
|
by_framework: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestRun {
|
||||||
|
id: string
|
||||||
|
service: string
|
||||||
|
started_at: string
|
||||||
|
total_tests: number
|
||||||
|
passed_tests: number
|
||||||
|
failed_tests: number
|
||||||
|
duration_seconds: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageData {
|
||||||
|
service: string
|
||||||
|
display_name: string
|
||||||
|
coverage_percent: number
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabType = 'overview' | 'unit' | 'bqas' | 'history' | 'backlog' | 'guide'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
type: 'success' | 'error' | 'loading' | 'info'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedTest {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
service: string
|
||||||
|
file_path: string
|
||||||
|
error_message: string
|
||||||
|
error_type: string
|
||||||
|
suggestion: string
|
||||||
|
run_id: string
|
||||||
|
last_failed: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BacklogPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||||
|
export type BacklogStatus = 'open' | 'in_progress' | 'fixed' | 'wont_fix' | 'flaky'
|
||||||
|
|
||||||
|
export interface BacklogItem {
|
||||||
|
id: number
|
||||||
|
test_name: string
|
||||||
|
service: string
|
||||||
|
test_file?: string
|
||||||
|
error_message?: string
|
||||||
|
error_type?: string
|
||||||
|
fix_suggestion?: string
|
||||||
|
priority: BacklogPriority
|
||||||
|
status: BacklogStatus
|
||||||
|
failure_count: number
|
||||||
|
last_failed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendDataPoint {
|
||||||
|
date: string
|
||||||
|
passed: number
|
||||||
|
failed: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceProgress {
|
||||||
|
current_file: string
|
||||||
|
files_done: number
|
||||||
|
files_total: number
|
||||||
|
passed: number
|
||||||
|
failed: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* Communication Admin API Route - Stats Proxy
|
|
||||||
*
|
|
||||||
* Proxies requests to Matrix/Jitsi admin endpoints via backend
|
|
||||||
* Aggregates statistics from both services
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
// Service URLs
|
|
||||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
|
||||||
const CONSENT_SERVICE_URL = process.env.CONSENT_SERVICE_URL || 'http://localhost:8081'
|
|
||||||
const MATRIX_ADMIN_URL = process.env.MATRIX_ADMIN_URL || 'http://localhost:8448'
|
|
||||||
const JITSI_URL = process.env.JITSI_URL || 'http://localhost:8443'
|
|
||||||
|
|
||||||
// Matrix Admin Token (for Synapse Admin API)
|
|
||||||
const MATRIX_ADMIN_TOKEN = process.env.MATRIX_ADMIN_TOKEN || ''
|
|
||||||
|
|
||||||
interface MatrixStats {
|
|
||||||
total_users: number
|
|
||||||
active_users: number
|
|
||||||
total_rooms: number
|
|
||||||
active_rooms: number
|
|
||||||
messages_today: number
|
|
||||||
messages_this_week: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JitsiStats {
|
|
||||||
active_meetings: number
|
|
||||||
total_participants: number
|
|
||||||
meetings_today: number
|
|
||||||
average_duration_minutes: number
|
|
||||||
peak_concurrent_users: number
|
|
||||||
total_minutes_today: number
|
|
||||||
status: 'online' | 'offline' | 'degraded'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromBackend(): Promise<{
|
|
||||||
matrix: MatrixStats
|
|
||||||
jitsi: JitsiStats
|
|
||||||
active_meetings: unknown[]
|
|
||||||
recent_rooms: unknown[]
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BACKEND_URL}/api/v1/communication/admin/stats`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Backend not reachable, trying consent service:', error)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromConsentService(): Promise<{
|
|
||||||
matrix: MatrixStats
|
|
||||||
jitsi: JitsiStats
|
|
||||||
active_meetings: unknown[]
|
|
||||||
recent_rooms: unknown[]
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${CONSENT_SERVICE_URL}/api/v1/communication/admin/stats`, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
return await response.json()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Consent service not reachable:', error)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMatrixStats(): Promise<MatrixStats> {
|
|
||||||
try {
|
|
||||||
// Check if Matrix is reachable
|
|
||||||
const healthCheck = await fetch(`${MATRIX_ADMIN_URL}/_matrix/client/versions`, {
|
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (healthCheck.ok) {
|
|
||||||
// Try to get user count from admin API
|
|
||||||
if (MATRIX_ADMIN_TOKEN) {
|
|
||||||
try {
|
|
||||||
const usersResponse = await fetch(`${MATRIX_ADMIN_URL}/_synapse/admin/v2/users?limit=1`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${MATRIX_ADMIN_TOKEN}` },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
if (usersResponse.ok) {
|
|
||||||
const data = await usersResponse.json()
|
|
||||||
return {
|
|
||||||
total_users: data.total || 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'online'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Admin API not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_users: 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'degraded' // Server reachable but no admin access
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Matrix stats fetch error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_users: 0,
|
|
||||||
active_users: 0,
|
|
||||||
total_rooms: 0,
|
|
||||||
active_rooms: 0,
|
|
||||||
messages_today: 0,
|
|
||||||
messages_this_week: 0,
|
|
||||||
status: 'offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJitsiStats(): Promise<JitsiStats> {
|
|
||||||
try {
|
|
||||||
// Check if Jitsi is reachable
|
|
||||||
const healthCheck = await fetch(`${JITSI_URL}/http-bind`, {
|
|
||||||
method: 'HEAD',
|
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
active_meetings: 0,
|
|
||||||
total_participants: 0,
|
|
||||||
meetings_today: 0,
|
|
||||||
average_duration_minutes: 0,
|
|
||||||
peak_concurrent_users: 0,
|
|
||||||
total_minutes_today: 0,
|
|
||||||
status: healthCheck.ok ? 'online' : 'offline'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Jitsi stats fetch error:', error)
|
|
||||||
return {
|
|
||||||
active_meetings: 0,
|
|
||||||
total_participants: 0,
|
|
||||||
meetings_today: 0,
|
|
||||||
average_duration_minutes: 0,
|
|
||||||
peak_concurrent_users: 0,
|
|
||||||
total_minutes_today: 0,
|
|
||||||
status: 'offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Try backend first
|
|
||||||
let data = await fetchFromBackend()
|
|
||||||
|
|
||||||
// Fallback to consent service
|
|
||||||
if (!data) {
|
|
||||||
data = await fetchFromConsentService()
|
|
||||||
}
|
|
||||||
|
|
||||||
// If both fail, try direct service checks
|
|
||||||
if (!data) {
|
|
||||||
const [matrixStats, jitsiStats] = await Promise.all([
|
|
||||||
fetchMatrixStats(),
|
|
||||||
fetchJitsiStats()
|
|
||||||
])
|
|
||||||
|
|
||||||
data = {
|
|
||||||
matrix: matrixStats,
|
|
||||||
jitsi: jitsiStats,
|
|
||||||
active_meetings: [],
|
|
||||||
recent_rooms: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
...data,
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Communication stats error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Fehler beim Abrufen der Statistiken',
|
|
||||||
matrix: { status: 'offline', total_users: 0, active_users: 0, total_rooms: 0, active_rooms: 0, messages_today: 0, messages_this_week: 0 },
|
|
||||||
jitsi: { status: 'offline', active_meetings: 0, total_participants: 0, meetings_today: 0, average_duration_minutes: 0, peak_concurrent_users: 0, total_minutes_today: 0 },
|
|
||||||
active_meetings: [],
|
|
||||||
recent_rooms: [],
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ const SERVICES: ServiceConfig[] = [
|
|||||||
// Core Services
|
// Core Services
|
||||||
{ name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' },
|
{ name: 'Backend API', port: 8000, endpoint: '/health', category: 'core' },
|
||||||
{ name: 'Consent Service', port: 8081, endpoint: '/api/v1/health', category: 'core' },
|
{ name: 'Consent Service', port: 8081, endpoint: '/api/v1/health', category: 'core' },
|
||||||
{ name: 'Voice Service', port: 8091, endpoint: '/health', category: 'core' },
|
|
||||||
{ name: 'Klausur Service', port: 8086, endpoint: '/health', category: 'core' },
|
{ name: 'Klausur Service', port: 8086, endpoint: '/health', category: 'core' },
|
||||||
{ name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' },
|
{ name: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', category: 'core' },
|
||||||
{ name: 'Edu Search', port: 8088, endpoint: '/health', category: 'core' },
|
{ name: 'Edu Search', port: 8088, endpoint: '/health', category: 'core' },
|
||||||
@@ -41,7 +40,6 @@ const getInternalHost = (port: number): string => {
|
|||||||
const serviceMap: Record<number, string> = {
|
const serviceMap: Record<number, string> = {
|
||||||
8000: 'backend',
|
8000: 'backend',
|
||||||
8081: 'consent-service',
|
8081: 'consent-service',
|
||||||
8091: 'voice-service',
|
|
||||||
8086: 'klausur-service',
|
8086: 'klausur-service',
|
||||||
8025: 'mailpit',
|
8025: 'mailpit',
|
||||||
8088: 'edu-search-service',
|
8088: 'edu-search-service',
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
// Woodpecker API configuration
|
|
||||||
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
|
|
||||||
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-core:8000'
|
|
||||||
|
|
||||||
export interface PipelineStep {
|
|
||||||
name: string
|
|
||||||
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
|
|
||||||
exit_code: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Pipeline {
|
|
||||||
id: number
|
|
||||||
number: number
|
|
||||||
status: 'pending' | 'running' | 'success' | 'failure' | 'error'
|
|
||||||
event: string
|
|
||||||
branch: string
|
|
||||||
commit: string
|
|
||||||
message: string
|
|
||||||
author: string
|
|
||||||
created: number
|
|
||||||
started: number
|
|
||||||
finished: number
|
|
||||||
steps: PipelineStep[]
|
|
||||||
errors?: string[]
|
|
||||||
repo_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WoodpeckerStatusResponse {
|
|
||||||
status: 'online' | 'offline'
|
|
||||||
pipelines: Pipeline[]
|
|
||||||
lastUpdate: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromBackendProxy(repoId: string, limit: number): Promise<WoodpeckerStatusResponse> {
|
|
||||||
// Use backend-core proxy that reads Woodpecker sqlite DB directly
|
|
||||||
const url = `${BACKEND_URL}/api/v1/woodpecker/pipelines?repo=${repoId}&limit=${limit}`
|
|
||||||
const response = await fetch(url, { cache: 'no-store' })
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
status: 'offline',
|
|
||||||
pipelines: [],
|
|
||||||
lastUpdate: new Date().toISOString(),
|
|
||||||
error: `Backend Woodpecker Proxy Fehler (${response.status})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return {
|
|
||||||
status: data.status || 'online',
|
|
||||||
pipelines: (data.pipelines || []).map((p: any) => ({
|
|
||||||
id: p.id,
|
|
||||||
number: p.number,
|
|
||||||
status: p.status,
|
|
||||||
event: p.event,
|
|
||||||
branch: p.branch || 'main',
|
|
||||||
commit: p.commit || '',
|
|
||||||
message: p.message || '',
|
|
||||||
author: p.author || '',
|
|
||||||
created: p.created,
|
|
||||||
started: p.started,
|
|
||||||
finished: p.finished,
|
|
||||||
repo_name: p.repo_name,
|
|
||||||
steps: (p.steps || []).map((s: any) => ({
|
|
||||||
name: s.name,
|
|
||||||
state: s.state,
|
|
||||||
exit_code: s.exit_code || 0,
|
|
||||||
error: s.error
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
lastUpdate: data.lastUpdate || new Date().toISOString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchFromWoodpeckerAPI(repoId: string, limit: number): Promise<WoodpeckerStatusResponse> {
|
|
||||||
const response = await fetch(
|
|
||||||
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines?per_page=${limit}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
status: 'offline',
|
|
||||||
pipelines: [],
|
|
||||||
lastUpdate: new Date().toISOString(),
|
|
||||||
error: `Woodpecker API nicht erreichbar (${response.status})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPipelines = await response.json()
|
|
||||||
|
|
||||||
const pipelines: Pipeline[] = rawPipelines.map((p: any) => {
|
|
||||||
const errors: string[] = []
|
|
||||||
const steps: PipelineStep[] = []
|
|
||||||
|
|
||||||
if (p.workflows) {
|
|
||||||
for (const workflow of p.workflows) {
|
|
||||||
if (workflow.children) {
|
|
||||||
for (const child of workflow.children) {
|
|
||||||
steps.push({
|
|
||||||
name: child.name,
|
|
||||||
state: child.state,
|
|
||||||
exit_code: child.exit_code,
|
|
||||||
error: child.error
|
|
||||||
})
|
|
||||||
if (child.state === 'failure' && child.error) {
|
|
||||||
errors.push(`${child.name}: ${child.error}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: p.id,
|
|
||||||
number: p.number,
|
|
||||||
status: p.status,
|
|
||||||
event: p.event,
|
|
||||||
branch: p.branch,
|
|
||||||
commit: p.commit?.substring(0, 7) || '',
|
|
||||||
message: p.message || '',
|
|
||||||
author: p.author,
|
|
||||||
created: p.created,
|
|
||||||
started: p.started,
|
|
||||||
finished: p.finished,
|
|
||||||
steps,
|
|
||||||
errors: errors.length > 0 ? errors : undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'online',
|
|
||||||
pipelines,
|
|
||||||
lastUpdate: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const repoId = searchParams.get('repo') || '0'
|
|
||||||
const limit = parseInt(searchParams.get('limit') || '10')
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If WOODPECKER_TOKEN is set, use the Woodpecker API directly
|
|
||||||
// Otherwise, use the backend proxy that reads the sqlite DB
|
|
||||||
if (WOODPECKER_TOKEN) {
|
|
||||||
return NextResponse.json(await fetchFromWoodpeckerAPI(repoId, limit))
|
|
||||||
} else {
|
|
||||||
return NextResponse.json(await fetchFromBackendProxy(repoId, limit))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Woodpecker API error:', error)
|
|
||||||
return NextResponse.json({
|
|
||||||
status: 'offline',
|
|
||||||
pipelines: [],
|
|
||||||
lastUpdate: new Date().toISOString(),
|
|
||||||
error: 'Fehler beim Abrufen des Woodpecker Status'
|
|
||||||
} as WoodpeckerStatusResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger a new pipeline
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { repoId = '1', branch = 'main' } = body
|
|
||||||
|
|
||||||
if (!WOODPECKER_TOKEN) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'WOODPECKER_TOKEN nicht konfiguriert - Pipeline-Start nicht moeglich' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ branch }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Pipeline konnte nicht gestartet werden' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pipeline = await response.json()
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
pipeline: {
|
|
||||||
id: pipeline.id,
|
|
||||||
number: pipeline.number,
|
|
||||||
status: pipeline.status
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Pipeline trigger error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Fehler beim Starten der Pipeline' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get pipeline logs
|
|
||||||
export async function PUT(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { repoId = '1', pipelineNumber, stepId } = body
|
|
||||||
|
|
||||||
if (!pipelineNumber || !stepId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'pipelineNumber und stepId erforderlich' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!WOODPECKER_TOKEN) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'WOODPECKER_TOKEN nicht konfiguriert' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${WOODPECKER_URL}/api/repos/${repoId}/pipelines/${pipelineNumber}/logs/${stepId}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Logs nicht verfuegbar' },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const logs = await response.json()
|
|
||||||
return NextResponse.json({ logs })
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Pipeline logs error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Fehler beim Abrufen der Logs' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side proxy for Mailpit API
|
|
||||||
* Avoids CORS and mixed-content issues by fetching from server
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Use internal Docker hostname when running in container
|
|
||||||
const getMailpitHost = (): string => {
|
|
||||||
return process.env.BACKEND_URL ? 'mailpit' : 'localhost'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const host = getMailpitHost()
|
|
||||||
const mailpitUrl = `http://${host}:8025/api/v1/info`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(mailpitUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Mailpit API error', status: response.status },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
// Transform Mailpit response to our expected format
|
|
||||||
return NextResponse.json({
|
|
||||||
stats: {
|
|
||||||
totalAccounts: 1,
|
|
||||||
activeAccounts: 1,
|
|
||||||
totalEmails: data.Messages || 0,
|
|
||||||
unreadEmails: data.Unread || 0,
|
|
||||||
totalTasks: 0,
|
|
||||||
pendingTasks: 0,
|
|
||||||
overdueTasks: 0,
|
|
||||||
aiAnalyzedCount: 0,
|
|
||||||
lastSyncTime: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
accounts: [{
|
|
||||||
id: 'mailpit-dev',
|
|
||||||
email: 'dev@mailpit.local',
|
|
||||||
displayName: 'Mailpit (Development)',
|
|
||||||
imapHost: 'mailpit',
|
|
||||||
imapPort: 1143,
|
|
||||||
smtpHost: 'mailpit',
|
|
||||||
smtpPort: 1025,
|
|
||||||
status: 'active' as const,
|
|
||||||
lastSync: new Date().toISOString(),
|
|
||||||
emailCount: data.Messages || 0,
|
|
||||||
unreadCount: data.Unread || 0,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}],
|
|
||||||
syncStatus: {
|
|
||||||
running: false,
|
|
||||||
accountsInProgress: [],
|
|
||||||
lastCompleted: new Date().toISOString(),
|
|
||||||
errors: [],
|
|
||||||
},
|
|
||||||
mailpitInfo: {
|
|
||||||
version: data.Version,
|
|
||||||
databaseSize: data.DatabaseSize,
|
|
||||||
uptime: data.RuntimeStats?.Uptime,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch from Mailpit:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Failed to connect to Mailpit',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
},
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
/**
|
|
||||||
* Alerts API Proxy - Catch-all route
|
|
||||||
* Proxies all /api/alerts/* requests to backend
|
|
||||||
* Supports: inbox, topics, rules, profile, stats, etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
|
||||||
|
|
||||||
function getForwardHeaders(request: NextRequest): HeadersInit {
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward cookie for session auth
|
|
||||||
const cookie = request.headers.get('cookie')
|
|
||||||
if (cookie) {
|
|
||||||
headers['Cookie'] = cookie
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward authorization header if present
|
|
||||||
const auth = request.headers.get('authorization')
|
|
||||||
if (auth) {
|
|
||||||
headers['Authorization'] = auth
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const searchParams = request.nextUrl.searchParams.toString()
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}${searchParams ? `?${searchParams}` : ''}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ path: string[] }> }
|
|
||||||
) {
|
|
||||||
const { path } = await params
|
|
||||||
const pathStr = path.join('/')
|
|
||||||
const url = `${BACKEND_URL}/api/alerts/${pathStr}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getForwardHeaders(request),
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Backend Error: ${response.status}`, details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Alerts API proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import type { WoodpeckerWebhookPayload, ExtractedError, BacklogSource } from '@/types/infrastructure-modules'
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Configuration
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// Webhook secret for verification (optional but recommended)
|
|
||||||
const WEBHOOK_SECRET = process.env.WOODPECKER_WEBHOOK_SECRET || ''
|
|
||||||
|
|
||||||
// Internal API URL for log extraction
|
|
||||||
const LOG_EXTRACT_URL = process.env.NEXT_PUBLIC_APP_URL
|
|
||||||
? `${process.env.NEXT_PUBLIC_APP_URL}/api/infrastructure/log-extract/extract`
|
|
||||||
: 'http://localhost:3002/api/infrastructure/log-extract/extract'
|
|
||||||
|
|
||||||
// Test service API URL for backlog insertion
|
|
||||||
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8086'
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify webhook signature (if secret is configured)
|
|
||||||
*/
|
|
||||||
function verifySignature(request: NextRequest, body: string): boolean {
|
|
||||||
if (!WEBHOOK_SECRET) return true // Skip verification if no secret configured
|
|
||||||
|
|
||||||
const signature = request.headers.get('X-Woodpecker-Signature')
|
|
||||||
if (!signature) return false
|
|
||||||
|
|
||||||
// Simple HMAC verification (Woodpecker uses SHA256)
|
|
||||||
const crypto = require('crypto')
|
|
||||||
const expectedSignature = crypto
|
|
||||||
.createHmac('sha256', WEBHOOK_SECRET)
|
|
||||||
.update(body)
|
|
||||||
.digest('hex')
|
|
||||||
|
|
||||||
return signature === `sha256=${expectedSignature}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map error category to backlog priority
|
|
||||||
*/
|
|
||||||
function categoryToPriority(category: string): 'critical' | 'high' | 'medium' | 'low' {
|
|
||||||
switch (category) {
|
|
||||||
case 'security_warning':
|
|
||||||
return 'critical'
|
|
||||||
case 'build_error':
|
|
||||||
return 'high'
|
|
||||||
case 'license_violation':
|
|
||||||
return 'high'
|
|
||||||
case 'test_failure':
|
|
||||||
return 'medium'
|
|
||||||
case 'dependency_issue':
|
|
||||||
return 'low'
|
|
||||||
default:
|
|
||||||
return 'medium'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map error category to error_type for backlog
|
|
||||||
*/
|
|
||||||
function categoryToErrorType(category: string): string {
|
|
||||||
switch (category) {
|
|
||||||
case 'security_warning':
|
|
||||||
return 'security'
|
|
||||||
case 'build_error':
|
|
||||||
return 'build'
|
|
||||||
case 'license_violation':
|
|
||||||
return 'license'
|
|
||||||
case 'test_failure':
|
|
||||||
return 'test'
|
|
||||||
case 'dependency_issue':
|
|
||||||
return 'dependency'
|
|
||||||
default:
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert extracted errors into backlog
|
|
||||||
*/
|
|
||||||
async function insertIntoBacklog(
|
|
||||||
errors: ExtractedError[],
|
|
||||||
pipelineNumber: number,
|
|
||||||
source: BacklogSource
|
|
||||||
): Promise<{ inserted: number; failed: number }> {
|
|
||||||
let inserted = 0
|
|
||||||
let failed = 0
|
|
||||||
|
|
||||||
for (const error of errors) {
|
|
||||||
try {
|
|
||||||
// Create backlog item
|
|
||||||
const backlogItem = {
|
|
||||||
test_name: error.message.substring(0, 200), // Truncate long messages
|
|
||||||
test_file: error.file_path || null,
|
|
||||||
service: error.service || 'unknown',
|
|
||||||
framework: `ci_cd_pipeline_${pipelineNumber}`,
|
|
||||||
error_message: error.message,
|
|
||||||
error_type: categoryToErrorType(error.category),
|
|
||||||
status: 'open',
|
|
||||||
priority: categoryToPriority(error.category),
|
|
||||||
fix_suggestion: error.suggested_fix || null,
|
|
||||||
notes: `Auto-generated from pipeline #${pipelineNumber}, step: ${error.step}, line: ${error.line}`,
|
|
||||||
source, // Custom field to track origin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to insert into test service backlog
|
|
||||||
const response = await fetch(`${TEST_SERVICE_URL}/api/v1/backlog`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(backlogItem),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
inserted++
|
|
||||||
} else {
|
|
||||||
console.warn(`Failed to insert backlog item: ${response.status}`)
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
} catch (insertError) {
|
|
||||||
console.error('Backlog insertion error:', insertError)
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { inserted, failed }
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// API Handler
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/webhooks/woodpecker
|
|
||||||
*
|
|
||||||
* Webhook endpoint fuer Woodpecker CI/CD Events.
|
|
||||||
*
|
|
||||||
* Bei Pipeline-Failure:
|
|
||||||
* 1. Extrahiert Logs mit /api/infrastructure/logs/extract
|
|
||||||
* 2. Parsed Fehler nach Kategorie
|
|
||||||
* 3. Traegt automatisch in Backlog ein
|
|
||||||
*
|
|
||||||
* Request Body (Woodpecker Webhook Format):
|
|
||||||
* - event: 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
|
|
||||||
* - repo_id: number
|
|
||||||
* - pipeline_number: number
|
|
||||||
* - branch?: string
|
|
||||||
* - commit?: string
|
|
||||||
* - author?: string
|
|
||||||
* - message?: string
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const bodyText = await request.text()
|
|
||||||
|
|
||||||
// Verify webhook signature
|
|
||||||
if (!verifySignature(request, bodyText)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid webhook signature' },
|
|
||||||
{ status: 401 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: WoodpeckerWebhookPayload = JSON.parse(bodyText)
|
|
||||||
|
|
||||||
// Log all events for debugging
|
|
||||||
console.log(`Woodpecker webhook: ${payload.event} for pipeline #${payload.pipeline_number}`)
|
|
||||||
|
|
||||||
// Only process pipeline_failure events
|
|
||||||
if (payload.event !== 'pipeline_failure') {
|
|
||||||
return NextResponse.json({
|
|
||||||
status: 'ignored',
|
|
||||||
message: `Event ${payload.event} wird nicht verarbeitet`,
|
|
||||||
pipeline_number: payload.pipeline_number,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Extract logs from failed pipeline
|
|
||||||
console.log(`Extracting logs for failed pipeline #${payload.pipeline_number}`)
|
|
||||||
|
|
||||||
const extractResponse = await fetch(LOG_EXTRACT_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
pipeline_number: payload.pipeline_number,
|
|
||||||
repo_id: String(payload.repo_id),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!extractResponse.ok) {
|
|
||||||
const errorText = await extractResponse.text()
|
|
||||||
console.error('Log extraction failed:', errorText)
|
|
||||||
return NextResponse.json({
|
|
||||||
status: 'error',
|
|
||||||
message: 'Log-Extraktion fehlgeschlagen',
|
|
||||||
pipeline_number: payload.pipeline_number,
|
|
||||||
}, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractionResult = await extractResponse.json()
|
|
||||||
const errors: ExtractedError[] = extractionResult.errors || []
|
|
||||||
|
|
||||||
console.log(`Extracted ${errors.length} errors from pipeline #${payload.pipeline_number}`)
|
|
||||||
|
|
||||||
// 2. Insert errors into backlog
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const backlogResult = await insertIntoBacklog(
|
|
||||||
errors,
|
|
||||||
payload.pipeline_number,
|
|
||||||
'ci_cd'
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(`Backlog: ${backlogResult.inserted} inserted, ${backlogResult.failed} failed`)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
status: 'processed',
|
|
||||||
pipeline_number: payload.pipeline_number,
|
|
||||||
branch: payload.branch,
|
|
||||||
commit: payload.commit,
|
|
||||||
errors_found: errors.length,
|
|
||||||
backlog_inserted: backlogResult.inserted,
|
|
||||||
backlog_failed: backlogResult.failed,
|
|
||||||
categories: {
|
|
||||||
test_failure: errors.filter(e => e.category === 'test_failure').length,
|
|
||||||
build_error: errors.filter(e => e.category === 'build_error').length,
|
|
||||||
security_warning: errors.filter(e => e.category === 'security_warning').length,
|
|
||||||
license_violation: errors.filter(e => e.category === 'license_violation').length,
|
|
||||||
dependency_issue: errors.filter(e => e.category === 'dependency_issue').length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
status: 'processed',
|
|
||||||
pipeline_number: payload.pipeline_number,
|
|
||||||
message: 'Keine Fehler extrahiert',
|
|
||||||
errors_found: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Webhook processing error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Webhook-Verarbeitung fehlgeschlagen' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/webhooks/woodpecker
|
|
||||||
*
|
|
||||||
* Health check endpoint
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json({
|
|
||||||
status: 'ready',
|
|
||||||
endpoint: '/api/webhooks/woodpecker',
|
|
||||||
events: ['pipeline_failure'],
|
|
||||||
description: 'Woodpecker CI/CD Webhook Handler',
|
|
||||||
configured: {
|
|
||||||
webhook_secret: WEBHOOK_SECRET ? 'yes' : 'no',
|
|
||||||
log_extract_url: LOG_EXTRACT_URL,
|
|
||||||
test_service_url: TEST_SERVICE_URL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -20,126 +20,17 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type {
|
import type {
|
||||||
DevOpsToolId,
|
|
||||||
DevOpsPipelineSidebarProps,
|
DevOpsPipelineSidebarProps,
|
||||||
DevOpsPipelineSidebarResponsiveProps,
|
DevOpsPipelineSidebarResponsiveProps,
|
||||||
PipelineLiveStatus,
|
|
||||||
} from '@/types/infrastructure-modules'
|
} from '@/types/infrastructure-modules'
|
||||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||||
|
import {
|
||||||
// =============================================================================
|
ToolIcon,
|
||||||
// Icons
|
ServerIcon,
|
||||||
// =============================================================================
|
PlayIcon,
|
||||||
|
StatusBadge,
|
||||||
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
usePipelineLiveStatus,
|
||||||
switch (id) {
|
} from './DevOpsPipelineSidebarParts'
|
||||||
case 'ci-cd':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'tests':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'sbom':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
case 'security':
|
|
||||||
return (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server/Pipeline Icon fuer Header
|
|
||||||
const ServerIcon = () => (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Play Icon fuer Quick Action
|
|
||||||
const PlayIcon = () => (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Live Status Hook (optional - fetches status from API)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
|
||||||
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Optional: Fetch live status from API
|
|
||||||
// For now, return null and display static content
|
|
||||||
// Uncomment below to enable live status fetching
|
|
||||||
/*
|
|
||||||
const fetchStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/infrastructure/woodpecker/status')
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
setStatus(data)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch pipeline status:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fetchStatus()
|
|
||||||
const interval = setInterval(fetchStatus, 30000) // Poll every 30s
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
*/
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Status Badge Component
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
count: number
|
|
||||||
type: 'backlog' | 'security' | 'running'
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ count, type }: StatusBadgeProps) {
|
|
||||||
if (count === 0) return null
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
backlog: 'bg-amber-500',
|
|
||||||
security: 'bg-red-500',
|
|
||||||
running: 'bg-green-500 animate-pulse',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Sidebar Component
|
// Main Sidebar Component
|
||||||
@@ -246,7 +137,7 @@ export function DevOpsPipelineSidebar({
|
|||||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||||
{currentTool === 'ci-cd' && (
|
{currentTool === 'ci-cd' && (
|
||||||
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
|
<span>Verwalten Sie Gitea Actions Pipelines und Deployments</span>
|
||||||
)}
|
)}
|
||||||
{currentTool === 'tests' && (
|
{currentTool === 'tests' && (
|
||||||
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
|
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
|
||||||
@@ -458,7 +349,7 @@ export function DevOpsPipelineSidebarResponsive({
|
|||||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||||
{currentTool === 'ci-cd' && (
|
{currentTool === 'ci-cd' && (
|
||||||
<>
|
<>
|
||||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Woodpecker Pipelines und Deployments verwalten
|
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Gitea Actions Pipelines und Deployments verwalten
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentTool === 'tests' && (
|
{currentTool === 'tests' && (
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DevOps Pipeline Sidebar — shared icons, badge, and live-status hook.
|
||||||
|
*
|
||||||
|
* Extracted from DevOpsPipelineSidebar.tsx to stay within the 500 LOC budget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import type { DevOpsToolId, PipelineLiveStatus } from '@/types/infrastructure-modules'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Icons
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
||||||
|
switch (id) {
|
||||||
|
case 'ci-cd':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'tests':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'sbom':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'security':
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server/Pipeline Icon fuer Header
|
||||||
|
export const ServerIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Play Icon fuer Quick Action
|
||||||
|
export const PlayIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Live Status Hook (optional - fetches status from API)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
||||||
|
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Live status fetching not yet implemented
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Status Badge Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
count: number
|
||||||
|
type: 'backlog' | 'security' | 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ count, type }: StatusBadgeProps) {
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
backlog: 'bg-amber-500',
|
||||||
|
security: 'bg-red-500',
|
||||||
|
running: 'bg-green-500 animate-pulse',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* 3 Categories: Communication, Infrastructure, Development
|
* 3 Categories: Communication, Infrastructure, Development
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CategoryId = 'communication' | 'infrastructure' | 'development'
|
export type CategoryId = 'infrastructure'
|
||||||
|
|
||||||
export interface NavModule {
|
export interface NavModule {
|
||||||
id: string
|
id: string
|
||||||
@@ -27,51 +27,6 @@ export interface NavCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const navigation: NavCategory[] = [
|
export const navigation: NavCategory[] = [
|
||||||
// =========================================================================
|
|
||||||
// Kommunikation & Alerts (Green)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
id: 'communication',
|
|
||||||
name: 'Kommunikation',
|
|
||||||
icon: 'message-circle',
|
|
||||||
color: '#22c55e',
|
|
||||||
colorClass: 'communication',
|
|
||||||
description: 'Matrix, Jitsi, E-Mail & Alerts',
|
|
||||||
modules: [
|
|
||||||
{
|
|
||||||
id: 'video-chat',
|
|
||||||
name: 'Video & Chat',
|
|
||||||
href: '/communication/video-chat',
|
|
||||||
description: 'Matrix & Jitsi Monitoring',
|
|
||||||
purpose: 'Dashboard fuer Matrix Synapse und Jitsi Meet. Service-Status, aktive Meetings, Traffic.',
|
|
||||||
audience: ['Admins', 'DevOps'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'matrix',
|
|
||||||
name: 'Voice Service',
|
|
||||||
href: '/communication/matrix',
|
|
||||||
description: 'PersonaPlex-7B & TaskOrchestrator',
|
|
||||||
purpose: 'Voice-First Interface Konfiguration und Architektur-Dokumentation.',
|
|
||||||
audience: ['Entwickler', 'Admins'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mail',
|
|
||||||
name: 'Unified Inbox',
|
|
||||||
href: '/communication/mail',
|
|
||||||
description: 'E-Mail-Konten & KI-Analyse',
|
|
||||||
purpose: 'E-Mail-Konten verwalten und KI-Kategorisierung nutzen.',
|
|
||||||
audience: ['Support', 'Admins'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alerts',
|
|
||||||
name: 'Alerts Monitoring',
|
|
||||||
href: '/communication/alerts',
|
|
||||||
description: 'Google Alerts & Feed-Ueberwachung',
|
|
||||||
purpose: 'Google Alerts und RSS-Feeds fuer relevante Neuigkeiten ueberwachen.',
|
|
||||||
audience: ['Marketing', 'Admins'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Infrastruktur & DevOps (Orange)
|
// Infrastruktur & DevOps (Orange)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -83,15 +38,6 @@ export const navigation: NavCategory[] = [
|
|||||||
colorClass: 'infrastructure',
|
colorClass: 'infrastructure',
|
||||||
description: 'GPU, Security, CI/CD & Monitoring',
|
description: 'GPU, Security, CI/CD & Monitoring',
|
||||||
modules: [
|
modules: [
|
||||||
{
|
|
||||||
id: 'gpu',
|
|
||||||
name: 'GPU Infrastruktur',
|
|
||||||
href: '/infrastructure/gpu',
|
|
||||||
description: 'vast.ai GPU Management',
|
|
||||||
purpose: 'GPU-Instanzen auf vast.ai fuer ML-Training und Inferenz verwalten.',
|
|
||||||
audience: ['DevOps', 'Entwickler'],
|
|
||||||
subgroup: 'Compute',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'middleware',
|
id: 'middleware',
|
||||||
name: 'Middleware',
|
name: 'Middleware',
|
||||||
@@ -123,7 +69,7 @@ export const navigation: NavCategory[] = [
|
|||||||
id: 'ci-cd',
|
id: 'ci-cd',
|
||||||
name: 'CI/CD Dashboard',
|
name: 'CI/CD Dashboard',
|
||||||
href: '/infrastructure/ci-cd',
|
href: '/infrastructure/ci-cd',
|
||||||
description: 'Gitea & Woodpecker Pipelines',
|
description: 'Gitea Actions Pipelines',
|
||||||
purpose: 'CI/CD Dashboard mit Pipelines, Deployment-Status und Container-Management.',
|
purpose: 'CI/CD Dashboard mit Pipelines, Deployment-Status und Container-Management.',
|
||||||
audience: ['DevOps', 'Entwickler'],
|
audience: ['DevOps', 'Entwickler'],
|
||||||
subgroup: 'DevOps Pipeline',
|
subgroup: 'DevOps Pipeline',
|
||||||
@@ -139,43 +85,6 @@ export const navigation: NavCategory[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// =========================================================================
|
|
||||||
// Entwicklung (Slate)
|
|
||||||
// =========================================================================
|
|
||||||
{
|
|
||||||
id: 'development',
|
|
||||||
name: 'Entwicklung',
|
|
||||||
icon: 'code',
|
|
||||||
color: '#64748b',
|
|
||||||
colorClass: 'development',
|
|
||||||
description: 'Docs, Screen Flow & Brandbook',
|
|
||||||
modules: [
|
|
||||||
{
|
|
||||||
id: 'docs',
|
|
||||||
name: 'Developer Docs',
|
|
||||||
href: '/development/docs',
|
|
||||||
description: 'MkDocs Dokumentation',
|
|
||||||
purpose: 'API-Dokumentation und Architektur-Diagramme durchsuchen.',
|
|
||||||
audience: ['Entwickler'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen-flow',
|
|
||||||
name: 'Screen Flow',
|
|
||||||
href: '/development/screen-flow',
|
|
||||||
description: 'UI Screen-Verbindungen',
|
|
||||||
purpose: 'Navigation und Screen-Verbindungen der Core-App visualisieren.',
|
|
||||||
audience: ['Designer', 'Entwickler'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'brandbook',
|
|
||||||
name: 'Brandbook',
|
|
||||||
href: '/development/brandbook',
|
|
||||||
description: 'Corporate Design',
|
|
||||||
purpose: 'Referenz fuer Logos, Farben, Typografie und Design-Richtlinien.',
|
|
||||||
audience: ['Designer', 'Marketing'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Meta modules (always visible)
|
// Meta modules (always visible)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Shared Types & Constants for Infrastructure/DevOps Modules
|
* Shared Types & Constants for Infrastructure/DevOps Modules
|
||||||
*
|
*
|
||||||
* Diese Datei enthaelt gemeinsame Typen und Konstanten fuer die DevOps-Pipeline:
|
* Diese Datei enthaelt gemeinsame Typen und Konstanten fuer die DevOps-Pipeline:
|
||||||
* - CI/CD: Woodpecker Pipelines & Deployments
|
* - CI/CD: Gitea Actions Pipelines & Deployments
|
||||||
* - Tests: Test Dashboard & Backlog
|
* - Tests: Test Dashboard & Backlog
|
||||||
* - SBOM: Software Bill of Materials & Lizenz-Checks
|
* - SBOM: Software Bill of Materials & Lizenz-Checks
|
||||||
* - Security: DevSecOps Scans & Vulnerabilities
|
* - Security: DevSecOps Scans & Vulnerabilities
|
||||||
@@ -230,24 +230,6 @@ export interface LogExtractionResponse {
|
|||||||
// Webhook Types
|
// Webhook Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Woodpecker Webhook Event Types
|
|
||||||
*/
|
|
||||||
export type WoodpeckerEventType = 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Woodpecker Webhook Payload
|
|
||||||
*/
|
|
||||||
export interface WoodpeckerWebhookPayload {
|
|
||||||
event: WoodpeckerEventType
|
|
||||||
repo_id: number
|
|
||||||
pipeline_number: number
|
|
||||||
branch?: string
|
|
||||||
commit?: string
|
|
||||||
author?: string
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// LLM Integration Types
|
// LLM Integration Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -346,18 +328,14 @@ export interface PipelineLiveStatus {
|
|||||||
export const INFRASTRUCTURE_API_ENDPOINTS = {
|
export const INFRASTRUCTURE_API_ENDPOINTS = {
|
||||||
/** CI/CD Endpoints */
|
/** CI/CD Endpoints */
|
||||||
CI_CD: {
|
CI_CD: {
|
||||||
PIPELINES: '/api/admin/infrastructure/woodpecker',
|
PIPELINES: '/api/v1/security/sbom/pipeline/history',
|
||||||
TRIGGER: '/api/admin/infrastructure/woodpecker/trigger',
|
STATUS: '/api/v1/security/sbom/pipeline/status',
|
||||||
LOGS: '/api/admin/infrastructure/woodpecker/logs',
|
TRIGGER: '/api/v1/security/sbom/pipeline/trigger',
|
||||||
},
|
},
|
||||||
/** Log Extraction Endpoints */
|
/** Log Extraction Endpoints */
|
||||||
LOG_EXTRACT: {
|
LOG_EXTRACT: {
|
||||||
EXTRACT: '/api/infrastructure/log-extract/extract',
|
EXTRACT: '/api/infrastructure/log-extract/extract',
|
||||||
},
|
},
|
||||||
/** Webhook Endpoints */
|
|
||||||
WEBHOOKS: {
|
|
||||||
WOODPECKER: '/api/webhooks/woodpecker',
|
|
||||||
},
|
|
||||||
/** LLM Endpoints */
|
/** LLM Endpoints */
|
||||||
LLM: {
|
LLM: {
|
||||||
ANALYZE: '/api/ai/analyze',
|
ANALYZE: '/api/ai/analyze',
|
||||||
@@ -375,7 +353,6 @@ export const INFRASTRUCTURE_API_ENDPOINTS = {
|
|||||||
*/
|
*/
|
||||||
export const DEVOPS_ARCHITECTURE = {
|
export const DEVOPS_ARCHITECTURE = {
|
||||||
services: [
|
services: [
|
||||||
{ name: 'Woodpecker CI', port: 8000, description: 'CI/CD Pipeline Server' },
|
|
||||||
{ name: 'Gitea', port: 3003, description: 'Git Repository Server' },
|
{ name: 'Gitea', port: 3003, description: 'Git Repository Server' },
|
||||||
{ name: 'Syft', type: 'CLI', description: 'SBOM Generator' },
|
{ name: 'Syft', type: 'CLI', description: 'SBOM Generator' },
|
||||||
{ name: 'Grype', type: 'CLI', description: 'Vulnerability Scanner' },
|
{ name: 'Grype', type: 'CLI', description: 'Vulnerability Scanner' },
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install DevSecOps tools (gitleaks, trivy, grype, syft)
|
# Install DevSecOps tools (gitleaks, trivy, grype, syft)
|
||||||
ARG TARGETARCH=arm64
|
ARG TARGETARCH
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
|
ARCH="${TARGETARCH:-$(dpkg --print-architecture)}"; \
|
||||||
# Gitleaks
|
# Gitleaks
|
||||||
GITLEAKS_VERSION=8.21.2; \
|
GITLEAKS_VERSION=8.21.2; \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then GITLEAKS_ARCH=arm64; else GITLEAKS_ARCH=x64; fi; \
|
if [ "$ARCH" = "arm64" ]; then GITLEAKS_ARCH=arm64; else GITLEAKS_ARCH=x64; fi; \
|
||||||
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz" \
|
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz" \
|
||||||
| tar xz -C /usr/local/bin gitleaks; \
|
| tar xz -C /usr/local/bin gitleaks; \
|
||||||
# Trivy
|
# Trivy
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Hybrid authentication supporting both Keycloak and local JWT tokens.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .keycloak_auth import (
|
from .keycloak_auth import (
|
||||||
# Config
|
# Config & Models
|
||||||
KeycloakConfig,
|
KeycloakConfig,
|
||||||
KeycloakUser,
|
KeycloakUser,
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ from .keycloak_auth import (
|
|||||||
TokenExpiredError,
|
TokenExpiredError,
|
||||||
TokenInvalidError,
|
TokenInvalidError,
|
||||||
KeycloakConfigError,
|
KeycloakConfigError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .dependencies import (
|
||||||
# Factory functions
|
# Factory functions
|
||||||
get_keycloak_config_from_env,
|
get_keycloak_config_from_env,
|
||||||
get_authenticator,
|
get_authenticator,
|
||||||
@@ -30,7 +32,7 @@ from .keycloak_auth import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config
|
# Config & Models
|
||||||
"KeycloakConfig",
|
"KeycloakConfig",
|
||||||
"KeycloakUser",
|
"KeycloakUser",
|
||||||
|
|
||||||
|
|||||||
164
backend-core/auth/dependencies.py
Normal file
164
backend-core/auth/dependencies.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
FastAPI Authentication Dependencies and Factory Functions.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- get_keycloak_config_from_env(): Create config from env vars
|
||||||
|
- get_authenticator(): Create HybridAuthenticator instance
|
||||||
|
- get_auth(): Global authenticator singleton
|
||||||
|
- get_current_user(): FastAPI dependency for authentication
|
||||||
|
- require_role(): FastAPI dependency factory for role-based access
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from fastapi import Request, HTTPException, Depends
|
||||||
|
|
||||||
|
from .keycloak_auth import (
|
||||||
|
KeycloakConfig,
|
||||||
|
KeycloakConfigError,
|
||||||
|
HybridAuthenticator,
|
||||||
|
TokenExpiredError,
|
||||||
|
TokenInvalidError,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# FACTORY FUNCTIONS
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
||||||
|
"""
|
||||||
|
Create KeycloakConfig from environment variables.
|
||||||
|
|
||||||
|
Required env vars:
|
||||||
|
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
||||||
|
- KEYCLOAK_REALM: e.g., breakpilot
|
||||||
|
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
||||||
|
- KEYCLOAK_VERIFY_SSL: Default true
|
||||||
|
"""
|
||||||
|
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
||||||
|
realm = os.environ.get("KEYCLOAK_REALM")
|
||||||
|
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
||||||
|
|
||||||
|
if not all([server_url, realm, client_id]):
|
||||||
|
logger.info("Keycloak not configured, using local JWT only")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return KeycloakConfig(
|
||||||
|
server_url=server_url,
|
||||||
|
realm=realm,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
||||||
|
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticator() -> HybridAuthenticator:
|
||||||
|
"""
|
||||||
|
Get configured authenticator instance.
|
||||||
|
|
||||||
|
Uses environment variables to determine configuration.
|
||||||
|
"""
|
||||||
|
keycloak_config = get_keycloak_config_from_env()
|
||||||
|
|
||||||
|
# JWT_SECRET is required - no default fallback in production
|
||||||
|
jwt_secret = os.environ.get("JWT_SECRET")
|
||||||
|
environment = os.environ.get("ENVIRONMENT", "development")
|
||||||
|
|
||||||
|
if not jwt_secret and environment == "production":
|
||||||
|
raise KeycloakConfigError(
|
||||||
|
"JWT_SECRET environment variable is required in production"
|
||||||
|
)
|
||||||
|
|
||||||
|
return HybridAuthenticator(
|
||||||
|
keycloak_config=keycloak_config,
|
||||||
|
local_jwt_secret=jwt_secret,
|
||||||
|
environment=environment
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================
|
||||||
|
# FASTAPI DEPENDENCY
|
||||||
|
# =============================================
|
||||||
|
|
||||||
|
# Global authenticator instance (lazy-initialized)
|
||||||
|
_authenticator: Optional[HybridAuthenticator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth() -> HybridAuthenticator:
|
||||||
|
"""Get or create global authenticator."""
|
||||||
|
global _authenticator
|
||||||
|
if _authenticator is None:
|
||||||
|
_authenticator = get_authenticator()
|
||||||
|
return _authenticator
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency to get current authenticated user.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/api/protected")
|
||||||
|
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
||||||
|
return {"user_id": user["user_id"]}
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
# Check for development mode
|
||||||
|
environment = os.environ.get("ENVIRONMENT", "development")
|
||||||
|
if environment == "development":
|
||||||
|
# Return demo user in development without token
|
||||||
|
return {
|
||||||
|
"user_id": "10000000-0000-0000-0000-000000000024",
|
||||||
|
"email": "demo@breakpilot.app",
|
||||||
|
"role": "admin",
|
||||||
|
"realm_roles": ["admin"],
|
||||||
|
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
||||||
|
"auth_method": "development_bypass"
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization header")
|
||||||
|
|
||||||
|
token = auth_header.split(" ")[1]
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth = get_auth()
|
||||||
|
return await auth.validate_token(token)
|
||||||
|
except TokenExpiredError:
|
||||||
|
raise HTTPException(status_code=401, detail="Token expired")
|
||||||
|
except TokenInvalidError as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication failed: {e}")
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def require_role(required_role: str):
|
||||||
|
"""
|
||||||
|
FastAPI dependency factory for role-based access.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/api/admin-only")
|
||||||
|
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
||||||
|
return {"message": "Admin access granted"}
|
||||||
|
"""
|
||||||
|
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
user_role = user.get("role", "user")
|
||||||
|
realm_roles = user.get("realm_roles", [])
|
||||||
|
|
||||||
|
if user_role == required_role or required_role in realm_roles:
|
||||||
|
return user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Role '{required_role}' required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return role_checker
|
||||||
@@ -374,142 +374,3 @@ class HybridAuthenticator:
|
|||||||
if self.keycloak_auth:
|
if self.keycloak_auth:
|
||||||
await self.keycloak_auth.close()
|
await self.keycloak_auth.close()
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
|
||||||
# FACTORY FUNCTIONS
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
|
|
||||||
"""
|
|
||||||
Create KeycloakConfig from environment variables.
|
|
||||||
|
|
||||||
Required env vars:
|
|
||||||
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
|
|
||||||
- KEYCLOAK_REALM: e.g., breakpilot
|
|
||||||
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
- KEYCLOAK_CLIENT_SECRET: For confidential clients
|
|
||||||
- KEYCLOAK_VERIFY_SSL: Default true
|
|
||||||
"""
|
|
||||||
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
|
|
||||||
realm = os.environ.get("KEYCLOAK_REALM")
|
|
||||||
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
|
|
||||||
|
|
||||||
if not all([server_url, realm, client_id]):
|
|
||||||
logger.info("Keycloak not configured, using local JWT only")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return KeycloakConfig(
|
|
||||||
server_url=server_url,
|
|
||||||
realm=realm,
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
|
|
||||||
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_authenticator() -> HybridAuthenticator:
|
|
||||||
"""
|
|
||||||
Get configured authenticator instance.
|
|
||||||
|
|
||||||
Uses environment variables to determine configuration.
|
|
||||||
"""
|
|
||||||
keycloak_config = get_keycloak_config_from_env()
|
|
||||||
|
|
||||||
# JWT_SECRET is required - no default fallback in production
|
|
||||||
jwt_secret = os.environ.get("JWT_SECRET")
|
|
||||||
environment = os.environ.get("ENVIRONMENT", "development")
|
|
||||||
|
|
||||||
if not jwt_secret and environment == "production":
|
|
||||||
raise KeycloakConfigError(
|
|
||||||
"JWT_SECRET environment variable is required in production"
|
|
||||||
)
|
|
||||||
|
|
||||||
return HybridAuthenticator(
|
|
||||||
keycloak_config=keycloak_config,
|
|
||||||
local_jwt_secret=jwt_secret,
|
|
||||||
environment=environment
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================
|
|
||||||
# FASTAPI DEPENDENCY
|
|
||||||
# =============================================
|
|
||||||
|
|
||||||
from fastapi import Request, HTTPException, Depends
|
|
||||||
|
|
||||||
# Global authenticator instance (lazy-initialized)
|
|
||||||
_authenticator: Optional[HybridAuthenticator] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth() -> HybridAuthenticator:
|
|
||||||
"""Get or create global authenticator."""
|
|
||||||
global _authenticator
|
|
||||||
if _authenticator is None:
|
|
||||||
_authenticator = get_authenticator()
|
|
||||||
return _authenticator
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(request: Request) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
FastAPI dependency to get current authenticated user.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@app.get("/api/protected")
|
|
||||||
async def protected_endpoint(user: dict = Depends(get_current_user)):
|
|
||||||
return {"user_id": user["user_id"]}
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get("authorization", "")
|
|
||||||
|
|
||||||
if not auth_header.startswith("Bearer "):
|
|
||||||
# Check for development mode
|
|
||||||
environment = os.environ.get("ENVIRONMENT", "development")
|
|
||||||
if environment == "development":
|
|
||||||
# Return demo user in development without token
|
|
||||||
return {
|
|
||||||
"user_id": "10000000-0000-0000-0000-000000000024",
|
|
||||||
"email": "demo@breakpilot.app",
|
|
||||||
"role": "admin",
|
|
||||||
"realm_roles": ["admin"],
|
|
||||||
"tenant_id": "a0000000-0000-0000-0000-000000000001",
|
|
||||||
"auth_method": "development_bypass"
|
|
||||||
}
|
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization header")
|
|
||||||
|
|
||||||
token = auth_header.split(" ")[1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
auth = get_auth()
|
|
||||||
return await auth.validate_token(token)
|
|
||||||
except TokenExpiredError:
|
|
||||||
raise HTTPException(status_code=401, detail="Token expired")
|
|
||||||
except TokenInvalidError as e:
|
|
||||||
raise HTTPException(status_code=401, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Authentication failed: {e}")
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
|
||||||
|
|
||||||
|
|
||||||
async def require_role(required_role: str):
|
|
||||||
"""
|
|
||||||
FastAPI dependency factory for role-based access.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
@app.get("/api/admin-only")
|
|
||||||
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
|
|
||||||
return {"message": "Admin access granted"}
|
|
||||||
"""
|
|
||||||
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
|
|
||||||
user_role = user.get("role", "user")
|
|
||||||
realm_roles = user.get("realm_roles", [])
|
|
||||||
|
|
||||||
if user_role == required_role or required_role in realm_roles:
|
|
||||||
return user
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=f"Role '{required_role}' required"
|
|
||||||
)
|
|
||||||
|
|
||||||
return role_checker
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
from auth_api import router as auth_router
|
from auth_api import router as auth_router
|
||||||
from rbac_api import router as rbac_router
|
from rbac_api import router as rbac_router
|
||||||
|
from rbac_teachers_api import router as rbac_teachers_router
|
||||||
from notification_api import router as notification_router
|
from notification_api import router as notification_router
|
||||||
from email_template_api import (
|
from email_template_api import (
|
||||||
router as email_template_router,
|
router as email_template_router,
|
||||||
@@ -25,8 +26,6 @@ from email_template_api import (
|
|||||||
)
|
)
|
||||||
from system_api import router as system_router
|
from system_api import router as system_router
|
||||||
from security_api import router as security_router
|
from security_api import router as security_router
|
||||||
from woodpecker_proxy_api import router as woodpecker_router
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Middleware imports
|
# Middleware imports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -91,9 +90,12 @@ app.add_middleware(RateLimiterMiddleware, valkey_url=VALKEY_URL)
|
|||||||
# Auth (proxy to consent-service)
|
# Auth (proxy to consent-service)
|
||||||
app.include_router(auth_router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
|
|
||||||
# RBAC (teacher / role management)
|
# RBAC (role / assignment / custom-role management)
|
||||||
app.include_router(rbac_router, prefix="/api")
|
app.include_router(rbac_router, prefix="/api")
|
||||||
|
|
||||||
|
# RBAC Teachers (teacher CRUD, listing, roles per teacher)
|
||||||
|
app.include_router(rbac_teachers_router, prefix="/api")
|
||||||
|
|
||||||
# Notifications (proxy to consent-service)
|
# Notifications (proxy to consent-service)
|
||||||
app.include_router(notification_router, prefix="/api")
|
app.include_router(notification_router, prefix="/api")
|
||||||
|
|
||||||
@@ -106,7 +108,6 @@ app.include_router(system_router) # already has paths defined in r
|
|||||||
|
|
||||||
# Security / DevSecOps dashboard
|
# Security / DevSecOps dashboard
|
||||||
app.include_router(security_router, prefix="/api")
|
app.include_router(security_router, prefix="/api")
|
||||||
app.include_router(woodpecker_router, prefix="/api")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Startup / Shutdown events
|
# Startup / Shutdown events
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
RBAC API - Teacher and Role Management Endpoints
|
RBAC API - Role and Assignment Management Endpoints
|
||||||
|
|
||||||
Provides API endpoints for:
|
Provides API endpoints for:
|
||||||
- Listing all teachers
|
- Listing all available roles (built-in + custom)
|
||||||
- Listing all available roles
|
- Assigning/revoking roles to users
|
||||||
- Assigning/revoking roles to teachers
|
- Role summary with assignment counts
|
||||||
- Viewing role assignments per teacher
|
- Custom role CRUD
|
||||||
|
|
||||||
|
Shared infrastructure (DB pool, Pydantic models, role definitions)
|
||||||
|
used by rbac_teachers_api.py as well.
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- Authentication: Keycloak (when configured) or local JWT
|
- Authentication: Keycloak (when configured) or local JWT
|
||||||
@@ -24,7 +27,8 @@ try:
|
|||||||
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback for standalone testing
|
# Fallback for standalone testing
|
||||||
from auth.keycloak_auth import get_current_user, TokenExpiredError, TokenInvalidError
|
from auth.keycloak_auth import TokenExpiredError, TokenInvalidError
|
||||||
|
from auth.dependencies import get_current_user
|
||||||
|
|
||||||
# Configuration from environment - NO DEFAULT SECRETS
|
# Configuration from environment - NO DEFAULT SECRETS
|
||||||
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
|
||||||
@@ -230,163 +234,6 @@ async def list_available_roles() -> List[RoleInfo]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/teachers")
|
|
||||||
async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
|
||||||
"""List all teachers with their current roles"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get all teachers with their user info
|
|
||||||
teachers = await conn.fetch("""
|
|
||||||
SELECT
|
|
||||||
t.id, t.user_id, t.teacher_code, t.title,
|
|
||||||
t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
ORDER BY t.last_name, t.first_name
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Get role assignments for all teachers
|
|
||||||
role_assignments = await conn.fetch("""
|
|
||||||
SELECT user_id, role
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Build role lookup
|
|
||||||
role_lookup: Dict[str, List[str]] = {}
|
|
||||||
for ra in role_assignments:
|
|
||||||
uid = str(ra["user_id"])
|
|
||||||
if uid not in role_lookup:
|
|
||||||
role_lookup[uid] = []
|
|
||||||
role_lookup[uid].append(ra["role"])
|
|
||||||
|
|
||||||
# Build response
|
|
||||||
result = []
|
|
||||||
for t in teachers:
|
|
||||||
uid = str(t["user_id"])
|
|
||||||
result.append(TeacherResponse(
|
|
||||||
id=str(t["id"]),
|
|
||||||
user_id=uid,
|
|
||||||
email=t["email"],
|
|
||||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
|
||||||
teacher_code=t["teacher_code"],
|
|
||||||
title=t["title"],
|
|
||||||
first_name=t["first_name"],
|
|
||||||
last_name=t["last_name"],
|
|
||||||
is_active=t["is_active"],
|
|
||||||
roles=role_lookup.get(uid, [])
|
|
||||||
))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/teachers/{teacher_id}/roles")
|
|
||||||
async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]:
|
|
||||||
"""Get all role assignments for a specific teacher"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get teacher's user_id
|
|
||||||
teacher = await conn.fetchrow(
|
|
||||||
"SELECT user_id FROM teachers WHERE id = $1",
|
|
||||||
teacher_id
|
|
||||||
)
|
|
||||||
if not teacher:
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
# Get role assignments
|
|
||||||
assignments = await conn.fetch("""
|
|
||||||
SELECT id, user_id, role, resource_type, resource_id,
|
|
||||||
valid_from, valid_to, granted_at, revoked_at
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY granted_at DESC
|
|
||||||
""", teacher["user_id"])
|
|
||||||
|
|
||||||
return [
|
|
||||||
RoleAssignmentResponse(
|
|
||||||
id=str(a["id"]),
|
|
||||||
user_id=str(a["user_id"]),
|
|
||||||
role=a["role"],
|
|
||||||
resource_type=a["resource_type"],
|
|
||||||
resource_id=str(a["resource_id"]),
|
|
||||||
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
|
||||||
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
|
||||||
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
|
||||||
is_active=a["revoked_at"] is None and (
|
|
||||||
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for a in assignments
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles/{role}/teachers")
|
|
||||||
async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
|
||||||
"""Get all teachers with a specific role"""
|
|
||||||
if role not in AVAILABLE_ROLES:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
|
||||||
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
teachers = await conn.fetch("""
|
|
||||||
SELECT DISTINCT
|
|
||||||
t.id, t.user_id, t.teacher_code, t.title,
|
|
||||||
t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
JOIN role_assignments ra ON t.user_id = ra.user_id
|
|
||||||
WHERE ra.role = $1
|
|
||||||
AND ra.revoked_at IS NULL
|
|
||||||
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
|
||||||
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
|
||||||
ORDER BY t.last_name, t.first_name
|
|
||||||
""", role)
|
|
||||||
|
|
||||||
# Get all roles for these teachers
|
|
||||||
if teachers:
|
|
||||||
user_ids = [t["user_id"] for t in teachers]
|
|
||||||
role_assignments = await conn.fetch("""
|
|
||||||
SELECT user_id, role
|
|
||||||
FROM role_assignments
|
|
||||||
WHERE user_id = ANY($1)
|
|
||||||
AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""", user_ids)
|
|
||||||
|
|
||||||
role_lookup: Dict[str, List[str]] = {}
|
|
||||||
for ra in role_assignments:
|
|
||||||
uid = str(ra["user_id"])
|
|
||||||
if uid not in role_lookup:
|
|
||||||
role_lookup[uid] = []
|
|
||||||
role_lookup[uid].append(ra["role"])
|
|
||||||
else:
|
|
||||||
role_lookup = {}
|
|
||||||
|
|
||||||
return [
|
|
||||||
TeacherResponse(
|
|
||||||
id=str(t["id"]),
|
|
||||||
user_id=str(t["user_id"]),
|
|
||||||
email=t["email"],
|
|
||||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
|
||||||
teacher_code=t["teacher_code"],
|
|
||||||
title=t["title"],
|
|
||||||
first_name=t["first_name"],
|
|
||||||
last_name=t["last_name"],
|
|
||||||
is_active=t["is_active"],
|
|
||||||
roles=role_lookup.get(str(t["user_id"]), [])
|
|
||||||
)
|
|
||||||
for t in teachers
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/assignments")
|
@router.post("/assignments")
|
||||||
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
||||||
"""Assign a role to a user"""
|
"""Assign a role to a user"""
|
||||||
@@ -519,178 +366,6 @@ async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# TEACHER MANAGEMENT ENDPOINTS
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
@router.post("/teachers")
|
|
||||||
async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
|
||||||
"""Create a new teacher with optional initial roles"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Check if email already exists
|
|
||||||
existing = await conn.fetchrow(
|
|
||||||
"SELECT id FROM users WHERE email = $1",
|
|
||||||
teacher.email
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=409, detail="Email already exists")
|
|
||||||
|
|
||||||
# Generate UUIDs
|
|
||||||
user_id = str(uuid.uuid4())
|
|
||||||
teacher_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create user first
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
|
||||||
VALUES ($1, $2, $3, '', 'teacher', true)
|
|
||||||
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
|
||||||
|
|
||||||
# Create teacher record
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
|
||||||
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
|
||||||
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
|
||||||
teacher.teacher_code, teacher.title)
|
|
||||||
|
|
||||||
# Assign initial roles if provided
|
|
||||||
assigned_roles = []
|
|
||||||
for role in teacher.roles:
|
|
||||||
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
|
||||||
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
|
||||||
):
|
|
||||||
await conn.execute("""
|
|
||||||
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
|
||||||
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
|
||||||
'a0000000-0000-0000-0000-000000000001', $3)
|
|
||||||
""", user_id, role, user.get("user_id"))
|
|
||||||
assigned_roles.append(role)
|
|
||||||
|
|
||||||
return TeacherResponse(
|
|
||||||
id=teacher_id,
|
|
||||||
user_id=user_id,
|
|
||||||
email=teacher.email,
|
|
||||||
name=f"{teacher.first_name} {teacher.last_name}",
|
|
||||||
teacher_code=teacher.teacher_code,
|
|
||||||
title=teacher.title,
|
|
||||||
first_name=teacher.first_name,
|
|
||||||
last_name=teacher.last_name,
|
|
||||||
is_active=True,
|
|
||||||
roles=assigned_roles
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/teachers/{teacher_id}")
|
|
||||||
async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
|
||||||
"""Update teacher information"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get current teacher data
|
|
||||||
teacher = await conn.fetchrow("""
|
|
||||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
if not teacher:
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
# Build update queries
|
|
||||||
if updates.email:
|
|
||||||
await conn.execute("UPDATE users SET email = $1 WHERE id = $2",
|
|
||||||
updates.email, teacher["user_id"])
|
|
||||||
|
|
||||||
teacher_updates = []
|
|
||||||
teacher_values = []
|
|
||||||
idx = 1
|
|
||||||
|
|
||||||
if updates.first_name:
|
|
||||||
teacher_updates.append(f"first_name = ${idx}")
|
|
||||||
teacher_values.append(updates.first_name)
|
|
||||||
idx += 1
|
|
||||||
if updates.last_name:
|
|
||||||
teacher_updates.append(f"last_name = ${idx}")
|
|
||||||
teacher_values.append(updates.last_name)
|
|
||||||
idx += 1
|
|
||||||
if updates.teacher_code is not None:
|
|
||||||
teacher_updates.append(f"teacher_code = ${idx}")
|
|
||||||
teacher_values.append(updates.teacher_code)
|
|
||||||
idx += 1
|
|
||||||
if updates.title is not None:
|
|
||||||
teacher_updates.append(f"title = ${idx}")
|
|
||||||
teacher_values.append(updates.title)
|
|
||||||
idx += 1
|
|
||||||
if updates.is_active is not None:
|
|
||||||
teacher_updates.append(f"is_active = ${idx}")
|
|
||||||
teacher_values.append(updates.is_active)
|
|
||||||
idx += 1
|
|
||||||
|
|
||||||
if teacher_updates:
|
|
||||||
teacher_values.append(teacher_id)
|
|
||||||
await conn.execute(
|
|
||||||
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
|
||||||
*teacher_values
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update user name if first/last name changed
|
|
||||||
if updates.first_name or updates.last_name:
|
|
||||||
new_first = updates.first_name or teacher["first_name"]
|
|
||||||
new_last = updates.last_name or teacher["last_name"]
|
|
||||||
await conn.execute("UPDATE users SET name = $1 WHERE id = $2",
|
|
||||||
f"{new_first} {new_last}", teacher["user_id"])
|
|
||||||
|
|
||||||
# Fetch updated data
|
|
||||||
updated = await conn.fetchrow("""
|
|
||||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
|
||||||
u.email, u.name
|
|
||||||
FROM teachers t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
# Get roles
|
|
||||||
roles = await conn.fetch("""
|
|
||||||
SELECT role FROM role_assignments
|
|
||||||
WHERE user_id = $1 AND revoked_at IS NULL
|
|
||||||
AND (valid_to IS NULL OR valid_to > NOW())
|
|
||||||
""", updated["user_id"])
|
|
||||||
|
|
||||||
return TeacherResponse(
|
|
||||||
id=str(updated["id"]),
|
|
||||||
user_id=str(updated["user_id"]),
|
|
||||||
email=updated["email"],
|
|
||||||
name=updated["name"],
|
|
||||||
teacher_code=updated["teacher_code"],
|
|
||||||
title=updated["title"],
|
|
||||||
first_name=updated["first_name"],
|
|
||||||
last_name=updated["last_name"],
|
|
||||||
is_active=updated["is_active"],
|
|
||||||
roles=[r["role"] for r in roles]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/teachers/{teacher_id}")
|
|
||||||
async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
|
||||||
"""Deactivate a teacher (soft delete)"""
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
result = await conn.execute("""
|
|
||||||
UPDATE teachers SET is_active = false WHERE id = $1
|
|
||||||
""", teacher_id)
|
|
||||||
|
|
||||||
if result == "UPDATE 0":
|
|
||||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
|
||||||
|
|
||||||
return {"status": "deactivated", "teacher_id": teacher_id}
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
358
backend-core/rbac_teachers_api.py
Normal file
358
backend-core/rbac_teachers_api.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"""
|
||||||
|
RBAC Teachers API - Teacher Management Endpoints
|
||||||
|
|
||||||
|
Provides API endpoints for:
|
||||||
|
- Listing all teachers with roles
|
||||||
|
- Getting teacher roles
|
||||||
|
- Getting teachers by role
|
||||||
|
- Creating, updating, deactivating teachers
|
||||||
|
|
||||||
|
Split from rbac_api.py for file-size compliance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
|
from rbac_api import (
|
||||||
|
get_pool,
|
||||||
|
get_current_user,
|
||||||
|
TeacherCreate,
|
||||||
|
TeacherUpdate,
|
||||||
|
TeacherResponse,
|
||||||
|
RoleAssignmentResponse,
|
||||||
|
AVAILABLE_ROLES,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rbac", tags=["rbac"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_teacher_response(teacher_row, roles: List[str]) -> TeacherResponse:
|
||||||
|
"""Build a TeacherResponse from a DB row and a list of role strings."""
|
||||||
|
return TeacherResponse(
|
||||||
|
id=str(teacher_row["id"]),
|
||||||
|
user_id=str(teacher_row["user_id"]),
|
||||||
|
email=teacher_row["email"],
|
||||||
|
name=teacher_row["name"] or f"{teacher_row['first_name']} {teacher_row['last_name']}",
|
||||||
|
teacher_code=teacher_row["teacher_code"],
|
||||||
|
title=teacher_row["title"],
|
||||||
|
first_name=teacher_row["first_name"],
|
||||||
|
last_name=teacher_row["last_name"],
|
||||||
|
is_active=teacher_row["is_active"],
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_role_lookup(role_assignments) -> Dict[str, List[str]]:
|
||||||
|
"""Build a user_id -> [roles] lookup from role assignment rows."""
|
||||||
|
role_lookup: Dict[str, List[str]] = {}
|
||||||
|
for ra in role_assignments:
|
||||||
|
uid = str(ra["user_id"])
|
||||||
|
if uid not in role_lookup:
|
||||||
|
role_lookup[uid] = []
|
||||||
|
role_lookup[uid].append(ra["role"])
|
||||||
|
return role_lookup
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TEACHER LISTING / QUERY ENDPOINTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teachers")
|
||||||
|
async def list_teachers(
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> List[TeacherResponse]:
|
||||||
|
"""List all teachers with their current roles"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teachers = await conn.fetch("""
|
||||||
|
SELECT
|
||||||
|
t.id, t.user_id, t.teacher_code, t.title,
|
||||||
|
t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
ORDER BY t.last_name, t.first_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
role_assignments = await conn.fetch("""
|
||||||
|
SELECT user_id, role
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""")
|
||||||
|
|
||||||
|
role_lookup = _build_role_lookup(role_assignments)
|
||||||
|
|
||||||
|
return [
|
||||||
|
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
||||||
|
for t in teachers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/teachers/{teacher_id}/roles")
|
||||||
|
async def get_teacher_roles(
|
||||||
|
teacher_id: str,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> List[RoleAssignmentResponse]:
|
||||||
|
"""Get all role assignments for a specific teacher"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teacher = await conn.fetchrow(
|
||||||
|
"SELECT user_id FROM teachers WHERE id = $1",
|
||||||
|
teacher_id,
|
||||||
|
)
|
||||||
|
if not teacher:
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
assignments = await conn.fetch("""
|
||||||
|
SELECT id, user_id, role, resource_type, resource_id,
|
||||||
|
valid_from, valid_to, granted_at, revoked_at
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY granted_at DESC
|
||||||
|
""", teacher["user_id"])
|
||||||
|
|
||||||
|
return [
|
||||||
|
RoleAssignmentResponse(
|
||||||
|
id=str(a["id"]),
|
||||||
|
user_id=str(a["user_id"]),
|
||||||
|
role=a["role"],
|
||||||
|
resource_type=a["resource_type"],
|
||||||
|
resource_id=str(a["resource_id"]),
|
||||||
|
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
||||||
|
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
||||||
|
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
||||||
|
is_active=a["revoked_at"] is None and (
|
||||||
|
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for a in assignments
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles/{role}/teachers")
|
||||||
|
async def get_teachers_by_role(
|
||||||
|
role: str,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> List[TeacherResponse]:
|
||||||
|
"""Get all teachers with a specific role"""
|
||||||
|
if role not in AVAILABLE_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teachers = await conn.fetch("""
|
||||||
|
SELECT DISTINCT
|
||||||
|
t.id, t.user_id, t.teacher_code, t.title,
|
||||||
|
t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
JOIN role_assignments ra ON t.user_id = ra.user_id
|
||||||
|
WHERE ra.role = $1
|
||||||
|
AND ra.revoked_at IS NULL
|
||||||
|
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
||||||
|
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||||
|
ORDER BY t.last_name, t.first_name
|
||||||
|
""", role)
|
||||||
|
|
||||||
|
if teachers:
|
||||||
|
user_ids = [t["user_id"] for t in teachers]
|
||||||
|
role_assignments = await conn.fetch("""
|
||||||
|
SELECT user_id, role
|
||||||
|
FROM role_assignments
|
||||||
|
WHERE user_id = ANY($1)
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""", user_ids)
|
||||||
|
role_lookup = _build_role_lookup(role_assignments)
|
||||||
|
else:
|
||||||
|
role_lookup = {}
|
||||||
|
|
||||||
|
return [
|
||||||
|
_build_teacher_response(t, role_lookup.get(str(t["user_id"]), []))
|
||||||
|
for t in teachers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TEACHER CRUD ENDPOINTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/teachers")
|
||||||
|
async def create_teacher(
|
||||||
|
teacher: TeacherCreate,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> TeacherResponse:
|
||||||
|
"""Create a new teacher with optional initial roles"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT id FROM users WHERE email = $1",
|
||||||
|
teacher.email,
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Email already exists")
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
teacher_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
||||||
|
VALUES ($1, $2, $3, '', 'teacher', true)
|
||||||
|
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
||||||
|
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
||||||
|
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
||||||
|
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
||||||
|
teacher.teacher_code, teacher.title)
|
||||||
|
|
||||||
|
assigned_roles = []
|
||||||
|
for role in teacher.roles:
|
||||||
|
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
||||||
|
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
||||||
|
):
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
||||||
|
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001', $3)
|
||||||
|
""", user_id, role, user.get("user_id"))
|
||||||
|
assigned_roles.append(role)
|
||||||
|
|
||||||
|
return TeacherResponse(
|
||||||
|
id=teacher_id,
|
||||||
|
user_id=user_id,
|
||||||
|
email=teacher.email,
|
||||||
|
name=f"{teacher.first_name} {teacher.last_name}",
|
||||||
|
teacher_code=teacher.teacher_code,
|
||||||
|
title=teacher.title,
|
||||||
|
first_name=teacher.first_name,
|
||||||
|
last_name=teacher.last_name,
|
||||||
|
is_active=True,
|
||||||
|
roles=assigned_roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/teachers/{teacher_id}")
|
||||||
|
async def update_teacher(
|
||||||
|
teacher_id: str,
|
||||||
|
updates: TeacherUpdate,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
) -> TeacherResponse:
|
||||||
|
"""Update teacher information"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
teacher = await conn.fetchrow("""
|
||||||
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
if not teacher:
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
if updates.email:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET email = $1 WHERE id = $2",
|
||||||
|
updates.email, teacher["user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
teacher_updates = []
|
||||||
|
teacher_values = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if updates.first_name:
|
||||||
|
teacher_updates.append(f"first_name = ${idx}")
|
||||||
|
teacher_values.append(updates.first_name)
|
||||||
|
idx += 1
|
||||||
|
if updates.last_name:
|
||||||
|
teacher_updates.append(f"last_name = ${idx}")
|
||||||
|
teacher_values.append(updates.last_name)
|
||||||
|
idx += 1
|
||||||
|
if updates.teacher_code is not None:
|
||||||
|
teacher_updates.append(f"teacher_code = ${idx}")
|
||||||
|
teacher_values.append(updates.teacher_code)
|
||||||
|
idx += 1
|
||||||
|
if updates.title is not None:
|
||||||
|
teacher_updates.append(f"title = ${idx}")
|
||||||
|
teacher_values.append(updates.title)
|
||||||
|
idx += 1
|
||||||
|
if updates.is_active is not None:
|
||||||
|
teacher_updates.append(f"is_active = ${idx}")
|
||||||
|
teacher_values.append(updates.is_active)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if teacher_updates:
|
||||||
|
teacher_values.append(teacher_id)
|
||||||
|
await conn.execute(
|
||||||
|
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
||||||
|
*teacher_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
if updates.first_name or updates.last_name:
|
||||||
|
new_first = updates.first_name or teacher["first_name"]
|
||||||
|
new_last = updates.last_name or teacher["last_name"]
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE users SET name = $1 WHERE id = $2",
|
||||||
|
f"{new_first} {new_last}", teacher["user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await conn.fetchrow("""
|
||||||
|
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||||
|
u.email, u.name
|
||||||
|
FROM teachers t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
roles = await conn.fetch("""
|
||||||
|
SELECT role FROM role_assignments
|
||||||
|
WHERE user_id = $1 AND revoked_at IS NULL
|
||||||
|
AND (valid_to IS NULL OR valid_to > NOW())
|
||||||
|
""", updated["user_id"])
|
||||||
|
|
||||||
|
return TeacherResponse(
|
||||||
|
id=str(updated["id"]),
|
||||||
|
user_id=str(updated["user_id"]),
|
||||||
|
email=updated["email"],
|
||||||
|
name=updated["name"],
|
||||||
|
teacher_code=updated["teacher_code"],
|
||||||
|
title=updated["title"],
|
||||||
|
first_name=updated["first_name"],
|
||||||
|
last_name=updated["last_name"],
|
||||||
|
is_active=updated["is_active"],
|
||||||
|
roles=[r["role"] for r in roles],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/teachers/{teacher_id}")
|
||||||
|
async def deactivate_teacher(
|
||||||
|
teacher_id: str,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Deactivate a teacher (soft delete)"""
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await conn.execute("""
|
||||||
|
UPDATE teachers SET is_active = false WHERE id = $1
|
||||||
|
""", teacher_id)
|
||||||
|
|
||||||
|
if result == "UPDATE 0":
|
||||||
|
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||||
|
|
||||||
|
return {"status": "deactivated", "teacher_id": teacher_id}
|
||||||
@@ -13,312 +13,47 @@ Features:
|
|||||||
- Fuehrt Security-Scans via subprocess aus
|
- Fuehrt Security-Scans via subprocess aus
|
||||||
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
||||||
- Generiert SBOM mit Syft
|
- Generiert SBOM mit Syft
|
||||||
|
|
||||||
|
Split structure:
|
||||||
|
- security_models.py — Pydantic models
|
||||||
|
- security_report_parsers.py — Report parsing, tool detection, aggregation
|
||||||
|
- security_mock_data.py — Mock data generators + /demo/* endpoints
|
||||||
|
- security_monitoring.py — /monitoring/* endpoints (logs, metrics, containers)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from typing import List, Optional
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||||
from pydantic import BaseModel
|
|
||||||
|
from security_models import (
|
||||||
|
ToolStatus,
|
||||||
|
Finding,
|
||||||
|
SeveritySummary,
|
||||||
|
HistoryItem,
|
||||||
|
)
|
||||||
|
from security_report_parsers import (
|
||||||
|
REPORTS_DIR,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
check_tool_installed,
|
||||||
|
get_latest_report,
|
||||||
|
get_all_findings,
|
||||||
|
calculate_summary,
|
||||||
|
)
|
||||||
|
from security_mock_data import (
|
||||||
|
get_mock_findings,
|
||||||
|
get_mock_sbom_data,
|
||||||
|
get_mock_history,
|
||||||
|
router as mock_data_router,
|
||||||
|
)
|
||||||
|
from security_monitoring import router as monitoring_router
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
router = APIRouter(prefix="/v1/security", tags=["Security"])
|
||||||
|
|
||||||
# Pfade - innerhalb des Backend-Verzeichnisses
|
# Include sub-routers (they share the same prefix/tags)
|
||||||
# In Docker: /app/security-reports, /app/scripts
|
router.include_router(mock_data_router, prefix="", tags=["Security"])
|
||||||
# Lokal: backend/security-reports, backend/scripts
|
router.include_router(monitoring_router, prefix="", tags=["Security"])
|
||||||
BACKEND_DIR = Path(__file__).parent
|
|
||||||
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
|
||||||
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
|
||||||
|
|
||||||
# Projekt-Root fuer Security-Scans
|
|
||||||
PROJECT_ROOT = BACKEND_DIR
|
|
||||||
|
|
||||||
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
|
||||||
try:
|
|
||||||
REPORTS_DIR.mkdir(exist_ok=True)
|
|
||||||
except PermissionError:
|
|
||||||
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
|
||||||
REPORTS_DIR = Path("/tmp/security-reports")
|
|
||||||
REPORTS_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Pydantic Models
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
class ToolStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
installed: bool
|
|
||||||
version: Optional[str] = None
|
|
||||||
last_run: Optional[str] = None
|
|
||||||
last_findings: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Finding(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool: str
|
|
||||||
severity: str
|
|
||||||
title: str
|
|
||||||
message: Optional[str] = None
|
|
||||||
file: Optional[str] = None
|
|
||||||
line: Optional[int] = None
|
|
||||||
found_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class SeveritySummary(BaseModel):
|
|
||||||
critical: int = 0
|
|
||||||
high: int = 0
|
|
||||||
medium: int = 0
|
|
||||||
low: int = 0
|
|
||||||
info: int = 0
|
|
||||||
total: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ScanResult(BaseModel):
|
|
||||||
tool: str
|
|
||||||
status: str
|
|
||||||
started_at: str
|
|
||||||
completed_at: Optional[str] = None
|
|
||||||
findings_count: int = 0
|
|
||||||
report_path: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class HistoryItem(BaseModel):
|
|
||||||
timestamp: str
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
status: str # success, warning, error
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Utility Functions
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
|
||||||
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
|
||||||
try:
|
|
||||||
if tool_name == "gitleaks":
|
|
||||||
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip()
|
|
||||||
elif tool_name == "semgrep":
|
|
||||||
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "bandit":
|
|
||||||
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip()
|
|
||||||
elif tool_name == "trivy":
|
|
||||||
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Parse "Version: 0.48.x"
|
|
||||||
for line in result.stdout.split('\n'):
|
|
||||||
if line.startswith('Version:'):
|
|
||||||
return True, line.split(':')[1].strip()
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "grype":
|
|
||||||
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
elif tool_name == "syft":
|
|
||||||
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return True, result.stdout.strip().split('\n')[0]
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
|
||||||
"""Findet den neuesten Report fuer ein Tool."""
|
|
||||||
if not REPORTS_DIR.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
|
||||||
if not reports:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return max(reports, key=lambda p: p.stat().st_mtime)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Gitleaks JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
if isinstance(data, list):
|
|
||||||
for item in data:
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("Fingerprint", "unknown"),
|
|
||||||
tool="gitleaks",
|
|
||||||
severity="HIGH", # Secrets sind immer kritisch
|
|
||||||
title=item.get("Description", "Secret detected"),
|
|
||||||
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
|
||||||
file=item.get("File", ""),
|
|
||||||
line=item.get("StartLine", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Semgrep JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("results", [])
|
|
||||||
for item in results:
|
|
||||||
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("check_id", "unknown"),
|
|
||||||
tool="semgrep",
|
|
||||||
severity=severity,
|
|
||||||
title=item.get("extra", {}).get("message", "Finding"),
|
|
||||||
message=item.get("check_id", ""),
|
|
||||||
file=item.get("path", ""),
|
|
||||||
line=item.get("start", {}).get("line", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Bandit JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("results", [])
|
|
||||||
for item in results:
|
|
||||||
severity = item.get("issue_severity", "LOW").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=item.get("test_id", "unknown"),
|
|
||||||
tool="bandit",
|
|
||||||
severity=severity,
|
|
||||||
title=item.get("issue_text", "Finding"),
|
|
||||||
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
|
||||||
file=item.get("filename", ""),
|
|
||||||
line=item.get("line_number", 0),
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Trivy JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
results = data.get("Results", [])
|
|
||||||
for result in results:
|
|
||||||
vulnerabilities = result.get("Vulnerabilities", []) or []
|
|
||||||
target = result.get("Target", "")
|
|
||||||
for vuln in vulnerabilities:
|
|
||||||
severity = vuln.get("Severity", "UNKNOWN").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=vuln.get("VulnerabilityID", "unknown"),
|
|
||||||
tool="trivy",
|
|
||||||
severity=severity,
|
|
||||||
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
|
||||||
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
|
||||||
file=target,
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def parse_grype_report(report_path: Path) -> List[Finding]:
|
|
||||||
"""Parst Grype JSON Report."""
|
|
||||||
findings = []
|
|
||||||
try:
|
|
||||||
with open(report_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
matches = data.get("matches", [])
|
|
||||||
for match in matches:
|
|
||||||
vuln = match.get("vulnerability", {})
|
|
||||||
artifact = match.get("artifact", {})
|
|
||||||
severity = vuln.get("severity", "Unknown").upper()
|
|
||||||
findings.append(Finding(
|
|
||||||
id=vuln.get("id", "unknown"),
|
|
||||||
tool="grype",
|
|
||||||
severity=severity,
|
|
||||||
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
|
||||||
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
|
||||||
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
|
||||||
line=None,
|
|
||||||
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
|
||||||
))
|
|
||||||
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
|
||||||
pass
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_findings() -> List[Finding]:
|
|
||||||
"""Sammelt alle Findings aus allen Reports."""
|
|
||||||
findings = []
|
|
||||||
|
|
||||||
# Gitleaks
|
|
||||||
gitleaks_report = get_latest_report("gitleaks")
|
|
||||||
if gitleaks_report:
|
|
||||||
findings.extend(parse_gitleaks_report(gitleaks_report))
|
|
||||||
|
|
||||||
# Semgrep
|
|
||||||
semgrep_report = get_latest_report("semgrep")
|
|
||||||
if semgrep_report:
|
|
||||||
findings.extend(parse_semgrep_report(semgrep_report))
|
|
||||||
|
|
||||||
# Bandit
|
|
||||||
bandit_report = get_latest_report("bandit")
|
|
||||||
if bandit_report:
|
|
||||||
findings.extend(parse_bandit_report(bandit_report))
|
|
||||||
|
|
||||||
# Trivy (filesystem)
|
|
||||||
trivy_fs_report = get_latest_report("trivy-fs")
|
|
||||||
if trivy_fs_report:
|
|
||||||
findings.extend(parse_trivy_report(trivy_fs_report))
|
|
||||||
|
|
||||||
# Grype
|
|
||||||
grype_report = get_latest_report("grype")
|
|
||||||
if grype_report:
|
|
||||||
findings.extend(parse_grype_report(grype_report))
|
|
||||||
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
|
||||||
"""Berechnet die Severity-Zusammenfassung."""
|
|
||||||
summary = SeveritySummary()
|
|
||||||
for finding in findings:
|
|
||||||
severity = finding.severity.upper()
|
|
||||||
if severity == "CRITICAL":
|
|
||||||
summary.critical += 1
|
|
||||||
elif severity == "HIGH":
|
|
||||||
summary.high += 1
|
|
||||||
elif severity == "MEDIUM":
|
|
||||||
summary.medium += 1
|
|
||||||
elif severity == "LOW":
|
|
||||||
summary.low += 1
|
|
||||||
else:
|
|
||||||
summary.info += 1
|
|
||||||
summary.total = len(findings)
|
|
||||||
return summary
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
@@ -435,11 +170,15 @@ async def get_history(limit: int = 20):
|
|||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
findings_count = len(data)
|
findings_count = len(data)
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
findings_count = len(data.get("results", [])) or len(data.get("matches", [])) or len(data.get("Results", []))
|
findings_count = (
|
||||||
|
len(data.get("results", []))
|
||||||
|
or len(data.get("matches", []))
|
||||||
|
or len(data.get("Results", []))
|
||||||
|
)
|
||||||
|
|
||||||
if findings_count > 0:
|
if findings_count > 0:
|
||||||
status = "warning"
|
status = "warning"
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
history.append(HistoryItem(
|
history.append(HistoryItem(
|
||||||
@@ -493,97 +232,19 @@ async def run_scan(scan_type: str, background_tasks: BackgroundTasks):
|
|||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
async def run_scan_async(scan_type: str):
|
async def run_scan_async(st: str):
|
||||||
"""Fuehrt den Scan asynchron aus."""
|
"""Fuehrt den Scan asynchron aus."""
|
||||||
try:
|
try:
|
||||||
if scan_type == "secrets" or scan_type == "all":
|
if st in ("secrets", "all"):
|
||||||
# Gitleaks
|
_run_secrets_scan(timestamp)
|
||||||
installed, _ = check_tool_installed("gitleaks")
|
if st in ("sast", "all"):
|
||||||
if installed:
|
_run_sast_scan(timestamp)
|
||||||
subprocess.run(
|
if st in ("deps", "all"):
|
||||||
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
_run_deps_scan(timestamp)
|
||||||
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
if st in ("sbom", "all"):
|
||||||
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
_run_sbom_scan(timestamp)
|
||||||
"--report-format", "json"],
|
if st in ("containers", "all"):
|
||||||
capture_output=True,
|
_run_container_scan(timestamp)
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if scan_type == "sast" or scan_type == "all":
|
|
||||||
# Semgrep
|
|
||||||
installed, _ = check_tool_installed("semgrep")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["semgrep", "scan", "--config", "auto",
|
|
||||||
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
|
||||||
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=600,
|
|
||||||
cwd=str(PROJECT_ROOT)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bandit
|
|
||||||
installed, _ = check_tool_installed("bandit")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
|
||||||
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
|
||||||
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if scan_type == "deps" or scan_type == "all":
|
|
||||||
# Trivy filesystem scan
|
|
||||||
installed, _ = check_tool_installed("trivy")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["trivy", "fs", str(PROJECT_ROOT),
|
|
||||||
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
|
||||||
"--format", "json",
|
|
||||||
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
|
|
||||||
# Grype
|
|
||||||
installed, _ = check_tool_installed("grype")
|
|
||||||
if installed:
|
|
||||||
result = subprocess.run(
|
|
||||||
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
if result.stdout:
|
|
||||||
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
|
||||||
f.write(result.stdout)
|
|
||||||
|
|
||||||
if scan_type == "sbom" or scan_type == "all":
|
|
||||||
# Syft SBOM generation
|
|
||||||
installed, _ = check_tool_installed("syft")
|
|
||||||
if installed:
|
|
||||||
subprocess.run(
|
|
||||||
["syft", f"dir:{PROJECT_ROOT}",
|
|
||||||
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
if scan_type == "containers" or scan_type == "all":
|
|
||||||
# Trivy image scan
|
|
||||||
installed, _ = check_tool_installed("trivy")
|
|
||||||
if installed:
|
|
||||||
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
|
||||||
for image in images:
|
|
||||||
subprocess.run(
|
|
||||||
["trivy", "image", image,
|
|
||||||
"--format", "json",
|
|
||||||
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=600
|
|
||||||
)
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -619,380 +280,95 @@ async def health_check():
|
|||||||
|
|
||||||
|
|
||||||
# ===========================
|
# ===========================
|
||||||
# Mock Data for Demo/Development
|
# Scan Helper Functions
|
||||||
# ===========================
|
# ===========================
|
||||||
|
|
||||||
def get_mock_sbom_data() -> Dict[str, Any]:
|
def _run_secrets_scan(timestamp: str):
|
||||||
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
"""Gitleaks scan."""
|
||||||
return {
|
installed, _ = check_tool_installed("gitleaks")
|
||||||
"bomFormat": "CycloneDX",
|
if installed:
|
||||||
"specVersion": "1.4",
|
subprocess.run(
|
||||||
"version": 1,
|
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
||||||
"metadata": {
|
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
||||||
"timestamp": datetime.now().isoformat(),
|
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
||||||
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
"--report-format", "json"],
|
||||||
"component": {
|
capture_output=True,
|
||||||
"type": "application",
|
timeout=300
|
||||||
"name": "breakpilot-pwa",
|
)
|
||||||
"version": "2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
|
||||||
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
|
||||||
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
|
||||||
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_findings() -> List[Finding]:
|
def _run_sast_scan(timestamp: str):
|
||||||
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
"""Semgrep + Bandit scan."""
|
||||||
# Alle kritischen Findings wurden behoben:
|
installed, _ = check_tool_installed("semgrep")
|
||||||
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
if installed:
|
||||||
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
subprocess.run(
|
||||||
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
["semgrep", "scan", "--config", "auto",
|
||||||
# - .env.example Placeholders verbessert
|
"--config", str(PROJECT_ROOT / ".semgrep.yml"),
|
||||||
# - Keine shell=True Verwendung im Code
|
"--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")],
|
||||||
return [
|
capture_output=True,
|
||||||
Finding(
|
timeout=600,
|
||||||
id="info-scan-complete",
|
cwd=str(PROJECT_ROOT)
|
||||||
tool="system",
|
)
|
||||||
severity="INFO",
|
|
||||||
title="Letzte Sicherheitspruefung erfolgreich",
|
installed, _ = check_tool_installed("bandit")
|
||||||
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
if installed:
|
||||||
file="",
|
subprocess.run(
|
||||||
line=None,
|
["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll",
|
||||||
found_at=datetime.now().isoformat()
|
"-x", str(PROJECT_ROOT / "backend" / "tests"),
|
||||||
),
|
"-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")],
|
||||||
]
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_mock_history() -> List[HistoryItem]:
|
def _run_deps_scan(timestamp: str):
|
||||||
"""Generiert Mock-Scan-Historie."""
|
"""Trivy filesystem + Grype scan."""
|
||||||
base_time = datetime.now()
|
installed, _ = check_tool_installed("trivy")
|
||||||
return [
|
if installed:
|
||||||
HistoryItem(
|
subprocess.run(
|
||||||
timestamp=(base_time).isoformat(),
|
["trivy", "fs", str(PROJECT_ROOT),
|
||||||
title="Full Security Scan",
|
"--config", str(PROJECT_ROOT / ".trivy.yaml"),
|
||||||
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
"--format", "json",
|
||||||
status="warning"
|
"--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")],
|
||||||
),
|
capture_output=True,
|
||||||
HistoryItem(
|
timeout=600
|
||||||
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
)
|
||||||
title="SBOM Generation",
|
|
||||||
description="20 Components analysiert",
|
|
||||||
status="success"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
|
||||||
title="Container Scan",
|
|
||||||
description="Keine kritischen CVEs",
|
|
||||||
status="success"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
|
||||||
title="Secrets Scan",
|
|
||||||
description="1 Finding (API Key in .env.example)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
|
||||||
title="SAST Scan",
|
|
||||||
description="3 Findings (Bandit, Semgrep)",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
HistoryItem(
|
|
||||||
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
|
||||||
title="Dependency Scan",
|
|
||||||
description="3 vulnerable packages",
|
|
||||||
status="warning"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
installed, _ = check_tool_installed("grype")
|
||||||
# ===========================
|
if installed:
|
||||||
# Demo-Mode Endpoints (with Mock Data)
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
@router.get("/demo/sbom")
|
|
||||||
async def get_demo_sbom():
|
|
||||||
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Erst echte Daten versuchen
|
|
||||||
sbom_report = get_latest_report("sbom")
|
|
||||||
if sbom_report and sbom_report.exists():
|
|
||||||
try:
|
|
||||||
with open(sbom_report) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
# Fallback zu Mock-Daten
|
|
||||||
return get_mock_sbom_data()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/findings")
|
|
||||||
async def get_demo_findings():
|
|
||||||
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
|
||||||
# Erst echte Daten versuchen
|
|
||||||
real_findings = get_all_findings()
|
|
||||||
if real_findings:
|
|
||||||
return real_findings
|
|
||||||
# Fallback zu Mock-Daten
|
|
||||||
return get_mock_findings()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/summary")
|
|
||||||
async def get_demo_summary():
|
|
||||||
"""Gibt Demo-Summary zurueck."""
|
|
||||||
real_findings = get_all_findings()
|
|
||||||
if real_findings:
|
|
||||||
return calculate_summary(real_findings)
|
|
||||||
# Mock summary
|
|
||||||
mock_findings = get_mock_findings()
|
|
||||||
return calculate_summary(mock_findings)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo/history")
|
|
||||||
async def get_demo_history():
|
|
||||||
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
|
||||||
real_history = await get_history()
|
|
||||||
if real_history:
|
|
||||||
return real_history
|
|
||||||
return get_mock_history()
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================
|
|
||||||
# Monitoring Endpoints
|
|
||||||
# ===========================
|
|
||||||
|
|
||||||
class LogEntry(BaseModel):
|
|
||||||
timestamp: str
|
|
||||||
level: str
|
|
||||||
service: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class MetricValue(BaseModel):
|
|
||||||
name: str
|
|
||||||
value: float
|
|
||||||
unit: str
|
|
||||||
trend: Optional[str] = None # up, down, stable
|
|
||||||
|
|
||||||
|
|
||||||
class ContainerStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
health: str
|
|
||||||
cpu_percent: float
|
|
||||||
memory_mb: float
|
|
||||||
uptime: str
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceStatus(BaseModel):
|
|
||||||
name: str
|
|
||||||
url: str
|
|
||||||
status: str
|
|
||||||
response_time_ms: int
|
|
||||||
last_check: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
|
||||||
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
|
||||||
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
services = ["backend", "consent-service", "postgres", "mailpit"]
|
|
||||||
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
|
||||||
messages = {
|
|
||||||
"backend": [
|
|
||||||
"Request completed: GET /api/consent/health 200",
|
|
||||||
"Request completed: POST /api/auth/login 200",
|
|
||||||
"Database connection established",
|
|
||||||
"JWT token validated successfully",
|
|
||||||
"Starting background task: email_notification",
|
|
||||||
"Cache miss for key: user_session_abc123",
|
|
||||||
"Request completed: GET /api/v1/security/demo/sbom 200",
|
|
||||||
],
|
|
||||||
"consent-service": [
|
|
||||||
"Health check passed",
|
|
||||||
"Document version created: v1.2.0",
|
|
||||||
"Consent recorded for user: user-12345",
|
|
||||||
"GDPR export job started",
|
|
||||||
"Database query executed in 12ms",
|
|
||||||
],
|
|
||||||
"postgres": [
|
|
||||||
"checkpoint starting: time",
|
|
||||||
"automatic analyze of table completed",
|
|
||||||
"connection authorized: user=breakpilot",
|
|
||||||
"statement: SELECT * FROM documents WHERE...",
|
|
||||||
],
|
|
||||||
"mailpit": [
|
|
||||||
"SMTP connection from 172.18.0.3",
|
|
||||||
"Email received: Consent Confirmation",
|
|
||||||
"Message stored: id=msg-001",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
base_time = datetime.now()
|
|
||||||
|
|
||||||
for i in range(limit):
|
|
||||||
svc = random.choice(services) if not service else service
|
|
||||||
lvl = random.choice(levels) if not level else level
|
|
||||||
msg_list = messages.get(svc, messages["backend"])
|
|
||||||
msg = random.choice(msg_list)
|
|
||||||
|
|
||||||
# Add some variety to error messages
|
|
||||||
if lvl == "ERROR":
|
|
||||||
msg = random.choice([
|
|
||||||
"Connection timeout after 30s",
|
|
||||||
"Failed to parse JSON response",
|
|
||||||
"Database query failed: connection reset",
|
|
||||||
"Rate limit exceeded for IP 192.168.1.1",
|
|
||||||
])
|
|
||||||
elif lvl == "WARNING":
|
|
||||||
msg = random.choice([
|
|
||||||
"Slow query detected: 523ms",
|
|
||||||
"Memory usage above 80%",
|
|
||||||
"Retry attempt 2/3 for external API",
|
|
||||||
"Deprecated API endpoint called",
|
|
||||||
])
|
|
||||||
|
|
||||||
logs.append(LogEntry(
|
|
||||||
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
|
||||||
level=lvl,
|
|
||||||
service=svc,
|
|
||||||
message=msg
|
|
||||||
))
|
|
||||||
|
|
||||||
# Filter
|
|
||||||
if service:
|
|
||||||
logs = [l for l in logs if l.service == service]
|
|
||||||
if level:
|
|
||||||
logs = [l for l in logs if l.level.upper() == level.upper()]
|
|
||||||
|
|
||||||
return logs[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
|
||||||
async def get_metrics():
|
|
||||||
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
return [
|
|
||||||
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
|
||||||
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
|
||||||
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
|
||||||
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
|
||||||
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
|
||||||
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
|
||||||
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
|
||||||
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
|
||||||
async def get_container_status():
|
|
||||||
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
|
||||||
import random
|
|
||||||
|
|
||||||
# Versuche echte Docker-Daten
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5
|
timeout=600
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.stdout:
|
||||||
containers = []
|
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
||||||
for line in result.stdout.strip().split('\n'):
|
f.write(result.stdout)
|
||||||
parts = line.split('\t')
|
|
||||||
if len(parts) >= 3:
|
|
||||||
name, status, state = parts[0], parts[1], parts[2]
|
|
||||||
# Parse uptime from status like "Up 2 hours"
|
|
||||||
uptime = status if "Up" in status else "N/A"
|
|
||||||
|
|
||||||
containers.append(ContainerStatus(
|
|
||||||
name=name,
|
|
||||||
status=state,
|
|
||||||
health="healthy" if state == "running" else "unhealthy",
|
|
||||||
cpu_percent=round(random.uniform(0.5, 15), 1),
|
|
||||||
memory_mb=round(random.uniform(50, 500), 0),
|
|
||||||
uptime=uptime
|
|
||||||
))
|
|
||||||
if containers:
|
|
||||||
return containers
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback: Demo-Daten
|
|
||||||
return [
|
|
||||||
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
|
||||||
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
|
||||||
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
def _run_sbom_scan(timestamp: str):
|
||||||
async def get_service_status():
|
"""Syft SBOM generation."""
|
||||||
"""Prueft den Status aller Services (Health-Checks)."""
|
installed, _ = check_tool_installed("syft")
|
||||||
import random
|
if installed:
|
||||||
|
subprocess.run(
|
||||||
|
["syft", f"dir:{PROJECT_ROOT}",
|
||||||
|
"-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
services_to_check = [
|
|
||||||
("Backend API", "http://localhost:8000/api/consent/health"),
|
|
||||||
("Consent Service", "http://consent-service:8081/health"),
|
|
||||||
("School Service", "http://school-service:8084/health"),
|
|
||||||
("Klausur Service", "http://klausur-service:8086/health"),
|
|
||||||
]
|
|
||||||
|
|
||||||
results = []
|
def _run_container_scan(timestamp: str):
|
||||||
for name, url in services_to_check:
|
"""Trivy image scan."""
|
||||||
status = "healthy"
|
installed, _ = check_tool_installed("trivy")
|
||||||
response_time = random.randint(15, 150)
|
if installed:
|
||||||
|
images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"]
|
||||||
# Versuche echten Health-Check fuer Backend
|
for image in images:
|
||||||
if "localhost:8000" in url:
|
subprocess.run(
|
||||||
try:
|
["trivy", "image", image,
|
||||||
import httpx
|
"--format", "json",
|
||||||
async with httpx.AsyncClient() as client:
|
"--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")],
|
||||||
start = datetime.now()
|
capture_output=True,
|
||||||
response = await client.get(url, timeout=5)
|
timeout=600
|
||||||
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
)
|
||||||
status = "healthy" if response.status_code == 200 else "unhealthy"
|
|
||||||
except:
|
|
||||||
status = "healthy" # Assume healthy if we're running
|
|
||||||
|
|
||||||
results.append(ServiceStatus(
|
|
||||||
name=name,
|
|
||||||
url=url,
|
|
||||||
status=status,
|
|
||||||
response_time_ms=response_time,
|
|
||||||
last_check=datetime.now().isoformat()
|
|
||||||
))
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|||||||
178
backend-core/security_mock_data.py
Normal file
178
backend-core/security_mock_data.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Security Mock Data & Demo Endpoints
|
||||||
|
|
||||||
|
Mock/demo data generators for the Security Dashboard.
|
||||||
|
Used as fallback when no real scan reports are available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from security_models import (
|
||||||
|
Finding,
|
||||||
|
SeveritySummary,
|
||||||
|
HistoryItem,
|
||||||
|
)
|
||||||
|
from security_report_parsers import get_all_findings, get_latest_report, calculate_summary
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Security"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Mock Data Generators
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def get_mock_sbom_data() -> Dict[str, Any]:
|
||||||
|
"""Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt."""
|
||||||
|
return {
|
||||||
|
"bomFormat": "CycloneDX",
|
||||||
|
"specVersion": "1.4",
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}],
|
||||||
|
"component": {
|
||||||
|
"type": "application",
|
||||||
|
"name": "breakpilot-pwa",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]},
|
||||||
|
{"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
|
||||||
|
{"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
{"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]},
|
||||||
|
{"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_mock_findings() -> List[Finding]:
|
||||||
|
"""Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden."""
|
||||||
|
# Alle kritischen Findings wurden behoben:
|
||||||
|
# - idna >= 3.7 gepinnt (CVE-2024-3651)
|
||||||
|
# - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27)
|
||||||
|
# - jinja2 3.1.6 installiert (CVE-2024-34064)
|
||||||
|
# - .env.example Placeholders verbessert
|
||||||
|
# - Keine shell=True Verwendung im Code
|
||||||
|
return [
|
||||||
|
Finding(
|
||||||
|
id="info-scan-complete",
|
||||||
|
tool="system",
|
||||||
|
severity="INFO",
|
||||||
|
title="Letzte Sicherheitspruefung erfolgreich",
|
||||||
|
message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.",
|
||||||
|
file="",
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.now().isoformat()
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_mock_history() -> List[HistoryItem]:
|
||||||
|
"""Generiert Mock-Scan-Historie."""
|
||||||
|
base_time = datetime.now()
|
||||||
|
return [
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time).isoformat(),
|
||||||
|
title="Full Security Scan",
|
||||||
|
description="7 Findings (1 High, 3 Medium, 3 Low)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(),
|
||||||
|
title="SBOM Generation",
|
||||||
|
description="20 Components analysiert",
|
||||||
|
status="success"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(),
|
||||||
|
title="Container Scan",
|
||||||
|
description="Keine kritischen CVEs",
|
||||||
|
status="success"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-1)).isoformat(),
|
||||||
|
title="Secrets Scan",
|
||||||
|
description="1 Finding (API Key in .env.example)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(),
|
||||||
|
title="SAST Scan",
|
||||||
|
description="3 Findings (Bandit, Semgrep)",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
HistoryItem(
|
||||||
|
timestamp=(base_time.replace(day=base_time.day-2)).isoformat(),
|
||||||
|
title="Dependency Scan",
|
||||||
|
description="3 vulnerable packages",
|
||||||
|
status="warning"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Demo-Mode Endpoints (with Mock Data)
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
@router.get("/demo/sbom")
|
||||||
|
async def get_demo_sbom():
|
||||||
|
"""Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Erst echte Daten versuchen
|
||||||
|
sbom_report = get_latest_report("sbom")
|
||||||
|
if sbom_report and sbom_report.exists():
|
||||||
|
try:
|
||||||
|
with open(sbom_report) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback zu Mock-Daten
|
||||||
|
return get_mock_sbom_data()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/findings")
|
||||||
|
async def get_demo_findings():
|
||||||
|
"""Gibt Demo-Findings zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Erst echte Daten versuchen
|
||||||
|
real_findings = get_all_findings()
|
||||||
|
if real_findings:
|
||||||
|
return real_findings
|
||||||
|
# Fallback zu Mock-Daten
|
||||||
|
return get_mock_findings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/summary")
|
||||||
|
async def get_demo_summary():
|
||||||
|
"""Gibt Demo-Summary zurueck."""
|
||||||
|
real_findings = get_all_findings()
|
||||||
|
if real_findings:
|
||||||
|
return calculate_summary(real_findings)
|
||||||
|
# Mock summary
|
||||||
|
mock_findings = get_mock_findings()
|
||||||
|
return calculate_summary(mock_findings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/history")
|
||||||
|
async def get_demo_history():
|
||||||
|
"""Gibt Demo-Historie zurueck wenn keine echten verfuegbar."""
|
||||||
|
# Note: uses mock data directly instead of calling the main history endpoint
|
||||||
|
return get_mock_history()
|
||||||
52
backend-core/security_models.py
Normal file
52
backend-core/security_models.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Security API - Shared Pydantic Models
|
||||||
|
|
||||||
|
Data models used across security_api, security_mock_data, and security_monitoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ToolStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
installed: bool
|
||||||
|
version: Optional[str] = None
|
||||||
|
last_run: Optional[str] = None
|
||||||
|
last_findings: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(BaseModel):
|
||||||
|
id: str
|
||||||
|
tool: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
message: Optional[str] = None
|
||||||
|
file: Optional[str] = None
|
||||||
|
line: Optional[int] = None
|
||||||
|
found_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class SeveritySummary(BaseModel):
|
||||||
|
critical: int = 0
|
||||||
|
high: int = 0
|
||||||
|
medium: int = 0
|
||||||
|
low: int = 0
|
||||||
|
info: int = 0
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResult(BaseModel):
|
||||||
|
tool: str
|
||||||
|
status: str
|
||||||
|
started_at: str
|
||||||
|
completed_at: Optional[str] = None
|
||||||
|
findings_count: int = 0
|
||||||
|
report_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryItem(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str # success, warning, error
|
||||||
243
backend-core/security_monitoring.py
Normal file
243
backend-core/security_monitoring.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Security Monitoring Endpoints
|
||||||
|
|
||||||
|
System monitoring endpoints for the Security Dashboard:
|
||||||
|
- Log viewing (demo data)
|
||||||
|
- System metrics (demo data)
|
||||||
|
- Container status (real Docker data with demo fallback)
|
||||||
|
- Service health checks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Security"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Pydantic Models
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
class LogEntry(BaseModel):
|
||||||
|
timestamp: str
|
||||||
|
level: str
|
||||||
|
service: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class MetricValue(BaseModel):
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
trend: Optional[str] = None # up, down, stable
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
health: str
|
||||||
|
cpu_percent: float
|
||||||
|
memory_mb: float
|
||||||
|
uptime: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
status: str
|
||||||
|
response_time_ms: int
|
||||||
|
last_check: str
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Monitoring Endpoints
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
@router.get("/monitoring/logs", response_model=List[LogEntry])
|
||||||
|
async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50):
|
||||||
|
"""Gibt Log-Eintraege zurueck (Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
services = ["backend", "consent-service", "postgres", "mailpit"]
|
||||||
|
levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"]
|
||||||
|
messages = {
|
||||||
|
"backend": [
|
||||||
|
"Request completed: GET /api/consent/health 200",
|
||||||
|
"Request completed: POST /api/auth/login 200",
|
||||||
|
"Database connection established",
|
||||||
|
"JWT token validated successfully",
|
||||||
|
"Starting background task: email_notification",
|
||||||
|
"Cache miss for key: user_session_abc123",
|
||||||
|
"Request completed: GET /api/v1/security/demo/sbom 200",
|
||||||
|
],
|
||||||
|
"consent-service": [
|
||||||
|
"Health check passed",
|
||||||
|
"Document version created: v1.2.0",
|
||||||
|
"Consent recorded for user: user-12345",
|
||||||
|
"GDPR export job started",
|
||||||
|
"Database query executed in 12ms",
|
||||||
|
],
|
||||||
|
"postgres": [
|
||||||
|
"checkpoint starting: time",
|
||||||
|
"automatic analyze of table completed",
|
||||||
|
"connection authorized: user=breakpilot",
|
||||||
|
"statement: SELECT * FROM documents WHERE...",
|
||||||
|
],
|
||||||
|
"mailpit": [
|
||||||
|
"SMTP connection from 172.18.0.3",
|
||||||
|
"Email received: Consent Confirmation",
|
||||||
|
"Message stored: id=msg-001",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
base_time = datetime.now()
|
||||||
|
|
||||||
|
for i in range(limit):
|
||||||
|
svc = random.choice(services) if not service else service
|
||||||
|
lvl = random.choice(levels) if not level else level
|
||||||
|
msg_list = messages.get(svc, messages["backend"])
|
||||||
|
msg = random.choice(msg_list)
|
||||||
|
|
||||||
|
# Add some variety to error messages
|
||||||
|
if lvl == "ERROR":
|
||||||
|
msg = random.choice([
|
||||||
|
"Connection timeout after 30s",
|
||||||
|
"Failed to parse JSON response",
|
||||||
|
"Database query failed: connection reset",
|
||||||
|
"Rate limit exceeded for IP 192.168.1.1",
|
||||||
|
])
|
||||||
|
elif lvl == "WARNING":
|
||||||
|
msg = random.choice([
|
||||||
|
"Slow query detected: 523ms",
|
||||||
|
"Memory usage above 80%",
|
||||||
|
"Retry attempt 2/3 for external API",
|
||||||
|
"Deprecated API endpoint called",
|
||||||
|
])
|
||||||
|
|
||||||
|
logs.append(LogEntry(
|
||||||
|
timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(),
|
||||||
|
level=lvl,
|
||||||
|
service=svc,
|
||||||
|
message=msg
|
||||||
|
))
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if service:
|
||||||
|
logs = [log for log in logs if log.service == service]
|
||||||
|
if level:
|
||||||
|
logs = [log for log in logs if log.level.upper() == level.upper()]
|
||||||
|
|
||||||
|
return logs[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/metrics", response_model=List[MetricValue])
|
||||||
|
async def get_metrics():
|
||||||
|
"""Gibt System-Metriken zurueck (Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
return [
|
||||||
|
MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"),
|
||||||
|
MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"),
|
||||||
|
MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"),
|
||||||
|
MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"),
|
||||||
|
MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"),
|
||||||
|
MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"),
|
||||||
|
MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"),
|
||||||
|
MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/containers", response_model=List[ContainerStatus])
|
||||||
|
async def get_container_status():
|
||||||
|
"""Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Versuche echte Docker-Daten
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
containers = []
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
parts = line.split('\t')
|
||||||
|
if len(parts) >= 3:
|
||||||
|
name, status, state = parts[0], parts[1], parts[2]
|
||||||
|
# Parse uptime from status like "Up 2 hours"
|
||||||
|
uptime = status if "Up" in status else "N/A"
|
||||||
|
|
||||||
|
containers.append(ContainerStatus(
|
||||||
|
name=name,
|
||||||
|
status=state,
|
||||||
|
health="healthy" if state == "running" else "unhealthy",
|
||||||
|
cpu_percent=round(random.uniform(0.5, 15), 1),
|
||||||
|
memory_mb=round(random.uniform(50, 500), 0),
|
||||||
|
uptime=uptime
|
||||||
|
))
|
||||||
|
if containers:
|
||||||
|
return containers
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: Demo-Daten
|
||||||
|
return [
|
||||||
|
ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"),
|
||||||
|
ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy",
|
||||||
|
cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
||||||
|
async def get_service_status():
|
||||||
|
"""Prueft den Status aller Services (Health-Checks)."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
services_to_check = [
|
||||||
|
("Backend API", "http://localhost:8000/api/consent/health"),
|
||||||
|
("Consent Service", "http://consent-service:8081/health"),
|
||||||
|
("School Service", "http://school-service:8084/health"),
|
||||||
|
("Klausur Service", "http://klausur-service:8086/health"),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, url in services_to_check:
|
||||||
|
status = "healthy"
|
||||||
|
response_time = random.randint(15, 150)
|
||||||
|
|
||||||
|
# Versuche echten Health-Check fuer Backend
|
||||||
|
if "localhost:8000" in url:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
start = datetime.now()
|
||||||
|
response = await client.get(url, timeout=5)
|
||||||
|
response_time = int((datetime.now() - start).total_seconds() * 1000)
|
||||||
|
status = "healthy" if response.status_code == 200 else "unhealthy"
|
||||||
|
except Exception:
|
||||||
|
status = "healthy" # Assume healthy if we're running
|
||||||
|
|
||||||
|
results.append(ServiceStatus(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
status=status,
|
||||||
|
response_time_ms=response_time,
|
||||||
|
last_check=datetime.now().isoformat()
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
268
backend-core/security_report_parsers.py
Normal file
268
backend-core/security_report_parsers.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Security Report Parsers & Utility Functions
|
||||||
|
|
||||||
|
Parsing logic for security tool reports (Gitleaks, Semgrep, Bandit, Trivy, Grype).
|
||||||
|
Also contains shared utility functions: tool detection, report lookup, summary calculation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from security_models import Finding, SeveritySummary
|
||||||
|
|
||||||
|
|
||||||
|
# Pfade - innerhalb des Backend-Verzeichnisses
|
||||||
|
# In Docker: /app/security-reports, /app/scripts
|
||||||
|
# Lokal: backend/security-reports, backend/scripts
|
||||||
|
BACKEND_DIR = Path(__file__).parent
|
||||||
|
REPORTS_DIR = BACKEND_DIR / "security-reports"
|
||||||
|
SCRIPTS_DIR = BACKEND_DIR / "scripts"
|
||||||
|
|
||||||
|
# Projekt-Root fuer Security-Scans
|
||||||
|
PROJECT_ROOT = BACKEND_DIR
|
||||||
|
|
||||||
|
# Sicherstellen, dass das Reports-Verzeichnis existiert
|
||||||
|
try:
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
except PermissionError:
|
||||||
|
# Falls keine Schreibrechte, verwende tmp-Verzeichnis
|
||||||
|
REPORTS_DIR = Path("/tmp/security-reports")
|
||||||
|
REPORTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Utility Functions
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Prueft, ob ein Tool installiert ist und gibt die Version zurueck."""
|
||||||
|
try:
|
||||||
|
if tool_name == "gitleaks":
|
||||||
|
result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
elif tool_name == "semgrep":
|
||||||
|
result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "bandit":
|
||||||
|
result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip()
|
||||||
|
elif tool_name == "trivy":
|
||||||
|
result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse "Version: 0.48.x"
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if line.startswith('Version:'):
|
||||||
|
return True, line.split(':')[1].strip()
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "grype":
|
||||||
|
result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
elif tool_name == "syft":
|
||||||
|
result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, result.stdout.strip().split('\n')[0]
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_report(tool_prefix: str) -> Optional[Path]:
|
||||||
|
"""Findet den neuesten Report fuer ein Tool."""
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json"))
|
||||||
|
if not reports:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return max(reports, key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Report Parsers
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def parse_gitleaks_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Gitleaks JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("Fingerprint", "unknown"),
|
||||||
|
tool="gitleaks",
|
||||||
|
severity="HIGH", # Secrets sind immer kritisch
|
||||||
|
title=item.get("Description", "Secret detected"),
|
||||||
|
message=f"Rule: {item.get('RuleID', 'unknown')}",
|
||||||
|
file=item.get("File", ""),
|
||||||
|
line=item.get("StartLine", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_semgrep_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Semgrep JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("results", [])
|
||||||
|
for item in results:
|
||||||
|
severity = item.get("extra", {}).get("severity", "INFO").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("check_id", "unknown"),
|
||||||
|
tool="semgrep",
|
||||||
|
severity=severity,
|
||||||
|
title=item.get("extra", {}).get("message", "Finding"),
|
||||||
|
message=item.get("check_id", ""),
|
||||||
|
file=item.get("path", ""),
|
||||||
|
line=item.get("start", {}).get("line", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bandit_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Bandit JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("results", [])
|
||||||
|
for item in results:
|
||||||
|
severity = item.get("issue_severity", "LOW").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=item.get("test_id", "unknown"),
|
||||||
|
tool="bandit",
|
||||||
|
severity=severity,
|
||||||
|
title=item.get("issue_text", "Finding"),
|
||||||
|
message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}",
|
||||||
|
file=item.get("filename", ""),
|
||||||
|
line=item.get("line_number", 0),
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_trivy_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Trivy JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
results = data.get("Results", [])
|
||||||
|
for result in results:
|
||||||
|
vulnerabilities = result.get("Vulnerabilities", []) or []
|
||||||
|
target = result.get("Target", "")
|
||||||
|
for vuln in vulnerabilities:
|
||||||
|
severity = vuln.get("Severity", "UNKNOWN").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=vuln.get("VulnerabilityID", "unknown"),
|
||||||
|
tool="trivy",
|
||||||
|
severity=severity,
|
||||||
|
title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")),
|
||||||
|
message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}",
|
||||||
|
file=target,
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_grype_report(report_path: Path) -> List[Finding]:
|
||||||
|
"""Parst Grype JSON Report."""
|
||||||
|
findings = []
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
matches = data.get("matches", [])
|
||||||
|
for match in matches:
|
||||||
|
vuln = match.get("vulnerability", {})
|
||||||
|
artifact = match.get("artifact", {})
|
||||||
|
severity = vuln.get("severity", "Unknown").upper()
|
||||||
|
findings.append(Finding(
|
||||||
|
id=vuln.get("id", "unknown"),
|
||||||
|
tool="grype",
|
||||||
|
severity=severity,
|
||||||
|
title=vuln.get("description", vuln.get("id", "CVE"))[:100],
|
||||||
|
message=f"{artifact.get('name', '')} {artifact.get('version', '')}",
|
||||||
|
file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "",
|
||||||
|
line=None,
|
||||||
|
found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat()
|
||||||
|
))
|
||||||
|
except (json.JSONDecodeError, KeyError, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================
|
||||||
|
# Aggregation Functions
|
||||||
|
# ===========================
|
||||||
|
|
||||||
|
def get_all_findings() -> List[Finding]:
|
||||||
|
"""Sammelt alle Findings aus allen Reports."""
|
||||||
|
findings = []
|
||||||
|
|
||||||
|
# Gitleaks
|
||||||
|
gitleaks_report = get_latest_report("gitleaks")
|
||||||
|
if gitleaks_report:
|
||||||
|
findings.extend(parse_gitleaks_report(gitleaks_report))
|
||||||
|
|
||||||
|
# Semgrep
|
||||||
|
semgrep_report = get_latest_report("semgrep")
|
||||||
|
if semgrep_report:
|
||||||
|
findings.extend(parse_semgrep_report(semgrep_report))
|
||||||
|
|
||||||
|
# Bandit
|
||||||
|
bandit_report = get_latest_report("bandit")
|
||||||
|
if bandit_report:
|
||||||
|
findings.extend(parse_bandit_report(bandit_report))
|
||||||
|
|
||||||
|
# Trivy (filesystem)
|
||||||
|
trivy_fs_report = get_latest_report("trivy-fs")
|
||||||
|
if trivy_fs_report:
|
||||||
|
findings.extend(parse_trivy_report(trivy_fs_report))
|
||||||
|
|
||||||
|
# Grype
|
||||||
|
grype_report = get_latest_report("grype")
|
||||||
|
if grype_report:
|
||||||
|
findings.extend(parse_grype_report(grype_report))
|
||||||
|
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_summary(findings: List[Finding]) -> SeveritySummary:
|
||||||
|
"""Berechnet die Severity-Zusammenfassung."""
|
||||||
|
summary = SeveritySummary()
|
||||||
|
for finding in findings:
|
||||||
|
severity = finding.severity.upper()
|
||||||
|
if severity == "CRITICAL":
|
||||||
|
summary.critical += 1
|
||||||
|
elif severity == "HIGH":
|
||||||
|
summary.high += 1
|
||||||
|
elif severity == "MEDIUM":
|
||||||
|
summary.medium += 1
|
||||||
|
elif severity == "LOW":
|
||||||
|
summary.low += 1
|
||||||
|
else:
|
||||||
|
summary.info += 1
|
||||||
|
summary.total = len(findings)
|
||||||
|
return summary
|
||||||
@@ -1,83 +1,59 @@
|
|||||||
"""
|
"""
|
||||||
File Processor Service - Dokumentenverarbeitung für BreakPilot.
|
File Processor Service - Dokumentenverarbeitung fuer BreakPilot.
|
||||||
|
|
||||||
Shared Service für:
|
Shared Service fuer:
|
||||||
- OCR (Optical Character Recognition) für Handschrift und gedruckten Text
|
- OCR (Optical Character Recognition) fuer Handschrift und gedruckten Text
|
||||||
- PDF-Parsing und Textextraktion
|
- PDF-Parsing und Textextraktion
|
||||||
- Bildverarbeitung und -optimierung
|
- Bildverarbeitung und -optimierung
|
||||||
- DOCX/DOC Textextraktion
|
- DOCX/DOC Textextraktion
|
||||||
|
|
||||||
Verwendet:
|
Verwendet:
|
||||||
- PaddleOCR für deutsche Handschrift
|
- PaddleOCR fuer deutsche Handschrift (via ImageProcessor)
|
||||||
- PyMuPDF für PDF-Verarbeitung
|
- PyMuPDF fuer PDF-Verarbeitung
|
||||||
- python-docx für DOCX-Dateien
|
- python-docx fuer DOCX-Dateien
|
||||||
- OpenCV für Bildvorverarbeitung
|
- OpenCV fuer Bildvorverarbeitung (via ImageProcessor)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import io
|
import io
|
||||||
import base64
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
from typing import Optional, List, Dict, Any
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from .file_processor_types import (
|
||||||
|
FileType,
|
||||||
|
ProcessingMode,
|
||||||
|
ProcessedRegion,
|
||||||
|
ProcessingResult,
|
||||||
|
)
|
||||||
|
from .image_processing import ImageProcessor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
class FileType(str, Enum):
|
"FileType",
|
||||||
"""Unterstützte Dateitypen."""
|
"ProcessingMode",
|
||||||
PDF = "pdf"
|
"ProcessedRegion",
|
||||||
IMAGE = "image"
|
"ProcessingResult",
|
||||||
DOCX = "docx"
|
"FileProcessor",
|
||||||
DOC = "doc"
|
"get_file_processor",
|
||||||
TXT = "txt"
|
"process_file",
|
||||||
UNKNOWN = "unknown"
|
"extract_text_from_pdf",
|
||||||
|
"ocr_image",
|
||||||
|
"ocr_handwriting",
|
||||||
class ProcessingMode(str, Enum):
|
]
|
||||||
"""Verarbeitungsmodi."""
|
|
||||||
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
|
||||||
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
|
||||||
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
|
||||||
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessedRegion:
|
|
||||||
"""Ein erkannter Textbereich."""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
|
||||||
page: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProcessingResult:
|
|
||||||
"""Ergebnis der Dokumentenverarbeitung."""
|
|
||||||
text: str
|
|
||||||
confidence: float
|
|
||||||
regions: List[ProcessedRegion]
|
|
||||||
page_count: int
|
|
||||||
file_type: FileType
|
|
||||||
processing_mode: ProcessingMode
|
|
||||||
metadata: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
class FileProcessor:
|
class FileProcessor:
|
||||||
"""
|
"""
|
||||||
Zentrale Dokumentenverarbeitung für BreakPilot.
|
Zentrale Dokumentenverarbeitung fuer BreakPilot.
|
||||||
|
|
||||||
Unterstützt:
|
Unterstuetzt:
|
||||||
- Handschrifterkennung (OCR) für Klausuren
|
- Handschrifterkennung (OCR) fuer Klausuren
|
||||||
- Textextraktion aus PDFs
|
- Textextraktion aus PDFs
|
||||||
- DOCX/DOC Verarbeitung
|
- DOCX/DOC Verarbeitung
|
||||||
- Bildvorverarbeitung für bessere OCR-Ergebnisse
|
- Bildvorverarbeitung fuer bessere OCR-Ergebnisse
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
||||||
@@ -85,37 +61,18 @@ class FileProcessor:
|
|||||||
Initialisiert den File Processor.
|
Initialisiert den File Processor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
|
ocr_lang: Sprache fuer OCR (default: "de" fuer Deutsch)
|
||||||
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
|
use_gpu: GPU fuer OCR nutzen (beschleunigt Verarbeitung)
|
||||||
"""
|
"""
|
||||||
self.ocr_lang = ocr_lang
|
self.ocr_lang = ocr_lang
|
||||||
self.use_gpu = use_gpu
|
self.use_gpu = use_gpu
|
||||||
self._ocr_engine = None
|
self._image_processor = ImageProcessor(ocr_lang=ocr_lang, use_gpu=use_gpu)
|
||||||
|
|
||||||
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})")
|
||||||
|
|
||||||
@property
|
def detect_file_type(
|
||||||
def ocr_engine(self):
|
self, file_path: str = None, file_bytes: bytes = None
|
||||||
"""Lazy-Loading des OCR-Engines."""
|
) -> FileType:
|
||||||
if self._ocr_engine is None:
|
|
||||||
self._ocr_engine = self._init_ocr_engine()
|
|
||||||
return self._ocr_engine
|
|
||||||
|
|
||||||
def _init_ocr_engine(self):
|
|
||||||
"""Initialisiert PaddleOCR oder Fallback."""
|
|
||||||
try:
|
|
||||||
from paddleocr import PaddleOCR
|
|
||||||
return PaddleOCR(
|
|
||||||
use_angle_cls=True,
|
|
||||||
lang='german', # Deutsch
|
|
||||||
use_gpu=self.use_gpu,
|
|
||||||
show_log=False
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType:
|
|
||||||
"""
|
"""
|
||||||
Erkennt den Dateityp.
|
Erkennt den Dateityp.
|
||||||
|
|
||||||
@@ -170,7 +127,9 @@ class FileProcessor:
|
|||||||
ProcessingResult mit extrahiertem Text und Metadaten
|
ProcessingResult mit extrahiertem Text und Metadaten
|
||||||
"""
|
"""
|
||||||
if not file_path and not file_bytes:
|
if not file_path and not file_bytes:
|
||||||
raise ValueError("Entweder file_path oder file_bytes muss angegeben werden")
|
raise ValueError(
|
||||||
|
"Entweder file_path oder file_bytes muss angegeben werden"
|
||||||
|
)
|
||||||
|
|
||||||
file_type = self.detect_file_type(file_path, file_bytes)
|
file_type = self.detect_file_type(file_path, file_bytes)
|
||||||
logger.info(f"Processing file of type: {file_type}")
|
logger.info(f"Processing file of type: {file_type}")
|
||||||
@@ -184,7 +143,7 @@ class FileProcessor:
|
|||||||
elif file_type == FileType.TXT:
|
elif file_type == FileType.TXT:
|
||||||
return self._process_txt(file_path, file_bytes)
|
return self._process_txt(file_path, file_bytes)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
|
raise ValueError(f"Nicht unterstuetzter Dateityp: {file_type}")
|
||||||
|
|
||||||
def _process_pdf(
|
def _process_pdf(
|
||||||
self,
|
self,
|
||||||
@@ -197,7 +156,6 @@ class FileProcessor:
|
|||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
||||||
# Fallback: PDF als Bild behandeln
|
|
||||||
return self._process_image(file_path, file_bytes, mode)
|
return self._process_image(file_path, file_bytes, mode)
|
||||||
|
|
||||||
if file_bytes:
|
if file_bytes:
|
||||||
@@ -211,11 +169,9 @@ class FileProcessor:
|
|||||||
region_count = 0
|
region_count = 0
|
||||||
|
|
||||||
for page_num, page in enumerate(doc, start=1):
|
for page_num, page in enumerate(doc, start=1):
|
||||||
# Erst versuchen Text direkt zu extrahieren
|
|
||||||
page_text = page.get_text()
|
page_text = page.get_text()
|
||||||
|
|
||||||
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
||||||
# PDF enthält Text (nicht nur Bilder)
|
|
||||||
all_text.append(page_text)
|
all_text.append(page_text)
|
||||||
all_regions.append(ProcessedRegion(
|
all_regions.append(ProcessedRegion(
|
||||||
text=page_text,
|
text=page_text,
|
||||||
@@ -227,11 +183,11 @@ class FileProcessor:
|
|||||||
region_count += 1
|
region_count += 1
|
||||||
else:
|
else:
|
||||||
# Seite als Bild rendern und OCR anwenden
|
# Seite als Bild rendern und OCR anwenden
|
||||||
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung
|
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||||
img_bytes = pix.tobytes("png")
|
img_bytes = pix.tobytes("png")
|
||||||
img = Image.open(io.BytesIO(img_bytes))
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
|
||||||
ocr_result = self._ocr_image(img)
|
ocr_result = self._image_processor.ocr_image(img)
|
||||||
all_text.append(ocr_result["text"])
|
all_text.append(ocr_result["text"])
|
||||||
|
|
||||||
for region in ocr_result["regions"]:
|
for region in ocr_result["regions"]:
|
||||||
@@ -242,7 +198,9 @@ class FileProcessor:
|
|||||||
|
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
||||||
avg_confidence = total_confidence / region_count if region_count > 0 else 0.0
|
avg_confidence = (
|
||||||
|
total_confidence / region_count if region_count > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text="\n\n".join(all_text),
|
text="\n\n".join(all_text),
|
||||||
@@ -266,11 +224,8 @@ class FileProcessor:
|
|||||||
else:
|
else:
|
||||||
img = Image.open(file_path)
|
img = Image.open(file_path)
|
||||||
|
|
||||||
# Bildvorverarbeitung
|
processed_img = self._image_processor.preprocess_image(img)
|
||||||
processed_img = self._preprocess_image(img)
|
ocr_result = self._image_processor.ocr_image(processed_img)
|
||||||
|
|
||||||
# OCR
|
|
||||||
ocr_result = self._ocr_image(processed_img)
|
|
||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text=ocr_result["text"],
|
text=ocr_result["text"],
|
||||||
@@ -306,7 +261,6 @@ class FileProcessor:
|
|||||||
if para.text.strip():
|
if para.text.strip():
|
||||||
paragraphs.append(para.text)
|
paragraphs.append(para.text)
|
||||||
|
|
||||||
# Auch Tabellen extrahieren
|
|
||||||
for table in doc.tables:
|
for table in doc.tables:
|
||||||
for row in table.rows:
|
for row in table.rows:
|
||||||
row_text = " | ".join(cell.text for cell in row.cells)
|
row_text = " | ".join(cell.text for cell in row.cells)
|
||||||
@@ -317,12 +271,9 @@ class FileProcessor:
|
|||||||
|
|
||||||
return ProcessingResult(
|
return ProcessingResult(
|
||||||
text=text,
|
text=text,
|
||||||
confidence=1.0, # Direkte Textextraktion
|
confidence=1.0,
|
||||||
regions=[ProcessedRegion(
|
regions=[ProcessedRegion(
|
||||||
text=text,
|
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
||||||
confidence=1.0,
|
|
||||||
bbox=(0, 0, 0, 0),
|
|
||||||
page=1
|
|
||||||
)],
|
)],
|
||||||
page_count=1,
|
page_count=1,
|
||||||
file_type=FileType.DOCX,
|
file_type=FileType.DOCX,
|
||||||
@@ -346,10 +297,7 @@ class FileProcessor:
|
|||||||
text=text,
|
text=text,
|
||||||
confidence=1.0,
|
confidence=1.0,
|
||||||
regions=[ProcessedRegion(
|
regions=[ProcessedRegion(
|
||||||
text=text,
|
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
||||||
confidence=1.0,
|
|
||||||
bbox=(0, 0, 0, 0),
|
|
||||||
page=1
|
|
||||||
)],
|
)],
|
||||||
page_count=1,
|
page_count=1,
|
||||||
file_type=FileType.TXT,
|
file_type=FileType.TXT,
|
||||||
@@ -357,159 +305,13 @@ class FileProcessor:
|
|||||||
metadata={"source": file_path or "bytes"}
|
metadata={"source": file_path or "bytes"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _preprocess_image(self, img: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Vorverarbeitung des Bildes für bessere OCR-Ergebnisse.
|
|
||||||
|
|
||||||
- Konvertierung zu Graustufen
|
|
||||||
- Kontrastverstärkung
|
|
||||||
- Rauschunterdrückung
|
|
||||||
- Binarisierung
|
|
||||||
"""
|
|
||||||
# PIL zu OpenCV
|
|
||||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
||||||
|
|
||||||
# Zu Graustufen konvertieren
|
|
||||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Rauschunterdrückung
|
|
||||||
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
|
||||||
|
|
||||||
# Kontrastverstärkung (CLAHE)
|
|
||||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
|
||||||
enhanced = clahe.apply(denoised)
|
|
||||||
|
|
||||||
# Adaptive Binarisierung
|
|
||||||
binary = cv2.adaptiveThreshold(
|
|
||||||
enhanced,
|
|
||||||
255,
|
|
||||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
||||||
cv2.THRESH_BINARY,
|
|
||||||
11,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
|
|
||||||
# Zurück zu PIL
|
|
||||||
return Image.fromarray(binary)
|
|
||||||
|
|
||||||
def _ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Führt OCR auf einem Bild aus.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mit text, confidence und regions
|
|
||||||
"""
|
|
||||||
if self.ocr_engine is None:
|
|
||||||
# Fallback wenn kein OCR-Engine verfügbar
|
|
||||||
return {
|
|
||||||
"text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"regions": []
|
|
||||||
}
|
|
||||||
|
|
||||||
# PIL zu numpy array
|
|
||||||
img_array = np.array(img)
|
|
||||||
|
|
||||||
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
|
||||||
if len(img_array.shape) == 2:
|
|
||||||
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
|
||||||
|
|
||||||
# OCR ausführen
|
|
||||||
result = self.ocr_engine.ocr(img_array, cls=True)
|
|
||||||
|
|
||||||
if not result or not result[0]:
|
|
||||||
return {"text": "", "confidence": 0.0, "regions": []}
|
|
||||||
|
|
||||||
all_text = []
|
|
||||||
all_regions = []
|
|
||||||
total_confidence = 0.0
|
|
||||||
|
|
||||||
for line in result[0]:
|
|
||||||
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
|
||||||
text, confidence = line[1]
|
|
||||||
|
|
||||||
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
|
||||||
x_coords = [p[0] for p in bbox_points]
|
|
||||||
y_coords = [p[1] for p in bbox_points]
|
|
||||||
bbox = (
|
|
||||||
int(min(x_coords)),
|
|
||||||
int(min(y_coords)),
|
|
||||||
int(max(x_coords)),
|
|
||||||
int(max(y_coords))
|
|
||||||
)
|
|
||||||
|
|
||||||
all_text.append(text)
|
|
||||||
all_regions.append(ProcessedRegion(
|
|
||||||
text=text,
|
|
||||||
confidence=confidence,
|
|
||||||
bbox=bbox
|
|
||||||
))
|
|
||||||
total_confidence += confidence
|
|
||||||
|
|
||||||
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"text": "\n".join(all_text),
|
|
||||||
"confidence": avg_confidence,
|
|
||||||
"regions": all_regions
|
|
||||||
}
|
|
||||||
|
|
||||||
def extract_handwriting_regions(
|
def extract_handwriting_regions(
|
||||||
self,
|
self,
|
||||||
img: Image.Image,
|
img: Image.Image,
|
||||||
min_area: int = 500
|
min_area: int = 500
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""Delegate to ImageProcessor."""
|
||||||
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
return self._image_processor.extract_handwriting_regions(img, min_area)
|
||||||
|
|
||||||
Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
img: Eingabebild
|
|
||||||
min_area: Minimale Fläche für erkannte Regionen
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Liste von Regionen mit Koordinaten und erkanntem Text
|
|
||||||
"""
|
|
||||||
# Bildvorverarbeitung
|
|
||||||
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
|
||||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
|
||||||
|
|
||||||
# Kanten erkennen
|
|
||||||
edges = cv2.Canny(gray, 50, 150)
|
|
||||||
|
|
||||||
# Morphologische Operationen zum Verbinden
|
|
||||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
|
||||||
dilated = cv2.dilate(edges, kernel, iterations=2)
|
|
||||||
|
|
||||||
# Konturen finden
|
|
||||||
contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
||||||
|
|
||||||
regions = []
|
|
||||||
for contour in contours:
|
|
||||||
area = cv2.contourArea(contour)
|
|
||||||
if area < min_area:
|
|
||||||
continue
|
|
||||||
|
|
||||||
x, y, w, h = cv2.boundingRect(contour)
|
|
||||||
|
|
||||||
# Region ausschneiden
|
|
||||||
region_img = img.crop((x, y, x + w, y + h))
|
|
||||||
|
|
||||||
# OCR auf Region anwenden
|
|
||||||
ocr_result = self._ocr_image(region_img)
|
|
||||||
|
|
||||||
regions.append({
|
|
||||||
"bbox": (x, y, x + w, y + h),
|
|
||||||
"area": area,
|
|
||||||
"text": ocr_result["text"],
|
|
||||||
"confidence": ocr_result["confidence"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Nach Y-Position sortieren (oben nach unten)
|
|
||||||
regions.sort(key=lambda r: r["bbox"][1])
|
|
||||||
|
|
||||||
return regions
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton-Instanz
|
# Singleton-Instanz
|
||||||
@@ -517,7 +319,7 @@ _file_processor: Optional[FileProcessor] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_file_processor() -> FileProcessor:
|
def get_file_processor() -> FileProcessor:
|
||||||
"""Gibt Singleton-Instanz des File Processors zurück."""
|
"""Gibt Singleton-Instanz des File Processors zurueck."""
|
||||||
global _file_processor
|
global _file_processor
|
||||||
if _file_processor is None:
|
if _file_processor is None:
|
||||||
_file_processor = FileProcessor()
|
_file_processor = FileProcessor()
|
||||||
@@ -530,34 +332,26 @@ def process_file(
|
|||||||
file_bytes: bytes = None,
|
file_bytes: bytes = None,
|
||||||
mode: ProcessingMode = ProcessingMode.MIXED
|
mode: ProcessingMode = ProcessingMode.MIXED
|
||||||
) -> ProcessingResult:
|
) -> ProcessingResult:
|
||||||
"""
|
"""Convenience function zum Verarbeiten einer Datei."""
|
||||||
Convenience function zum Verarbeiten einer Datei.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Pfad zur Datei
|
|
||||||
file_bytes: Dateiinhalt als Bytes
|
|
||||||
mode: Verarbeitungsmodus
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ProcessingResult
|
|
||||||
"""
|
|
||||||
processor = get_file_processor()
|
processor = get_file_processor()
|
||||||
return processor.process(file_path, file_bytes, mode)
|
return processor.process(file_path, file_bytes, mode)
|
||||||
|
|
||||||
|
|
||||||
def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str:
|
def extract_text_from_pdf(
|
||||||
|
file_path: str = None, file_bytes: bytes = None
|
||||||
|
) -> str:
|
||||||
"""Extrahiert Text aus einer PDF-Datei."""
|
"""Extrahiert Text aus einer PDF-Datei."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
||||||
return result.text
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
"""Führt OCR auf einem Bild aus."""
|
"""Fuehrt OCR auf einem Bild aus."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED)
|
||||||
return result.text
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str:
|
||||||
"""Führt Handschrift-OCR auf einem Bild aus."""
|
"""Fuehrt Handschrift-OCR auf einem Bild aus."""
|
||||||
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING)
|
||||||
return result.text
|
return result.text
|
||||||
|
|||||||
46
backend-core/services/file_processor_types.py
Normal file
46
backend-core/services/file_processor_types.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Shared types for file processing and image processing modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class FileType(str, Enum):
|
||||||
|
"""Unterstuetzte Dateitypen."""
|
||||||
|
PDF = "pdf"
|
||||||
|
IMAGE = "image"
|
||||||
|
DOCX = "docx"
|
||||||
|
DOC = "doc"
|
||||||
|
TXT = "txt"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingMode(str, Enum):
|
||||||
|
"""Verarbeitungsmodi."""
|
||||||
|
OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung
|
||||||
|
OCR_PRINTED = "ocr_printed" # Gedruckter Text
|
||||||
|
TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX)
|
||||||
|
MIXED = "mixed" # Kombiniert OCR + Textextraktion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessedRegion:
|
||||||
|
"""Ein erkannter Textbereich."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
bbox: Tuple[int, int, int, int] # x1, y1, x2, y2
|
||||||
|
page: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessingResult:
|
||||||
|
"""Ergebnis der Dokumentenverarbeitung."""
|
||||||
|
text: str
|
||||||
|
confidence: float
|
||||||
|
regions: List[ProcessedRegion]
|
||||||
|
page_count: int
|
||||||
|
file_type: FileType
|
||||||
|
processing_mode: ProcessingMode
|
||||||
|
metadata: Dict[str, Any]
|
||||||
213
backend-core/services/image_processing.py
Normal file
213
backend-core/services/image_processing.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Image Processing and OCR Service.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Image preprocessing for better OCR results (grayscale, denoising, binarization)
|
||||||
|
- PaddleOCR integration for text recognition
|
||||||
|
- Handwriting region extraction from scanned documents
|
||||||
|
|
||||||
|
Used by FileProcessor for image and PDF-to-image OCR workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .file_processor_types import ProcessedRegion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageProcessor:
|
||||||
|
"""
|
||||||
|
Image preprocessing and OCR for BreakPilot.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- PaddleOCR for German handwriting and printed text
|
||||||
|
- OpenCV-based preprocessing (denoising, CLAHE, adaptive binarization)
|
||||||
|
- Handwriting region extraction for exam correction
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ocr_lang: str = "de", use_gpu: bool = False):
|
||||||
|
self.ocr_lang = ocr_lang
|
||||||
|
self.use_gpu = use_gpu
|
||||||
|
self._ocr_engine = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ocr_engine(self):
|
||||||
|
"""Lazy-Loading des OCR-Engines."""
|
||||||
|
if self._ocr_engine is None:
|
||||||
|
self._ocr_engine = self._init_ocr_engine()
|
||||||
|
return self._ocr_engine
|
||||||
|
|
||||||
|
def _init_ocr_engine(self):
|
||||||
|
"""Initialisiert PaddleOCR oder Fallback."""
|
||||||
|
try:
|
||||||
|
from paddleocr import PaddleOCR
|
||||||
|
return PaddleOCR(
|
||||||
|
use_angle_cls=True,
|
||||||
|
lang='german',
|
||||||
|
use_gpu=self.use_gpu,
|
||||||
|
show_log=False
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("PaddleOCR nicht installiert - verwende Fallback")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def preprocess_image(self, img: Image.Image) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Vorverarbeitung des Bildes fuer bessere OCR-Ergebnisse.
|
||||||
|
|
||||||
|
- Konvertierung zu Graustufen
|
||||||
|
- Kontrastverstaerkung
|
||||||
|
- Rauschunterdrueckung
|
||||||
|
- Binarisierung
|
||||||
|
"""
|
||||||
|
# PIL zu OpenCV
|
||||||
|
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
|
||||||
|
# Zu Graustufen konvertieren
|
||||||
|
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Rauschunterdrueckung
|
||||||
|
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||||
|
|
||||||
|
# Kontrastverstaerkung (CLAHE)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(denoised)
|
||||||
|
|
||||||
|
# Adaptive Binarisierung
|
||||||
|
binary = cv2.adaptiveThreshold(
|
||||||
|
enhanced,
|
||||||
|
255,
|
||||||
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY,
|
||||||
|
11,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zurueck zu PIL
|
||||||
|
return Image.fromarray(binary)
|
||||||
|
|
||||||
|
def ocr_image(self, img: Image.Image) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fuehrt OCR auf einem Bild aus.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mit text, confidence und regions
|
||||||
|
"""
|
||||||
|
if self.ocr_engine is None:
|
||||||
|
return {
|
||||||
|
"text": "[OCR nicht verfuegbar - bitte PaddleOCR installieren]",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"regions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# PIL zu numpy array
|
||||||
|
img_array = np.array(img)
|
||||||
|
|
||||||
|
# Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB)
|
||||||
|
if len(img_array.shape) == 2:
|
||||||
|
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
||||||
|
|
||||||
|
# OCR ausfuehren
|
||||||
|
result = self.ocr_engine.ocr(img_array, cls=True)
|
||||||
|
|
||||||
|
if not result or not result[0]:
|
||||||
|
return {"text": "", "confidence": 0.0, "regions": []}
|
||||||
|
|
||||||
|
all_text = []
|
||||||
|
all_regions = []
|
||||||
|
total_confidence = 0.0
|
||||||
|
|
||||||
|
for line in result[0]:
|
||||||
|
bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
|
||||||
|
text, confidence = line[1]
|
||||||
|
|
||||||
|
# Bounding Box zu x1, y1, x2, y2 konvertieren
|
||||||
|
x_coords = [p[0] for p in bbox_points]
|
||||||
|
y_coords = [p[1] for p in bbox_points]
|
||||||
|
bbox = (
|
||||||
|
int(min(x_coords)),
|
||||||
|
int(min(y_coords)),
|
||||||
|
int(max(x_coords)),
|
||||||
|
int(max(y_coords))
|
||||||
|
)
|
||||||
|
|
||||||
|
all_text.append(text)
|
||||||
|
all_regions.append(ProcessedRegion(
|
||||||
|
text=text,
|
||||||
|
confidence=confidence,
|
||||||
|
bbox=bbox
|
||||||
|
))
|
||||||
|
total_confidence += confidence
|
||||||
|
|
||||||
|
avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": "\n".join(all_text),
|
||||||
|
"confidence": avg_confidence,
|
||||||
|
"regions": all_regions
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract_handwriting_regions(
|
||||||
|
self,
|
||||||
|
img: Image.Image,
|
||||||
|
min_area: int = 500
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
||||||
|
|
||||||
|
Nuetzlich fuer Klausuren mit gedruckten Fragen und handschriftlichen Antworten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: Eingabebild
|
||||||
|
min_area: Minimale Flaeche fuer erkannte Regionen
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von Regionen mit Koordinaten und erkanntem Text
|
||||||
|
"""
|
||||||
|
# Bildvorverarbeitung
|
||||||
|
cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||||
|
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Kanten erkennen
|
||||||
|
edges = cv2.Canny(gray, 50, 150)
|
||||||
|
|
||||||
|
# Morphologische Operationen zum Verbinden
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
|
||||||
|
dilated = cv2.dilate(edges, kernel, iterations=2)
|
||||||
|
|
||||||
|
# Konturen finden
|
||||||
|
contours, _ = cv2.findContours(
|
||||||
|
dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||||
|
)
|
||||||
|
|
||||||
|
regions = []
|
||||||
|
for contour in contours:
|
||||||
|
area = cv2.contourArea(contour)
|
||||||
|
if area < min_area:
|
||||||
|
continue
|
||||||
|
|
||||||
|
x, y, w, h = cv2.boundingRect(contour)
|
||||||
|
|
||||||
|
# Region ausschneiden
|
||||||
|
region_img = img.crop((x, y, x + w, y + h))
|
||||||
|
|
||||||
|
# OCR auf Region anwenden
|
||||||
|
ocr_result = self.ocr_image(region_img)
|
||||||
|
|
||||||
|
regions.append({
|
||||||
|
"bbox": (x, y, x + w, y + h),
|
||||||
|
"area": area,
|
||||||
|
"text": ocr_result["text"],
|
||||||
|
"confidence": ocr_result["confidence"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Nach Y-Position sortieren (oben nach unten)
|
||||||
|
regions.sort(key=lambda r: r["bbox"][1])
|
||||||
|
|
||||||
|
return regions
|
||||||
85
backend-core/services/pdf_models.py
Normal file
85
backend-core/services/pdf_models.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
PDF Models - Dataclasses fuer PDF-Generierung.
|
||||||
|
|
||||||
|
Enthaelt alle Datenmodelle die von PDFService und den Convenience-Funktionen
|
||||||
|
in pdf_service.py verwendet werden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SchoolInfo:
|
||||||
|
"""Schulinformationen fuer Header."""
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
phone: str
|
||||||
|
email: str
|
||||||
|
logo_path: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
principal: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LetterData:
|
||||||
|
"""Daten fuer Elternbrief-PDF."""
|
||||||
|
recipient_name: str
|
||||||
|
recipient_address: str
|
||||||
|
student_name: str
|
||||||
|
student_class: str
|
||||||
|
subject: str
|
||||||
|
content: str
|
||||||
|
date: str
|
||||||
|
teacher_name: str
|
||||||
|
teacher_title: Optional[str] = None
|
||||||
|
school_info: Optional[SchoolInfo] = None
|
||||||
|
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
||||||
|
tone: str = "professional"
|
||||||
|
legal_references: Optional[List[Dict[str, str]]] = None
|
||||||
|
gfk_principles_applied: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CertificateData:
|
||||||
|
"""Daten fuer Zeugnis-PDF."""
|
||||||
|
student_name: str
|
||||||
|
student_birthdate: str
|
||||||
|
student_class: str
|
||||||
|
school_year: str
|
||||||
|
certificate_type: str # halbjahr, jahres, abschluss
|
||||||
|
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
||||||
|
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
||||||
|
remarks: Optional[str] = None
|
||||||
|
class_teacher: str = ""
|
||||||
|
principal: str = ""
|
||||||
|
school_info: Optional[SchoolInfo] = None
|
||||||
|
issue_date: str = ""
|
||||||
|
social_behavior: Optional[str] = None # A, B, C, D
|
||||||
|
work_behavior: Optional[str] = None # A, B, C, D
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StudentInfo:
|
||||||
|
"""Schuelerinformationen fuer Korrektur-PDFs."""
|
||||||
|
student_id: str
|
||||||
|
name: str
|
||||||
|
class_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CorrectionData:
|
||||||
|
"""Daten fuer Korrektur-Uebersicht PDF."""
|
||||||
|
student: StudentInfo
|
||||||
|
exam_title: str
|
||||||
|
subject: str
|
||||||
|
date: str
|
||||||
|
max_points: int
|
||||||
|
achieved_points: int
|
||||||
|
grade: str
|
||||||
|
percentage: float
|
||||||
|
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
||||||
|
teacher_notes: str = ""
|
||||||
|
ai_feedback: str = ""
|
||||||
|
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
||||||
|
class_average: Optional[float] = None
|
||||||
@@ -1,115 +1,54 @@
|
|||||||
"""
|
"""
|
||||||
PDF Service - Zentrale PDF-Generierung für BreakPilot.
|
PDF Service - Zentrale PDF-Generierung fuer BreakPilot.
|
||||||
|
|
||||||
Shared Service für:
|
Shared Service fuer:
|
||||||
- Letters (Elternbriefe)
|
- Letters (Elternbriefe)
|
||||||
- Zeugnisse (Schulzeugnisse)
|
- Zeugnisse (Schulzeugnisse)
|
||||||
- Correction (Korrektur-Übersichten)
|
- Correction (Korrektur-Uebersichten)
|
||||||
|
|
||||||
Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates.
|
Verwendet WeasyPrint fuer PDF-Rendering und Jinja2 fuer Templates.
|
||||||
|
|
||||||
|
Datenmodelle: services/pdf_models.py
|
||||||
|
HTML-Templates: services/pdf_templates.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, List
|
from typing import Any, Dict, Optional
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from weasyprint import HTML, CSS
|
from weasyprint import HTML, CSS
|
||||||
from weasyprint.text.fonts import FontConfiguration
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
|
||||||
|
from .pdf_models import (
|
||||||
|
SchoolInfo,
|
||||||
|
LetterData,
|
||||||
|
CertificateData,
|
||||||
|
StudentInfo,
|
||||||
|
CorrectionData,
|
||||||
|
)
|
||||||
|
from .pdf_templates import (
|
||||||
|
get_base_css,
|
||||||
|
get_letter_template_html,
|
||||||
|
get_certificate_template_html,
|
||||||
|
get_correction_template_html,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Template directory
|
# Template directory
|
||||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SchoolInfo:
|
|
||||||
"""Schulinformationen für Header."""
|
|
||||||
name: str
|
|
||||||
address: str
|
|
||||||
phone: str
|
|
||||||
email: str
|
|
||||||
logo_path: Optional[str] = None
|
|
||||||
website: Optional[str] = None
|
|
||||||
principal: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LetterData:
|
|
||||||
"""Daten für Elternbrief-PDF."""
|
|
||||||
recipient_name: str
|
|
||||||
recipient_address: str
|
|
||||||
student_name: str
|
|
||||||
student_class: str
|
|
||||||
subject: str
|
|
||||||
content: str
|
|
||||||
date: str
|
|
||||||
teacher_name: str
|
|
||||||
teacher_title: Optional[str] = None
|
|
||||||
school_info: Optional[SchoolInfo] = None
|
|
||||||
letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob
|
|
||||||
tone: str = "professional"
|
|
||||||
legal_references: Optional[List[Dict[str, str]]] = None
|
|
||||||
gfk_principles_applied: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CertificateData:
|
|
||||||
"""Daten für Zeugnis-PDF."""
|
|
||||||
student_name: str
|
|
||||||
student_birthdate: str
|
|
||||||
student_class: str
|
|
||||||
school_year: str
|
|
||||||
certificate_type: str # halbjahr, jahres, abschluss
|
|
||||||
subjects: List[Dict[str, Any]] # [{name, grade, note}]
|
|
||||||
attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused}
|
|
||||||
remarks: Optional[str] = None
|
|
||||||
class_teacher: str = ""
|
|
||||||
principal: str = ""
|
|
||||||
school_info: Optional[SchoolInfo] = None
|
|
||||||
issue_date: str = ""
|
|
||||||
social_behavior: Optional[str] = None # A, B, C, D
|
|
||||||
work_behavior: Optional[str] = None # A, B, C, D
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StudentInfo:
|
|
||||||
"""Schülerinformationen für Korrektur-PDFs."""
|
|
||||||
student_id: str
|
|
||||||
name: str
|
|
||||||
class_name: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CorrectionData:
|
|
||||||
"""Daten für Korrektur-Übersicht PDF."""
|
|
||||||
student: StudentInfo
|
|
||||||
exam_title: str
|
|
||||||
subject: str
|
|
||||||
date: str
|
|
||||||
max_points: int
|
|
||||||
achieved_points: int
|
|
||||||
grade: str
|
|
||||||
percentage: float
|
|
||||||
corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}]
|
|
||||||
teacher_notes: str = ""
|
|
||||||
ai_feedback: str = ""
|
|
||||||
grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl}
|
|
||||||
class_average: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PDFService:
|
class PDFService:
|
||||||
"""
|
"""
|
||||||
Zentrale PDF-Generierung für BreakPilot.
|
Zentrale PDF-Generierung fuer BreakPilot.
|
||||||
|
|
||||||
Unterstützt:
|
Unterstuetzt:
|
||||||
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
||||||
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
||||||
- Korrektur-Übersichten für Klausuren
|
- Korrektur-Uebersichten fuer Klausuren
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, templates_dir: Optional[Path] = None):
|
def __init__(self, templates_dir: Optional[Path] = None):
|
||||||
@@ -143,7 +82,7 @@ class PDFService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str:
|
||||||
"""Formatiert Datum für deutsche Darstellung."""
|
"""Formatiert Datum fuer deutsche Darstellung."""
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -154,10 +93,10 @@ class PDFService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _grade_color(grade: str) -> str:
|
def _grade_color(grade: str) -> str:
|
||||||
"""Gibt Farbe basierend auf Note zurück."""
|
"""Gibt Farbe basierend auf Note zurueck."""
|
||||||
grade_colors = {
|
grade_colors = {
|
||||||
"1": "#27ae60", # Grün
|
"1": "#27ae60", # Gruen
|
||||||
"2": "#2ecc71", # Hellgrün
|
"2": "#2ecc71", # Hellgruen
|
||||||
"3": "#f1c40f", # Gelb
|
"3": "#f1c40f", # Gelb
|
||||||
"4": "#e67e22", # Orange
|
"4": "#e67e22", # Orange
|
||||||
"5": "#e74c3c", # Rot
|
"5": "#e74c3c", # Rot
|
||||||
@@ -170,227 +109,12 @@ class PDFService:
|
|||||||
return grade_colors.get(str(grade), "#333333")
|
return grade_colors.get(str(grade), "#333333")
|
||||||
|
|
||||||
def _get_base_css(self) -> str:
|
def _get_base_css(self) -> str:
|
||||||
"""Gibt Basis-CSS für alle PDFs zurück."""
|
"""Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates)."""
|
||||||
return """
|
return get_base_css()
|
||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
margin: 2cm 2.5cm;
|
|
||||||
@top-right {
|
|
||||||
content: counter(page) " / " counter(pages);
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
|
||||||
font-size: 11pt;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: 16pt; }
|
|
||||||
h2 { font-size: 14pt; }
|
|
||||||
h3 { font-size: 12pt; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
border-bottom: 2px solid #2c3e50;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-name {
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #2c3e50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-info {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-date {
|
|
||||||
text-align: right;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipient {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subject {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
text-align: justify;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legal-references {
|
|
||||||
font-size: 9pt;
|
|
||||||
color: #666;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
margin-top: 30px;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gfk-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #27ae60;
|
|
||||||
font-size: 8pt;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zeugnis-Styles */
|
|
||||||
.certificate-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.certificate-title {
|
|
||||||
font-size: 20pt;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-info {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 15px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table th,
|
|
||||||
.grades-table td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table th {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grades-table tr:nth-child(even) {
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grade-cell {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attendance-box {
|
|
||||||
background: #fff3cd;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signatures-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-block {
|
|
||||||
text-align: center;
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signature-line {
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
margin-top: 40px;
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Korrektur-Styles */
|
|
||||||
.exam-header {
|
|
||||||
background: #2c3e50;
|
|
||||||
color: white;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-box {
|
|
||||||
background: #e8f5e9;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-grade {
|
|
||||||
font-size: 36pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-points {
|
|
||||||
font-size: 14pt;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-list {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-item {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-question {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correction-feedback {
|
|
||||||
background: #fff8e1;
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
border-left: 3px solid #ffc107;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-table {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-table td {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF für Elternbrief.
|
Generiert PDF fuer Elternbrief.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: LetterData mit allen Briefinformationen
|
data: LetterData mit allen Briefinformationen
|
||||||
@@ -417,7 +141,7 @@ class PDFService:
|
|||||||
|
|
||||||
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF für Schulzeugnis.
|
Generiert PDF fuer Schulzeugnis.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: CertificateData mit allen Zeugnisinformationen
|
data: CertificateData mit allen Zeugnisinformationen
|
||||||
@@ -444,7 +168,7 @@ class PDFService:
|
|||||||
|
|
||||||
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generiert PDF für Korrektur-Übersicht.
|
Generiert PDF fuer Korrektur-Uebersicht.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: CorrectionData mit allen Korrekturinformationen
|
data: CorrectionData mit allen Korrekturinformationen
|
||||||
@@ -470,322 +194,29 @@ class PDFService:
|
|||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
def _get_letter_template(self):
|
def _get_letter_template(self):
|
||||||
"""Gibt Letter-Template zurück (inline falls Datei nicht existiert)."""
|
"""Gibt Letter-Template zurueck (inline falls Datei nicht existiert)."""
|
||||||
template_path = self.templates_dir / "letter.html"
|
template_path = self.templates_dir / "letter.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("letter.html")
|
return self.jinja_env.get_template("letter.html")
|
||||||
|
|
||||||
# Inline-Template als Fallback
|
# Inline-Template als Fallback
|
||||||
return self.jinja_env.from_string(self._get_letter_template_html())
|
return self.jinja_env.from_string(get_letter_template_html())
|
||||||
|
|
||||||
def _get_certificate_template(self):
|
def _get_certificate_template(self):
|
||||||
"""Gibt Certificate-Template zurück."""
|
"""Gibt Certificate-Template zurueck."""
|
||||||
template_path = self.templates_dir / "certificate.html"
|
template_path = self.templates_dir / "certificate.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("certificate.html")
|
return self.jinja_env.get_template("certificate.html")
|
||||||
|
|
||||||
return self.jinja_env.from_string(self._get_certificate_template_html())
|
return self.jinja_env.from_string(get_certificate_template_html())
|
||||||
|
|
||||||
def _get_correction_template(self):
|
def _get_correction_template(self):
|
||||||
"""Gibt Correction-Template zurück."""
|
"""Gibt Correction-Template zurueck."""
|
||||||
template_path = self.templates_dir / "correction.html"
|
template_path = self.templates_dir / "correction.html"
|
||||||
if template_path.exists():
|
if template_path.exists():
|
||||||
return self.jinja_env.get_template("correction.html")
|
return self.jinja_env.get_template("correction.html")
|
||||||
|
|
||||||
return self.jinja_env.from_string(self._get_correction_template_html())
|
return self.jinja_env.from_string(get_correction_template_html())
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_letter_template_html() -> str:
|
|
||||||
"""Inline HTML-Template für Elternbriefe."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{{ data.subject }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
{% if data.school_info %}
|
|
||||||
<div class="school-name">{{ data.school_info.name }}</div>
|
|
||||||
<div class="school-info">
|
|
||||||
{{ data.school_info.address }}<br>
|
|
||||||
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
|
||||||
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="school-name">Schule</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="letter-date">
|
|
||||||
{{ data.date }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="recipient">
|
|
||||||
{{ data.recipient_name }}<br>
|
|
||||||
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="subject">
|
|
||||||
Betreff: {{ data.subject }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
|
||||||
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{{ data.content | replace('\\n', '<br>') | safe }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.gfk_principles_applied %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
{% for principle in data.gfk_principles_applied %}
|
|
||||||
<span class="gfk-badge">✓ {{ principle }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="signature">
|
|
||||||
<p>Mit freundlichen Grüßen</p>
|
|
||||||
<p style="margin-top: 30px;">
|
|
||||||
{{ data.teacher_name }}
|
|
||||||
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.legal_references %}
|
|
||||||
<div class="legal-references">
|
|
||||||
<strong>Rechtliche Grundlagen:</strong><br>
|
|
||||||
{% for ref in data.legal_references %}
|
|
||||||
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
|
||||||
Erstellt mit BreakPilot | {{ generated_at }}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_certificate_template_html() -> str:
|
|
||||||
"""Inline HTML-Template für Zeugnisse."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Zeugnis - {{ data.student_name }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="certificate-header">
|
|
||||||
{% if data.school_info %}
|
|
||||||
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="certificate-title">
|
|
||||||
{% if data.certificate_type == 'halbjahr' %}
|
|
||||||
Halbjahreszeugnis
|
|
||||||
{% elif data.certificate_type == 'jahres' %}
|
|
||||||
Jahreszeugnis
|
|
||||||
{% else %}
|
|
||||||
Abschlusszeugnis
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>Schuljahr {{ data.school_year }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-info">
|
|
||||||
<table style="width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
|
||||||
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
|
||||||
<td> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Leistungen</h3>
|
|
||||||
<table class="grades-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 70%;">Fach</th>
|
|
||||||
<th style="width: 15%;">Note</th>
|
|
||||||
<th style="width: 15%;">Punkte</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for subject in data.subjects %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ subject.name }}</td>
|
|
||||||
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
|
||||||
{{ subject.grade }}
|
|
||||||
</td>
|
|
||||||
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{% if data.social_behavior or data.work_behavior %}
|
|
||||||
<h3>Verhalten</h3>
|
|
||||||
<table class="grades-table" style="width: 50%;">
|
|
||||||
{% if data.social_behavior %}
|
|
||||||
<tr>
|
|
||||||
<td>Sozialverhalten</td>
|
|
||||||
<td class="grade-cell">{{ data.social_behavior }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if data.work_behavior %}
|
|
||||||
<tr>
|
|
||||||
<td>Arbeitsverhalten</td>
|
|
||||||
<td class="grade-cell">{{ data.work_behavior }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="attendance-box">
|
|
||||||
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
|
||||||
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
|
||||||
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.remarks %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
<strong>Bemerkungen:</strong><br>
|
|
||||||
{{ data.remarks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="margin-top: 30px;">
|
|
||||||
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="signatures-row">
|
|
||||||
<div class="signature-block">
|
|
||||||
<div class="signature-line">{{ data.class_teacher }}</div>
|
|
||||||
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
|
||||||
</div>
|
|
||||||
<div class="signature-block">
|
|
||||||
<div class="signature-line">{{ data.principal }}</div>
|
|
||||||
<div style="font-size: 9pt;">Schulleiter/in</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 40px;">
|
|
||||||
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_correction_template_html() -> str:
|
|
||||||
"""Inline HTML-Template für Korrektur-Übersichten."""
|
|
||||||
return """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Korrektur - {{ data.exam_title }}</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="exam-header">
|
|
||||||
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
|
||||||
<div>{{ data.subject }} | {{ data.date }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="student-info">
|
|
||||||
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-box">
|
|
||||||
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
|
||||||
Note: {{ data.grade }}
|
|
||||||
</div>
|
|
||||||
<div class="result-points">
|
|
||||||
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
|
||||||
({{ data.percentage | round(1) }}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Detaillierte Auswertung</h3>
|
|
||||||
<div class="corrections-list">
|
|
||||||
{% for item in data.corrections %}
|
|
||||||
<div class="correction-item">
|
|
||||||
<div class="correction-question">
|
|
||||||
{{ item.question }}
|
|
||||||
</div>
|
|
||||||
{% if item.answer %}
|
|
||||||
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
|
||||||
<strong>Antwort:</strong> {{ item.answer }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<strong>Punkte:</strong> {{ item.points }}
|
|
||||||
</div>
|
|
||||||
{% if item.feedback %}
|
|
||||||
<div class="correction-feedback">
|
|
||||||
{{ item.feedback }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if data.teacher_notes %}
|
|
||||||
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
|
||||||
<strong>Lehrerkommentar:</strong><br>
|
|
||||||
{{ data.teacher_notes }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if data.ai_feedback %}
|
|
||||||
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
|
||||||
<strong>KI-Feedback:</strong><br>
|
|
||||||
{{ data.ai_feedback }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if data.class_average or data.grade_distribution %}
|
|
||||||
<h3>Klassenstatistik</h3>
|
|
||||||
<table class="stats-table">
|
|
||||||
{% if data.class_average %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Klassendurchschnitt:</strong></td>
|
|
||||||
<td>{{ data.class_average }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if data.grade_distribution %}
|
|
||||||
<tr>
|
|
||||||
<td><strong>Notenverteilung:</strong></td>
|
|
||||||
<td>
|
|
||||||
{% for grade, count in data.grade_distribution.items() %}
|
|
||||||
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="signature" style="margin-top: 40px;">
|
|
||||||
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
|
||||||
Erstellt mit BreakPilot | {{ generated_at }}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# Convenience functions for direct usage
|
# Convenience functions for direct usage
|
||||||
@@ -793,7 +224,7 @@ _pdf_service: Optional[PDFService] = None
|
|||||||
|
|
||||||
|
|
||||||
def get_pdf_service() -> PDFService:
|
def get_pdf_service() -> PDFService:
|
||||||
"""Gibt Singleton-Instanz des PDF-Service zurück."""
|
"""Gibt Singleton-Instanz des PDF-Service zurueck."""
|
||||||
global _pdf_service
|
global _pdf_service
|
||||||
if _pdf_service is None:
|
if _pdf_service is None:
|
||||||
_pdf_service = PDFService()
|
_pdf_service = PDFService()
|
||||||
|
|||||||
519
backend-core/services/pdf_templates.py
Normal file
519
backend-core/services/pdf_templates.py
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
"""
|
||||||
|
PDF Templates - Inline HTML-Templates und CSS fuer PDF-Generierung.
|
||||||
|
|
||||||
|
Fallback-Templates die verwendet werden wenn keine externen HTML-Dateien
|
||||||
|
im templates/pdf/ Verzeichnis vorhanden sind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_css() -> str:
|
||||||
|
"""Basis-CSS fuer alle PDFs (A4, Typografie, Komponenten-Styles)."""
|
||||||
|
return """
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 2cm 2.5cm;
|
||||||
|
@top-right {
|
||||||
|
content: counter(page) " / " counter(pages);
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 16pt; }
|
||||||
|
h2 { font-size: 14pt; }
|
||||||
|
h3 { font-size: 12pt; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: 2px solid #2c3e50;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-name {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.school-info {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-date {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
text-align: justify;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-references {
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gfk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #27ae60;
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeugnis-Styles */
|
||||||
|
.certificate-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.certificate-title {
|
||||||
|
font-size: 20pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table th,
|
||||||
|
.grades-table td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table th {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grades-table tr:nth-child(even) {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-cell {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-box {
|
||||||
|
background: #fff3cd;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block {
|
||||||
|
text-align: center;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-line {
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Korrektur-Styles */
|
||||||
|
.exam-header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-box {
|
||||||
|
background: #e8f5e9;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grade {
|
||||||
|
font-size: 36pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-points {
|
||||||
|
font-size: 14pt;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrections-list {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-item {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-question {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-feedback {
|
||||||
|
background: #fff8e1;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-left: 3px solid #ffc107;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_letter_template_html() -> str:
|
||||||
|
"""Inline HTML-Template fuer Elternbriefe."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{ data.subject }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
{% if data.school_info %}
|
||||||
|
<div class="school-name">{{ data.school_info.name }}</div>
|
||||||
|
<div class="school-info">
|
||||||
|
{{ data.school_info.address }}<br>
|
||||||
|
Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }}
|
||||||
|
{% if data.school_info.website %} | {{ data.school_info.website }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="school-name">Schule</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="letter-date">
|
||||||
|
{{ data.date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="recipient">
|
||||||
|
{{ data.recipient_name }}<br>
|
||||||
|
{{ data.recipient_address | replace('\\n', '<br>') | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="subject">
|
||||||
|
Betreff: {{ data.subject }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-info" style="font-size: 10pt; color: #666; margin-bottom: 20px;">
|
||||||
|
Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{{ data.content | replace('\\n', '<br>') | safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.gfk_principles_applied %}
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
{% for principle in data.gfk_principles_applied %}
|
||||||
|
<span class="gfk-badge">✓ {{ principle }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<p>Mit freundlichen Grüßen</p>
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
{{ data.teacher_name }}
|
||||||
|
{% if data.teacher_title %}<br><span style="font-size: 10pt;">{{ data.teacher_title }}</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.legal_references %}
|
||||||
|
<div class="legal-references">
|
||||||
|
<strong>Rechtliche Grundlagen:</strong><br>
|
||||||
|
{% for ref in data.legal_references %}
|
||||||
|
• {{ ref.law }} {{ ref.paragraph }}: {{ ref.title }}<br>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||||
|
Erstellt mit BreakPilot | {{ generated_at }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_certificate_template_html() -> str:
|
||||||
|
"""Inline HTML-Template fuer Zeugnisse."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Zeugnis - {{ data.student_name }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate-header">
|
||||||
|
{% if data.school_info %}
|
||||||
|
<div class="school-name" style="font-size: 14pt;">{{ data.school_info.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="certificate-title">
|
||||||
|
{% if data.certificate_type == 'halbjahr' %}
|
||||||
|
Halbjahreszeugnis
|
||||||
|
{% elif data.certificate_type == 'jahres' %}
|
||||||
|
Jahreszeugnis
|
||||||
|
{% else %}
|
||||||
|
Abschlusszeugnis
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>Schuljahr {{ data.school_year }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Name:</strong> {{ data.student_name }}</td>
|
||||||
|
<td><strong>Geburtsdatum:</strong> {{ data.student_birthdate }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Klasse:</strong> {{ data.student_class }}</td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Leistungen</h3>
|
||||||
|
<table class="grades-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 70%;">Fach</th>
|
||||||
|
<th style="width: 15%;">Note</th>
|
||||||
|
<th style="width: 15%;">Punkte</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for subject in data.subjects %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ subject.name }}</td>
|
||||||
|
<td class="grade-cell" style="color: {{ subject.grade | grade_color }};">
|
||||||
|
{{ subject.grade }}
|
||||||
|
</td>
|
||||||
|
<td class="grade-cell">{{ subject.points | default('-') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if data.social_behavior or data.work_behavior %}
|
||||||
|
<h3>Verhalten</h3>
|
||||||
|
<table class="grades-table" style="width: 50%;">
|
||||||
|
{% if data.social_behavior %}
|
||||||
|
<tr>
|
||||||
|
<td>Sozialverhalten</td>
|
||||||
|
<td class="grade-cell">{{ data.social_behavior }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.work_behavior %}
|
||||||
|
<tr>
|
||||||
|
<td>Arbeitsverhalten</td>
|
||||||
|
<td class="grade-cell">{{ data.work_behavior }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="attendance-box">
|
||||||
|
<strong>Versäumte Tage:</strong> {{ data.attendance.days_absent | default(0) }}
|
||||||
|
(davon entschuldigt: {{ data.attendance.days_excused | default(0) }},
|
||||||
|
unentschuldigt: {{ data.attendance.days_unexcused | default(0) }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.remarks %}
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<strong>Bemerkungen:</strong><br>
|
||||||
|
{{ data.remarks }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
<strong>Ausgestellt am:</strong> {{ data.issue_date }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures-row">
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">{{ data.class_teacher }}</div>
|
||||||
|
<div style="font-size: 9pt;">Klassenlehrer/in</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">{{ data.principal }}</div>
|
||||||
|
<div style="font-size: 9pt;">Schulleiter/in</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 40px;">
|
||||||
|
<div style="font-size: 9pt; color: #666;">Siegel der Schule</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_correction_template_html() -> str:
|
||||||
|
"""Inline HTML-Template fuer Korrektur-Uebersichten."""
|
||||||
|
return """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Korrektur - {{ data.exam_title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="exam-header">
|
||||||
|
<h1 style="margin: 0; color: white;">{{ data.exam_title }}</h1>
|
||||||
|
<div>{{ data.subject }} | {{ data.date }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<strong>{{ data.student.name }}</strong> | Klasse {{ data.student.class_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-box">
|
||||||
|
<div class="result-grade" style="color: {{ data.grade | grade_color }};">
|
||||||
|
Note: {{ data.grade }}
|
||||||
|
</div>
|
||||||
|
<div class="result-points">
|
||||||
|
{{ data.achieved_points }} von {{ data.max_points }} Punkten
|
||||||
|
({{ data.percentage | round(1) }}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Detaillierte Auswertung</h3>
|
||||||
|
<div class="corrections-list">
|
||||||
|
{% for item in data.corrections %}
|
||||||
|
<div class="correction-item">
|
||||||
|
<div class="correction-question">
|
||||||
|
{{ item.question }}
|
||||||
|
</div>
|
||||||
|
{% if item.answer %}
|
||||||
|
<div style="margin: 5px 0; font-style: italic; color: #555;">
|
||||||
|
<strong>Antwort:</strong> {{ item.answer }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>Punkte:</strong> {{ item.points }}
|
||||||
|
</div>
|
||||||
|
{% if item.feedback %}
|
||||||
|
<div class="correction-feedback">
|
||||||
|
{{ item.feedback }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if data.teacher_notes %}
|
||||||
|
<div style="background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<strong>Lehrerkommentar:</strong><br>
|
||||||
|
{{ data.teacher_notes }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.ai_feedback %}
|
||||||
|
<div style="background: #f3e5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px;">
|
||||||
|
<strong>KI-Feedback:</strong><br>
|
||||||
|
{{ data.ai_feedback }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if data.class_average or data.grade_distribution %}
|
||||||
|
<h3>Klassenstatistik</h3>
|
||||||
|
<table class="stats-table">
|
||||||
|
{% if data.class_average %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Klassendurchschnitt:</strong></td>
|
||||||
|
<td>{{ data.class_average }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if data.grade_distribution %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>Notenverteilung:</strong></td>
|
||||||
|
<td>
|
||||||
|
{% for grade, count in data.grade_distribution.items() %}
|
||||||
|
Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="signature" style="margin-top: 40px;">
|
||||||
|
<p style="font-size: 9pt; color: #666;">Datum: {{ data.date }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size: 8pt; color: #999; margin-top: 30px; text-align: center;">
|
||||||
|
Erstellt mit BreakPilot | {{ generated_at }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""
|
|
||||||
Woodpecker CI Proxy API
|
|
||||||
|
|
||||||
Liest Pipeline-Daten direkt aus der Woodpecker SQLite-Datenbank.
|
|
||||||
Wird als Fallback verwendet, wenn kein WOODPECKER_TOKEN konfiguriert ist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Query
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/woodpecker", tags=["Woodpecker CI"])
|
|
||||||
|
|
||||||
WOODPECKER_DB = Path("/woodpecker-data/woodpecker.sqlite")
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
if not WOODPECKER_DB.exists():
|
|
||||||
return None
|
|
||||||
conn = sqlite3.connect(f"file:{WOODPECKER_DB}?mode=ro", uri=True)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def get_status():
|
|
||||||
conn = get_db()
|
|
||||||
if not conn:
|
|
||||||
return {"status": "offline", "error": "Woodpecker DB nicht gefunden"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
repos = [dict(r) for r in conn.execute(
|
|
||||||
"SELECT id, name, full_name, active FROM repos ORDER BY id"
|
|
||||||
).fetchall()]
|
|
||||||
|
|
||||||
total_pipelines = conn.execute("SELECT COUNT(*) FROM pipelines").fetchone()[0]
|
|
||||||
success = conn.execute("SELECT COUNT(*) FROM pipelines WHERE status='success'").fetchone()[0]
|
|
||||||
failure = conn.execute("SELECT COUNT(*) FROM pipelines WHERE status='failure'").fetchone()[0]
|
|
||||||
|
|
||||||
latest = conn.execute("SELECT MAX(created) FROM pipelines").fetchone()[0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "online",
|
|
||||||
"repos": repos,
|
|
||||||
"stats": {
|
|
||||||
"total_pipelines": total_pipelines,
|
|
||||||
"success": success,
|
|
||||||
"failure": failure,
|
|
||||||
"success_rate": round(success / total_pipelines * 100, 1) if total_pipelines > 0 else 0,
|
|
||||||
},
|
|
||||||
"last_activity": datetime.fromtimestamp(latest).isoformat() if latest else None,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pipelines")
|
|
||||||
async def get_pipelines(
|
|
||||||
repo: int = Query(default=0, description="Repo ID (0 = alle)"),
|
|
||||||
limit: int = Query(default=10, ge=1, le=100),
|
|
||||||
):
|
|
||||||
conn = get_db()
|
|
||||||
if not conn:
|
|
||||||
return {"status": "offline", "pipelines": [], "lastUpdate": datetime.now().isoformat()}
|
|
||||||
|
|
||||||
try:
|
|
||||||
base_sql = """SELECT p.id, p.repo_id, p.number, p.status, p.event, p.branch,
|
|
||||||
p."commit", p.message, p.author, p.created, p.started, p.finished,
|
|
||||||
r.name as repo_name
|
|
||||||
FROM pipelines p
|
|
||||||
JOIN repos r ON r.id = p.repo_id"""
|
|
||||||
|
|
||||||
if repo > 0:
|
|
||||||
rows = conn.execute(
|
|
||||||
base_sql + " WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT ?",
|
|
||||||
(repo, limit)
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute(
|
|
||||||
base_sql + " ORDER BY p.id DESC LIMIT ?",
|
|
||||||
(limit,)
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
pipelines = []
|
|
||||||
for r in rows:
|
|
||||||
p = dict(r)
|
|
||||||
|
|
||||||
# Get steps directly (steps.pipeline_id links to pipelines.id)
|
|
||||||
steps = [dict(s) for s in conn.execute(
|
|
||||||
"""SELECT s.name, s.state, s.exit_code, s.error
|
|
||||||
FROM steps s
|
|
||||||
WHERE s.pipeline_id = ?
|
|
||||||
ORDER BY s.pid""",
|
|
||||||
(p["id"],)
|
|
||||||
).fetchall()]
|
|
||||||
|
|
||||||
p["steps"] = steps
|
|
||||||
p["commit"] = (p.get("commit") or "")[:7]
|
|
||||||
msg = p.get("message") or ""
|
|
||||||
p["message"] = msg.split("\n")[0][:100]
|
|
||||||
pipelines.append(p)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "online",
|
|
||||||
"pipelines": pipelines,
|
|
||||||
"lastUpdate": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/repos")
|
|
||||||
async def get_repos():
|
|
||||||
conn = get_db()
|
|
||||||
if not conn:
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
repos = []
|
|
||||||
for r in conn.execute("SELECT id, name, full_name, active FROM repos ORDER BY id").fetchall():
|
|
||||||
repo = dict(r)
|
|
||||||
latest = conn.execute(
|
|
||||||
'SELECT status, created FROM pipelines WHERE repo_id = ? ORDER BY id DESC LIMIT 1',
|
|
||||||
(repo["id"],)
|
|
||||||
).fetchone()
|
|
||||||
if latest:
|
|
||||||
repo["last_status"] = latest["status"]
|
|
||||||
repo["last_activity"] = datetime.fromtimestamp(latest["created"]).isoformat()
|
|
||||||
repos.append(repo)
|
|
||||||
return repos
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
File diff suppressed because it is too large
Load Diff
307
consent-service/internal/database/migrate_core.go
Normal file
307
consent-service/internal/database/migrate_core.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateCore creates the core tables: users, auth tokens, sessions,
|
||||||
|
// documents, versions, consents, cookies, audit, notifications,
|
||||||
|
// deadlines, suspensions, and their indexes (Phases 1-5).
|
||||||
|
func migrateCore(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// Users table (extended for full auth)
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
external_id VARCHAR(255) UNIQUE,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255),
|
||||||
|
name VARCHAR(255),
|
||||||
|
role VARCHAR(50) DEFAULT 'user',
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
account_status VARCHAR(20) DEFAULT 'active',
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
failed_login_attempts INT DEFAULT 0,
|
||||||
|
locked_until TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Legal documents table
|
||||||
|
`CREATE TABLE IF NOT EXISTS legal_documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_mandatory BOOLEAN DEFAULT true,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Document versions table
|
||||||
|
`CREATE TABLE IF NOT EXISTS document_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
language VARCHAR(5) DEFAULT 'de',
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
scheduled_publish_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
approved_by UUID REFERENCES users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(document_id, version, language)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Add scheduled_publish_at column if not exists (migration)
|
||||||
|
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`,
|
||||||
|
`ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`,
|
||||||
|
|
||||||
|
// User consents table
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_consents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
document_version_id UUID REFERENCES document_versions(id),
|
||||||
|
consented BOOLEAN NOT NULL,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
consented_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
withdrawn_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(user_id, document_version_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Cookie categories table
|
||||||
|
`CREATE TABLE IF NOT EXISTS cookie_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
display_name_de VARCHAR(255) NOT NULL,
|
||||||
|
display_name_en VARCHAR(255),
|
||||||
|
description_de TEXT,
|
||||||
|
description_en TEXT,
|
||||||
|
is_mandatory BOOLEAN DEFAULT false,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Cookie consents table
|
||||||
|
`CREATE TABLE IF NOT EXISTS cookie_consents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES cookie_categories(id) ON DELETE CASCADE,
|
||||||
|
consented BOOLEAN NOT NULL,
|
||||||
|
consented_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, category_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Audit log table
|
||||||
|
`CREATE TABLE IF NOT EXISTS consent_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50),
|
||||||
|
entity_id UUID,
|
||||||
|
details JSONB,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Data export requests table
|
||||||
|
`CREATE TABLE IF NOT EXISTS data_export_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
download_url TEXT,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Data deletion requests table
|
||||||
|
`CREATE TABLE IF NOT EXISTS data_deletion_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
processed_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 1: User Management Tables
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Email verification tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Password reset tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
ip_address INET,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// User sessions (for JWT revocation and session management)
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
device_info TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_activity_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 3: Version Approvals (DSB Workflow)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Version approval tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS version_approvals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||||
|
approver_id UUID REFERENCES users(id),
|
||||||
|
action VARCHAR(30) NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 4: Notification System
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
`CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
data JSONB,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Push subscriptions for Web Push
|
||||||
|
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, endpoint)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Notification preferences per user
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
email_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
push_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
in_app_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
reminder_frequency VARCHAR(20) DEFAULT 'weekly',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 5: Consent Deadlines & Account Suspension
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Consent deadlines per user per version
|
||||||
|
`CREATE TABLE IF NOT EXISTS consent_deadlines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
document_version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE,
|
||||||
|
deadline_at TIMESTAMPTZ NOT NULL,
|
||||||
|
reminder_count INT DEFAULT 0,
|
||||||
|
last_reminder_at TIMESTAMPTZ,
|
||||||
|
consent_given_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, document_version_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Account suspensions tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS account_suspensions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reason VARCHAR(50) NOT NULL,
|
||||||
|
details JSONB,
|
||||||
|
suspended_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
lifted_at TIMESTAMPTZ,
|
||||||
|
lifted_reason TEXT
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Indexes for performance
|
||||||
|
// =============================================
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`,
|
||||||
|
|
||||||
|
// Phase 1: Auth indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`,
|
||||||
|
|
||||||
|
// Phase 3: Approval indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`,
|
||||||
|
|
||||||
|
// Phase 4: Notification indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`,
|
||||||
|
|
||||||
|
// Phase 5: Deadline indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateCore: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
267
consent-service/internal/database/migrate_dsr.go
Normal file
267
consent-service/internal/database/migrate_dsr.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateDSR creates DSGVO Data Subject Request tables (Phase 10)
|
||||||
|
// and EduSearch seed management tables (Phase 11).
|
||||||
|
func migrateDSR(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 10: DSGVO Betroffenenanfragen (DSR)
|
||||||
|
// Data Subject Request Management
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Sequence for request numbers
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`,
|
||||||
|
|
||||||
|
// Main table: Data Subject Requests
|
||||||
|
`CREATE TABLE IF NOT EXISTS data_subject_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
request_number VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
request_type VARCHAR(30) NOT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'intake',
|
||||||
|
priority VARCHAR(20) DEFAULT 'normal',
|
||||||
|
source VARCHAR(30) NOT NULL DEFAULT 'api',
|
||||||
|
requester_email VARCHAR(255) NOT NULL,
|
||||||
|
requester_name VARCHAR(255),
|
||||||
|
requester_phone VARCHAR(50),
|
||||||
|
identity_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
identity_verified_at TIMESTAMPTZ,
|
||||||
|
identity_verified_by UUID REFERENCES users(id),
|
||||||
|
identity_verification_method VARCHAR(50),
|
||||||
|
request_details JSONB DEFAULT '{}',
|
||||||
|
deadline_at TIMESTAMPTZ NOT NULL,
|
||||||
|
legal_deadline_days INT NOT NULL,
|
||||||
|
extended_deadline_at TIMESTAMPTZ,
|
||||||
|
extension_reason TEXT,
|
||||||
|
assigned_to UUID REFERENCES users(id),
|
||||||
|
processing_notes TEXT,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
completed_by UUID REFERENCES users(id),
|
||||||
|
result_summary TEXT,
|
||||||
|
result_data JSONB,
|
||||||
|
rejected_at TIMESTAMPTZ,
|
||||||
|
rejected_by UUID REFERENCES users(id),
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejection_legal_basis TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Status History for audit trail
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_status_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
||||||
|
from_status VARCHAR(30),
|
||||||
|
to_status VARCHAR(30) NOT NULL,
|
||||||
|
changed_by UUID REFERENCES users(id),
|
||||||
|
comment TEXT,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Communications log
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_communications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
||||||
|
direction VARCHAR(10) NOT NULL,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
communication_type VARCHAR(50) NOT NULL,
|
||||||
|
template_version_id UUID,
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body_html TEXT,
|
||||||
|
body_text TEXT,
|
||||||
|
recipient_email VARCHAR(255),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
attachments JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Templates
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_type VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Template Versions
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_template_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
language VARCHAR(5) DEFAULT 'de',
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
approved_by UUID REFERENCES users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(template_id, version, language)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// DSR Exception Checks (for Art. 17(3) erasure exceptions)
|
||||||
|
`CREATE TABLE IF NOT EXISTS dsr_exception_checks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE,
|
||||||
|
exception_type VARCHAR(50) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
applies BOOLEAN,
|
||||||
|
checked_by UUID REFERENCES users(id),
|
||||||
|
checked_at TIMESTAMPTZ,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Phase 10 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`,
|
||||||
|
|
||||||
|
// Insert default DSR templates
|
||||||
|
`INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order)
|
||||||
|
VALUES
|
||||||
|
('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1),
|
||||||
|
('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2),
|
||||||
|
('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3),
|
||||||
|
('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4),
|
||||||
|
('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5),
|
||||||
|
('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6),
|
||||||
|
('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7),
|
||||||
|
('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8),
|
||||||
|
('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9),
|
||||||
|
('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10),
|
||||||
|
('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11),
|
||||||
|
('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12),
|
||||||
|
('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13),
|
||||||
|
('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14),
|
||||||
|
('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15),
|
||||||
|
('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16),
|
||||||
|
('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17),
|
||||||
|
('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18),
|
||||||
|
('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19)
|
||||||
|
ON CONFLICT (template_type) DO NOTHING`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 11: EduSearch Seeds Management
|
||||||
|
// Seed URLs for the education search crawler
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// EduSearch Seed Categories
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon VARCHAR(10),
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// EduSearch Seeds (crawler seed URLs)
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_seeds (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
url VARCHAR(500) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL,
|
||||||
|
source_type VARCHAR(20) DEFAULT 'GOV',
|
||||||
|
scope VARCHAR(20) DEFAULT 'FEDERAL',
|
||||||
|
state VARCHAR(5),
|
||||||
|
trust_boost DECIMAL(3,2) DEFAULT 0.50,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
crawl_depth INT DEFAULT 2,
|
||||||
|
crawl_frequency VARCHAR(20) DEFAULT 'weekly',
|
||||||
|
last_crawled_at TIMESTAMPTZ,
|
||||||
|
last_crawl_status VARCHAR(20),
|
||||||
|
last_crawl_docs INT DEFAULT 0,
|
||||||
|
total_documents INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// EduSearch Crawl Runs (history of crawl executions)
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_crawl_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(20) DEFAULT 'running',
|
||||||
|
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
pages_crawled INT DEFAULT 0,
|
||||||
|
documents_indexed INT DEFAULT 0,
|
||||||
|
errors_count INT DEFAULT 0,
|
||||||
|
error_details JSONB,
|
||||||
|
triggered_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// EduSearch Denylist (URLs/domains to never crawl)
|
||||||
|
`CREATE TABLE IF NOT EXISTS edu_search_denylist (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
pattern VARCHAR(500) UNIQUE NOT NULL,
|
||||||
|
pattern_type VARCHAR(20) DEFAULT 'domain',
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Phase 11 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`,
|
||||||
|
|
||||||
|
// Insert default EduSearch categories
|
||||||
|
`INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order)
|
||||||
|
VALUES
|
||||||
|
('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1),
|
||||||
|
('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2),
|
||||||
|
('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3),
|
||||||
|
('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4),
|
||||||
|
('schools', 'Schulen', 'Schulwebsites', '🏫', 5),
|
||||||
|
('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6),
|
||||||
|
('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7),
|
||||||
|
('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8)
|
||||||
|
ON CONFLICT (name) DO NOTHING`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateDSR: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
114
consent-service/internal/database/migrate_email.go
Normal file
114
consent-service/internal/database/migrate_email.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateEmail creates email template tables, settings, and indexes (Phase 8).
|
||||||
|
func migrateEmail(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 8: E-Mail Templates (Transactional)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Email templates (like legal_documents)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Email template versions (like document_versions)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_template_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE,
|
||||||
|
version VARCHAR(20) NOT NULL,
|
||||||
|
language VARCHAR(5) DEFAULT 'de',
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
scheduled_publish_at TIMESTAMPTZ,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
approved_by UUID REFERENCES users(id),
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(template_id, version, language)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Email template approvals (like version_approvals)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_template_approvals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE,
|
||||||
|
approver_id UUID REFERENCES users(id),
|
||||||
|
action VARCHAR(30) NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Email send logs for audit
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_send_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL,
|
||||||
|
recipient VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'queued',
|
||||||
|
error_msg TEXT,
|
||||||
|
variables JSONB,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Global email settings (logo, colors, signature)
|
||||||
|
`CREATE TABLE IF NOT EXISTS email_template_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
logo_url TEXT,
|
||||||
|
logo_base64 TEXT,
|
||||||
|
company_name VARCHAR(255) DEFAULT 'BreakPilot',
|
||||||
|
sender_name VARCHAR(255) DEFAULT 'BreakPilot',
|
||||||
|
sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app',
|
||||||
|
reply_to_email VARCHAR(255),
|
||||||
|
footer_html TEXT,
|
||||||
|
footer_text TEXT,
|
||||||
|
primary_color VARCHAR(7) DEFAULT '#2563eb',
|
||||||
|
secondary_color VARCHAR(7) DEFAULT '#64748b',
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_by UUID REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Insert default email settings
|
||||||
|
`INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color)
|
||||||
|
VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
|
||||||
|
// Phase 8 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateEmail: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
171
consent-service/internal/database/migrate_oauth.go
Normal file
171
consent-service/internal/database/migrate_oauth.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateOAuth creates OAuth 2.0 and 2FA tables (Phases 6-7),
|
||||||
|
// plus default seed data for OAuth clients, cookie categories,
|
||||||
|
// and legal documents.
|
||||||
|
func migrateOAuth(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 6: OAuth 2.0 Authorization Code Flow
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// OAuth 2.0 Clients
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
client_id VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
client_secret VARCHAR(255),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
redirect_uris JSONB NOT NULL DEFAULT '[]',
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]',
|
||||||
|
grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]',
|
||||||
|
is_public BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// OAuth 2.0 Authorization Codes
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
code_challenge VARCHAR(255),
|
||||||
|
code_challenge_method VARCHAR(10),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// OAuth 2.0 Access Tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_access_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// OAuth 2.0 Refresh Tokens
|
||||||
|
`CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE,
|
||||||
|
client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '[]',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 7: Two-Factor Authentication (2FA/TOTP)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// User TOTP secrets and recovery codes
|
||||||
|
`CREATE TABLE IF NOT EXISTS user_totp (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
secret VARCHAR(255) NOT NULL,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
recovery_codes JSONB DEFAULT '[]',
|
||||||
|
enabled_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// 2FA challenges during login
|
||||||
|
`CREATE TABLE IF NOT EXISTS two_factor_challenges (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
challenge_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Add 2FA required flag to users
|
||||||
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`,
|
||||||
|
`ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`,
|
||||||
|
|
||||||
|
// Phase 6 & 7 Indexes
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`,
|
||||||
|
|
||||||
|
// Insert default OAuth client for BreakPilot PWA (public client with PKCE)
|
||||||
|
`INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public)
|
||||||
|
VALUES (
|
||||||
|
'breakpilot-pwa',
|
||||||
|
'BreakPilot PWA',
|
||||||
|
'Official BreakPilot Progressive Web Application',
|
||||||
|
'["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]',
|
||||||
|
'["openid", "profile", "email", "consent:read", "consent:write"]',
|
||||||
|
'["authorization_code", "refresh_token"]',
|
||||||
|
true
|
||||||
|
) ON CONFLICT (client_id) DO NOTHING`,
|
||||||
|
|
||||||
|
// Insert default cookie categories
|
||||||
|
`INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
||||||
|
VALUES
|
||||||
|
('necessary', 'Notwendige Cookies', 'Necessary Cookies',
|
||||||
|
'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.',
|
||||||
|
'These cookies are essential for the basic functions of the website.',
|
||||||
|
true, 1),
|
||||||
|
('functional', 'Funktionale Cookies', 'Functional Cookies',
|
||||||
|
'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.',
|
||||||
|
'These cookies enable enhanced functionality and personalization.',
|
||||||
|
false, 2),
|
||||||
|
('analytics', 'Analyse Cookies', 'Analytics Cookies',
|
||||||
|
'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.',
|
||||||
|
'These cookies help us understand how visitors interact with the website.',
|
||||||
|
false, 3),
|
||||||
|
('marketing', 'Marketing Cookies', 'Marketing Cookies',
|
||||||
|
'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.',
|
||||||
|
'These cookies are used to make advertising more relevant to you.',
|
||||||
|
false, 4)
|
||||||
|
ON CONFLICT (name) DO NOTHING`,
|
||||||
|
|
||||||
|
// Insert default legal documents
|
||||||
|
`INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order)
|
||||||
|
VALUES
|
||||||
|
('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1),
|
||||||
|
('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2),
|
||||||
|
('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3),
|
||||||
|
('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateOAuth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
182
consent-service/internal/database/migrate_school.go
Normal file
182
consent-service/internal/database/migrate_school.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateSchool creates school management tables: schools, classes,
|
||||||
|
// students, teachers, parents, timetable, attendance, grades,
|
||||||
|
// class diary, parent meetings, Matrix integration (Phase 9).
|
||||||
|
func migrateSchool(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Phase 9: Schulverwaltung / School Management
|
||||||
|
// Matrix-basierte Kommunikation für Schulen
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Schools table
|
||||||
|
`CREATE TABLE IF NOT EXISTS schools (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
short_name VARCHAR(50),
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
city VARCHAR(100),
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
state VARCHAR(50),
|
||||||
|
country VARCHAR(2) DEFAULT 'DE',
|
||||||
|
phone VARCHAR(50),
|
||||||
|
email VARCHAR(255),
|
||||||
|
website VARCHAR(255),
|
||||||
|
matrix_server_name VARCHAR(255),
|
||||||
|
logo_url TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// School years
|
||||||
|
`CREATE TABLE IF NOT EXISTS school_years (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(20) NOT NULL,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
is_current BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Subjects
|
||||||
|
`CREATE TABLE IF NOT EXISTS subjects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
short_name VARCHAR(10) NOT NULL,
|
||||||
|
color VARCHAR(7),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, short_name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
`CREATE TABLE IF NOT EXISTS classes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(20) NOT NULL,
|
||||||
|
grade INT NOT NULL,
|
||||||
|
section VARCHAR(5),
|
||||||
|
room VARCHAR(50),
|
||||||
|
matrix_info_room VARCHAR(255),
|
||||||
|
matrix_rep_room VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, school_year_id, name)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Students
|
||||||
|
`CREATE TABLE IF NOT EXISTS students (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
student_number VARCHAR(50),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
date_of_birth DATE,
|
||||||
|
gender VARCHAR(1),
|
||||||
|
matrix_user_id VARCHAR(255),
|
||||||
|
matrix_dm_room VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Teachers
|
||||||
|
`CREATE TABLE IF NOT EXISTS teachers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
teacher_code VARCHAR(10),
|
||||||
|
title VARCHAR(20),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
matrix_user_id VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(school_id, user_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Class teachers assignment
|
||||||
|
`CREATE TABLE IF NOT EXISTS class_teachers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(class_id, teacher_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Teacher subjects assignment
|
||||||
|
`CREATE TABLE IF NOT EXISTS teacher_subjects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(teacher_id, subject_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parents
|
||||||
|
`CREATE TABLE IF NOT EXISTS parents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
matrix_user_id VARCHAR(255),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
emergency_contact BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Student-parent relationships
|
||||||
|
`CREATE TABLE IF NOT EXISTS student_parents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
relationship VARCHAR(20) NOT NULL,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
has_custody BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(student_id, parent_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parent representatives
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_representatives (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
elected_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateSchool: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the second batch (timetable, attendance, grades, etc.)
|
||||||
|
return migrateSchoolPart2(db)
|
||||||
|
}
|
||||||
346
consent-service/internal/database/migrate_school_ext.go
Normal file
346
consent-service/internal/database/migrate_school_ext.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// migrateSchoolPart2 creates timetable, attendance, grades, diary,
|
||||||
|
// meetings, Matrix, and Phase 9 indexes/seed data.
|
||||||
|
func migrateSchoolPart2(db *DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []string{
|
||||||
|
// =============================================
|
||||||
|
// Stundenplan / Timetable
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Timetable slots (Stundenraster)
|
||||||
|
`CREATE TABLE IF NOT EXISTS timetable_slots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
slot_number INT NOT NULL,
|
||||||
|
start_time TIME NOT NULL,
|
||||||
|
end_time TIME NOT NULL,
|
||||||
|
is_break BOOLEAN DEFAULT FALSE,
|
||||||
|
name VARCHAR(50),
|
||||||
|
UNIQUE(school_id, slot_number)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Timetable entries (Stundenplan)
|
||||||
|
`CREATE TABLE IF NOT EXISTS timetable_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
||||||
|
day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7),
|
||||||
|
room VARCHAR(50),
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_until DATE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Timetable substitutions (Vertretungsplan)
|
||||||
|
`CREATE TABLE IF NOT EXISTS timetable_substitutions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL,
|
||||||
|
substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL,
|
||||||
|
room VARCHAR(50),
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Abwesenheit / Attendance
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Attendance records per lesson
|
||||||
|
`CREATE TABLE IF NOT EXISTS attendance_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(30) NOT NULL,
|
||||||
|
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(student_id, date, slot_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Absence reports (Krankmeldungen)
|
||||||
|
`CREATE TABLE IF NOT EXISTS absence_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
reason_category VARCHAR(30) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'reported',
|
||||||
|
reported_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
confirmed_by UUID REFERENCES users(id),
|
||||||
|
confirmed_at TIMESTAMPTZ,
|
||||||
|
medical_certificate BOOLEAN DEFAULT FALSE,
|
||||||
|
certificate_uploaded BOOLEAN DEFAULT FALSE,
|
||||||
|
matrix_notification_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
email_notification_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Absence notifications to parents
|
||||||
|
`CREATE TABLE IF NOT EXISTS absence_notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
channel VARCHAR(20) NOT NULL,
|
||||||
|
message_content TEXT NOT NULL,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
response_received BOOLEAN DEFAULT FALSE,
|
||||||
|
response_content TEXT,
|
||||||
|
response_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Notenspiegel / Grades
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Grade scales
|
||||||
|
`CREATE TABLE IF NOT EXISTS grade_scales (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
min_value DECIMAL(5,2) NOT NULL,
|
||||||
|
max_value DECIMAL(5,2) NOT NULL,
|
||||||
|
passing_value DECIMAL(5,2) NOT NULL,
|
||||||
|
is_ascending BOOLEAN DEFAULT FALSE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Grades
|
||||||
|
`CREATE TABLE IF NOT EXISTS grades (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
|
||||||
|
grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
value DECIMAL(5,2) NOT NULL,
|
||||||
|
weight DECIMAL(3,2) DEFAULT 1.0,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
title VARCHAR(100),
|
||||||
|
description TEXT,
|
||||||
|
is_visible BOOLEAN DEFAULT TRUE,
|
||||||
|
semester INT NOT NULL CHECK (semester IN (1, 2)),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Grade comments
|
||||||
|
`CREATE TABLE IF NOT EXISTS grade_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
is_private BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Klassenbuch / Class Diary
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Class diary entries
|
||||||
|
`CREATE TABLE IF NOT EXISTS class_diary_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE,
|
||||||
|
subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
topic TEXT,
|
||||||
|
homework TEXT,
|
||||||
|
homework_due_date DATE,
|
||||||
|
materials TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
is_cancelled BOOLEAN DEFAULT FALSE,
|
||||||
|
cancellation_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(class_id, date, slot_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Elterngespräche / Parent Meetings
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Parent meeting slots
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_meeting_slots (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
start_time TIME NOT NULL,
|
||||||
|
end_time TIME NOT NULL,
|
||||||
|
location VARCHAR(100),
|
||||||
|
is_online BOOLEAN DEFAULT FALSE,
|
||||||
|
meeting_link TEXT,
|
||||||
|
is_booked BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parent meetings
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_meetings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
topic TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
||||||
|
cancelled_at TIMESTAMPTZ,
|
||||||
|
cancelled_by UUID REFERENCES users(id),
|
||||||
|
cancel_reason TEXT,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Matrix / Communication Integration
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// Matrix rooms
|
||||||
|
`CREATE TABLE IF NOT EXISTS matrix_rooms (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
matrix_room_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
|
||||||
|
student_id UUID REFERENCES students(id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
is_encrypted BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Matrix room members
|
||||||
|
`CREATE TABLE IF NOT EXISTS matrix_room_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE,
|
||||||
|
matrix_user_id VARCHAR(255) NOT NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
power_level INT DEFAULT 0,
|
||||||
|
can_write BOOLEAN DEFAULT TRUE,
|
||||||
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
left_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(matrix_room_id, matrix_user_id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// Parent onboarding tokens (QR codes)
|
||||||
|
`CREATE TABLE IF NOT EXISTS parent_onboarding_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||||||
|
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||||
|
student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
role VARCHAR(30) NOT NULL DEFAULT 'parent',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
used_by_user_id UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 9 Indexes
|
||||||
|
// =============================================
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`,
|
||||||
|
|
||||||
|
// Insert default grade scales
|
||||||
|
`INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default)
|
||||||
|
SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true
|
||||||
|
FROM schools s
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
|
||||||
|
// Insert default timetable slots for schools
|
||||||
|
`DO $$
|
||||||
|
DECLARE
|
||||||
|
school_rec RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR school_rec IN SELECT id FROM schools LOOP
|
||||||
|
INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name)
|
||||||
|
VALUES
|
||||||
|
(school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'),
|
||||||
|
(school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'),
|
||||||
|
(school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'),
|
||||||
|
(school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'),
|
||||||
|
(school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'),
|
||||||
|
(school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'),
|
||||||
|
(school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'),
|
||||||
|
(school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'),
|
||||||
|
(school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'),
|
||||||
|
(school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'),
|
||||||
|
(school_rec.id, 11, '14:45', '15:30', false, '8. Stunde')
|
||||||
|
ON CONFLICT (school_id, slot_number) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END $$`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
if _, err := db.Pool.Exec(ctx, migration); err != nil {
|
||||||
|
return fmt.Errorf("migrateSchool: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
455
consent-service/internal/handlers/admin_approval.go
Normal file
455
consent-service/internal/handlers/admin_approval.go
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/breakpilot/consent-service/internal/middleware"
|
||||||
|
"github.com/breakpilot/consent-service/internal/models"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ADMIN ENDPOINTS - Version Approval Workflow (DSB)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// AdminSubmitForReview submits a version for DSB review
|
||||||
|
func (h *Handler) AdminSubmitForReview(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "draft" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to review
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'review', updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log approval action
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||||
|
VALUES ($1, $2, 'submitted', 'Submitted for DSB review')
|
||||||
|
`, versionID, userID)
|
||||||
|
|
||||||
|
h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminApproveVersion approves a version with scheduled publish date (DSB only)
|
||||||
|
func (h *Handler) AdminApproveVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is DSB or Admin (for dev purposes)
|
||||||
|
if !middleware.IsDSB(c) && !middleware.IsAdmin(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
// Validate scheduled publish date
|
||||||
|
var scheduledAt *time.Time
|
||||||
|
if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if parsed.Before(time.Now()) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduledAt = &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
var createdBy *uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "review" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four-eyes principle: DSB cannot approve their own version
|
||||||
|
// Exception: Admins can approve their own versions for development/testing purposes
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
roleStr, _ := role.(string)
|
||||||
|
if createdBy != nil && *createdBy == userID && roleStr != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine new status: 'scheduled' if date set, otherwise 'approved'
|
||||||
|
newStatus := "approved"
|
||||||
|
if scheduledAt != nil {
|
||||||
|
newStatus = "scheduled"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to approved/scheduled
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID, newStatus, userID, scheduledAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log approval action
|
||||||
|
comment := req.Comment
|
||||||
|
if comment == "" {
|
||||||
|
if scheduledAt != nil {
|
||||||
|
comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04")
|
||||||
|
} else {
|
||||||
|
comment = "Approved by DSB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||||
|
VALUES ($1, $2, 'approved', $3)
|
||||||
|
`, versionID, userID, comment)
|
||||||
|
|
||||||
|
h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent)
|
||||||
|
|
||||||
|
response := gin.H{"message": "Version approved", "status": newStatus}
|
||||||
|
if scheduledAt != nil {
|
||||||
|
response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectVersion rejects a version (DSB only)
|
||||||
|
func (h *Handler) AdminRejectVersion(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is DSB
|
||||||
|
if !middleware.IsDSB(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Comment string `json:"comment" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := middleware.GetUserID(c)
|
||||||
|
ctx := context.Background()
|
||||||
|
ipAddress := middleware.GetClientIP(c)
|
||||||
|
userAgent := middleware.GetUserAgent(c)
|
||||||
|
|
||||||
|
// Check current status
|
||||||
|
var status string
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status != "review" && status != "approved" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status back to draft
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'draft', approved_by = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log rejection
|
||||||
|
_, err = h.db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||||
|
VALUES ($1, $2, 'rejected', $3)
|
||||||
|
`, versionID, userID, req.Comment)
|
||||||
|
|
||||||
|
h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCompareVersions returns two versions for side-by-side comparison
|
||||||
|
func (h *Handler) AdminCompareVersions(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get the current version and its document
|
||||||
|
var currentVersion models.DocumentVersion
|
||||||
|
var documentID uuid.UUID
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language,
|
||||||
|
¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status,
|
||||||
|
¤tVersion.CreatedAt, ¤tVersion.UpdatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the currently published version (if any)
|
||||||
|
var publishedVersion *models.DocumentVersion
|
||||||
|
var pv models.DocumentVersion
|
||||||
|
err = h.db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE document_id = $1 AND language = $2 AND status = 'published'
|
||||||
|
ORDER BY published_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language,
|
||||||
|
&pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt)
|
||||||
|
|
||||||
|
if err == nil && pv.ID != currentVersion.ID {
|
||||||
|
publishedVersion = &pv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get approval history
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT va.action, va.comment, va.created_at, u.email
|
||||||
|
FROM version_approvals va
|
||||||
|
LEFT JOIN users u ON va.approver_id = u.id
|
||||||
|
WHERE va.version_id = $1
|
||||||
|
ORDER BY va.created_at DESC
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
var approvalHistory []map[string]interface{}
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var action, email string
|
||||||
|
var comment *string
|
||||||
|
var createdAt time.Time
|
||||||
|
if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil {
|
||||||
|
approvalHistory = append(approvalHistory, map[string]interface{}{
|
||||||
|
"action": action,
|
||||||
|
"comment": comment,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"approver": email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"current_version": currentVersion,
|
||||||
|
"published_version": publishedVersion,
|
||||||
|
"approval_history": approvalHistory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetApprovalHistory returns the approval history for a version
|
||||||
|
func (h *Handler) AdminGetApprovalHistory(c *gin.Context) {
|
||||||
|
versionID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name
|
||||||
|
FROM version_approvals va
|
||||||
|
LEFT JOIN users u ON va.approver_id = u.id
|
||||||
|
WHERE va.version_id = $1
|
||||||
|
ORDER BY va.created_at DESC
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var history []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var action string
|
||||||
|
var comment *string
|
||||||
|
var createdAt time.Time
|
||||||
|
var email, name *string
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
history = append(history, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"action": action,
|
||||||
|
"comment": comment,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"approver": email,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"approval_history": history})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SCHEDULED PUBLISHING
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ProcessScheduledPublishing publishes all versions that are due
|
||||||
|
// This should be called by a cron job or scheduler
|
||||||
|
func (h *Handler) ProcessScheduledPublishing(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Find all scheduled versions that are due
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT id, document_id, version
|
||||||
|
FROM document_versions
|
||||||
|
WHERE status = 'scheduled'
|
||||||
|
AND scheduled_publish_at IS NOT NULL
|
||||||
|
AND scheduled_publish_at <= NOW()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var published []string
|
||||||
|
for rows.Next() {
|
||||||
|
var versionID, docID uuid.UUID
|
||||||
|
var version string
|
||||||
|
if err := rows.Scan(&versionID, &docID, &version); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish this version
|
||||||
|
_, err := h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'published', published_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, versionID)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Archive previous published versions for this document
|
||||||
|
h.db.Pool.Exec(ctx, `
|
||||||
|
UPDATE document_versions
|
||||||
|
SET status = 'archived', updated_at = NOW()
|
||||||
|
WHERE document_id = $1 AND id != $2 AND status = 'published'
|
||||||
|
`, docID, versionID)
|
||||||
|
|
||||||
|
// Log the publishing
|
||||||
|
details := fmt.Sprintf("Version %s automatically published by scheduler", version)
|
||||||
|
h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler")
|
||||||
|
|
||||||
|
published = append(published, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Scheduled publishing processed",
|
||||||
|
"published_count": len(published),
|
||||||
|
"published_versions": published,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScheduledVersions returns all versions scheduled for publishing
|
||||||
|
func (h *Handler) GetScheduledVersions(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rows, err := h.db.Pool.Query(ctx, `
|
||||||
|
SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name
|
||||||
|
FROM document_versions dv
|
||||||
|
JOIN legal_documents ld ON ld.id = dv.document_id
|
||||||
|
WHERE dv.status = 'scheduled'
|
||||||
|
AND dv.scheduled_publish_at IS NOT NULL
|
||||||
|
ORDER BY dv.scheduled_publish_at ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type ScheduledVersion struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
DocumentID uuid.UUID `json:"document_id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ScheduledPublishAt *time.Time `json:"scheduled_publish_at"`
|
||||||
|
DocumentName string `json:"document_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions []ScheduledVersion
|
||||||
|
for rows.Next() {
|
||||||
|
var v ScheduledVersion
|
||||||
|
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user