@@ -24,6 +24,11 @@ import {
|
|||||||
} from './client/settings.js'
|
} from './client/settings.js'
|
||||||
import { drawMultiChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
import { drawMultiChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
||||||
import { createMetricsDashboard } from './ui/dashboard.js'
|
import { createMetricsDashboard } from './ui/dashboard.js'
|
||||||
|
import {
|
||||||
|
buildFleetRoster,
|
||||||
|
summarizeFleet,
|
||||||
|
renderFleetCards,
|
||||||
|
} from './ui/fleet.js'
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id)
|
const $ = (id) => document.getElementById(id)
|
||||||
|
|
||||||
@@ -78,6 +83,15 @@ const els = {
|
|||||||
metricsPresets: $('metrics-presets'),
|
metricsPresets: $('metrics-presets'),
|
||||||
metricsMeta: $('metrics-meta'),
|
metricsMeta: $('metrics-meta'),
|
||||||
metricsHover: $('metrics-hover'),
|
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'),
|
collapseSidebarBtn: $('collapse-sidebar-btn'),
|
||||||
settingReduceMotion: $('setting-reduce-motion'),
|
settingReduceMotion: $('setting-reduce-motion'),
|
||||||
settingChartPoints: $('setting-chart-points'),
|
settingChartPoints: $('setting-chart-points'),
|
||||||
@@ -117,6 +131,9 @@ const metricsDashboard = createMetricsDashboard({
|
|||||||
forcePlayBtn: $('metrics-force-play'),
|
forcePlayBtn: $('metrics-force-play'),
|
||||||
boardBtn: $('metrics-board'),
|
boardBtn: $('metrics-board'),
|
||||||
relatedPanel: $('metrics-related'),
|
relatedPanel: $('metrics-related'),
|
||||||
|
liveEl: $('metrics-live'),
|
||||||
|
liveLabel: $('metrics-live-label'),
|
||||||
|
retentionHint: $('metrics-retention-hint'),
|
||||||
},
|
},
|
||||||
getCatalog: () => chartCatalog,
|
getCatalog: () => chartCatalog,
|
||||||
queryData: (args) => manager.request(Methods.queryData, args),
|
queryData: (args) => manager.request(Methods.queryData, args),
|
||||||
@@ -439,109 +456,89 @@ function updateActivePeerChip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBookmarks() {
|
function renderBookmarks() {
|
||||||
const list = loadBookmarks()
|
loadFleetView()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPeers() {
|
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()
|
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) {
|
function exploreValue(chart, values) {
|
||||||
@@ -812,7 +809,7 @@ function renderFleetStrip(fleet, desktopPeers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderChartCatalog(_filter = '') {
|
function renderChartCatalog(_filter = '') {
|
||||||
metricsDashboard.render()
|
metricsDashboard.setCatalog(chartCatalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCatalogChart(id) {
|
async function selectCatalogChart(id) {
|
||||||
@@ -1021,6 +1018,7 @@ function showView(name) {
|
|||||||
metricsDashboard.render()
|
metricsDashboard.render()
|
||||||
requestAnimationFrame(() => metricsDashboard.redrawVisible())
|
requestAnimationFrame(() => metricsDashboard.redrawVisible())
|
||||||
}
|
}
|
||||||
|
if (name === 'fleet') loadFleetView()
|
||||||
if (name === 'settings') syncSettingsUi()
|
if (name === 'settings') syncSettingsUi()
|
||||||
requestAnimationFrame(() => redrawAll())
|
requestAnimationFrame(() => redrawAll())
|
||||||
}
|
}
|
||||||
@@ -1272,11 +1270,18 @@ manager.on('disconnected', () => {
|
|||||||
manager.on('reconnect-failed', ({ tries }) => {
|
manager.on('reconnect-failed', ({ tries }) => {
|
||||||
setOnline(false, { reconnecting: true })
|
setOnline(false, { reconnecting: true })
|
||||||
log(`Reconnect attempt failed (try ${tries})`)
|
log(`Reconnect attempt failed (try ${tries})`)
|
||||||
|
loadFleetView()
|
||||||
})
|
})
|
||||||
|
|
||||||
manager.on('reconnect-exhausted', ({ publicKeyHex }) => {
|
manager.on('reconnect-exhausted', ({ publicKeyHex }) => {
|
||||||
setOnline(false, { reconnecting: false })
|
setOnline(false, { reconnecting: false })
|
||||||
log(`Reconnect exhausted for ${String(publicKeyHex).slice(0, 12)}…`)
|
log(`Reconnect exhausted for ${String(publicKeyHex).slice(0, 12)}…`)
|
||||||
|
loadFleetView()
|
||||||
|
})
|
||||||
|
|
||||||
|
els.fleetRefreshBtn?.addEventListener('click', () => {
|
||||||
|
loadFleetView()
|
||||||
|
renderFleetStrip(null, manager.list())
|
||||||
})
|
})
|
||||||
|
|
||||||
async function restorePeersOnBoot() {
|
async function restorePeersOnBoot() {
|
||||||
|
|||||||
@@ -148,6 +148,23 @@ export class ConnectionManager extends EventEmitter {
|
|||||||
return [...this.connections.values()]
|
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 {string} [publicKeyHex]
|
||||||
* @param {{ forgetReconnect?: boolean }} [opts]
|
* @param {{ forgetReconnect?: boolean }} [opts]
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ Do not name third-party products in code, commits, or user-facing copy.
|
|||||||
- [x] Persist pinned charts
|
- [x] Persist pinned charts
|
||||||
- [x] Board mode (pinned-only wall reusing the same cards)
|
- [x] Board mode (pinned-only wall reusing the same cards)
|
||||||
- [x] Wallboard / force-play mode
|
- [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
|
### P5 — Authoring model
|
||||||
|
|
||||||
|
|||||||
+53
-45
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script>
|
<script>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="nav-group-label">Fleet</div>
|
<div class="nav-group-label">Fleet</div>
|
||||||
<button type="button" class="nav-link" data-view="fleet">
|
<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>
|
||||||
<button type="button" class="nav-link" data-view="connect">
|
<button type="button" class="nav-link" data-view="connect">
|
||||||
<span class="nav-ico">﹢</span><span class="nav-label">Connect</span>
|
<span class="nav-ico">﹢</span><span class="nav-label">Connect</span>
|
||||||
@@ -179,20 +179,28 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="dash-kicker">Metrics</p>
|
<p class="dash-kicker">Metrics</p>
|
||||||
<h1 class="dash-title">Charts</h1>
|
<h1 class="dash-title">Charts</h1>
|
||||||
<p class="page-subtitle">Every collected context, sectioned and live · <kbd>Space</kbd> play · <kbd>/</kbd> search · <kbd>1–5</kbd> window</p>
|
<p class="page-subtitle">Sectioned wall · shared time · <kbd>Space</kbd> · <kbd>/</kbd> · <kbd>1–5</kbd></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="metrics-timebar" id="metrics-timebar">
|
</header>
|
||||||
<button type="button" id="metrics-play" class="ghost" aria-pressed="true">Pause</button>
|
<div class="metrics-toolbar" id="metrics-timebar">
|
||||||
<button type="button" id="metrics-force-play" class="ghost" aria-pressed="false" title="Keep live updating (wallboard)">Force</button>
|
<div class="metrics-toolbar-left">
|
||||||
<button type="button" id="metrics-board" class="ghost" aria-pressed="false" title="Show only pinned charts">Board</button>
|
<span id="metrics-live" class="metrics-live is-live" aria-live="polite">
|
||||||
<button type="button" id="metrics-reset" class="ghost" title="Reset to live 5m">Reset</button>
|
<i class="metrics-live-dot" aria-hidden="true"></i>
|
||||||
<div id="metrics-presets" class="metrics-presets" role="group" aria-label="Time window">
|
<span id="metrics-live-label">Live</span>
|
||||||
<button type="button" class="ghost" data-preset="1m">1m</button>
|
</span>
|
||||||
<button type="button" class="ghost active" data-preset="5m">5m</button>
|
<button type="button" id="metrics-play" class="btn btn-ghost" aria-pressed="true">Pause</button>
|
||||||
<button type="button" class="ghost" data-preset="15m">15m</button>
|
<button type="button" id="metrics-force-play" class="btn btn-ghost" aria-pressed="false" title="Keep live (wallboard)">Force</button>
|
||||||
<button type="button" class="ghost" data-preset="1h">1h</button>
|
<button type="button" id="metrics-board" class="btn btn-ghost" aria-pressed="false" title="Pinned board only">Board</button>
|
||||||
<button type="button" class="ghost" data-preset="6h">6h</button>
|
<button type="button" id="metrics-reset" class="btn btn-ghost" title="Reset to live 5m">Reset</button>
|
||||||
</div>
|
</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">
|
<label class="metrics-group-label muted">
|
||||||
Group
|
Group
|
||||||
<select id="metrics-group" aria-label="Downsample aggregation">
|
<select id="metrics-group" aria-label="Downsample aggregation">
|
||||||
@@ -202,23 +210,22 @@
|
|||||||
<option value="sum">sum</option>
|
<option value="sum">sum</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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-meta" class="muted metrics-meta"></span>
|
||||||
<span id="metrics-hover" class="muted metrics-hover"></span>
|
<span id="metrics-hover" class="muted metrics-hover"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
<div id="metrics-related" class="dash-card related-panel hidden"></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">
|
<div class="metrics-shell">
|
||||||
<aside class="metrics-toc dash-card">
|
<aside class="metrics-toc">
|
||||||
<input id="chart-search" type="search" placeholder="Filter by id, title, family…" autocomplete="off" />
|
<input id="chart-search" type="search" placeholder="Filter charts…" autocomplete="off" />
|
||||||
<nav id="metrics-toc" class="metrics-toc-nav" aria-label="Chart sections"></nav>
|
<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>
|
<ul id="chart-catalog-list" class="catalog-list hidden" hidden></ul>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="metrics-wall" class="metrics-wall" tabindex="0"></div>
|
<div id="metrics-wall" class="metrics-wall" tabindex="0"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Detail strip for deep-dive (dblclick a card) -->
|
<article id="chart-detail-panel" class="chart-detail metrics-detail hidden">
|
||||||
<article id="chart-detail-panel" class="dash-card chart-detail metrics-detail hidden">
|
|
||||||
<header>
|
<header>
|
||||||
<h3 id="chart-detail-title">Select a chart</h3>
|
<h3 id="chart-detail-title">Select a chart</h3>
|
||||||
<span id="chart-detail-meta" class="muted"></span>
|
<span id="chart-detail-meta" class="muted"></span>
|
||||||
@@ -246,33 +253,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Fleet / agents -->
|
<!-- Fleet -->
|
||||||
<section id="fleet-view" class="view hidden">
|
<section id="fleet-view" class="view hidden">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="dash-kicker">Peers</p>
|
<p class="dash-kicker">Multi-host</p>
|
||||||
<h1 class="dash-title">Agents</h1>
|
<h1 class="dash-title">Fleet</h1>
|
||||||
<p class="page-subtitle">Saved bookmarks and live connections</p>
|
<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>
|
</div>
|
||||||
<label class="check-row">
|
|
||||||
<input type="checkbox" id="compare-toggle" />
|
|
||||||
Compare CPU overlay
|
|
||||||
</label>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="fleet-layout">
|
<section id="fleet-summary-bar" class="fleet-summary-bar" aria-live="polite">
|
||||||
<div class="dash-card">
|
<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>
|
||||||
<h3 class="rail-sub">Saved</h3>
|
<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>
|
||||||
<ul id="bookmark-list" class="peer-list bookmark-list"></ul>
|
<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>
|
<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>
|
||||||
<div class="dash-card">
|
</section>
|
||||||
<h3 class="rail-sub">Connected</h3>
|
<div id="fleet-cards" class="fleet-cards"></div>
|
||||||
<ul id="peer-list" class="peer-list"></ul>
|
<pre id="invite-out" class="invite-out hidden"></pre>
|
||||||
<div class="admin-block">
|
<!-- Compatibility hooks for legacy list renderers -->
|
||||||
<button id="btn-invite" class="ghost" disabled>Mint invite</button>
|
<ul id="bookmark-list" class="hidden" hidden></ul>
|
||||||
<pre id="invite-out" class="invite-out hidden"></pre>
|
<ul id="peer-list" class="hidden" hidden></ul>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Connect -->
|
<!-- Connect -->
|
||||||
|
|||||||
@@ -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
@@ -15,6 +15,8 @@
|
|||||||
* padLeft?: number,
|
* padLeft?: number,
|
||||||
* showYAxis?: boolean,
|
* showYAxis?: boolean,
|
||||||
* units?: string,
|
* units?: string,
|
||||||
|
* emptyMessage?: string,
|
||||||
|
* dimmed?: boolean,
|
||||||
* }} [opts]
|
* }} [opts]
|
||||||
*/
|
*/
|
||||||
export function drawMultiChart(canvas, lines, 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) }))
|
.map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) }))
|
||||||
.filter((l) => l.values.length >= 2)
|
.filter((l) => l.values.length >= 2)
|
||||||
if (!prepared.length) {
|
if (!prepared.length) {
|
||||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.45)'
|
ctx.fillStyle = 'rgba(154, 168, 188, 0.55)'
|
||||||
ctx.font = '12px ui-sans-serif, system-ui, sans-serif'
|
ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif'
|
||||||
ctx.fillText('No data', padL + 8, padT + 20)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.dimmed) ctx.globalAlpha = 0.72
|
||||||
|
|
||||||
let max = 1
|
let max = 1
|
||||||
let min = 0
|
let min = 0
|
||||||
if (opts.stacked) {
|
if (opts.stacked) {
|
||||||
@@ -177,6 +185,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
|||||||
ctx.fill()
|
ctx.fill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.globalAlpha = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+260
-51
@@ -9,7 +9,8 @@ const GROUPS = ['average', 'min', 'max', 'sum']
|
|||||||
|
|
||||||
const MIN_WINDOW = 30
|
const MIN_WINDOW = 30
|
||||||
const MAX_WINDOW = 21600
|
const MAX_WINDOW = 21600
|
||||||
const DEFAULT_HEIGHT = 160
|
const DEFAULT_HEIGHT = 148
|
||||||
|
const HERO_HEIGHT = 200
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
@@ -27,6 +28,9 @@ const DEFAULT_HEIGHT = 160
|
|||||||
* forcePlayBtn?: HTMLButtonElement|null,
|
* forcePlayBtn?: HTMLButtonElement|null,
|
||||||
* relatedPanel?: HTMLElement|null,
|
* relatedPanel?: HTMLElement|null,
|
||||||
* boardBtn?: HTMLButtonElement|null,
|
* boardBtn?: HTMLButtonElement|null,
|
||||||
|
* liveEl?: HTMLElement|null,
|
||||||
|
* liveLabel?: HTMLElement|null,
|
||||||
|
* retentionHint?: HTMLElement|null,
|
||||||
* }} DashboardEls
|
* }} DashboardEls
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -75,7 +79,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
relatedIds: new Set(),
|
relatedIds: new Set(),
|
||||||
relatedSeed: '',
|
relatedSeed: '',
|
||||||
boardOnly: false,
|
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(),
|
cards: new Map(),
|
||||||
/** @type {Map<string, HTMLElement>} */
|
/** @type {Map<string, HTMLElement>} */
|
||||||
cardEls: new Map(),
|
cardEls: new Map(),
|
||||||
@@ -84,6 +88,10 @@ export function createMetricsDashboard(opts) {
|
|||||||
built: false,
|
built: false,
|
||||||
refetchTimer: /** @type {ReturnType<typeof setTimeout>|null} */ (null),
|
refetchTimer: /** @type {ReturnType<typeof setTimeout>|null} */ (null),
|
||||||
pan: /** @type {{ active: boolean, startX: number, startOffset: number }|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() {
|
function points() {
|
||||||
@@ -131,11 +139,28 @@ export function createMetricsDashboard(opts) {
|
|||||||
opts.els.playBtn.setAttribute('aria-pressed', state.playing ? 'true' : 'false')
|
opts.els.playBtn.setAttribute('aria-pressed', state.playing ? 'true' : 'false')
|
||||||
opts.els.playBtn.disabled = state.forcePlay
|
opts.els.playBtn.disabled = state.forcePlay
|
||||||
}
|
}
|
||||||
|
syncLiveUi()
|
||||||
syncPresetUi()
|
syncPresetUi()
|
||||||
updateMeta()
|
updateMeta()
|
||||||
if (state.playing) scheduleRefetchVisible()
|
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) {
|
function setForcePlay(on) {
|
||||||
state.forcePlay = Boolean(on)
|
state.forcePlay = Boolean(on)
|
||||||
persistPrefs({ forcePlay: state.forcePlay })
|
persistPrefs({ forcePlay: state.forcePlay })
|
||||||
@@ -146,6 +171,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
opts.els.root?.classList.toggle('force-play', state.forcePlay)
|
opts.els.root?.classList.toggle('force-play', state.forcePlay)
|
||||||
if (state.forcePlay) setPlaying(true)
|
if (state.forcePlay) setPlaying(true)
|
||||||
else if (opts.els.playBtn) opts.els.playBtn.disabled = false
|
else if (opts.els.playBtn) opts.els.playBtn.disabled = false
|
||||||
|
syncLiveUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGroup(group) {
|
function setGroup(group) {
|
||||||
@@ -158,6 +184,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
|
|
||||||
function setPreset(id) {
|
function setPreset(id) {
|
||||||
const p = TIME_PRESETS.find((x) => x.id === id) || TIME_PRESETS[1]
|
const p = TIME_PRESETS.find((x) => x.id === id) || TIME_PRESETS[1]
|
||||||
|
if (!presetAvailable(p.seconds)) return
|
||||||
state.presetId = p.id
|
state.presetId = p.id
|
||||||
state.afterSeconds = p.seconds
|
state.afterSeconds = p.seconds
|
||||||
state.endOffset = 0
|
state.endOffset = 0
|
||||||
@@ -166,11 +193,52 @@ export function createMetricsDashboard(opts) {
|
|||||||
opts.els.playBtn.textContent = 'Pause'
|
opts.els.playBtn.textContent = 'Pause'
|
||||||
opts.els.playBtn.setAttribute('aria-pressed', 'true')
|
opts.els.playBtn.setAttribute('aria-pressed', 'true')
|
||||||
}
|
}
|
||||||
|
syncLiveUi()
|
||||||
syncPresetUi()
|
syncPresetUi()
|
||||||
scheduleRefetchVisible()
|
scheduleRefetchVisible()
|
||||||
updateMeta()
|
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) {
|
function setWindow(seconds, endOffset = state.endOffset) {
|
||||||
state.afterSeconds = clamp(seconds, MIN_WINDOW, MAX_WINDOW)
|
state.afterSeconds = clamp(seconds, MIN_WINDOW, MAX_WINDOW)
|
||||||
state.endOffset = Math.max(0, endOffset)
|
state.endOffset = Math.max(0, endOffset)
|
||||||
@@ -191,9 +259,49 @@ export function createMetricsDashboard(opts) {
|
|||||||
|
|
||||||
function syncPresetUi() {
|
function syncPresetUi() {
|
||||||
if (!opts.els.presets) return
|
if (!opts.els.presets) return
|
||||||
|
computeRetentionSeconds()
|
||||||
|
const catalogEmpty = !Object.keys(opts.getCatalog() || {}).length
|
||||||
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
|
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() {
|
function updateMeta() {
|
||||||
@@ -230,10 +338,23 @@ export function createMetricsDashboard(opts) {
|
|||||||
const catalog = opts.getCatalog() || {}
|
const catalog = opts.getCatalog() || {}
|
||||||
const meta = catalog[id] || state.cards.get(id)?.meta || {}
|
const meta = catalog[id] || state.cards.get(id)?.meta || {}
|
||||||
const card = ensureCard(id, meta)
|
const card = ensureCard(id, meta)
|
||||||
card.status = 'loading'
|
const hadData = card.status === 'ok' && card.dims.size > 0
|
||||||
paintCard(id)
|
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 {
|
try {
|
||||||
const q = await opts.queryData(queryArgs(id))
|
const q = await opts.queryData(queryArgs(id))
|
||||||
|
if (state.fetchGen.get(id) !== gen) return
|
||||||
const labels = Array.isArray(q.labels)
|
const labels = Array.isArray(q.labels)
|
||||||
? q.labels.filter((l) => l && l !== 'time')
|
? q.labels.filter((l) => l && l !== 'time')
|
||||||
: []
|
: []
|
||||||
@@ -244,23 +365,51 @@ export function createMetricsDashboard(opts) {
|
|||||||
const n = q.data[0].length - 1
|
const n = q.data[0].length - 1
|
||||||
dimNames = Array.from({ length: Math.max(0, n) }, (_, i) => `d${i}`)
|
dimNames = Array.from({ length: Math.max(0, n) }, (_, i) => `d${i}`)
|
||||||
}
|
}
|
||||||
card.labels = dimNames
|
const nextDims = new Map()
|
||||||
card.dims = new Map()
|
|
||||||
card.source = q.source || 'memory'
|
|
||||||
const max = maxPoints()
|
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 (const row of q.data || []) {
|
||||||
for (let i = 0; i < dimNames.length; i++) {
|
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) {
|
} 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' }
|
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) {
|
function inferDimNames(meta, n) {
|
||||||
@@ -324,21 +473,19 @@ export function createMetricsDashboard(opts) {
|
|||||||
article.classList.toggle('related', state.relatedIds.has(id))
|
article.classList.toggle('related', state.relatedIds.has(id))
|
||||||
const pinBtn = article.querySelector('.metric-pin-btn')
|
const pinBtn = article.querySelector('.metric-pin-btn')
|
||||||
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
|
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
|
||||||
|
article.classList.toggle('updating', Boolean(card?.updating))
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
const src = card?.source && card.source !== 'memory' ? ` · ${card.source}` : ''
|
if (card?.updating) statusEl.textContent = 'updating'
|
||||||
statusEl.textContent =
|
else if (card?.status === 'loading') statusEl.textContent = 'loading'
|
||||||
card?.status === 'loading'
|
else if (card?.status === 'error') statusEl.textContent = 'error'
|
||||||
? 'loading'
|
else if (card?.status === 'empty') statusEl.textContent = 'empty'
|
||||||
: card?.status === 'error'
|
else if (card?.status === 'ok' && card.source && card.source !== 'memory') {
|
||||||
? 'error'
|
statusEl.textContent = card.source
|
||||||
: card?.status === 'empty'
|
} else statusEl.textContent = ''
|
||||||
? 'empty'
|
|
||||||
: card?.status === 'ok'
|
|
||||||
? src.replace(/^ · /, '')
|
|
||||||
: ''
|
|
||||||
}
|
}
|
||||||
if (!card || !canvas) return
|
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 { dims, hidden } = sortedDims(card, id)
|
||||||
const lines = []
|
const lines = []
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -357,6 +504,14 @@ export function createMetricsDashboard(opts) {
|
|||||||
const anomaly = opts.getAnomaly?.(id) || null
|
const anomaly = opts.getAnomaly?.(id) || null
|
||||||
if (anomaly?.severity) article.dataset.severity = anomaly.severity
|
if (anomaly?.severity) article.dataset.severity = anomaly.severity
|
||||||
else delete article.dataset.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, {
|
drawMultiChart(canvas, lines, {
|
||||||
maxPoints: maxPoints(),
|
maxPoints: maxPoints(),
|
||||||
stacked: card.mode === 'stacked',
|
stacked: card.mode === 'stacked',
|
||||||
@@ -364,30 +519,48 @@ export function createMetricsDashboard(opts) {
|
|||||||
hoverIndex: state.hoverIndex,
|
hoverIndex: state.hoverIndex,
|
||||||
threshold: anomaly?.threshold ?? null,
|
threshold: anomaly?.threshold ?? null,
|
||||||
severity: anomaly?.severity || null,
|
severity: anomaly?.severity || null,
|
||||||
|
emptyMessage: emptyMsg,
|
||||||
|
dimmed: Boolean(card.updating),
|
||||||
})
|
})
|
||||||
const statsEl = article.querySelector('.metric-card-stats')
|
const statsEl = article.querySelector('.metric-card-stats')
|
||||||
if (statsEl && article.classList.contains('expanded')) {
|
if (statsEl && article.classList.contains('expanded')) {
|
||||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
|
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
|
||||||
}
|
}
|
||||||
if (legend) {
|
if (legend && !card.updating) {
|
||||||
legend.innerHTML = ''
|
const nextHtml = lines
|
||||||
lines.forEach((line) => {
|
.map((line) => {
|
||||||
const btn = document.createElement('button')
|
const last = line.values[line.values.length - 1]
|
||||||
btn.type = 'button'
|
const text = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
|
||||||
btn.className = 'dim-chip' + (line.hidden ? ' off' : '')
|
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>`
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
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) {
|
function toggleDim(chartId, dim) {
|
||||||
let set = state.hiddenDims.get(chartId)
|
let set = state.hiddenDims.get(chartId)
|
||||||
if (!set) {
|
if (!set) {
|
||||||
@@ -416,7 +589,11 @@ export function createMetricsDashboard(opts) {
|
|||||||
if (state.pinned.has(id)) state.pinned.delete(id)
|
if (state.pinned.has(id)) state.pinned.delete(id)
|
||||||
else state.pinned.add(id)
|
else state.pinned.add(id)
|
||||||
persistPrefs({ pinned: [...state.pinned] })
|
persistPrefs({ pinned: [...state.pinned] })
|
||||||
|
// Soft refresh: keep scroll position / avoid wall wipe flicker
|
||||||
|
const wall = opts.els.wall
|
||||||
|
const top = wall?.scrollTop || 0
|
||||||
render()
|
render()
|
||||||
|
if (wall) wall.scrollTop = top
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showRelated(seedId) {
|
async function showRelated(seedId) {
|
||||||
@@ -719,7 +896,9 @@ export function createMetricsDashboard(opts) {
|
|||||||
if (state.collapsed.has(sec.id)) state.collapsed.delete(sec.id)
|
if (state.collapsed.has(sec.id)) state.collapsed.delete(sec.id)
|
||||||
else state.collapsed.add(sec.id)
|
else state.collapsed.add(sec.id)
|
||||||
persistPrefs({ collapsed: [...state.collapsed] })
|
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')
|
const title = document.createElement('h2')
|
||||||
title.textContent = sec.title
|
title.textContent = sec.title
|
||||||
@@ -800,30 +979,35 @@ export function createMetricsDashboard(opts) {
|
|||||||
|
|
||||||
function buildCardEl(id, meta) {
|
function buildCardEl(id, meta) {
|
||||||
const card = ensureCard(id, meta)
|
const card = ensureCard(id, meta)
|
||||||
|
const height = cardHeightFor(id, card)
|
||||||
|
const hero = isHeroChart(id, meta)
|
||||||
const article = document.createElement('article')
|
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.dataset.chartId = id
|
||||||
article.innerHTML = `
|
article.innerHTML = `
|
||||||
<header class="metric-card-head">
|
<header class="metric-card-head">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="metric-card-title" title="Click for stats">${escapeHtml(meta.title || id)}</h4>
|
<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>
|
||||||
<div class="metric-card-actions">
|
<div class="metric-card-actions">
|
||||||
<button type="button" class="ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
|
<button type="button" class="btn btn-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="btn btn-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-type-btn" title="Chart type">${escapeHtml(card.mode)}</button>
|
||||||
<span class="metric-card-status muted"></span>
|
<span class="metric-card-status muted"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<canvas height="${state.cardHeight}"></canvas>
|
<canvas height="${height}"></canvas>
|
||||||
<div class="metric-card-legend"></div>
|
<div class="metric-card-legend"></div>
|
||||||
<div class="metric-card-stats hidden"></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')
|
const canvas = article.querySelector('canvas')
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
canvas.style.height = `${state.cardHeight}px`
|
canvas.style.height = `${height}px`
|
||||||
bindCanvasInteractions(canvas, id)
|
bindCanvasInteractions(canvas, id)
|
||||||
}
|
}
|
||||||
article.querySelector('.metric-pin-btn')?.addEventListener('click', (ev) => {
|
article.querySelector('.metric-pin-btn')?.addEventListener('click', (ev) => {
|
||||||
@@ -887,6 +1071,13 @@ export function createMetricsDashboard(opts) {
|
|||||||
window.addEventListener('pointermove', onMove)
|
window.addEventListener('pointermove', onMove)
|
||||||
window.addEventListener('pointerup', onUp)
|
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() {
|
function refreshSectionKpis() {
|
||||||
@@ -968,7 +1159,14 @@ export function createMetricsDashboard(opts) {
|
|||||||
bindKeyboard()
|
bindKeyboard()
|
||||||
setForcePlay(state.forcePlay)
|
setForcePlay(state.forcePlay)
|
||||||
setPlaying(true)
|
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() {
|
function bindKeyboard() {
|
||||||
@@ -1005,6 +1203,8 @@ export function createMetricsDashboard(opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCatalog(_catalog) {
|
function setCatalog(_catalog) {
|
||||||
|
computeRetentionSeconds()
|
||||||
|
syncPresetUi()
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,7 +1215,11 @@ export function createMetricsDashboard(opts) {
|
|||||||
card.labels = []
|
card.labels = []
|
||||||
card.status = 'idle'
|
card.status = 'idle'
|
||||||
card.source = ''
|
card.source = ''
|
||||||
|
card.updating = false
|
||||||
|
card.emptyReason = ''
|
||||||
}
|
}
|
||||||
|
state.retentionSeconds = 0
|
||||||
|
syncPresetUi()
|
||||||
scheduleRefetchVisible()
|
scheduleRefetchVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,6 +1299,11 @@ function clampHeight(h) {
|
|||||||
return clamp(Number(h) || DEFAULT_HEIGHT, 100, 360)
|
return clamp(Number(h) || DEFAULT_HEIGHT, 100, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {string} s */
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return escapeHtml(s).replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {string} s */
|
/** @param {string} s */
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s || '')
|
return String(s || '')
|
||||||
|
|||||||
+296
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
+385
-23
@@ -36,8 +36,8 @@
|
|||||||
--titlebar-h: 42px;
|
--titlebar-h: 42px;
|
||||||
--sidebar-w: 248px;
|
--sidebar-w: 248px;
|
||||||
--sidebar-collapsed-w: 64px;
|
--sidebar-collapsed-w: 64px;
|
||||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
|
||||||
--space: 16px;
|
--space: 16px;
|
||||||
--space-sm: 10px;
|
--space-sm: 10px;
|
||||||
@@ -678,10 +678,63 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
opacity: 0.75;
|
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) ─── */
|
/* ─── Master metrics wall (Charts tab) ─── */
|
||||||
.metrics-header {
|
.metrics-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px 20px;
|
gap: 12px 20px;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle kbd {
|
.page-subtitle kbd {
|
||||||
@@ -695,24 +748,119 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-timebar {
|
.metrics-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
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 {
|
.metrics-presets {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
padding: 2px;
|
padding: 3px;
|
||||||
border-radius: 10px;
|
border-radius: 11px;
|
||||||
background: var(--bg-elevated, rgba(255, 255, 255, 0.04));
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-presets .ghost.active {
|
button.metrics-tf {
|
||||||
background: rgba(52, 211, 153, 0.16);
|
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);
|
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,
|
.metrics-meta,
|
||||||
@@ -824,10 +972,10 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
|
|
||||||
.metrics-shell {
|
.metrics-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
gap: var(--space);
|
gap: var(--space);
|
||||||
min-height: 480px;
|
min-height: 420px;
|
||||||
height: calc(100vh - var(--titlebar-h) - 168px);
|
height: calc(100vh - var(--titlebar-h) - 210px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-toc {
|
.metrics-toc {
|
||||||
@@ -836,6 +984,21 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
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 {
|
.metrics-toc-nav {
|
||||||
@@ -956,19 +1119,43 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
|
|
||||||
.metrics-grid {
|
.metrics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 14px 10px;
|
padding: 12px 14px 8px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--bg-card, rgba(255, 255, 255, 0.03));
|
background:
|
||||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 48%),
|
||||||
|
var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
min-height: 0;
|
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 {
|
.metric-card-head {
|
||||||
@@ -1001,9 +1188,19 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
|
|
||||||
.metric-card canvas {
|
.metric-card canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 160px;
|
height: 148px;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 8px;
|
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 {
|
.metric-card-legend {
|
||||||
@@ -1255,10 +1452,167 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fleet-layout {
|
/* ─── Fleet tab ─── */
|
||||||
|
.fleet-summary-bar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: var(--space);
|
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 {
|
.rail-sub {
|
||||||
@@ -1550,7 +1904,6 @@ code {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.charts-grid,
|
.charts-grid,
|
||||||
.fleet-layout,
|
|
||||||
.charts-browser,
|
.charts-browser,
|
||||||
.metrics-shell {
|
.metrics-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1565,6 +1918,12 @@ code {
|
|||||||
.metrics-wall {
|
.metrics-wall {
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
}
|
}
|
||||||
|
.fleet-summary-bar {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.metrics-toolbar-right {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -1580,4 +1939,7 @@ code {
|
|||||||
.dash-kpis {
|
.dash-kpis {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.fleet-summary-bar {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user