Compare commits
542 Commits
1527f4ffe7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e890b1490a | ||
|
|
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)
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
### Zwei-Rechner-Setup + Orca
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **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
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
git push origin main && git push gitea main
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
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 && /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
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && <cmd>"
|
||||
@@ -51,6 +95,14 @@ networks:
|
||||
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)
|
||||
@@ -161,7 +213,7 @@ networks:
|
||||
| `compliance` | Compliance | compliance_*, dsr, gdpr, sdk_tenants, consent_admin |
|
||||
|
||||
```bash
|
||||
# DB-Zugang
|
||||
# DB-Zugang (lokal)
|
||||
ssh macmini "docker exec bp-core-postgres psql -U breakpilot -d breakpilot_db"
|
||||
```
|
||||
|
||||
@@ -185,15 +237,45 @@ breakpilot-core/
|
||||
├── gitea/ # Gitea Config
|
||||
├── docs-src/ # MkDocs Quellen
|
||||
├── mkdocs.yml # MkDocs Config
|
||||
├── control-pipeline/ # RAG/Control Pipeline (Port 8098)
|
||||
├── scripts/ # Helper Scripts
|
||||
└── 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
|
||||
|
||||
### 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
|
||||
# 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).
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT!):
|
||||
ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-core && git push all main"
|
||||
git push origin main
|
||||
|
||||
# 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
|
||||
|
||||
### 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_ADMIN_PASSWORD=admin
|
||||
|
||||
# Woodpecker CI
|
||||
WOODPECKER_HOST=http://macmini:8090
|
||||
WOODPECKER_ADMIN=pilotadmin
|
||||
WOODPECKER_AGENT_SECRET=woodpecker-secret
|
||||
|
||||
# Gitea Runner
|
||||
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 fastapi uvicorn pydantic pytest pytest-asyncio
|
||||
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/
|
||||
*.pem
|
||||
*.key
|
||||
.mcp.json
|
||||
|
||||
# Node
|
||||
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
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Ensure public directory exists
|
||||
RUN mkdir -p public
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
@@ -30,8 +33,8 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup -S -g 1001 nodejs
|
||||
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||
|
||||
# Copy built assets
|
||||
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: 'Mailpit', status: 'unknown' },
|
||||
{ name: 'Gitea', status: 'unknown' },
|
||||
{ name: 'Woodpecker CI', 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 { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
interface MiddlewareConfig {
|
||||
id: string
|
||||
middleware_name: string
|
||||
enabled: boolean
|
||||
config: Record<string, unknown>
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
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 }>
|
||||
}
|
||||
import type { MiddlewareConfig, RateLimitIP, MiddlewareEvent, MiddlewareStats } from './types'
|
||||
import { StatusOverview } from './_components/StatusOverview'
|
||||
import { OverviewTab } from './_components/OverviewTab'
|
||||
import { ConfigTab } from './_components/ConfigTab'
|
||||
import { IpListTab } from './_components/IpListTab'
|
||||
import { EventsTab } from './_components/EventsTab'
|
||||
import { StatsTab } from './_components/StatsTab'
|
||||
|
||||
export default function MiddlewareAdminPage() {
|
||||
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 blacklistCount = ipList.filter(ip => ip.list_type === 'blacklist').length
|
||||
|
||||
@@ -232,38 +176,14 @@ export default function MiddlewareAdminPage() {
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<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={fetchData}
|
||||
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">{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>
|
||||
<StatusOverview
|
||||
configCount={configs.length}
|
||||
whitelistCount={whitelistCount}
|
||||
blacklistCount={blacklistCount}
|
||||
eventCount={events.length}
|
||||
loading={loading}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mb-6">
|
||||
@@ -298,332 +218,28 @@ export default function MiddlewareAdminPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<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={() => 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>
|
||||
<OverviewTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab */}
|
||||
{activeTab === 'config' && (
|
||||
<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={() => 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>
|
||||
<ConfigTab configs={configs} actionLoading={actionLoading} onToggle={toggleMiddleware} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP List Tab */}
|
||||
{activeTab === 'ip-list' && (
|
||||
<div>
|
||||
{/* Add IP Form */}
|
||||
<form onSubmit={addIP} 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 => setNewIP(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"
|
||||
<IpListTab
|
||||
ipList={ipList}
|
||||
actionLoading={actionLoading}
|
||||
newIP={newIP}
|
||||
newIPType={newIPType}
|
||||
newIPReason={newIPReason}
|
||||
onNewIPChange={setNewIP}
|
||||
onNewIPTypeChange={setNewIPType}
|
||||
onNewIPReasonChange={setNewIPReason}
|
||||
onAddIP={addIP}
|
||||
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>
|
||||
|
||||
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'
|
||||
@@ -3,255 +3,51 @@
|
||||
/**
|
||||
* SBOM (Software Bill of Materials) Admin Page
|
||||
*
|
||||
* Migriert von /admin/sbom (website) nach /infrastructure/sbom (admin-v2)
|
||||
*
|
||||
* Displays:
|
||||
* - All infrastructure components (Docker services)
|
||||
* - Python/Go dependencies
|
||||
* - Node.js packages
|
||||
* - License information
|
||||
* - Version tracking
|
||||
* Displays all infrastructure components, dependencies, and license information.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
|
||||
import type { Component, SBOMData, CategoryType, InfoTabType } from './types'
|
||||
import {
|
||||
INFRASTRUCTURE_COMPONENTS, SECURITY_TOOLS,
|
||||
PYTHON_PACKAGES, GO_MODULES, NODE_PACKAGES, UNITY_PACKAGES, CSHARP_PACKAGES,
|
||||
} from './_components/sbom-data'
|
||||
|
||||
interface Component {
|
||||
type: string
|
||||
name: string
|
||||
version: string
|
||||
purl?: string
|
||||
licenses?: { license: { id: string } }[]
|
||||
category?: string
|
||||
port?: string
|
||||
description?: string
|
||||
license?: string
|
||||
sourceUrl?: string
|
||||
}
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
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 }
|
||||
const getCategoryColor = (category?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
database: 'bg-blue-100 text-blue-800', security: 'bg-purple-100 text-purple-800',
|
||||
'security-tool': 'bg-red-100 text-red-800', application: 'bg-green-100 text-green-800',
|
||||
communication: 'bg-yellow-100 text-yellow-800', storage: 'bg-orange-100 text-orange-800',
|
||||
search: 'bg-pink-100 text-pink-800', erp: 'bg-indigo-100 text-indigo-800',
|
||||
cache: 'bg-cyan-100 text-cyan-800', ai: 'bg-violet-100 text-violet-800',
|
||||
development: 'bg-gray-100 text-gray-800', cicd: 'bg-orange-100 text-orange-800',
|
||||
python: 'bg-emerald-100 text-emerald-800', go: 'bg-sky-100 text-sky-800',
|
||||
nodejs: 'bg-lime-100 text-lime-800', unity: 'bg-amber-100 text-amber-800',
|
||||
csharp: 'bg-fuchsia-100 text-fuchsia-800', game: 'bg-rose-100 text-rose-800',
|
||||
voice: 'bg-teal-100 text-teal-800', qa: 'bg-blue-100 text-blue-800',
|
||||
}
|
||||
components?: Component[]
|
||||
return map[category || ''] || 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
|
||||
type CategoryType = 'all' | 'infrastructure' | 'security-tools' | 'python' | 'go' | 'nodejs' | 'unity' | 'csharp'
|
||||
type InfoTabType = 'audit' | 'documentation'
|
||||
const getLicenseColor = (license?: string) => {
|
||||
if (!license) return 'bg-gray-100 text-gray-600'
|
||||
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
|
||||
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
|
||||
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
|
||||
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
// Infrastructure components from docker-compose.yml and project analysis
|
||||
const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||
// ===== DATABASES =====
|
||||
{ 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' },
|
||||
|
||||
// ===== CACHE & QUEUE =====
|
||||
{ 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' },
|
||||
|
||||
// ===== SEARCH ENGINES =====
|
||||
{ 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' },
|
||||
|
||||
// ===== OBJECT STORAGE =====
|
||||
{ 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: '-' },
|
||||
|
||||
// ===== SECURITY =====
|
||||
{ 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' },
|
||||
|
||||
// ===== COMMUNICATION =====
|
||||
{ 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' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Python) =====
|
||||
{ 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: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Go) =====
|
||||
{ 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: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Node.js) =====
|
||||
{ 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: '-' },
|
||||
|
||||
// ===== APPLICATION SERVICES (Vue) =====
|
||||
{ type: 'service', name: 'Creator Studio (Vue 3)', version: '3.4', category: 'application', port: '-', description: 'Content Creation UI', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== AI/LLM SERVICES =====
|
||||
{ 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' },
|
||||
|
||||
// ===== ERP =====
|
||||
{ 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' },
|
||||
|
||||
// ===== CI/CD & VERSION CONTROL =====
|
||||
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
|
||||
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', 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' },
|
||||
|
||||
// ===== DEVELOPMENT =====
|
||||
{ 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' },
|
||||
|
||||
// ===== GAME (Breakpilot Drive) =====
|
||||
{ type: 'service', name: 'Breakpilot Drive (Unity WebGL)', version: '6000.0', category: 'game', port: '3001', description: 'Lernspiel fuer Schueler (Klasse 2-6)', license: 'Proprietary', sourceUrl: '-' },
|
||||
|
||||
// ===== VOICE SERVICE =====
|
||||
{ type: 'service', name: 'Voice Service (FastAPI)', version: '1.0', category: 'voice', port: '8091', description: 'Voice-First Interface mit PersonaPlex-7B & TaskOrchestrator', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'PersonaPlex-7B (NVIDIA)', version: '7B', category: 'voice', port: '8998', description: 'Full-Duplex Speech-to-Speech (Produktion)', license: 'MIT/NVIDIA Open Model', sourceUrl: 'https://developer.nvidia.com' },
|
||||
{ type: 'service', name: 'TaskOrchestrator', version: '1.0', category: 'voice', port: '-', description: 'Agent-Orchestrierung mit Task State Machine', license: 'Proprietary', sourceUrl: '-' },
|
||||
{ type: 'service', name: 'Mimi Audio Codec', version: '1.0', category: 'voice', port: '-', description: 'Audio Streaming (24kHz, 80ms Frames)', license: 'MIT', sourceUrl: '-' },
|
||||
|
||||
// ===== BQAS (Quality Assurance) =====
|
||||
{ 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: '-' },
|
||||
]
|
||||
|
||||
// Security Tools discovered in project
|
||||
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' },
|
||||
]
|
||||
|
||||
// Key Python packages (from requirements.txt)
|
||||
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 für 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' },
|
||||
]
|
||||
|
||||
// Key Go modules (from go.mod files)
|
||||
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' },
|
||||
]
|
||||
|
||||
// Key Node.js packages (from package.json files)
|
||||
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' },
|
||||
]
|
||||
|
||||
// Unity packages (Breakpilot Drive game engine)
|
||||
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' },
|
||||
]
|
||||
|
||||
// C# dependencies (Breakpilot Drive)
|
||||
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' },
|
||||
]
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export default function SBOMPage() {
|
||||
const [sbomData, setSbomData] = useState<SBOMData | null>(null)
|
||||
@@ -262,136 +58,45 @@ export default function SBOMPage() {
|
||||
const [showFullDocs, setShowFullDocs] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSBOM()
|
||||
}, [])
|
||||
|
||||
const loadSBOM = async () => {
|
||||
(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/security/sbom')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSbomData(data)
|
||||
}
|
||||
if (res.ok) setSbomData(await res.json())
|
||||
} catch (error) {
|
||||
console.error('Failed to load SBOM:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const getAllComponents = (): Component[] => {
|
||||
const infraComponents = INFRASTRUCTURE_COMPONENTS.map(c => ({
|
||||
...c,
|
||||
category: c.category || 'infrastructure'
|
||||
}))
|
||||
|
||||
const securityToolsComponents = SECURITY_TOOLS.map(c => ({
|
||||
...c,
|
||||
category: c.category || 'security-tool'
|
||||
}))
|
||||
|
||||
const pythonComponents = PYTHON_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'python'
|
||||
}))
|
||||
|
||||
const goComponents = GO_MODULES.map(c => ({
|
||||
...c,
|
||||
category: 'go'
|
||||
}))
|
||||
|
||||
const nodeComponents = NODE_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'nodejs'
|
||||
}))
|
||||
|
||||
const unityComponents = UNITY_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'unity'
|
||||
}))
|
||||
|
||||
const csharpComponents = CSHARP_PACKAGES.map(c => ({
|
||||
...c,
|
||||
category: 'csharp'
|
||||
}))
|
||||
|
||||
// Add dynamic SBOM data from backend if available
|
||||
const dynamicPython = (sbomData?.components || []).map(c => ({
|
||||
...c,
|
||||
category: 'python'
|
||||
}))
|
||||
|
||||
return [...infraComponents, ...securityToolsComponents, ...pythonComponents, ...goComponents, ...nodeComponents, ...unityComponents, ...csharpComponents, ...dynamicPython]
|
||||
}
|
||||
|
||||
const getFilteredComponents = () => {
|
||||
let components = getAllComponents()
|
||||
|
||||
if (activeCategory !== 'all') {
|
||||
if (activeCategory === 'infrastructure') {
|
||||
components = INFRASTRUCTURE_COMPONENTS
|
||||
} else if (activeCategory === 'security-tools') {
|
||||
components = SECURITY_TOOLS
|
||||
} else if (activeCategory === 'python') {
|
||||
components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
|
||||
} else if (activeCategory === 'go') {
|
||||
components = GO_MODULES
|
||||
} else if (activeCategory === 'nodejs') {
|
||||
components = NODE_PACKAGES
|
||||
} else if (activeCategory === 'unity') {
|
||||
components = UNITY_PACKAGES
|
||||
} else if (activeCategory === 'csharp') {
|
||||
components = CSHARP_PACKAGES
|
||||
}
|
||||
}
|
||||
const getFilteredComponents = (): Component[] => {
|
||||
let components: Component[]
|
||||
if (activeCategory === 'all') {
|
||||
components = [
|
||||
...INFRASTRUCTURE_COMPONENTS, ...SECURITY_TOOLS, ...PYTHON_PACKAGES,
|
||||
...GO_MODULES, ...NODE_PACKAGES, ...UNITY_PACKAGES, ...CSHARP_PACKAGES,
|
||||
...(sbomData?.components || []).map(c => ({ ...c, category: 'python' })),
|
||||
]
|
||||
} else if (activeCategory === 'infrastructure') components = INFRASTRUCTURE_COMPONENTS
|
||||
else if (activeCategory === 'security-tools') components = SECURITY_TOOLS
|
||||
else if (activeCategory === 'python') components = [...PYTHON_PACKAGES, ...(sbomData?.components || [])]
|
||||
else if (activeCategory === 'go') components = GO_MODULES
|
||||
else if (activeCategory === 'nodejs') components = NODE_PACKAGES
|
||||
else if (activeCategory === 'unity') components = UNITY_PACKAGES
|
||||
else if (activeCategory === 'csharp') components = CSHARP_PACKAGES
|
||||
else components = []
|
||||
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
components = components.filter(c =>
|
||||
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.version.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(c.description?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
c.name.toLowerCase().includes(term) || c.version.toLowerCase().includes(term) || (c.description?.toLowerCase().includes(term))
|
||||
)
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
const getCategoryColor = (category?: string) => {
|
||||
switch (category) {
|
||||
case 'database': return 'bg-blue-100 text-blue-800'
|
||||
case 'security': return 'bg-purple-100 text-purple-800'
|
||||
case 'security-tool': return 'bg-red-100 text-red-800'
|
||||
case 'application': return 'bg-green-100 text-green-800'
|
||||
case 'communication': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'storage': return 'bg-orange-100 text-orange-800'
|
||||
case 'search': return 'bg-pink-100 text-pink-800'
|
||||
case 'erp': return 'bg-indigo-100 text-indigo-800'
|
||||
case 'cache': return 'bg-cyan-100 text-cyan-800'
|
||||
case 'ai': return 'bg-violet-100 text-violet-800'
|
||||
case 'development': return 'bg-gray-100 text-gray-800'
|
||||
case 'cicd': return 'bg-orange-100 text-orange-800'
|
||||
case 'python': return 'bg-emerald-100 text-emerald-800'
|
||||
case 'go': return 'bg-sky-100 text-sky-800'
|
||||
case 'nodejs': return 'bg-lime-100 text-lime-800'
|
||||
case 'unity': return 'bg-amber-100 text-amber-800'
|
||||
case 'csharp': return 'bg-fuchsia-100 text-fuchsia-800'
|
||||
case 'game': return 'bg-rose-100 text-rose-800'
|
||||
case 'voice': return 'bg-teal-100 text-teal-800'
|
||||
case 'qa': return 'bg-blue-100 text-blue-800'
|
||||
default: return 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getLicenseColor = (license?: string) => {
|
||||
if (!license) return 'bg-gray-100 text-gray-600'
|
||||
if (license.includes('MIT')) return 'bg-green-100 text-green-700'
|
||||
if (license.includes('Apache')) return 'bg-blue-100 text-blue-700'
|
||||
if (license.includes('BSD')) return 'bg-cyan-100 text-cyan-700'
|
||||
if (license.includes('GPL') || license.includes('LGPL')) return 'bg-orange-100 text-orange-700'
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalInfra: INFRASTRUCTURE_COMPONENTS.length,
|
||||
totalSecurityTools: SECURITY_TOOLS.length,
|
||||
@@ -402,8 +107,6 @@ export default function SBOMPage() {
|
||||
totalCsharp: CSHARP_PACKAGES.length,
|
||||
totalAll: INFRASTRUCTURE_COMPONENTS.length + SECURITY_TOOLS.length + PYTHON_PACKAGES.length + GO_MODULES.length + NODE_PACKAGES.length + UNITY_PACKAGES.length + CSHARP_PACKAGES.length + (sbomData?.components?.length || 0),
|
||||
databases: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'database').length,
|
||||
services: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'application').length,
|
||||
communication: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'communication').length,
|
||||
game: INFRASTRUCTURE_COMPONENTS.filter(c => c.category === 'game').length,
|
||||
}
|
||||
|
||||
@@ -422,112 +125,55 @@ export default function SBOMPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PagePurpose
|
||||
title="SBOM"
|
||||
purpose="Software Bill of Materials - Alle Komponenten & Abhaengigkeiten der Breakpilot-Plattform. Wichtig fuer Supply-Chain-Security, Compliance-Audits und Lizenz-Pruefung."
|
||||
audience={['DevOps', 'Compliance', 'Security', 'Auditoren']}
|
||||
gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
|
||||
architecture={{
|
||||
services: ['Syft (SBOM Generator)', 'Trivy (CVE Scanner)'],
|
||||
databases: ['CycloneDX JSON'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
||||
{ name: 'Controls', href: '/sdk/controls', description: 'Security Controls' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
<PagePurpose title="SBOM" purpose="Software Bill of Materials - Alle Komponenten & Abhaengigkeiten der Breakpilot-Plattform." audience={['DevOps', 'Compliance', 'Security', 'Auditoren']} gdprArticles={['Art. 32 (Sicherheit der Verarbeitung)']}
|
||||
architecture={{ services: ['Syft (SBOM Generator)', 'Trivy (CVE Scanner)'], databases: ['CycloneDX JSON'] }}
|
||||
relatedPages={[{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' }, { name: 'Controls', href: '/sdk/controls', description: 'Security Controls' }]}
|
||||
collapsible={true} defaultCollapsed={true} />
|
||||
|
||||
{/* DevOps Pipeline Sidebar */}
|
||||
<DevOpsPipelineSidebarResponsive currentTool="sbom" />
|
||||
|
||||
{/* Wizard Link */}
|
||||
<div className="mb-6 flex justify-end">
|
||||
<Link
|
||||
href="/infrastructure/sbom/wizard"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium bg-purple-100 text-purple-700 border border-purple-200 hover:bg-purple-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.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>
|
||||
<Link href="/infrastructure/sbom/wizard" className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium bg-purple-100 text-purple-700 border border-purple-200 hover:bg-purple-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.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>
|
||||
Lern-Wizard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 lg:grid-cols-10 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-slate-800">{stats.totalAll}</div>
|
||||
<div className="text-sm text-slate-500">Komponenten Total</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.totalInfra}</div>
|
||||
<div className="text-sm text-slate-500">Docker Services</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-red-600">{stats.totalSecurityTools}</div>
|
||||
<div className="text-sm text-slate-500">Security Tools</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-emerald-600">{stats.totalPython}</div>
|
||||
<div className="text-sm text-slate-500">Python</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-sky-600">{stats.totalGo}</div>
|
||||
<div className="text-sm text-slate-500">Go</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-lime-600">{stats.totalNode}</div>
|
||||
<div className="text-sm text-slate-500">Node.js</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-amber-600">{stats.totalUnity}</div>
|
||||
<div className="text-sm text-slate-500">Unity</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-fuchsia-600">{stats.totalCsharp}</div>
|
||||
<div className="text-sm text-slate-500">C#</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.databases}</div>
|
||||
<div className="text-sm text-slate-500">Datenbanken</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-3xl font-bold text-rose-600">{stats.game}</div>
|
||||
<div className="text-sm text-slate-500">Game</div>
|
||||
{[
|
||||
{ value: stats.totalAll, label: 'Komponenten Total', color: 'text-slate-800' },
|
||||
{ value: stats.totalInfra, label: 'Docker Services', color: 'text-purple-600' },
|
||||
{ value: stats.totalSecurityTools, label: 'Security Tools', color: 'text-red-600' },
|
||||
{ value: stats.totalPython, label: 'Python', color: 'text-emerald-600' },
|
||||
{ value: stats.totalGo, label: 'Go', color: 'text-sky-600' },
|
||||
{ value: stats.totalNode, label: 'Node.js', color: 'text-lime-600' },
|
||||
{ value: stats.totalUnity, label: 'Unity', color: 'text-amber-600' },
|
||||
{ value: stats.totalCsharp, label: 'C#', color: 'text-fuchsia-600' },
|
||||
{ value: stats.databases, label: 'Datenbanken', color: 'text-blue-600' },
|
||||
{ value: stats.game, label: 'Game', color: 'text-rose-600' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-white rounded-lg shadow p-4">
|
||||
<div className={`text-3xl font-bold ${s.color}`}>{s.value}</div>
|
||||
<div className="text-sm text-slate-500">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
{/* Category Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id as CategoryType)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-orange-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<button key={cat.id} onClick={() => setActiveCategory(cat.id as CategoryType)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeCategory === cat.id ? 'bg-orange-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
|
||||
{cat.name} ({cat.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
/>
|
||||
<input type="text" placeholder="Suchen..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent" />
|
||||
<svg className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
@@ -539,22 +185,9 @@ export default function SBOMPage() {
|
||||
{sbomData?.metadata && (
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-6 text-sm">
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div>
|
||||
<span className="text-slate-500">Format:</span>
|
||||
<span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Generiert:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Anwendung:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}
|
||||
</span>
|
||||
</div>
|
||||
<div><span className="text-slate-500">Format:</span><span className="ml-2 font-medium">{sbomData.bomFormat} {sbomData.specVersion}</span></div>
|
||||
<div><span className="text-slate-500">Generiert:</span><span className="ml-2 font-medium">{sbomData.metadata.timestamp ? new Date(sbomData.metadata.timestamp).toLocaleString('de-DE') : '-'}</span></div>
|
||||
<div><span className="text-slate-500">Anwendung:</span><span className="ml-2 font-medium">{sbomData.metadata.component?.name} v{sbomData.metadata.component?.version}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -581,96 +214,38 @@ export default function SBOMPage() {
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredComponents.map((component, idx) => {
|
||||
// Get license from either the new license field or the old licenses array
|
||||
const licenseId = component.license || component.licenses?.[0]?.license?.id
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-4">
|
||||
<div className="text-sm font-medium text-gray-900">{component.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-gray-900">{component.version}</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>
|
||||
{component.category || component.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{component.description ? (
|
||||
<div className="text-sm text-gray-600 leading-relaxed">{component.description}</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">Keine Beschreibung</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.port ? (
|
||||
<span className="text-sm font-mono text-gray-600">{component.port}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{licenseId ? (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>
|
||||
{licenseId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{component.sourceUrl && component.sourceUrl !== '-' ? (
|
||||
<a
|
||||
href={component.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-600 hover:text-orange-800 text-sm flex items-center gap-1"
|
||||
>
|
||||
<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>
|
||||
GitHub
|
||||
<td className="px-4 py-4"><div className="text-sm font-medium text-gray-900">{component.name}</div></td>
|
||||
<td className="px-4 py-4 whitespace-nowrap"><span className="text-sm font-mono text-gray-900">{component.version}</span></td>
|
||||
<td className="px-4 py-4 whitespace-nowrap"><span className={`px-2 py-1 text-xs font-medium rounded ${getCategoryColor(component.category)}`}>{component.category || component.type}</span></td>
|
||||
<td className="px-4 py-4">{component.description ? <div className="text-sm text-gray-600 leading-relaxed">{component.description}</div> : <span className="text-sm text-gray-400 italic">Keine Beschreibung</span>}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">{component.port ? <span className="text-sm font-mono text-gray-600">{component.port}</span> : <span className="text-sm text-gray-400">-</span>}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">{licenseId ? <span className={`px-2 py-1 text-xs font-medium rounded ${getLicenseColor(licenseId)}`}>{licenseId}</span> : <span className="text-sm text-gray-400">-</span>}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">{component.sourceUrl && component.sourceUrl !== '-' ? (
|
||||
<a href={component.sourceUrl} target="_blank" rel="noopener noreferrer" className="text-orange-600 hover:text-orange-800 text-sm flex items-center gap-1">
|
||||
<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>GitHub
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
) : <span className="text-sm text-gray-400">-</span>}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredComponents.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Keine Komponenten gefunden.
|
||||
</div>
|
||||
)}
|
||||
{filteredComponents.length === 0 && <div className="p-8 text-center text-gray-500">Keine Komponenten gefunden.</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
const data = JSON.stringify({
|
||||
...sbomData,
|
||||
infrastructure: INFRASTRUCTURE_COMPONENTS
|
||||
}, null, 2)
|
||||
<button onClick={() => {
|
||||
const data = JSON.stringify({ ...sbomData, infrastructure: INFRASTRUCTURE_COMPONENTS }, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
}}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
const a = document.createElement('a'); a.href = url; a.download = `breakpilot-sbom-${new Date().toISOString().split('T')[0]}.json`; a.click()
|
||||
}} className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
SBOM exportieren (JSON)
|
||||
</button>
|
||||
</div>
|
||||
@@ -678,237 +253,85 @@ export default function SBOMPage() {
|
||||
{/* Info Tabs Section */}
|
||||
<div className="mt-8">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Tab Headers */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveInfoTab('audit')}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeInfoTab === 'audit'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{[{ id: 'audit' as const, label: 'Audit', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, { id: 'documentation' as const, label: 'Dokumentation', icon: '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' }].map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveInfoTab(tab.id)}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${activeInfoTab === tab.id ? 'border-orange-500 text-orange-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
|
||||
<span className="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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Audit
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveInfoTab('documentation')}
|
||||
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeInfoTab === 'documentation'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="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 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>
|
||||
Dokumentation
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={tab.icon} /></svg>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{/* Audit Tab */}
|
||||
{activeInfoTab === 'audit' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* SBOM Status */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 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>
|
||||
SBOM Status
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">SBOM Status</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Letzte Generierung</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">CI/CD</span>
|
||||
</span>
|
||||
{['Letzte Generierung', 'Format', 'Komponenten', 'Transitive Deps'].map((label) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">{label}</span>
|
||||
<span className="flex items-center gap-2"><span className="w-2 h-2 rounded-full bg-green-500"></span><span className="text-sm font-medium">{label === 'Letzte Generierung' ? 'CI/CD' : label === 'Format' ? 'CycloneDX 1.5' : label === 'Komponenten' ? 'Alle erfasst' : 'Inkludiert'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Format</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">CycloneDX 1.5</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Komponenten</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">Alle erfasst</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Transitive Deps</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">Inkludiert</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License Compliance */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
License Compliance
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">License Compliance</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Erlaubte Lizenzen</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">MIT, Apache, BSD</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Copyleft (GPL)</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Unbekannte Lizenzen</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
<span className="text-sm font-medium">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Kommerzielle</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500"></span>
|
||||
<span className="text-sm font-medium">Review erforderlich</span>
|
||||
</span>
|
||||
{[{ label: 'Erlaubte Lizenzen', value: 'MIT, Apache, BSD', ok: true }, { label: 'Copyleft (GPL)', value: '0', ok: true }, { label: 'Unbekannte Lizenzen', value: '0', ok: true }, { label: 'Kommerzielle', value: 'Review erforderlich', ok: false }].map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">{item.label}</span>
|
||||
<span className="flex items-center gap-2"><span className={`w-2 h-2 rounded-full ${item.ok ? 'bg-green-500' : 'bg-yellow-500'}`}></span><span className="text-sm font-medium">{item.value}</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Tab */}
|
||||
{activeInfoTab === 'documentation' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800">SBOM 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"
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{!showFullDocs ? (
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<p className="text-slate-600">
|
||||
Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.
|
||||
Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.
|
||||
</p>
|
||||
<p className="text-slate-600">Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Generator</h4>
|
||||
<p className="text-sm text-blue-600">Syft (Primary), Trivy (Validation)</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-purple-800 mb-2">Format</h4>
|
||||
<p className="text-sm text-purple-600">CycloneDX 1.5, SPDX</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h4 className="font-medium text-green-800 mb-2">Retention</h4>
|
||||
<p className="text-sm text-green-600">5 Jahre (Compliance)</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg"><h4 className="font-medium text-blue-800 mb-2">Generator</h4><p className="text-sm text-blue-600">Syft (Primary), Trivy (Validation)</p></div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg"><h4 className="font-medium text-purple-800 mb-2">Format</h4><p className="text-sm text-purple-600">CycloneDX 1.5, SPDX</p></div>
|
||||
<div className="bg-green-50 p-4 rounded-lg"><h4 className="font-medium text-green-800 mb-2">Retention</h4><p className="text-sm text-green-600">5 Jahre (Compliance)</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-slate max-w-none bg-slate-50 p-6 rounded-lg overflow-auto max-h-[600px]">
|
||||
{showFullDocs && (
|
||||
<div className="mt-6 bg-slate-50 rounded-lg p-6 border border-slate-200 prose prose-slate max-w-none overflow-auto max-h-[600px]">
|
||||
<h2>Software Bill of Materials (SBOM)</h2>
|
||||
|
||||
<h3>1. Uebersicht</h3>
|
||||
<p>Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten. Es dient der Compliance, Sicherheit und Supply-Chain-Transparenz.</p>
|
||||
|
||||
<h3>2. SBOM-Generierung</h3>
|
||||
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`Source Code
|
||||
│
|
||||
v
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ SBOM Generators │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Syft │ │ Trivy │ │ Native Tooling │ │
|
||||
│ │ (Primary) │ │ (Validation)│ │ (npm, go mod, pip) │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
|
||||
└─────────┼────────────────┼────────────────────┼───────────────┘
|
||||
│ │ │
|
||||
└────────────────┴────────────────────┘
|
||||
│
|
||||
v
|
||||
┌────────────────┐
|
||||
│ CycloneDX │
|
||||
│ Format │
|
||||
└────────────────┘`}
|
||||
</pre>
|
||||
|
||||
<h3>3. Erfasste Komponenten</h3>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Typ</th>
|
||||
<th className="text-left">Quelle</th>
|
||||
<th className="text-left">Beispiele</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<p>Das SBOM-Modul generiert und analysiert die vollstaendige Komponentenliste aller Software-Abhaengigkeiten.</p>
|
||||
<h3>2. Erfasste Komponenten</h3>
|
||||
<table className="min-w-full"><thead><tr><th className="text-left">Typ</th><th className="text-left">Quelle</th><th className="text-left">Beispiele</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>npm packages</td><td>package-lock.json</td><td>react, next, tailwindcss</td></tr>
|
||||
<tr><td>Go modules</td><td>go.sum</td><td>gin, gorm, jwt-go</td></tr>
|
||||
<tr><td>Python packages</td><td>requirements.txt</td><td>fastapi, pydantic, httpx</td></tr>
|
||||
<tr><td>Container Images</td><td>Dockerfile</td><td>node:20-alpine, postgres:16</td></tr>
|
||||
<tr><td>OS Packages</td><td>apk, apt</td><td>openssl, libpq</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. License Compliance</h3>
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Kategorie</th>
|
||||
<th className="text-left">Lizenzen</th>
|
||||
<th className="text-left">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<h3>3. License Compliance</h3>
|
||||
<table className="min-w-full"><thead><tr><th className="text-left">Kategorie</th><th className="text-left">Lizenzen</th><th className="text-left">Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Permissive (erlaubt)</td><td>MIT, Apache 2.0, BSD, ISC</td><td className="text-green-600">OK</td></tr>
|
||||
<tr><td>Permissive</td><td>MIT, Apache 2.0, BSD, ISC</td><td className="text-green-600">OK</td></tr>
|
||||
<tr><td>Weak Copyleft</td><td>LGPL, MPL</td><td className="text-yellow-600">Review</td></tr>
|
||||
<tr><td>Strong Copyleft</td><td>GPL, AGPL</td><td className="text-red-600">Nicht erlaubt</td></tr>
|
||||
<tr><td>Proprietaer</td><td>Commercial</td><td className="text-yellow-600">Genehmigung</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>5. Aufbewahrung & Compliance</h3>
|
||||
<ul>
|
||||
<li><strong>Retention:</strong> 5 Jahre (Compliance)</li>
|
||||
<li><strong>Format:</strong> JSON + PDF Report</li>
|
||||
<li><strong>Signierung:</strong> Digital signiert</li>
|
||||
<li><strong>Audit:</strong> Jederzeit abrufbar</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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
|
||||
{ name: 'Backend API', port: 8000, endpoint: '/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: 'Mail Service (Mailpit)', port: 8025, endpoint: '/api/v1/info', 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> = {
|
||||
8000: 'backend',
|
||||
8081: 'consent-service',
|
||||
8091: 'voice-service',
|
||||
8086: 'klausur-service',
|
||||
8025: 'mailpit',
|
||||
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 { useState, useEffect } from 'react'
|
||||
import type {
|
||||
DevOpsToolId,
|
||||
DevOpsPipelineSidebarProps,
|
||||
DevOpsPipelineSidebarResponsiveProps,
|
||||
PipelineLiveStatus,
|
||||
} from '@/types/infrastructure-modules'
|
||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
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>
|
||||
)
|
||||
}
|
||||
import {
|
||||
ToolIcon,
|
||||
ServerIcon,
|
||||
PlayIcon,
|
||||
StatusBadge,
|
||||
usePipelineLiveStatus,
|
||||
} from './DevOpsPipelineSidebarParts'
|
||||
|
||||
// =============================================================================
|
||||
// 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="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentTool === 'ci-cd' && (
|
||||
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
|
||||
<span>Verwalten Sie Gitea Actions Pipelines und Deployments</span>
|
||||
)}
|
||||
{currentTool === 'tests' && (
|
||||
<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">
|
||||
{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' && (
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
export type CategoryId = 'communication' | 'infrastructure' | 'development'
|
||||
export type CategoryId = 'infrastructure'
|
||||
|
||||
export interface NavModule {
|
||||
id: string
|
||||
@@ -27,51 +27,6 @@ export interface 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)
|
||||
// =========================================================================
|
||||
@@ -83,15 +38,6 @@ export const navigation: NavCategory[] = [
|
||||
colorClass: 'infrastructure',
|
||||
description: 'GPU, Security, CI/CD & Monitoring',
|
||||
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',
|
||||
name: 'Middleware',
|
||||
@@ -123,7 +69,7 @@ export const navigation: NavCategory[] = [
|
||||
id: 'ci-cd',
|
||||
name: 'CI/CD Dashboard',
|
||||
href: '/infrastructure/ci-cd',
|
||||
description: 'Gitea & Woodpecker Pipelines',
|
||||
description: 'Gitea Actions Pipelines',
|
||||
purpose: 'CI/CD Dashboard mit Pipelines, Deployment-Status und Container-Management.',
|
||||
audience: ['DevOps', 'Entwickler'],
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared Types & Constants for Infrastructure/DevOps Modules
|
||||
*
|
||||
* 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
|
||||
* - SBOM: Software Bill of Materials & Lizenz-Checks
|
||||
* - Security: DevSecOps Scans & Vulnerabilities
|
||||
@@ -230,24 +230,6 @@ export interface LogExtractionResponse {
|
||||
// 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
|
||||
// =============================================================================
|
||||
@@ -346,18 +328,14 @@ export interface PipelineLiveStatus {
|
||||
export const INFRASTRUCTURE_API_ENDPOINTS = {
|
||||
/** CI/CD Endpoints */
|
||||
CI_CD: {
|
||||
PIPELINES: '/api/admin/infrastructure/woodpecker',
|
||||
TRIGGER: '/api/admin/infrastructure/woodpecker/trigger',
|
||||
LOGS: '/api/admin/infrastructure/woodpecker/logs',
|
||||
PIPELINES: '/api/v1/security/sbom/pipeline/history',
|
||||
STATUS: '/api/v1/security/sbom/pipeline/status',
|
||||
TRIGGER: '/api/v1/security/sbom/pipeline/trigger',
|
||||
},
|
||||
/** Log Extraction Endpoints */
|
||||
LOG_EXTRACT: {
|
||||
EXTRACT: '/api/infrastructure/log-extract/extract',
|
||||
},
|
||||
/** Webhook Endpoints */
|
||||
WEBHOOKS: {
|
||||
WOODPECKER: '/api/webhooks/woodpecker',
|
||||
},
|
||||
/** LLM Endpoints */
|
||||
LLM: {
|
||||
ANALYZE: '/api/ai/analyze',
|
||||
@@ -375,7 +353,6 @@ export const INFRASTRUCTURE_API_ENDPOINTS = {
|
||||
*/
|
||||
export const DEVOPS_ARCHITECTURE = {
|
||||
services: [
|
||||
{ name: 'Woodpecker CI', port: 8000, description: 'CI/CD Pipeline Server' },
|
||||
{ name: 'Gitea', port: 3003, description: 'Git Repository Server' },
|
||||
{ name: 'Syft', type: 'CLI', description: 'SBOM Generator' },
|
||||
{ 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/*
|
||||
|
||||
# Install DevSecOps tools (gitleaks, trivy, grype, syft)
|
||||
ARG TARGETARCH=arm64
|
||||
ARG TARGETARCH
|
||||
RUN set -eux; \
|
||||
ARCH="${TARGETARCH:-$(dpkg --print-architecture)}"; \
|
||||
# Gitleaks
|
||||
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" \
|
||||
| tar xz -C /usr/local/bin gitleaks; \
|
||||
# Trivy
|
||||
|
||||
@@ -5,7 +5,7 @@ Hybrid authentication supporting both Keycloak and local JWT tokens.
|
||||
"""
|
||||
|
||||
from .keycloak_auth import (
|
||||
# Config
|
||||
# Config & Models
|
||||
KeycloakConfig,
|
||||
KeycloakUser,
|
||||
|
||||
@@ -18,7 +18,9 @@ from .keycloak_auth import (
|
||||
TokenExpiredError,
|
||||
TokenInvalidError,
|
||||
KeycloakConfigError,
|
||||
)
|
||||
|
||||
from .dependencies import (
|
||||
# Factory functions
|
||||
get_keycloak_config_from_env,
|
||||
get_authenticator,
|
||||
@@ -30,7 +32,7 @@ from .keycloak_auth import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
# Config & Models
|
||||
"KeycloakConfig",
|
||||
"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:
|
||||
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 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 email_template_api import (
|
||||
router as email_template_router,
|
||||
@@ -25,8 +26,6 @@ from email_template_api import (
|
||||
)
|
||||
from system_api import router as system_router
|
||||
from security_api import router as security_router
|
||||
from woodpecker_proxy_api import router as woodpecker_router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware imports
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -91,9 +90,12 @@ app.add_middleware(RateLimiterMiddleware, valkey_url=VALKEY_URL)
|
||||
# Auth (proxy to consent-service)
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
|
||||
# RBAC (teacher / role management)
|
||||
# RBAC (role / assignment / custom-role management)
|
||||
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)
|
||||
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
|
||||
app.include_router(security_router, prefix="/api")
|
||||
app.include_router(woodpecker_router, prefix="/api")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
- Listing all teachers
|
||||
- Listing all available roles
|
||||
- Assigning/revoking roles to teachers
|
||||
- Viewing role assignments per teacher
|
||||
- Listing all available roles (built-in + custom)
|
||||
- Assigning/revoking roles to users
|
||||
- Role summary with assignment counts
|
||||
- Custom role CRUD
|
||||
|
||||
Shared infrastructure (DB pool, Pydantic models, role definitions)
|
||||
used by rbac_teachers_api.py as well.
|
||||
|
||||
Architecture:
|
||||
- Authentication: Keycloak (when configured) or local JWT
|
||||
@@ -24,7 +27,8 @@ try:
|
||||
from auth import get_current_user, TokenExpiredError, TokenInvalidError
|
||||
except ImportError:
|
||||
# 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
|
||||
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")
|
||||
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
||||
"""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
|
||||
# ==========================================
|
||||
|
||||
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
|
||||
- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports
|
||||
- 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 subprocess
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Optional
|
||||
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"])
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# ===========================
|
||||
# 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
|
||||
# Include sub-routers (they share the same prefix/tags)
|
||||
router.include_router(mock_data_router, prefix="", tags=["Security"])
|
||||
router.include_router(monitoring_router, prefix="", tags=["Security"])
|
||||
|
||||
|
||||
# ===========================
|
||||
@@ -435,11 +170,15 @@ async def get_history(limit: int = 20):
|
||||
if isinstance(data, list):
|
||||
findings_count = len(data)
|
||||
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:
|
||||
status = "warning"
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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")
|
||||
|
||||
async def run_scan_async(scan_type: str):
|
||||
async def run_scan_async(st: str):
|
||||
"""Fuehrt den Scan asynchron aus."""
|
||||
try:
|
||||
if scan_type == "secrets" or scan_type == "all":
|
||||
# Gitleaks
|
||||
installed, _ = check_tool_installed("gitleaks")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
||||
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
||||
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
||||
"--report-format", "json"],
|
||||
capture_output=True,
|
||||
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
|
||||
)
|
||||
|
||||
if st in ("secrets", "all"):
|
||||
_run_secrets_scan(timestamp)
|
||||
if st in ("sast", "all"):
|
||||
_run_sast_scan(timestamp)
|
||||
if st in ("deps", "all"):
|
||||
_run_deps_scan(timestamp)
|
||||
if st in ("sbom", "all"):
|
||||
_run_sbom_scan(timestamp)
|
||||
if st in ("containers", "all"):
|
||||
_run_container_scan(timestamp)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
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]:
|
||||
"""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 _run_secrets_scan(timestamp: str):
|
||||
"""Gitleaks scan."""
|
||||
installed, _ = check_tool_installed("gitleaks")
|
||||
if installed:
|
||||
subprocess.run(
|
||||
["gitleaks", "detect", "--source", str(PROJECT_ROOT),
|
||||
"--config", str(PROJECT_ROOT / ".gitleaks.toml"),
|
||||
"--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"),
|
||||
"--report-format", "json"],
|
||||
capture_output=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
|
||||
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 _run_sast_scan(timestamp: str):
|
||||
"""Semgrep + Bandit scan."""
|
||||
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)
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
),
|
||||
]
|
||||
def _run_deps_scan(timestamp: str):
|
||||
"""Trivy filesystem + Grype 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
|
||||
)
|
||||
|
||||
|
||||
# ===========================
|
||||
# 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:
|
||||
installed, _ = check_tool_installed("grype")
|
||||
if installed:
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"],
|
||||
["grype", f"dir:{PROJECT_ROOT}", "-o", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
timeout=600
|
||||
)
|
||||
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:
|
||||
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"),
|
||||
]
|
||||
if result.stdout:
|
||||
with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f:
|
||||
f.write(result.stdout)
|
||||
|
||||
|
||||
@router.get("/monitoring/services", response_model=List[ServiceStatus])
|
||||
async def get_service_status():
|
||||
"""Prueft den Status aller Services (Health-Checks)."""
|
||||
import random
|
||||
def _run_sbom_scan(timestamp: str):
|
||||
"""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
|
||||
)
|
||||
|
||||
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:
|
||||
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
|
||||
def _run_container_scan(timestamp: str):
|
||||
"""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
|
||||
)
|
||||
|
||||
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:
|
||||
- OCR (Optical Character Recognition) für Handschrift und gedruckten Text
|
||||
Shared Service fuer:
|
||||
- OCR (Optical Character Recognition) fuer Handschrift und gedruckten Text
|
||||
- PDF-Parsing und Textextraktion
|
||||
- Bildverarbeitung und -optimierung
|
||||
- DOCX/DOC Textextraktion
|
||||
|
||||
Verwendet:
|
||||
- PaddleOCR für deutsche Handschrift
|
||||
- PyMuPDF für PDF-Verarbeitung
|
||||
- python-docx für DOCX-Dateien
|
||||
- OpenCV für Bildvorverarbeitung
|
||||
- PaddleOCR fuer deutsche Handschrift (via ImageProcessor)
|
||||
- PyMuPDF fuer PDF-Verarbeitung
|
||||
- python-docx fuer DOCX-Dateien
|
||||
- OpenCV fuer Bildvorverarbeitung (via ImageProcessor)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import io
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from .file_processor_types import (
|
||||
FileType,
|
||||
ProcessingMode,
|
||||
ProcessedRegion,
|
||||
ProcessingResult,
|
||||
)
|
||||
from .image_processing import ImageProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileType(str, Enum):
|
||||
"""Unterstützte 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]
|
||||
__all__ = [
|
||||
"FileType",
|
||||
"ProcessingMode",
|
||||
"ProcessedRegion",
|
||||
"ProcessingResult",
|
||||
"FileProcessor",
|
||||
"get_file_processor",
|
||||
"process_file",
|
||||
"extract_text_from_pdf",
|
||||
"ocr_image",
|
||||
"ocr_handwriting",
|
||||
]
|
||||
|
||||
|
||||
class FileProcessor:
|
||||
"""
|
||||
Zentrale Dokumentenverarbeitung für BreakPilot.
|
||||
Zentrale Dokumentenverarbeitung fuer BreakPilot.
|
||||
|
||||
Unterstützt:
|
||||
- Handschrifterkennung (OCR) für Klausuren
|
||||
Unterstuetzt:
|
||||
- Handschrifterkennung (OCR) fuer Klausuren
|
||||
- Textextraktion aus PDFs
|
||||
- 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):
|
||||
@@ -85,37 +61,18 @@ class FileProcessor:
|
||||
Initialisiert den File Processor.
|
||||
|
||||
Args:
|
||||
ocr_lang: Sprache für OCR (default: "de" für Deutsch)
|
||||
use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung)
|
||||
ocr_lang: Sprache fuer OCR (default: "de" fuer Deutsch)
|
||||
use_gpu: GPU fuer OCR nutzen (beschleunigt Verarbeitung)
|
||||
"""
|
||||
self.ocr_lang = ocr_lang
|
||||
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})")
|
||||
|
||||
@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', # 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:
|
||||
def detect_file_type(
|
||||
self, file_path: str = None, file_bytes: bytes = None
|
||||
) -> FileType:
|
||||
"""
|
||||
Erkennt den Dateityp.
|
||||
|
||||
@@ -170,7 +127,9 @@ class FileProcessor:
|
||||
ProcessingResult mit extrahiertem Text und Metadaten
|
||||
"""
|
||||
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)
|
||||
logger.info(f"Processing file of type: {file_type}")
|
||||
@@ -184,7 +143,7 @@ class FileProcessor:
|
||||
elif file_type == FileType.TXT:
|
||||
return self._process_txt(file_path, file_bytes)
|
||||
else:
|
||||
raise ValueError(f"Nicht unterstützter Dateityp: {file_type}")
|
||||
raise ValueError(f"Nicht unterstuetzter Dateityp: {file_type}")
|
||||
|
||||
def _process_pdf(
|
||||
self,
|
||||
@@ -197,7 +156,6 @@ class FileProcessor:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
logger.warning("PyMuPDF nicht installiert - versuche Fallback")
|
||||
# Fallback: PDF als Bild behandeln
|
||||
return self._process_image(file_path, file_bytes, mode)
|
||||
|
||||
if file_bytes:
|
||||
@@ -211,11 +169,9 @@ class FileProcessor:
|
||||
region_count = 0
|
||||
|
||||
for page_num, page in enumerate(doc, start=1):
|
||||
# Erst versuchen Text direkt zu extrahieren
|
||||
page_text = page.get_text()
|
||||
|
||||
if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING:
|
||||
# PDF enthält Text (nicht nur Bilder)
|
||||
all_text.append(page_text)
|
||||
all_regions.append(ProcessedRegion(
|
||||
text=page_text,
|
||||
@@ -227,11 +183,11 @@ class FileProcessor:
|
||||
region_count += 1
|
||||
else:
|
||||
# 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 = 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"])
|
||||
|
||||
for region in ocr_result["regions"]:
|
||||
@@ -242,7 +198,9 @@ class FileProcessor:
|
||||
|
||||
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(
|
||||
text="\n\n".join(all_text),
|
||||
@@ -266,11 +224,8 @@ class FileProcessor:
|
||||
else:
|
||||
img = Image.open(file_path)
|
||||
|
||||
# Bildvorverarbeitung
|
||||
processed_img = self._preprocess_image(img)
|
||||
|
||||
# OCR
|
||||
ocr_result = self._ocr_image(processed_img)
|
||||
processed_img = self._image_processor.preprocess_image(img)
|
||||
ocr_result = self._image_processor.ocr_image(processed_img)
|
||||
|
||||
return ProcessingResult(
|
||||
text=ocr_result["text"],
|
||||
@@ -306,7 +261,6 @@ class FileProcessor:
|
||||
if para.text.strip():
|
||||
paragraphs.append(para.text)
|
||||
|
||||
# Auch Tabellen extrahieren
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
row_text = " | ".join(cell.text for cell in row.cells)
|
||||
@@ -316,13 +270,10 @@ class FileProcessor:
|
||||
text = "\n\n".join(paragraphs)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text,
|
||||
confidence=1.0, # Direkte Textextraktion
|
||||
regions=[ProcessedRegion(
|
||||
text=text,
|
||||
confidence=1.0,
|
||||
bbox=(0, 0, 0, 0),
|
||||
page=1
|
||||
regions=[ProcessedRegion(
|
||||
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
||||
)],
|
||||
page_count=1,
|
||||
file_type=FileType.DOCX,
|
||||
@@ -346,10 +297,7 @@ class FileProcessor:
|
||||
text=text,
|
||||
confidence=1.0,
|
||||
regions=[ProcessedRegion(
|
||||
text=text,
|
||||
confidence=1.0,
|
||||
bbox=(0, 0, 0, 0),
|
||||
page=1
|
||||
text=text, confidence=1.0, bbox=(0, 0, 0, 0), page=1
|
||||
)],
|
||||
page_count=1,
|
||||
file_type=FileType.TXT,
|
||||
@@ -357,159 +305,13 @@ class FileProcessor:
|
||||
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(
|
||||
self,
|
||||
img: Image.Image,
|
||||
min_area: int = 500
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Erkennt und extrahiert handschriftliche Bereiche aus einem Bild.
|
||||
|
||||
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
|
||||
"""Delegate to ImageProcessor."""
|
||||
return self._image_processor.extract_handwriting_regions(img, min_area)
|
||||
|
||||
|
||||
# Singleton-Instanz
|
||||
@@ -517,7 +319,7 @@ _file_processor: Optional[FileProcessor] = None
|
||||
|
||||
|
||||
def get_file_processor() -> FileProcessor:
|
||||
"""Gibt Singleton-Instanz des File Processors zurück."""
|
||||
"""Gibt Singleton-Instanz des File Processors zurueck."""
|
||||
global _file_processor
|
||||
if _file_processor is None:
|
||||
_file_processor = FileProcessor()
|
||||
@@ -530,34 +332,26 @@ def process_file(
|
||||
file_bytes: bytes = None,
|
||||
mode: ProcessingMode = ProcessingMode.MIXED
|
||||
) -> ProcessingResult:
|
||||
"""
|
||||
Convenience function zum Verarbeiten einer Datei.
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur Datei
|
||||
file_bytes: Dateiinhalt als Bytes
|
||||
mode: Verarbeitungsmodus
|
||||
|
||||
Returns:
|
||||
ProcessingResult
|
||||
"""
|
||||
"""Convenience function zum Verarbeiten einer Datei."""
|
||||
processor = get_file_processor()
|
||||
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."""
|
||||
result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT)
|
||||
return result.text
|
||||
|
||||
|
||||
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)
|
||||
return result.text
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
- 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 os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from weasyprint import HTML, CSS
|
||||
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__)
|
||||
|
||||
# Template directory
|
||||
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:
|
||||
"""
|
||||
Zentrale PDF-Generierung für BreakPilot.
|
||||
Zentrale PDF-Generierung fuer BreakPilot.
|
||||
|
||||
Unterstützt:
|
||||
Unterstuetzt:
|
||||
- Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen
|
||||
- Schulzeugnisse (Halbjahr, Jahres, Abschluss)
|
||||
- Korrektur-Übersichten für Klausuren
|
||||
- Korrektur-Uebersichten fuer Klausuren
|
||||
"""
|
||||
|
||||
def __init__(self, templates_dir: Optional[Path] = None):
|
||||
@@ -143,7 +82,7 @@ class PDFService:
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
return ""
|
||||
try:
|
||||
@@ -154,10 +93,10 @@ class PDFService:
|
||||
|
||||
@staticmethod
|
||||
def _grade_color(grade: str) -> str:
|
||||
"""Gibt Farbe basierend auf Note zurück."""
|
||||
"""Gibt Farbe basierend auf Note zurueck."""
|
||||
grade_colors = {
|
||||
"1": "#27ae60", # Grün
|
||||
"2": "#2ecc71", # Hellgrün
|
||||
"1": "#27ae60", # Gruen
|
||||
"2": "#2ecc71", # Hellgruen
|
||||
"3": "#f1c40f", # Gelb
|
||||
"4": "#e67e22", # Orange
|
||||
"5": "#e74c3c", # Rot
|
||||
@@ -170,227 +109,12 @@ class PDFService:
|
||||
return grade_colors.get(str(grade), "#333333")
|
||||
|
||||
def _get_base_css(self) -> str:
|
||||
"""Gibt Basis-CSS für alle PDFs zurück."""
|
||||
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;
|
||||
}
|
||||
"""
|
||||
"""Gibt Basis-CSS fuer alle PDFs zurueck (delegiert an pdf_templates)."""
|
||||
return get_base_css()
|
||||
|
||||
def generate_letter_pdf(self, data: LetterData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Elternbrief.
|
||||
Generiert PDF fuer Elternbrief.
|
||||
|
||||
Args:
|
||||
data: LetterData mit allen Briefinformationen
|
||||
@@ -417,7 +141,7 @@ class PDFService:
|
||||
|
||||
def generate_certificate_pdf(self, data: CertificateData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Schulzeugnis.
|
||||
Generiert PDF fuer Schulzeugnis.
|
||||
|
||||
Args:
|
||||
data: CertificateData mit allen Zeugnisinformationen
|
||||
@@ -444,7 +168,7 @@ class PDFService:
|
||||
|
||||
def generate_correction_pdf(self, data: CorrectionData) -> bytes:
|
||||
"""
|
||||
Generiert PDF für Korrektur-Übersicht.
|
||||
Generiert PDF fuer Korrektur-Uebersicht.
|
||||
|
||||
Args:
|
||||
data: CorrectionData mit allen Korrekturinformationen
|
||||
@@ -470,322 +194,29 @@ class PDFService:
|
||||
return pdf_bytes
|
||||
|
||||
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"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("letter.html")
|
||||
|
||||
# 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):
|
||||
"""Gibt Certificate-Template zurück."""
|
||||
"""Gibt Certificate-Template zurueck."""
|
||||
template_path = self.templates_dir / "certificate.html"
|
||||
if template_path.exists():
|
||||
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):
|
||||
"""Gibt Correction-Template zurück."""
|
||||
"""Gibt Correction-Template zurueck."""
|
||||
template_path = self.templates_dir / "correction.html"
|
||||
if template_path.exists():
|
||||
return self.jinja_env.get_template("correction.html")
|
||||
|
||||
return self.jinja_env.from_string(self._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>
|
||||
"""
|
||||
return self.jinja_env.from_string(get_correction_template_html())
|
||||
|
||||
|
||||
# Convenience functions for direct usage
|
||||
@@ -793,7 +224,7 @@ _pdf_service: Optional[PDFService] = None
|
||||
|
||||
|
||||
def get_pdf_service() -> PDFService:
|
||||
"""Gibt Singleton-Instanz des PDF-Service zurück."""
|
||||
"""Gibt Singleton-Instanz des PDF-Service zurueck."""
|
||||
global _pdf_service
|
||||
if _pdf_service is None:
|
||||
_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