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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user