Updates
CI / test (push) Successful in 1m2s

This commit is contained in:
Raven Scott
2026-07-18 20:48:35 -04:00
parent a7648dc090
commit d3edb56062
9 changed files with 1177 additions and 222 deletions
+105 -100
View File
@@ -24,6 +24,11 @@ import {
} from './client/settings.js'
import { drawMultiChart, CHART_PALETTE, pushRing } from './ui/charts.js'
import { createMetricsDashboard } from './ui/dashboard.js'
import {
buildFleetRoster,
summarizeFleet,
renderFleetCards,
} from './ui/fleet.js'
const $ = (id) => document.getElementById(id)
@@ -78,6 +83,15 @@ const els = {
metricsPresets: $('metrics-presets'),
metricsMeta: $('metrics-meta'),
metricsHover: $('metrics-hover'),
metricsLive: $('metrics-live'),
metricsLiveLabel: $('metrics-live-label'),
metricsRetentionHint: $('metrics-retention-hint'),
fleetCards: $('fleet-cards'),
fleetRefreshBtn: $('fleet-refresh-btn'),
fleetStatLive: $('fleet-stat-live'),
fleetStatRetry: $('fleet-stat-retry'),
fleetStatOffline: $('fleet-stat-offline'),
fleetStatFailed: $('fleet-stat-failed'),
collapseSidebarBtn: $('collapse-sidebar-btn'),
settingReduceMotion: $('setting-reduce-motion'),
settingChartPoints: $('setting-chart-points'),
@@ -117,6 +131,9 @@ const metricsDashboard = createMetricsDashboard({
forcePlayBtn: $('metrics-force-play'),
boardBtn: $('metrics-board'),
relatedPanel: $('metrics-related'),
liveEl: $('metrics-live'),
liveLabel: $('metrics-live-label'),
retentionHint: $('metrics-retention-hint'),
},
getCatalog: () => chartCatalog,
queryData: (args) => manager.request(Methods.queryData, args),
@@ -439,109 +456,89 @@ function updateActivePeerChip() {
}
function renderBookmarks() {
const list = loadBookmarks()
const live = new Set(manager.list().map((c) => c.publicKeyHex))
const activeId = manager.active?.publicKeyHex
els.bookmarkList.innerHTML = ''
if (!list.length) {
const li = document.createElement('li')
li.className = 'muted'
li.textContent = 'No saved agents'
els.bookmarkList.appendChild(li)
return
}
for (const b of list) {
const li = document.createElement('li')
const label = b.alias || `${b.publicKeyHex.slice(0, 12)}`
const online = live.has(b.publicKeyHex)
const isActive = activeId === b.publicKeyHex
if (isActive) li.classList.add('active')
li.innerHTML = `<span title="${escapeHtml(b.publicKeyHex)}">${escapeHtml(label)}
<small class="muted">${online ? (isActive ? 'active' : 'online') : 'saved'}</small></span>
<span class="bookmark-actions">
<button type="button" class="linkish" data-act="connect" title="${online ? 'Set active' : 'Connect'}">${online ? '●' : '↗'}</button>
<button type="button" class="linkish" data-act="forget" title="Forget">×</button>
</span>`
li.querySelector('[data-act="connect"]').addEventListener('click', async (e) => {
e.stopPropagation()
try {
if (online) {
await activatePeer(b.publicKeyHex)
log(`Active → ${label}`)
} else {
log(`Dialing ${label}`)
await dialPeer(b.invite || b.publicKeyHex, {
alias: b.alias,
invite: b.invite,
capability: b.capability,
adminSeed: b.adminSeed,
})
log(`Connected ${label}`)
showView('overview')
}
} catch (err) {
log(`Connect failed: ${err.message}`)
}
})
li.querySelector('[data-act="forget"]').addEventListener('click', (e) => {
e.stopPropagation()
removeBookmark(b.publicKeyHex)
manager.disconnect(b.publicKeyHex).catch(() => {})
renderBookmarks()
renderPeers()
log(`Forgot ${label}`)
})
li.addEventListener('click', async () => {
try {
if (online) await activatePeer(b.publicKeyHex)
else {
els.connectInput.value = b.invite || b.publicKeyHex
if (b.alias) els.peerAlias.value = b.alias
showView('connect')
}
} catch (err) {
log(`Switch failed: ${err.message}`)
}
})
els.bookmarkList.appendChild(li)
}
loadFleetView()
}
function renderPeers() {
const list = manager.list() || []
els.peerList.innerHTML = ''
if (!list.length) {
const li = document.createElement('li')
li.className = 'muted'
li.textContent = 'No agents connected'
els.peerList.appendChild(li)
updateActivePeerChip()
return
}
const bookmarks = loadBookmarks()
for (const p of list) {
const id = p.publicKeyHex || p.id
const bm = bookmarks.find((b) => b.publicKeyHex === id)
const active = manager.active?.publicKeyHex === id
const li = document.createElement('li')
li.className = active ? 'active' : ''
const label = bm?.alias || `${String(id).slice(0, 12)}`
li.innerHTML = `<span>${escapeHtml(label)}</span><span class="muted">${p.connected ? (active ? 'active' : 'live') : '…'}</span>`
li.addEventListener('click', () => {
activatePeer(id).catch((err) => log(`Switch failed: ${err.message}`))
})
li.addEventListener('dblclick', () => {
const alias = prompt('Alias for this agent', bm?.alias || '')
if (alias != null) {
setBookmarkAlias(id, alias)
renderBookmarks()
renderPeers()
}
})
els.peerList.appendChild(li)
}
updateActivePeerChip()
renderBookmarks()
loadFleetView()
renderFleetStrip(null, manager.list())
}
function loadFleetView() {
if (!els.fleetCards) return
const roster = buildFleetRoster({
saved: loadBookmarks(),
live: manager.list(),
activeId: manager.active?.publicKeyHex || null,
getReconnectInfo: (id) => manager.getReconnectInfo(id),
})
const summary = summarizeFleet(roster)
if (els.fleetStatLive) els.fleetStatLive.textContent = String(summary.live)
if (els.fleetStatRetry) els.fleetStatRetry.textContent = String(summary.reconnecting)
if (els.fleetStatOffline) els.fleetStatOffline.textContent = String(summary.offline)
if (els.fleetStatFailed) els.fleetStatFailed.textContent = String(summary.failed)
renderFleetCards(els.fleetCards, roster, {
onActivate: async (peer) => {
try {
await activatePeer(peer.publicKeyHex)
log(`Active → ${peer.alias || peer.id.slice(0, 12)}`)
loadFleetView()
} catch (err) {
log(`Switch failed: ${err.message}`)
}
},
onReconnect: async (peer) => {
try {
log(`Dialing ${peer.alias || peer.id.slice(0, 12)}`)
await dialPeer(peer.invite || peer.publicKeyHex, {
alias: peer.alias,
invite: peer.invite,
capability: peer.capability,
adminSeed: peer.adminSeed,
})
log(`Connected ${peer.alias || peer.id.slice(0, 12)}`)
loadFleetView()
} catch (err) {
log(`Connect failed: ${err.message}`)
loadFleetView()
}
},
onForget: async (peer) => {
const label = peer.alias || peer.id.slice(0, 12)
if (!confirm(`Forget ${label}?`)) return
removeBookmark(peer.publicKeyHex)
await manager.disconnect(peer.publicKeyHex).catch(() => {})
log(`Forgot ${label}`)
loadFleetView()
renderFleetStrip(null, manager.list())
},
onOpenCharts: async (peer) => {
try {
if (peer.connected) await activatePeer(peer.publicKeyHex)
else {
await dialPeer(peer.invite || peer.publicKeyHex, {
alias: peer.alias,
invite: peer.invite,
capability: peer.capability,
adminSeed: peer.adminSeed,
})
}
showView('charts')
} catch (err) {
log(`Open charts failed: ${err.message}`)
}
},
onAlias: (peer) => {
const alias = prompt('Alias for this agent', peer.alias || '')
if (alias != null) {
setBookmarkAlias(peer.publicKeyHex, alias)
loadFleetView()
updateActivePeerChip()
}
},
})
}
function exploreValue(chart, values) {
@@ -812,7 +809,7 @@ function renderFleetStrip(fleet, desktopPeers) {
}
function renderChartCatalog(_filter = '') {
metricsDashboard.render()
metricsDashboard.setCatalog(chartCatalog)
}
async function selectCatalogChart(id) {
@@ -1021,6 +1018,7 @@ function showView(name) {
metricsDashboard.render()
requestAnimationFrame(() => metricsDashboard.redrawVisible())
}
if (name === 'fleet') loadFleetView()
if (name === 'settings') syncSettingsUi()
requestAnimationFrame(() => redrawAll())
}
@@ -1272,11 +1270,18 @@ manager.on('disconnected', () => {
manager.on('reconnect-failed', ({ tries }) => {
setOnline(false, { reconnecting: true })
log(`Reconnect attempt failed (try ${tries})`)
loadFleetView()
})
manager.on('reconnect-exhausted', ({ publicKeyHex }) => {
setOnline(false, { reconnecting: false })
log(`Reconnect exhausted for ${String(publicKeyHex).slice(0, 12)}`)
loadFleetView()
})
els.fleetRefreshBtn?.addEventListener('click', () => {
loadFleetView()
renderFleetStrip(null, manager.list())
})
async function restorePeersOnBoot() {
+17
View File
@@ -148,6 +148,23 @@ export class ConnectionManager extends EventEmitter {
return [...this.connections.values()]
}
/**
* Reconnect budget / state for Fleet cards (PearDock-style).
* @param {string} publicKeyHex
*/
getReconnectInfo(publicKeyHex) {
const id = String(publicKeyHex || '').toLowerCase()
const attempts = this._tries.get(id) || 0
const maxAttempts = this.maxReconnectTries
const reconnecting = this._reconnectTimers.has(id)
const failed =
!reconnecting &&
maxAttempts > 0 &&
attempts >= maxAttempts &&
!this.connections.get(id)?.connected
return { attempts, maxAttempts, reconnecting, failed }
}
/**
* @param {string} [publicKeyHex]
* @param {{ forgetReconnect?: boolean }} [opts]
+4
View File
@@ -78,6 +78,10 @@ Do not name third-party products in code, commits, or user-facing copy.
- [x] Persist pinned charts
- [x] Board mode (pinned-only wall reusing the same cards)
- [x] Wallboard / force-play mode
- [x] Stale-while-revalidate paint (no refetch flicker)
- [x] Retention-aware time presets (disable windows with no history yet)
- [x] Live / Paused / Force indicator on Charts toolbar
- [x] Dedicated Fleet tab (multi-host cards, set active / reconnect)
### P5 — Authoring model
+53 -45
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<script>
@@ -52,7 +52,7 @@
</button>
<div class="nav-group-label">Fleet</div>
<button type="button" class="nav-link" data-view="fleet">
<span class="nav-ico"></span><span class="nav-label">Agents</span>
<span class="nav-ico"></span><span class="nav-label">Fleet</span>
</button>
<button type="button" class="nav-link" data-view="connect">
<span class="nav-ico"></span><span class="nav-label">Connect</span>
@@ -179,20 +179,28 @@
<div>
<p class="dash-kicker">Metrics</p>
<h1 class="dash-title">Charts</h1>
<p class="page-subtitle">Every collected context, sectioned and live · <kbd>Space</kbd> play · <kbd>/</kbd> search · <kbd>15</kbd> window</p>
<p class="page-subtitle">Sectioned wall · shared time · <kbd>Space</kbd> · <kbd>/</kbd> · <kbd>15</kbd></p>
</div>
<div class="metrics-timebar" id="metrics-timebar">
<button type="button" id="metrics-play" class="ghost" aria-pressed="true">Pause</button>
<button type="button" id="metrics-force-play" class="ghost" aria-pressed="false" title="Keep live updating (wallboard)">Force</button>
<button type="button" id="metrics-board" class="ghost" aria-pressed="false" title="Show only pinned charts">Board</button>
<button type="button" id="metrics-reset" class="ghost" title="Reset to live 5m">Reset</button>
<div id="metrics-presets" class="metrics-presets" role="group" aria-label="Time window">
<button type="button" class="ghost" data-preset="1m">1m</button>
<button type="button" class="ghost active" data-preset="5m">5m</button>
<button type="button" class="ghost" data-preset="15m">15m</button>
<button type="button" class="ghost" data-preset="1h">1h</button>
<button type="button" class="ghost" data-preset="6h">6h</button>
</div>
</header>
<div class="metrics-toolbar" id="metrics-timebar">
<div class="metrics-toolbar-left">
<span id="metrics-live" class="metrics-live is-live" aria-live="polite">
<i class="metrics-live-dot" aria-hidden="true"></i>
<span id="metrics-live-label">Live</span>
</span>
<button type="button" id="metrics-play" class="btn btn-ghost" aria-pressed="true">Pause</button>
<button type="button" id="metrics-force-play" class="btn btn-ghost" aria-pressed="false" title="Keep live (wallboard)">Force</button>
<button type="button" id="metrics-board" class="btn btn-ghost" aria-pressed="false" title="Pinned board only">Board</button>
<button type="button" id="metrics-reset" class="btn btn-ghost" title="Reset to live 5m">Reset</button>
</div>
<div id="metrics-presets" class="metrics-presets" role="group" aria-label="Time window">
<button type="button" class="metrics-tf" data-preset="1m" title="Last 1 minute">1m</button>
<button type="button" class="metrics-tf active" data-preset="5m" title="Last 5 minutes">5m</button>
<button type="button" class="metrics-tf" data-preset="15m" title="Last 15 minutes">15m</button>
<button type="button" class="metrics-tf" data-preset="1h" title="Last hour">1h</button>
<button type="button" class="metrics-tf" data-preset="6h" title="Last 6 hours">6h</button>
</div>
<div class="metrics-toolbar-right">
<label class="metrics-group-label muted">
Group
<select id="metrics-group" aria-label="Downsample aggregation">
@@ -202,23 +210,22 @@
<option value="sum">sum</option>
</select>
</label>
<button type="button" id="metrics-dim-sort" class="ghost">Sort: name</button>
<button type="button" id="metrics-dim-sort" class="btn btn-ghost">Sort: name</button>
<span id="metrics-meta" class="muted metrics-meta"></span>
<span id="metrics-hover" class="muted metrics-hover"></span>
</div>
</header>
<div id="metrics-related" class="dash-card related-panel hidden"></div>
</div>
<p id="metrics-retention-hint" class="metrics-retention-hint muted hidden" role="status"></p>
<div id="metrics-related" class="related-panel hidden"></div>
<div class="metrics-shell">
<aside class="metrics-toc dash-card">
<input id="chart-search" type="search" placeholder="Filter by id, title, family…" autocomplete="off" />
<aside class="metrics-toc">
<input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" />
<nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav>
<!-- legacy list kept hidden for compatibility -->
<ul id="chart-catalog-list" class="catalog-list hidden" hidden></ul>
</aside>
<div id="metrics-wall" class="metrics-wall" tabindex="0"></div>
</div>
<!-- Detail strip for deep-dive (dblclick a card) -->
<article id="chart-detail-panel" class="dash-card chart-detail metrics-detail hidden">
<article id="chart-detail-panel" class="chart-detail metrics-detail hidden">
<header>
<h3 id="chart-detail-title">Select a chart</h3>
<span id="chart-detail-meta" class="muted"></span>
@@ -246,33 +253,34 @@
</div>
</section>
<!-- Fleet / agents -->
<!-- Fleet -->
<section id="fleet-view" class="view hidden">
<header class="page-header">
<div>
<p class="dash-kicker">Peers</p>
<h1 class="dash-title">Agents</h1>
<p class="page-subtitle">Saved bookmarks and live connections</p>
<p class="dash-kicker">Multi-host</p>
<h1 class="dash-title">Fleet</h1>
<p class="page-subtitle">Concurrent agents — set active, reconnect, open charts</p>
</div>
<div class="page-actions">
<label class="check-row">
<input type="checkbox" id="compare-toggle" />
Compare CPU overlay
</label>
<button type="button" id="fleet-refresh-btn" class="btn btn-ghost">Refresh</button>
<button type="button" id="btn-invite" class="btn btn-ghost" disabled>Mint invite</button>
</div>
<label class="check-row">
<input type="checkbox" id="compare-toggle" />
Compare CPU overlay
</label>
</header>
<div class="fleet-layout">
<div class="dash-card">
<h3 class="rail-sub">Saved</h3>
<ul id="bookmark-list" class="peer-list bookmark-list"></ul>
</div>
<div class="dash-card">
<h3 class="rail-sub">Connected</h3>
<ul id="peer-list" class="peer-list"></ul>
<div class="admin-block">
<button id="btn-invite" class="ghost" disabled>Mint invite</button>
<pre id="invite-out" class="invite-out hidden"></pre>
</div>
</div>
</div>
<section id="fleet-summary-bar" class="fleet-summary-bar" aria-live="polite">
<div class="fleet-stat" data-fleet-stat="live"><span class="fleet-stat-val" id="fleet-stat-live">0</span><span class="muted">Live</span></div>
<div class="fleet-stat" data-fleet-stat="retry"><span class="fleet-stat-val" id="fleet-stat-retry">0</span><span class="muted">Retrying</span></div>
<div class="fleet-stat" data-fleet-stat="offline"><span class="fleet-stat-val" id="fleet-stat-offline">0</span><span class="muted">Offline</span></div>
<div class="fleet-stat" data-fleet-stat="failed"><span class="fleet-stat-val" id="fleet-stat-failed">0</span><span class="muted">Failed</span></div>
</section>
<div id="fleet-cards" class="fleet-cards"></div>
<pre id="invite-out" class="invite-out hidden"></pre>
<!-- Compatibility hooks for legacy list renderers -->
<ul id="bookmark-list" class="hidden" hidden></ul>
<ul id="peer-list" class="hidden" hidden></ul>
</section>
<!-- Connect -->
+45
View File
@@ -0,0 +1,45 @@
import test from 'brittle'
import { buildFleetRoster, summarizeFleet } from '../ui/fleet.js'
const hex = (n) => n.repeat(64)
test('buildFleetRoster merges saved + live and sorts active first', (t) => {
const a = hex('a')
const b = hex('b')
const c = hex('c')
const roster = buildFleetRoster({
saved: [
{ publicKeyHex: a, alias: 'alpha' },
{ publicKeyHex: b, alias: 'bravo' },
{ publicKeyHex: c, alias: 'charlie' },
],
live: [
{ publicKeyHex: a, connected: true },
{ publicKeyHex: b, connected: false },
],
activeId: a,
getReconnectInfo: (id) =>
id === b
? { attempts: 3, maxAttempts: 20, reconnecting: true, failed: false }
: { attempts: 0, maxAttempts: 20, reconnecting: false, failed: false },
})
t.is(roster[0].id, a)
t.ok(roster[0].active && roster[0].connected)
t.ok(roster.find((p) => p.id === b)?.reconnecting)
t.is(roster[roster.length - 1].id, c)
})
test('summarizeFleet counts states', (t) => {
const s = summarizeFleet([
{ connected: true, reconnecting: false, failed: false },
{ connected: true, reconnecting: false, failed: false },
{ connected: false, reconnecting: true, failed: false },
{ connected: false, reconnecting: false, failed: true },
{ connected: false, reconnecting: false, failed: false },
])
t.is(s.live, 2)
t.is(s.reconnecting, 1)
t.is(s.failed, 1)
t.is(s.offline, 1)
t.is(s.total, 5)
})
+12 -3
View File
@@ -15,6 +15,8 @@
* padLeft?: number,
* showYAxis?: boolean,
* units?: string,
* emptyMessage?: string,
* dimmed?: boolean,
* }} [opts]
*/
export function drawMultiChart(canvas, lines, opts = {}) {
@@ -54,12 +56,18 @@ export function drawMultiChart(canvas, lines, opts = {}) {
.map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) }))
.filter((l) => l.values.length >= 2)
if (!prepared.length) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.45)'
ctx.font = '12px ui-sans-serif, system-ui, sans-serif'
ctx.fillText('No data', padL + 8, padT + 20)
ctx.fillStyle = 'rgba(154, 168, 188, 0.55)'
ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif'
const msg = opts.emptyMessage || 'No data'
const linesMsg = String(msg).split('\n')
linesMsg.forEach((line, i) => {
ctx.fillText(line, padL + 8, padT + 20 + i * 16)
})
return
}
if (opts.dimmed) ctx.globalAlpha = 0.72
let max = 1
let min = 0
if (opts.stacked) {
@@ -177,6 +185,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
ctx.fill()
}
}
ctx.globalAlpha = 1
}
/**
+260 -51
View File
@@ -9,7 +9,8 @@ const GROUPS = ['average', 'min', 'max', 'sum']
const MIN_WINDOW = 30
const MAX_WINDOW = 21600
const DEFAULT_HEIGHT = 160
const DEFAULT_HEIGHT = 148
const HERO_HEIGHT = 200
/**
* @typedef {{
@@ -27,6 +28,9 @@ const DEFAULT_HEIGHT = 160
* forcePlayBtn?: HTMLButtonElement|null,
* relatedPanel?: HTMLElement|null,
* boardBtn?: HTMLButtonElement|null,
* liveEl?: HTMLElement|null,
* liveLabel?: HTMLElement|null,
* retentionHint?: HTMLElement|null,
* }} DashboardEls
*/
@@ -75,7 +79,7 @@ export function createMetricsDashboard(opts) {
relatedIds: new Set(),
relatedSeed: '',
boardOnly: false,
/** @type {Map<string, { dims: Map<string, number[]>, labels: string[], status: string, meta: object, mode: string, source?: string }>} */
/** @type {Map<string, { dims: Map<string, number[]>, labels: string[], status: string, meta: object, mode: string, source?: string, updating?: boolean, emptyReason?: string }>} */
cards: new Map(),
/** @type {Map<string, HTMLElement>} */
cardEls: new Map(),
@@ -84,6 +88,10 @@ export function createMetricsDashboard(opts) {
built: false,
refetchTimer: /** @type {ReturnType<typeof setTimeout>|null} */ (null),
pan: /** @type {{ active: boolean, startX: number, startOffset: number }|null} */ (null),
/** @type {Map<string, number>} in-flight generation per chart */
fetchGen: new Map(),
/** Seconds of history available across catalog (0 = unknown). */
retentionSeconds: 0,
}
function points() {
@@ -131,11 +139,28 @@ export function createMetricsDashboard(opts) {
opts.els.playBtn.setAttribute('aria-pressed', state.playing ? 'true' : 'false')
opts.els.playBtn.disabled = state.forcePlay
}
syncLiveUi()
syncPresetUi()
updateMeta()
if (state.playing) scheduleRefetchVisible()
}
function syncLiveUi() {
const live = opts.els.liveEl
const label = opts.els.liveLabel
if (!live) return
live.classList.toggle('is-live', state.playing && state.endOffset === 0)
live.classList.toggle('is-paused', !state.playing || state.endOffset > 0)
live.classList.toggle('is-force', state.forcePlay)
if (label) {
label.textContent = state.forcePlay
? 'Force live'
: state.playing && state.endOffset === 0
? 'Live'
: 'Paused'
}
}
function setForcePlay(on) {
state.forcePlay = Boolean(on)
persistPrefs({ forcePlay: state.forcePlay })
@@ -146,6 +171,7 @@ export function createMetricsDashboard(opts) {
opts.els.root?.classList.toggle('force-play', state.forcePlay)
if (state.forcePlay) setPlaying(true)
else if (opts.els.playBtn) opts.els.playBtn.disabled = false
syncLiveUi()
}
function setGroup(group) {
@@ -158,6 +184,7 @@ export function createMetricsDashboard(opts) {
function setPreset(id) {
const p = TIME_PRESETS.find((x) => x.id === id) || TIME_PRESETS[1]
if (!presetAvailable(p.seconds)) return
state.presetId = p.id
state.afterSeconds = p.seconds
state.endOffset = 0
@@ -166,11 +193,52 @@ export function createMetricsDashboard(opts) {
opts.els.playBtn.textContent = 'Pause'
opts.els.playBtn.setAttribute('aria-pressed', 'true')
}
syncLiveUi()
syncPresetUi()
scheduleRefetchVisible()
updateMeta()
}
/**
* Longest window we can honestly show from catalog retention.
* Prefers max(last_entry - first_entry); falls back to "unknown" (all enabled).
*/
function computeRetentionSeconds() {
const catalog = opts.getCatalog() || {}
let best = 0
let any = false
for (const meta of Object.values(catalog)) {
const first = Number(meta?.first_entry ?? meta?.firstEntry ?? 0)
const last = Number(meta?.last_entry ?? meta?.lastEntry ?? 0)
if (first > 0 && last >= first) {
any = true
best = Math.max(best, last - first)
} else if (last > 0) {
// Unix seconds → age from now
const age = Math.max(0, Math.floor(Date.now() / 1000) - last)
if (first > 0) {
any = true
best = Math.max(best, last - first)
} else if (age > 0) {
any = true
best = Math.max(best, age + 60)
}
}
}
state.retentionSeconds = any ? best : 0
return state.retentionSeconds
}
function presetAvailable(seconds) {
const catalog = opts.getCatalog() || {}
if (!Object.keys(catalog).length) return false
const retention = state.retentionSeconds || computeRetentionSeconds()
// Unknown retention → allow short windows; gate 1h/6h until we know depth
if (!retention) return seconds <= 900
// Need a little headroom beyond the window
return retention + 30 >= seconds
}
function setWindow(seconds, endOffset = state.endOffset) {
state.afterSeconds = clamp(seconds, MIN_WINDOW, MAX_WINDOW)
state.endOffset = Math.max(0, endOffset)
@@ -191,9 +259,49 @@ export function createMetricsDashboard(opts) {
function syncPresetUi() {
if (!opts.els.presets) return
computeRetentionSeconds()
const catalogEmpty = !Object.keys(opts.getCatalog() || {}).length
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-preset') === state.presetId)
const id = btn.getAttribute('data-preset') || ''
const p = TIME_PRESETS.find((x) => x.id === id)
const seconds = p?.seconds || 0
const ok = !catalogEmpty && presetAvailable(seconds)
btn.classList.toggle('active', id === state.presetId)
btn.disabled = !ok
btn.title = !ok
? catalogEmpty
? 'Connect an agent to load history'
: state.retentionSeconds
? `Need ~${formatDuration(seconds)} of history (have ~${formatDuration(state.retentionSeconds)})`
: 'Not enough history yet for this window'
: `Last ${id}`
})
const hint = opts.els.retentionHint
if (hint) {
if (catalogEmpty) {
hint.textContent = 'Connect an agent to enable time windows.'
hint.classList.remove('hidden')
} else if (state.retentionSeconds > 0 && state.retentionSeconds < 3600) {
hint.textContent = `History depth ~${formatDuration(state.retentionSeconds)} — longer windows stay disabled until more samples accumulate.`
hint.classList.remove('hidden')
} else {
hint.textContent = ''
hint.classList.add('hidden')
}
}
// If current preset became unavailable, fall back to longest available
if (!catalogEmpty && !presetAvailable(state.afterSeconds)) {
const fallback =
[...TIME_PRESETS].reverse().find((p) => presetAvailable(p.seconds)) || TIME_PRESETS[0]
if (fallback && (fallback.id !== state.presetId || fallback.seconds !== state.afterSeconds)) {
state.presetId = fallback.id
state.afterSeconds = fallback.seconds
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-preset') === state.presetId)
})
scheduleRefetchVisible()
}
}
}
function updateMeta() {
@@ -230,10 +338,23 @@ export function createMetricsDashboard(opts) {
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || state.cards.get(id)?.meta || {}
const card = ensureCard(id, meta)
card.status = 'loading'
paintCard(id)
const hadData = card.status === 'ok' && card.dims.size > 0
const gen = (state.fetchGen.get(id) || 0) + 1
state.fetchGen.set(id, gen)
// Stale-while-revalidate: keep previous series painted; never blank the canvas.
if (hadData) {
card.updating = true
paintCard(id)
} else {
card.status = 'loading'
card.emptyReason = 'Waiting for first sample…'
paintCard(id)
}
try {
const q = await opts.queryData(queryArgs(id))
if (state.fetchGen.get(id) !== gen) return
const labels = Array.isArray(q.labels)
? q.labels.filter((l) => l && l !== 'time')
: []
@@ -244,23 +365,51 @@ export function createMetricsDashboard(opts) {
const n = q.data[0].length - 1
dimNames = Array.from({ length: Math.max(0, n) }, (_, i) => `d${i}`)
}
card.labels = dimNames
card.dims = new Map()
card.source = q.source || 'memory'
const nextDims = new Map()
const max = maxPoints()
for (const name of dimNames) card.dims.set(name, [])
for (const name of dimNames) nextDims.set(name, [])
for (const row of q.data || []) {
for (let i = 0; i < dimNames.length; i++) {
pushDim(card.dims, dimNames[i], Number(row[i + 1]) || 0, max)
pushDim(nextDims, dimNames[i], Number(row[i + 1]) || 0, max)
}
}
card.status = card.dims.size ? 'ok' : 'empty'
const pointCount = [...nextDims.values()].reduce((n, arr) => Math.max(n, arr.length), 0)
// Commit result in place — previous paint stays until this swap (no blank frame).
card.labels = dimNames
card.dims = nextDims
card.source = q.source || 'memory'
if (pointCount >= 2) {
card.status = 'ok'
card.emptyReason = ''
} else {
card.status = 'empty'
card.emptyReason = retentionEmptyReason(meta)
}
} catch (err) {
card.status = 'error'
if (state.fetchGen.get(id) !== gen) return
if (!hadData) {
card.status = 'error'
card.emptyReason = err?.message || 'query failed'
}
card.meta = { ...card.meta, error: err?.message || 'query failed' }
} finally {
if (state.fetchGen.get(id) === gen) {
card.updating = false
paintCard(id)
refreshSectionKpis()
syncPresetUi()
}
}
paintCard(id)
refreshSectionKpis()
}
function retentionEmptyReason(meta) {
const first = Number(meta?.first_entry ?? meta?.firstEntry ?? 0)
const last = Number(meta?.last_entry ?? meta?.lastEntry ?? 0)
if (first > 0 && state.afterSeconds > last - first + 60) {
return `No samples this far back — try a shorter window`
}
if (!first && !last) return 'Waiting for first sample…'
return 'No data for this window'
}
function inferDimNames(meta, n) {
@@ -324,21 +473,19 @@ export function createMetricsDashboard(opts) {
article.classList.toggle('related', state.relatedIds.has(id))
const pinBtn = article.querySelector('.metric-pin-btn')
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
article.classList.toggle('updating', Boolean(card?.updating))
if (statusEl) {
const src = card?.source && card.source !== 'memory' ? ` · ${card.source}` : ''
statusEl.textContent =
card?.status === 'loading'
? 'loading'
: card?.status === 'error'
? 'error'
: card?.status === 'empty'
? 'empty'
: card?.status === 'ok'
? src.replace(/^ · /, '')
: ''
if (card?.updating) statusEl.textContent = 'updating'
else if (card?.status === 'loading') statusEl.textContent = 'loading'
else if (card?.status === 'error') statusEl.textContent = 'error'
else if (card?.status === 'empty') statusEl.textContent = 'empty'
else if (card?.status === 'ok' && card.source && card.source !== 'memory') {
statusEl.textContent = card.source
} else statusEl.textContent = ''
}
if (!card || !canvas) return
canvas.style.height = `${state.cardHeight}px`
const height = cardHeightFor(id, card)
canvas.style.height = `${height}px`
const { dims, hidden } = sortedDims(card, id)
const lines = []
let i = 0
@@ -357,6 +504,14 @@ export function createMetricsDashboard(opts) {
const anomaly = opts.getAnomaly?.(id) || null
if (anomaly?.severity) article.dataset.severity = anomaly.severity
else delete article.dataset.severity
const emptyMsg =
card.status === 'loading'
? card.emptyReason || 'Loading…'
: card.status === 'error'
? card.emptyReason || 'Error'
: card.status === 'empty'
? card.emptyReason || 'No data'
: 'No data'
drawMultiChart(canvas, lines, {
maxPoints: maxPoints(),
stacked: card.mode === 'stacked',
@@ -364,30 +519,48 @@ export function createMetricsDashboard(opts) {
hoverIndex: state.hoverIndex,
threshold: anomaly?.threshold ?? null,
severity: anomaly?.severity || null,
emptyMessage: emptyMsg,
dimmed: Boolean(card.updating),
})
const statsEl = article.querySelector('.metric-card-stats')
if (statsEl && article.classList.contains('expanded')) {
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
}
if (legend) {
legend.innerHTML = ''
lines.forEach((line) => {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'dim-chip' + (line.hidden ? ' off' : '')
btn.style.setProperty('--dim-color', line.color)
const last = line.values[line.values.length - 1]
btn.textContent = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
btn.title = 'Toggle dimension'
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
toggleDim(id, line.label)
if (legend && !card.updating) {
const nextHtml = lines
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
})
legend.appendChild(btn)
})
.join('')
if (legend.dataset.sig !== nextHtml) {
legend.dataset.sig = nextHtml
legend.innerHTML = nextHtml
legend.querySelectorAll('[data-dim]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
toggleDim(id, btn.getAttribute('data-dim') || '')
})
})
}
}
}
function cardHeightFor(id, card) {
const base = state.cardHeight || DEFAULT_HEIGHT
if (state.pinned.has(id)) return Math.max(base, HERO_HEIGHT)
const dims = card?.labels?.length || card?.dims?.size || 0
if (dims >= 8) return Math.max(base, 180)
if (isHeroChart(id, card?.meta)) return Math.max(base, 172)
return base
}
function isHeroChart(id, meta) {
const priority = Number(meta?.priority ?? 9999)
return priority > 0 && priority <= 200
}
function toggleDim(chartId, dim) {
let set = state.hiddenDims.get(chartId)
if (!set) {
@@ -416,7 +589,11 @@ export function createMetricsDashboard(opts) {
if (state.pinned.has(id)) state.pinned.delete(id)
else state.pinned.add(id)
persistPrefs({ pinned: [...state.pinned] })
// Soft refresh: keep scroll position / avoid wall wipe flicker
const wall = opts.els.wall
const top = wall?.scrollTop || 0
render()
if (wall) wall.scrollTop = top
}
async function showRelated(seedId) {
@@ -719,7 +896,9 @@ export function createMetricsDashboard(opts) {
if (state.collapsed.has(sec.id)) state.collapsed.delete(sec.id)
else state.collapsed.add(sec.id)
persistPrefs({ collapsed: [...state.collapsed] })
render()
const collapsed = state.collapsed.has(sec.id)
sectionEl.classList.toggle('collapsed', collapsed)
toggle.textContent = collapsed ? '▸' : '▾'
})
const title = document.createElement('h2')
title.textContent = sec.title
@@ -800,30 +979,35 @@ export function createMetricsDashboard(opts) {
function buildCardEl(id, meta) {
const card = ensureCard(id, meta)
const height = cardHeightFor(id, card)
const hero = isHeroChart(id, meta)
const article = document.createElement('article')
article.className = 'metric-card' + (state.pinned.has(id) ? ' pinned' : '')
article.className =
'metric-card' +
(state.pinned.has(id) ? ' pinned' : '') +
(hero ? ' metric-card--hero' : '')
article.dataset.chartId = id
article.innerHTML = `
<header class="metric-card-head">
<div>
<h4 class="metric-card-title" title="Click for stats">${escapeHtml(meta.title || id)}</h4>
<p class="metric-card-sub muted">${escapeHtml(id)}${meta.units ? ` · ${escapeHtml(meta.units)}` : ''}</p>
<p class="metric-card-sub muted"><span class="metric-units">${meta.units ? escapeHtml(meta.units) : ''}</span>${meta.units ? ' · ' : ''}${escapeHtml(id)}</p>
</div>
<div class="metric-card-actions">
<button type="button" class="ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
<button type="button" class="ghost metric-related-btn" title="Find related">⇢</button>
<button type="button" class="ghost metric-type-btn" title="Chart type">${escapeHtml(card.mode)}</button>
<button type="button" class="btn btn-ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
<button type="button" class="btn btn-ghost metric-related-btn" title="Find related">⇢</button>
<button type="button" class="btn btn-ghost metric-type-btn" title="Chart type">${escapeHtml(card.mode)}</button>
<span class="metric-card-status muted"></span>
</div>
</header>
<canvas height="${state.cardHeight}"></canvas>
<canvas height="${height}"></canvas>
<div class="metric-card-legend"></div>
<div class="metric-card-stats hidden"></div>
<div class="metric-resize" title="Drag to resize height"></div>
<div class="metric-resize" title="Drag to resize · double-click to reset"></div>
`
const canvas = article.querySelector('canvas')
if (canvas) {
canvas.style.height = `${state.cardHeight}px`
canvas.style.height = `${height}px`
bindCanvasInteractions(canvas, id)
}
article.querySelector('.metric-pin-btn')?.addEventListener('click', (ev) => {
@@ -887,6 +1071,13 @@ export function createMetricsDashboard(opts) {
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
})
handle.addEventListener('dblclick', (ev) => {
ev.preventDefault()
ev.stopPropagation()
state.cardHeight = DEFAULT_HEIGHT
persistPrefs({ cardHeight: state.cardHeight })
for (const id of state.cardEls.keys()) paintCard(id)
})
}
function refreshSectionKpis() {
@@ -968,7 +1159,14 @@ export function createMetricsDashboard(opts) {
bindKeyboard()
setForcePlay(state.forcePlay)
setPlaying(true)
setPreset(state.presetId)
syncPresetUi()
// Apply default window without forcing unavailable presets
if (presetAvailable(300)) setPreset('5m')
else {
const p = TIME_PRESETS.find((x) => presetAvailable(x.seconds)) || TIME_PRESETS[0]
setPreset(p.id)
}
syncLiveUi()
}
function bindKeyboard() {
@@ -1005,6 +1203,8 @@ export function createMetricsDashboard(opts) {
}
function setCatalog(_catalog) {
computeRetentionSeconds()
syncPresetUi()
render()
}
@@ -1015,7 +1215,11 @@ export function createMetricsDashboard(opts) {
card.labels = []
card.status = 'idle'
card.source = ''
card.updating = false
card.emptyReason = ''
}
state.retentionSeconds = 0
syncPresetUi()
scheduleRefetchVisible()
}
@@ -1095,6 +1299,11 @@ function clampHeight(h) {
return clamp(Number(h) || DEFAULT_HEIGHT, 100, 360)
}
/** @param {string} s */
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;')
}
/** @param {string} s */
function escapeHtml(s) {
return String(s || '')
+296
View File
@@ -0,0 +1,296 @@
/**
* Fleet tab multi-host roster cards (PearDock-style).
*/
/**
* @typedef {{
* id: string,
* publicKeyHex: string,
* alias: string,
* connected: boolean,
* active: boolean,
* reconnecting: boolean,
* failed: boolean,
* attempts: number,
* maxAttempts: number,
* invite: string|null,
* capability: string|null,
* adminSeed: string|null,
* lastConnectedAt: number|null,
* }} FleetPeer
*/
/**
* Merge saved peers + live connections into a sorted roster.
* @param {{
* saved: Array<object>,
* live: Array<{ publicKeyHex: string, connected?: boolean }>,
* activeId: string|null,
* getReconnectInfo: (id: string) => { attempts: number, maxAttempts: number, reconnecting: boolean, failed: boolean },
* }} opts
* @returns {FleetPeer[]}
*/
export function buildFleetRoster(opts) {
const byId = new Map()
for (const b of opts.saved || []) {
const id = String(b.publicKeyHex || b.id || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) continue
byId.set(id, {
id,
publicKeyHex: id,
alias: b.alias || '',
connected: false,
active: false,
reconnecting: false,
failed: false,
attempts: 0,
maxAttempts: 0,
invite: b.invite || null,
capability: b.capability || null,
adminSeed: b.adminSeed || null,
lastConnectedAt: b.lastConnectedAt || null,
})
}
for (const c of opts.live || []) {
const id = String(c.publicKeyHex || '').toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) continue
const prev = byId.get(id) || {
id,
publicKeyHex: id,
alias: '',
invite: null,
capability: null,
adminSeed: null,
lastConnectedAt: null,
}
const info = opts.getReconnectInfo?.(id) || {
attempts: 0,
maxAttempts: 0,
reconnecting: false,
failed: false,
}
byId.set(id, {
...prev,
connected: Boolean(c.connected),
active: opts.activeId === id,
reconnecting: Boolean(info.reconnecting) && !c.connected,
failed: Boolean(info.failed) && !c.connected,
attempts: info.attempts || 0,
maxAttempts: info.maxAttempts || 0,
})
}
// Also surface reconnecting peers that dropped from connections map
for (const [id, peer] of byId) {
if (peer.connected) continue
const info = opts.getReconnectInfo?.(id)
if (!info) continue
peer.reconnecting = Boolean(info.reconnecting)
peer.failed = Boolean(info.failed)
peer.attempts = info.attempts || 0
peer.maxAttempts = info.maxAttempts || 0
}
const rank = (p) => {
if (p.active && p.connected) return 0
if (p.connected) return 1
if (p.reconnecting) return 2
if (p.failed) return 3
return 4
}
return [...byId.values()].sort((a, b) => {
const d = rank(a) - rank(b)
if (d) return d
return (a.alias || a.id).localeCompare(b.alias || b.id)
})
}
/**
* @param {FleetPeer[]} roster
*/
export function summarizeFleet(roster) {
let live = 0
let reconnecting = 0
let failed = 0
let offline = 0
for (const p of roster) {
if (p.connected) live++
else if (p.reconnecting) reconnecting++
else if (p.failed) failed++
else offline++
}
return { total: roster.length, live, reconnecting, failed, offline }
}
/**
* Render Fleet cards into a container.
* @param {HTMLElement} root
* @param {FleetPeer[]} roster
* @param {{
* onActivate: (peer: FleetPeer) => void|Promise<void>,
* onReconnect: (peer: FleetPeer) => void|Promise<void>,
* onForget: (peer: FleetPeer) => void|Promise<void>,
* onOpenCharts: (peer: FleetPeer) => void|Promise<void>,
* onAlias: (peer: FleetPeer) => void|Promise<void>,
* }} handlers
*/
export function renderFleetCards(root, roster, handlers) {
if (!root) return
root.innerHTML = ''
if (!roster.length) {
const empty = document.createElement('div')
empty.className = 'fleet-empty'
empty.innerHTML = `
<p class="fleet-empty-title">No agents yet</p>
<p class="muted">Connect a public key or <code>pd1.</code> invite saved agents appear here and restore on launch.</p>
`
root.appendChild(empty)
return
}
const grid = document.createElement('div')
grid.className = 'fleet-grid'
for (const peer of roster) {
grid.appendChild(buildCard(peer, handlers))
}
root.appendChild(grid)
}
/**
* @param {FleetPeer} peer
* @param {object} handlers
*/
function buildCard(peer, handlers) {
const card = document.createElement('article')
const state = peer.connected
? peer.active
? 'active'
: 'online'
: peer.reconnecting
? 'reconnecting'
: peer.failed
? 'failed'
: 'offline'
card.className = `fleet-card fleet-card--${state}`
card.dataset.fleetId = peer.id
const title = peer.alias || `${peer.id.slice(0, 12)}`
const badge = badgeFor(state, peer)
const health = healthLine(peer, state)
card.innerHTML = `
<header class="fleet-card-head">
<div class="fleet-card-titles">
<h3 class="fleet-card-title" title="Double-click to rename">${escapeHtml(title)}</h3>
<p class="fleet-card-key muted">${escapeHtml(peer.id.slice(0, 24))}</p>
</div>
<span class="fleet-badge fleet-badge--${state}">${escapeHtml(badge)}</span>
</header>
<ul class="fleet-card-meta">
<li><span class="muted">Status</span><strong>${escapeHtml(health)}</strong></li>
<li><span class="muted">Role</span><strong>${peer.active ? 'Active' : peer.connected ? 'Standby' : ''}</strong></li>
<li><span class="muted">Last seen</span><strong>${escapeHtml(formatLastSeen(peer))}</strong></li>
</ul>
<footer class="fleet-card-actions"></footer>
`
const footer = card.querySelector('.fleet-card-actions')
appendActions(footer, peer, state, handlers)
card.querySelector('.fleet-card-title')?.addEventListener('dblclick', (ev) => {
ev.stopPropagation()
handlers.onAlias?.(peer)
})
return card
}
function badgeFor(state, peer) {
if (state === 'active') return 'Active'
if (state === 'online') return 'Online'
if (state === 'reconnecting') {
return peer.maxAttempts
? `Retry ${peer.attempts}/${peer.maxAttempts}`
: 'Retrying'
}
if (state === 'failed') return 'Failed'
return 'Offline'
}
function healthLine(peer, state) {
if (state === 'active' || state === 'online') return 'Connected'
if (state === 'reconnecting') return 'Reconnecting…'
if (state === 'failed') return 'Max reconnects reached'
return 'Saved · not connected'
}
function formatLastSeen(peer) {
if (peer.connected) return 'now'
if (!peer.lastConnectedAt) return '—'
const age = Math.max(0, Date.now() - Number(peer.lastConnectedAt))
if (age < 60_000) return 'just now'
if (age < 3600_000) return `${Math.floor(age / 60_000)}m ago`
if (age < 86400_000) return `${Math.floor(age / 3600_000)}h ago`
return `${Math.floor(age / 86400_000)}d ago`
}
function appendActions(footer, peer, state, handlers) {
if (!footer) return
if (state === 'active') {
const active = btn('Active', 'ghost', true)
footer.appendChild(active)
footer.appendChild(
btn('Open Charts', 'ghost', false, () => handlers.onOpenCharts?.(peer))
)
} else if (state === 'online') {
footer.appendChild(
btn('Set active', 'primary', false, () => handlers.onActivate?.(peer))
)
footer.appendChild(
btn('Charts', 'ghost', false, () => handlers.onOpenCharts?.(peer))
)
} else if (state === 'reconnecting') {
footer.appendChild(btn('Retrying…', 'ghost', true))
} else {
footer.appendChild(
btn('Reconnect', state === 'failed' ? 'warn' : 'ghost', false, () =>
handlers.onReconnect?.(peer)
)
)
}
footer.appendChild(
btn('Forget', 'danger-ghost', false, () => handlers.onForget?.(peer))
)
}
function btn(label, kind, disabled, onClick) {
const b = document.createElement('button')
b.type = 'button'
b.className =
kind === 'primary'
? 'btn btn-primary'
: kind === 'warn'
? 'btn btn-warn'
: kind === 'danger-ghost'
? 'btn btn-ghost danger'
: 'btn btn-ghost'
b.textContent = label
b.disabled = Boolean(disabled)
if (onClick) {
b.addEventListener('click', (ev) => {
ev.stopPropagation()
onClick()
})
}
return b
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+385 -23
View File
@@ -36,8 +36,8 @@
--titlebar-h: 42px;
--sidebar-w: 248px;
--sidebar-collapsed-w: 64px;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--space: 16px;
--space-sm: 10px;
@@ -678,10 +678,63 @@ body.is-offline .offline-banner:not(.hidden) {
opacity: 0.75;
}
/* ─── Shared buttons ─── */
button.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 9px;
border: 1px solid var(--border-color);
background: var(--bg-elevated);
color: var(--text-secondary);
font: inherit;
font-size: 12.5px;
font-weight: 500;
padding: 6px 12px;
cursor: pointer;
transform: none;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
button.btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
transform: none;
box-shadow: none;
}
button.btn:disabled {
opacity: 0.38;
cursor: not-allowed;
}
button.btn-ghost {
background: transparent;
}
button.btn-primary {
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 45%, transparent);
color: var(--accent-primary);
}
button.btn-primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-primary) 28%, transparent);
color: var(--text-primary);
}
button.btn-warn {
background: rgba(251, 191, 36, 0.14);
border-color: rgba(251, 191, 36, 0.4);
color: #fbbf24;
}
button.btn.danger {
color: var(--accent-danger);
}
button.btn.danger:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.12);
}
/* ─── Master metrics wall (Charts tab) ─── */
.metrics-header {
flex-wrap: wrap;
gap: 12px 20px;
margin-bottom: 4px;
}
.page-subtitle kbd {
@@ -695,24 +748,119 @@ body.is-offline .offline-banner:not(.hidden) {
color: var(--text-secondary);
}
.metrics-timebar {
.metrics-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
gap: 10px 14px;
padding: 10px 14px;
margin-bottom: 10px;
border-radius: 14px;
background:
linear-gradient(135deg, rgba(45, 212, 191, 0.06), transparent 42%),
var(--bg-secondary);
border: 1px solid var(--border-color);
}
.metrics-toolbar-left,
.metrics-toolbar-right {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.metrics-toolbar-right {
margin-left: auto;
}
.metrics-live {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
min-width: 88px;
}
.metrics-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-faint);
display: inline-block;
}
.metrics-live.is-live {
color: var(--accent-primary);
}
.metrics-live.is-live .metrics-live-dot {
background: var(--accent-primary);
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.55);
animation: metrics-live-pulse 1.6s ease-out infinite;
}
.metrics-live.is-paused .metrics-live-dot {
background: var(--accent-warning);
}
.metrics-live.is-force {
color: var(--accent-info);
}
.metrics-live.is-force .metrics-live-dot {
background: var(--accent-info);
}
@keyframes metrics-live-pulse {
0% { box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.45); }
70% { box-shadow: 0 0 0 8px rgba(45, 212, 191, 0); }
100% { box-shadow: 0 0 0 0 rgba(45, 212, 191, 0); }
}
body[data-reduce-motion='1'] .metrics-live.is-live .metrics-live-dot {
animation: none;
}
.metrics-presets {
display: inline-flex;
gap: 4px;
padding: 2px;
border-radius: 10px;
background: var(--bg-elevated, rgba(255, 255, 255, 0.04));
gap: 2px;
padding: 3px;
border-radius: 11px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.metrics-presets .ghost.active {
background: rgba(52, 211, 153, 0.16);
button.metrics-tf {
border: 0;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 12px;
font-weight: 600;
font-family: var(--font-mono);
padding: 6px 11px;
border-radius: 8px;
cursor: pointer;
transform: none;
}
button.metrics-tf:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-hover);
transform: none;
box-shadow: none;
}
button.metrics-tf.active {
background: color-mix(in srgb, var(--accent-primary) 20%, transparent);
color: var(--text-primary);
}
button.metrics-tf:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.metrics-retention-hint {
margin: 0 0 10px;
font-size: 12px;
}
.metrics-meta,
@@ -824,10 +972,10 @@ body.is-offline .offline-banner:not(.hidden) {
.metrics-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-columns: 220px minmax(0, 1fr);
gap: var(--space);
min-height: 480px;
height: calc(100vh - var(--titlebar-h) - 168px);
min-height: 420px;
height: calc(100vh - var(--titlebar-h) - 210px);
}
.metrics-toc {
@@ -836,6 +984,21 @@ body.is-offline .offline-banner:not(.hidden) {
gap: 10px;
min-height: 0;
overflow: hidden;
padding: 12px;
border-radius: 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.metrics-toc #chart-search {
width: 100%;
border-radius: 9px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 8px 10px;
font: inherit;
font-size: 13px;
}
.metrics-toc-nav {
@@ -956,19 +1119,43 @@ body.is-offline .offline-banner:not(.hidden) {
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
gap: 14px;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px 10px;
padding: 12px 14px 8px;
border-radius: 14px;
background: var(--bg-card, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 48%),
var(--bg-secondary);
border: 1px solid var(--border-color);
min-height: 0;
transition: border-color 0.15s ease, opacity 0.2s ease;
}
.metric-card--hero {
grid-column: span 1;
}
@media (min-width: 1400px) {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card--hero {
grid-column: span 2;
}
}
.metric-card.updating {
opacity: 0.92;
}
.metric-card.updating canvas {
filter: saturate(0.92);
}
.metric-card-head {
@@ -1001,9 +1188,19 @@ body.is-offline .offline-banner:not(.hidden) {
.metric-card canvas {
width: 100%;
height: 160px;
height: 148px;
display: block;
border-radius: 8px;
background: rgba(0, 0, 0, 0.18);
}
html[data-theme='light'] .metric-card canvas {
background: rgba(15, 23, 42, 0.03);
}
.metric-units {
color: var(--accent-secondary);
font-weight: 500;
}
.metric-card-legend {
@@ -1255,10 +1452,167 @@ body.is-offline .offline-banner:not(.hidden) {
color: var(--text-muted);
}
.fleet-layout {
/* ─── Fleet tab ─── */
.fleet-summary-bar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: var(--space);
}
.fleet-stat {
display: grid;
gap: 2px;
padding: 14px 16px;
border-radius: 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.fleet-stat-val {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
}
.fleet-stat[data-fleet-stat='live'] .fleet-stat-val { color: var(--accent-success); }
.fleet-stat[data-fleet-stat='retry'] .fleet-stat-val { color: var(--accent-warning); }
.fleet-stat[data-fleet-stat='failed'] .fleet-stat-val { color: var(--accent-danger); }
.fleet-cards {
min-height: 200px;
}
.fleet-empty {
padding: 56px 24px;
text-align: center;
border-radius: 16px;
border: 1px dashed var(--border-strong);
background: var(--bg-secondary);
}
.fleet-empty-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 650;
}
.fleet-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.fleet-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 16px;
background:
linear-gradient(165deg, rgba(255, 255, 255, 0.03), transparent 50%),
var(--bg-secondary);
border: 1px solid var(--border-color);
min-height: 210px;
}
.fleet-card--active {
border-color: color-mix(in srgb, var(--accent-primary) 55%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 25%, transparent);
}
.fleet-card--online {
border-color: color-mix(in srgb, var(--accent-success) 40%, transparent);
}
.fleet-card--reconnecting {
border-color: color-mix(in srgb, var(--accent-warning) 45%, transparent);
}
.fleet-card--failed {
border-color: color-mix(in srgb, var(--accent-danger) 45%, transparent);
box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.2);
}
.fleet-card--offline {
opacity: 0.92;
}
.fleet-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.fleet-card-title {
margin: 0;
font-size: 16px;
font-weight: 650;
letter-spacing: -0.02em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.fleet-card-key {
margin: 4px 0 0;
font-size: 11px;
font-family: var(--font-mono);
}
.fleet-badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.02em;
padding: 4px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.16);
color: var(--text-muted);
}
.fleet-badge--active,
.fleet-badge--online {
background: rgba(74, 222, 128, 0.16);
color: #4ade80;
}
.fleet-badge--reconnecting {
background: rgba(251, 191, 36, 0.16);
color: #fbbf24;
}
.fleet-badge--failed {
background: rgba(248, 113, 113, 0.16);
color: #f87171;
}
.fleet-card-meta {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
font-size: 12.5px;
}
.fleet-card-meta li {
display: flex;
justify-content: space-between;
gap: 12px;
}
.fleet-card-meta strong {
font-weight: 550;
color: var(--text-secondary);
text-align: right;
}
.fleet-card-actions {
margin-top: auto;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fleet-card-actions .btn {
min-width: 6.5rem;
}
.rail-sub {
@@ -1550,7 +1904,6 @@ code {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.charts-grid,
.fleet-layout,
.charts-browser,
.metrics-shell {
grid-template-columns: 1fr;
@@ -1565,6 +1918,12 @@ code {
.metrics-wall {
max-height: 70vh;
}
.fleet-summary-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metrics-toolbar-right {
margin-left: 0;
}
}
@media (max-width: 720px) {
@@ -1580,4 +1939,7 @@ code {
.dash-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fleet-summary-bar {
grid-template-columns: 1fr 1fr;
}
}