feat(web): add state timeline and resource panels to live mesh dashboard

Two new panels below the existing peer graph + live stream grid:
- StateTimelinePanel: vertical timeline of audit events and presence
  status changes, auto-scrolling, sorted newest-first
- ResourcePanel: 2x2 card grid showing live peers, envelopes by
  priority, audit event breakdown, and session status

Both share the same TanStack Query cache key as the existing panels
(no extra API calls). Matches the --cm-* dark terminal aesthetic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:50:18 +01:00
parent b0dc538119
commit 7d432b3aaa
3 changed files with 502 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import {
getMyMeshStreamResponseSchema,
type GetMyMeshStreamResponse,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
const POLL_INTERVAL_MS = 4000;
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface ResourceCard {
key: string;
icon: string;
label: string;
count: number;
items: { id: string; text: string; sub: string }[];
accent: string;
}
/* ------------------------------------------------------------------ */
/* Build resource cards from stream data */
/* ------------------------------------------------------------------ */
const buildResources = (data: GetMyMeshStreamResponse): ResourceCard[] => {
const onlinePeers = data.presences.filter((p) => !p.disconnectedAt);
const offlinePeers = data.presences.filter((p) => p.disconnectedAt);
const priorityCounts = { now: 0, next: 0, low: 0 };
for (const e of data.envelopes) {
priorityCounts[e.priority] = (priorityCounts[e.priority] ?? 0) + 1;
}
// Unique senders
const uniqueSenders = new Set(data.envelopes.map((e) => e.senderMemberId));
// Recent audit event types
const eventTypes = new Map<string, number>();
for (const e of data.auditEvents) {
eventTypes.set(e.eventType, (eventTypes.get(e.eventType) ?? 0) + 1);
}
return [
{
key: "peers",
icon: "⬡",
label: "Live Peers",
count: onlinePeers.length,
accent: "text-emerald-500",
items: onlinePeers.slice(0, 4).map((p) => ({
id: p.id,
text: p.displayName ?? p.memberId.slice(0, 8),
sub: `${p.status} · ${p.cwd.split("/").pop() ?? p.cwd}`,
})),
},
{
key: "envelopes",
icon: "▤",
label: "Envelopes",
count: data.envelopes.length,
accent: "text-[var(--cm-clay)]",
items: [
{
id: "priority-now",
text: `${priorityCounts.now} now`,
sub: "urgent / bypass busy",
},
{
id: "priority-next",
text: `${priorityCounts.next} next`,
sub: "default priority",
},
{
id: "priority-low",
text: `${priorityCounts.low} low`,
sub: "pull-only",
},
{
id: "senders",
text: `${uniqueSenders.size} unique senders`,
sub: "across all envelopes",
},
],
},
{
key: "events",
icon: "◈",
label: "Audit Events",
count: data.auditEvents.length,
accent: "text-[#c46686]",
items: Array.from(eventTypes.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([type, count]) => ({
id: `evt-${type}`,
text: type.replace(/_/g, " "),
sub: `${count} occurrence${count !== 1 ? "s" : ""}`,
})),
},
{
key: "sessions",
icon: "⊡",
label: "Sessions",
count: data.presences.length,
accent: "text-[var(--cm-fg-secondary)]",
items: [
{
id: "online",
text: `${onlinePeers.length} online`,
sub: "currently connected",
},
{
id: "offline",
text: `${offlinePeers.length} offline`,
sub: "recently disconnected",
},
...data.presences
.filter((p) => p.status === "working")
.slice(0, 2)
.map((p) => ({
id: `working-${p.id}`,
text: `${p.displayName ?? p.memberId.slice(0, 8)}`,
sub: "currently working",
})),
],
},
];
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export const ResourcePanel = ({ meshId }: { meshId: string }) => {
const { data, isFetching, dataUpdatedAt } = useQuery({
queryKey: ["mesh", "stream", meshId],
queryFn: () =>
handle(api.my.meshes[":id"].stream.$get, {
schema: getMyMeshStreamResponseSchema,
})({ param: { id: meshId } }),
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: false,
});
const resources = useMemo(
() => (data ? buildResources(data) : []),
[data],
);
const secondsAgo = dataUpdatedAt
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
: null;
return (
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header */}
<div
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="flex items-center gap-3">
<span
className={
"inline-block h-2 w-2 rounded-full " +
(isFetching
? "bg-[var(--cm-clay)] animate-pulse"
: "bg-emerald-500")
}
/>
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
resources
</span>
</div>
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
</span>
</div>
{/* Resource cards grid */}
<div
className="grid grid-cols-2 gap-px bg-[var(--cm-border)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{resources.map((card) => (
<div
key={card.key}
className="flex flex-col bg-[var(--cm-bg)] p-3"
>
{/* Card header */}
<div className="flex items-baseline justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className={`text-[11px] ${card.accent}`}>
{card.icon}
</span>
<span className="text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
{card.label}
</span>
</div>
<span className={`text-lg font-semibold leading-none tabular-nums ${card.accent}`}>
{card.count}
</span>
</div>
{/* Recent items */}
<div className="flex flex-col gap-1">
{card.items.length === 0 ? (
<span className="text-[9px] text-[var(--cm-fg-tertiary)]">
none
</span>
) : (
card.items.map((item) => (
<div key={item.id} className="min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] text-[var(--cm-fg-secondary)] truncate">
{item.text}
</span>
</div>
<div className="text-[9px] text-[var(--cm-fg-tertiary)] truncate">
{item.sub}
</div>
</div>
))
)}
</div>
</div>
))}
</div>
{/* Footer */}
<div
className="flex items-center justify-between border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>derived from stream data</span>
<span>read-only snapshot</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,249 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import {
getMyMeshStreamResponseSchema,
type GetMyMeshStreamResponse,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
const POLL_INTERVAL_MS = 4000;
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface TimelineEntry {
id: string;
timestamp: Date;
type: "audit" | "presence" | "envelope";
icon: string;
label: string;
detail: string;
actor: string | null;
}
/* ------------------------------------------------------------------ */
/* Build timeline from stream data */
/* ------------------------------------------------------------------ */
const EVENT_LABELS: Record<string, string> = {
peer_connected: "connected",
peer_disconnected: "disconnected",
message_sent: "msg sent",
message_delivered: "msg delivered",
invite_created: "invite created",
invite_redeemed: "invite redeemed",
member_joined: "member joined",
member_removed: "member removed",
state_changed: "state changed",
};
const EVENT_ICONS: Record<string, string> = {
peer_connected: "↑",
peer_disconnected: "↓",
message_sent: "→",
message_delivered: "✓",
invite_created: "✉",
invite_redeemed: "★",
member_joined: "+",
member_removed: "",
state_changed: "Δ",
};
const buildTimeline = (data: GetMyMeshStreamResponse): TimelineEntry[] => {
const entries: TimelineEntry[] = [];
// Audit events → timeline entries
for (const e of data.auditEvents) {
entries.push({
id: e.id,
timestamp: new Date(e.createdAt),
type: "audit",
icon: EVENT_ICONS[e.eventType] ?? "•",
label: EVENT_LABELS[e.eventType] ?? e.eventType.replace(/_/g, " "),
detail: [
e.actorPeerId ? `actor:${e.actorPeerId.slice(0, 8)}` : null,
e.targetPeerId ? `target:${e.targetPeerId.slice(0, 8)}` : null,
]
.filter(Boolean)
.join(" → ") || "—",
actor: e.actorPeerId,
});
}
// Presence status snapshots → timeline entries (latest status per peer)
for (const p of data.presences) {
entries.push({
id: `presence-${p.id}`,
timestamp: new Date(p.statusUpdatedAt),
type: "presence",
icon: p.status === "idle" ? "◇" : p.status === "working" ? "◆" : "◈",
label: `${p.displayName ?? p.memberId.slice(0, 8)}${p.status}`,
detail: `via ${p.statusSource} · pid ${p.pid}`,
actor: p.memberId,
});
}
// Sort descending (newest first)
entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
return entries;
};
/* ------------------------------------------------------------------ */
/* Format helpers */
/* ------------------------------------------------------------------ */
const fmtTime = (d: Date) =>
d.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const TYPE_COLORS: Record<TimelineEntry["type"], string> = {
audit: "text-[var(--cm-clay)]",
presence: "text-emerald-500",
envelope: "text-[#c46686]",
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export const StateTimelinePanel = ({ meshId }: { meshId: string }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const { data, isFetching, dataUpdatedAt } = useQuery({
queryKey: ["mesh", "stream", meshId],
queryFn: () =>
handle(api.my.meshes[":id"].stream.$get, {
schema: getMyMeshStreamResponseSchema,
})({ param: { id: meshId } }),
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: false,
});
const entries = useMemo(
() => (data ? buildTimeline(data) : []),
[data],
);
const secondsAgo = dataUpdatedAt
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
: null;
// Auto-scroll to top (newest) on new data
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, [entries.length]);
return (
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header */}
<div
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="flex items-center gap-3">
<span
className={
"inline-block h-2 w-2 rounded-full " +
(isFetching
? "bg-[var(--cm-clay)] animate-pulse"
: "bg-emerald-500")
}
/>
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
event timeline
</span>
</div>
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
{entries.length} events ·{" "}
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
</span>
</div>
{/* Timeline body */}
<div
ref={scrollRef}
className="max-h-[420px] overflow-y-auto scrollbar-thin"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{entries.length === 0 ? (
<div className="flex items-center justify-center py-12 text-[11px] text-[var(--cm-fg-tertiary)]">
No events recorded yet.
</div>
) : (
<div className="relative px-4 py-3">
{/* Vertical spine */}
<div className="absolute left-[27px] top-3 bottom-3 w-px bg-[var(--cm-border)]" />
{entries.map((entry, i) => (
<div
key={entry.id}
className="group relative flex items-start gap-3 py-1.5"
>
{/* Node dot */}
<div className="relative z-10 flex h-4 w-4 flex-shrink-0 items-center justify-center">
<span
className={`text-[10px] leading-none ${TYPE_COLORS[entry.type]}`}
>
{entry.icon}
</span>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-[10px] text-[var(--cm-fg-tertiary)] tabular-nums">
{fmtTime(entry.timestamp)}
</span>
<span className={`text-[11px] font-medium ${TYPE_COLORS[entry.type]}`}>
{entry.label}
</span>
</div>
<div className="mt-0.5 text-[10px] text-[var(--cm-fg-tertiary)] truncate">
{entry.detail}
</div>
</div>
{/* Type badge */}
<span className="flex-shrink-0 rounded border border-[var(--cm-border)] px-1.5 py-0.5 text-[8px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]">
{entry.type}
</span>
</div>
))}
</div>
)}
</div>
{/* Footer legend */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="flex items-center gap-1.5">
<span className="text-[var(--cm-clay)]"></span>
audit
</span>
<span className="flex items-center gap-1.5">
<span className="text-emerald-500"></span>
presence
</span>
<span className="flex items-center gap-1.5">
<span className="text-[#c46686]"></span>
envelope
</span>
<span className="mx-1 text-[var(--cm-border)]">|</span>
<span>newest first · auto-scroll</span>
</div>
</div>
);
};