@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.
- package/CHANGELOG.md +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentRow — Expandable agent row with lazy-loaded dispatch prompt.
|
|
3
|
+
*
|
|
4
|
+
* Expansion is keyboard-operable (Enter/Space on button) and scroll-preserving
|
|
5
|
+
* across prop updates (via key-based row identity).
|
|
6
|
+
*
|
|
7
|
+
* Expanded state shows:
|
|
8
|
+
* - Task/status/runtime/tokens + dispatch prompt (fetched from /agent?id=)
|
|
9
|
+
* - Cached with simple eviction (100-item LRU cache per session)
|
|
10
|
+
*
|
|
11
|
+
* D5: all interactive elements are real <button>/<a>, status colors from theme.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useRef, useEffect } from 'react';
|
|
15
|
+
import type { Agent } from '../lib/types';
|
|
16
|
+
import { fetchAgent } from '../lib/api';
|
|
17
|
+
import { TESTIDS } from '../test/fixtures';
|
|
18
|
+
import './AgentRow.css';
|
|
19
|
+
|
|
20
|
+
interface AgentRowProps {
|
|
21
|
+
agent: Agent;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Simple LRU cache for agent details
|
|
25
|
+
const agentDetailCache = new Map<string, { data: any; timestamp: number }>();
|
|
26
|
+
const MAX_CACHE_SIZE = 100;
|
|
27
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
28
|
+
|
|
29
|
+
function getCachedAgentDetail(id: string): any | null {
|
|
30
|
+
const cached = agentDetailCache.get(id);
|
|
31
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
32
|
+
return cached.data;
|
|
33
|
+
}
|
|
34
|
+
if (cached) {
|
|
35
|
+
agentDetailCache.delete(id);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setCachedAgentDetail(id: string, data: any) {
|
|
41
|
+
if (agentDetailCache.size >= MAX_CACHE_SIZE) {
|
|
42
|
+
const firstKey = agentDetailCache.keys().next().value;
|
|
43
|
+
if (firstKey !== undefined) {
|
|
44
|
+
agentDetailCache.delete(firstKey);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
agentDetailCache.set(id, { data, timestamp: Date.now() });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Determine status color.
|
|
52
|
+
*/
|
|
53
|
+
function getStatusColor(status: string): string {
|
|
54
|
+
if (status === 'running') return 'var(--color-status-ok)';
|
|
55
|
+
if (status === 'idle') return 'var(--color-status-info)';
|
|
56
|
+
if (status === 'SUSPICIOUS' || status === 'HIGH') return 'var(--color-status-error)';
|
|
57
|
+
if (status === 'MED' || status === 'DRIFT') return 'var(--color-status-warn)';
|
|
58
|
+
return 'var(--color-status-neutral)';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function AgentRow({ agent }: AgentRowProps) {
|
|
62
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
63
|
+
const [detail, setDetail] = useState<any | null>(null);
|
|
64
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
const expandButtonRef = useRef<HTMLButtonElement>(null);
|
|
67
|
+
|
|
68
|
+
// Load agent detail on expansion
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!isExpanded) return;
|
|
71
|
+
|
|
72
|
+
const cached = getCachedAgentDetail(agent.id);
|
|
73
|
+
if (cached) {
|
|
74
|
+
setDetail(cached);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setIsLoading(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
|
|
81
|
+
fetchAgent(agent.id)
|
|
82
|
+
.then((data) => {
|
|
83
|
+
setCachedAgentDetail(agent.id, data);
|
|
84
|
+
setDetail(data);
|
|
85
|
+
})
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
setError(err.message || 'Failed to load agent details');
|
|
88
|
+
})
|
|
89
|
+
.finally(() => {
|
|
90
|
+
setIsLoading(false);
|
|
91
|
+
});
|
|
92
|
+
}, [isExpanded, agent.id]);
|
|
93
|
+
|
|
94
|
+
const handleToggle = () => {
|
|
95
|
+
setIsExpanded(!isExpanded);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<li className="agent-row" data-testid={TESTIDS.agentRow} key={agent.id}>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
ref={expandButtonRef}
|
|
103
|
+
className="agent-row__toggle"
|
|
104
|
+
onClick={handleToggle}
|
|
105
|
+
aria-expanded={isExpanded}
|
|
106
|
+
aria-controls={`agent-detail-${agent.id}`}
|
|
107
|
+
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} agent ${agent.id}`}
|
|
108
|
+
>
|
|
109
|
+
<span className="agent-row__toggle-icon">{isExpanded ? '▼' : '▶'}</span>
|
|
110
|
+
</button>
|
|
111
|
+
|
|
112
|
+
<div className="agent-row__header">
|
|
113
|
+
<span
|
|
114
|
+
className="agent-row__status-icon"
|
|
115
|
+
style={{ color: getStatusColor(agent.status) }}
|
|
116
|
+
aria-label={`Status: ${agent.status}`}
|
|
117
|
+
title={`Status: ${agent.status}`}
|
|
118
|
+
>
|
|
119
|
+
●
|
|
120
|
+
</span>
|
|
121
|
+
|
|
122
|
+
<span className="agent-row__id">{agent.id}</span>
|
|
123
|
+
|
|
124
|
+
<span className="agent-row__age">
|
|
125
|
+
{agent.age_s < 60 ? `${agent.age_s}s` : `${Math.floor(agent.age_s / 60)}m`}
|
|
126
|
+
</span>
|
|
127
|
+
|
|
128
|
+
<span className="agent-row__hint">{agent.hint}</span>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{isExpanded && (
|
|
132
|
+
<div className="agent-row__detail" id={`agent-detail-${agent.id}`} data-testid={TESTIDS.agentRowDetail}>
|
|
133
|
+
<div className="agent-row__detail-row">
|
|
134
|
+
<span className="agent-row__detail-label">Project:</span>
|
|
135
|
+
<span className="agent-row__detail-value">{agent.project}</span>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="agent-row__detail-row">
|
|
139
|
+
<span className="agent-row__detail-label">Status:</span>
|
|
140
|
+
<span
|
|
141
|
+
className="agent-row__detail-value"
|
|
142
|
+
style={{ color: getStatusColor(agent.status) }}
|
|
143
|
+
>
|
|
144
|
+
{agent.status}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="agent-row__detail-row">
|
|
149
|
+
<span className="agent-row__detail-label">Started:</span>
|
|
150
|
+
<span className="agent-row__detail-value">
|
|
151
|
+
{agent.startedAt ? new Date(agent.startedAt).toLocaleString() : 'unknown'}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div className="agent-row__detail-row">
|
|
156
|
+
<span className="agent-row__detail-label">Last Activity:</span>
|
|
157
|
+
<span className="agent-row__detail-value">
|
|
158
|
+
{agent.lastActivity ? new Date(agent.lastActivity).toLocaleString() : 'unknown'}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="agent-row__detail-row">
|
|
163
|
+
<span className="agent-row__detail-label">Runtime:</span>
|
|
164
|
+
<span className="agent-row__detail-value">
|
|
165
|
+
{typeof agent.runtimeSeconds === 'number'
|
|
166
|
+
? `${Math.floor(agent.runtimeSeconds / 60)}m ${agent.runtimeSeconds % 60}s`
|
|
167
|
+
: 'unknown'}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="agent-row__detail-row">
|
|
172
|
+
<span className="agent-row__detail-label">Tokens Used:</span>
|
|
173
|
+
<span className="agent-row__detail-value">
|
|
174
|
+
{typeof agent.tokensUsed === 'number' ? agent.tokensUsed.toLocaleString() : 'unknown'}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div className="agent-row__detail-row">
|
|
179
|
+
<span className="agent-row__detail-label">Task:</span>
|
|
180
|
+
<span className="agent-row__detail-value">{agent.taskLabel}</span>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{isLoading && (
|
|
184
|
+
<div className="agent-row__prompt">
|
|
185
|
+
<span className="agent-row__prompt-label">Dispatch Prompt:</span>
|
|
186
|
+
<div className="agent-row__prompt-loading">Loading...</div>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{error && (
|
|
191
|
+
<div className="agent-row__prompt">
|
|
192
|
+
<span className="agent-row__prompt-label">Dispatch Prompt:</span>
|
|
193
|
+
<div className="agent-row__prompt-error">Error: {error}</div>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{detail && !isLoading && (
|
|
198
|
+
<div className="agent-row__prompt">
|
|
199
|
+
<span className="agent-row__prompt-label">Dispatch Prompt:</span>
|
|
200
|
+
<pre className="agent-row__prompt-text">{detail.dispatch_prompt}</pre>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
</li>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
.agents-panel {
|
|
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
|
+
.agents-panel 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
|
+
.agents-panel__summaries {
|
|
18
|
+
display: grid;
|
|
19
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
20
|
+
gap: var(--space-3);
|
|
21
|
+
margin-bottom: var(--space-4);
|
|
22
|
+
padding: var(--space-3);
|
|
23
|
+
background: var(--color-bg-subtle);
|
|
24
|
+
border: 1px solid var(--color-border-subtle);
|
|
25
|
+
border-radius: var(--radius-md);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.agent-summary {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: var(--space-2);
|
|
32
|
+
padding: var(--space-2);
|
|
33
|
+
background: var(--color-surface);
|
|
34
|
+
border: 1px solid var(--color-border-subtle);
|
|
35
|
+
border-radius: var(--radius-sm);
|
|
36
|
+
font-size: var(--font-size-sm);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.agent-summary__project {
|
|
40
|
+
flex: 0 0 auto;
|
|
41
|
+
font-weight: var(--font-weight-medium);
|
|
42
|
+
color: var(--color-text);
|
|
43
|
+
min-width: fit-content;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.agent-summary__status {
|
|
47
|
+
flex: 0 0 auto;
|
|
48
|
+
padding: var(--space-1) var(--space-2);
|
|
49
|
+
border-radius: var(--radius-sm);
|
|
50
|
+
font-size: var(--font-size-xs);
|
|
51
|
+
font-weight: var(--font-weight-bold);
|
|
52
|
+
text-transform: uppercase;
|
|
53
|
+
white-space: nowrap;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.agent-summary__status--running {
|
|
57
|
+
background: var(--color-status-ok-bg);
|
|
58
|
+
color: var(--color-status-ok);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.agent-summary__status--idle {
|
|
62
|
+
background: var(--color-status-info-bg);
|
|
63
|
+
color: var(--color-status-info);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.agent-summary__status--SUSPICIOUS,
|
|
67
|
+
.agent-summary__status--HIGH {
|
|
68
|
+
background: var(--color-status-error-bg);
|
|
69
|
+
color: var(--color-status-error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.agent-summary__status--MED,
|
|
73
|
+
.agent-summary__status--DRIFT {
|
|
74
|
+
background: var(--color-status-warn-bg);
|
|
75
|
+
color: var(--color-status-warn);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.agent-summary__runtime {
|
|
79
|
+
flex: 1;
|
|
80
|
+
text-align: right;
|
|
81
|
+
font-family: var(--font-mono);
|
|
82
|
+
color: var(--color-text-muted);
|
|
83
|
+
min-width: fit-content;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.agents-panel__list {
|
|
87
|
+
list-style: none;
|
|
88
|
+
margin: 0;
|
|
89
|
+
padding: 0;
|
|
90
|
+
margin-top: var(--space-4);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.empty-state {
|
|
94
|
+
padding: var(--space-4);
|
|
95
|
+
text-align: center;
|
|
96
|
+
color: var(--color-text-muted);
|
|
97
|
+
font-size: var(--font-size-sm);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@media (max-width: 768px) {
|
|
101
|
+
.agents-panel {
|
|
102
|
+
padding: var(--space-3);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.agents-panel h2 {
|
|
106
|
+
font-size: var(--font-size-md);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { AgentsPanel } from './AgentsPanel';
|
|
4
|
+
import { fixtureAgents, TESTIDS } from '../test/fixtures';
|
|
5
|
+
|
|
6
|
+
vi.mock('./AgentRow', () => ({
|
|
7
|
+
AgentRow: ({ agent }: any) => <div data-testid="agent-row-mock">{agent.id}</div>,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe('AgentsPanel', () => {
|
|
11
|
+
it('renders agents list with count', () => {
|
|
12
|
+
render(<AgentsPanel agents={fixtureAgents} />);
|
|
13
|
+
|
|
14
|
+
expect(screen.getByText(`Fleet Agents (${fixtureAgents.length})`)).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders each agent row', () => {
|
|
18
|
+
render(<AgentsPanel agents={fixtureAgents} />);
|
|
19
|
+
|
|
20
|
+
const rows = screen.getAllByTestId('agent-row-mock');
|
|
21
|
+
expect(rows).toHaveLength(fixtureAgents.length);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders empty state when no agents', () => {
|
|
25
|
+
render(<AgentsPanel agents={[]} />);
|
|
26
|
+
|
|
27
|
+
expect(screen.getByText('No agents running.')).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders empty state when agents is null', () => {
|
|
31
|
+
render(<AgentsPanel agents={null} />);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByText('No agents running.')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('has correct data-testid', () => {
|
|
37
|
+
render(<AgentsPanel agents={fixtureAgents} />);
|
|
38
|
+
|
|
39
|
+
expect(screen.getByTestId(TESTIDS.agentRow)).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentsPanel — Fleet agents list with expandable rows.
|
|
3
|
+
* Each agent row displays project · status · runtime at a glance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Agent } from '../lib/types';
|
|
7
|
+
import { AgentRow } from './AgentRow';
|
|
8
|
+
import { TESTIDS } from '../test/fixtures';
|
|
9
|
+
import './AgentsPanel.css';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format runtime in seconds to a readable string.
|
|
13
|
+
*/
|
|
14
|
+
function formatRuntime(seconds: number | undefined): string {
|
|
15
|
+
if (!seconds || seconds < 0) return 'unknown';
|
|
16
|
+
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
17
|
+
const mins = Math.floor(seconds / 60);
|
|
18
|
+
if (mins < 60) return `${mins}m`;
|
|
19
|
+
const hours = Math.floor(seconds / 3600);
|
|
20
|
+
return `${hours}h`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AgentsPanelProps {
|
|
24
|
+
agents: Agent[] | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function AgentsPanel({ agents }: AgentsPanelProps) {
|
|
28
|
+
if (!agents || agents.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<section className="agents-panel" data-testid={TESTIDS.agentRow}>
|
|
31
|
+
<h2>Fleet Agents</h2>
|
|
32
|
+
<p className="empty-state">No agents running.</p>
|
|
33
|
+
</section>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<section className="agents-panel" data-testid={TESTIDS.agentRow}>
|
|
39
|
+
<h2>Fleet Agents ({agents.length})</h2>
|
|
40
|
+
<div className="agents-panel__summaries">
|
|
41
|
+
{agents.map((agent) => (
|
|
42
|
+
<div key={`${agent.id}-summary`} className="agent-summary">
|
|
43
|
+
<span className="agent-summary__project">{agent.project}</span>
|
|
44
|
+
<span className={`agent-summary__status agent-summary__status--${agent.status}`}>
|
|
45
|
+
{agent.status}
|
|
46
|
+
</span>
|
|
47
|
+
<span className="agent-summary__runtime">{formatRuntime(agent.runtimeSeconds)}</span>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
<ul className="agents-panel__list">
|
|
52
|
+
{agents.map((agent) => (
|
|
53
|
+
<AgentRow key={agent.id} agent={agent} />
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
.alerts-panel {
|
|
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
|
+
.alerts-panel 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
|
+
.alerts-panel__list {
|
|
18
|
+
list-style: none;
|
|
19
|
+
margin: 0;
|
|
20
|
+
padding: 0;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
gap: var(--space-3);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.alerts-panel__item {
|
|
27
|
+
display: flex;
|
|
28
|
+
gap: var(--space-3);
|
|
29
|
+
align-items: flex-start;
|
|
30
|
+
padding: var(--space-3);
|
|
31
|
+
background: var(--color-bg-subtle);
|
|
32
|
+
border-left: 4px solid var(--color-border);
|
|
33
|
+
border-radius: var(--radius-md);
|
|
34
|
+
font-size: var(--font-size-sm);
|
|
35
|
+
line-height: var(--line-height-normal);
|
|
36
|
+
word-break: break-word;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.alerts-panel__item[data-severity='error'] {
|
|
40
|
+
background: var(--color-status-error-bg);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.alerts-panel__item[data-severity='warn'] {
|
|
44
|
+
background: var(--color-status-warn-bg);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.alerts-panel__item[data-severity='info'] {
|
|
48
|
+
background: var(--color-status-info-bg);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.alerts-panel__severity {
|
|
52
|
+
display: inline-block;
|
|
53
|
+
min-width: 50px;
|
|
54
|
+
font-weight: var(--font-weight-bold);
|
|
55
|
+
font-size: var(--font-size-xs);
|
|
56
|
+
text-transform: uppercase;
|
|
57
|
+
letter-spacing: 0.05em;
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.alerts-panel__text {
|
|
62
|
+
color: var(--color-text);
|
|
63
|
+
font-family: var(--font-mono);
|
|
64
|
+
font-size: var(--font-size-xs);
|
|
65
|
+
flex-grow: 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.empty-state {
|
|
69
|
+
padding: var(--space-4);
|
|
70
|
+
text-align: center;
|
|
71
|
+
color: var(--color-text-muted);
|
|
72
|
+
font-size: var(--font-size-sm);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@media (max-width: 768px) {
|
|
76
|
+
.alerts-panel {
|
|
77
|
+
padding: var(--space-3);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.alerts-panel h2 {
|
|
81
|
+
font-size: var(--font-size-md);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.alerts-panel__item {
|
|
85
|
+
padding: var(--space-2);
|
|
86
|
+
gap: var(--space-2);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { AlertsPanel } from './AlertsPanel';
|
|
4
|
+
import { fixtureAlerts, fixtureAlertsEmpty, TESTIDS } from '../test/fixtures';
|
|
5
|
+
|
|
6
|
+
describe('AlertsPanel', () => {
|
|
7
|
+
it('renders alerts with severity styling', () => {
|
|
8
|
+
render(<AlertsPanel alerts={fixtureAlerts} />);
|
|
9
|
+
|
|
10
|
+
expect(screen.getByTestId(TESTIDS.alertsPanel)).toBeInTheDocument();
|
|
11
|
+
const alertLines = screen.getAllByTestId(TESTIDS.alertLine);
|
|
12
|
+
expect(alertLines).toHaveLength(fixtureAlerts.lines.length);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('displays alert severity as text', () => {
|
|
16
|
+
render(<AlertsPanel alerts={fixtureAlerts} />);
|
|
17
|
+
|
|
18
|
+
// First line has HIGH
|
|
19
|
+
expect(screen.getByText(/HIGH/)).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders empty state when no alerts', () => {
|
|
23
|
+
render(<AlertsPanel alerts={fixtureAlertsEmpty} />);
|
|
24
|
+
|
|
25
|
+
expect(screen.getByText('No alerts.')).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders empty state when alerts is null', () => {
|
|
29
|
+
render(<AlertsPanel alerts={null} />);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByText('No alerts.')).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('applies severity-based styling to alert items', () => {
|
|
35
|
+
render(<AlertsPanel alerts={fixtureAlerts} />);
|
|
36
|
+
|
|
37
|
+
const alertItems = screen.getAllByTestId(TESTIDS.alertLine);
|
|
38
|
+
// First alert has HIGH severity
|
|
39
|
+
expect(alertItems[0]).toHaveAttribute('data-severity', 'error');
|
|
40
|
+
// Second alert has MED severity
|
|
41
|
+
expect(alertItems[1]).toHaveAttribute('data-severity', 'warn');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('displays alert text content', () => {
|
|
45
|
+
render(<AlertsPanel alerts={fixtureAlerts} />);
|
|
46
|
+
|
|
47
|
+
fixtureAlerts.lines.forEach((line) => {
|
|
48
|
+
expect(screen.getByText(line)).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlertsPanel — Security alerts with severity styling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Alert } from '../lib/types';
|
|
6
|
+
import { TESTIDS } from '../test/fixtures';
|
|
7
|
+
import './AlertsPanel.css';
|
|
8
|
+
|
|
9
|
+
interface AlertsPanelProps {
|
|
10
|
+
alerts: Alert | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Determine severity level from alert line.
|
|
15
|
+
*/
|
|
16
|
+
function getSeverity(line: string): 'error' | 'warn' | 'info' {
|
|
17
|
+
if (line.includes('HIGH') || line.includes('SUSPICIOUS')) return 'error';
|
|
18
|
+
if (line.includes('MED') || line.includes('DRIFT')) return 'warn';
|
|
19
|
+
return 'info';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get color for severity level.
|
|
24
|
+
*/
|
|
25
|
+
function getSeverityColor(severity: 'error' | 'warn' | 'info'): string {
|
|
26
|
+
if (severity === 'error') return 'var(--color-status-error)';
|
|
27
|
+
if (severity === 'warn') return 'var(--color-status-warn)';
|
|
28
|
+
return 'var(--color-status-info)';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AlertsPanel({ alerts }: AlertsPanelProps) {
|
|
32
|
+
const hasAlerts = alerts && alerts.lines.length > 0;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<section className="alerts-panel" data-testid={TESTIDS.alertsPanel}>
|
|
36
|
+
<h2>Security Alerts</h2>
|
|
37
|
+
{!hasAlerts ? (
|
|
38
|
+
<p className="empty-state">No alerts.</p>
|
|
39
|
+
) : (
|
|
40
|
+
<ul className="alerts-panel__list">
|
|
41
|
+
{alerts.lines.map((line, idx) => {
|
|
42
|
+
const severity = getSeverity(line);
|
|
43
|
+
return (
|
|
44
|
+
<li
|
|
45
|
+
key={idx}
|
|
46
|
+
className="alerts-panel__item"
|
|
47
|
+
data-testid={TESTIDS.alertLine}
|
|
48
|
+
data-severity={severity}
|
|
49
|
+
style={{
|
|
50
|
+
borderLeftColor: getSeverityColor(severity),
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<span
|
|
54
|
+
className="alerts-panel__severity"
|
|
55
|
+
style={{ color: getSeverityColor(severity) }}
|
|
56
|
+
>
|
|
57
|
+
{severity.toUpperCase()}
|
|
58
|
+
</span>
|
|
59
|
+
<span className="alerts-panel__text">{line}</span>
|
|
60
|
+
</li>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|