@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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessagesTail — Role-colored recent main-thread messages with auto-follow toggle.
|
|
3
|
+
* Scrolls to bottom when new messages arrive (if follow enabled).
|
|
4
|
+
* User scroll up pauses auto-follow; toggle re-enables it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useRef, useState } from 'react';
|
|
8
|
+
import { formatTimestamp } from '../lib/format';
|
|
9
|
+
import { TESTIDS } from '../test/fixtures';
|
|
10
|
+
import type { Message } from '../lib/types';
|
|
11
|
+
import styles from './MessagesTail.module.css';
|
|
12
|
+
|
|
13
|
+
const FOLLOW_THRESHOLD = 50; // pixels from bottom
|
|
14
|
+
const MAX_MESSAGES = 12; // Display last ~12 messages
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
messages: Message[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function MessagesTail({ messages }: Props) {
|
|
21
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const [isFollowing, setIsFollowing] = useState(true);
|
|
23
|
+
|
|
24
|
+
// Detect if user scrolled up manually
|
|
25
|
+
const handleScroll = () => {
|
|
26
|
+
if (!containerRef.current) return;
|
|
27
|
+
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
|
28
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
29
|
+
if (distanceFromBottom > FOLLOW_THRESHOLD) {
|
|
30
|
+
setIsFollowing(false);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Auto-scroll to bottom when new messages arrive (if following)
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!isFollowing || !containerRef.current) return;
|
|
37
|
+
|
|
38
|
+
// Wait for DOM to update
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
const lastMessage = containerRef.current?.lastElementChild as HTMLElement;
|
|
41
|
+
if (lastMessage?.scrollIntoView) {
|
|
42
|
+
lastMessage.scrollIntoView({ behavior: 'smooth' });
|
|
43
|
+
} else if (containerRef.current) {
|
|
44
|
+
// Fallback for test environment
|
|
45
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
46
|
+
}
|
|
47
|
+
}, 0);
|
|
48
|
+
}, [messages, isFollowing]);
|
|
49
|
+
|
|
50
|
+
const displayMessages = messages.slice(-MAX_MESSAGES);
|
|
51
|
+
const lastMessageTimestamp = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1].timestamp : null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div data-testid={TESTIDS.messagesTail} className={styles.container} aria-live="polite" aria-label="Main-thread messages">
|
|
55
|
+
<div className={styles.header}>
|
|
56
|
+
<h3 className={styles.title}>Main-Thread Messages</h3>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
data-testid={TESTIDS.messagesFollowToggle}
|
|
60
|
+
aria-pressed={isFollowing}
|
|
61
|
+
onClick={() => setIsFollowing(!isFollowing)}
|
|
62
|
+
className={styles.toggleButton}
|
|
63
|
+
title={isFollowing ? 'Stop auto-following' : 'Resume auto-following'}
|
|
64
|
+
>
|
|
65
|
+
{isFollowing ? '📌 Following' : '📌 Paused'}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div ref={containerRef} className={styles.messagesBox} onScroll={handleScroll}>
|
|
70
|
+
{displayMessages.length === 0 ? (
|
|
71
|
+
<div className={styles.emptyState}>(no messages)</div>
|
|
72
|
+
) : (
|
|
73
|
+
displayMessages.map((msg, idx) => (
|
|
74
|
+
<div
|
|
75
|
+
key={idx}
|
|
76
|
+
data-testid={`message-${idx}`}
|
|
77
|
+
className={`${styles.message} ${styles[`role-${msg.role}`]}`}
|
|
78
|
+
>
|
|
79
|
+
<span className={styles.role}>{msg.role}</span>
|
|
80
|
+
<span className={styles.timestamp}>{formatTimestamp(msg.timestamp)}</span>
|
|
81
|
+
<div className={styles.text}>{msg.text}</div>
|
|
82
|
+
</div>
|
|
83
|
+
))
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{lastMessageTimestamp && (
|
|
88
|
+
<div className={styles.footer}>
|
|
89
|
+
<span className={styles.footerTimestamp}>updated {formatTimestamp(lastMessageTimestamp)}</span>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
.repos-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
|
+
.repos-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
|
+
.repos-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
|
+
.repos-panel__item {
|
|
27
|
+
display: flex;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: var(--space-3);
|
|
31
|
+
padding: var(--space-3);
|
|
32
|
+
background: var(--color-bg-subtle);
|
|
33
|
+
border: 1px solid var(--color-border-subtle);
|
|
34
|
+
border-radius: var(--radius-md);
|
|
35
|
+
font-size: var(--font-size-sm);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.repos-panel__item[data-severity='ok'] {
|
|
39
|
+
background: var(--color-status-ok-bg);
|
|
40
|
+
border-color: var(--color-status-ok);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.repos-panel__item[data-severity='warn'] {
|
|
44
|
+
background: var(--color-status-warn-bg);
|
|
45
|
+
border-color: var(--color-status-warn);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.repos-panel__item[data-severity='error'] {
|
|
49
|
+
background: var(--color-status-error-bg);
|
|
50
|
+
border-color: var(--color-status-error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.repos-panel__name {
|
|
54
|
+
font-weight: var(--font-weight-bold);
|
|
55
|
+
color: var(--color-text);
|
|
56
|
+
font-family: var(--font-mono);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.repos-panel__state {
|
|
60
|
+
font-family: var(--font-mono);
|
|
61
|
+
font-size: var(--font-size-xs);
|
|
62
|
+
text-align: right;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.empty-state {
|
|
66
|
+
padding: var(--space-4);
|
|
67
|
+
text-align: center;
|
|
68
|
+
color: var(--color-text-muted);
|
|
69
|
+
font-size: var(--font-size-sm);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (max-width: 768px) {
|
|
73
|
+
.repos-panel {
|
|
74
|
+
padding: var(--space-3);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.repos-panel h2 {
|
|
78
|
+
font-size: var(--font-size-md);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.repos-panel__item {
|
|
82
|
+
padding: var(--space-2);
|
|
83
|
+
flex-direction: column;
|
|
84
|
+
align-items: flex-start;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.repos-panel__state {
|
|
88
|
+
text-align: left;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { ReposPanel } from './ReposPanel';
|
|
4
|
+
import { fixtureRepos, TESTIDS } from '../test/fixtures';
|
|
5
|
+
|
|
6
|
+
describe('ReposPanel', () => {
|
|
7
|
+
it('renders repos list', () => {
|
|
8
|
+
render(<ReposPanel repos={fixtureRepos} />);
|
|
9
|
+
|
|
10
|
+
expect(screen.getByTestId(TESTIDS.reposPanel)).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByText('aesop')).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText('tr-sample-tracker')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders repo status', () => {
|
|
16
|
+
render(<ReposPanel repos={fixtureRepos} />);
|
|
17
|
+
|
|
18
|
+
// Multiple repos have "clean" status, so use getAllByText
|
|
19
|
+
const cleanElements = screen.getAllByText('clean');
|
|
20
|
+
expect(cleanElements.length).toBeGreaterThan(0);
|
|
21
|
+
expect(screen.getByText(/dirty/)).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders empty state when no repos', () => {
|
|
25
|
+
render(<ReposPanel repos={[]} />);
|
|
26
|
+
|
|
27
|
+
expect(screen.getByText('No repositories.')).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders empty state when repos is null', () => {
|
|
31
|
+
render(<ReposPanel repos={null} />);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByText('No repositories.')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('applies severity-based styling based on repo state', () => {
|
|
37
|
+
render(<ReposPanel repos={fixtureRepos} />);
|
|
38
|
+
|
|
39
|
+
const items = screen.getAllByRole('listitem');
|
|
40
|
+
// First repo is clean
|
|
41
|
+
expect(items[0]).toHaveAttribute('data-severity', 'ok');
|
|
42
|
+
// Second repo is dirty
|
|
43
|
+
expect(items[1]).toHaveAttribute('data-severity', 'warn');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReposPanel — Repository status display.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { RepoStatus } from '../lib/types';
|
|
6
|
+
import { TESTIDS } from '../test/fixtures';
|
|
7
|
+
import './ReposPanel.css';
|
|
8
|
+
|
|
9
|
+
interface ReposPanelProps {
|
|
10
|
+
repos: RepoStatus[] | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Determine repo state severity for color coding.
|
|
15
|
+
*/
|
|
16
|
+
function getRepoSeverity(state: unknown): 'ok' | 'warn' | 'error' {
|
|
17
|
+
if (typeof state === 'string') {
|
|
18
|
+
if (state.toLowerCase().includes('clean')) return 'ok';
|
|
19
|
+
if (state.toLowerCase().includes('dirty')) return 'warn';
|
|
20
|
+
return 'error';
|
|
21
|
+
}
|
|
22
|
+
return 'ok';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ReposPanel({ repos }: ReposPanelProps) {
|
|
26
|
+
const hasRepos = repos && repos.length > 0;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<section className="repos-panel" data-testid={TESTIDS.reposPanel}>
|
|
30
|
+
<h2>Repositories</h2>
|
|
31
|
+
{!hasRepos ? (
|
|
32
|
+
<p className="empty-state">No repositories.</p>
|
|
33
|
+
) : (
|
|
34
|
+
<ul className="repos-panel__list">
|
|
35
|
+
{repos.map((repo, idx) => {
|
|
36
|
+
const repoName = repo.repo || `Repo ${idx + 1}`;
|
|
37
|
+
const repoState = repo.state ? String(repo.state) : 'unknown';
|
|
38
|
+
const severity = getRepoSeverity(repoState);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<li
|
|
42
|
+
key={idx}
|
|
43
|
+
className="repos-panel__item"
|
|
44
|
+
data-severity={severity}
|
|
45
|
+
>
|
|
46
|
+
<span className="repos-panel__name">{repoName}</span>
|
|
47
|
+
<span
|
|
48
|
+
className="repos-panel__state"
|
|
49
|
+
style={{
|
|
50
|
+
color:
|
|
51
|
+
severity === 'ok'
|
|
52
|
+
? 'var(--color-status-ok)'
|
|
53
|
+
: severity === 'warn'
|
|
54
|
+
? 'var(--color-status-warn)'
|
|
55
|
+
: 'var(--color-status-error)',
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{repoState}
|
|
59
|
+
</span>
|
|
60
|
+
</li>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* Scorecard — accessible stat tiles for run verdicts */
|
|
2
|
+
|
|
3
|
+
.scorecard {
|
|
4
|
+
padding: var(--space-5);
|
|
5
|
+
border: 1px solid var(--color-border);
|
|
6
|
+
border-radius: var(--radius-md);
|
|
7
|
+
background: var(--color-surface);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.scorecard-header {
|
|
11
|
+
margin-bottom: var(--space-4);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.scorecard-header h3 {
|
|
15
|
+
margin: 0 0 var(--space-2) 0;
|
|
16
|
+
font-size: var(--font-size-lg);
|
|
17
|
+
font-weight: var(--font-weight-medium);
|
|
18
|
+
color: var(--color-text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.scorecard-total {
|
|
22
|
+
font-size: var(--font-size-sm);
|
|
23
|
+
color: var(--color-text-muted);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.scorecard-tiles {
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
29
|
+
gap: var(--space-3);
|
|
30
|
+
margin-bottom: var(--space-4);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.scorecard-tile {
|
|
34
|
+
padding: var(--space-3);
|
|
35
|
+
border-radius: var(--radius-md);
|
|
36
|
+
text-align: center;
|
|
37
|
+
border: 1px solid var(--color-border);
|
|
38
|
+
background: var(--color-bg-subtle);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.tile-label {
|
|
42
|
+
font-size: var(--font-size-sm);
|
|
43
|
+
font-weight: var(--font-weight-medium);
|
|
44
|
+
margin-bottom: var(--space-1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.tile-percent {
|
|
48
|
+
font-size: var(--font-size-xl);
|
|
49
|
+
font-weight: var(--font-weight-bold);
|
|
50
|
+
margin-bottom: var(--space-1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.tile-count {
|
|
54
|
+
font-size: var(--font-size-xs);
|
|
55
|
+
color: var(--color-text-muted);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Severity coloring */
|
|
59
|
+
.scorecard-tile--ok {
|
|
60
|
+
border-color: var(--color-status-ok);
|
|
61
|
+
background: var(--color-status-ok-bg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.scorecard-tile--ok .tile-label,
|
|
65
|
+
.scorecard-tile--ok .tile-percent {
|
|
66
|
+
color: var(--color-status-ok);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.scorecard-tile--error {
|
|
70
|
+
border-color: var(--color-status-error);
|
|
71
|
+
background: var(--color-status-error-bg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.scorecard-tile--error .tile-label,
|
|
75
|
+
.scorecard-tile--error .tile-percent {
|
|
76
|
+
color: var(--color-status-error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.scorecard-tile--warn {
|
|
80
|
+
border-color: var(--color-status-warn);
|
|
81
|
+
background: var(--color-status-warn-bg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.scorecard-tile--warn .tile-label,
|
|
85
|
+
.scorecard-tile--warn .tile-percent {
|
|
86
|
+
color: var(--color-status-warn);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.scorecard-tile--neutral {
|
|
90
|
+
border-color: var(--color-status-neutral);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.scorecard-tile--neutral .tile-label,
|
|
94
|
+
.scorecard-tile--neutral .tile-percent {
|
|
95
|
+
color: var(--color-status-neutral);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.scorecard-footer {
|
|
99
|
+
border-top: 1px solid var(--color-border-subtle);
|
|
100
|
+
padding-top: var(--space-3);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.scorecard-footer small {
|
|
104
|
+
color: var(--color-text-muted);
|
|
105
|
+
font-size: var(--font-size-xs);
|
|
106
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scorecard component tests — stat tiles for run verdicts.
|
|
3
|
+
* Tests both modes: with pricing and without. Tests empty data handling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { render, screen } from '@testing-library/react';
|
|
8
|
+
import { Scorecard } from './Scorecard';
|
|
9
|
+
import { fixtureCost, fixtureCostWithPricing, TESTIDS } from '../test/fixtures';
|
|
10
|
+
|
|
11
|
+
describe('Scorecard', () => {
|
|
12
|
+
it('renders scorecard with fixture data', () => {
|
|
13
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
14
|
+
expect(screen.getByTestId(TESTIDS.scorecard)).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('displays total runs count', () => {
|
|
18
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
19
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
20
|
+
expect(scorecard.textContent).toContain('142');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('displays OK rate as percentage', () => {
|
|
24
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
25
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
26
|
+
// ok_rate is 0.9296, should display as "92.96%"
|
|
27
|
+
expect(scorecard.textContent).toMatch(/92\.9\d%|93\.0%/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('displays FAILED rate', () => {
|
|
31
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
32
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
33
|
+
// failed_rate is 0.0493, should display as "4.9%" or similar
|
|
34
|
+
expect(scorecard.textContent).toMatch(/4\.\d%|5\.0%/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('displays EMPTY rate', () => {
|
|
38
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
39
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
40
|
+
// empty_rate is 0.0141, should display as "1.4%" or "1.5%"
|
|
41
|
+
expect(scorecard.textContent).toMatch(/1\.\d%|1\.4%/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('displays HUNG rate', () => {
|
|
45
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
46
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
47
|
+
// hung_rate is 0.007, should display as "0.7%" or similar
|
|
48
|
+
expect(scorecard.textContent).toMatch(/0\.\d%/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('shows severity coloring for OK stat', () => {
|
|
52
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
53
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
54
|
+
// Should have some indication of OK status (class or style)
|
|
55
|
+
expect(scorecard.innerHTML).toContain('OK');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shows severity coloring for FAILED stat', () => {
|
|
59
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
60
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
61
|
+
expect(scorecard.innerHTML).toContain('FAILED');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('shows skipped_lines footnote when > 0', () => {
|
|
65
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
66
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
67
|
+
// skipped_lines is 3
|
|
68
|
+
expect(scorecard.textContent).toContain('3');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not show skipped_lines footnote when 0', () => {
|
|
72
|
+
const noskip = {
|
|
73
|
+
...fixtureCost,
|
|
74
|
+
skipped_lines: 0,
|
|
75
|
+
};
|
|
76
|
+
render(<Scorecard cost={noskip} />);
|
|
77
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
78
|
+
// Should not mention skipped lines
|
|
79
|
+
const text = scorecard.textContent || '';
|
|
80
|
+
// This is a loose check — just ensure no excessive mention
|
|
81
|
+
const skipMentions = (text.match(/skipped/gi) || []).length;
|
|
82
|
+
expect(skipMentions).toBeLessThanOrEqual(1); // tolerance for "skipped" in any case
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('works with pricing fixture data', () => {
|
|
86
|
+
render(<Scorecard cost={fixtureCostWithPricing} />);
|
|
87
|
+
expect(screen.getByTestId(TESTIDS.scorecard)).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('handles empty scorecard (0 runs)', () => {
|
|
91
|
+
const empty = {
|
|
92
|
+
...fixtureCost,
|
|
93
|
+
overall_scorecard: {
|
|
94
|
+
total_runs: 0,
|
|
95
|
+
ok_count: 0,
|
|
96
|
+
failed_count: 0,
|
|
97
|
+
empty_count: 0,
|
|
98
|
+
hung_count: 0,
|
|
99
|
+
ok_rate: 0,
|
|
100
|
+
failed_rate: 0,
|
|
101
|
+
empty_rate: 0,
|
|
102
|
+
hung_rate: 0,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
render(<Scorecard cost={empty} />);
|
|
106
|
+
expect(screen.getByTestId(TESTIDS.scorecard)).toBeInTheDocument();
|
|
107
|
+
expect(screen.getByTestId(TESTIDS.scorecard).textContent).toContain('0');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('uses theme color tokens for status colors', () => {
|
|
111
|
+
render(<Scorecard cost={fixtureCost} />);
|
|
112
|
+
const scorecard = screen.getByTestId(TESTIDS.scorecard);
|
|
113
|
+
// Verify that no inline hex colors are used (must use CSS vars)
|
|
114
|
+
const html = scorecard.innerHTML;
|
|
115
|
+
expect(html).not.toMatch(/#[0-9a-fA-F]{3,6}/);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scorecard component — accessible stat tiles for run verdicts.
|
|
3
|
+
* Displays OK/FAILED/EMPTY/HUNG rates as percentages and counts.
|
|
4
|
+
* Severity coloring uses theme color tokens.
|
|
5
|
+
* Includes skipped_lines footnote when > 0.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CostSummary } from '../lib/types';
|
|
9
|
+
import { formatPercent } from '../lib/format';
|
|
10
|
+
import { TESTIDS } from '../test/fixtures';
|
|
11
|
+
import './Scorecard.css';
|
|
12
|
+
|
|
13
|
+
interface ScorecardProps {
|
|
14
|
+
cost: CostSummary;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface StatTile {
|
|
18
|
+
label: string;
|
|
19
|
+
count: number;
|
|
20
|
+
rate: number;
|
|
21
|
+
severity: 'ok' | 'error' | 'warn' | 'neutral';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Scorecard({ cost }: ScorecardProps) {
|
|
25
|
+
const { overall_scorecard: sc, skipped_lines } = cost;
|
|
26
|
+
|
|
27
|
+
const stats: StatTile[] = [
|
|
28
|
+
{
|
|
29
|
+
label: 'OK',
|
|
30
|
+
count: sc.ok_count,
|
|
31
|
+
rate: sc.ok_rate,
|
|
32
|
+
severity: 'ok',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: 'FAILED',
|
|
36
|
+
count: sc.failed_count,
|
|
37
|
+
rate: sc.failed_rate,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: 'EMPTY',
|
|
42
|
+
count: sc.empty_count,
|
|
43
|
+
rate: sc.empty_rate,
|
|
44
|
+
severity: 'warn',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: 'HUNG',
|
|
48
|
+
count: sc.hung_count,
|
|
49
|
+
rate: sc.hung_rate,
|
|
50
|
+
severity: 'error',
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<section
|
|
56
|
+
className="scorecard"
|
|
57
|
+
data-testid={TESTIDS.scorecard}
|
|
58
|
+
aria-label="Fleet quality scorecard"
|
|
59
|
+
>
|
|
60
|
+
<header className="scorecard-header">
|
|
61
|
+
<h3>Run Verdict Rates</h3>
|
|
62
|
+
<div className="scorecard-total">Total runs: {sc.total_runs}</div>
|
|
63
|
+
</header>
|
|
64
|
+
<div className="scorecard-tiles">
|
|
65
|
+
{stats.map((stat) => (
|
|
66
|
+
<article
|
|
67
|
+
key={stat.label}
|
|
68
|
+
className={`scorecard-tile scorecard-tile--${stat.severity}`}
|
|
69
|
+
>
|
|
70
|
+
<div className="tile-label">{stat.label}</div>
|
|
71
|
+
<div className="tile-percent">{formatPercent(stat.rate)}</div>
|
|
72
|
+
<div className="tile-count">{stat.count} runs</div>
|
|
73
|
+
</article>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
{skipped_lines > 0 && (
|
|
77
|
+
<footer className="scorecard-footer">
|
|
78
|
+
<small>
|
|
79
|
+
Data quality note: {skipped_lines} line(s) in the ledger could not be parsed.
|
|
80
|
+
</small>
|
|
81
|
+
</footer>
|
|
82
|
+
)}
|
|
83
|
+
</section>
|
|
84
|
+
);
|
|
85
|
+
}
|