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