bytelyst-devops-tools/dashboard/web/src/lib/hermes-ops-history.ts
Hermes VM eaaa545e6c feat(dashboard): close Phase 6 (trend cards + theme toggle), drop-root scaffold, Agents inventory, Phase 0 reconfirm
Closes the remaining tractable items from the carry-forward queue.

1. Drop-root scaffold for the backend container (P2 mitigation)
   `backend/Dockerfile` adds non-root `app` user (uid 1001) + `docker`
   group (gid via `DOCKER_GID` build arg, default 999). `BACKEND_USER`
   build arg defaults to `root` so existing deployments keep working;
   set it to `app` plus `DOCKER_GID=$(getent group docker | cut -d: -f3)`
   to flip the runtime non-root. `dashboard/DEPLOYMENT.md` gets a new
   "Running non-root" section with the exact `chgrp`/`chmod` recipe
   for the bind-mounted log files (the host-side prep that pairs with
   the build flip). DEPLOYMENT.md mitigation roadmap updated.

2. Phase 6 trend cards
   `lib/hermes-ops-history.ts` keeps the last 24 ops snapshots in
   localStorage (de-duped on `generatedAt`, schema-guarded on read,
   degrades silently on quota exceeded). Three trend cards in the
   ops panel:
     - Warning-volume sparkline + current count
     - Healthy-instance count sparkline (X/2)
     - Per-instance "minutes since last backup commit" with a 30m
       stale threshold
   SVG polyline sparklines, no chart library — `<svg viewBox="0 0
   100 100" preserveAspectRatio="none">` with `vector-effect:
   non-scaling-stroke` so the line stays 2px regardless of the
   parent's width.

3. Phase 6 theme toggle
   `components/theme-toggle.tsx` Sun/Moon button mounted in the
   Hermes layout next to the instance switcher. Persists in
   localStorage `bytelyst.theme.v1`. The design system already
   defined `[data-theme="light"]` overrides in `styles/tokens.css`;
   the toggle just sets the attribute. FOUC-prevention inline script
   in the root layout reads the same key BEFORE React hydrates so
   the first paint matches the user's last choice.

4. Phase 3 partial close: Agents pane → telemetry inventory
   `/hermes/agents` now renders a "Memory & Skills inventory (live)"
   SectionCard backed by the Phase 3 telemetry endpoint per instance
   — `hermes memory list` and `hermes skills list` rendered with
   per-section probe-status badges (`up`/`unknown`), item counts,
   and the first N entries each. Agent **health** statuses (latency,
   failure rate, last-success/failure) stay seed-data — observability
   for those needs a separate ingestion contract that the telemetry
   endpoint doesn't provide today.

5. Phase 0 reconfirmation
   Roadmap Phase 0 ticked with explicit verification notes for each
   guardrail (no public listener, manual approvals, secret hygiene,
   Caddy review). Remains "must hold throughout" — the ticks reflect
   today's verified state, not single-checkbox completion.

Verified: backend typecheck , 74/74 backend unit tests , web
typecheck , 7/7 E2E , lint 0 errors, build green, coverage gate
≥95% lines on every gated file.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 08:26:26 +00:00

94 lines
3.1 KiB
TypeScript

'use client';
// Client-side rolling history of `HermesOpsSnapshot`s — Phase 6 trend cards.
// We keep just a handful of derived metrics per snapshot (warning count,
// healthy-instance count, the freshest backup-commit timestamp per
// instance) so the localStorage payload stays tiny. Capped at 24 entries
// (24 minutes of 1-minute polls — long enough to spot a trend, short
// enough that an old entry doesn't pollute "what's happening right now").
import type { HermesOpsSnapshot } from './api';
export interface HermesOpsHistoryEntry {
/** ISO-8601 generation time, unique enough for a key. */
generatedAt: string;
/** Total warning count from the snapshot. */
warningCount: number;
/** Number of instances passing all five health checks (matches the panel). */
healthyInstances: number;
/** Per-instance freshest-backup-commit ISO timestamp, or null when unknown. */
backupFreshness: Record<string, string | null>;
}
const STORAGE_KEY = 'bytelyst.hermesOpsHistory.v1';
const MAX_ENTRIES = 24;
function safeRead(): HermesOpsHistoryEntry[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// Guard each entry shape so a stale schema doesn't crash callers.
return parsed.filter(
(e) =>
e && typeof e.generatedAt === 'string' && typeof e.warningCount === 'number' && typeof e.healthyInstances === 'number',
);
} catch {
return [];
}
}
function safeWrite(entries: HermesOpsHistoryEntry[]): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Quota exceeded / private mode — drop silently. Trend cards degrade
// gracefully to "no history yet".
}
}
/**
* Record a new snapshot's derived metrics. Returns the trimmed history
* AFTER the new entry is appended. De-dupes on `generatedAt` so the same
* snapshot rendered twice (cache hit) doesn't double-count.
*/
export function recordHermesOpsSnapshot(snapshot: HermesOpsSnapshot): HermesOpsHistoryEntry[] {
const existing = safeRead();
if (existing.some((e) => e.generatedAt === snapshot.generatedAt)) {
return existing;
}
const healthyInstances = snapshot.instances.filter(
(instance) =>
instance.gateway.active &&
instance.dashboard.active &&
instance.backup.timer.active &&
instance.backup.repo.clean &&
instance.google.workspaceToken,
).length;
const backupFreshness: Record<string, string | null> = {};
for (const instance of snapshot.instances) {
backupFreshness[instance.id] = instance.backup.repo.lastCommitAt ?? null;
}
const entry: HermesOpsHistoryEntry = {
generatedAt: snapshot.generatedAt,
warningCount: snapshot.warnings.length,
healthyInstances,
backupFreshness,
};
const next = [...existing, entry].slice(-MAX_ENTRIES);
safeWrite(next);
return next;
}
/** Read the current history (no side effects). */
export function getHermesOpsHistory(): HermesOpsHistoryEntry[] {
return safeRead();
}