@matt82198/aesop 0.1.0-beta.3 → 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 +79 -95
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +126 -245
- package/aesop.config.example.json +34 -26
- package/bin/CLAUDE.md +67 -0
- package/bin/cli.js +593 -36
- package/daemons/CLAUDE.md +36 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +95 -12
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELEASING.md +159 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/archive/README.md +3 -0
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/archive/spikes/tiered-cognition/README.md +27 -0
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- 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 +61 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +75 -88
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +613 -61
- package/package.json +15 -3
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/skills/power/SKILL.md +161 -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 +72 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.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__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.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/agent-forensics.sh +186 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +317 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +138 -31
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/stall_check.py +172 -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 +513 -0
- package/tools/verify_submit_encoding.py +202 -0
- package/ui/CLAUDE.md +125 -0
- 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 +183 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +97 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +735 -0
- package/ui/config.py +123 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +144 -0
- package/ui/handler.py +676 -0
- package/ui/render.py +49 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +183 -0
- 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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed API client with CSRF header support.
|
|
3
|
+
* Fetches CSRF token from window.__AESOP_CSRF_TOKEN__ (set via sentinel in index.html)
|
|
4
|
+
* or falls back to GET /api/session if sentinel is unavailable (e.g., dev server without build).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
FullState,
|
|
9
|
+
CostSummary,
|
|
10
|
+
DashboardData,
|
|
11
|
+
AuditBacklog,
|
|
12
|
+
Agent,
|
|
13
|
+
AgentDetail,
|
|
14
|
+
TrackerItem,
|
|
15
|
+
SubmitResponse,
|
|
16
|
+
} from './types';
|
|
17
|
+
|
|
18
|
+
let _csrfTokenCache: string | null = null;
|
|
19
|
+
let _csrfTokenPromise: Promise<string> | null = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the CSRF token from the sentinel-injected global or /api/session endpoint.
|
|
23
|
+
* Caches the result to avoid repeated fetches.
|
|
24
|
+
*/
|
|
25
|
+
async function getCSRFToken(): Promise<string> {
|
|
26
|
+
// Fast path: check if token is already in the window object
|
|
27
|
+
if (typeof window !== 'undefined') {
|
|
28
|
+
const sentinel = (window as any).__AESOP_CSRF_TOKEN__;
|
|
29
|
+
if (sentinel && typeof sentinel === 'string') {
|
|
30
|
+
return sentinel;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Avoid concurrent /api/session calls
|
|
35
|
+
if (_csrfTokenPromise) {
|
|
36
|
+
return _csrfTokenPromise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (_csrfTokenCache) {
|
|
40
|
+
return _csrfTokenCache;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_csrfTokenPromise = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch('/api/session', {
|
|
46
|
+
method: 'GET',
|
|
47
|
+
credentials: 'same-origin',
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error(`/api/session returned ${res.status}`);
|
|
51
|
+
}
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
const token = data.token ?? '';
|
|
54
|
+
_csrfTokenCache = token;
|
|
55
|
+
return token;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('Failed to fetch CSRF token:', err);
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
return _csrfTokenPromise;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface FetchOptions extends RequestInit {
|
|
66
|
+
requireCSRF?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Typed fetch wrapper that injects CSRF header for mutations.
|
|
71
|
+
*/
|
|
72
|
+
async function typedFetch<T>(
|
|
73
|
+
url: string,
|
|
74
|
+
options?: FetchOptions
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const opts = { ...options };
|
|
77
|
+
|
|
78
|
+
// For POST/PUT/DELETE, inject CSRF header
|
|
79
|
+
if (opts.requireCSRF !== false && opts.method && ['POST', 'PUT', 'DELETE'].includes(opts.method)) {
|
|
80
|
+
const token = await getCSRFToken();
|
|
81
|
+
opts.headers = {
|
|
82
|
+
...(opts.headers as Record<string, string>),
|
|
83
|
+
'X-Aesop-Token': token,
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const res = await fetch(url, opts);
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const text = await res.text();
|
|
91
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return res.json() as Promise<T>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* GET /data — dashboard data snapshot
|
|
99
|
+
*/
|
|
100
|
+
export async function fetchData(): Promise<DashboardData> {
|
|
101
|
+
return typedFetch('/data', { method: 'GET', requireCSRF: false });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* GET /api/state — consolidated first-paint snapshot (U2 adds this)
|
|
106
|
+
*/
|
|
107
|
+
export async function fetchState(): Promise<FullState> {
|
|
108
|
+
return typedFetch('/api/state', { method: 'GET', requireCSRF: false });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* GET /api/backlog — audit backlog
|
|
113
|
+
*/
|
|
114
|
+
export async function fetchBacklog(): Promise<AuditBacklog> {
|
|
115
|
+
return typedFetch('/api/backlog', { method: 'GET', requireCSRF: false });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* GET /api/agents — fleet agents list
|
|
120
|
+
*/
|
|
121
|
+
export async function fetchAgents(): Promise<Agent[]> {
|
|
122
|
+
return typedFetch('/api/agents', { method: 'GET', requireCSRF: false });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* GET /api/tracker — tracker items with optional filters
|
|
127
|
+
*/
|
|
128
|
+
export async function fetchTrackerItems(
|
|
129
|
+
status?: string,
|
|
130
|
+
priority?: string
|
|
131
|
+
): Promise<TrackerItem[]> {
|
|
132
|
+
const params = new URLSearchParams();
|
|
133
|
+
if (status) params.append('status', status);
|
|
134
|
+
if (priority) params.append('priority', priority);
|
|
135
|
+
const query = params.toString();
|
|
136
|
+
const url = `/api/tracker${query ? '?' + query : ''}`;
|
|
137
|
+
return typedFetch(url, { method: 'GET', requireCSRF: false });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* GET /agent?id=<id> — agent dispatch prompt and metadata
|
|
142
|
+
*/
|
|
143
|
+
export async function fetchAgent(agentId: string): Promise<AgentDetail> {
|
|
144
|
+
const url = `/agent?id=${encodeURIComponent(agentId)}`;
|
|
145
|
+
return typedFetch(url, { method: 'GET', requireCSRF: false });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* GET /api/cost — cost summary with token/verdict data (U3)
|
|
150
|
+
*/
|
|
151
|
+
export async function fetchCost(): Promise<CostSummary> {
|
|
152
|
+
return typedFetch('/api/cost', { method: 'GET', requireCSRF: false });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* POST /submit — submit text to inbox
|
|
157
|
+
*/
|
|
158
|
+
export async function submitInbox(text: string): Promise<SubmitResponse> {
|
|
159
|
+
return typedFetch('/submit', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
requireCSRF: true,
|
|
162
|
+
body: JSON.stringify({ text }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* POST /api/tracker — create tracker item
|
|
168
|
+
*/
|
|
169
|
+
export async function createTrackerItem(data: {
|
|
170
|
+
title: string;
|
|
171
|
+
priority?: string;
|
|
172
|
+
status?: string;
|
|
173
|
+
lane?: string;
|
|
174
|
+
source?: string;
|
|
175
|
+
tags?: string[];
|
|
176
|
+
notes?: string;
|
|
177
|
+
pr_link?: string;
|
|
178
|
+
}): Promise<TrackerItem> {
|
|
179
|
+
return typedFetch('/api/tracker', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
requireCSRF: true,
|
|
182
|
+
body: JSON.stringify(data),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* POST /api/tracker/<id> — update tracker item
|
|
188
|
+
*/
|
|
189
|
+
export async function updateTrackerItem(
|
|
190
|
+
itemId: string,
|
|
191
|
+
data: Partial<{
|
|
192
|
+
status: string;
|
|
193
|
+
lane: string;
|
|
194
|
+
priority: string;
|
|
195
|
+
notes: string;
|
|
196
|
+
pr_link: string;
|
|
197
|
+
tags: string[];
|
|
198
|
+
}>
|
|
199
|
+
): Promise<TrackerItem> {
|
|
200
|
+
const url = `/api/tracker/${encodeURIComponent(itemId)}`;
|
|
201
|
+
return typedFetch(url, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
requireCSRF: true,
|
|
204
|
+
body: JSON.stringify(data),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* POST /api/tracker/<id>?action=delete — delete (archive) tracker item
|
|
210
|
+
*/
|
|
211
|
+
export async function deleteTrackerItem(itemId: string): Promise<TrackerItem> {
|
|
212
|
+
const url = `/api/tracker/${encodeURIComponent(itemId)}?action=delete`;
|
|
213
|
+
return typedFetch(url, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
requireCSRF: true,
|
|
216
|
+
body: '{}',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatAge, formatTokens, formatTimestamp, formatCurrency, formatPercent, capitalize } from './format';
|
|
3
|
+
|
|
4
|
+
describe('formatAge', () => {
|
|
5
|
+
it('returns unknown for negative ages', () => {
|
|
6
|
+
expect(formatAge(-1)).toBe('unknown');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('formats seconds', () => {
|
|
10
|
+
expect(formatAge(0)).toBe('0s');
|
|
11
|
+
expect(formatAge(45)).toBe('45s');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('formats minutes with seconds remainder', () => {
|
|
15
|
+
expect(formatAge(90)).toBe('1m 30s');
|
|
16
|
+
expect(formatAge(120)).toBe('2m');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('formats hours with minutes remainder', () => {
|
|
20
|
+
expect(formatAge(3600)).toBe('1h');
|
|
21
|
+
expect(formatAge(3660)).toBe('1h 1m');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('formats days with hours remainder', () => {
|
|
25
|
+
expect(formatAge(86400)).toBe('1d');
|
|
26
|
+
expect(formatAge(86400 + 3 * 3600)).toBe('1d 3h');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('formatTokens', () => {
|
|
31
|
+
it('leaves small counts alone', () => {
|
|
32
|
+
expect(formatTokens(0)).toBe('0');
|
|
33
|
+
expect(formatTokens(999)).toBe('999');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('formats thousands', () => {
|
|
37
|
+
expect(formatTokens(1000)).toBe('1K');
|
|
38
|
+
expect(formatTokens(1200)).toBe('1.2K');
|
|
39
|
+
expect(formatTokens(999999)).toBe('999K');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('formats millions', () => {
|
|
43
|
+
expect(formatTokens(1000000)).toBe('1M');
|
|
44
|
+
expect(formatTokens(1500000)).toBe('1.5M');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('formatTimestamp', () => {
|
|
49
|
+
it('says now for very recent timestamps', () => {
|
|
50
|
+
expect(formatTimestamp(new Date().toISOString())).toBe('now');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('formats minutes ago', () => {
|
|
54
|
+
const t = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
55
|
+
expect(formatTimestamp(t)).toBe('5m ago');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('formats hours ago', () => {
|
|
59
|
+
const t = new Date(Date.now() - 3 * 3600 * 1000).toISOString();
|
|
60
|
+
expect(formatTimestamp(t)).toBe('3h ago');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns input on unparseable strings', () => {
|
|
64
|
+
// new Date('garbage') is Invalid Date; formatter must not throw
|
|
65
|
+
const out = formatTimestamp('garbage');
|
|
66
|
+
expect(typeof out).toBe('string');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('formatCurrency', () => {
|
|
71
|
+
it('formats dollars with two decimals', () => {
|
|
72
|
+
expect(formatCurrency(1.234)).toBe('$1.23');
|
|
73
|
+
expect(formatCurrency(0)).toBe('$0.00');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('formatPercent', () => {
|
|
78
|
+
it('formats fraction as percentage with one decimal', () => {
|
|
79
|
+
expect(formatPercent(0.456)).toBe('45.6%');
|
|
80
|
+
expect(formatPercent(1)).toBe('100.0%');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('capitalize', () => {
|
|
85
|
+
it('capitalizes first letter', () => {
|
|
86
|
+
expect(capitalize('running')).toBe('Running');
|
|
87
|
+
expect(capitalize('')).toBe('');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting utilities for display values.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format age in seconds as human-readable string.
|
|
7
|
+
* Examples: "2s", "1m 30s", "2h", "1d 3h"
|
|
8
|
+
*/
|
|
9
|
+
export function formatAge(ageSeconds: number): string {
|
|
10
|
+
if (ageSeconds < 0) return 'unknown';
|
|
11
|
+
if (ageSeconds < 60) return `${ageSeconds}s`;
|
|
12
|
+
if (ageSeconds < 3600) {
|
|
13
|
+
const minutes = Math.floor(ageSeconds / 60);
|
|
14
|
+
const seconds = ageSeconds % 60;
|
|
15
|
+
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
16
|
+
}
|
|
17
|
+
if (ageSeconds < 86400) {
|
|
18
|
+
const hours = Math.floor(ageSeconds / 3600);
|
|
19
|
+
const minutes = Math.floor((ageSeconds % 3600) / 60);
|
|
20
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
21
|
+
}
|
|
22
|
+
const days = Math.floor(ageSeconds / 86400);
|
|
23
|
+
const hours = Math.floor((ageSeconds % 86400) / 3600);
|
|
24
|
+
return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format token count as human-readable string.
|
|
29
|
+
* Examples: "123", "1.2K", "1.5M"
|
|
30
|
+
*/
|
|
31
|
+
export function formatTokens(count: number): string {
|
|
32
|
+
if (count < 1000) return String(count);
|
|
33
|
+
if (count < 1000000) {
|
|
34
|
+
const k = (count / 1000).toFixed(1);
|
|
35
|
+
return k.endsWith('.0') ? `${Math.floor(count / 1000)}K` : `${k}K`;
|
|
36
|
+
}
|
|
37
|
+
const m = (count / 1000000).toFixed(1);
|
|
38
|
+
return m.endsWith('.0') ? `${Math.floor(count / 1000000)}M` : `${m}M`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format ISO 8601 timestamp as relative time or date.
|
|
43
|
+
* Examples: "now", "2m ago", "Today 14:30", "Jan 15 2026"
|
|
44
|
+
*/
|
|
45
|
+
export function formatTimestamp(isoString: string): string {
|
|
46
|
+
try {
|
|
47
|
+
const date = new Date(isoString);
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const diffMs = now.getTime() - date.getTime();
|
|
50
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
51
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
52
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
53
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
54
|
+
|
|
55
|
+
if (diffSecs < 60) return 'now';
|
|
56
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
57
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
58
|
+
if (diffDays === 0) {
|
|
59
|
+
// Today
|
|
60
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
61
|
+
const mins = String(date.getMinutes()).padStart(2, '0');
|
|
62
|
+
return `Today ${hours}:${mins}`;
|
|
63
|
+
}
|
|
64
|
+
if (diffDays < 365) {
|
|
65
|
+
// This year: "Jan 15 14:30"
|
|
66
|
+
const monthStr = date.toLocaleString('en-US', { month: 'short' });
|
|
67
|
+
const day = date.getDate();
|
|
68
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
69
|
+
const mins = String(date.getMinutes()).padStart(2, '0');
|
|
70
|
+
return `${monthStr} ${day} ${hours}:${mins}`;
|
|
71
|
+
}
|
|
72
|
+
// Different year: "Jan 15 2025"
|
|
73
|
+
const monthStr = date.toLocaleString('en-US', { month: 'short' });
|
|
74
|
+
const day = date.getDate();
|
|
75
|
+
const year = date.getFullYear();
|
|
76
|
+
return `${monthStr} ${day} ${year}`;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return isoString;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Format currency amount.
|
|
84
|
+
* Examples: "$1.23", "$0.01", "$123.45"
|
|
85
|
+
*/
|
|
86
|
+
export function formatCurrency(usd: number): string {
|
|
87
|
+
return `$${usd.toFixed(2)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format percentage with one decimal.
|
|
92
|
+
* Examples: "45.6%", "100.0%"
|
|
93
|
+
*/
|
|
94
|
+
export function formatPercent(fraction: number): string {
|
|
95
|
+
return `${(fraction * 100).toFixed(1)}%`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Capitalize first letter of string.
|
|
100
|
+
*/
|
|
101
|
+
export function capitalize(s: string): string {
|
|
102
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
103
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeUrl } from './sanitizeUrl';
|
|
3
|
+
|
|
4
|
+
describe('sanitizeUrl', () => {
|
|
5
|
+
it('allows http URLs', () => {
|
|
6
|
+
const url = 'http://example.com/path';
|
|
7
|
+
expect(sanitizeUrl(url)).toBe(url);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('allows https URLs', () => {
|
|
11
|
+
const url = 'https://github.com/owner/repo/pull/123';
|
|
12
|
+
expect(sanitizeUrl(url)).toBe(url);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('allows https URLs with query params', () => {
|
|
16
|
+
const url = 'https://example.com/search?q=test&lang=en';
|
|
17
|
+
expect(sanitizeUrl(url)).toBe(url);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('allows https URLs with explicit ports', () => {
|
|
21
|
+
const url = 'https://example.com:8443/path';
|
|
22
|
+
expect(sanitizeUrl(url)).toBe(url);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('allows http URLs on localhost ports', () => {
|
|
26
|
+
const url = 'http://localhost:8770/data';
|
|
27
|
+
expect(sanitizeUrl(url)).toBe(url);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('blocks javascript: URLs', () => {
|
|
31
|
+
expect(sanitizeUrl('javascript:alert("xss")')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('blocks javascript: URLs with mixed case', () => {
|
|
35
|
+
expect(sanitizeUrl('JaVaScRiPt:alert(1)')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('blocks data: URLs', () => {
|
|
39
|
+
expect(sanitizeUrl('data:text/html,<script>alert("xss")</script>')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('blocks vbscript: URLs', () => {
|
|
43
|
+
expect(sanitizeUrl('vbscript:msgbox("xss")')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('blocks mailto: URLs', () => {
|
|
47
|
+
expect(sanitizeUrl('mailto:attacker@example.com')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('blocks file: URLs', () => {
|
|
51
|
+
expect(sanitizeUrl('file:///etc/passwd')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('allows relative paths (resolve same-origin, matching old sanitizeURL)', () => {
|
|
55
|
+
// Old dashboard.html sanitizeURL parses with base location.href, so a
|
|
56
|
+
// relative path resolves to same-origin http and is safe/allowed.
|
|
57
|
+
expect(sanitizeUrl('/path/to/page')).toBe('/path/to/page');
|
|
58
|
+
expect(sanitizeUrl('relative/page')).toBe('relative/page');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns the original string, not the resolved URL', () => {
|
|
62
|
+
expect(sanitizeUrl('/pr/123')).toBe('/pr/123');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns null for empty strings', () => {
|
|
66
|
+
expect(sanitizeUrl('')).toBeNull();
|
|
67
|
+
expect(sanitizeUrl(' ')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns null for null/undefined', () => {
|
|
71
|
+
expect(sanitizeUrl(null)).toBeNull();
|
|
72
|
+
expect(sanitizeUrl(undefined)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null for non-string input', () => {
|
|
76
|
+
expect(sanitizeUrl(123 as any)).toBeNull();
|
|
77
|
+
expect(sanitizeUrl({} as any)).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('trims whitespace before validating', () => {
|
|
81
|
+
const url = ' https://example.com ';
|
|
82
|
+
expect(sanitizeUrl(url)).toBe('https://example.com');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL sanitization for safe rendering in anchor tags.
|
|
3
|
+
* Prevents XSS by allowlisting only http: and https: schemes.
|
|
4
|
+
*
|
|
5
|
+
* Semantics ported from the old dashboard.html sanitizeURL():
|
|
6
|
+
* - The URL is parsed with `location.href` as the base, so relative URLs
|
|
7
|
+
* resolve same-origin (http/https) and are therefore allowed.
|
|
8
|
+
* - Anything whose resolved protocol is not http:/https: (javascript:,
|
|
9
|
+
* data:, vbscript:, file:, mailto:, ...) is rejected.
|
|
10
|
+
* - Unparseable input is treated as unsafe.
|
|
11
|
+
*
|
|
12
|
+
* Returns the ORIGINAL string when safe (never the resolved form), or null
|
|
13
|
+
* when unsafe so the caller can render an inert, href-less element.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function sanitizeUrl(url: string | null | undefined): string | null {
|
|
17
|
+
if (!url || typeof url !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const trimmed = url.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const base =
|
|
28
|
+
typeof location !== 'undefined' ? location.href : 'http://localhost/';
|
|
29
|
+
const parsed = new URL(trimmed, base);
|
|
30
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
31
|
+
return trimmed;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// Unparseable URL: treat as unsafe.
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|