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:
@@ -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({
|
||||
<PeerGraphPanel meshId={id} />
|
||||
<LiveStreamPanel meshId={id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<StateTimelinePanel meshId={id} />
|
||||
<ResourcePanel meshId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal file
247
apps/web/src/modules/mesh/resource-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal file
249
apps/web/src/modules/mesh/state-timeline-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user