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

@@ -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({

View File

@@ -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(

View File

@@ -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({