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}
|
||||
</DashboardHeaderDescription>
|
||||
</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
|
||||
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
|
||||
className={buttonVariants({ variant: "default" })}
|
||||
@@ -62,6 +70,7 @@ export default async function MeshPage({
|
||||
Generate invite link
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<div className="grid gap-8">
|
||||
|
||||
@@ -95,6 +95,7 @@ const pathsConfig = {
|
||||
new: `${DASHBOARD_PREFIX}/meshes/new`,
|
||||
mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`,
|
||||
invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`,
|
||||
live: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/live`,
|
||||
},
|
||||
invites: `${DASHBOARD_PREFIX}/invites`,
|
||||
settings: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Reveal, SectionIcon } from "./_reveal";
|
||||
import {
|
||||
@@ -9,67 +8,33 @@ import {
|
||||
SCRIPT,
|
||||
SCRIPT_DURATION_MS,
|
||||
type DemoMessage,
|
||||
type Peer,
|
||||
type PeerStatus,
|
||||
} from "./demo-dashboard-script";
|
||||
import { MeshStream, type StreamMessage, type StreamPeer } from "./mesh-stream";
|
||||
|
||||
const STATUS_DOT: Record<PeerStatus, string> = {
|
||||
idle: "bg-emerald-500",
|
||||
working: "bg-[var(--cm-clay)] animate-pulse",
|
||||
offline: "bg-[var(--cm-fg-tertiary)]",
|
||||
};
|
||||
const toStreamMessage = (
|
||||
m: DemoMessage,
|
||||
loopKey: number,
|
||||
): 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> = {
|
||||
terminal: (
|
||||
<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>
|
||||
),
|
||||
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 };
|
||||
const STREAM_PEERS: StreamPeer[] = PEERS.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
machine: p.machine,
|
||||
surface: p.surface,
|
||||
}));
|
||||
|
||||
export const DemoDashboard = () => {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
const [focusedPeer, setFocusedPeer] = useState<string | null>(null);
|
||||
const [loopCount, setLoopCount] = useState(0);
|
||||
const [hoveredMessage, setHoveredMessage] = useState<number | null>(null);
|
||||
const startRef = useRef<number>(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
@@ -99,19 +64,13 @@ export const DemoDashboard = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playing, tick]);
|
||||
|
||||
const visible = useMemo<VisibleMessage[]>(() => {
|
||||
return SCRIPT.filter((m) => m.t <= elapsed).map((m, i) => ({
|
||||
...m,
|
||||
seq: loopCount * 100 + i,
|
||||
}));
|
||||
}, [elapsed, loopCount]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!focusedPeer) return visible;
|
||||
return visible.filter(
|
||||
(m) => m.from === focusedPeer || m.to === focusedPeer,
|
||||
const messages = useMemo<StreamMessage[]>(
|
||||
() =>
|
||||
SCRIPT.filter((m) => m.t <= elapsed).map((m) =>
|
||||
toStreamMessage(m, loopCount),
|
||||
),
|
||||
[elapsed, loopCount],
|
||||
);
|
||||
}, [visible, focusedPeer]);
|
||||
|
||||
const handleRestart = () => {
|
||||
setElapsed(0);
|
||||
@@ -119,8 +78,29 @@ export const DemoDashboard = () => {
|
||||
setLoopCount((c) => c + 1);
|
||||
};
|
||||
|
||||
const peerName = (id: string) =>
|
||||
PEERS.find((p) => p.id === id)?.name ?? id;
|
||||
const footer = (
|
||||
<>
|
||||
<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 (
|
||||
<section
|
||||
@@ -196,227 +176,14 @@ export const DemoDashboard = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[200px_220px_1fr] min-h-[480px]">
|
||||
{/* server sidebar */}
|
||||
<aside
|
||||
className="hidden border-r border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/40 p-4 md:block"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<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]
|
||||
}
|
||||
{/* unused var to silence lint on LOOP_PAUSE_MS if dead-code elimination hits */}
|
||||
<span hidden>{LOOP_PAUSE_MS}</span>
|
||||
<MeshStream
|
||||
peers={STREAM_PEERS}
|
||||
messages={messages}
|
||||
channelLabel="live-stream"
|
||||
footer={footer}
|
||||
/>
|
||||
<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>
|
||||
</Reveal>
|
||||
|
||||
@@ -429,9 +196,6 @@ export const DemoDashboard = () => {
|
||||
broker routes ciphertext, never plaintext
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* prevent eslint exhaustive-deps hook warning from dead var */}
|
||||
{loopCount < -1 && <span />}
|
||||
</div>
|
||||
</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,
|
||||
sql,
|
||||
} 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 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 }) => {
|
||||
const meshesOwned = await db
|
||||
.select({
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getMyExport,
|
||||
getMyInvitesSent,
|
||||
getMyMeshById,
|
||||
getMyMeshStream,
|
||||
getMyMeshes,
|
||||
} 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) => {
|
||||
const user = c.var.user;
|
||||
return c.json(
|
||||
|
||||
@@ -139,6 +139,53 @@ export type CreateMyInviteResponse = z.infer<typeof createMyInviteResponseSchema
|
||||
// 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({
|
||||
sent: z.array(
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user