@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,93 @@
1
+ /* CostTable — per-model cost and token display */
2
+
3
+ .cost-table {
4
+ width: 100%;
5
+ border-collapse: collapse;
6
+ font-size: var(--font-size-sm);
7
+ background: var(--color-surface);
8
+ }
9
+
10
+ .cost-table caption {
11
+ text-align: left;
12
+ padding: var(--space-2);
13
+ font-size: var(--font-size-sm);
14
+ color: var(--color-text-muted);
15
+ caption-side: top;
16
+ }
17
+
18
+ .cost-table thead {
19
+ background: var(--color-bg-inset);
20
+ border-bottom: 2px solid var(--color-border);
21
+ }
22
+
23
+ .cost-table th {
24
+ padding: var(--space-2) var(--space-3);
25
+ text-align: left;
26
+ font-weight: var(--font-weight-bold);
27
+ color: var(--color-text);
28
+ }
29
+
30
+ .cost-table th.col-numeric {
31
+ text-align: right;
32
+ }
33
+
34
+ .cost-table tbody tr {
35
+ border-bottom: 1px solid var(--color-border-subtle);
36
+ }
37
+
38
+ .cost-table tbody tr:hover {
39
+ background: var(--color-bg-subtle);
40
+ }
41
+
42
+ .cost-table td {
43
+ padding: var(--space-2) var(--space-3);
44
+ text-align: left;
45
+ color: var(--color-text);
46
+ }
47
+
48
+ .cost-table td.col-numeric {
49
+ text-align: right;
50
+ font-family: var(--font-mono);
51
+ }
52
+
53
+ .cost-table .model-name {
54
+ font-weight: var(--font-weight-medium);
55
+ font-family: var(--font-mono);
56
+ font-size: var(--font-size-xs);
57
+ word-break: break-word;
58
+ max-width: 180px;
59
+ }
60
+
61
+ /* Verdict severity indicators */
62
+ .cost-table .verdict-failed {
63
+ color: var(--color-status-error);
64
+ }
65
+
66
+ .cost-table .verdict-empty {
67
+ color: var(--color-status-warn);
68
+ }
69
+
70
+ .cost-table .verdict-hung {
71
+ color: var(--color-status-error);
72
+ }
73
+
74
+ /* Total cost column emphasis */
75
+ .cost-table .col-total {
76
+ font-weight: var(--font-weight-bold);
77
+ }
78
+
79
+ /* Responsive: overflow on smaller screens */
80
+ @media (max-width: 768px) {
81
+ .cost-table {
82
+ font-size: var(--font-size-xs);
83
+ }
84
+
85
+ .cost-table th,
86
+ .cost-table td {
87
+ padding: var(--space-1) var(--space-2);
88
+ }
89
+
90
+ .cost-table .model-name {
91
+ max-width: 100px;
92
+ }
93
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * CostTable component tests — per-model cost and token display.
3
+ * Tests tokens-only mode (no pricing) and pricing mode separately.
4
+ * Tests proper table semantics, caption, thead/tbody, scope attributes.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { render, screen } from '@testing-library/react';
9
+ import { CostTable } from './CostTable';
10
+ import { fixtureCost, fixtureCostWithPricing, TESTIDS } from '../test/fixtures';
11
+
12
+ describe('CostTable', () => {
13
+ it('renders table with testid', () => {
14
+ render(<CostTable cost={fixtureCost} />);
15
+ expect(screen.getByTestId(TESTIDS.costTable)).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders as a proper <table> element', () => {
19
+ render(<CostTable cost={fixtureCost} />);
20
+ const table = screen.getByTestId(TESTIDS.costTable);
21
+ expect(table.tagName).toBe('TABLE');
22
+ });
23
+
24
+ it('has a caption describing the table', () => {
25
+ render(<CostTable cost={fixtureCost} />);
26
+ const table = screen.getByTestId(TESTIDS.costTable);
27
+ const caption = table.querySelector('caption');
28
+ expect(caption).toBeInTheDocument();
29
+ expect(caption?.textContent).toBeTruthy();
30
+ });
31
+
32
+ it('has proper thead and tbody structure', () => {
33
+ render(<CostTable cost={fixtureCost} />);
34
+ const table = screen.getByTestId(TESTIDS.costTable);
35
+ expect(table.querySelector('thead')).toBeInTheDocument();
36
+ expect(table.querySelector('tbody')).toBeInTheDocument();
37
+ });
38
+
39
+ it('has column headers with scope="col"', () => {
40
+ render(<CostTable cost={fixtureCost} />);
41
+ const table = screen.getByTestId(TESTIDS.costTable);
42
+ const ths = table.querySelectorAll('th[scope="col"]');
43
+ expect(ths.length).toBeGreaterThan(0);
44
+ });
45
+
46
+ it('lists all models in fixture data', () => {
47
+ render(<CostTable cost={fixtureCost} />);
48
+ expect(screen.getByText(/haiku/i)).toBeInTheDocument();
49
+ expect(screen.getByText(/sonnet/i)).toBeInTheDocument();
50
+ });
51
+
52
+ it('displays runs count for each model', () => {
53
+ render(<CostTable cost={fixtureCost} />);
54
+ // haiku has 128 runs
55
+ expect(screen.getByText('128')).toBeInTheDocument();
56
+ // sonnet has 14 runs
57
+ expect(screen.getByText('14')).toBeInTheDocument();
58
+ });
59
+
60
+ it('displays token counts in readable format (e.g., "2.1M")', () => {
61
+ render(<CostTable cost={fixtureCost} />);
62
+ const table = screen.getByTestId(TESTIDS.costTable);
63
+ // haiku tokens_in is 2140050, should display as "2.1M"
64
+ expect(table.textContent).toContain('2.1M');
65
+ });
66
+
67
+ it('displays verdict counts (OK/FAILED/EMPTY/HUNG)', () => {
68
+ render(<CostTable cost={fixtureCost} />);
69
+ const table = screen.getByTestId(TESTIDS.costTable);
70
+ // haiku has verdicts: OK: 119, FAILED: 6, EMPTY: 2, HUNG: 1
71
+ expect(table.textContent).toContain('119');
72
+ expect(table.textContent).toContain('6');
73
+ expect(table.textContent).toContain('2');
74
+ expect(table.textContent).toContain('1');
75
+ });
76
+
77
+ describe('tokens-only mode (no pricing)', () => {
78
+ it('shows tokens columns but no currency columns when has_pricing=false', () => {
79
+ render(<CostTable cost={fixtureCost} />);
80
+ const table = screen.getByTestId(TESTIDS.costTable);
81
+ // Should have "tokens in/out" headers (case-insensitive)
82
+ expect(table.textContent).toMatch(/tokens\s+in/i);
83
+ expect(table.textContent).toMatch(/tokens\s+out/i);
84
+ // Should NOT have a $ symbol (currency) or "estimate" label
85
+ expect(table.innerHTML).not.toContain('$');
86
+ });
87
+ });
88
+
89
+ describe('pricing mode', () => {
90
+ it('shows currency columns labeled "estimate" when has_pricing=true', () => {
91
+ render(<CostTable cost={fixtureCostWithPricing} />);
92
+ const table = screen.getByTestId(TESTIDS.costTable);
93
+ // Should show "estimate" label for pricing
94
+ expect(table.textContent).toContain('estimate');
95
+ // Should show $ symbols
96
+ expect(table.innerHTML).toContain('$');
97
+ });
98
+
99
+ it('displays dollar amounts for input cost', () => {
100
+ render(<CostTable cost={fixtureCostWithPricing} />);
101
+ const table = screen.getByTestId(TESTIDS.costTable);
102
+ // haiku input_cost is 2.14
103
+ expect(table.textContent).toContain('2.14');
104
+ });
105
+
106
+ it('displays dollar amounts for output cost', () => {
107
+ render(<CostTable cost={fixtureCostWithPricing} />);
108
+ const table = screen.getByTestId(TESTIDS.costTable);
109
+ // haiku output_cost is 2.05
110
+ expect(table.textContent).toContain('2.05');
111
+ });
112
+
113
+ it('displays total cost', () => {
114
+ render(<CostTable cost={fixtureCostWithPricing} />);
115
+ const table = screen.getByTestId(TESTIDS.costTable);
116
+ // haiku total_cost is 4.19
117
+ expect(table.textContent).toContain('4.19');
118
+ });
119
+ });
120
+
121
+ it('handles single row (1 model) without breaking layout', () => {
122
+ const single = {
123
+ ...fixtureCost,
124
+ models: {
125
+ 'claude-haiku-4-5-20251001': fixtureCost.models['claude-haiku-4-5-20251001'],
126
+ },
127
+ };
128
+ render(<CostTable cost={single} />);
129
+ const table = screen.getByTestId(TESTIDS.costTable);
130
+ const rows = table.querySelectorAll('tbody tr');
131
+ expect(rows.length).toBe(1);
132
+ });
133
+
134
+ it('handles many rows without breaking layout', () => {
135
+ const many = {
136
+ ...fixtureCost,
137
+ models: {
138
+ ...fixtureCost.models,
139
+ 'model-1': {
140
+ runs: 10,
141
+ tokens_in: 1000,
142
+ tokens_out: 500,
143
+ verdicts: { OK: 9, FAILED: 1, EMPTY: 0, HUNG: 0 },
144
+ },
145
+ 'model-2': {
146
+ runs: 20,
147
+ tokens_in: 2000,
148
+ tokens_out: 1000,
149
+ verdicts: { OK: 18, FAILED: 2, EMPTY: 0, HUNG: 0 },
150
+ },
151
+ },
152
+ };
153
+ render(<CostTable cost={many} />);
154
+ const table = screen.getByTestId(TESTIDS.costTable);
155
+ const rows = table.querySelectorAll('tbody tr');
156
+ expect(rows.length).toBeGreaterThan(2);
157
+ });
158
+
159
+ it('uses theme color tokens (no hex colors in output)', () => {
160
+ render(<CostTable cost={fixtureCost} />);
161
+ const table = screen.getByTestId(TESTIDS.costTable);
162
+ // Should not have inline hex color styles
163
+ expect(table.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,6}(?![0-9a-fA-F])/);
164
+ });
165
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * CostTable component — per-model cost and token summary table.
3
+ * Proper table semantics: caption, thead with scope="col", tbody.
4
+ * Columns: Model | Runs | Tokens In | Tokens Out | Verdicts (OK/FAILED/EMPTY/HUNG) | [Pricing if has_pricing]
5
+ * Dollar columns labeled "estimate" when pricing is available.
6
+ * Uses formatTokens() for readable token display.
7
+ */
8
+
9
+ import type { CostSummary, CostModelStats } from '../lib/types';
10
+ import { formatTokens, formatCurrency } from '../lib/format';
11
+ import { TESTIDS } from '../test/fixtures';
12
+ import './CostTable.css';
13
+
14
+ interface CostTableProps {
15
+ cost: CostSummary;
16
+ }
17
+
18
+ export function CostTable({ cost }: CostTableProps) {
19
+ const { models, has_pricing, estimates_by_model } = cost;
20
+ const modelIds = Object.keys(models).sort();
21
+
22
+ return (
23
+ <table className="cost-table" data-testid={TESTIDS.costTable}>
24
+ <caption>Per-model cost and token usage summary</caption>
25
+ <thead>
26
+ <tr>
27
+ <th scope="col">Model</th>
28
+ <th scope="col" className="col-numeric">
29
+ Runs
30
+ </th>
31
+ <th scope="col" className="col-numeric">
32
+ Tokens In
33
+ </th>
34
+ <th scope="col" className="col-numeric">
35
+ Tokens Out
36
+ </th>
37
+ <th scope="col" className="col-numeric">
38
+ OK
39
+ </th>
40
+ <th scope="col" className="col-numeric">
41
+ FAILED
42
+ </th>
43
+ <th scope="col" className="col-numeric">
44
+ EMPTY
45
+ </th>
46
+ <th scope="col" className="col-numeric">
47
+ HUNG
48
+ </th>
49
+ {has_pricing && (
50
+ <>
51
+ <th scope="col" className="col-numeric">
52
+ Input (estimate)
53
+ </th>
54
+ <th scope="col" className="col-numeric">
55
+ Output (estimate)
56
+ </th>
57
+ <th scope="col" className="col-numeric">
58
+ Total (estimate)
59
+ </th>
60
+ </>
61
+ )}
62
+ </tr>
63
+ </thead>
64
+ <tbody>
65
+ {modelIds.map((modelId) => {
66
+ const stats = models[modelId] as CostModelStats;
67
+ const estimate = has_pricing ? estimates_by_model[modelId] : null;
68
+
69
+ return (
70
+ <tr key={modelId}>
71
+ <td className="model-name">{modelId}</td>
72
+ <td className="col-numeric">{stats.runs}</td>
73
+ <td className="col-numeric">{formatTokens(stats.tokens_in)}</td>
74
+ <td className="col-numeric">{formatTokens(stats.tokens_out)}</td>
75
+ <td className="col-numeric">{stats.verdicts.OK}</td>
76
+ <td className="col-numeric verdict-failed">{stats.verdicts.FAILED}</td>
77
+ <td className="col-numeric verdict-empty">{stats.verdicts.EMPTY}</td>
78
+ <td className="col-numeric verdict-hung">{stats.verdicts.HUNG}</td>
79
+ {has_pricing && estimate && (
80
+ <>
81
+ <td className="col-numeric">{formatCurrency(estimate.input_cost)}</td>
82
+ <td className="col-numeric">{formatCurrency(estimate.output_cost)}</td>
83
+ <td className="col-numeric col-total">
84
+ {formatCurrency(estimate.total_cost)}
85
+ </td>
86
+ </>
87
+ )}
88
+ </tr>
89
+ );
90
+ })}
91
+ </tbody>
92
+ </table>
93
+ );
94
+ }
@@ -0,0 +1,68 @@
1
+ .events-feed {
2
+ flex: 1;
3
+ min-width: 0;
4
+ padding: var(--space-5);
5
+ background: var(--color-surface);
6
+ border: 1px solid var(--color-border);
7
+ border-radius: var(--radius-md);
8
+ }
9
+
10
+ .events-feed h2 {
11
+ margin: 0 0 var(--space-4) 0;
12
+ font-size: var(--font-size-lg);
13
+ font-weight: var(--font-weight-bold);
14
+ color: var(--color-text);
15
+ }
16
+
17
+ .events-feed__list {
18
+ list-style: none;
19
+ margin: 0;
20
+ padding: 0;
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: var(--space-2);
24
+ max-height: 300px;
25
+ overflow-y: auto;
26
+ }
27
+
28
+ .events-feed__item {
29
+ padding: var(--space-2) var(--space-3);
30
+ background: var(--color-bg-inset);
31
+ border: 1px solid var(--color-border-subtle);
32
+ border-radius: var(--radius-sm);
33
+ font-size: var(--font-size-xs);
34
+ line-height: var(--line-height-normal);
35
+ color: var(--color-text-muted);
36
+ }
37
+
38
+ .events-feed__item code {
39
+ font-family: var(--font-mono);
40
+ color: var(--color-text);
41
+ word-break: break-word;
42
+ }
43
+
44
+ .empty-state {
45
+ padding: var(--space-4);
46
+ text-align: center;
47
+ color: var(--color-text-muted);
48
+ font-size: var(--font-size-sm);
49
+ }
50
+
51
+ @media (max-width: 768px) {
52
+ .events-feed {
53
+ padding: var(--space-3);
54
+ }
55
+
56
+ .events-feed h2 {
57
+ font-size: var(--font-size-md);
58
+ }
59
+
60
+ .events-feed__list {
61
+ max-height: 200px;
62
+ }
63
+
64
+ .events-feed__item {
65
+ padding: var(--space-2);
66
+ font-size: var(--font-size-xs);
67
+ }
68
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { EventsFeed } from './EventsFeed';
4
+ import { fixtureEvents, TESTIDS } from '../test/fixtures';
5
+
6
+ describe('EventsFeed', () => {
7
+ it('renders events list', () => {
8
+ render(<EventsFeed events={fixtureEvents} />);
9
+
10
+ expect(screen.getByTestId(TESTIDS.eventsFeed)).toBeInTheDocument();
11
+ fixtureEvents.forEach((event) => {
12
+ expect(screen.getByText(event)).toBeInTheDocument();
13
+ });
14
+ });
15
+
16
+ it('renders empty state when no events', () => {
17
+ render(<EventsFeed events={[]} />);
18
+
19
+ expect(screen.getByText('No events.')).toBeInTheDocument();
20
+ });
21
+
22
+ it('renders empty state when events is null', () => {
23
+ render(<EventsFeed events={null} />);
24
+
25
+ expect(screen.getByText('No events.')).toBeInTheDocument();
26
+ });
27
+
28
+ it('displays events in monospace font', () => {
29
+ render(<EventsFeed events={fixtureEvents} />);
30
+
31
+ const codeElements = screen.getAllByText(/BACKUP|SCAN|PUSH/);
32
+ codeElements.forEach((el) => {
33
+ expect(el.tagName).toBe('CODE');
34
+ });
35
+ });
36
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * EventsFeed — FLEET-BACKUP.log tail (recent events).
3
+ */
4
+
5
+ import { TESTIDS } from '../test/fixtures';
6
+ import './EventsFeed.css';
7
+
8
+ interface EventsFeedProps {
9
+ events: string[] | null;
10
+ }
11
+
12
+ export function EventsFeed({ events }: EventsFeedProps) {
13
+ const hasEvents = events && events.length > 0;
14
+
15
+ return (
16
+ <section className="events-feed" data-testid={TESTIDS.eventsFeed}>
17
+ <h2>Recent Events</h2>
18
+ {!hasEvents ? (
19
+ <p className="empty-state">No events.</p>
20
+ ) : (
21
+ <ul className="events-feed__list">
22
+ {events.map((event, idx) => (
23
+ <li key={idx} className="events-feed__item">
24
+ <code>{event}</code>
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ )}
29
+ </section>
30
+ );
31
+ }
@@ -0,0 +1,137 @@
1
+ .health-header {
2
+ position: sticky;
3
+ top: 0;
4
+ z-index: 100;
5
+ background: var(--color-surface);
6
+ border-bottom: 1px solid var(--color-border);
7
+ padding: var(--space-3) var(--space-5);
8
+ }
9
+
10
+ .health-header__cells {
11
+ display: flex;
12
+ flex-wrap: wrap;
13
+ gap: var(--space-4);
14
+ align-items: center;
15
+ max-width: var(--content-max-width);
16
+ margin: 0 auto;
17
+ }
18
+
19
+ .health-header__cell {
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: flex-start;
23
+ gap: var(--space-2);
24
+ padding: var(--space-2) var(--space-3);
25
+ background: var(--color-bg-subtle);
26
+ border: 1px solid var(--color-border-subtle);
27
+ border-radius: var(--radius-md);
28
+ font-size: var(--font-size-sm);
29
+ cursor: pointer;
30
+ transition: background-color var(--transition-fast), border-color var(--transition-fast),
31
+ box-shadow var(--transition-fast);
32
+ }
33
+
34
+ .health-header__cell:hover {
35
+ background: var(--color-bg-inset);
36
+ border-color: var(--color-border);
37
+ }
38
+
39
+ .health-header__cell:focus-visible {
40
+ outline: 2px solid var(--color-focus-ring);
41
+ outline-offset: 2px;
42
+ }
43
+
44
+ .health-header__label {
45
+ font-size: var(--font-size-xs);
46
+ color: var(--color-text-muted);
47
+ font-weight: var(--font-weight-medium);
48
+ text-transform: uppercase;
49
+ letter-spacing: 0.05em;
50
+ }
51
+
52
+ .health-header__status {
53
+ font-family: var(--font-mono);
54
+ font-size: var(--font-size-sm);
55
+ font-weight: var(--font-weight-bold);
56
+ color: var(--color-text);
57
+ }
58
+
59
+ .health-header__count {
60
+ font-family: var(--font-mono);
61
+ font-size: var(--font-size-lg);
62
+ font-weight: var(--font-weight-bold);
63
+ color: var(--color-accent);
64
+ }
65
+
66
+ .health-header__badge {
67
+ display: inline-block;
68
+ padding: var(--space-1) var(--space-2);
69
+ background: var(--color-status-warn-bg);
70
+ color: var(--color-status-warn);
71
+ border-radius: var(--radius-sm);
72
+ font-size: var(--font-size-xs);
73
+ font-weight: var(--font-weight-bold);
74
+ }
75
+
76
+ .health-header__icon {
77
+ font-size: var(--font-size-lg);
78
+ line-height: 1;
79
+ }
80
+
81
+ .health-header__cell--sse {
82
+ cursor: default;
83
+ }
84
+
85
+ .health-header__cell--sse:hover {
86
+ background: var(--color-bg-subtle);
87
+ border-color: var(--color-border-subtle);
88
+ }
89
+
90
+ .health-header__cell--timestamp {
91
+ cursor: default;
92
+ }
93
+
94
+ .health-header__cell--timestamp:hover {
95
+ background: var(--color-bg-subtle);
96
+ border-color: var(--color-border-subtle);
97
+ }
98
+
99
+ .health-header__cell--stale {
100
+ background: var(--color-status-warn-bg);
101
+ border-color: var(--color-status-warn);
102
+ }
103
+
104
+ .health-header__cell--stale:hover {
105
+ background: var(--color-status-warn-bg);
106
+ border-color: var(--color-status-warn);
107
+ }
108
+
109
+ .health-header__cell--theme,
110
+ .health-header__cell--refresh {
111
+ margin-left: auto;
112
+ }
113
+
114
+ @media (max-width: 768px) {
115
+ .health-header {
116
+ padding: var(--space-2) var(--space-3);
117
+ }
118
+
119
+ .health-header__cells {
120
+ gap: var(--space-3);
121
+ }
122
+
123
+ .health-header__cell {
124
+ padding: var(--space-2);
125
+ font-size: var(--font-size-xs);
126
+ }
127
+
128
+ .health-header__count {
129
+ font-size: var(--font-size-md);
130
+ }
131
+ }
132
+
133
+ @media (prefers-reduced-motion: reduce) {
134
+ .health-header__cell {
135
+ transition: none;
136
+ }
137
+ }