@ashlr/hub 2.2.0 → 3.0.1
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 +73 -0
- package/README.md +95 -8
- package/dist/api/plugin.d.ts +1 -0
- package/dist/cli/backlog.js +2 -0
- package/dist/cli/backlog.js.map +1 -1
- package/dist/cli/digest.d.ts +6 -0
- package/dist/cli/digest.js +127 -2
- package/dist/cli/digest.js.map +1 -1
- package/dist/cli/eval-fixtures.d.ts +18 -0
- package/dist/cli/eval-fixtures.js +38 -0
- package/dist/cli/eval-fixtures.js.map +1 -0
- package/dist/cli/eval.d.ts +56 -0
- package/dist/cli/eval.js +283 -0
- package/dist/cli/eval.js.map +1 -0
- package/dist/cli/fleet.d.ts +27 -0
- package/dist/cli/fleet.js +222 -0
- package/dist/cli/fleet.js.map +1 -0
- package/dist/cli/goal.d.ts +15 -0
- package/dist/cli/goal.js +91 -0
- package/dist/cli/goal.js.map +1 -0
- package/dist/cli/help.js +10 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inbox.d.ts +6 -0
- package/dist/cli/inbox.js +183 -8
- package/dist/cli/inbox.js.map +1 -1
- package/dist/cli/index.js +50 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/loop.d.ts +16 -0
- package/dist/cli/loop.js +66 -0
- package/dist/cli/loop.js.map +1 -0
- package/dist/cli/mcp.d.ts +12 -0
- package/dist/cli/mcp.js +148 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/onboard.d.ts +16 -0
- package/dist/cli/onboard.js +71 -0
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli/plugins.js +9 -8
- package/dist/cli/plugins.js.map +1 -1
- package/dist/cli/pulse.d.ts +10 -0
- package/dist/cli/pulse.js +261 -0
- package/dist/cli/pulse.js.map +1 -1
- package/dist/cli/run.js +21 -3
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/stack.d.ts +34 -0
- package/dist/cli/stack.js +221 -0
- package/dist/cli/stack.js.map +1 -0
- package/dist/cli/update.js +12 -5
- package/dist/cli/update.js.map +1 -1
- package/dist/core/daemon/loop.d.ts +10 -5
- package/dist/core/daemon/loop.js +167 -37
- package/dist/core/daemon/loop.js.map +1 -1
- package/dist/core/env-bridge.js +3 -2
- package/dist/core/env-bridge.js.map +1 -1
- package/dist/core/fleet/automerge-pass.d.ts +33 -0
- package/dist/core/fleet/automerge-pass.js +61 -0
- package/dist/core/fleet/automerge-pass.js.map +1 -0
- package/dist/core/fleet/quota.d.ts +70 -0
- package/dist/core/fleet/quota.js +198 -0
- package/dist/core/fleet/quota.js.map +1 -0
- package/dist/core/fleet/router.d.ts +46 -0
- package/dist/core/fleet/router.js +121 -0
- package/dist/core/fleet/router.js.map +1 -0
- package/dist/core/fleet/self.d.ts +58 -0
- package/dist/core/fleet/self.js +187 -0
- package/dist/core/fleet/self.js.map +1 -0
- package/dist/core/fleet/status.d.ts +54 -0
- package/dist/core/fleet/status.js +157 -0
- package/dist/core/fleet/status.js.map +1 -0
- package/dist/core/foundry/provenance.d.ts +78 -0
- package/dist/core/foundry/provenance.js +172 -0
- package/dist/core/foundry/provenance.js.map +1 -0
- package/dist/core/git.d.ts +13 -0
- package/dist/core/git.js +26 -0
- package/dist/core/git.js.map +1 -1
- package/dist/core/inbox/merge.d.ts +151 -0
- package/dist/core/inbox/merge.js +858 -0
- package/dist/core/inbox/merge.js.map +1 -0
- package/dist/core/inbox/store.js +10 -1
- package/dist/core/inbox/store.js.map +1 -1
- package/dist/core/integrations/markdown.d.ts +66 -0
- package/dist/core/integrations/markdown.js +132 -0
- package/dist/core/integrations/markdown.js.map +1 -0
- package/dist/core/integrations/secrets.d.ts +37 -0
- package/dist/core/integrations/secrets.js +67 -0
- package/dist/core/integrations/secrets.js.map +1 -0
- package/dist/core/integrations/stack.d.ts +47 -0
- package/dist/core/integrations/stack.js +107 -0
- package/dist/core/integrations/stack.js.map +1 -0
- package/dist/core/mcp-native-engineer.d.ts +123 -0
- package/dist/core/mcp-native-engineer.js +936 -0
- package/dist/core/mcp-native-engineer.js.map +1 -0
- package/dist/core/mcp-native.js +20 -42
- package/dist/core/mcp-native.js.map +1 -1
- package/dist/core/mcp-registry.js +2 -0
- package/dist/core/mcp-registry.js.map +1 -1
- package/dist/core/observability/codex-source.d.ts +53 -0
- package/dist/core/observability/codex-source.js +390 -0
- package/dist/core/observability/codex-source.js.map +1 -0
- package/dist/core/observability/estimate.d.ts +12 -0
- package/dist/core/observability/estimate.js +26 -0
- package/dist/core/observability/estimate.js.map +1 -1
- package/dist/core/observability/limits.d.ts +52 -0
- package/dist/core/observability/limits.js +316 -0
- package/dist/core/observability/limits.js.map +1 -0
- package/dist/core/observability/usage-source.js +3 -1
- package/dist/core/observability/usage-source.js.map +1 -1
- package/dist/core/portfolio/scanners.d.ts +1 -0
- package/dist/core/portfolio/scanners.js +40 -1
- package/dist/core/portfolio/scanners.js.map +1 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/providers.js.map +1 -1
- package/dist/core/quality/health.js +1 -0
- package/dist/core/quality/health.js.map +1 -1
- package/dist/core/run/agent-loop.d.ts +8 -0
- package/dist/core/run/agent-loop.js +24 -14
- package/dist/core/run/agent-loop.js.map +1 -1
- package/dist/core/run/engine-registry.d.ts +48 -0
- package/dist/core/run/engine-registry.js +190 -0
- package/dist/core/run/engine-registry.js.map +1 -0
- package/dist/core/run/engines.d.ts +17 -3
- package/dist/core/run/engines.js +63 -44
- package/dist/core/run/engines.js.map +1 -1
- package/dist/core/run/learned-router.d.ts +121 -0
- package/dist/core/run/learned-router.js +374 -0
- package/dist/core/run/learned-router.js.map +1 -0
- package/dist/core/run/model-manager.js +3 -3
- package/dist/core/run/model-manager.js.map +1 -1
- package/dist/core/run/model-profile.d.ts +67 -0
- package/dist/core/run/model-profile.js +132 -0
- package/dist/core/run/model-profile.js.map +1 -0
- package/dist/core/run/orchestrator.d.ts +1 -1
- package/dist/core/run/orchestrator.js +642 -510
- package/dist/core/run/orchestrator.js.map +1 -1
- package/dist/core/run/prompts/budget.d.ts +25 -0
- package/dist/core/run/prompts/budget.js +51 -0
- package/dist/core/run/prompts/budget.js.map +1 -0
- package/dist/core/run/prompts/index.d.ts +14 -0
- package/dist/core/run/prompts/index.js +59 -0
- package/dist/core/run/prompts/index.js.map +1 -0
- package/dist/core/run/prompts/layers.d.ts +26 -0
- package/dist/core/run/prompts/layers.js +73 -0
- package/dist/core/run/prompts/layers.js.map +1 -0
- package/dist/core/run/prompts/roles.d.ts +23 -0
- package/dist/core/run/prompts/roles.js +48 -0
- package/dist/core/run/prompts/roles.js.map +1 -0
- package/dist/core/run/prompts/types.d.ts +38 -0
- package/dist/core/run/prompts/types.js +5 -0
- package/dist/core/run/prompts/types.js.map +1 -0
- package/dist/core/run/provider-client.d.ts +6 -24
- package/dist/core/run/provider-client.js +125 -61
- package/dist/core/run/provider-client.js.map +1 -1
- package/dist/core/run/router.js +8 -0
- package/dist/core/run/router.js.map +1 -1
- package/dist/core/run/sandboxed-engine.d.ts +66 -0
- package/dist/core/run/sandboxed-engine.js +262 -0
- package/dist/core/run/sandboxed-engine.js.map +1 -0
- package/dist/core/run/verify-commands.d.ts +83 -0
- package/dist/core/run/verify-commands.js +234 -0
- package/dist/core/run/verify-commands.js.map +1 -0
- package/dist/core/run/verify.d.ts +31 -1
- package/dist/core/run/verify.js +48 -0
- package/dist/core/run/verify.js.map +1 -1
- package/dist/core/sandbox/audit.d.ts +27 -0
- package/dist/core/sandbox/audit.js +34 -0
- package/dist/core/sandbox/audit.js.map +1 -1
- package/dist/core/sandbox/confine.d.ts +134 -0
- package/dist/core/sandbox/confine.js +370 -0
- package/dist/core/sandbox/confine.js.map +1 -0
- package/dist/core/types.d.ts +227 -5
- package/dist/core/web/api.js +77 -28
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/control.d.ts +116 -0
- package/dist/core/web/control.js +307 -0
- package/dist/core/web/control.js.map +1 -0
- package/dist/core/web/public/app.js +512 -6
- package/dist/core/web/public/styles.css +468 -0
- package/package.json +3 -2
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
// Constants
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
|
|
27
|
-
const VIEWS = ['overview', 'runs', 'swarms', 'pulse', 'genome', 'portfolio', 'inbox', 'daemon'];
|
|
28
|
-
const DEFAULT_VIEW = '
|
|
27
|
+
const VIEWS = ['control', 'overview', 'runs', 'swarms', 'pulse', 'genome', 'portfolio', 'inbox', 'daemon', 'fleet'];
|
|
28
|
+
const DEFAULT_VIEW = 'control';
|
|
29
29
|
const API_BASE = ''; // same origin
|
|
30
30
|
|
|
31
31
|
// sessionStorage key for the session token (never localStorage, never URL).
|
|
@@ -64,6 +64,9 @@ const state = {
|
|
|
64
64
|
inbox: { pending: 0, proposals: [] },
|
|
65
65
|
inboxDetail: null, // currently-open Proposal (full, with diff)
|
|
66
66
|
daemon: null, // DaemonState | null
|
|
67
|
+
fleet: null, // M49: FleetStatus | null
|
|
68
|
+
control: null, // M61: GET /api/control composite view
|
|
69
|
+
controlInterval: null, // M61: 4s polling timer
|
|
67
70
|
inboxBadge: 0, // pending count from SSE, drives nav badge
|
|
68
71
|
loading: {}, // viewName -> boolean
|
|
69
72
|
error: {}, // viewName -> string | null
|
|
@@ -269,6 +272,8 @@ function connectSSE() {
|
|
|
269
272
|
try {
|
|
270
273
|
state.daemon = JSON.parse(e.data);
|
|
271
274
|
if (state.activeView === 'daemon') renderDaemon();
|
|
275
|
+
// M61: control view picks up daemon events
|
|
276
|
+
if (state.activeView === 'control') loadControl();
|
|
272
277
|
} catch {}
|
|
273
278
|
});
|
|
274
279
|
es.addEventListener('error', () => {
|
|
@@ -322,7 +327,8 @@ async function onHashChange() {
|
|
|
322
327
|
|
|
323
328
|
function renderActiveView() {
|
|
324
329
|
const view = state.activeView;
|
|
325
|
-
if (view === '
|
|
330
|
+
if (view === 'control') renderControl();
|
|
331
|
+
else if (view === 'overview') renderOverview();
|
|
326
332
|
else if (view === 'runs') renderRuns();
|
|
327
333
|
else if (view === 'swarms') renderSwarms();
|
|
328
334
|
else if (view === 'pulse') renderPulse();
|
|
@@ -330,10 +336,17 @@ function renderActiveView() {
|
|
|
330
336
|
else if (view === 'portfolio') renderPortfolio();
|
|
331
337
|
else if (view === 'inbox') renderInbox();
|
|
332
338
|
else if (view === 'daemon') renderDaemon();
|
|
339
|
+
else if (view === 'fleet') renderFleet();
|
|
333
340
|
}
|
|
334
341
|
|
|
335
342
|
async function loadView(view) {
|
|
336
|
-
|
|
343
|
+
// Stop control polling when navigating away
|
|
344
|
+
if (view !== 'control' && state.controlInterval) {
|
|
345
|
+
clearInterval(state.controlInterval);
|
|
346
|
+
state.controlInterval = null;
|
|
347
|
+
}
|
|
348
|
+
if (view === 'control') await loadControl();
|
|
349
|
+
else if (view === 'overview') await loadOverview();
|
|
337
350
|
else if (view === 'runs') await loadRuns();
|
|
338
351
|
else if (view === 'swarms') await loadSwarms();
|
|
339
352
|
else if (view === 'pulse') await loadPulse();
|
|
@@ -341,6 +354,7 @@ async function loadView(view) {
|
|
|
341
354
|
else if (view === 'portfolio') await loadPortfolio();
|
|
342
355
|
else if (view === 'inbox') await loadInbox();
|
|
343
356
|
else if (view === 'daemon') await loadDaemon();
|
|
357
|
+
else if (view === 'fleet') await loadFleet();
|
|
344
358
|
}
|
|
345
359
|
|
|
346
360
|
// ---------------------------------------------------------------------------
|
|
@@ -362,6 +376,7 @@ function renderShell() {
|
|
|
362
376
|
|
|
363
377
|
// Nav icons per view (inline SVG path data)
|
|
364
378
|
const VIEW_ICONS = {
|
|
379
|
+
control: '<rect x="1" y="1" width="14" height="9" rx="1.5" stroke="currentColor" stroke-width="1.4" fill="none"/><line x1="4" y1="13" x2="12" y2="13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><line x1="8" y1="10" x2="8" y2="13" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><circle cx="5" cy="5.5" r="1.3" fill="currentColor" opacity=".9"/><circle cx="8" cy="5.5" r="1.3" fill="currentColor" opacity=".6"/><circle cx="11" cy="5.5" r="1.3" fill="currentColor" opacity=".35"/>',
|
|
365
380
|
overview: '<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".85"/><rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".55"/><rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" opacity=".3"/>',
|
|
366
381
|
runs: '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><polyline points="5.5,8 7.5,10 10.5,6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
|
367
382
|
swarms: '<circle cx="8" cy="3" r="2" fill="currentColor" opacity=".9"/><circle cx="3" cy="13" r="2" fill="currentColor" opacity=".7"/><circle cx="13" cy="13" r="2" fill="currentColor" opacity=".7"/><line x1="8" y1="5" x2="3" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="8" y1="5" x2="13" y2="11" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><line x1="3" y1="13" x2="13" y2="13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>',
|
|
@@ -382,8 +397,10 @@ function renderShell() {
|
|
|
382
397
|
iconSvg.setAttribute('aria-hidden', 'true');
|
|
383
398
|
iconSvg.innerHTML = VIEW_ICONS[v] ?? '';
|
|
384
399
|
|
|
385
|
-
const
|
|
386
|
-
const
|
|
400
|
+
const VIEW_LABELS = { control: 'Mission Control' };
|
|
401
|
+
const labelText = VIEW_LABELS[v] ?? (v.charAt(0).toUpperCase() + v.slice(1));
|
|
402
|
+
const label = document.createTextNode(labelText);
|
|
403
|
+
const a = el('a', { cls: `nav-link${v === 'control' ? ' nav-link--control' : ''}`, href: `#${v}`, 'data-view': v });
|
|
387
404
|
a.appendChild(iconSvg);
|
|
388
405
|
a.appendChild(label);
|
|
389
406
|
|
|
@@ -1843,6 +1860,108 @@ function renderDaemon() {
|
|
|
1843
1860
|
main.appendChild(section);
|
|
1844
1861
|
}
|
|
1845
1862
|
|
|
1863
|
+
// ---------------------------------------------------------------------------
|
|
1864
|
+
// M49: Fleet view — read-only fleet control plane snapshot
|
|
1865
|
+
// ---------------------------------------------------------------------------
|
|
1866
|
+
|
|
1867
|
+
async function loadFleet() {
|
|
1868
|
+
showLoading('fleet');
|
|
1869
|
+
try {
|
|
1870
|
+
state.fleet = await apiFetch('/api/fleet');
|
|
1871
|
+
renderFleet();
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
showError('fleet', err.message);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function quotaTag(quota) {
|
|
1878
|
+
const map = {
|
|
1879
|
+
ok: 'var(--status-done)',
|
|
1880
|
+
warn: 'var(--status-aborted)',
|
|
1881
|
+
over: 'var(--status-failed)',
|
|
1882
|
+
unlimited: 'var(--text-dim, #888)',
|
|
1883
|
+
};
|
|
1884
|
+
return el('span', {
|
|
1885
|
+
cls: 'fleet-quota',
|
|
1886
|
+
style: `color:${map[quota] || 'inherit'}`,
|
|
1887
|
+
title: `quota: ${quota}`,
|
|
1888
|
+
}, quota);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function renderFleet() {
|
|
1892
|
+
if (state.activeView !== 'fleet') return;
|
|
1893
|
+
const main = getMain();
|
|
1894
|
+
if (!main) return;
|
|
1895
|
+
main.innerHTML = '';
|
|
1896
|
+
|
|
1897
|
+
const section = el('section', { cls: 'view-section' });
|
|
1898
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
1899
|
+
el('h1', { cls: 'view-title' }, 'Fleet'),
|
|
1900
|
+
el('span', { cls: 'view-subtitle' }, 'Control plane & observability')
|
|
1901
|
+
));
|
|
1902
|
+
|
|
1903
|
+
const f = state.fleet;
|
|
1904
|
+
if (!f) {
|
|
1905
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
1906
|
+
el('p', {}, 'Fleet status unavailable.'),
|
|
1907
|
+
el('p', { cls: 'hint' }, 'Try again, or check the daemon with `ashlr fleet status`.')
|
|
1908
|
+
));
|
|
1909
|
+
main.appendChild(section);
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Paused / killed banner
|
|
1914
|
+
if (f.killed) {
|
|
1915
|
+
section.appendChild(el('div', { cls: 'fleet-banner fleet-banner--paused' },
|
|
1916
|
+
el('strong', {}, 'Fleet paused'),
|
|
1917
|
+
el('span', {}, ' — the kill switch is engaged. Resume with `ashlr fleet resume`.')
|
|
1918
|
+
));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// Daemon + queue + merges summary card
|
|
1922
|
+
const summary = el('div', { cls: 'fleet-card card' });
|
|
1923
|
+
summary.appendChild(infoGrid([
|
|
1924
|
+
['Daemon', f.daemon.running ? 'running' : 'stopped'],
|
|
1925
|
+
['Last tick', f.daemon.lastTickAt ? fmtRelative(f.daemon.lastTickAt) : '—'],
|
|
1926
|
+
['Spend today', f.daemon.todaySpentUsd != null ? `$${f.daemon.todaySpentUsd.toFixed(4)}` : '—'],
|
|
1927
|
+
['Backlog queue', f.queue?.backlogItems ?? '—'],
|
|
1928
|
+
['Merges (24h)', f.merges?.recent ?? '—'],
|
|
1929
|
+
]));
|
|
1930
|
+
section.appendChild(summary);
|
|
1931
|
+
|
|
1932
|
+
// Backends table
|
|
1933
|
+
const backendsCard = el('div', { cls: 'fleet-card card' });
|
|
1934
|
+
backendsCard.appendChild(el('h2', { cls: 'card-title' }, 'Backends'));
|
|
1935
|
+
const backends = Array.isArray(f.backends) ? f.backends : [];
|
|
1936
|
+
if (backends.length === 0) {
|
|
1937
|
+
backendsCard.appendChild(el('p', { cls: 'hint' }, 'No backends configured.'));
|
|
1938
|
+
} else {
|
|
1939
|
+
const list = el('div', { cls: 'fleet-backends' });
|
|
1940
|
+
for (const b of backends) {
|
|
1941
|
+
const row = el('div', { cls: 'fleet-backend-row' },
|
|
1942
|
+
el('span', { cls: 'fleet-backend-name' }, b.backend),
|
|
1943
|
+
el('span', { cls: 'fleet-backend-dispatches' }, `${b.dispatchesRecent} dispatch(es) / 24h`),
|
|
1944
|
+
quotaTag(b.quota)
|
|
1945
|
+
);
|
|
1946
|
+
list.appendChild(row);
|
|
1947
|
+
}
|
|
1948
|
+
backendsCard.appendChild(list);
|
|
1949
|
+
}
|
|
1950
|
+
section.appendChild(backendsCard);
|
|
1951
|
+
|
|
1952
|
+
// Proposals card
|
|
1953
|
+
const propsCard = el('div', { cls: 'fleet-card card' });
|
|
1954
|
+
propsCard.appendChild(el('h2', { cls: 'card-title' }, 'Proposals'));
|
|
1955
|
+
propsCard.appendChild(infoGrid([
|
|
1956
|
+
['Pending', f.proposals?.pending ?? 0],
|
|
1957
|
+
['Frontier pending', f.proposals?.frontierPending ?? 0],
|
|
1958
|
+
['Applied', f.proposals?.applied ?? 0],
|
|
1959
|
+
]));
|
|
1960
|
+
section.appendChild(propsCard);
|
|
1961
|
+
|
|
1962
|
+
main.appendChild(section);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1846
1965
|
/** Format a signed delta for the portfolio "today" block. null → em-dash. */
|
|
1847
1966
|
function fmtDelta(n, prefix = '') {
|
|
1848
1967
|
if (n === null || n === undefined || typeof n !== 'number' || !isFinite(n)) return '—';
|
|
@@ -1879,6 +1998,393 @@ function updateSseDot(connected) {
|
|
|
1879
1998
|
dot.classList.toggle('connected', connected);
|
|
1880
1999
|
}
|
|
1881
2000
|
|
|
2001
|
+
// ---------------------------------------------------------------------------
|
|
2002
|
+
// Mission Control (M61) — /api/control composite live view
|
|
2003
|
+
// ---------------------------------------------------------------------------
|
|
2004
|
+
|
|
2005
|
+
async function loadControl() {
|
|
2006
|
+
// Guard against overlapping fetches: /api/control aggregates a usage rollup
|
|
2007
|
+
// that can take a beat, and on single-threaded Node a stacked poll would pile
|
|
2008
|
+
// up requests (and freeze the tab waiting). Skip if one is already in flight.
|
|
2009
|
+
if (state.controlLoading) return;
|
|
2010
|
+
state.controlLoading = true;
|
|
2011
|
+
// Don't show skeleton loading flash on poll refreshes — only on first load
|
|
2012
|
+
if (!state.control) showLoading('control');
|
|
2013
|
+
try {
|
|
2014
|
+
state.control = await apiFetch('/api/control');
|
|
2015
|
+
renderControl();
|
|
2016
|
+
} catch (err) {
|
|
2017
|
+
if (!state.control) showError('control', err.message);
|
|
2018
|
+
// On poll failure keep stale data visible — don't replace with error
|
|
2019
|
+
} finally {
|
|
2020
|
+
state.controlLoading = false;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// Start polling (8s) if not already running and we're still on this view.
|
|
2024
|
+
if (state.activeView === 'control' && !state.controlInterval) {
|
|
2025
|
+
state.controlInterval = setInterval(() => {
|
|
2026
|
+
if (state.activeView !== 'control') {
|
|
2027
|
+
clearInterval(state.controlInterval);
|
|
2028
|
+
state.controlInterval = null;
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
loadControl();
|
|
2032
|
+
}, 8000);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function renderControl() {
|
|
2037
|
+
if (state.activeView !== 'control') return;
|
|
2038
|
+
const main = getMain();
|
|
2039
|
+
if (!main) return;
|
|
2040
|
+
// Preserve scroll position across the 8s poll re-render (otherwise a watched
|
|
2041
|
+
// dashboard jumps to the top every refresh).
|
|
2042
|
+
const _scrollY = window.scrollY;
|
|
2043
|
+
main.innerHTML = '';
|
|
2044
|
+
|
|
2045
|
+
const d = state.control;
|
|
2046
|
+
const section = el('section', { cls: 'view-section' });
|
|
2047
|
+
|
|
2048
|
+
section.appendChild(el('div', { cls: 'view-header' },
|
|
2049
|
+
el('h1', { cls: 'view-title' }, 'Mission Control'),
|
|
2050
|
+
el('span', { cls: 'view-subtitle' }, d ? `Updated ${fmtRelative(d.ts)}` : 'Live fleet overview')
|
|
2051
|
+
));
|
|
2052
|
+
|
|
2053
|
+
if (!d) {
|
|
2054
|
+
section.appendChild(el('div', { cls: 'empty-state' },
|
|
2055
|
+
el('p', {}, 'Control data unavailable.'),
|
|
2056
|
+
el('p', { cls: 'hint' }, 'Ensure the daemon is running and the server serves /api/control.')
|
|
2057
|
+
));
|
|
2058
|
+
main.appendChild(section);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// ── 1. Fleet Pulse (hero) ──────────────────────────────────────────────
|
|
2063
|
+
const fleet = d.fleet ?? d.daemon ?? {};
|
|
2064
|
+
const daemon = d.daemon ?? fleet.daemon ?? {};
|
|
2065
|
+
const queue = d.fleet?.queue ?? fleet.queue ?? {};
|
|
2066
|
+
const props = d.fleet?.proposals ?? fleet.proposals ?? {};
|
|
2067
|
+
const merges = d.fleet?.merges ?? fleet.merges ?? {};
|
|
2068
|
+
const isRunning = daemon.running ?? false;
|
|
2069
|
+
const isKilled = d.fleet?.killed ?? false;
|
|
2070
|
+
|
|
2071
|
+
if (isKilled) {
|
|
2072
|
+
section.appendChild(el('div', { cls: 'ctrl-banner ctrl-banner--paused' },
|
|
2073
|
+
el('strong', {}, 'Fleet paused'),
|
|
2074
|
+
el('span', {}, ' — kill switch engaged. Resume with `ashlr fleet resume`.')
|
|
2075
|
+
));
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const heroPulse = el('div', { cls: 'ctrl-hero' });
|
|
2079
|
+
const daemonStatusEl = el('div', { cls: 'ctrl-daemon-status' },
|
|
2080
|
+
el('span', { cls: `ctrl-live-dot${isRunning ? ' running' : ''}`, title: isRunning ? 'Running' : 'Stopped' }),
|
|
2081
|
+
el('span', { cls: `ctrl-daemon-label${isRunning ? ' running' : ''}` }, isRunning ? 'Daemon running' : 'Daemon stopped'),
|
|
2082
|
+
daemon.pid ? el('span', { cls: 'ctrl-pid' }, `PID ${daemon.pid}`) : null
|
|
2083
|
+
);
|
|
2084
|
+
heroPulse.appendChild(daemonStatusEl);
|
|
2085
|
+
|
|
2086
|
+
if (daemon.lastTickAt) {
|
|
2087
|
+
heroPulse.appendChild(el('div', { cls: 'ctrl-last-tick' }, `Last tick ${fmtRelative(daemon.lastTickAt)}`));
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
const heroMetrics = el('div', { cls: 'ctrl-hero-metrics' });
|
|
2091
|
+
heroMetrics.appendChild(controlMetric('Spend today', daemon.todaySpentUsd != null ? `$${daemon.todaySpentUsd.toFixed(4)}` : '—', '#fbbf24'));
|
|
2092
|
+
heroMetrics.appendChild(controlMetric('Queue depth', queue.backlogItems ?? '—', '#60a5fa'));
|
|
2093
|
+
heroMetrics.appendChild(controlMetric('Proposals', props.pending ?? 0, '#a78bfa'));
|
|
2094
|
+
heroMetrics.appendChild(controlMetric('Merges (24h)', merges.recent ?? '—', '#4ade80'));
|
|
2095
|
+
heroMetrics.appendChild(controlMetric('Kill switch', isKilled ? 'ENGAGED' : 'off', isKilled ? '#f87171' : '#64748b'));
|
|
2096
|
+
heroPulse.appendChild(heroMetrics);
|
|
2097
|
+
section.appendChild(heroPulse);
|
|
2098
|
+
|
|
2099
|
+
// ── 2. Local models ────────────────────────────────────────────────────
|
|
2100
|
+
const modelsData = d.models ?? {};
|
|
2101
|
+
const providers = Array.isArray(modelsData.providers) ? modelsData.providers : [];
|
|
2102
|
+
const activeProvider = modelsData.activeProvider ?? '';
|
|
2103
|
+
|
|
2104
|
+
const modelsCard = el('div', { cls: 'ctrl-card card' });
|
|
2105
|
+
modelsCard.appendChild(el('div', { cls: 'card-header' },
|
|
2106
|
+
el('span', { cls: 'card-title' }, 'Local Models'),
|
|
2107
|
+
el('span', { cls: 'card-subtitle' }, activeProvider ? `active: ${activeProvider}` : 'no active provider')
|
|
2108
|
+
));
|
|
2109
|
+
const modelsBody = el('div', { cls: 'card-body' });
|
|
2110
|
+
|
|
2111
|
+
if (providers.length === 0) {
|
|
2112
|
+
modelsBody.appendChild(el('p', { cls: 'hint' }, 'No local model providers detected.'));
|
|
2113
|
+
} else {
|
|
2114
|
+
for (const prov of providers) {
|
|
2115
|
+
const isActive = prov.id === activeProvider;
|
|
2116
|
+
const row = el('div', { cls: `ctrl-provider-row${isActive ? ' active' : ''}` });
|
|
2117
|
+
row.appendChild(el('span', { cls: `ctrl-health-dot ${prov.up ? 'up' : 'down'}`, title: prov.up ? 'Up' : 'Down' }));
|
|
2118
|
+
row.appendChild(el('span', { cls: 'ctrl-provider-name' }, prov.id));
|
|
2119
|
+
if (prov.kind) row.appendChild(el('span', { cls: 'ctrl-provider-kind' }, prov.kind));
|
|
2120
|
+
if (prov.baseUrl) row.appendChild(el('span', { cls: 'ctrl-provider-url' }, prov.baseUrl));
|
|
2121
|
+
if (isActive) row.appendChild(el('span', { cls: 'ctrl-active-badge' }, 'active'));
|
|
2122
|
+
const chips = el('div', { cls: 'ctrl-model-chips' });
|
|
2123
|
+
const models = Array.isArray(prov.models) ? prov.models : [];
|
|
2124
|
+
if (models.length === 0) {
|
|
2125
|
+
chips.appendChild(el('span', { cls: 'ctrl-model-chip muted' }, 'no models'));
|
|
2126
|
+
} else {
|
|
2127
|
+
models.forEach((m) => chips.appendChild(el('span', { cls: 'ctrl-model-chip' }, typeof m === 'string' ? m : (m.id ?? m.name ?? String(m)))));
|
|
2128
|
+
}
|
|
2129
|
+
row.appendChild(chips);
|
|
2130
|
+
modelsBody.appendChild(row);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
modelsCard.appendChild(modelsBody);
|
|
2134
|
+
section.appendChild(modelsCard);
|
|
2135
|
+
|
|
2136
|
+
// ── 3. Backends & limits ───────────────────────────────────────────────
|
|
2137
|
+
const backends = Array.isArray(d.fleet?.backends) ? d.fleet.backends : [];
|
|
2138
|
+
const limits = Array.isArray(d.limits) ? d.limits : [];
|
|
2139
|
+
const subLimits = d.subscriptionLimits ?? {};
|
|
2140
|
+
|
|
2141
|
+
const backendsCard = el('div', { cls: 'ctrl-card card' });
|
|
2142
|
+
backendsCard.appendChild(el('div', { cls: 'card-header' },
|
|
2143
|
+
el('span', { cls: 'card-title' }, 'Backends & Limits')
|
|
2144
|
+
));
|
|
2145
|
+
const backendsBody = el('div', { cls: 'card-body' });
|
|
2146
|
+
|
|
2147
|
+
if (subLimits.note) {
|
|
2148
|
+
backendsBody.appendChild(el('div', { cls: 'ctrl-sub-note' },
|
|
2149
|
+
el('span', { cls: 'ctrl-sub-icon' }, '⚠'),
|
|
2150
|
+
el('span', {}, subLimits.note)
|
|
2151
|
+
));
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// ── M63: rolling-window usage (REAL, from local transcripts) ──────────────
|
|
2155
|
+
const subWindows = Array.isArray(subLimits.windows) ? subLimits.windows : [];
|
|
2156
|
+
if (subWindows.length > 0) {
|
|
2157
|
+
backendsBody.appendChild(el('div', { cls: 'ctrl-limits-heading' }, 'Rolling usage (transcript-sourced)'));
|
|
2158
|
+
// Group by window label so we render 5h then 24h sections
|
|
2159
|
+
for (const windowLabel of ['5h', '24h']) {
|
|
2160
|
+
const rows = subWindows.filter((w) => w.window === windowLabel);
|
|
2161
|
+
if (rows.length === 0) continue;
|
|
2162
|
+
const section = el('div', { cls: 'ctrl-win-section' });
|
|
2163
|
+
section.appendChild(el('div', { cls: 'ctrl-win-label' }, `Last ${windowLabel}`));
|
|
2164
|
+
for (const w of rows) {
|
|
2165
|
+
const tokStr = typeof w.tokens === 'number' ? fmtK(w.tokens) : '—';
|
|
2166
|
+
const costStr = typeof w.costUsd === 'number' ? `$${fmt(w.costUsd, 4)}` : '—';
|
|
2167
|
+
section.appendChild(el('div', { cls: 'ctrl-win-row' },
|
|
2168
|
+
el('span', { cls: 'ctrl-win-provider' }, w.provider ?? '?'),
|
|
2169
|
+
el('span', { cls: 'ctrl-win-tokens' }, tokStr),
|
|
2170
|
+
el('span', { cls: 'ctrl-win-cost' }, costStr)
|
|
2171
|
+
));
|
|
2172
|
+
}
|
|
2173
|
+
backendsBody.appendChild(section);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// ── M63/M64: provider subscription/key status ────────────────────────────
|
|
2178
|
+
const subProviders = Array.isArray(subLimits.providers) ? subLimits.providers : [];
|
|
2179
|
+
if (subProviders.length > 0) {
|
|
2180
|
+
backendsBody.appendChild(el('div', { cls: 'ctrl-limits-heading' }, 'Provider status'));
|
|
2181
|
+
for (const p of subProviders) {
|
|
2182
|
+
const kindBadge = el('span', {
|
|
2183
|
+
cls: `ctrl-prov-kind-badge ${p.kind === 'api-key' ? 'api-key' : 'subscription'}`
|
|
2184
|
+
}, p.kind === 'api-key' ? 'API key' : 'subscription');
|
|
2185
|
+
|
|
2186
|
+
// M64: for providers with real used% (e.g. Codex), render a usage bar + reset time
|
|
2187
|
+
// p.used is used_percent (0-100) when coming from Codex rate-limits
|
|
2188
|
+
const hasUsedPct = typeof p.used === 'number' && p.used >= 0 && p.used <= 100;
|
|
2189
|
+
const isSubscriptionPct = p.kind === 'subscription' && hasUsedPct;
|
|
2190
|
+
|
|
2191
|
+
const row = el('div', { cls: 'ctrl-prov-status-row' },
|
|
2192
|
+
el('span', { cls: 'ctrl-prov-status-name' }, p.provider ?? '?'),
|
|
2193
|
+
kindBadge,
|
|
2194
|
+
el('span', { cls: 'ctrl-prov-status-detail' }, p.detail ?? ''),
|
|
2195
|
+
// API-key providers show raw token counts; subscription providers with used%
|
|
2196
|
+
// get a percentage badge (not "tok")
|
|
2197
|
+
!isSubscriptionPct && typeof p.used === 'number'
|
|
2198
|
+
? el('span', { cls: 'ctrl-prov-status-used' }, fmtK(p.used) + ' tok')
|
|
2199
|
+
: null
|
|
2200
|
+
);
|
|
2201
|
+
|
|
2202
|
+
// Rate-limit bar for subscription providers with real used% data (Codex)
|
|
2203
|
+
if (isSubscriptionPct) {
|
|
2204
|
+
const pct = Math.min(100, Math.round(p.used));
|
|
2205
|
+
const barColor = pct >= 90 ? 'var(--status-failed)'
|
|
2206
|
+
: pct >= 70 ? 'var(--status-aborted)'
|
|
2207
|
+
: 'var(--accent)';
|
|
2208
|
+
const resetStr = p.resetAt
|
|
2209
|
+
? new Date(p.resetAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
2210
|
+
: null;
|
|
2211
|
+
const barWrap = el('div', { cls: 'ctrl-prov-rl-wrap' },
|
|
2212
|
+
el('div', { cls: 'ctrl-prov-rl-bar-track' },
|
|
2213
|
+
el('div', { cls: 'ctrl-prov-rl-bar', style: `width:${pct}%;background:${barColor}` })
|
|
2214
|
+
),
|
|
2215
|
+
el('span', { cls: 'ctrl-prov-rl-pct' }, `${pct}%`),
|
|
2216
|
+
resetStr ? el('span', { cls: 'ctrl-prov-rl-reset' }, `resets ${resetStr}`) : null
|
|
2217
|
+
);
|
|
2218
|
+
row.appendChild(barWrap);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
backendsBody.appendChild(row);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
if (backends.length === 0 && limits.length === 0) {
|
|
2226
|
+
backendsBody.appendChild(el('p', { cls: 'hint' }, 'No backends configured.'));
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (backends.length > 0) {
|
|
2230
|
+
const bList = el('div', { cls: 'ctrl-backend-list' });
|
|
2231
|
+
for (const b of backends) {
|
|
2232
|
+
bList.appendChild(el('div', { cls: 'ctrl-backend-row' },
|
|
2233
|
+
el('span', { cls: 'ctrl-backend-name' }, b.id ?? b.backend ?? '?'),
|
|
2234
|
+
el('span', { cls: 'ctrl-backend-dispatches' }, `${b.dispatchesRecent ?? 0} dispatch(es) / 24h`),
|
|
2235
|
+
quotaTag(b.quota ?? 'ok')
|
|
2236
|
+
));
|
|
2237
|
+
}
|
|
2238
|
+
backendsBody.appendChild(bList);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
if (limits.length > 0) {
|
|
2242
|
+
backendsBody.appendChild(el('div', { cls: 'ctrl-limits-heading' }, 'Rate windows'));
|
|
2243
|
+
for (const lim of limits) {
|
|
2244
|
+
const used = lim.used ?? 0;
|
|
2245
|
+
const max = lim.max ?? 1;
|
|
2246
|
+
const pct = Math.min(100, max > 0 ? Math.round((used / max) * 100) : 0);
|
|
2247
|
+
const standing = lim.standing ?? 'ok';
|
|
2248
|
+
const barColor = standing === 'over' ? 'var(--status-failed)'
|
|
2249
|
+
: standing === 'warn' ? 'var(--status-aborted)'
|
|
2250
|
+
: 'var(--accent)';
|
|
2251
|
+
backendsBody.appendChild(el('div', { cls: 'ctrl-limit-row' },
|
|
2252
|
+
el('span', { cls: 'ctrl-limit-backend' }, lim.backend ?? '?'),
|
|
2253
|
+
el('span', { cls: 'ctrl-limit-window' }, lim.window ?? ''),
|
|
2254
|
+
el('div', { cls: 'ctrl-limit-bar-wrap' },
|
|
2255
|
+
el('div', { cls: 'ctrl-limit-bar', style: `width:${pct}%;background:${barColor}` })
|
|
2256
|
+
),
|
|
2257
|
+
el('span', { cls: 'ctrl-limit-label' }, `${used}/${max === Infinity ? '∞' : max}`),
|
|
2258
|
+
el('span', { cls: `ctrl-standing ${standing}` }, standing)
|
|
2259
|
+
));
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
backendsCard.appendChild(backendsBody);
|
|
2264
|
+
section.appendChild(backendsCard);
|
|
2265
|
+
|
|
2266
|
+
// ── 4. Usage (7d) ─────────────────────────────────────────────────────
|
|
2267
|
+
const usage = d.usage ?? {};
|
|
2268
|
+
const byProvider = Array.isArray(usage.byProvider) ? usage.byProvider : [];
|
|
2269
|
+
|
|
2270
|
+
const usageCard = el('div', { cls: 'ctrl-card card' });
|
|
2271
|
+
usageCard.appendChild(el('div', { cls: 'card-header' },
|
|
2272
|
+
el('span', { cls: 'card-title' }, `Usage${usage.window ? ` (${usage.window})` : ''}`)
|
|
2273
|
+
));
|
|
2274
|
+
const usageBody = el('div', { cls: 'card-body' });
|
|
2275
|
+
|
|
2276
|
+
const usageTotals = el('div', { cls: 'ctrl-usage-totals' });
|
|
2277
|
+
usageTotals.appendChild(controlMetric('Total tokens', usage.totalTokens != null ? fmtK(usage.totalTokens) : '—', '#60a5fa'));
|
|
2278
|
+
usageTotals.appendChild(controlMetric('Total cost', usage.totalCostUsd != null ? `$${fmt(usage.totalCostUsd)}` : '—', '#fbbf24'));
|
|
2279
|
+
usageTotals.appendChild(controlMetric('Local savings', usage.localSavingsUsd != null ? `$${fmt(usage.localSavingsUsd)}` : '—', '#4ade80'));
|
|
2280
|
+
usageBody.appendChild(usageTotals);
|
|
2281
|
+
|
|
2282
|
+
if (byProvider.length > 0) {
|
|
2283
|
+
usageBody.appendChild(el('div', { cls: 'ctrl-prov-heading' }, 'By provider'));
|
|
2284
|
+
for (const p of byProvider) {
|
|
2285
|
+
const pct = Math.min(100, Math.round(p.sharePct ?? 0));
|
|
2286
|
+
const isLocal = (p.tier === 'local');
|
|
2287
|
+
usageBody.appendChild(el('div', { cls: 'ctrl-prov-row' },
|
|
2288
|
+
el('span', { cls: `ctrl-prov-label${isLocal ? ' local' : ''}` },
|
|
2289
|
+
`${p.provider ?? '?'}${p.tier ? ` (${p.tier})` : ''}`),
|
|
2290
|
+
el('div', { cls: 'ctrl-prov-bar-wrap' },
|
|
2291
|
+
el('div', { cls: `ctrl-prov-bar${isLocal ? ' local' : ''}`, style: `width:${pct}%` })
|
|
2292
|
+
),
|
|
2293
|
+
el('span', { cls: 'ctrl-prov-pct' }, `${pct}%`),
|
|
2294
|
+
el('span', { cls: 'ctrl-prov-cost' }, p.costUsd != null ? `$${fmt(p.costUsd)}` : ''),
|
|
2295
|
+
el('span', { cls: 'ctrl-prov-tokens' }, p.tokens != null ? fmtK(p.tokens) : '')
|
|
2296
|
+
));
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
usageCard.appendChild(usageBody);
|
|
2300
|
+
section.appendChild(usageCard);
|
|
2301
|
+
|
|
2302
|
+
// ── 5. Security (M67) ─────────────────────────────────────────────────
|
|
2303
|
+
const sec = d.security ?? {};
|
|
2304
|
+
const secFindings = Array.isArray(sec.findings) ? sec.findings : [];
|
|
2305
|
+
const secCounts = sec.counts ?? {};
|
|
2306
|
+
|
|
2307
|
+
const secCard = el('div', { cls: 'ctrl-card card' });
|
|
2308
|
+
secCard.appendChild(el('div', { cls: 'card-header' },
|
|
2309
|
+
el('span', { cls: 'card-title' }, 'Security'),
|
|
2310
|
+
el('span', { cls: 'card-subtitle' }, sec.available ? `${secFindings.length} finding(s)` : 'no data')
|
|
2311
|
+
));
|
|
2312
|
+
const secBody = el('div', { cls: 'card-body' });
|
|
2313
|
+
|
|
2314
|
+
if (!sec.available || secFindings.length === 0) {
|
|
2315
|
+
secBody.appendChild(el('p', { cls: 'hint' }, 'No security findings in cached backlog. Run a backlog scan to populate.'));
|
|
2316
|
+
} else {
|
|
2317
|
+
// Severity count badges
|
|
2318
|
+
const countsRow = el('div', { cls: 'ctrl-sec-counts' });
|
|
2319
|
+
if (secCounts.critical > 0) {
|
|
2320
|
+
countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--critical' }, `${secCounts.critical} critical`));
|
|
2321
|
+
}
|
|
2322
|
+
if (secCounts.high > 0) {
|
|
2323
|
+
countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--high' }, `${secCounts.high} high`));
|
|
2324
|
+
}
|
|
2325
|
+
if (secCounts.medium > 0) {
|
|
2326
|
+
countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--medium' }, `${secCounts.medium} medium`));
|
|
2327
|
+
}
|
|
2328
|
+
if (secCounts.low > 0) {
|
|
2329
|
+
countsRow.appendChild(el('span', { cls: 'ctrl-sec-badge ctrl-sec-badge--low' }, `${secCounts.low} low`));
|
|
2330
|
+
}
|
|
2331
|
+
secBody.appendChild(countsRow);
|
|
2332
|
+
|
|
2333
|
+
// Findings list (capped at 20 to keep the panel scannable)
|
|
2334
|
+
const list = el('div', { cls: 'ctrl-sec-list' });
|
|
2335
|
+
for (const f of secFindings.slice(0, 20)) {
|
|
2336
|
+
const sev = f.severity ?? 'low';
|
|
2337
|
+
list.appendChild(el('div', { cls: 'ctrl-sec-row' },
|
|
2338
|
+
el('span', { cls: `ctrl-sec-sev ctrl-sec-sev--${sev}` }, sev),
|
|
2339
|
+
el('span', { cls: 'ctrl-sec-repo' }, f.repo ?? '?'),
|
|
2340
|
+
el('span', { cls: 'ctrl-sec-title' }, f.title ?? '')
|
|
2341
|
+
));
|
|
2342
|
+
}
|
|
2343
|
+
if (secFindings.length > 20) {
|
|
2344
|
+
list.appendChild(el('p', { cls: 'hint' }, `+${secFindings.length - 20} more — run \`ashlr backlog\` to view all.`));
|
|
2345
|
+
}
|
|
2346
|
+
secBody.appendChild(list);
|
|
2347
|
+
}
|
|
2348
|
+
secCard.appendChild(secBody);
|
|
2349
|
+
section.appendChild(secCard);
|
|
2350
|
+
|
|
2351
|
+
// ── 6. Activity log ───────────────────────────────────────────────────
|
|
2352
|
+
const logs = Array.isArray(d.logs) ? [...d.logs].reverse() : [];
|
|
2353
|
+
|
|
2354
|
+
const logsCard = el('div', { cls: 'ctrl-card card' });
|
|
2355
|
+
logsCard.appendChild(el('div', { cls: 'card-header' },
|
|
2356
|
+
el('span', { cls: 'card-title' }, 'Activity Log'),
|
|
2357
|
+
el('span', { cls: 'card-subtitle' }, `${logs.length} recent entries`)
|
|
2358
|
+
));
|
|
2359
|
+
const logsBody = el('div', { cls: 'ctrl-log-body' });
|
|
2360
|
+
|
|
2361
|
+
if (logs.length === 0) {
|
|
2362
|
+
logsBody.appendChild(el('p', { cls: 'hint' }, 'No recent activity.'));
|
|
2363
|
+
} else {
|
|
2364
|
+
for (const entry of logs.slice(0, 50)) {
|
|
2365
|
+
const kind = entry.kind ?? 'info';
|
|
2366
|
+
logsBody.appendChild(el('div', { cls: `ctrl-log-row ctrl-log-row--${kind}` },
|
|
2367
|
+
el('span', { cls: 'ctrl-log-ts' }, entry.ts ? fmtRelative(entry.ts) : '—'),
|
|
2368
|
+
el('span', { cls: `ctrl-log-kind ctrl-log-kind--${kind}` }, kind),
|
|
2369
|
+
el('span', { cls: 'ctrl-log-msg' }, entry.msg ?? '')
|
|
2370
|
+
));
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
logsCard.appendChild(logsBody);
|
|
2374
|
+
section.appendChild(logsCard);
|
|
2375
|
+
|
|
2376
|
+
main.appendChild(section);
|
|
2377
|
+
if (_scrollY > 0) window.scrollTo(0, _scrollY);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// Small metric block used in the Mission Control hero + usage panel
|
|
2381
|
+
function controlMetric(label, value, accent) {
|
|
2382
|
+
return el('div', { cls: 'ctrl-metric' },
|
|
2383
|
+
el('div', { cls: 'ctrl-metric-value', style: `color:${accent}` }, String(value)),
|
|
2384
|
+
el('div', { cls: 'ctrl-metric-label' }, label)
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
1882
2388
|
// ---------------------------------------------------------------------------
|
|
1883
2389
|
// Boot
|
|
1884
2390
|
// ---------------------------------------------------------------------------
|