feat(web): live mesh dashboard — real data through extracted MeshStream
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled

Wires the Discord-style demo UI to real user data. Users with 1+ meshes
now get situational awareness: who's online, what's in the queue, what
the broker saw recently — polling every 4s, all E2E encrypted.

Extraction pass:
- New `<MeshStream peers messages channelLabel footer>` renderer at
  modules/marketing/home/mesh-stream.tsx — pure presentation, no
  playback engine, no data fetching. Handles peer filter, hover-for-
  ciphertext tooltip, animated message list.
- demo-dashboard.tsx refactored to use it: keeps the playback loop,
  traffic-light chrome, and script-driven messages; passes everything
  to MeshStream via props. ~120 LOC shorter.

Backend:
- new GET /api/my/meshes/:id/stream in packages/api (same authz gate
  as /my/meshes/:id — owner OR non-revoked member). Returns:
  - up to 20 live presences (disconnectedAt IS NULL), joined to
    meshMember for displayName
  - up to 50 most-recent message_queue envelopes with metadata only:
    sender + displayName, targetSpec, priority, createdAt, deliveredAt,
    byte size, and a 24-char ciphertext preview (this IS what the
    broker sees — no plaintext anywhere in the response)
  - up to 20 recent audit events

- getMyMeshStreamResponseSchema in schema/mesh-user.ts matches exactly.

Frontend:
- new LiveStreamPanel client component at modules/mesh/live-stream-panel.tsx
  — react-query with refetchInterval: 4000ms, refetchIntervalInBackground
  false. Maps presences + envelopes to MeshStream's Peer/Message shape,
  classifies targetSpec into message type ("tag:*" → ask_mesh, "*" →
  broadcast, else direct). Passes through the ciphertextPreview as the
  hover content — no fake ciphertext in live view.
- new route /dashboard/meshes/[id]/live with server-side authz preflight
  via /my/meshes/:id. Mounts LiveStreamPanel inside a dashboard page
  shell with breadcrumb back to mesh detail.
- Mesh detail page gets a new "Live" pill button (clay-pulsing dot)
  next to "Generate invite link" in the header.
- paths config gets dashboard.user.meshes.live(id).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-05 14:51:14 +01:00
parent 64ca600195
commit 5bffdb1d30
9 changed files with 745 additions and 300 deletions

View File

@@ -0,0 +1,120 @@
"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";
import {
MeshStream,
type StreamMessage,
type StreamPeer,
} from "~/modules/marketing/home/mesh-stream";
const POLL_INTERVAL_MS = 4000;
const classifyTarget = (
target: string,
): "direct" | "ask_mesh" | "broadcast" => {
if (target === "*") return "broadcast";
if (target.startsWith("tag:")) return "ask_mesh";
return "direct";
};
const buildStream = (data: GetMyMeshStreamResponse) => {
const peers: StreamPeer[] = data.presences.map((p) => ({
id: p.memberId,
name: p.displayName ?? p.memberId.slice(0, 8),
status: p.status === "dnd" ? "dnd" : p.status,
machine: p.cwd,
surface: "terminal",
}));
const messages: StreamMessage[] = data.envelopes
.slice()
.reverse()
.map((e) => ({
key: e.id,
from: e.senderMemberId,
to: e.targetSpec,
type: classifyTarget(e.targetSpec),
ciphertext: e.ciphertextPreview,
createdAt: new Date(e.createdAt),
}));
return { peers, messages };
};
export const LiveStreamPanel = ({ meshId }: { meshId: string }) => {
const { data, isLoading, dataUpdatedAt, isFetching } = 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 { peers, messages } = useMemo(
() =>
data ? buildStream(data) : { peers: [], messages: [] },
[data],
);
const secondsAgo = dataUpdatedAt
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
: null;
const footer = (
<div
className="flex items-center justify-between px-4 py-2 text-[10px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span>
{messages.length} envelopes · {peers.length} live peers
</span>
<span>
{isFetching ? "▶ polling…" : `${secondsAgo ?? "—"}s ago`}
{" · "}every {POLL_INTERVAL_MS / 1000}s
</span>
<span>read-only · E2E encrypted</span>
</div>
);
const emptyLabel = isLoading
? "Connecting to mesh…"
: "No envelopes yet. When your peers send messages they'll appear here.";
return (
<div className="overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
<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)]">
live · polling every {POLL_INTERVAL_MS / 1000}s
</span>
</div>
</div>
<MeshStream
peers={peers}
messages={messages}
channelLabel="live-stream"
emptyLabel={emptyLabel}
footer={footer}
/>
</div>
);
};