admina-framework 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1045 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Admina — Governance Dashboard</title>
7
+ <!-- Alpine.js — vendored locally so the dashboard works on air-gapped deployments -->
8
+ <script defer src="/vendor/alpinejs.min.js"></script>
9
+ <style>
10
+ :root {
11
+ --bg: #0a0f17; --surface: #0d1117; --surface2: #161d2b;
12
+ --border: #1e2a3d; --border2: #243347; --text: #e2e8f0;
13
+ --muted: #94a3b8; --dim: #64748b;
14
+ --teal: #14b8a6; --teal-dark: #0d9488;
15
+ --teal-bg: rgba(20,184,166,.08); --teal-border: rgba(20,184,166,.25);
16
+ --green: #22c55e; --red: #ef4444;
17
+ --amber: #f59e0b; --purple: #a855f7; --blue: #3b82f6;
18
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
19
+ }
20
+ * { margin: 0; padding: 0; box-sizing: border-box; }
21
+ [x-cloak] { display: none !important; }
22
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
23
+
24
+ /* ── Top bar ─────────────────────────────────────────── */
25
+ .topbar {
26
+ position: sticky; top: 0; z-index: 100;
27
+ display: flex; align-items: center; justify-content: space-between;
28
+ padding: 12px 24px; border-bottom: 1px solid var(--border);
29
+ background: rgba(13,17,23,.92); backdrop-filter: blur(12px);
30
+ }
31
+ .topbar .logo { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 700; }
32
+ .topbar .logo img { width: 32px; height: 32px; object-fit: contain; }
33
+ .topbar .logo .accent { color: var(--teal); }
34
+ .topbar .right { display: flex; align-items: center; gap: 16px; }
35
+ .topbar .status { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); }
36
+ .topbar .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; animation: pulse 2s infinite; }
37
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
38
+ .topbar .sep { width: 1px; height: 18px; background: var(--border2); }
39
+ .topbar .ts { font-size: 12px; color: var(--dim); font-family: var(--mono); }
40
+ .topbar .refresh-btn {
41
+ background: transparent; color: var(--teal); border: 1px solid var(--teal-border);
42
+ padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
43
+ transition: background 0.15s;
44
+ }
45
+ .topbar .refresh-btn:hover { background: var(--teal-bg); }
46
+
47
+ /* ── Layout ──────────────────────────────────────────── */
48
+ .main { padding: 24px; display: flex; flex-direction: column; gap: 24px; max-width: 1400px; margin: 0 auto; }
49
+ .row { display: grid; gap: 16px; }
50
+ .row-2 { grid-template-columns: 1fr 1fr; }
51
+ .row-score { grid-template-columns: 340px 1fr; }
52
+ @media (max-width: 900px) { .row-2, .row-score { grid-template-columns: 1fr; } }
53
+
54
+ /* ── Card ─────────────────────────────────────────────── */
55
+ .card {
56
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
57
+ padding: 20px; transition: border-color 0.2s;
58
+ }
59
+ .card:hover { border-color: var(--border2); }
60
+ .card-header { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
61
+ .card-header .icon { font-size: 16px; }
62
+
63
+ /* ── Governance Score ────────────────────────────────── */
64
+ .score-card {
65
+ background: var(--surface); border: 1px solid var(--teal-border); border-radius: 12px;
66
+ padding: 28px; text-align: center; position: relative; overflow: hidden;
67
+ }
68
+ .score-card::before {
69
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
70
+ background: linear-gradient(90deg, var(--teal), var(--green));
71
+ }
72
+ .score-ring { position: relative; width: 160px; height: 160px; margin: 0 auto 20px; }
73
+ .score-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
74
+ .score-ring .bg { fill: none; stroke: var(--border); stroke-width: 10; }
75
+ .score-ring .fg { fill: none; stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 0.8s ease, stroke 0.3s; }
76
+ .score-ring .value {
77
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
78
+ font-size: 44px; font-weight: 800; font-family: var(--mono); line-height: 1;
79
+ }
80
+ .score-ring .label { position: absolute; top: 67%; left: 50%; transform: translateX(-50%);
81
+ font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.1em; }
82
+ .score-breakdown { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; text-align: left; }
83
+ .score-row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
84
+ .score-row .name { color: var(--muted); }
85
+ .score-row .pts { font-family: var(--mono); font-weight: 600; }
86
+ .score-bar { height: 4px; border-radius: 2px; background: var(--border); margin-top: 3px; overflow: hidden; }
87
+ .score-bar .fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
88
+
89
+ /* ── Live Feed ───────────────────────────────────────── */
90
+ .feed-list { display: flex; flex-direction: column; gap: 6px; max-height: 500px; overflow-y: auto; }
91
+ .feed-list::-webkit-scrollbar { width: 4px; }
92
+ .feed-list::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
93
+ .feed-item {
94
+ display: grid; grid-template-columns: 100px 70px 1fr auto; gap: 12px; align-items: center;
95
+ padding: 8px 12px; border-radius: 8px; font-size: 13px;
96
+ background: var(--surface2); border: 1px solid transparent; transition: border-color 0.15s;
97
+ }
98
+ .feed-item:hover { border-color: var(--border2); }
99
+ .feed-item.new { animation: fadeIn 0.3s ease; }
100
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
101
+ .feed-time { font-family: var(--mono); font-size: 11px; color: var(--dim); }
102
+ .feed-detail { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
103
+ .feed-empty { padding: 40px; text-align: center; color: var(--dim); font-size: 14px; }
104
+ .ws-status { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 4px; }
105
+
106
+ /* ── Badges ──────────────────────────────────────────── */
107
+ .badge {
108
+ display: inline-block; padding: 2px 8px; border-radius: 4px;
109
+ font-size: 11px; font-weight: 600; text-transform: uppercase; white-space: nowrap;
110
+ }
111
+ .badge-allow { background: rgba(34,197,94,.12); color: var(--green); }
112
+ .badge-block { background: rgba(239,68,68,.12); color: var(--red); }
113
+ .badge-redact { background: rgba(168,85,247,.12); color: var(--purple); }
114
+ .badge-circuit_break { background: rgba(245,158,11,.12); color: var(--amber); }
115
+ .badge-modify { background: rgba(168,85,247,.12); color: var(--purple); }
116
+ .badge-low { background: rgba(34,197,94,.08); color: var(--green); }
117
+ .badge-medium { background: rgba(245,158,11,.08); color: var(--amber); }
118
+ .badge-high { background: rgba(239,68,68,.08); color: var(--red); }
119
+ .badge-critical { background: rgba(239,68,68,.18); color: #ff5f57; }
120
+
121
+ /* ── Compliance ──────────────────────────────────────── */
122
+ .compliance-grid { display: flex; flex-direction: column; gap: 10px; }
123
+ .compliance-row {
124
+ display: grid; grid-template-columns: 1fr 100px 60px; gap: 12px; align-items: center;
125
+ padding: 12px 16px; border-radius: 8px; background: var(--surface2); font-size: 13px;
126
+ }
127
+ .compliance-row .art { font-weight: 600; }
128
+ .compliance-row .name { color: var(--muted); font-size: 12px; }
129
+ .compliance-bar { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
130
+ .compliance-bar .fill { height: 100%; border-radius: 3px; transition: width 0.6s; }
131
+ .compliance-pct { font-family: var(--mono); font-weight: 600; font-size: 13px; text-align: right; }
132
+ .countdown-box {
133
+ display: flex; align-items: center; gap: 12px; padding: 14px 16px;
134
+ background: rgba(239,68,68,.06); border: 1px solid rgba(239,68,68,.2);
135
+ border-radius: 8px; margin-bottom: 12px;
136
+ }
137
+ .countdown-box .days { font-size: 28px; font-weight: 800; font-family: var(--mono); color: var(--red); }
138
+ .countdown-box .label { font-size: 12px; color: var(--muted); }
139
+ .countdown-box .sublabel { font-size: 11px; color: var(--dim); }
140
+
141
+ /* ── Sovereignty ─────────────────────────────────────── */
142
+ .zone-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
143
+ .zone-card {
144
+ background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
145
+ padding: 16px; position: relative; overflow: hidden;
146
+ }
147
+ .zone-card::before {
148
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
149
+ background: linear-gradient(90deg, var(--green), transparent);
150
+ }
151
+ .zone-card .zone-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
152
+ .zone-card .zone-status { font-size: 12px; margin-bottom: 8px; }
153
+ .zone-card .zone-desc { font-size: 12px; color: var(--dim); }
154
+ .zone-card .zone-count { font-size: 28px; font-weight: 700; font-family: var(--mono); margin-top: 8px; }
155
+ .zone-card .zone-count-label { font-size: 11px; color: var(--muted); }
156
+
157
+ /* ── KPI Cards (existing stats) ──────────────────────── */
158
+ .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
159
+ .kpi { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
160
+ .kpi .kpi-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 6px; }
161
+ .kpi .kpi-value { font-size: 24px; font-weight: 700; font-family: var(--mono); }
162
+ .kpi .kpi-sub { font-size: 11px; color: var(--dim); margin-top: 3px; }
163
+
164
+ /* ── Infrastructure Health ────────────────────────────── */
165
+ .infra-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
166
+ .infra-card {
167
+ background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
168
+ padding: 16px; position: relative; overflow: hidden;
169
+ }
170
+ .infra-card::before {
171
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
172
+ transition: background 0.3s;
173
+ }
174
+ .infra-card.healthy::before { background: linear-gradient(90deg, var(--green), transparent); }
175
+ .infra-card.unhealthy::before { background: linear-gradient(90deg, var(--red), transparent); }
176
+ .infra-card.not_configured::before { background: linear-gradient(90deg, var(--dim), transparent); }
177
+ .infra-card.degraded::before { background: linear-gradient(90deg, var(--amber), transparent); }
178
+ .infra-card.unreachable::before { background: linear-gradient(90deg, var(--red), transparent); }
179
+ .infra-card .ic-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; text-transform: capitalize; }
180
+ .infra-card .ic-status { font-size: 12px; margin-bottom: 6px; }
181
+ .infra-card .ic-detail { font-size: 12px; color: var(--dim); font-family: var(--mono); }
182
+ .infra-summary {
183
+ display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
184
+ font-size: 13px; color: var(--muted);
185
+ }
186
+ .infra-summary .count { font-size: 20px; font-weight: 700; font-family: var(--mono); }
187
+
188
+ /* ── Model Status ───────────────────────────────────── */
189
+ .model-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
190
+ .model-card {
191
+ background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
192
+ padding: 16px; position: relative; overflow: hidden;
193
+ }
194
+ .model-card::before {
195
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
196
+ background: linear-gradient(90deg, var(--blue), transparent);
197
+ }
198
+ .model-card .mc-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
199
+ .model-card .mc-meta { font-size: 12px; color: var(--dim); margin-bottom: 2px; }
200
+ .model-card .mc-status { margin-top: 8px; }
201
+
202
+ /* ── Modal ──────────────────────────────────────────── */
203
+ .modal-backdrop {
204
+ position: fixed; inset: 0; background: rgba(0,0,0,.6);
205
+ display: flex; align-items: center; justify-content: center;
206
+ z-index: 1000; padding: 24px;
207
+ }
208
+ .modal {
209
+ background: var(--surface); border: 1px solid var(--border);
210
+ border-radius: 12px; max-width: 720px; width: 100%;
211
+ max-height: calc(100vh - 48px); overflow-y: auto;
212
+ box-shadow: 0 20px 60px rgba(0,0,0,.5);
213
+ }
214
+ .modal-header {
215
+ display: flex; align-items: center; justify-content: space-between;
216
+ padding: 18px 22px; border-bottom: 1px solid var(--border);
217
+ }
218
+ .modal-title { font-size: 15px; font-weight: 700; }
219
+ .modal-close {
220
+ background: transparent; border: none; color: var(--muted);
221
+ font-size: 22px; cursor: pointer; line-height: 1; padding: 0 4px;
222
+ }
223
+ .modal-close:hover { color: var(--text); }
224
+ .modal-body { padding: 18px 22px; font-size: 13px; color: var(--text); line-height: 1.6; }
225
+ .modal-body p { margin-bottom: 12px; color: var(--muted); }
226
+ .modal-body code.inline {
227
+ font-family: var(--mono); color: var(--teal); font-size: 12px;
228
+ background: var(--surface2); padding: 1px 5px; border-radius: 3px;
229
+ }
230
+ .modal-pre {
231
+ background: var(--surface2); padding: 12px; border-radius: 6px;
232
+ font-family: var(--mono); font-size: 11px; color: var(--muted);
233
+ overflow-x: auto; margin: 8px 0 16px; line-height: 1.5;
234
+ border: 1px solid var(--border);
235
+ }
236
+ .modal-copy-btn {
237
+ background: var(--teal-bg); color: var(--teal); border: 1px solid var(--teal-border);
238
+ padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 12px;
239
+ font-weight: 600; margin-bottom: 8px;
240
+ }
241
+ .modal-copy-btn:hover { background: rgba(20,184,166,.15); }
242
+
243
+ /* ── Section header ─────────────────────────────────── */
244
+ .section-header {
245
+ margin: 12px 0 -8px;
246
+ padding: 12px 16px;
247
+ border-left: 3px solid var(--teal);
248
+ background: linear-gradient(90deg, rgba(20,184,166,.04), transparent);
249
+ border-radius: 0 8px 8px 0;
250
+ }
251
+ .section-header.section-config {
252
+ border-left-color: #534AB7;
253
+ background: linear-gradient(90deg, rgba(83,74,183,.06), transparent);
254
+ }
255
+ .section-header .section-title {
256
+ font-size: 14px; font-weight: 700; letter-spacing: 0.04em;
257
+ text-transform: uppercase; color: var(--text);
258
+ }
259
+ .section-header .section-subtitle {
260
+ font-size: 12px; color: var(--muted); margin-top: 2px;
261
+ }
262
+ .card-header .card-subtitle {
263
+ font-size: 11px; text-transform: none; letter-spacing: 0;
264
+ color: var(--dim); font-weight: 400; margin-left: 8px;
265
+ }
266
+
267
+ /* ── OISG Score ──────────────────────────────────────── */
268
+ .oisg-card {
269
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
270
+ padding: 20px; position: relative; overflow: hidden;
271
+ }
272
+ .oisg-card::before {
273
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
274
+ background: linear-gradient(90deg, #0F6E56, #534AB7, #993C1D, #185FA5);
275
+ }
276
+ .oisg-top {
277
+ display: grid;
278
+ grid-template-columns: minmax(220px, 1fr) minmax(280px, 2fr);
279
+ gap: 32px; align-items: start;
280
+ }
281
+ @media (max-width: 760px) {
282
+ .oisg-top { grid-template-columns: 1fr; }
283
+ }
284
+ .oisg-quadrant-wrapper { position: relative; width: 100%; }
285
+ .oisg-quadrant {
286
+ display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
287
+ width: 100%; aspect-ratio: 1;
288
+ }
289
+ .oisg-quad {
290
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
291
+ border-radius: 8px; color: #fff; transition: opacity 0.6s;
292
+ position: relative;
293
+ }
294
+ .oisg-quad-o { background: #0F6E56; clip-path: polygon(0 0, 100% 0, 100% 65%, 65% 100%, 0 100%); }
295
+ .oisg-quad-i { background: #534AB7; clip-path: polygon(0 0, 100% 0, 100% 100%, 35% 100%, 0 65%); }
296
+ .oisg-quad-s { background: #993C1D; clip-path: polygon(0 0, 65% 0, 100% 35%, 100% 100%, 0 100%); }
297
+ .oisg-quad-g { background: #185FA5; clip-path: polygon(35% 0, 100% 0, 100% 100%, 0 100%, 0 35%); }
298
+ .oisg-quad .q-letter { font-size: 28px; font-weight: 700; font-family: var(--mono); }
299
+ .oisg-quad .q-name { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.85; }
300
+ .oisg-quad .q-score { font-size: 15px; font-weight: 600; font-family: var(--mono); }
301
+ .oisg-center-score {
302
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
303
+ text-align: center; pointer-events: none; z-index: 2;
304
+ background: var(--surface); border-radius: 50%;
305
+ width: 90px; height: 90px;
306
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
307
+ box-shadow: 0 0 0 4px var(--border);
308
+ }
309
+ .oisg-center-score .oisg-total { font-size: 32px; font-weight: 800; font-family: var(--mono); line-height: 1; }
310
+ .oisg-center-score .oisg-max { font-size: 11px; color: var(--dim); }
311
+ .oisg-center-score .oisg-level { font-size: 9px; font-weight: 600; margin-top: 2px; white-space: nowrap; }
312
+ .oisg-criteria { min-width: 0; }
313
+ .oisg-pillar-group { margin-bottom: 10px; }
314
+ .oisg-pillar-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
315
+ .oisg-criterion {
316
+ display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted);
317
+ padding: 2px 0;
318
+ }
319
+ .oisg-criterion .dot-ok { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
320
+ .oisg-criterion .dot-miss { width: 8px; height: 8px; border-radius: 50%; background: var(--red); opacity: 0.5; flex-shrink: 0; }
321
+ .oisg-link { display: inline-block; margin-top: 12px; font-size: 12px; color: var(--teal); text-decoration: none; }
322
+ .oisg-link:hover { text-decoration: underline; }
323
+
324
+ /* ── Quick Links ─────────────────────────────────────── */
325
+ .links-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
326
+ .link-card {
327
+ background: var(--surface2); border: 1px solid var(--border); border-radius: 10px;
328
+ padding: 16px; text-decoration: none; color: var(--text); transition: border-color 0.15s;
329
+ }
330
+ .link-card:hover { border-color: var(--teal); }
331
+ .link-card .lc-label { font-size: 11px; text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
332
+ .link-card .lc-title { font-size: 14px; font-weight: 600; }
333
+ </style>
334
+ </head>
335
+ <body>
336
+
337
+ <!-- ════════════════════════════════════════════════════════
338
+ Alpine.js Dashboard Application
339
+ ════════════════════════════════════════════════════════ -->
340
+ <div x-data="dashboard()" x-init="init()">
341
+
342
+ <!-- Top Bar -->
343
+ <div class="topbar">
344
+ <div class="logo">
345
+ <img src="heimdall.png" alt="Heimdall" title="Heimdall — the Governance Owl">
346
+ <span class="accent">Admina</span> Governance Dashboard <span style="font-size: 12px; font-weight: 400; color: var(--dim); margin-left: 6px;">%%VERSION%% (%%GIT_COMMIT%%)</span>
347
+ </div>
348
+ <div class="right">
349
+ <span class="ts" x-text="lastUpdate"></span>
350
+ <div class="sep"></div>
351
+ <button class="refresh-btn" @click="refresh()">Refresh</button>
352
+ <div class="sep"></div>
353
+ <div class="status">
354
+ <div class="dot" :style="{ background: healthy ? 'var(--green)' : 'var(--red)' }"></div>
355
+ <span x-text="healthy ? 'Connected' : 'Disconnected'"></span>
356
+ </div>
357
+ </div>
358
+ </div>
359
+
360
+ <div class="main">
361
+
362
+ <!-- ════ Section: Live governance (runtime metrics) ════ -->
363
+ <div class="section-header">
364
+ <div class="section-title">Live Governance</div>
365
+ <div class="section-subtitle">Runtime metrics from this Admina instance — events processed, attacks blocked, services health.</div>
366
+ </div>
367
+
368
+ <!-- ════ Row 1: Admina Score + Live Feed ════ -->
369
+ <div class="row row-score">
370
+
371
+ <!-- Admina Score Card (runtime weighted composite) -->
372
+ <div class="score-card">
373
+ <div class="card-header">
374
+ ADMINA SCORE
375
+ <span class="card-subtitle">live runtime composite</span>
376
+ </div>
377
+ <div class="score-ring">
378
+ <svg viewBox="0 0 120 120">
379
+ <circle class="bg" cx="60" cy="60" r="50"></circle>
380
+ <circle class="fg" cx="60" cy="60" r="50"
381
+ :stroke="scoreColor"
382
+ :stroke-dasharray="314.16"
383
+ :stroke-dashoffset="314.16 - (314.16 * score.score / 100)">
384
+ </circle>
385
+ </svg>
386
+ <div class="value" :style="{ color: scoreColor }" x-text="score.score"></div>
387
+ <div class="label">/ 100</div>
388
+ </div>
389
+ <div class="score-breakdown">
390
+ <template x-for="item in scoreItems" :key="item.key">
391
+ <div>
392
+ <div class="score-row">
393
+ <span class="name" x-text="item.label"></span>
394
+ <span class="pts" :style="{ color: item.value > 0 ? 'var(--green)' : 'var(--dim)' }">
395
+ +<span x-text="item.value"></span>/<span x-text="item.max"></span>
396
+ </span>
397
+ </div>
398
+ <div class="score-bar">
399
+ <div class="fill" :style="{ width: (item.value / item.max * 100) + '%', background: item.value > 0 ? 'var(--teal)' : 'var(--border2)' }"></div>
400
+ </div>
401
+ <div x-show="item.hint" :title="item.hint"
402
+ style="font-size: 10px; color: var(--amber); margin-top: 2px; font-style: italic;"
403
+ x-text="'\u26A0 ' + (item.hint || '')"></div>
404
+ </div>
405
+ </template>
406
+ </div>
407
+ </div>
408
+
409
+ <!-- Live Governance Feed -->
410
+ <div class="card">
411
+ <div class="card-header" style="justify-content: space-between;">
412
+ <span>LIVE GOVERNANCE FEED</span>
413
+ <span class="ws-status" :style="{ background: wsConnected ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)', color: wsConnected ? 'var(--green)' : 'var(--red)' }"
414
+ x-text="wsConnected ? 'WS LIVE' : 'WS OFF'"></span>
415
+ </div>
416
+ <div class="feed-list">
417
+ <template x-if="feedEvents.length === 0">
418
+ <div class="feed-empty">Waiting for governance events...</div>
419
+ </template>
420
+ <template x-for="(ev, i) in feedEvents" :key="ev._id">
421
+ <div class="feed-item" :class="{ 'new': i === 0 }">
422
+ <span class="feed-time" x-text="ev.time"></span>
423
+ <span>
424
+ <span class="badge" :class="'badge-' + (ev.action || 'allow').toLowerCase()" x-text="ev.action || 'ALLOW'"></span>
425
+ </span>
426
+ <span class="feed-detail" x-text="ev.detail"></span>
427
+ <span class="badge" :class="'badge-' + (ev.risk_level || 'low').toLowerCase()" x-text="ev.risk_level || 'LOW'"></span>
428
+ </div>
429
+ </template>
430
+ </div>
431
+ </div>
432
+ </div>
433
+
434
+ <!-- ════ Row 2: Compliance + Sovereignty ════ -->
435
+ <div class="row row-2">
436
+
437
+ <!-- EU AI Act Compliance Gaps -->
438
+ <div class="card">
439
+ <div class="card-header">
440
+ EU AI ACT COMPLIANCE
441
+ <span class="card-subtitle" x-show="hasAssessment" x-text="'last assessment ' + (complianceData?.latest?.assessed_at?.slice(0, 10) || '')"></span>
442
+ <span class="card-subtitle" x-show="!hasAssessment" style="color: var(--amber);">no gap analysis run yet</span>
443
+ </div>
444
+
445
+ <!-- No-assessment notice (compact) -->
446
+ <div x-show="!hasAssessment"
447
+ style="margin-bottom: 14px; padding: 8px 12px; background: rgba(245,158,11,.06);
448
+ border: 1px solid rgba(245,158,11,.3); border-radius: 6px; font-size: 12px;
449
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;">
450
+ <span style="color: var(--amber); font-weight: 600;">
451
+ ⚠ No real assessment recorded
452
+ <span style="color: var(--muted); font-weight: 400; margin-left: 6px;">— articles below show defaults</span>
453
+ </span>
454
+ <button @click="showAssessmentHelp = true"
455
+ style="background: transparent; border: 1px solid var(--amber);
456
+ color: var(--amber); padding: 4px 10px; border-radius: 4px;
457
+ cursor: pointer; font-size: 12px; font-weight: 600; white-space: nowrap;">
458
+ click here to fix →
459
+ </button>
460
+ </div>
461
+
462
+ <!-- Countdown -->
463
+ <div class="countdown-box">
464
+ <div>
465
+ <div class="days" x-text="daysUntilDeadline"></div>
466
+ </div>
467
+ <div>
468
+ <div class="label">days until enforcement</div>
469
+ <div class="sublabel">Deadline: August 2, 2026</div>
470
+ </div>
471
+ </div>
472
+
473
+ <!-- Article checklist -->
474
+ <div class="compliance-grid">
475
+ <template x-for="art in complianceArticles" :key="art.article">
476
+ <div class="compliance-row">
477
+ <div>
478
+ <div class="art" x-text="art.article"></div>
479
+ <div class="name" x-text="art.name"></div>
480
+ </div>
481
+ <div class="compliance-bar">
482
+ <div class="fill" :style="{
483
+ width: art.pct + '%',
484
+ background: art.pct === 100 ? 'var(--green)' : art.pct >= 50 ? 'var(--amber)' : 'var(--red)'
485
+ }"></div>
486
+ </div>
487
+ <div class="compliance-pct" :style="{
488
+ color: art.pct === 100 ? 'var(--green)' : art.pct >= 50 ? 'var(--amber)' : 'var(--red)'
489
+ }" x-text="art.pct + '%'"></div>
490
+ </div>
491
+ </template>
492
+ </div>
493
+
494
+ <div style="margin-top: 12px; font-size: 12px; color: var(--dim);">
495
+ <template x-if="hasAssessment">
496
+ <span>
497
+ Overall: <span style="font-weight: 600;" :style="{ color: complianceScore === 100 ? 'var(--green)' : 'var(--amber)' }"
498
+ x-text="complianceScore + '%'"></span> compliant
499
+ <span x-show="complianceGapCount > 0" style="margin-left: 8px;">
500
+ (<span x-text="complianceGapCount"></span> gaps to address)
501
+ </span>
502
+ </span>
503
+ </template>
504
+ <template x-if="!hasAssessment">
505
+ <span style="font-style: italic;">Overall: <span style="color: var(--amber); font-weight: 600;">unknown</span> — run a gap analysis to compute</span>
506
+ </template>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- Data Sovereignty Map -->
511
+ <div class="card">
512
+ <div class="card-header">DATA SOVEREIGNTY</div>
513
+
514
+ <div style="margin-bottom: 16px; display: flex; align-items: center; gap: 12px;">
515
+ <span class="badge badge-allow" x-show="sovereignty.data_residency_enforced">RESIDENCY ENFORCED</span>
516
+ <span style="font-size: 12px; color: var(--dim);">
517
+ <span x-text="sovereignty.total_governed_events || 0" style="font-weight: 600; color: var(--text);"></span> total governed events
518
+ </span>
519
+ </div>
520
+
521
+ <div class="zone-grid">
522
+ <template x-for="(zone, key) in sovereignty.zones || {}" :key="key">
523
+ <div class="zone-card">
524
+ <div class="zone-name" x-text="zone.name"></div>
525
+ <div class="zone-status">
526
+ <span class="badge" :class="zone.status === 'enforced' ? 'badge-allow' : 'badge-medium'"
527
+ x-text="zone.status"></span>
528
+ </div>
529
+ <div class="zone-desc" x-text="zone.description"></div>
530
+ </div>
531
+ </template>
532
+ </div>
533
+
534
+ <!-- Proxy stats overview -->
535
+ <div style="margin-top: 20px;">
536
+ <div class="card-header">PROXY METRICS</div>
537
+ <div class="kpi-grid">
538
+ <div class="kpi">
539
+ <div class="kpi-label">Total Requests</div>
540
+ <div class="kpi-value" x-text="stats.proxy?.requests_total ?? '—'"></div>
541
+ </div>
542
+ <div class="kpi">
543
+ <div class="kpi-label">Allowed</div>
544
+ <div class="kpi-value" style="color: var(--green);" x-text="stats.proxy?.requests_allowed ?? '—'"></div>
545
+ </div>
546
+ <div class="kpi">
547
+ <div class="kpi-label">Blocked</div>
548
+ <div class="kpi-value" style="color: var(--red);" x-text="stats.proxy?.requests_blocked ?? '—'"></div>
549
+ </div>
550
+ <div class="kpi">
551
+ <div class="kpi-label">PII Redacted</div>
552
+ <div class="kpi-value" style="color: var(--purple);" x-text="stats.proxy?.requests_redacted ?? '—'"></div>
553
+ </div>
554
+ <div class="kpi">
555
+ <div class="kpi-label">Avg Latency</div>
556
+ <div class="kpi-value" x-text="stats.proxy?.avg_latency_ms != null ? stats.proxy.avg_latency_ms.toFixed(1) + 'ms' : '—'"></div>
557
+ </div>
558
+ <div class="kpi">
559
+ <div class="kpi-label">Forensic Records</div>
560
+ <div class="kpi-value" x-text="stats.forensic_blackbox?.record_count ?? '—'"></div>
561
+ </div>
562
+ </div>
563
+ </div>
564
+ </div>
565
+ </div>
566
+
567
+ <!-- ════ Row 3: Model Status + Infrastructure Health ════ -->
568
+ <div class="row row-2">
569
+
570
+ <!-- Model Status -->
571
+ <div class="card">
572
+ <div class="card-header">MODEL STATUS</div>
573
+
574
+ <div style="margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
575
+ <span class="badge" :class="modelData.engine?.rust_available ? 'badge-allow' : 'badge-medium'"
576
+ x-text="modelData.engine?.engine === 'rust' ? 'RUST ENGINE' : 'PYTHON ENGINE'"></span>
577
+ <span x-show="modelData.engine?.rust_version" style="font-size: 12px; color: var(--dim);">
578
+ v<span x-text="modelData.engine?.rust_version || ''"></span>
579
+ </span>
580
+ <span x-show="modelData.ai_infra_enabled" class="badge badge-allow">AI INFRA ENABLED</span>
581
+ <span x-show="!modelData.ai_infra_enabled" style="font-size: 12px; color: var(--dim);">
582
+ AI infra not enabled (activate in admina.yaml)
583
+ </span>
584
+ </div>
585
+
586
+ <div class="model-grid">
587
+ <template x-for="model in modelData.models || []" :key="model.name">
588
+ <div class="model-card">
589
+ <div class="mc-name" x-text="model.name"></div>
590
+ <div class="mc-meta">Type: <span x-text="model.type"></span></div>
591
+ <div class="mc-meta">Backend: <span x-text="model.backend"></span></div>
592
+ <div class="mc-meta" x-show="model.version">Version: <span x-text="model.version || ''"></span></div>
593
+ <div class="mc-meta" x-show="model.size">Size: <span x-text="formatBytes(model.size)"></span></div>
594
+ <div class="mc-status">
595
+ <span class="badge" :class="model.status === 'active' || model.status === 'loaded' ? 'badge-allow' : 'badge-medium'"
596
+ x-text="model.status"></span>
597
+ </div>
598
+ </div>
599
+ </template>
600
+ </div>
601
+
602
+ <template x-if="modelData.gpu">
603
+ <div style="margin-top: 16px;">
604
+ <div class="card-header">GPU UTILIZATION</div>
605
+ <div class="kpi-grid">
606
+ <div class="kpi">
607
+ <div class="kpi-label">GPU Name</div>
608
+ <div class="kpi-value" style="font-size: 16px;" x-text="modelData.gpu?.name || '—'"></div>
609
+ </div>
610
+ <div class="kpi">
611
+ <div class="kpi-label">VRAM Used</div>
612
+ <div class="kpi-value" style="font-size: 16px;" x-text="modelData.gpu?.vram_used || '—'"></div>
613
+ </div>
614
+ <div class="kpi">
615
+ <div class="kpi-label">Utilization</div>
616
+ <div class="kpi-value" style="font-size: 16px;" x-text="modelData.gpu?.utilization || '—'"></div>
617
+ </div>
618
+ </div>
619
+ </div>
620
+ </template>
621
+ </div>
622
+
623
+ <!-- Infrastructure Health -->
624
+ <div class="card">
625
+ <div class="card-header">INFRASTRUCTURE HEALTH</div>
626
+
627
+ <div class="infra-summary">
628
+ <span class="badge" :class="infraData.overall === 'healthy' ? 'badge-allow' : 'badge-medium'"
629
+ x-text="(infraData.overall || 'unknown').toUpperCase()"></span>
630
+ <span>
631
+ <span class="count" :style="{ color: infraData.healthy_count === infraData.total_count ? 'var(--green)' : 'var(--amber)' }"
632
+ x-text="infraData.healthy_count || 0"></span>/<span x-text="infraData.total_count || 0"></span> services healthy
633
+ </span>
634
+ </div>
635
+
636
+ <div class="infra-grid">
637
+ <template x-for="(svc, name) in infraData.services || {}" :key="name">
638
+ <div class="infra-card" :class="svc.status">
639
+ <div class="ic-name" x-text="name.replace('_', ' ')"></div>
640
+ <div class="ic-status">
641
+ <span class="badge" :class="{
642
+ 'badge-allow': svc.status === 'healthy',
643
+ 'badge-high': svc.status === 'unhealthy' || svc.status === 'unreachable',
644
+ 'badge-medium': svc.status === 'degraded' || svc.status === 'not_configured'
645
+ }" x-text="svc.status"></span>
646
+ </div>
647
+ <div class="ic-detail" x-show="svc.latency_ms != null">
648
+ <span x-text="svc.latency_ms + 'ms'"></span> response
649
+ </div>
650
+ <div class="ic-detail" x-show="svc.used_memory_human">
651
+ Memory: <span x-text="svc.used_memory_human || ''"></span>
652
+ </div>
653
+ <div class="ic-detail" x-show="svc.event_count != null">
654
+ <span x-text="svc.event_count"></span> events stored
655
+ </div>
656
+ <div class="ic-detail" x-show="svc.bucket_count != null">
657
+ <span x-text="svc.bucket_count"></span> buckets
658
+ </div>
659
+ <div class="ic-detail" x-show="svc.url">
660
+ <span x-text="svc.url || ''"></span>
661
+ </div>
662
+ <div class="ic-detail" x-show="svc.error" style="color: var(--red);">
663
+ <span x-text="svc.error || ''"></span>
664
+ </div>
665
+ </div>
666
+ </template>
667
+ </div>
668
+ </div>
669
+ </div>
670
+
671
+ <!-- ════ Modal: How to populate the EU AI Act gap analysis ════ -->
672
+ <div class="modal-backdrop" x-show="showAssessmentHelp" x-cloak
673
+ @click.self="showAssessmentHelp = false"
674
+ @keydown.escape.window="showAssessmentHelp = false">
675
+ <div class="modal">
676
+ <div class="modal-header">
677
+ <div class="modal-title">How to populate the EU AI Act gap analysis</div>
678
+ <button class="modal-close" @click="showAssessmentHelp = false" aria-label="Close">×</button>
679
+ </div>
680
+ <div class="modal-body">
681
+ <p>
682
+ EU AI Act requirements (Articles 9–15) are <strong>policy and process attestations</strong>
683
+ that Admina cannot infer from runtime traffic alone — they require a human to declare
684
+ which checks the organisation has actually completed (e.g. "risk management process documented",
685
+ "data bias examination performed").
686
+ </p>
687
+ <p>
688
+ Until you submit a real assessment, the dashboard renders all articles at 0% with a default
689
+ placeholder. To record your real coverage, POST your declared evidence to
690
+ <code class="inline">/api/compliance/gap-analysis</code>.
691
+ </p>
692
+ <p style="margin-bottom: 6px;"><strong>Example request:</strong></p>
693
+ <button class="modal-copy-btn" @click="copyAssessmentCmd($event)">Copy command</button>
694
+ <pre class="modal-pre" id="assessment-cmd">curl -X POST http://localhost:8080/api/compliance/gap-analysis \
695
+ -H "X-API-Key: $ADMINA_API_KEY" -H "Content-Type: application/json" \
696
+ -d '{"risk_category": "high",
697
+ "current_compliance": {
698
+ "risk_management": [true, true, false, true],
699
+ "data_governance": [true, false, true, true],
700
+ "technical_documentation": [false, false, true, false],
701
+ "record_keeping": [true, true, true, true],
702
+ "transparency": [true, false, false, false],
703
+ "human_oversight": [false, true, false, false],
704
+ "accuracy_robustness": [true, true, false, true]
705
+ }}'</pre>
706
+ <p style="font-size: 12px;">
707
+ Each list has 4 booleans, one per check defined for that article. Replace the placeholder
708
+ values with your real evidence. After submitting, refresh the dashboard to see the article
709
+ grid and Admina Score reflect your actual coverage.
710
+ </p>
711
+ </div>
712
+ </div>
713
+ </div>
714
+
715
+ <!-- Quick Links -->
716
+ <div class="card">
717
+ <div class="card-header">QUICK LINKS</div>
718
+ <div class="links-grid">
719
+ <a href="/docs" target="_blank" class="link-card">
720
+ <div class="lc-label">Proxy API</div>
721
+ <div class="lc-title">Swagger UI</div>
722
+ </a>
723
+ <a href="http://localhost:3001" target="_blank" class="link-card">
724
+ <div class="lc-label">Grafana</div>
725
+ <div class="lc-title">OTEL Dashboards</div>
726
+ </a>
727
+ <a href="http://localhost:9090" target="_blank" class="link-card">
728
+ <div class="lc-label">MinIO</div>
729
+ <div class="lc-title">Forensic Storage</div>
730
+ </a>
731
+ <a href="http://localhost:8123/play" target="_blank" class="link-card">
732
+ <div class="lc-label">ClickHouse</div>
733
+ <div class="lc-title">Query Events</div>
734
+ </a>
735
+ </div>
736
+ </div>
737
+
738
+ <!-- ════ Section: Instance Configuration (OISG) ════ -->
739
+ <div class="section-header section-config">
740
+ <div class="section-title">Instance Configuration</div>
741
+ <div class="section-subtitle">
742
+ Static adequacy assessment of which OISG capabilities this Admina deployment enables —
743
+ independent of runtime traffic. Reflects <a href="https://oisg.ai" target="_blank" rel="noopener" style="color: var(--teal); text-decoration: none;">oisg.ai</a> adequacy criteria.
744
+ </div>
745
+ </div>
746
+
747
+ <!-- OISG Adequacy Score (capability/config snapshot) -->
748
+ <div class="oisg-card" x-show="oisg.total != null">
749
+ <div class="card-header" style="justify-content: space-between;">
750
+ <span>
751
+ OISG ADEQUACY SCORE
752
+ <span class="card-subtitle">instance capabilities snapshot</span>
753
+ </span>
754
+ <a class="oisg-link" href="https://oisg.ai" target="_blank" rel="noopener" style="margin: 0;">oisg.ai &rarr;</a>
755
+ </div>
756
+ <div class="oisg-top">
757
+
758
+ <!-- Quadrant map with center score -->
759
+ <div class="oisg-quadrant-wrapper">
760
+ <div class="oisg-quadrant">
761
+ <div class="oisg-quad oisg-quad-o" :style="{ opacity: 0.15 + ((oisg.pillars?.open?.score || 0) / 25) * 0.85 }">
762
+ <span class="q-letter">O</span><span class="q-name">Open</span>
763
+ <span class="q-score" x-text="(oisg.pillars?.open?.score || 0) + '/25'"></span>
764
+ </div>
765
+ <div class="oisg-quad oisg-quad-i" :style="{ opacity: 0.15 + ((oisg.pillars?.intelligent?.score || 0) / 25) * 0.85 }">
766
+ <span class="q-letter">I</span><span class="q-name">Intelligent</span>
767
+ <span class="q-score" x-text="(oisg.pillars?.intelligent?.score || 0) + '/25'"></span>
768
+ </div>
769
+ <div class="oisg-quad oisg-quad-s" :style="{ opacity: 0.15 + ((oisg.pillars?.secure?.score || 0) / 25) * 0.85 }">
770
+ <span class="q-letter">S</span><span class="q-name">Secure</span>
771
+ <span class="q-score" x-text="(oisg.pillars?.secure?.score || 0) + '/25'"></span>
772
+ </div>
773
+ <div class="oisg-quad oisg-quad-g" :style="{ opacity: 0.15 + ((oisg.pillars?.governed?.score || 0) / 25) * 0.85 }">
774
+ <span class="q-letter">G</span><span class="q-name">Governed</span>
775
+ <span class="q-score" x-text="(oisg.pillars?.governed?.score || 0) + '/25'"></span>
776
+ </div>
777
+ </div>
778
+ <!-- Center score overlay -->
779
+ <div class="oisg-center-score">
780
+ <div class="oisg-total" :style="{ color: oisgColor }" x-text="oisg.total || 0"></div>
781
+ <div class="oisg-max">/ 100</div>
782
+ <div class="oisg-level" :style="{ color: oisgColor }" x-text="oisg.level || ''"></div>
783
+ </div>
784
+ </div>
785
+
786
+ <!-- Criteria checklist -->
787
+ <div class="oisg-criteria">
788
+ <template x-for="(pillar, pkey) in oisg.pillars || {}" :key="pkey">
789
+ <div class="oisg-pillar-group">
790
+ <div class="oisg-pillar-label" :style="{ color: oisgPillarColor(pkey) }" x-text="pillar.name"></div>
791
+ <template x-for="c in pillar.criteria || []" :key="c.id">
792
+ <div class="oisg-criterion" :title="c.reason">
793
+ <span :class="c.satisfied ? 'dot-ok' : 'dot-miss'"></span>
794
+ <span x-text="c.id.toUpperCase()" style="font-family: var(--mono); font-weight: 600; width: 22px;"></span>
795
+ <span x-text="c.satisfied ? 'Met' : 'Gap'" :style="{ color: c.satisfied ? 'var(--green)' : 'var(--red)', fontWeight: 600, width: '28px' }"></span>
796
+ <span style="flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" x-text="c.reason"></span>
797
+ </div>
798
+ </template>
799
+ </div>
800
+ </template>
801
+ </div>
802
+
803
+ </div>
804
+ </div>
805
+
806
+ </div>
807
+ </div>
808
+
809
+ <script>
810
+ /**
811
+ * Admina Governance Dashboard — Alpine.js application.
812
+ *
813
+ * Reads from the Phase 3.1 API endpoints. Does NOT write.
814
+ * All API calls go through the nginx reverse proxy (/api/*).
815
+ */
816
+ function dashboard() {
817
+ return {
818
+ // ── State ──────────────────────────────────────────
819
+ healthy: false,
820
+ lastUpdate: '—',
821
+ score: { score: 0, max_score: 100, breakdown: {} },
822
+ feedEvents: [],
823
+ wsConnected: false,
824
+ sovereignty: {},
825
+ stats: {},
826
+ complianceData: null,
827
+ infraData: {},
828
+ modelData: {},
829
+ oisg: {},
830
+ showAssessmentHelp: false,
831
+ _ws: null,
832
+ _feedId: 0,
833
+ _refreshTimer: null,
834
+
835
+ // ── Computed ────────────────────────────────────────
836
+ get scoreColor() {
837
+ const s = this.score.score;
838
+ if (s >= 80) return 'var(--green)';
839
+ if (s >= 50) return 'var(--amber)';
840
+ return 'var(--red)';
841
+ },
842
+
843
+ get oisgColor() {
844
+ const t = this.oisg.total || 0;
845
+ if (t >= 80) return '#5DCAA5';
846
+ if (t >= 50) return '#85B7EB';
847
+ if (t >= 25) return '#AFA9EC';
848
+ return '#F0997B';
849
+ },
850
+
851
+ oisgPillarColor(key) {
852
+ const map = { open: '#5DCAA5', intelligent: '#AFA9EC', secure: '#F0997B', governed: '#85B7EB' };
853
+ return map[key] || 'var(--muted)';
854
+ },
855
+
856
+ get scoreItems() {
857
+ const bd = this.score.breakdown || {};
858
+ const noAssessment = this.complianceData && this.complianceData.has_assessment === false;
859
+ return [
860
+ { key: 'data_residency', label: 'Data residency enforced', value: bd.data_residency ?? 0, max: 25 },
861
+ { key: 'interactions_audited', label: 'All interactions audited', value: bd.interactions_audited ?? 0, max: 25 },
862
+ {
863
+ key: 'eu_ai_act_coverage',
864
+ label: 'EU AI Act gap coverage',
865
+ value: bd.eu_ai_act_coverage ?? 0,
866
+ max: 25,
867
+ hint: noAssessment ? 'No gap analysis run yet — see EU AI Act card' : null,
868
+ },
869
+ { key: 'no_recent_attacks', label: 'No blocked attacks (24h)', value: bd.no_recent_attacks ?? 0, max: 15 },
870
+ { key: 'forensic_chain_valid', label: 'Forensic chain valid', value: bd.forensic_chain_valid ?? 0, max: 10 },
871
+ ];
872
+ },
873
+
874
+ get hasAssessment() {
875
+ return !!(this.complianceData && this.complianceData.has_assessment);
876
+ },
877
+
878
+ get daysUntilDeadline() {
879
+ // Pick the deadline from the live /api/dashboard/compliance payload
880
+ // when available, otherwise fall back to the Annex III high-risk date
881
+ // as agreed in the Omnibus VII deal (Council/Parliament, 7 May 2026).
882
+ const apiDeadline =
883
+ (this.complianceData && this.complianceData.enforcement_deadline) ||
884
+ (this.complianceData && this.complianceData.latest &&
885
+ this.complianceData.latest.enforcement_deadline) ||
886
+ '2027-12-02';
887
+ const deadline = new Date(apiDeadline + 'T00:00:00Z');
888
+ const now = new Date();
889
+ return Math.max(0, Math.ceil((deadline - now) / (1000 * 60 * 60 * 24)));
890
+ },
891
+
892
+ get complianceArticles() {
893
+ if (!this.complianceData || !this.complianceData.latest) return [];
894
+ const latest = this.complianceData.latest;
895
+ if (!latest.applicable) return [];
896
+
897
+ // Build per-article breakdown from gaps
898
+ const articles = [
899
+ { article: 'Art. 9', name: 'Risk Management System', total: 4 },
900
+ { article: 'Art. 10', name: 'Data Governance', total: 4 },
901
+ { article: 'Art. 11', name: 'Technical Documentation', total: 4 },
902
+ { article: 'Art. 12', name: 'Record Keeping / Logging', total: 4 },
903
+ { article: 'Art. 13', name: 'Transparency', total: 4 },
904
+ { article: 'Art. 14', name: 'Human Oversight', total: 4 },
905
+ { article: 'Art. 15', name: 'Accuracy, Robustness, Cybersecurity', total: 4 },
906
+ ];
907
+ const gaps = latest.gaps || [];
908
+ return articles.map(a => {
909
+ const artGaps = gaps.filter(g => g.article === a.article).length;
910
+ const passed = a.total - artGaps;
911
+ return { ...a, passed, pct: Math.round((passed / a.total) * 100) };
912
+ });
913
+ },
914
+
915
+ get complianceScore() {
916
+ const latest = this.complianceData?.latest;
917
+ if (!latest) return 0;
918
+ return latest.compliance_score ?? 0;
919
+ },
920
+
921
+ get complianceGapCount() {
922
+ return this.complianceData?.latest?.gap_count ?? 0;
923
+ },
924
+
925
+ // ── Init ───────────────────────────────────────────
926
+ async init() {
927
+ await this.refresh();
928
+ this.connectWebSocket();
929
+ // Auto-refresh every 10s
930
+ this._refreshTimer = setInterval(() => this.refresh(), 10000);
931
+ },
932
+
933
+ // ── Fetch all data ─────────────────────────────────
934
+ async refresh() {
935
+ try {
936
+ const [scoreRes, compRes, sovRes, statsRes, feedRes, infraRes, modelsRes, oisgRes] = await Promise.all([
937
+ fetch('/api/dashboard/score').then(r => r.json()),
938
+ fetch('/api/dashboard/compliance').then(r => r.json()),
939
+ fetch('/api/dashboard/sovereignty').then(r => r.json()),
940
+ fetch('/api/stats').then(r => r.json()),
941
+ fetch('/api/dashboard/feed?limit=50').then(r => r.json()),
942
+ fetch('/api/dashboard/infra').then(r => r.json()),
943
+ fetch('/api/dashboard/models').then(r => r.json()),
944
+ fetch('/api/dashboard/oisg').then(r => r.json()).catch(() => ({})),
945
+ ]);
946
+
947
+ this.score = scoreRes;
948
+ this.complianceData = compRes;
949
+ this.sovereignty = sovRes;
950
+ this.stats = statsRes;
951
+ this.infraData = infraRes;
952
+ this.modelData = modelsRes;
953
+ this.oisg = oisgRes;
954
+ this.healthy = true;
955
+ this.lastUpdate = new Date().toLocaleTimeString();
956
+
957
+ // Seed the feed with historical events if WS hasn't provided any yet
958
+ if (this.feedEvents.length === 0 && feedRes.events && feedRes.events.length > 0) {
959
+ this.feedEvents = feedRes.events.slice(0, 50).map(e => this._formatFeedEvent(e));
960
+ }
961
+ } catch (err) {
962
+ console.error('Dashboard refresh failed:', err);
963
+ this.healthy = false;
964
+ this.lastUpdate = 'error';
965
+ }
966
+ },
967
+
968
+ // ── WebSocket ──────────────────────────────────────
969
+ connectWebSocket() {
970
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
971
+ const url = `${proto}//${location.host}/api/dashboard/live`;
972
+
973
+ try {
974
+ this._ws = new WebSocket(url);
975
+
976
+ this._ws.onopen = () => {
977
+ this.wsConnected = true;
978
+ console.log('[Admina] WebSocket connected');
979
+ };
980
+
981
+ this._ws.onmessage = (msg) => {
982
+ try {
983
+ const ev = JSON.parse(msg.data);
984
+ const formatted = {
985
+ _id: ++this._feedId,
986
+ time: ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString() : new Date().toLocaleTimeString(),
987
+ action: ev.action || 'ALLOW',
988
+ risk_level: ev.risk_level || 'LOW',
989
+ detail: ev.domain ? `[${ev.domain}] ${ev.event_type}` : ev.event_type || 'event',
990
+ };
991
+ this.feedEvents.unshift(formatted);
992
+ if (this.feedEvents.length > 100) this.feedEvents.pop();
993
+ } catch (e) { /* ignore malformed messages */ }
994
+ };
995
+
996
+ this._ws.onclose = () => {
997
+ this.wsConnected = false;
998
+ console.log('[Admina] WebSocket disconnected, reconnecting in 3s...');
999
+ setTimeout(() => this.connectWebSocket(), 3000);
1000
+ };
1001
+
1002
+ this._ws.onerror = () => {
1003
+ this.wsConnected = false;
1004
+ };
1005
+ } catch (e) {
1006
+ this.wsConnected = false;
1007
+ setTimeout(() => this.connectWebSocket(), 5000);
1008
+ }
1009
+ },
1010
+
1011
+ // ── Helpers ─────────────────────────────────────────
1012
+ copyAssessmentCmd(ev) {
1013
+ const pre = document.getElementById('assessment-cmd');
1014
+ if (!pre) return;
1015
+ navigator.clipboard.writeText(pre.textContent.trim()).then(() => {
1016
+ const btn = ev.currentTarget;
1017
+ const original = btn.textContent;
1018
+ btn.textContent = 'Copied!';
1019
+ setTimeout(() => { btn.textContent = original; }, 1600);
1020
+ }).catch(() => { /* clipboard unavailable */ });
1021
+ },
1022
+
1023
+ formatBytes(bytes) {
1024
+ if (bytes == null) return '—';
1025
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
1026
+ let i = 0;
1027
+ let val = bytes;
1028
+ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
1029
+ return val.toFixed(1) + ' ' + units[i];
1030
+ },
1031
+
1032
+ _formatFeedEvent(e) {
1033
+ return {
1034
+ _id: ++this._feedId,
1035
+ time: e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '—',
1036
+ action: (e.action || 'ALLOW').toUpperCase(),
1037
+ risk_level: (e.risk_level || 'LOW').toUpperCase(),
1038
+ detail: e.method ? `${e.method}${e.tool_name ? ' → ' + e.tool_name : ''}` : e.event_type || 'event',
1039
+ };
1040
+ },
1041
+ };
1042
+ }
1043
+ </script>
1044
+ </body>
1045
+ </html>