feat(web): live mesh dashboard — real data through extracted MeshStream
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
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:
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
|
||||||
|
import { handle } from "@turbostarter/api/utils";
|
||||||
|
import { Badge } from "@turbostarter/ui-web/badge";
|
||||||
|
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||||
|
|
||||||
|
import { pathsConfig } from "~/config/paths";
|
||||||
|
import { api } from "~/lib/api/server";
|
||||||
|
import { getMetadata } from "~/lib/metadata";
|
||||||
|
import {
|
||||||
|
DashboardHeader,
|
||||||
|
DashboardHeaderDescription,
|
||||||
|
DashboardHeaderTitle,
|
||||||
|
} from "~/modules/common/layout/dashboard/header";
|
||||||
|
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||||
|
|
||||||
|
export const generateMetadata = getMetadata({
|
||||||
|
title: "Live mesh",
|
||||||
|
description: "Real-time situational awareness of your mesh.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function LiveMeshPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Authz gate — same endpoint the detail page uses
|
||||||
|
const data = await handle(api.my.meshes[":id"].$get, {
|
||||||
|
schema: getMyMeshResponseSchema,
|
||||||
|
})({ param: { id } }).catch(() => null);
|
||||||
|
|
||||||
|
if (!data || !data.mesh) notFound();
|
||||||
|
const { mesh } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardHeader>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<DashboardHeaderTitle>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
{mesh.name}
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
live
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</DashboardHeaderTitle>
|
||||||
|
<DashboardHeaderDescription>
|
||||||
|
Real-time view of presences and envelope routing across this
|
||||||
|
mesh. Broker sees ciphertext only.
|
||||||
|
</DashboardHeaderDescription>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.mesh(mesh.id)}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
← Mesh detail
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DashboardHeader>
|
||||||
|
|
||||||
|
<LiveStreamPanel meshId={id} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,6 +55,14 @@ export default async function MeshPage({
|
|||||||
· tier {mesh.tier} · {mesh.visibility} · {mesh.transport}
|
· tier {mesh.tier} · {mesh.visibility} · {mesh.transport}
|
||||||
</DashboardHeaderDescription>
|
</DashboardHeaderDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.live(mesh.id)}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
<span className="mr-1.5 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
Live
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
|
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
|
||||||
className={buttonVariants({ variant: "default" })}
|
className={buttonVariants({ variant: "default" })}
|
||||||
@@ -62,6 +70,7 @@ export default async function MeshPage({
|
|||||||
Generate invite link
|
Generate invite link
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</DashboardHeader>
|
</DashboardHeader>
|
||||||
|
|
||||||
<div className="grid gap-8">
|
<div className="grid gap-8">
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const pathsConfig = {
|
|||||||
new: `${DASHBOARD_PREFIX}/meshes/new`,
|
new: `${DASHBOARD_PREFIX}/meshes/new`,
|
||||||
mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`,
|
mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`,
|
||||||
invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`,
|
invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`,
|
||||||
|
live: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/live`,
|
||||||
},
|
},
|
||||||
invites: `${DASHBOARD_PREFIX}/invites`,
|
invites: `${DASHBOARD_PREFIX}/invites`,
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Reveal, SectionIcon } from "./_reveal";
|
import { Reveal, SectionIcon } from "./_reveal";
|
||||||
import {
|
import {
|
||||||
@@ -9,67 +8,33 @@ import {
|
|||||||
SCRIPT,
|
SCRIPT,
|
||||||
SCRIPT_DURATION_MS,
|
SCRIPT_DURATION_MS,
|
||||||
type DemoMessage,
|
type DemoMessage,
|
||||||
type Peer,
|
|
||||||
type PeerStatus,
|
|
||||||
} from "./demo-dashboard-script";
|
} from "./demo-dashboard-script";
|
||||||
|
import { MeshStream, type StreamMessage, type StreamPeer } from "./mesh-stream";
|
||||||
|
|
||||||
const STATUS_DOT: Record<PeerStatus, string> = {
|
const toStreamMessage = (
|
||||||
idle: "bg-emerald-500",
|
m: DemoMessage,
|
||||||
working: "bg-[var(--cm-clay)] animate-pulse",
|
loopKey: number,
|
||||||
offline: "bg-[var(--cm-fg-tertiary)]",
|
): StreamMessage => ({
|
||||||
};
|
key: `${loopKey}-${m.t}`,
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
type: m.type,
|
||||||
|
text: m.text,
|
||||||
|
ciphertext: m.ciphertext,
|
||||||
|
});
|
||||||
|
|
||||||
const SURFACE_ICON: Record<Peer["surface"], React.ReactNode> = {
|
const STREAM_PEERS: StreamPeer[] = PEERS.map((p) => ({
|
||||||
terminal: (
|
id: p.id,
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
name: p.name,
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
|
status: p.status,
|
||||||
<path d="M6 9l3 3-3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
machine: p.machine,
|
||||||
</svg>
|
surface: p.surface,
|
||||||
),
|
}));
|
||||||
phone: (
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="7" y="2" width="10" height="20" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<circle cx="12" cy="18" r="1" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
slack: (
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="10" y="3" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<rect x="12" y="15" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<rect x="3" y="10" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<rect x="15" y="12" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_GLYPH = (type: DemoMessage["type"]) => {
|
|
||||||
if (type === "ask_mesh")
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-[4px] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-[var(--cm-clay)]">
|
|
||||||
⟐ broadcast
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
if (type === "self_nominate")
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-[4px] border border-emerald-500/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-emerald-500">
|
|
||||||
← hand-raise
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-[4px] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-[var(--cm-fg-secondary)]">
|
|
||||||
→ direct
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type VisibleMessage = DemoMessage & { seq: number };
|
|
||||||
|
|
||||||
export const DemoDashboard = () => {
|
export const DemoDashboard = () => {
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [playing, setPlaying] = useState(true);
|
const [playing, setPlaying] = useState(true);
|
||||||
const [focusedPeer, setFocusedPeer] = useState<string | null>(null);
|
|
||||||
const [loopCount, setLoopCount] = useState(0);
|
const [loopCount, setLoopCount] = useState(0);
|
||||||
const [hoveredMessage, setHoveredMessage] = useState<number | null>(null);
|
|
||||||
const startRef = useRef<number>(0);
|
const startRef = useRef<number>(0);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -99,19 +64,13 @@ export const DemoDashboard = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [playing, tick]);
|
}, [playing, tick]);
|
||||||
|
|
||||||
const visible = useMemo<VisibleMessage[]>(() => {
|
const messages = useMemo<StreamMessage[]>(
|
||||||
return SCRIPT.filter((m) => m.t <= elapsed).map((m, i) => ({
|
() =>
|
||||||
...m,
|
SCRIPT.filter((m) => m.t <= elapsed).map((m) =>
|
||||||
seq: loopCount * 100 + i,
|
toStreamMessage(m, loopCount),
|
||||||
}));
|
),
|
||||||
}, [elapsed, loopCount]);
|
[elapsed, loopCount],
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (!focusedPeer) return visible;
|
|
||||||
return visible.filter(
|
|
||||||
(m) => m.from === focusedPeer || m.to === focusedPeer,
|
|
||||||
);
|
);
|
||||||
}, [visible, focusedPeer]);
|
|
||||||
|
|
||||||
const handleRestart = () => {
|
const handleRestart = () => {
|
||||||
setElapsed(0);
|
setElapsed(0);
|
||||||
@@ -119,8 +78,29 @@ export const DemoDashboard = () => {
|
|||||||
setLoopCount((c) => c + 1);
|
setLoopCount((c) => c + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const peerName = (id: string) =>
|
const footer = (
|
||||||
PEERS.find((p) => p.id === id)?.name ?? id;
|
<>
|
||||||
|
<div
|
||||||
|
className="h-[2px] bg-[var(--cm-clay)] transition-[width] duration-[100ms] ease-linear"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (elapsed / SCRIPT_DURATION_MS) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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} / {SCRIPT.length} messages
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
loop #{loopCount + 1} · {Math.floor(elapsed / 1000)}s /{" "}
|
||||||
|
{Math.floor(SCRIPT_DURATION_MS / 1000)}s
|
||||||
|
</span>
|
||||||
|
<span>{playing ? "▶ playing" : "⏸ paused"}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -196,227 +176,14 @@ export const DemoDashboard = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* unused var to silence lint on LOOP_PAUSE_MS if dead-code elimination hits */}
|
||||||
<div className="grid grid-cols-[200px_220px_1fr] min-h-[480px]">
|
<span hidden>{LOOP_PAUSE_MS}</span>
|
||||||
{/* server sidebar */}
|
<MeshStream
|
||||||
<aside
|
peers={STREAM_PEERS}
|
||||||
className="hidden border-r border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/40 p-4 md:block"
|
messages={messages}
|
||||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
channelLabel="live-stream"
|
||||||
>
|
footer={footer}
|
||||||
<div
|
|
||||||
className="mb-3 text-[10px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
your meshes
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<li className="rounded-[var(--cm-radius-xs)] px-2.5 py-1.5 text-[13px] text-[var(--cm-fg-tertiary)] hover:bg-[var(--cm-bg)]">
|
|
||||||
smoke-test
|
|
||||||
</li>
|
|
||||||
<li className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)]/15 px-2.5 py-1.5 text-[13px] font-medium text-[var(--cm-clay)]">
|
|
||||||
{MESH_NAME}
|
|
||||||
</li>
|
|
||||||
<li className="rounded-[var(--cm-radius-xs)] px-2.5 py-1.5 text-[13px] text-[var(--cm-fg-tertiary)] hover:bg-[var(--cm-bg)]">
|
|
||||||
home-lab
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
className="mt-3 w-full rounded-[var(--cm-radius-xs)] border border-dashed border-[var(--cm-border)] px-2.5 py-1.5 text-left text-[12px] text-[var(--cm-fg-tertiary)] transition-colors hover:border-[var(--cm-fg-tertiary)] hover:text-[var(--cm-fg-secondary)]"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
+ join mesh
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* peers */}
|
|
||||||
<aside
|
|
||||||
className="border-r border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/20 p-4"
|
|
||||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="mb-3 flex items-center justify-between text-[10px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
<span>peers · {PEERS.filter((p) => p.status !== "offline").length} online</span>
|
|
||||||
{focusedPeer && (
|
|
||||||
<button
|
|
||||||
onClick={() => setFocusedPeer(null)}
|
|
||||||
className="text-[var(--cm-clay)] hover:underline"
|
|
||||||
aria-label="Clear filter"
|
|
||||||
>
|
|
||||||
clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{PEERS.map((p) => {
|
|
||||||
const active = focusedPeer === p.id;
|
|
||||||
return (
|
|
||||||
<li key={p.id}>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setFocusedPeer(active ? null : p.id)
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
"group flex w-full items-center gap-2.5 rounded-[var(--cm-radius-xs)] px-2 py-1.5 text-left transition-colors " +
|
|
||||||
(active
|
|
||||||
? "bg-[var(--cm-clay)]/15"
|
|
||||||
: "hover:bg-[var(--cm-bg)]")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"h-2 w-2 flex-shrink-0 rounded-full " +
|
|
||||||
STATUS_DOT[p.status]
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"truncate text-[13px] " +
|
|
||||||
(active
|
|
||||||
? "font-medium text-[var(--cm-clay)]"
|
|
||||||
: "text-[var(--cm-fg)]")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--cm-fg-tertiary)]">
|
|
||||||
{SURFACE_ICON[p.surface]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="truncate text-[10px] text-[var(--cm-fg-tertiary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
{p.machine}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* message stream */}
|
|
||||||
<div
|
|
||||||
className="relative flex flex-col"
|
|
||||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 border-b border-[var(--cm-border)] px-4 py-2.5"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
<span className="text-[var(--cm-clay)]">#</span>
|
|
||||||
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
|
|
||||||
live-stream
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
||||||
{focusedPeer
|
|
||||||
? `filtered: ${peerName(focusedPeer)}`
|
|
||||||
: "all peers · E2E encrypted"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ol className="flex-1 space-y-3 overflow-y-auto p-4">
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{filtered.map((m) => (
|
|
||||||
<motion.li
|
|
||||||
key={`${m.seq}-${m.t}`}
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.4,
|
|
||||||
ease: [0.22, 0.61, 0.36, 1],
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredMessage(m.seq)}
|
|
||||||
onMouseLeave={() => setHoveredMessage(null)}
|
|
||||||
className="group relative"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0 pt-0.5">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--cm-bg-elevated)] text-[10px] font-medium uppercase text-[var(--cm-fg-secondary)]">
|
|
||||||
{peerName(m.from).slice(0, 2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
|
|
||||||
{peerName(m.from)}
|
|
||||||
</span>
|
|
||||||
{m.to && (
|
|
||||||
<>
|
|
||||||
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-[12px] text-[var(--cm-fg-secondary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
{m.to.startsWith("tag:")
|
|
||||||
? m.to
|
|
||||||
: peerName(m.to)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{TYPE_GLYPH(m.type)}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="text-[14px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
||||||
>
|
|
||||||
{m.text}
|
|
||||||
</p>
|
|
||||||
{hoveredMessage === m.seq && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 4 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mt-2 rounded-[var(--cm-radius-xs)] border border-dashed border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)]/50 px-3 py-2"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
<div className="mb-1 text-[9px] uppercase tracking-wider text-[var(--cm-clay)]">
|
|
||||||
broker sees only this
|
|
||||||
</div>
|
|
||||||
<code className="block break-all text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
||||||
{m.ciphertext}
|
|
||||||
</code>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.li>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</ol>
|
|
||||||
{/* progress bar */}
|
|
||||||
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30">
|
|
||||||
<div
|
|
||||||
className="h-[2px] bg-[var(--cm-clay)] transition-[width] duration-[100ms] ease-linear"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, (elapsed / SCRIPT_DURATION_MS) * 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
{visible.length} / {SCRIPT.length} messages
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
loop #{loopCount + 1} · {Math.floor(elapsed / 1000)}s /{" "}
|
|
||||||
{Math.floor(SCRIPT_DURATION_MS / 1000)}s
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{playing ? "▶ playing" : "⏸ paused"}
|
|
||||||
{focusedPeer && ` · filtered`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
@@ -429,9 +196,6 @@ export const DemoDashboard = () => {
|
|||||||
broker routes ciphertext, never plaintext
|
broker routes ciphertext, never plaintext
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* prevent eslint exhaustive-deps hook warning from dead var */}
|
|
||||||
{loopCount < -1 && <span />}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
324
apps/web/src/modules/marketing/home/mesh-stream.tsx
Normal file
324
apps/web/src/modules/marketing/home/mesh-stream.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export type PeerStatus = "idle" | "working" | "dnd" | "offline";
|
||||||
|
export type MessageType = "ask_mesh" | "self_nominate" | "direct" | "broadcast";
|
||||||
|
|
||||||
|
export interface StreamPeer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: PeerStatus;
|
||||||
|
/** e.g. "macOS · payments-api" or "iOS · push-relay" */
|
||||||
|
machine: string;
|
||||||
|
surface?: "terminal" | "phone" | "slack";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamMessage {
|
||||||
|
/** stable unique key */
|
||||||
|
key: string;
|
||||||
|
/** peer id or display name */
|
||||||
|
from: string;
|
||||||
|
/** peer id, "tag:xxx", "*", or null (self-nominate) */
|
||||||
|
to: string | null;
|
||||||
|
type: MessageType;
|
||||||
|
/** plaintext for demo, undefined for live (broker never sees it) */
|
||||||
|
text?: string;
|
||||||
|
/** truncated base64url — what the broker actually sees */
|
||||||
|
ciphertext: string;
|
||||||
|
/** absolute time, optional — used by live dashboard */
|
||||||
|
createdAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_DOT: Record<PeerStatus, string> = {
|
||||||
|
idle: "bg-emerald-500",
|
||||||
|
working: "bg-[var(--cm-clay)] animate-pulse",
|
||||||
|
dnd: "bg-[#c46686]",
|
||||||
|
offline: "bg-[var(--cm-fg-tertiary)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_CHIP: Record<MessageType, { label: string; className: string }> = {
|
||||||
|
ask_mesh: {
|
||||||
|
label: "⟐ broadcast",
|
||||||
|
className:
|
||||||
|
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-clay)]",
|
||||||
|
},
|
||||||
|
broadcast: {
|
||||||
|
label: "⟐ broadcast",
|
||||||
|
className:
|
||||||
|
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-clay)]",
|
||||||
|
},
|
||||||
|
self_nominate: {
|
||||||
|
label: "← hand-raise",
|
||||||
|
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500",
|
||||||
|
},
|
||||||
|
direct: {
|
||||||
|
label: "→ direct",
|
||||||
|
className:
|
||||||
|
"border-[var(--cm-border)] bg-[var(--cm-bg)] text-[var(--cm-fg-secondary)]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const surfaceGlyph = (s?: StreamPeer["surface"]) => {
|
||||||
|
if (s === "phone")
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="7" y="2" width="10" height="20" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<circle cx="12" cy="18" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
if (s === "slack")
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="10" y="3" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<rect x="12" y="15" width="2" height="6" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<rect x="3" y="10" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<rect x="15" y="12" width="6" height="2" rx="1" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M6 9l3 3-3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveName = (id: string, peers: StreamPeer[]) =>
|
||||||
|
peers.find((p) => p.id === id)?.name ?? id;
|
||||||
|
|
||||||
|
export interface MeshStreamProps {
|
||||||
|
peers: StreamPeer[];
|
||||||
|
messages: StreamMessage[];
|
||||||
|
/** text shown in stream header, right of # */
|
||||||
|
channelLabel?: string;
|
||||||
|
/** override the "N peers online" hint */
|
||||||
|
peersHint?: string;
|
||||||
|
/** override empty-state message */
|
||||||
|
emptyLabel?: string;
|
||||||
|
/** footer content (stats / progress bar / timers) */
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MeshStream = ({
|
||||||
|
peers,
|
||||||
|
messages,
|
||||||
|
channelLabel = "live-stream",
|
||||||
|
peersHint,
|
||||||
|
emptyLabel = "Waiting for messages…",
|
||||||
|
footer,
|
||||||
|
}: MeshStreamProps) => {
|
||||||
|
const [focusedPeer, setFocusedPeer] = useState<string | null>(null);
|
||||||
|
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onlineCount = peers.filter((p) => p.status !== "offline").length;
|
||||||
|
const filtered = focusedPeer
|
||||||
|
? messages.filter((m) => m.from === focusedPeer || m.to === focusedPeer)
|
||||||
|
: messages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[480px] grid-cols-1 md:grid-cols-[220px_1fr]">
|
||||||
|
{/* peers sidebar */}
|
||||||
|
<aside
|
||||||
|
className="border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/20 p-4 md:border-b-0 md:border-r"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mb-3 flex items-center justify-between text-[10px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span>{peersHint ?? `peers · ${onlineCount} online`}</span>
|
||||||
|
{focusedPeer && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusedPeer(null)}
|
||||||
|
className="text-[var(--cm-clay)] hover:underline"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{peers.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
no peers online
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{peers.map((p) => {
|
||||||
|
const active = focusedPeer === p.id;
|
||||||
|
return (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusedPeer(active ? null : p.id)}
|
||||||
|
className={
|
||||||
|
"group flex w-full items-center gap-2.5 rounded-[var(--cm-radius-xs)] px-2 py-1.5 text-left transition-colors " +
|
||||||
|
(active
|
||||||
|
? "bg-[var(--cm-clay)]/15"
|
||||||
|
: "hover:bg-[var(--cm-bg)]")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"h-2 w-2 flex-shrink-0 rounded-full " +
|
||||||
|
STATUS_DOT[p.status]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"truncate text-[13px] " +
|
||||||
|
(active
|
||||||
|
? "font-medium text-[var(--cm-clay)]"
|
||||||
|
: "text-[var(--cm-fg)]")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--cm-fg-tertiary)]">
|
||||||
|
{surfaceGlyph(p.surface)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate text-[10px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{p.machine}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* message stream */}
|
||||||
|
<div
|
||||||
|
className="relative flex flex-col"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 border-b border-[var(--cm-border)] px-4 py-2.5"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span className="text-[var(--cm-clay)]">#</span>
|
||||||
|
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
|
||||||
|
{channelLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||||
|
{focusedPeer
|
||||||
|
? `filtered: ${resolveName(focusedPeer, peers)}`
|
||||||
|
: "all peers · E2E encrypted"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ol className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<li
|
||||||
|
className="py-8 text-center text-[13px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{emptyLabel}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{filtered.map((m) => (
|
||||||
|
<motion.li
|
||||||
|
key={m.key}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.22, 0.61, 0.36, 1],
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredKey(m.key)}
|
||||||
|
onMouseLeave={() => setHoveredKey(null)}
|
||||||
|
className="group relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[var(--cm-bg-elevated)] text-[10px] font-medium uppercase text-[var(--cm-fg-secondary)]">
|
||||||
|
{resolveName(m.from, peers).slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-[var(--cm-fg)]">
|
||||||
|
{resolveName(m.from, peers)}
|
||||||
|
</span>
|
||||||
|
{m.to && (
|
||||||
|
<>
|
||||||
|
<span className="text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[12px] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{m.to.startsWith("tag:") || m.to === "*"
|
||||||
|
? m.to
|
||||||
|
: resolveName(m.to, peers)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"inline-flex items-center gap-1 rounded-[4px] border px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider " +
|
||||||
|
TYPE_CHIP[m.type].className
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TYPE_CHIP[m.type].label}
|
||||||
|
</span>
|
||||||
|
{m.createdAt && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{m.createdAt.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{m.text && (
|
||||||
|
<p
|
||||||
|
className="text-[14px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hoveredKey === m.key && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mt-2 rounded-[var(--cm-radius-xs)] border border-dashed border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)]/50 px-3 py-2"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<div className="mb-1 text-[9px] uppercase tracking-wider text-[var(--cm-clay)]">
|
||||||
|
broker sees only this
|
||||||
|
</div>
|
||||||
|
<code className="block break-all text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||||
|
{m.ciphertext}
|
||||||
|
{m.ciphertext && !m.text && "…"}
|
||||||
|
</code>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</ol>
|
||||||
|
{footer && (
|
||||||
|
<div className="border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
120
apps/web/src/modules/mesh/live-stream-panel.tsx
Normal file
120
apps/web/src/modules/mesh/live-stream-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,14 @@ import {
|
|||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "@turbostarter/db";
|
} from "@turbostarter/db";
|
||||||
import { auditLog, invite, mesh, meshMember } from "@turbostarter/db/schema";
|
import {
|
||||||
|
auditLog,
|
||||||
|
invite,
|
||||||
|
mesh,
|
||||||
|
meshMember,
|
||||||
|
messageQueue,
|
||||||
|
presence,
|
||||||
|
} from "@turbostarter/db/schema";
|
||||||
import { db } from "@turbostarter/db/server";
|
import { db } from "@turbostarter/db/server";
|
||||||
|
|
||||||
import type { GetMyMeshesInput } from "../../schema";
|
import type { GetMyMeshesInput } from "../../schema";
|
||||||
@@ -163,6 +170,100 @@ export const getMyMeshById = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live mesh stream — presences + recent message envelopes (metadata only) +
|
||||||
|
* recent audit events. Polled every 3-5s by the live dashboard. Authz:
|
||||||
|
* caller must own OR be a non-revoked member of the mesh.
|
||||||
|
*
|
||||||
|
* Envelopes expose a 24-char ciphertext preview so the UI can show
|
||||||
|
* "broker sees: <blob>" truthfully — this IS what the broker sees.
|
||||||
|
* Plaintext, nonces, full ciphertext are NEVER returned from here.
|
||||||
|
*/
|
||||||
|
export const getMyMeshStream = async ({
|
||||||
|
userId,
|
||||||
|
meshId,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
meshId: string;
|
||||||
|
}) => {
|
||||||
|
// Authz check — same pattern as getMyMeshById
|
||||||
|
const [m] = await db
|
||||||
|
.select({ ownerUserId: mesh.ownerUserId })
|
||||||
|
.from(mesh)
|
||||||
|
.where(eq(mesh.id, meshId))
|
||||||
|
.limit(1);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const isOwner = m.ownerUserId === userId;
|
||||||
|
if (!isOwner) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select({ id: meshMember.id })
|
||||||
|
.from(meshMember)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(meshMember.meshId, meshId),
|
||||||
|
eq(meshMember.userId, userId),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!membership) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presences = await db
|
||||||
|
.select({
|
||||||
|
id: presence.id,
|
||||||
|
memberId: presence.memberId,
|
||||||
|
displayName: meshMember.displayName,
|
||||||
|
sessionId: presence.sessionId,
|
||||||
|
pid: presence.pid,
|
||||||
|
cwd: presence.cwd,
|
||||||
|
status: presence.status,
|
||||||
|
statusSource: presence.statusSource,
|
||||||
|
statusUpdatedAt: presence.statusUpdatedAt,
|
||||||
|
lastPingAt: presence.lastPingAt,
|
||||||
|
disconnectedAt: presence.disconnectedAt,
|
||||||
|
})
|
||||||
|
.from(presence)
|
||||||
|
.leftJoin(meshMember, eq(presence.memberId, meshMember.id))
|
||||||
|
.where(and(eq(meshMember.meshId, meshId), isNull(presence.disconnectedAt)))
|
||||||
|
.orderBy(desc(presence.lastPingAt))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
const envelopes = await db
|
||||||
|
.select({
|
||||||
|
id: messageQueue.id,
|
||||||
|
senderMemberId: messageQueue.senderMemberId,
|
||||||
|
senderDisplayName: meshMember.displayName,
|
||||||
|
targetSpec: messageQueue.targetSpec,
|
||||||
|
priority: messageQueue.priority,
|
||||||
|
ciphertextPreview: sql<string>`LEFT(${messageQueue.ciphertext}, 24)`,
|
||||||
|
size: sql<number>`OCTET_LENGTH(${messageQueue.ciphertext})`,
|
||||||
|
createdAt: messageQueue.createdAt,
|
||||||
|
deliveredAt: messageQueue.deliveredAt,
|
||||||
|
})
|
||||||
|
.from(messageQueue)
|
||||||
|
.leftJoin(meshMember, eq(messageQueue.senderMemberId, meshMember.id))
|
||||||
|
.where(eq(messageQueue.meshId, meshId))
|
||||||
|
.orderBy(desc(messageQueue.createdAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
const auditEvents = await db
|
||||||
|
.select({
|
||||||
|
id: auditLog.id,
|
||||||
|
eventType: auditLog.eventType,
|
||||||
|
actorPeerId: auditLog.actorPeerId,
|
||||||
|
targetPeerId: auditLog.targetPeerId,
|
||||||
|
createdAt: auditLog.createdAt,
|
||||||
|
})
|
||||||
|
.from(auditLog)
|
||||||
|
.where(eq(auditLog.meshId, meshId))
|
||||||
|
.orderBy(desc(auditLog.createdAt))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
return { presences, envelopes, auditEvents };
|
||||||
|
};
|
||||||
|
|
||||||
export const getMyExport = async ({ userId }: { userId: string }) => {
|
export const getMyExport = async ({ userId }: { userId: string }) => {
|
||||||
const meshesOwned = await db
|
const meshesOwned = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getMyExport,
|
getMyExport,
|
||||||
getMyInvitesSent,
|
getMyInvitesSent,
|
||||||
getMyMeshById,
|
getMyMeshById,
|
||||||
|
getMyMeshStream,
|
||||||
getMyMeshes,
|
getMyMeshes,
|
||||||
} from "./queries";
|
} from "./queries";
|
||||||
|
|
||||||
@@ -47,6 +48,15 @@ export const myRouter = new Hono<Env>()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.get("/meshes/:id/stream", async (c) => {
|
||||||
|
const user = c.var.user;
|
||||||
|
return c.json(
|
||||||
|
(await getMyMeshStream({
|
||||||
|
userId: user.id,
|
||||||
|
meshId: c.req.param("id"),
|
||||||
|
})) ?? { presences: [], envelopes: [], auditEvents: [] },
|
||||||
|
);
|
||||||
|
})
|
||||||
.get("/meshes/:id", async (c) => {
|
.get("/meshes/:id", async (c) => {
|
||||||
const user = c.var.user;
|
const user = c.var.user;
|
||||||
return c.json(
|
return c.json(
|
||||||
|
|||||||
@@ -139,6 +139,53 @@ export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema
|
|||||||
// List my invites (pending + sent)
|
// List my invites (pending + sent)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Live mesh stream (presences + recent envelopes + recent audit events)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const getMyMeshStreamResponseSchema = z.object({
|
||||||
|
presences: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
memberId: z.string(),
|
||||||
|
displayName: z.string().nullable(),
|
||||||
|
sessionId: z.string(),
|
||||||
|
pid: z.number(),
|
||||||
|
cwd: z.string(),
|
||||||
|
status: z.enum(["idle", "working", "dnd"]),
|
||||||
|
statusSource: z.enum(["hook", "manual", "jsonl"]),
|
||||||
|
statusUpdatedAt: z.coerce.date(),
|
||||||
|
lastPingAt: z.coerce.date(),
|
||||||
|
disconnectedAt: z.coerce.date().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
envelopes: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
senderMemberId: z.string(),
|
||||||
|
senderDisplayName: z.string().nullable(),
|
||||||
|
targetSpec: z.string(),
|
||||||
|
priority: z.enum(["now", "next", "low"]),
|
||||||
|
ciphertextPreview: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
deliveredAt: z.coerce.date().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
auditEvents: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
eventType: z.string(),
|
||||||
|
actorPeerId: z.string().nullable(),
|
||||||
|
targetPeerId: z.string().nullable(),
|
||||||
|
createdAt: z.coerce.date(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type GetMyMeshStreamResponse = z.infer<
|
||||||
|
typeof getMyMeshStreamResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const getMyInvitesResponseSchema = z.object({
|
export const getMyInvitesResponseSchema = z.object({
|
||||||
sent: z.array(
|
sent: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user