feat(dashboard): surface Heartbeat uptime_secs in the sidebar footer

The Heartbeat event has shipped since v2.0.5 carrying uptime_secs,
memory_count, avg_retention, suppressed_count. Three of the four were
already wired into the sidebar footer (memory count, retention,
forgetting indicator). uptime_secs was the one field that fired every
30 seconds into a silent void.

Added:
- `uptimeSeconds` derived store + `formatUptime(secs)` helper in
  websocket.ts. The helper picks the two most significant units so the
  sidebar stays tight: "3d 4h" instead of "3d 4h 22m 17s", "18m 43s"
  for shorter runs, "47s" on a fresh boot.
- New line in the sidebar status footer between retention and the
  ForgettingIndicator: "up 3d 4h" with a hover tooltip ("MCP server
  uptime") for discoverability. Hidden at sub-lg breakpoints to match
  the existing responsive pattern of the surrounding text.

Zero backend work — the data was already on the wire. This is pure
UI gap closure: four of four Heartbeat fields now visible.

svelte-check clean (580 files, 0 errors).
This commit is contained in:
Sam Valladares 2026-04-19 20:33:20 -05:00
parent fc6dca6338
commit 7a3d30914d
2 changed files with 36 additions and 2 deletions

View file

@ -105,3 +105,24 @@ export const avgRetention = derived(websocket, $ws =>
export const suppressedCount = derived(websocket, $ws =>
($ws.lastHeartbeat?.data?.suppressed_count as number) ?? 0
);
// v2.0.7: uptime of the MCP server in seconds, refreshed every heartbeat.
// Exposed raw so callers can format as they like; the helper below is the
// standard compact format ("3d 4h 22m", "18m", "47s") used in the sidebar.
export const uptimeSeconds = derived(websocket, $ws =>
($ws.lastHeartbeat?.data?.uptime_secs as number) ?? 0
);
export function formatUptime(secs: number): string {
if (!Number.isFinite(secs) || secs < 0) return '—';
const d = Math.floor(secs / 86_400);
const h = Math.floor((secs % 86_400) / 3_600);
const m = Math.floor((secs % 3_600) / 60);
const s = Math.floor(secs % 60);
// Compact representation: show the two most significant units so the
// sidebar stays readable ("3d 4h" not "3d 4h 22m 17s", "18m 43s", etc).
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
return `${s}s`;
}

View file

@ -4,7 +4,15 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { websocket, isConnected, memoryCount, avgRetention, suppressedCount } from '$stores/websocket';
import {
websocket,
isConnected,
memoryCount,
avgRetention,
suppressedCount,
uptimeSeconds,
formatUptime,
} from '$stores/websocket';
import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte';
let { children } = $props();
@ -141,9 +149,14 @@
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
</div>
<div class="hidden lg:block text-xs text-muted">
<div class="hidden lg:block text-xs text-muted space-y-0.5">
<div>{$memoryCount} memories</div>
<div>{($avgRetention * 100).toFixed(0)}% retention</div>
<!-- v2.0.7: surface uptime_secs from the Heartbeat event. Fires
every 30s so this self-refreshes. "up 3d 4h" format. -->
{#if $uptimeSeconds > 0}
<div title="MCP server uptime">up {formatUptime($uptimeSeconds)}</div>
{/if}
</div>
{#if $suppressedCount > 0}
<div class="hidden lg:block pt-1">