aimeat 1.5.0 → 1.6.1

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 (201) hide show
  1. package/dist/.env.example +15 -0
  2. package/dist/bin/aimeat.js +0 -0
  3. package/dist/locales/en.json +79 -2
  4. package/dist/locales/fi.json +79 -2
  5. package/dist/public/css/views/admin.css +97 -0
  6. package/dist/public/js/services/admin.js +7 -1
  7. package/dist/public/views/admin/federation-tab.js +60 -0
  8. package/dist/public/views/admin/stats-tab.js +480 -187
  9. package/dist/public/views/profile/access-tab.js +1 -0
  10. package/dist/src/auth/node-keys.d.ts.map +1 -1
  11. package/dist/src/auth/node-keys.js +3 -0
  12. package/dist/src/auth/node-keys.js.map +1 -1
  13. package/dist/src/config.d.ts +13 -0
  14. package/dist/src/config.d.ts.map +1 -1
  15. package/dist/src/config.js +14 -0
  16. package/dist/src/config.js.map +1 -1
  17. package/dist/src/index.js +41 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/mcp/extensions.js +1 -1
  20. package/dist/src/mcp/extensions.js.map +1 -1
  21. package/dist/src/middleware/rate-limit.d.ts.map +1 -1
  22. package/dist/src/middleware/rate-limit.js +4 -1
  23. package/dist/src/middleware/rate-limit.js.map +1 -1
  24. package/dist/src/models/schemas.d.ts +45 -0
  25. package/dist/src/models/schemas.d.ts.map +1 -1
  26. package/dist/src/models/schemas.js +37 -1
  27. package/dist/src/models/schemas.js.map +1 -1
  28. package/dist/src/routes/actions.d.ts.map +1 -1
  29. package/dist/src/routes/actions.js +1 -0
  30. package/dist/src/routes/actions.js.map +1 -1
  31. package/dist/src/routes/admin-monitoring.d.ts.map +1 -1
  32. package/dist/src/routes/admin-monitoring.js +7 -0
  33. package/dist/src/routes/admin-monitoring.js.map +1 -1
  34. package/dist/src/routes/admin.d.ts.map +1 -1
  35. package/dist/src/routes/admin.js +33 -15
  36. package/dist/src/routes/admin.js.map +1 -1
  37. package/dist/src/routes/agents.js +2 -2
  38. package/dist/src/routes/agents.js.map +1 -1
  39. package/dist/src/routes/apps.d.ts.map +1 -1
  40. package/dist/src/routes/apps.js +5 -1
  41. package/dist/src/routes/apps.js.map +1 -1
  42. package/dist/src/routes/auth.d.ts.map +1 -1
  43. package/dist/src/routes/auth.js +19 -1
  44. package/dist/src/routes/auth.js.map +1 -1
  45. package/dist/src/routes/boards.js +2 -2
  46. package/dist/src/routes/boards.js.map +1 -1
  47. package/dist/src/routes/consent.d.ts.map +1 -1
  48. package/dist/src/routes/consent.js +2 -1
  49. package/dist/src/routes/consent.js.map +1 -1
  50. package/dist/src/routes/disputes.js +4 -4
  51. package/dist/src/routes/disputes.js.map +1 -1
  52. package/dist/src/routes/extensions.d.ts.map +1 -1
  53. package/dist/src/routes/extensions.js +72 -21
  54. package/dist/src/routes/extensions.js.map +1 -1
  55. package/dist/src/routes/federation-auth.d.ts.map +1 -1
  56. package/dist/src/routes/federation-auth.js +2 -62
  57. package/dist/src/routes/federation-auth.js.map +1 -1
  58. package/dist/src/routes/federation-peer.d.ts.map +1 -1
  59. package/dist/src/routes/federation-peer.js +13 -1
  60. package/dist/src/routes/federation-peer.js.map +1 -1
  61. package/dist/src/routes/flags.d.ts.map +1 -1
  62. package/dist/src/routes/flags.js +2 -1
  63. package/dist/src/routes/flags.js.map +1 -1
  64. package/dist/src/routes/foundry.d.ts.map +1 -1
  65. package/dist/src/routes/foundry.js +2 -2
  66. package/dist/src/routes/foundry.js.map +1 -1
  67. package/dist/src/routes/generator.d.ts.map +1 -1
  68. package/dist/src/routes/generator.js +2 -2
  69. package/dist/src/routes/generator.js.map +1 -1
  70. package/dist/src/routes/ghii.d.ts.map +1 -1
  71. package/dist/src/routes/ghii.js +67 -31
  72. package/dist/src/routes/ghii.js.map +1 -1
  73. package/dist/src/routes/memory.d.ts.map +1 -1
  74. package/dist/src/routes/memory.js +2 -0
  75. package/dist/src/routes/memory.js.map +1 -1
  76. package/dist/src/routes/owners.d.ts.map +1 -1
  77. package/dist/src/routes/owners.js +104 -8
  78. package/dist/src/routes/owners.js.map +1 -1
  79. package/dist/src/routes/packages.js +1 -1
  80. package/dist/src/routes/packages.js.map +1 -1
  81. package/dist/src/routes/stats.d.ts +13 -0
  82. package/dist/src/routes/stats.d.ts.map +1 -1
  83. package/dist/src/routes/stats.js +54 -4
  84. package/dist/src/routes/stats.js.map +1 -1
  85. package/dist/src/routes/storage-files.d.ts.map +1 -1
  86. package/dist/src/routes/storage-files.js +5 -1
  87. package/dist/src/routes/storage-files.js.map +1 -1
  88. package/dist/src/routes/upload.js +2 -2
  89. package/dist/src/routes/upload.js.map +1 -1
  90. package/dist/src/routes/wallet.d.ts.map +1 -1
  91. package/dist/src/routes/wallet.js +2 -1
  92. package/dist/src/routes/wallet.js.map +1 -1
  93. package/dist/src/routes/work.d.ts.map +1 -1
  94. package/dist/src/routes/work.js +2 -1
  95. package/dist/src/routes/work.js.map +1 -1
  96. package/dist/src/server-bootstrap/routes-loader.d.ts +1 -1
  97. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  98. package/dist/src/server-bootstrap/routes-loader.js +3 -3
  99. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  100. package/dist/src/server-bootstrap/service-init.d.ts.map +1 -1
  101. package/dist/src/server-bootstrap/service-init.js +2 -0
  102. package/dist/src/server-bootstrap/service-init.js.map +1 -1
  103. package/dist/src/server-bootstrap/static-files.d.ts.map +1 -1
  104. package/dist/src/server-bootstrap/static-files.js +10 -7
  105. package/dist/src/server-bootstrap/static-files.js.map +1 -1
  106. package/dist/src/server.d.ts.map +1 -1
  107. package/dist/src/server.js +4 -2
  108. package/dist/src/server.js.map +1 -1
  109. package/dist/src/services/capability-invoke.d.ts.map +1 -1
  110. package/dist/src/services/capability-invoke.js +5 -18
  111. package/dist/src/services/capability-invoke.js.map +1 -1
  112. package/dist/src/services/config-schema.d.ts.map +1 -1
  113. package/dist/src/services/config-schema.js +13 -0
  114. package/dist/src/services/config-schema.js.map +1 -1
  115. package/dist/src/services/consent.d.ts.map +1 -1
  116. package/dist/src/services/consent.js +1 -10
  117. package/dist/src/services/consent.js.map +1 -1
  118. package/dist/src/services/directory.d.ts.map +1 -1
  119. package/dist/src/services/directory.js +10 -4
  120. package/dist/src/services/directory.js.map +1 -1
  121. package/dist/src/services/email.d.ts +17 -4
  122. package/dist/src/services/email.d.ts.map +1 -1
  123. package/dist/src/services/email.js +30 -12
  124. package/dist/src/services/email.js.map +1 -1
  125. package/dist/src/services/extension-runtime.d.ts.map +1 -1
  126. package/dist/src/services/extension-runtime.js +5 -0
  127. package/dist/src/services/extension-runtime.js.map +1 -1
  128. package/dist/src/services/federation.d.ts +2 -0
  129. package/dist/src/services/federation.d.ts.map +1 -1
  130. package/dist/src/services/federation.js.map +1 -1
  131. package/dist/src/services/mailbox-notification.d.ts +17 -0
  132. package/dist/src/services/mailbox-notification.d.ts.map +1 -1
  133. package/dist/src/services/mailbox-notification.js +25 -0
  134. package/dist/src/services/mailbox-notification.js.map +1 -1
  135. package/dist/src/services/morsel.d.ts.map +1 -1
  136. package/dist/src/services/morsel.js +10 -9
  137. package/dist/src/services/morsel.js.map +1 -1
  138. package/dist/src/services/password.d.ts +1 -6
  139. package/dist/src/services/password.d.ts.map +1 -1
  140. package/dist/src/services/password.js +19 -10
  141. package/dist/src/services/password.js.map +1 -1
  142. package/dist/src/services/push.d.ts +16 -0
  143. package/dist/src/services/push.d.ts.map +1 -1
  144. package/dist/src/services/push.js +20 -0
  145. package/dist/src/services/push.js.map +1 -1
  146. package/dist/src/services/quota.d.ts.map +1 -1
  147. package/dist/src/services/quota.js +2 -1
  148. package/dist/src/services/quota.js.map +1 -1
  149. package/dist/src/services/scheduler.d.ts.map +1 -1
  150. package/dist/src/services/scheduler.js +17 -1
  151. package/dist/src/services/scheduler.js.map +1 -1
  152. package/dist/src/services/stats.d.ts +42 -2
  153. package/dist/src/services/stats.d.ts.map +1 -1
  154. package/dist/src/services/stats.js +176 -1
  155. package/dist/src/services/stats.js.map +1 -1
  156. package/dist/src/services/totp.js +2 -2
  157. package/dist/src/storage/interface.d.ts +6 -1
  158. package/dist/src/storage/interface.d.ts.map +1 -1
  159. package/dist/src/storage/providers/mongodb/index.d.ts +10 -0
  160. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  161. package/dist/src/storage/providers/mongodb/index.js +81 -2
  162. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  163. package/dist/src/storage/providers/sqlite/index.d.ts +10 -0
  164. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  165. package/dist/src/storage/providers/sqlite/index.js +73 -2
  166. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  167. package/dist/src/storage/providers/sqlite/repos/consent.d.ts +1 -0
  168. package/dist/src/storage/providers/sqlite/repos/consent.d.ts.map +1 -1
  169. package/dist/src/storage/providers/sqlite/repos/consent.js +4 -0
  170. package/dist/src/storage/providers/sqlite/repos/consent.js.map +1 -1
  171. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  172. package/dist/src/storage/providers/sqlite/schema.js +19 -0
  173. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  174. package/dist/src/storage/repositories/consent.repository.d.ts +1 -0
  175. package/dist/src/storage/repositories/consent.repository.d.ts.map +1 -1
  176. package/dist/src/storage/repositories/device-auth.repository.d.ts +1 -0
  177. package/dist/src/storage/repositories/device-auth.repository.d.ts.map +1 -1
  178. package/dist/src/storage/repositories/extension-instance.repository.d.ts +1 -0
  179. package/dist/src/storage/repositories/extension-instance.repository.d.ts.map +1 -1
  180. package/dist/src/storage/repositories/index.d.ts +1 -0
  181. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  182. package/dist/src/storage/repositories/knowledge.repository.d.ts +2 -0
  183. package/dist/src/storage/repositories/knowledge.repository.d.ts.map +1 -1
  184. package/dist/src/storage/repositories/moderation.repository.d.ts +1 -0
  185. package/dist/src/storage/repositories/moderation.repository.d.ts.map +1 -1
  186. package/dist/src/storage/repositories/stats.repository.d.ts +12 -0
  187. package/dist/src/storage/repositories/stats.repository.d.ts.map +1 -0
  188. package/dist/src/storage/repositories/stats.repository.js +7 -0
  189. package/dist/src/storage/repositories/stats.repository.js.map +1 -0
  190. package/dist/src/storage/storage-factory.js +1 -0
  191. package/dist/src/storage/storage-factory.js.map +1 -1
  192. package/dist/src/utils/password-validation.d.ts +8 -0
  193. package/dist/src/utils/password-validation.d.ts.map +1 -0
  194. package/dist/src/utils/password-validation.js +25 -0
  195. package/dist/src/utils/password-validation.js.map +1 -0
  196. package/dist/src/utils/service-summary.d.ts +6 -1
  197. package/dist/src/utils/service-summary.d.ts.map +1 -1
  198. package/dist/src/utils/service-summary.js +6 -1
  199. package/dist/src/utils/service-summary.js.map +1 -1
  200. package/package.json +97 -77
  201. package/prisma/schema.prisma +26 -10
@@ -1,187 +1,480 @@
1
- import { h } from 'preact';
2
- import { useEffect, useRef } from 'preact/hooks';
3
- import htm from 'htm';
4
- const html = htm.bind(h);
5
- import { t } from '/js/i18n.js';
6
- import { num, fmtUp, fmtBytes, StatsGrid, StatCard, EconRow } from './shared.js';
7
-
8
- export default function StatsTab({ data }) {
9
- const sd = data.stats;
10
- const chartRef = useRef(false);
11
-
12
- useEffect(() => {
13
- if (sd && !chartRef.current) {
14
- chartRef.current = true;
15
- renderCharts(sd);
16
- }
17
- }, [sd]);
18
-
19
- if (!sd) return html`<div class="empty">${t('dashboard.statsNotAvailable')}</div>`;
20
-
21
- const ts = sd.tunnel || {};
22
- const ms = sd.mailbox || {};
23
- const cs = sd.consent_permissions || {};
24
-
25
- return html`
26
- <p class="adm-text-dim adm-text-sm adm-mb-lg">${t('dashboard.statsExplain')}</p>
27
-
28
- <div class="adm-grid adm-grid-4">
29
- <${StatCard} label=${t('dashboard.requestsTotal')} value=${sd.requests_total} />
30
- <${StatCard} label=${t('dashboard.memoryOps')} value=${(sd.memory_reads || 0) + (sd.memory_writes || 0)} sub=${t('dashboard.reads') + ': ' + num(sd.memory_reads) + ' / ' + t('dashboard.writes') + ': ' + num(sd.memory_writes)} />
31
- <${StatCard} label=${t('dashboard.consentOps')} value=${(sd.consent_grants || 0) + (sd.consent_revocations || 0)} sub=${t('dashboard.grants') + ': ' + num(sd.consent_grants) + ' / ' + t('dashboard.revocations') + ': ' + num(sd.consent_revocations)} />
32
- <${StatCard} label=${t('dashboard.schemaOps')} value=${sd.schema_validations} sub=${t('dashboard.failures') + ': ' + num(sd.schema_validation_failures)} />
33
- </div>
34
-
35
- <div class="adm-grid adm-grid-4 adm-mb-lg">
36
- <${StatCard} label=${t('dashboard.uptime')} value=${fmtUp(sd.uptime_seconds || 0)} />
37
- <${StatCard} label=${t('dashboard.registeredOwners')} value=${sd.active_owners} />
38
- <${StatCard} label=${t('dashboard.registeredAgents')} value=${sd.active_agents} />
39
- </div>
40
-
41
- <div class="adm-grid adm-grid-2">
42
- <div class="adm-card"><h2>${t('dashboard.dailyActivity')}</h2><canvas id="chartDaily" height="200"></canvas></div>
43
- <div class="adm-card"><h2>${t('dashboard.weeklyComparison')}</h2><canvas id="chartWeekly" height="200"></canvas></div>
44
- </div>
45
- <div class="adm-card adm-mt-lg"><h2>${t('dashboard.monthlyTrend')}</h2><canvas id="chartMonthly" height="160"></canvas></div>
46
-
47
- <!-- Tunnel Stats -->
48
- <h3 class="adm-text-accent" style="margin-top:24px;font-size:.9rem">${t('dashboard.tunnelStats')}</h3>
49
- <div class="adm-grid adm-grid-4 adm-mt-md">
50
- <${StatCard} label=${t('dashboard.tunnelActive')} value=${ts.connections_active || 0} color="#22c55e" />
51
- <${StatCard} label=${t('dashboard.tunnelTotal')} value=${ts.connections_total || 0} />
52
- <${StatCard} label=${t('dashboard.tunnelDisconnections')} value=${ts.disconnections_total || 0} color="#eab308" />
53
- <${StatCard} label=${t('dashboard.tunnelReconnects')} value=${ts.reconnects_total || 0} color="#3b82f6" />
54
- </div>
55
- <div class="adm-grid adm-grid-4">
56
- <${StatCard} label=${t('dashboard.tunnelMsgSent')} value=${ts.messages_sent_total || 0} />
57
- <${StatCard} label=${t('dashboard.tunnelMsgReceived')} value=${ts.messages_received_total || 0} />
58
- <${StatCard} label=${t('dashboard.tunnelDeliveryFails')} value=${ts.delivery_failures_total || 0} color=${ts.delivery_failures_total > 0 ? '#ef4444' : '#22c55e'} />
59
- <${StatCard} label=${t('dashboard.tunnelHeartbeatMisses')} value=${ts.heartbeat_misses_total || 0} color=${ts.heartbeat_misses_total > 0 ? '#eab308' : '#22c55e'} />
60
- </div>
61
- <div class="adm-grid adm-grid-2">
62
- <div class="adm-card">
63
- <h2>${t('dashboard.latency')}</h2>
64
- <${EconRow} label=${t('dashboard.tunnelLatencyAvg')} value=${(ts.delivery_latency_avg_ms || 0).toFixed(1) + ' ms'} />
65
- <${EconRow} label=${t('dashboard.tunnelLatencyP95')} value=${(ts.delivery_latency_p95_ms || 0).toFixed(1) + ' ms'} />
66
- <${EconRow} label=${t('dashboard.tunnelMailboxFallbacks')} value=${num(ts.mailbox_fallbacks_total || 0)} />
67
- </div>
68
- <div class="adm-card"><h2>${t('dashboard.tunnelActivity')}</h2><canvas id="chartTunnel" height="180"></canvas></div>
69
- </div>
70
-
71
- <!-- Mailbox Stats -->
72
- <h3 class="adm-mt-lg adm-text-sm" style="color:#a855f7">${t('dashboard.mailboxStats')}</h3>
73
- <div class="adm-grid adm-grid-4 adm-mt-md">
74
- <${StatCard} label=${t('dashboard.mailboxItems')} value=${ms.items_total || 0} />
75
- <${StatCard} label=${t('dashboard.mailboxBytes')} value=${fmtBytes(ms.bytes_total || 0)} />
76
- <${StatCard} label=${t('dashboard.mailboxEnqueued')} value=${ms.enqueued_total || 0} color="#3b82f6" />
77
- <${StatCard} label=${t('dashboard.mailboxDelivered')} value=${ms.delivered_total || 0} color="#22c55e" />
78
- </div>
79
- <div class="adm-grid adm-grid-4">
80
- <${StatCard} label=${t('dashboard.mailboxExpired')} value=${ms.expired_total || 0} color=${ms.expired_total > 0 ? '#eab308' : '#22c55e'} />
81
- <${StatCard} label=${t('dashboard.mailboxQuotaRejects')} value=${ms.quota_rejections_total || 0} color=${ms.quota_rejections_total > 0 ? '#ef4444' : '#22c55e'} />
82
- <${StatCard} label=${t('dashboard.mailboxOldestAge')} value=${fmtUp(ms.oldest_item_age_seconds || 0)} color="var(--text-dim)" />
83
- </div>
84
-
85
- <!-- Consent Permission Stats -->
86
- <h3 class="adm-mt-lg adm-text-sm" style="color:#a855f7">${t('dashboard.consentPermStats')}</h3>
87
- <div class="adm-grid adm-grid-4 adm-mt-md">
88
- <${StatCard} label=${t('dashboard.consentActiveRules')} value=${cs.active_rules || 0} color="#a855f7" />
89
- <${StatCard} label=${t('dashboard.consentByGaii')} value=${cs.by_gaii || 0} color="#3b82f6" />
90
- <${StatCard} label=${t('dashboard.consentByGhii')} value=${cs.by_ghii || 0} color="#a855f7" />
91
- <${StatCard} label=${t('dashboard.consentByOrganism')} value=${cs.by_organism || 0} color="#22c55e" />
92
- </div>
93
- <div class="adm-grid adm-grid-4">
94
- <${StatCard} label=${t('dashboard.consentByDomain')} value=${cs.by_domain || 0} color="#eab308" />
95
- <${StatCard} label=${t('dashboard.consentByNode')} value=${cs.by_node || 0} color="var(--text-dim)" />
96
- <${StatCard} label=${t('dashboard.consentByWildcard')} value=${cs.by_wildcard || 0} color=${cs.by_wildcard > 0 ? '#ef4444' : '#22c55e'} />
97
- <${StatCard} label=${t('dashboard.consentDataPatterns')} value=${cs.unique_patterns || 0} />
98
- </div>
99
-
100
- <!-- Security Stats -->
101
- <h3 class="adm-mt-lg adm-text-sm" style="color:#ef4444">${t('dashboard.securityStats')}</h3>
102
- <div class="adm-grid adm-grid-4 adm-mt-md">
103
- <${StatCard} label=${t('dashboard.authFailures')} value=${sd.auth_failures_total || 0} color=${sd.auth_failures_total > 0 ? '#ef4444' : '#22c55e'} />
104
- <${StatCard} label=${t('dashboard.rateLimitHits')} value=${sd.rate_limit_hits_total || 0} color=${sd.rate_limit_hits_total > 0 ? '#eab308' : '#22c55e'} />
105
- <${StatCard} label=${t('dashboard.scopeDenials')} value=${sd.scope_denials_total || 0} color=${sd.scope_denials_total > 0 ? '#eab308' : '#22c55e'} />
106
- </div>
107
- `;
108
- }
109
-
110
- /** Load Chart.js and render charts */
111
- async function renderCharts(sd) {
112
- // Load Chart.js from CDN
113
- if (!window.Chart) {
114
- await new Promise(resolve => {
115
- const s = document.createElement('script');
116
- s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js';
117
- s.onload = resolve;
118
- document.head.appendChild(s);
119
- });
120
- }
121
- if (!window.Chart) return;
122
-
123
- const daily = sd.daily_history || {};
124
- const days = Object.keys(daily).sort().slice(-30);
125
- const chartOpts = {
126
- responsive: true,
127
- plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } },
128
- scales: {
129
- x: { ticks: { color: '#94a3b8', maxRotation: 45 }, grid: { color: '#334155' } },
130
- y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },
131
- },
132
- };
133
-
134
- const dc = document.getElementById('chartDaily');
135
- if (dc) {
136
- new Chart(dc, { type: 'bar', data: {
137
- labels: days.map(d => d.slice(5)),
138
- datasets: [
139
- { label: 'Requests', data: days.map(d => (daily[d] || {}).requests_total || 0), backgroundColor: '#3b82f688' },
140
- { label: 'Writes', data: days.map(d => (daily[d] || {}).memory_writes || 0), backgroundColor: '#22c55e88' },
141
- { label: 'Reads', data: days.map(d => (daily[d] || {}).memory_reads || 0), backgroundColor: '#06b6d488' },
142
- ],
143
- }, options: chartOpts });
144
- }
145
-
146
- const wc = document.getElementById('chartWeekly');
147
- if (wc && days.length > 0) {
148
- const weekLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
149
- const getWeekData = (weeksAgo) => {
150
- const data = new Array(7).fill(0);
151
- const now = new Date();
152
- for (let i = 0; i < 7; i++) {
153
- const d = new Date(now);
154
- d.setDate(d.getDate() - d.getDay() + i + 1 - (weeksAgo * 7));
155
- const key = d.toISOString().slice(0, 10);
156
- if (daily[key]) data[i] = daily[key].requests_total || 0;
157
- }
158
- return data;
159
- };
160
- new Chart(wc, { type: 'line', data: {
161
- labels: weekLabels,
162
- datasets: [
163
- { label: 'This week', data: getWeekData(0), borderColor: '#3b82f6', backgroundColor: '#3b82f622', fill: true },
164
- { label: 'Last week', data: getWeekData(1), borderColor: '#22c55e', backgroundColor: '#22c55e22', fill: true },
165
- ],
166
- }, options: chartOpts });
167
- }
168
-
169
- const mc = document.getElementById('chartMonthly');
170
- if (mc && days.length > 0) {
171
- new Chart(mc, { type: 'line', data: {
172
- labels: days.map(d => d.slice(5)),
173
- datasets: [{ label: 'Requests', data: days.map(d => (daily[d] || {}).requests_total || 0), borderColor: '#06b6d4', backgroundColor: '#06b6d422', fill: true }],
174
- }, options: chartOpts });
175
- }
176
-
177
- // Tunnel chart
178
- const tc = document.getElementById('chartTunnel');
179
- if (tc) {
180
- const tunnel = sd.tunnel || {};
181
- new Chart(tc, { type: 'doughnut', data: {
182
- labels: ['Sent', 'Received', 'Rejected', 'Fallbacks'],
183
- datasets: [{ data: [tunnel.messages_sent_total || 0, tunnel.messages_received_total || 0, tunnel.delivery_failures_total || 0, tunnel.mailbox_fallbacks_total || 0],
184
- backgroundColor: ['#3b82f6', '#22c55e', '#ef4444', '#eab308'] }],
185
- }, options: { responsive: true, plugins: { legend: { labels: { color: '#94a3b8' } } } } });
186
- }
187
- }
1
+ /**
2
+ * @file stats-tab.js
3
+ * @description Admin dashboard Statistics tab with time range selector, notification
4
+ * delivery sections (email, push, mailbox), and per-day Chart.js charts.
5
+ * Manages its own data fetching so the period can be changed without reloading
6
+ * the entire dashboard.
7
+ * @structure
8
+ * - getDateRange(period) -- converts preset key to { from, to } date strings
9
+ * - successRate / rateColor -- helpers for delivery percentage display
10
+ * - sumByPrefix -- sums daily history keys matching a prefix
11
+ * - TimeRange -- period preset buttons + custom date inputs
12
+ * - BreakdownTable -- generic type-breakdown table (email / push)
13
+ * - StatsTab (default) -- main tab component
14
+ * - renderAllCharts -- loads Chart.js and draws all canvases
15
+ * @version-history
16
+ * v1.0.0 -- 2026-05-01 -- Initial stats tab with basic cards and Chart.js charts
17
+ * v2.0.0 -- 2026-05-21 -- Major rewrite: time range selector, email / push /
18
+ * mailbox notification sections, per-day charts, self-managed data fetching
19
+ * v2.0.1 -- 2026-05-21 -- i18n chart labels, chart cleanup on unmount, live
20
+ * badges on gauge cards, section header CSS classes, breakdown table th fix
21
+ * v2.0.2 -- 2026-05-21 -- Replace inline styles with CSS classes, i18n weekday
22
+ * labels, use periodCustom/periodFrom/periodTo i18n keys on date inputs
23
+ */
24
+ import { h } from 'preact';
25
+ import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
26
+ import htm from 'htm';
27
+ const html = htm.bind(h);
28
+ import { t } from '/js/i18n.js';
29
+ import { num, fmtUp, fmtBytes, StatsGrid, StatCard, EconRow } from './shared.js';
30
+ import * as api from '/js/services/admin.js';
31
+
32
+ /* ── Helpers ── */
33
+
34
+ /** Compute { from, to } date strings from a period preset key. */
35
+ function getDateRange(period) {
36
+ const today = new Date();
37
+ const fmt = d => d.toISOString().split('T')[0];
38
+ const to = fmt(today);
39
+ switch (period) {
40
+ case 'today': return { from: to, to };
41
+ case 'week': {
42
+ const mon = new Date(today);
43
+ mon.setDate(mon.getDate() - ((mon.getDay() + 6) % 7));
44
+ return { from: fmt(mon), to };
45
+ }
46
+ case '7d': {
47
+ const d = new Date(today); d.setDate(d.getDate() - 6);
48
+ return { from: fmt(d), to };
49
+ }
50
+ case '30d': {
51
+ const d = new Date(today); d.setDate(d.getDate() - 29);
52
+ return { from: fmt(d), to };
53
+ }
54
+ default: return null; // 'all' = no params
55
+ }
56
+ }
57
+
58
+ function successRate(sent, failed) {
59
+ if (!sent && !failed) return null;
60
+ return ((sent / (sent + failed)) * 100).toFixed(1);
61
+ }
62
+
63
+ function rateColor(rate) {
64
+ if (rate === null) return 'var(--text-dim)';
65
+ const n = parseFloat(rate);
66
+ if (n >= 95) return '#22c55e';
67
+ if (n >= 80) return '#eab308';
68
+ return '#ef4444';
69
+ }
70
+
71
+ /** Sum all values in a day-object whose key starts with `prefix`. */
72
+ function sumByPrefix(dayData, prefix) {
73
+ let total = 0;
74
+ for (const [key, val] of Object.entries(dayData || {})) {
75
+ if (key.startsWith(prefix)) total += val;
76
+ }
77
+ return total;
78
+ }
79
+
80
+ /* ── Sub-components ── */
81
+
82
+ function TimeRange({ period, setPeriod, customFrom, setCustomFrom, customTo, setCustomTo, onApply }) {
83
+ const presets = [
84
+ { key: 'today', label: t('dashboard.periodToday') },
85
+ { key: 'week', label: t('dashboard.periodThisWeek') },
86
+ { key: '7d', label: t('dashboard.period7Days') },
87
+ { key: '30d', label: t('dashboard.period30Days') },
88
+ { key: 'all', label: t('dashboard.periodAll') },
89
+ ];
90
+ return html`
91
+ <div class="adm-time-range">
92
+ <span class="adm-time-range-label">${t('dashboard.periodLabel')}:</span>
93
+ ${presets.map(p => html`
94
+ <button class="adm-time-btn ${period === p.key ? 'active' : ''}"
95
+ onClick=${() => setPeriod(p.key)}>${p.label}</button>
96
+ `)}
97
+ <div class="adm-time-custom">
98
+ <span class="adm-text-dim adm-text-sm">${t('dashboard.periodCustom')}:</span>
99
+ <input type="date" value=${customFrom} aria-label=${t('dashboard.periodFrom')}
100
+ onInput=${e => setCustomFrom(e.target.value)} />
101
+ <span class="adm-date-separator">-</span>
102
+ <input type="date" value=${customTo} aria-label=${t('dashboard.periodTo')}
103
+ onInput=${e => setCustomTo(e.target.value)} />
104
+ <button class="adm-time-btn" onClick=${onApply}>${t('dashboard.periodApply')}</button>
105
+ </div>
106
+ </div>
107
+ `;
108
+ }
109
+
110
+ function BreakdownTable({ byType, columns, typePrefix }) {
111
+ if (!byType || Object.keys(byType).length === 0) return null;
112
+ return html`
113
+ <table class="adm-breakdown-table">
114
+ <thead><tr>
115
+ <th>${t('dashboard.breakdownType')}</th>
116
+ ${columns.map(c => html`<th class="num">${t(c.label)}</th>`)}
117
+ </tr></thead>
118
+ <tbody>
119
+ ${Object.keys(byType).map(type => html`
120
+ <tr>
121
+ <td>${t(typePrefix + type) || type}</td>
122
+ ${columns.map(c => html`<td class="num">${num(c.getData(type))}</td>`)}
123
+ </tr>
124
+ `)}
125
+ </tbody>
126
+ </table>
127
+ `;
128
+ }
129
+
130
+ /* ── Main component ── */
131
+
132
+ export default function StatsTab({ data }) {
133
+ const [period, setPeriod] = useState('7d');
134
+ const [customFrom, setCustomFrom] = useState('');
135
+ const [customTo, setCustomTo] = useState('');
136
+ const [sd, setSd] = useState(data.stats);
137
+ const [loading, setLoading] = useState(false);
138
+ const chartRefs = useRef({});
139
+
140
+ const fetchStats = useCallback(async (p, cf, ct) => {
141
+ setLoading(true);
142
+ try {
143
+ const range = p === 'custom'
144
+ ? (cf && ct ? { from: cf, to: ct } : null)
145
+ : getDateRange(p);
146
+ const resp = await api.getStats(range?.from, range?.to);
147
+ if (resp.data) setSd(resp.data);
148
+ } catch { /* keep existing data */ }
149
+ setLoading(false);
150
+ }, []);
151
+
152
+ useEffect(() => {
153
+ if (period !== 'custom') fetchStats(period);
154
+ }, [period]);
155
+
156
+ // Listen for live updates
157
+ useEffect(() => {
158
+ const handler = () => { fetchStats(period, customFrom, customTo); };
159
+ window.addEventListener('aimeat-live-update', handler);
160
+ return () => window.removeEventListener('aimeat-live-update', handler);
161
+ }, [period, customFrom, customTo]);
162
+
163
+ // Re-render charts when data changes; destroy on unmount
164
+ useEffect(() => {
165
+ if (sd) renderAllCharts(sd, chartRefs);
166
+ return () => {
167
+ for (const key of Object.keys(chartRefs.current)) {
168
+ if (chartRefs.current[key]) {
169
+ chartRefs.current[key].destroy();
170
+ delete chartRefs.current[key];
171
+ }
172
+ }
173
+ };
174
+ }, [sd]);
175
+
176
+ if (!sd) return html`<div class="empty">${t('dashboard.statsNotAvailable')}</div>`;
177
+
178
+ const ts = sd.tunnel || {};
179
+ const ms = sd.mailbox || {};
180
+ const cs = sd.consent_permissions || {};
181
+ const gs = sd.gauges || {};
182
+
183
+ // Email stats
184
+ const emailSent = sd.email_sent || 0;
185
+ const emailFailed = sd.email_failed || 0;
186
+ const emailRetried = sd.email_retried || 0;
187
+ const emailRate = successRate(emailSent, emailFailed);
188
+
189
+ // Push stats
190
+ const pushSent = sd.push_sent || 0;
191
+ const pushFailed = sd.push_failed || 0;
192
+ const pushExpired = sd.push_expired_subs || 0;
193
+ const pushRate = successRate(pushSent, pushFailed);
194
+
195
+ // Mailbox notification stats
196
+ const mboxSent = sd.mailbox_notif_sent || 0;
197
+ const mboxFailed = sd.mailbox_notif_failed || 0;
198
+ const mboxBlocked = sd.mailbox_notif_blocked || 0;
199
+
200
+ return html`
201
+ <!-- Time Range Selector -->
202
+ <${TimeRange} period=${period} setPeriod=${setPeriod}
203
+ customFrom=${customFrom} setCustomFrom=${setCustomFrom}
204
+ customTo=${customTo} setCustomTo=${setCustomTo}
205
+ onApply=${() => fetchStats('custom', customFrom, customTo)} />
206
+
207
+ ${loading ? html`<div class="adm-text-dim adm-text-sm">${t('dashboard.loading')}</div>` : ''}
208
+
209
+ <p class="adm-text-dim adm-text-sm adm-mb-lg">${t('dashboard.statsExplain')}</p>
210
+
211
+ <!-- Top-level stat cards -->
212
+ <div class="adm-grid adm-grid-4">
213
+ <${StatCard} label=${t('dashboard.requestsTotal')} value=${sd.requests_total} />
214
+ <${StatCard} label=${t('dashboard.memoryOps')} value=${(sd.memory_reads || 0) + (sd.memory_writes || 0)} sub=${t('dashboard.reads') + ': ' + num(sd.memory_reads) + ' / ' + t('dashboard.writes') + ': ' + num(sd.memory_writes)} />
215
+ <${StatCard} label=${t('dashboard.consentOps')} value=${(sd.consent_grants || 0) + (sd.consent_revocations || 0)} sub=${t('dashboard.grants') + ': ' + num(sd.consent_grants) + ' / ' + t('dashboard.revocations') + ': ' + num(sd.consent_revocations)} />
216
+ <${StatCard} label=${t('dashboard.schemaOps')} value=${sd.schema_validations} sub=${t('dashboard.failures') + ': ' + num(sd.schema_validation_failures)} />
217
+ </div>
218
+
219
+ <div class="adm-grid adm-grid-4 adm-mb-lg">
220
+ <${StatCard} label=${t('dashboard.uptime')} value=${fmtUp(sd.uptime_seconds || 0)} />
221
+ <${StatCard} label=${t('dashboard.registeredOwners')} value=${sd.active_owners} />
222
+ <${StatCard} label=${t('dashboard.registeredAgents')} value=${sd.active_agents} />
223
+ </div>
224
+
225
+ <!-- Charts (daily, weekly, monthly) -->
226
+ <div class="adm-grid adm-grid-2">
227
+ <div class="adm-card"><h2>${t('dashboard.dailyActivity')}</h2><canvas id="chartDaily" height="200"></canvas></div>
228
+ <div class="adm-card"><h2>${t('dashboard.weeklyComparison')}</h2><canvas id="chartWeekly" height="200"></canvas></div>
229
+ </div>
230
+ <div class="adm-card adm-mt-lg"><h2>${t('dashboard.monthlyTrend')}</h2><canvas id="chartMonthly" height="160"></canvas></div>
231
+
232
+ <!-- Tunnel Stats -->
233
+ <h3 class="adm-mt-lg adm-text-sm adm-text-accent">${t('dashboard.tunnelStats')}</h3>
234
+ <div class="adm-grid adm-grid-4 adm-mt-md">
235
+ <${StatCard} label=${html`${t('dashboard.tunnelActive')} <span class="adm-badge-live">${t('dashboard.live')}</span>`} value=${gs.tunnel_connections_active ?? ts.connections_active ?? 0} color="#22c55e" />
236
+ <${StatCard} label=${t('dashboard.tunnelTotal')} value=${ts.connections_total || 0} />
237
+ <${StatCard} label=${t('dashboard.tunnelDisconnections')} value=${ts.disconnections_total || 0} color="#eab308" />
238
+ <${StatCard} label=${t('dashboard.tunnelReconnects')} value=${ts.reconnects_total || 0} color="#3b82f6" />
239
+ </div>
240
+ <div class="adm-grid adm-grid-4">
241
+ <${StatCard} label=${t('dashboard.tunnelMsgSent')} value=${ts.messages_sent_total || 0} />
242
+ <${StatCard} label=${t('dashboard.tunnelMsgReceived')} value=${ts.messages_received_total || 0} />
243
+ <${StatCard} label=${t('dashboard.tunnelDeliveryFails')} value=${ts.delivery_failures_total || 0} color=${ts.delivery_failures_total > 0 ? '#ef4444' : '#22c55e'} />
244
+ <${StatCard} label=${t('dashboard.tunnelHeartbeatMisses')} value=${ts.heartbeat_misses_total || 0} color=${ts.heartbeat_misses_total > 0 ? '#eab308' : '#22c55e'} />
245
+ </div>
246
+ <div class="adm-grid adm-grid-2">
247
+ <div class="adm-card">
248
+ <h2>${t('dashboard.latency')}</h2>
249
+ <${EconRow} label=${t('dashboard.tunnelLatencyAvg')} value=${(ts.delivery_latency_avg_ms || 0).toFixed(1) + ' ms'} />
250
+ <${EconRow} label=${t('dashboard.tunnelLatencyP95')} value=${(ts.delivery_latency_p95_ms || 0).toFixed(1) + ' ms'} />
251
+ <${EconRow} label=${t('dashboard.tunnelMailboxFallbacks')} value=${num(ts.mailbox_fallbacks_total || 0)} />
252
+ </div>
253
+ <div class="adm-card"><h2>${t('dashboard.tunnelActivity')}</h2><canvas id="chartTunnel" height="180"></canvas></div>
254
+ </div>
255
+
256
+ <!-- Mailbox Stats -->
257
+ <h3 class="adm-mt-lg adm-text-sm adm-section-purple">${t('dashboard.mailboxStats')}</h3>
258
+ <div class="adm-grid adm-grid-4 adm-mt-md">
259
+ <${StatCard} label=${html`${t('dashboard.mailboxItems')} <span class="adm-badge-live">${t('dashboard.live')}</span>`} value=${gs.mailbox_items_total ?? ms.items_total ?? 0} />
260
+ <${StatCard} label=${html`${t('dashboard.mailboxBytes')} <span class="adm-badge-live">${t('dashboard.live')}</span>`} value=${fmtBytes(gs.mailbox_bytes_total ?? ms.bytes_total ?? 0)} />
261
+ <${StatCard} label=${t('dashboard.mailboxEnqueued')} value=${ms.enqueued_total || 0} color="#3b82f6" />
262
+ <${StatCard} label=${t('dashboard.mailboxDelivered')} value=${ms.delivered_total || 0} color="#22c55e" />
263
+ </div>
264
+ <div class="adm-grid adm-grid-4">
265
+ <${StatCard} label=${t('dashboard.mailboxExpired')} value=${ms.expired_total || 0} color=${ms.expired_total > 0 ? '#eab308' : '#22c55e'} />
266
+ <${StatCard} label=${t('dashboard.mailboxQuotaRejects')} value=${ms.quota_rejections_total || 0} color=${ms.quota_rejections_total > 0 ? '#ef4444' : '#22c55e'} />
267
+ <${StatCard} label=${html`${t('dashboard.mailboxOldestAge')} <span class="adm-badge-live">${t('dashboard.live')}</span>`} value=${fmtUp(gs.mailbox_oldest_item_age_seconds ?? ms.oldest_item_age_seconds ?? 0)} color="var(--text-dim)" />
268
+ </div>
269
+
270
+ <!-- Email Delivery -->
271
+ <h3 class="adm-mt-lg adm-text-sm adm-section-cyan">${t('dashboard.emailDelivery')}</h3>
272
+ <div class="adm-grid adm-grid-4 adm-mt-md">
273
+ <${StatCard} label=${t('dashboard.emailsSent')} value=${emailSent} />
274
+ <${StatCard} label=${t('dashboard.emailsFailed')} value=${emailFailed} color=${emailFailed > 0 ? '#ef4444' : '#22c55e'} />
275
+ <${StatCard} label=${t('dashboard.emailsRetried')} value=${emailRetried} color=${emailRetried > 0 ? '#eab308' : '#22c55e'} />
276
+ <${StatCard} label=${t('dashboard.successRate')} value=${emailRate !== null ? emailRate + '%' : '—'} color=${rateColor(emailRate)} />
277
+ </div>
278
+ ${sd.email_sent_by_type ? html`
279
+ <div class="adm-card adm-mt-md">
280
+ <${BreakdownTable} byType=${sd.email_sent_by_type}
281
+ typePrefix="dashboard.emailType."
282
+ columns=${[
283
+ { label: 'dashboard.breakdownSent', getData: type => (sd.email_sent_by_type || {})[type] || 0 },
284
+ { label: 'dashboard.breakdownFailed', getData: type => (sd.email_failed_by_type || {})[type] || 0 },
285
+ { label: 'dashboard.breakdownRetried', getData: type => (sd.email_retried_by_type || {})[type] || 0 },
286
+ ]} />
287
+ </div>
288
+ ` : ''}
289
+ <div class="adm-card adm-mt-md"><canvas id="chartEmailDaily" height="180"></canvas></div>
290
+
291
+ <!-- Push Notifications -->
292
+ <h3 class="adm-mt-lg adm-text-sm adm-section-violet">${t('dashboard.pushDelivery')}</h3>
293
+ <div class="adm-grid adm-grid-4 adm-mt-md">
294
+ <${StatCard} label=${t('dashboard.pushSent')} value=${pushSent} />
295
+ <${StatCard} label=${t('dashboard.pushFailed')} value=${pushFailed} color=${pushFailed > 0 ? '#ef4444' : '#22c55e'} />
296
+ <${StatCard} label=${t('dashboard.expiredSubs')} value=${pushExpired} color=${pushExpired > 0 ? '#eab308' : '#22c55e'} />
297
+ <${StatCard} label=${t('dashboard.successRate')} value=${pushRate !== null ? pushRate + '%' : '—'} color=${rateColor(pushRate)} />
298
+ </div>
299
+ ${sd.push_sent_by_type ? html`
300
+ <div class="adm-card adm-mt-md">
301
+ <${BreakdownTable} byType=${sd.push_sent_by_type}
302
+ typePrefix="dashboard.pushType."
303
+ columns=${[
304
+ { label: 'dashboard.breakdownSent', getData: type => (sd.push_sent_by_type || {})[type] || 0 },
305
+ { label: 'dashboard.breakdownFailed', getData: type => (sd.push_failed_by_type || {})[type] || 0 },
306
+ ]} />
307
+ </div>
308
+ ` : ''}
309
+ <div class="adm-card adm-mt-md"><canvas id="chartPushDaily" height="180"></canvas></div>
310
+
311
+ <!-- Mailbox Notifications -->
312
+ <h3 class="adm-mt-lg adm-text-sm adm-section-amber">${t('dashboard.mailboxNotifications')}</h3>
313
+ <div class="adm-grid adm-grid-3 adm-mt-md">
314
+ <${StatCard} label=${t('dashboard.mailboxNotifSent')} value=${mboxSent} />
315
+ <${StatCard} label=${t('dashboard.mailboxNotifFailed')} value=${mboxFailed} color=${mboxFailed > 0 ? '#ef4444' : '#22c55e'} />
316
+ <${StatCard} label=${t('dashboard.mailboxNotifBlocked')} value=${mboxBlocked} color=${mboxBlocked > 0 ? '#eab308' : '#22c55e'} />
317
+ </div>
318
+ ${(sd.mailbox_notif_blocked_by_type || sd.mailbox_notif_sent_by_type) ? html`
319
+ <div class="adm-card adm-mt-md adm-text-sm adm-mailbox-inline">
320
+ ${sd.mailbox_notif_blocked_by_type ? html`
321
+ <div class="adm-blocked-reasons">
322
+ <strong>${t('dashboard.blocked')}:</strong>
323
+ ${' '} ${t('dashboard.blockedCooldown')}: ${num((sd.mailbox_notif_blocked_by_type || {}).cooldown || 0)}
324
+ ${' | '} ${t('dashboard.blockedQuietHours')}: ${num((sd.mailbox_notif_blocked_by_type || {}).quiet_hours || 0)}
325
+ ${' | '} ${t('dashboard.blockedDisabled')}: ${num((sd.mailbox_notif_blocked_by_type || {}).disabled || 0)}
326
+ </div>
327
+ ` : ''}
328
+ ${sd.mailbox_notif_sent_by_type ? html`
329
+ <div class="adm-channels-summary">
330
+ <strong>${t('dashboard.channels')}:</strong>
331
+ ${' '} ${t('dashboard.channelPush')}: ${num((sd.mailbox_notif_sent_by_type || {}).push || 0)} ${t('dashboard.breakdownSent').toLowerCase()}, ${num((sd.mailbox_notif_failed_by_type || {}).push || 0)} ${t('dashboard.breakdownFailed').toLowerCase()}
332
+ ${' | '} ${t('dashboard.channelEmail')}: ${num((sd.mailbox_notif_sent_by_type || {}).email || 0)} ${t('dashboard.breakdownSent').toLowerCase()}, ${num((sd.mailbox_notif_failed_by_type || {}).email || 0)} ${t('dashboard.breakdownFailed').toLowerCase()}
333
+ </div>
334
+ ` : ''}
335
+ </div>
336
+ ` : ''}
337
+
338
+ <!-- Consent Permission Stats -->
339
+ <h3 class="adm-mt-lg adm-text-sm adm-section-purple">${t('dashboard.consentPermStats')}</h3>
340
+ <div class="adm-grid adm-grid-4 adm-mt-md">
341
+ <${StatCard} label=${t('dashboard.consentActiveRules')} value=${cs.active_rules || 0} color="#a855f7" />
342
+ <${StatCard} label=${t('dashboard.consentByGaii')} value=${cs.by_gaii || 0} color="#3b82f6" />
343
+ <${StatCard} label=${t('dashboard.consentByGhii')} value=${cs.by_ghii || 0} color="#a855f7" />
344
+ <${StatCard} label=${t('dashboard.consentByOrganism')} value=${cs.by_organism || 0} color="#22c55e" />
345
+ </div>
346
+ <div class="adm-grid adm-grid-4">
347
+ <${StatCard} label=${t('dashboard.consentByDomain')} value=${cs.by_domain || 0} color="#eab308" />
348
+ <${StatCard} label=${t('dashboard.consentByNode')} value=${cs.by_node || 0} color="var(--text-dim)" />
349
+ <${StatCard} label=${t('dashboard.consentByWildcard')} value=${cs.by_wildcard || 0} color=${cs.by_wildcard > 0 ? '#ef4444' : '#22c55e'} />
350
+ <${StatCard} label=${t('dashboard.consentDataPatterns')} value=${cs.unique_patterns || 0} />
351
+ </div>
352
+
353
+ <!-- Security Stats -->
354
+ <h3 class="adm-mt-lg adm-text-sm adm-section-red">${t('dashboard.securityStats')}</h3>
355
+ <div class="adm-grid adm-grid-4 adm-mt-md">
356
+ <${StatCard} label=${t('dashboard.authFailures')} value=${sd.auth_failures_total || 0} color=${sd.auth_failures_total > 0 ? '#ef4444' : '#22c55e'} />
357
+ <${StatCard} label=${t('dashboard.rateLimitHits')} value=${sd.rate_limit_hits_total || 0} color=${sd.rate_limit_hits_total > 0 ? '#eab308' : '#22c55e'} />
358
+ <${StatCard} label=${t('dashboard.scopeDenials')} value=${sd.scope_denials_total || 0} color=${sd.scope_denials_total > 0 ? '#eab308' : '#22c55e'} />
359
+ </div>
360
+ `;
361
+ }
362
+
363
+ /* ── Chart rendering ── */
364
+
365
+ /** Load Chart.js from CDN and render all charts. */
366
+ async function renderAllCharts(sd, chartRefs) {
367
+ // Load Chart.js if not already available
368
+ if (!window.Chart) {
369
+ await new Promise(resolve => {
370
+ const s = document.createElement('script');
371
+ s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js';
372
+ s.onload = resolve;
373
+ document.head.appendChild(s);
374
+ });
375
+ }
376
+ if (!window.Chart) return;
377
+
378
+ // Destroy previous chart instances
379
+ const refs = chartRefs.current;
380
+ for (const key of Object.keys(refs)) {
381
+ if (refs[key] && typeof refs[key].destroy === 'function') {
382
+ refs[key].destroy();
383
+ }
384
+ }
385
+ chartRefs.current = {};
386
+
387
+ const daily = sd.daily || sd.daily_history || {};
388
+ const days = Object.keys(daily).sort().slice(-30);
389
+
390
+ const chartOpts = {
391
+ responsive: true,
392
+ plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } },
393
+ scales: {
394
+ x: { ticks: { color: '#94a3b8', maxRotation: 45 }, grid: { color: '#334155' } },
395
+ y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },
396
+ },
397
+ };
398
+
399
+ // Daily Activity bar chart
400
+ const dc = document.getElementById('chartDaily');
401
+ if (dc) {
402
+ refs.chartDaily = new Chart(dc, { type: 'bar', data: {
403
+ labels: days.map(d => d.slice(5)),
404
+ datasets: [
405
+ { label: t('dashboard.requestsTotal') || 'Requests', data: days.map(d => (daily[d] || {}).requests_total || 0), backgroundColor: '#3b82f688' },
406
+ { label: t('dashboard.writes') || 'Writes', data: days.map(d => (daily[d] || {}).memory_writes || 0), backgroundColor: '#22c55e88' },
407
+ { label: t('dashboard.reads') || 'Reads', data: days.map(d => (daily[d] || {}).memory_reads || 0), backgroundColor: '#06b6d488' },
408
+ ],
409
+ }, options: chartOpts });
410
+ }
411
+
412
+ // Weekly Comparison line chart
413
+ const wc = document.getElementById('chartWeekly');
414
+ if (wc && days.length > 0) {
415
+ const weekLabels = [t('dashboard.weekMon'), t('dashboard.weekTue'), t('dashboard.weekWed'), t('dashboard.weekThu'), t('dashboard.weekFri'), t('dashboard.weekSat'), t('dashboard.weekSun')];
416
+ const getWeekData = (weeksAgo) => {
417
+ const data = new Array(7).fill(0);
418
+ const now = new Date();
419
+ for (let i = 0; i < 7; i++) {
420
+ const d = new Date(now);
421
+ d.setDate(d.getDate() - d.getDay() + i + 1 - (weeksAgo * 7));
422
+ const key = d.toISOString().slice(0, 10);
423
+ if (daily[key]) data[i] = daily[key].requests_total || 0;
424
+ }
425
+ return data;
426
+ };
427
+ refs.chartWeekly = new Chart(wc, { type: 'line', data: {
428
+ labels: weekLabels,
429
+ datasets: [
430
+ { label: t('dashboard.thisWeek') || 'This week', data: getWeekData(0), borderColor: '#3b82f6', backgroundColor: '#3b82f622', fill: true },
431
+ { label: t('dashboard.lastWeek') || 'Last week', data: getWeekData(1), borderColor: '#22c55e', backgroundColor: '#22c55e22', fill: true },
432
+ ],
433
+ }, options: chartOpts });
434
+ }
435
+
436
+ // Monthly Trend line chart
437
+ const mc = document.getElementById('chartMonthly');
438
+ if (mc && days.length > 0) {
439
+ refs.chartMonthly = new Chart(mc, { type: 'line', data: {
440
+ labels: days.map(d => d.slice(5)),
441
+ datasets: [{ label: t('dashboard.requestsTotal') || 'Requests', data: days.map(d => (daily[d] || {}).requests_total || 0), borderColor: '#06b6d4', backgroundColor: '#06b6d422', fill: true }],
442
+ }, options: chartOpts });
443
+ }
444
+
445
+ // Tunnel doughnut chart
446
+ const tc = document.getElementById('chartTunnel');
447
+ if (tc) {
448
+ const tunnel = sd.tunnel || {};
449
+ refs.chartTunnel = new Chart(tc, { type: 'doughnut', data: {
450
+ labels: [t('dashboard.tunnelMsgSent') || 'Sent', t('dashboard.tunnelMsgReceived') || 'Received', t('dashboard.tunnelDeliveryFails') || 'Rejected', t('dashboard.tunnelMailboxFallbacks') || 'Fallbacks'],
451
+ datasets: [{ data: [tunnel.messages_sent_total || 0, tunnel.messages_received_total || 0, tunnel.delivery_failures_total || 0, tunnel.mailbox_fallbacks_total || 0],
452
+ backgroundColor: ['#3b82f6', '#22c55e', '#ef4444', '#eab308'] }],
453
+ }, options: { responsive: true, plugins: { legend: { labels: { color: '#94a3b8' } } } } });
454
+ }
455
+
456
+ // Email Daily bar chart
457
+ const ec = document.getElementById('chartEmailDaily');
458
+ if (ec && days.length > 0) {
459
+ refs.chartEmailDaily = new Chart(ec, { type: 'bar', data: {
460
+ labels: days.map(d => d.slice(5)),
461
+ datasets: [
462
+ { label: t('dashboard.breakdownSent') || 'Sent', data: days.map(d => sumByPrefix(daily[d], 'email_sent')), backgroundColor: '#06b6d488' },
463
+ { label: t('dashboard.breakdownFailed') || 'Failed', data: days.map(d => sumByPrefix(daily[d], 'email_failed')), backgroundColor: '#ef444488' },
464
+ { label: t('dashboard.breakdownRetried') || 'Retried', data: days.map(d => sumByPrefix(daily[d], 'email_retried')), backgroundColor: '#eab30888' },
465
+ ],
466
+ }, options: chartOpts });
467
+ }
468
+
469
+ // Push Daily bar chart
470
+ const pc = document.getElementById('chartPushDaily');
471
+ if (pc && days.length > 0) {
472
+ refs.chartPushDaily = new Chart(pc, { type: 'bar', data: {
473
+ labels: days.map(d => d.slice(5)),
474
+ datasets: [
475
+ { label: t('dashboard.breakdownSent') || 'Sent', data: days.map(d => sumByPrefix(daily[d], 'push_sent')), backgroundColor: '#8b5cf688' },
476
+ { label: t('dashboard.breakdownFailed') || 'Failed', data: days.map(d => sumByPrefix(daily[d], 'push_failed')), backgroundColor: '#ef444488' },
477
+ ],
478
+ }, options: chartOpts });
479
+ }
480
+ }