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 +
+
+ ); +};