From 7d432b3aaa9395139cc2ee38442882f886c5729e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:50:18 +0100 Subject: [PATCH] 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 --- .../(user)/meshes/[id]/live/page.tsx | 6 + apps/web/src/modules/mesh/resource-panel.tsx | 247 +++++++++++++++++ .../src/modules/mesh/state-timeline-panel.tsx | 249 ++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 apps/web/src/modules/mesh/resource-panel.tsx create mode 100644 apps/web/src/modules/mesh/state-timeline-panel.tsx diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx index 5c84bda..8b210d6 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx @@ -16,6 +16,8 @@ import { } from "~/modules/common/layout/dashboard/header"; import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel"; import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel"; +import { ResourcePanel } from "~/modules/mesh/resource-panel"; +import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel"; export const generateMetadata = getMetadata({ title: "Live mesh", @@ -68,6 +70,10 @@ export default async function LiveMeshPage({ +
+ + +
); } diff --git a/apps/web/src/modules/mesh/resource-panel.tsx b/apps/web/src/modules/mesh/resource-panel.tsx new file mode 100644 index 0000000..3fedf08 --- /dev/null +++ b/apps/web/src/modules/mesh/resource-panel.tsx @@ -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(); + 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 ( +
+ {/* Header */} +
+
+ + + resources + +
+ + {isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`} + +
+ + {/* Resource cards grid */} +
+ {resources.map((card) => ( +
+ {/* Card header */} +
+
+ + {card.icon} + + + {card.label} + +
+ + {card.count} + +
+ + {/* Recent items */} +
+ {card.items.length === 0 ? ( + + none + + ) : ( + card.items.map((item) => ( +
+
+ + {item.text} + +
+
+ {item.sub} +
+
+ )) + )} +
+
+ ))} +
+ + {/* Footer */} +
+ derived from stream data + read-only snapshot +
+
+ ); +}; diff --git a/apps/web/src/modules/mesh/state-timeline-panel.tsx b/apps/web/src/modules/mesh/state-timeline-panel.tsx new file mode 100644 index 0000000..71f8ae7 --- /dev/null +++ b/apps/web/src/modules/mesh/state-timeline-panel.tsx @@ -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 = { + 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 = { + 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 = { + audit: "text-[var(--cm-clay)]", + presence: "text-emerald-500", + envelope: "text-[#c46686]", +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export const StateTimelinePanel = ({ meshId }: { meshId: string }) => { + const scrollRef = useRef(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 ( +
+ {/* Header */} +
+
+ + + event timeline + +
+ + {entries.length} events ·{" "} + {isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`} + +
+ + {/* Timeline body */} +
+ {entries.length === 0 ? ( +
+ No events recorded yet. +
+ ) : ( +
+ {/* Vertical spine */} +
+ + {entries.map((entry, i) => ( +
+ {/* Node dot */} +
+ + {entry.icon} + +
+ + {/* Content */} +
+
+ + {fmtTime(entry.timestamp)} + + + {entry.label} + +
+
+ {entry.detail} +
+
+ + {/* Type badge */} + + {entry.type} + +
+ ))} +
+ )} +
+ + {/* Footer legend */} +
+ + + audit + + + + presence + + + + envelope + + | + newest first · auto-scroll +
+
+ ); +};