@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5

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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { userEvent } from '@testing-library/user-event';
4
+ import { HealthHeader } from './HealthHeader';
5
+ import {
6
+ fixtureWatchdog,
7
+ fixtureWatchdogStale,
8
+ fixtureMonitor,
9
+ fixtureAlerts,
10
+ fixtureAlertsEmpty,
11
+ fixtureAgents,
12
+ fixtureStatus,
13
+ TESTIDS,
14
+ } from '../test/fixtures';
15
+
16
+ describe('HealthHeader', () => {
17
+ const mockThemeToggle = vi.fn();
18
+ const mockRefresh = vi.fn();
19
+
20
+ it('renders all health cells', () => {
21
+ render(
22
+ <HealthHeader
23
+ watchdog={fixtureWatchdog}
24
+ monitor={fixtureMonitor}
25
+ orchestrator={fixtureStatus}
26
+ agents={fixtureAgents}
27
+ alerts={fixtureAlerts}
28
+ connectionStatus={{ status: 'live' }}
29
+ onThemeToggle={mockThemeToggle}
30
+ onRefresh={mockRefresh}
31
+ />
32
+ );
33
+
34
+ expect(screen.getByTestId(TESTIDS.healthHeader)).toBeInTheDocument();
35
+ expect(screen.getByTestId(TESTIDS.healthWatchdog)).toBeInTheDocument();
36
+ expect(screen.getByTestId(TESTIDS.healthMonitor)).toBeInTheDocument();
37
+ expect(screen.getByTestId(TESTIDS.healthOrchestrator)).toBeInTheDocument();
38
+ expect(screen.getByTestId(TESTIDS.healthAgentsCount)).toBeInTheDocument();
39
+ expect(screen.getByTestId(TESTIDS.healthAlertsCount)).toBeInTheDocument();
40
+ });
41
+
42
+ it('displays watchdog ALIVE status', () => {
43
+ render(
44
+ <HealthHeader
45
+ watchdog={fixtureWatchdog}
46
+ monitor={fixtureMonitor}
47
+ orchestrator={fixtureStatus}
48
+ agents={fixtureAgents}
49
+ alerts={fixtureAlertsEmpty}
50
+ connectionStatus={{ status: 'live' }}
51
+ onThemeToggle={mockThemeToggle}
52
+ onRefresh={mockRefresh}
53
+ />
54
+ );
55
+
56
+ const watchdogCell = screen.getByTestId(TESTIDS.healthWatchdog);
57
+ expect(watchdogCell).toHaveTextContent('ALIVE');
58
+ });
59
+
60
+ it('displays watchdog STALE status with age', () => {
61
+ render(
62
+ <HealthHeader
63
+ watchdog={fixtureWatchdogStale}
64
+ monitor={fixtureMonitor}
65
+ orchestrator={fixtureStatus}
66
+ agents={fixtureAgents}
67
+ alerts={fixtureAlertsEmpty}
68
+ connectionStatus={{ status: 'live' }}
69
+ onThemeToggle={mockThemeToggle}
70
+ onRefresh={mockRefresh}
71
+ />
72
+ );
73
+
74
+ const watchdogCell = screen.getByTestId(TESTIDS.healthWatchdog);
75
+ expect(watchdogCell).toHaveTextContent('STALE');
76
+ expect(watchdogCell).toHaveTextContent('10m');
77
+ });
78
+
79
+ it('displays monitor status', () => {
80
+ render(
81
+ <HealthHeader
82
+ watchdog={fixtureWatchdog}
83
+ monitor={fixtureMonitor}
84
+ orchestrator={fixtureStatus}
85
+ agents={fixtureAgents}
86
+ alerts={fixtureAlertsEmpty}
87
+ connectionStatus={{ status: 'live' }}
88
+ onThemeToggle={mockThemeToggle}
89
+ onRefresh={mockRefresh}
90
+ />
91
+ );
92
+
93
+ const monitorCell = screen.getByTestId(TESTIDS.healthMonitor);
94
+ expect(monitorCell).toHaveTextContent('ALIVE');
95
+ });
96
+
97
+ it('displays agent count', () => {
98
+ render(
99
+ <HealthHeader
100
+ watchdog={fixtureWatchdog}
101
+ monitor={fixtureMonitor}
102
+ orchestrator={fixtureStatus}
103
+ agents={fixtureAgents}
104
+ alerts={fixtureAlertsEmpty}
105
+ connectionStatus={{ status: 'live' }}
106
+ onThemeToggle={mockThemeToggle}
107
+ onRefresh={mockRefresh}
108
+ />
109
+ );
110
+
111
+ const agentsCell = screen.getByTestId(TESTIDS.healthAgentsCount);
112
+ expect(agentsCell).toHaveTextContent('3');
113
+ });
114
+
115
+ it('displays alerts count', () => {
116
+ render(
117
+ <HealthHeader
118
+ watchdog={fixtureWatchdog}
119
+ monitor={fixtureMonitor}
120
+ orchestrator={fixtureStatus}
121
+ agents={fixtureAgents}
122
+ alerts={fixtureAlerts}
123
+ connectionStatus={{ status: 'live' }}
124
+ onThemeToggle={mockThemeToggle}
125
+ onRefresh={mockRefresh}
126
+ />
127
+ );
128
+
129
+ const alertsCell = screen.getByTestId(TESTIDS.healthAlertsCount);
130
+ expect(alertsCell).toHaveTextContent('2');
131
+ });
132
+
133
+ it('displays SSE live status', () => {
134
+ render(
135
+ <HealthHeader
136
+ watchdog={fixtureWatchdog}
137
+ monitor={fixtureMonitor}
138
+ orchestrator={fixtureStatus}
139
+ agents={fixtureAgents}
140
+ alerts={fixtureAlertsEmpty}
141
+ connectionStatus={{ status: 'live' }}
142
+ onThemeToggle={mockThemeToggle}
143
+ onRefresh={mockRefresh}
144
+ />
145
+ );
146
+
147
+ const sseCell = screen.getByTestId(TESTIDS.sseStatus);
148
+ expect(sseCell).toHaveTextContent('Live');
149
+ });
150
+
151
+ it('displays SSE reconnecting status', () => {
152
+ render(
153
+ <HealthHeader
154
+ watchdog={fixtureWatchdog}
155
+ monitor={fixtureMonitor}
156
+ orchestrator={fixtureStatus}
157
+ agents={fixtureAgents}
158
+ alerts={fixtureAlertsEmpty}
159
+ connectionStatus={{ status: 'reconnecting' }}
160
+ onThemeToggle={mockThemeToggle}
161
+ onRefresh={mockRefresh}
162
+ />
163
+ );
164
+
165
+ const sseCell = screen.getByTestId(TESTIDS.sseStatus);
166
+ expect(sseCell).toHaveTextContent('Reconnecting');
167
+ });
168
+
169
+ it('calls onThemeToggle when theme button clicked', async () => {
170
+ const user = userEvent.setup();
171
+ render(
172
+ <HealthHeader
173
+ watchdog={fixtureWatchdog}
174
+ monitor={fixtureMonitor}
175
+ orchestrator={fixtureStatus}
176
+ agents={fixtureAgents}
177
+ alerts={fixtureAlertsEmpty}
178
+ connectionStatus={{ status: 'live' }}
179
+ onThemeToggle={mockThemeToggle}
180
+ onRefresh={mockRefresh}
181
+ />
182
+ );
183
+
184
+ const themeButton = screen.getByTestId(TESTIDS.themeToggle);
185
+ await user.click(themeButton);
186
+ expect(mockThemeToggle).toHaveBeenCalled();
187
+ });
188
+
189
+ it('calls onRefresh when refresh button clicked', async () => {
190
+ const user = userEvent.setup();
191
+ render(
192
+ <HealthHeader
193
+ watchdog={fixtureWatchdog}
194
+ monitor={fixtureMonitor}
195
+ orchestrator={fixtureStatus}
196
+ agents={fixtureAgents}
197
+ alerts={fixtureAlertsEmpty}
198
+ connectionStatus={{ status: 'live' }}
199
+ onThemeToggle={mockThemeToggle}
200
+ onRefresh={mockRefresh}
201
+ />
202
+ );
203
+
204
+ const refreshButton = screen.getByTestId(TESTIDS.refreshButton);
205
+ await user.click(refreshButton);
206
+ expect(mockRefresh).toHaveBeenCalled();
207
+ });
208
+
209
+ it('is keyboard accessible (buttons can receive focus)', () => {
210
+ render(
211
+ <HealthHeader
212
+ watchdog={fixtureWatchdog}
213
+ monitor={fixtureMonitor}
214
+ orchestrator={fixtureStatus}
215
+ agents={fixtureAgents}
216
+ alerts={fixtureAlertsEmpty}
217
+ connectionStatus={{ status: 'live' }}
218
+ onThemeToggle={mockThemeToggle}
219
+ onRefresh={mockRefresh}
220
+ />
221
+ );
222
+
223
+ const themeButton = screen.getByTestId(TESTIDS.themeToggle);
224
+ const refreshButton = screen.getByTestId(TESTIDS.refreshButton);
225
+
226
+ expect(themeButton).toHaveAttribute('type', 'button');
227
+ expect(refreshButton).toHaveAttribute('type', 'button');
228
+ });
229
+
230
+ it('handles null values gracefully', () => {
231
+ render(
232
+ <HealthHeader
233
+ watchdog={null}
234
+ monitor={null}
235
+ orchestrator={null}
236
+ agents={null}
237
+ alerts={null}
238
+ connectionStatus={{ status: 'error' }}
239
+ onThemeToggle={mockThemeToggle}
240
+ onRefresh={mockRefresh}
241
+ />
242
+ );
243
+
244
+ expect(screen.getByTestId(TESTIDS.healthHeader)).toBeInTheDocument();
245
+ expect(screen.getByTestId(TESTIDS.healthWatchdog)).toHaveTextContent('unknown');
246
+ });
247
+
248
+ it('displays audit phase badge when orchestrator is in audit phase', () => {
249
+ const auditStatus = {
250
+ orchestrators: [
251
+ {
252
+ id: 'main',
253
+ role: 'orchestrator',
254
+ activity: 'running audit',
255
+ phase: 'audit',
256
+ age_seconds: 42,
257
+ stale: false,
258
+ },
259
+ ],
260
+ };
261
+
262
+ render(
263
+ <HealthHeader
264
+ watchdog={fixtureWatchdog}
265
+ monitor={fixtureMonitor}
266
+ orchestrator={auditStatus}
267
+ agents={fixtureAgents}
268
+ alerts={fixtureAlertsEmpty}
269
+ connectionStatus={{ status: 'live' }}
270
+ onThemeToggle={mockThemeToggle}
271
+ onRefresh={mockRefresh}
272
+ />
273
+ );
274
+
275
+ const orchestratorCell = screen.getByTestId(TESTIDS.healthOrchestrator);
276
+ expect(orchestratorCell).toHaveTextContent('Audit');
277
+ });
278
+ });
@@ -0,0 +1,281 @@
1
+ /**
2
+ * HealthHeader — Sticky status header (always visible, D4).
3
+ *
4
+ * Displays: watchdog ALIVE/STALE+age · monitor status · orchestrator
5
+ * activity/phase · running-agents count · unreviewed-alerts count
6
+ * (severity-colored) · SSE connection state · theme toggle · manual refresh.
7
+ *
8
+ * Every cell is a clickable element jumping to its corresponding view (#/overview, #/activity, etc).
9
+ * Props driven by App.tsx; no local state beyond focus/hover.
10
+ */
11
+
12
+ import { useCallback } from 'react';
13
+ import type { HeartbeatStatus, Agent, OrchestratorStatus, Alert, SSEConnectionStatus } from '../lib/types';
14
+ import { TESTIDS } from '../test/fixtures';
15
+ import './HealthHeader.css';
16
+
17
+ interface HealthHeaderProps {
18
+ watchdog: HeartbeatStatus | null;
19
+ monitor: HeartbeatStatus | null;
20
+ orchestrator: OrchestratorStatus | null;
21
+ agents: Agent[] | null;
22
+ alerts: Alert | null;
23
+ connectionStatus: SSEConnectionStatus;
24
+ dataTimestamp: number | null; // Epoch ms when last SSE payload was received
25
+ onThemeToggle: () => void;
26
+ onRefresh: () => void;
27
+ }
28
+
29
+ /**
30
+ * Format age in seconds as a readable duration.
31
+ */
32
+ function formatAge(ageSeconds: number): string {
33
+ if (ageSeconds < 0) return 'unknown';
34
+ if (ageSeconds < 60) return `${ageSeconds}s`;
35
+ const minutes = Math.floor(ageSeconds / 60);
36
+ if (minutes < 60) return `${minutes}m`;
37
+ const hours = Math.floor(ageSeconds / 3600);
38
+ return `${hours}h`;
39
+ }
40
+
41
+ /**
42
+ * Determine status color based on alert severity or status.
43
+ */
44
+ function getStatusColor(status: string): string {
45
+ if (status === 'HIGH' || status === 'SUSPICIOUS') return 'var(--color-status-error)';
46
+ if (status === 'MED' || status === 'DRIFT') return 'var(--color-status-warn)';
47
+ if (status === 'ALIVE' || status === 'running' || status === 'OK') return 'var(--color-status-ok)';
48
+ if (status === 'STALE') return 'var(--color-status-error)';
49
+ if (status === 'idle') return 'var(--color-status-info)';
50
+ return 'var(--color-status-neutral)';
51
+ }
52
+
53
+ /**
54
+ * Format a timestamp as a relative time string (e.g., "1m ago", "15s ago").
55
+ */
56
+ function formatRelativeTime(epochMs: number): string {
57
+ const ageMs = Date.now() - epochMs;
58
+ const ageSecs = Math.floor(ageMs / 1000);
59
+
60
+ if (ageSecs < 60) return `${ageSecs}s ago`;
61
+ const mins = Math.floor(ageSecs / 60);
62
+ if (mins < 60) return `${mins}m ago`;
63
+ const hours = Math.floor(ageSecs / 3600);
64
+ return `${hours}h ago`;
65
+ }
66
+
67
+ export function HealthHeader({
68
+ watchdog,
69
+ monitor,
70
+ orchestrator,
71
+ agents,
72
+ alerts,
73
+ connectionStatus,
74
+ dataTimestamp,
75
+ onThemeToggle,
76
+ onRefresh,
77
+ }: HealthHeaderProps) {
78
+ const handleWatchdogClick = useCallback(() => {
79
+ window.location.hash = '#/activity';
80
+ }, []);
81
+
82
+ const handleMonitorClick = useCallback(() => {
83
+ window.location.hash = '#/activity';
84
+ }, []);
85
+
86
+ const handleOrchestratorClick = useCallback(() => {
87
+ window.location.hash = '#/activity';
88
+ }, []);
89
+
90
+ const handleAlertsClick = useCallback(() => {
91
+ window.location.hash = '#/';
92
+ }, []);
93
+
94
+ // Determine audit phase badge — the status file signals via phase/activity, not role
95
+ const isAuditPhase =
96
+ orchestrator?.orchestrators.some(
97
+ (o) => o.phase?.toLowerCase().includes('audit') || o.activity?.toLowerCase().includes('audit'),
98
+ ) ?? false;
99
+ const orchestratorActivity =
100
+ orchestrator?.orchestrators.map((o) => o.activity || o.phase).filter(Boolean)[0] ?? 'no active session';
101
+
102
+ const agentsCount = agents?.length ?? 0;
103
+ const alertsCount = alerts?.count ?? 0;
104
+
105
+ // Compute data staleness (stale if > 60 seconds old)
106
+ const dataAgeMs = dataTimestamp ? Date.now() - dataTimestamp : -1;
107
+ const isDataStale = dataAgeMs > 60000;
108
+ const dataTimeStr = dataTimestamp ? formatRelativeTime(dataTimestamp) : 'unknown';
109
+
110
+ // Determine max severity for alerts color
111
+ let maxAlertSeverity = 'neutral';
112
+ if (alertsCount > 0 && alerts?.lines.length) {
113
+ const firstLine = alerts.lines[0] || '';
114
+ if (firstLine.includes('HIGH') || firstLine.includes('SUSPICIOUS')) {
115
+ maxAlertSeverity = 'error';
116
+ } else if (firstLine.includes('MED') || firstLine.includes('DRIFT')) {
117
+ maxAlertSeverity = 'warn';
118
+ }
119
+ }
120
+
121
+ return (
122
+ <header className="health-header" data-testid={TESTIDS.healthHeader} role="banner">
123
+ <div className="health-header__cells">
124
+ {/* Watchdog cell */}
125
+ <button
126
+ type="button"
127
+ className="health-header__cell health-header__cell--watchdog"
128
+ data-testid={TESTIDS.healthWatchdog}
129
+ onClick={handleWatchdogClick}
130
+ aria-label={`Watchdog: ${watchdog?.alive ?? 'unknown'} (age: ${formatAge(watchdog?.age ?? -1)})`}
131
+ >
132
+ <span className="health-header__label">Watchdog</span>
133
+ <span
134
+ className={`health-header__status text-status-${
135
+ watchdog?.alive === 'ALIVE'
136
+ ? 'ok'
137
+ : watchdog?.alive === 'STALE'
138
+ ? 'error'
139
+ : 'neutral'
140
+ }`}
141
+ >
142
+ {watchdog?.alive ?? 'unknown'}
143
+ {watchdog && watchdog.age >= 0 && ` +${formatAge(watchdog.age)}`}
144
+ </span>
145
+ </button>
146
+
147
+ {/* Monitor cell */}
148
+ <button
149
+ type="button"
150
+ className="health-header__cell health-header__cell--monitor"
151
+ data-testid={TESTIDS.healthMonitor}
152
+ onClick={handleMonitorClick}
153
+ aria-label={`Monitor: ${monitor?.alive ?? 'unknown'}`}
154
+ >
155
+ <span className="health-header__label">Monitor</span>
156
+ <span
157
+ className={`health-header__status text-status-${
158
+ monitor?.alive === 'ALIVE' ? 'ok' : 'neutral'
159
+ }`}
160
+ >
161
+ {monitor?.alive ?? 'unknown'}
162
+ </span>
163
+ </button>
164
+
165
+ {/* Orchestrator cell */}
166
+ <button
167
+ type="button"
168
+ className="health-header__cell health-header__cell--orchestrator"
169
+ data-testid={TESTIDS.healthOrchestrator}
170
+ onClick={handleOrchestratorClick}
171
+ aria-label="Orchestrator status"
172
+ >
173
+ <span className="health-header__label">Orchestrator</span>
174
+ {isAuditPhase && (
175
+ <span className="health-header__badge" role="status">
176
+ Audit
177
+ </span>
178
+ )}
179
+ <span className="health-header__status">{orchestratorActivity}</span>
180
+ </button>
181
+
182
+ {/* Agents count */}
183
+ <button
184
+ type="button"
185
+ className="health-header__cell health-header__cell--agents"
186
+ data-testid={TESTIDS.healthAgentsCount}
187
+ onClick={handleAlertsClick}
188
+ aria-label={`${agentsCount} agents running`}
189
+ >
190
+ <span className="health-header__label">Agents</span>
191
+ <span className="health-header__count">{agentsCount}</span>
192
+ </button>
193
+
194
+ {/* Alerts count */}
195
+ <button
196
+ type="button"
197
+ className={`health-header__cell health-header__cell--alerts ${
198
+ maxAlertSeverity === 'error'
199
+ ? 'text-status-error'
200
+ : maxAlertSeverity === 'warn'
201
+ ? 'text-status-warn'
202
+ : ''
203
+ }`}
204
+ data-testid={TESTIDS.healthAlertsCount}
205
+ onClick={handleAlertsClick}
206
+ aria-label={`${alertsCount} alerts`}
207
+ >
208
+ <span className="health-header__label">Alerts</span>
209
+ <span className="health-header__count">{alertsCount}</span>
210
+ </button>
211
+
212
+ {/* Data timestamp */}
213
+ <span
214
+ className={`health-header__cell health-header__cell--timestamp ${
215
+ isDataStale ? 'health-header__cell--stale' : ''
216
+ }`}
217
+ data-testid="health-data-timestamp"
218
+ role="status"
219
+ aria-live="polite"
220
+ aria-label={`Data as of ${dataTimeStr}`}
221
+ >
222
+ <span className="health-header__label">Data</span>
223
+ <span className={`health-header__status ${isDataStale ? 'text-status-warn' : 'text-status-ok'}`}>
224
+ {dataTimeStr}
225
+ </span>
226
+ </span>
227
+
228
+ {/* SSE status */}
229
+ <span
230
+ className="health-header__cell health-header__cell--sse"
231
+ data-testid={TESTIDS.sseStatus}
232
+ data-status={connectionStatus.status}
233
+ role="status"
234
+ aria-live="polite"
235
+ aria-label={`Connection: ${connectionStatus.status}`}
236
+ >
237
+ <span className="health-header__label">SSE</span>
238
+ <span
239
+ className={`health-header__status text-status-${
240
+ connectionStatus.status === 'live'
241
+ ? 'ok'
242
+ : connectionStatus.status === 'reconnecting'
243
+ ? 'warn'
244
+ : 'error'
245
+ }`}
246
+ >
247
+ {connectionStatus.status === 'live'
248
+ ? 'Live'
249
+ : connectionStatus.status === 'reconnecting'
250
+ ? 'Reconnecting'
251
+ : 'Error'}
252
+ </span>
253
+ </span>
254
+
255
+ {/* Theme toggle */}
256
+ <button
257
+ type="button"
258
+ className="health-header__cell health-header__cell--theme"
259
+ data-testid={TESTIDS.themeToggle}
260
+ onClick={onThemeToggle}
261
+ aria-label="Toggle color theme"
262
+ >
263
+ <span className="health-header__label">Theme</span>
264
+ <span className="health-header__icon">◐</span>
265
+ </button>
266
+
267
+ {/* Refresh button */}
268
+ <button
269
+ type="button"
270
+ className="health-header__cell health-header__cell--refresh"
271
+ data-testid={TESTIDS.refreshButton}
272
+ onClick={onRefresh}
273
+ aria-label="Refresh data"
274
+ >
275
+ <span className="health-header__label">Refresh</span>
276
+ <span className="health-header__icon">↻</span>
277
+ </button>
278
+ </div>
279
+ </header>
280
+ );
281
+ }
@@ -0,0 +1,135 @@
1
+ .inbox-form {
2
+ padding: var(--space-5);
3
+ background: var(--color-surface);
4
+ border: 1px solid var(--color-border);
5
+ border-radius: var(--radius-md);
6
+ }
7
+
8
+ .inbox-form h2 {
9
+ margin: 0 0 var(--space-4) 0;
10
+ font-size: var(--font-size-lg);
11
+ font-weight: var(--font-weight-bold);
12
+ color: var(--color-text);
13
+ }
14
+
15
+ .inbox-form__form {
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: var(--space-3);
19
+ }
20
+
21
+ .inbox-form__group {
22
+ display: flex;
23
+ gap: var(--space-2);
24
+ align-items: stretch;
25
+ }
26
+
27
+ .inbox-form__input {
28
+ flex: 1;
29
+ min-width: 0;
30
+ padding: var(--space-3) var(--space-4);
31
+ background: var(--color-bg);
32
+ border: 1px solid var(--color-border);
33
+ border-radius: var(--radius-md);
34
+ font-size: var(--font-size-sm);
35
+ font-family: var(--font-sans);
36
+ color: var(--color-text);
37
+ line-height: var(--line-height-normal);
38
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
39
+ }
40
+
41
+ .inbox-form__input:focus-visible {
42
+ outline: 2px solid var(--color-focus-ring);
43
+ outline-offset: 2px;
44
+ border-color: var(--color-accent);
45
+ }
46
+
47
+ .inbox-form__input::placeholder {
48
+ color: var(--color-text-muted);
49
+ }
50
+
51
+ .inbox-form__input:disabled {
52
+ opacity: 0.6;
53
+ cursor: not-allowed;
54
+ }
55
+
56
+ .inbox-form__submit {
57
+ padding: var(--space-3) var(--space-5);
58
+ background: var(--color-accent);
59
+ color: var(--color-text-inverse);
60
+ border: none;
61
+ border-radius: var(--radius-md);
62
+ font-size: var(--font-size-sm);
63
+ font-weight: var(--font-weight-bold);
64
+ cursor: pointer;
65
+ transition: background-color var(--transition-fast);
66
+ }
67
+
68
+ .inbox-form__submit:hover:not(:disabled) {
69
+ background: var(--color-accent-emphasis);
70
+ }
71
+
72
+ .inbox-form__submit:focus-visible {
73
+ outline: 2px solid var(--color-focus-ring);
74
+ outline-offset: 2px;
75
+ }
76
+
77
+ .inbox-form__submit:disabled {
78
+ opacity: 0.5;
79
+ cursor: not-allowed;
80
+ }
81
+
82
+ .inbox-form__error {
83
+ padding: var(--space-3);
84
+ background: var(--color-status-error-bg);
85
+ color: var(--color-status-error);
86
+ border-left: 4px solid var(--color-status-error);
87
+ border-radius: var(--radius-md);
88
+ font-size: var(--font-size-sm);
89
+ }
90
+
91
+ .inbox-form__success {
92
+ padding: var(--space-3);
93
+ background: var(--color-status-ok-bg);
94
+ color: var(--color-status-ok);
95
+ border-left: 4px solid var(--color-status-ok);
96
+ border-radius: var(--radius-md);
97
+ font-size: var(--font-size-sm);
98
+ }
99
+
100
+ .sr-only {
101
+ position: absolute;
102
+ width: 1px;
103
+ height: 1px;
104
+ padding: 0;
105
+ margin: -1px;
106
+ overflow: hidden;
107
+ clip: rect(0, 0, 0, 0);
108
+ white-space: nowrap;
109
+ border-width: 0;
110
+ }
111
+
112
+ @media (max-width: 768px) {
113
+ .inbox-form {
114
+ padding: var(--space-3);
115
+ }
116
+
117
+ .inbox-form h2 {
118
+ font-size: var(--font-size-md);
119
+ }
120
+
121
+ .inbox-form__group {
122
+ flex-direction: column;
123
+ }
124
+
125
+ .inbox-form__submit {
126
+ width: 100%;
127
+ }
128
+ }
129
+
130
+ @media (prefers-reduced-motion: reduce) {
131
+ .inbox-form__input,
132
+ .inbox-form__submit {
133
+ transition: none;
134
+ }
135
+ }