From 0664180a549ba0a5b61e6df25d6dc7f91103688b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:31:15 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20universe=20dashboard=20=E2=80=94?= =?UTF-8?q?=20meshes=20+=20incoming=20invitations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /dashboard landing that surfaces meshes and invitations-to-you in one view. Replaces the simple mesh grid at /dashboard (preserved at /dashboard/legacy). Backend additions: - GET /api/my/invites/incoming — pending_invite rows addressed to the authed user's email, joined with invite for role + expiry and user/mesh for display. Unaccepted + unrevoked + unexpired only. - DELETE /api/my/invites/incoming/:id — dismiss a pending invite (revokes the pending_invite row only; underlying invite code stays valid so the inviter can re-send). Web additions (all under apps/web/src/modules/dashboard/universe/): - welcome.tsx — editorial serif header with mesh + invite counts - invitations.tsx — client card with Accept (→ /i/:code claim flow) and optimistic Decline - meshes-grid.tsx — hero card + compact grid, linked to mesh detail - reveal.tsx — fade-up motion matching marketing _reveal.tsx Styling uses the existing claudemesh design tokens (--cm-clay, --cm-bg-elevated, Anthropic Sans/Serif/Mono) — nothing redefined. Onboarding redirect (0 meshes → /meshes/new?onboarding=1) preserved, now gated on 0 invitations too so users with pending invites still land on the dashboard. Sidebar icon switched to Atom for the "universe" concept. Standalone prototype saved at prototypes/live-dashboard.html for reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/[locale]/dashboard/(user)/layout.tsx | 2 +- .../[locale]/dashboard/(user)/legacy/page.tsx | 79 ++ .../app/[locale]/dashboard/(user)/page.tsx | 117 +- apps/web/src/config/paths.ts | 1 + .../dashboard/universe/invitations.tsx | 152 +++ .../dashboard/universe/meshes-grid.tsx | 210 ++++ .../src/modules/dashboard/universe/reveal.tsx | 38 + .../modules/dashboard/universe/welcome.tsx | 77 ++ packages/api/src/modules/mesh/mutations.ts | 32 + packages/api/src/modules/mesh/queries.ts | 45 + packages/api/src/modules/mesh/router.ts | 25 + packages/api/src/schema/mesh-user.ts | 21 + prototypes/live-dashboard.html | 1001 +++++++++++++++++ 13 files changed, 1734 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx create mode 100644 apps/web/src/modules/dashboard/universe/invitations.tsx create mode 100644 apps/web/src/modules/dashboard/universe/meshes-grid.tsx create mode 100644 apps/web/src/modules/dashboard/universe/reveal.tsx create mode 100644 apps/web/src/modules/dashboard/universe/welcome.tsx create mode 100644 prototypes/live-dashboard.html diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx index 3f2ec2b..ca6abb1 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -18,7 +18,7 @@ const menu = [ { title: "dashboard", href: pathsConfig.dashboard.user.index, - icon: Icons.Home, + icon: Icons.Atom, }, { title: "meshes", diff --git a/apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx new file mode 100644 index 0000000..1eab253 --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx @@ -0,0 +1,79 @@ +import Link from "next/link"; + +import { getMyMeshesResponseSchema } 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"; + +export const generateMetadata = getMetadata({ + title: "Legacy dashboard", + description: "Simple legacy view of your meshes.", +}); + +export default async function LegacyDashboardHomePage() { + const { data } = await handle(api.my.meshes.$get, { + schema: getMyMeshesResponseSchema, + })({ + query: { page: "1", perPage: "6", sort: JSON.stringify([]) }, + }); + + return ( +
+
+

Your meshes

+

+ Open one to see its members, generate invites, or share it. +

+
+
+ {data.map((m) => ( + +
+
+

+ {m.name} +

+

+ {m.slug} +

+
+ + {m.isOwner ? "owner" : m.myRole} + +
+
+ + {m.tier} + + + {m.memberCount} {m.memberCount === 1 ? "member" : "members"} + +
+ + ))} +
+
+ + All meshes + + + New mesh + +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx index bf095da..394bd7a 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx @@ -1,84 +1,71 @@ -import Link from "next/link"; import { redirect } from "next/navigation"; -import { getMyMeshesResponseSchema } from "@turbostarter/api/schema"; +import { + getMyInvitesIncomingResponseSchema, + getMyMeshesResponseSchema, +} 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 { appConfig } from "~/config/app"; import { pathsConfig } from "~/config/paths"; import { api } from "~/lib/api/server"; +import { getSession } from "~/lib/auth/server"; import { getMetadata } from "~/lib/metadata"; +import { InvitationsSection } from "~/modules/dashboard/universe/invitations"; +import { MeshesGrid } from "~/modules/dashboard/universe/meshes-grid"; +import { UniverseWelcome } from "~/modules/dashboard/universe/welcome"; export const generateMetadata = getMetadata({ - title: "Dashboard", - description: "Your meshes.", + title: "Your universe", + description: "Meshes, peers, and invitations — all in one place.", }); -export default async function DashboardHomePage() { - const { data } = await handle(api.my.meshes.$get, { - schema: getMyMeshesResponseSchema, - })({ - query: { page: "1", perPage: "6", sort: JSON.stringify([]) }, - }); +export default async function UniversePage() { + const { user } = await getSession(); + const name = user?.name ?? "there"; - // First-time onboarding: 0-mesh user → bounce to create - if (data.length === 0) { + const [{ data: meshes }, { incoming }] = await Promise.all([ + handle(api.my.meshes.$get, { + schema: getMyMeshesResponseSchema, + })({ + query: { page: "1", perPage: "50", sort: JSON.stringify([]) }, + }), + handle(api.my.invites.incoming.$get, { + schema: getMyInvitesIncomingResponseSchema, + })(), + ]); + + const activeMeshes = meshes.filter((m) => !m.archivedAt); + + // First-time onboarding: brand-new user with nothing waiting → create flow. + if (activeMeshes.length === 0 && incoming.length === 0) { redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`); } return ( -
-
-

Your meshes

-

- Open one to see its members, generate invites, or share it. -

-
-
- {data.map((m) => ( - -
-
-

- {m.name} -

-

- {m.slug} -

-
- - {m.isOwner ? "owner" : m.myRole} - -
-
- - {m.tier} - - - {m.memberCount} {m.memberCount === 1 ? "member" : "members"} - -
- - ))} -
-
- - All meshes - - - New mesh - +
+ {/* Subtle radial backdrop, matching marketing hero */} +
+
+ + + + +
); diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index 4ac18fc..0549daf 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -90,6 +90,7 @@ const pathsConfig = { dashboard: { user: { index: DASHBOARD_PREFIX, + legacy: `${DASHBOARD_PREFIX}/legacy`, ai: `${DASHBOARD_PREFIX}/ai`, vocabulary: `${DASHBOARD_PREFIX}/vocabulary`, meshes: { diff --git a/apps/web/src/modules/dashboard/universe/invitations.tsx b/apps/web/src/modules/dashboard/universe/invitations.tsx new file mode 100644 index 0000000..758ae2d --- /dev/null +++ b/apps/web/src/modules/dashboard/universe/invitations.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; + +import { Reveal } from "./reveal"; + +interface IncomingInvite { + id: string; + meshId: string; + meshName: string | null; + meshSlug: string | null; + code: string; + role: "admin" | "member" | null; + expiresAt: string | Date | null; + sentAt: string | Date; + inviterName: string | null; + inviterEmail: string | null; + memberCount: number; +} + +type CardStatus = "idle" | "declining" | "declined"; + +const formatExpiry = (d: string | Date | null): string => { + if (!d) return "NO EXPIRY"; + const date = typeof d === "string" ? new Date(d) : d; + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return "EXPIRED"; + const h = Math.floor(diffMs / 36e5); + const days = Math.floor(h / 24); + const hoursRem = h % 24; + if (days > 0) return `EXPIRES IN ${days}D ${hoursRem}H`; + return `EXPIRES IN ${h}H`; +}; + +export const InvitationsSection = ({ + incoming, + appBaseUrl, +}: { + incoming: IncomingInvite[]; + appBaseUrl: string; +}) => { + const [dismissed, setDismissed] = useState>({}); + + const visible = incoming.filter((i) => dismissed[i.id] !== "declined"); + + if (visible.length === 0) return null; + + const decline = async (id: string) => { + setDismissed((s) => ({ ...s, [id]: "declining" })); + try { + const res = await fetch(`/api/my/invites/incoming/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(await res.text()); + setDismissed((s) => ({ ...s, [id]: "declined" })); + } catch { + setDismissed((s) => ({ ...s, [id]: "idle" })); + } + }; + + return ( +
+ +
+

+ Invitations waiting +

+ + {visible.length} pending + +
+
+ +
+ {visible.map((inv, idx) => { + const status = dismissed[inv.id] ?? "idle"; + const inviterLabel = + inv.inviterName ?? inv.inviterEmail ?? "someone"; + const joinHref = `${appBaseUrl}/i/${inv.code}`; + + return ( + +
+ + +
+ From ·{" "} + + {inviterLabel} + +
+ +

+ Join{" "} + + {inv.meshName ?? inv.meshSlug ?? "a mesh"} + +

+ +

+ {inv.memberCount}{" "} + {inv.memberCount === 1 ? "member" : "members"} · you’d join as{" "} + + {inv.role ?? "member"} + +

+ +
+ + Accept + + + + {formatExpiry(inv.expiresAt)} + +
+
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/web/src/modules/dashboard/universe/meshes-grid.tsx b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx new file mode 100644 index 0000000..c9c73e2 --- /dev/null +++ b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx @@ -0,0 +1,210 @@ +import Link from "next/link"; + +import { pathsConfig } from "~/config/paths"; + +import { Reveal } from "./reveal"; + +interface MeshSummary { + id: string; + name: string; + slug: string; + tier: "free" | "pro" | "team" | "enterprise"; + myRole: "admin" | "member"; + isOwner: boolean; + memberCount: number; + archivedAt: Date | string | null; +} + +const MAX_CHIPS = 6; + +/** + * Compact member-count chips. Real per-session live status would require + * polling /stream for each mesh — we show the structure here and defer the + * live overlay to the per-mesh live page. + */ +const MemberChips = ({ count }: { count: number }) => { + if (count === 0) { + return ( + + + empty + + ); + } + const shown = Math.min(count, MAX_CHIPS); + const extra = count - shown; + return ( + <> + {Array.from({ length: shown }).map((_, i) => ( + + + member + + ))} + {extra > 0 ? ( + + +{extra} + + ) : null} + + ); +}; + +const roleClass = (isOwner: boolean, role: string) => { + if (isOwner) return "text-[var(--cm-clay)] border-[rgba(217,119,87,0.4)]"; + if (role === "admin") return "text-[var(--cm-cactus)] border-[rgba(188,209,202,0.4)]"; + return "text-[var(--cm-fg-secondary)]"; +}; + +const MeshCard = ({ + mesh, + size = "compact", +}: { + mesh: MeshSummary; + size?: "hero" | "compact"; +}) => { + const isHero = size === "hero"; + const href = pathsConfig.dashboard.user.meshes.mesh(mesh.id); + + return ( + + {isHero ? ( + + ) : null} + +
+
+

+ {isHero ? ( + {mesh.name} + ) : ( + mesh.name + )} +

+

+ {mesh.slug} + {isHero ? ` · id ${mesh.id.slice(0, 8)}…` : ""} +

+
+ + {mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole} + +
+ +
+ +
+ +
+ 0 ? "text-[var(--cm-cactus)]" : ""}> + {mesh.memberCount} {mesh.memberCount === 1 ? "MEMBER" : "MEMBERS"} + {" · "} + {mesh.tier} + + + open → + +
+ + ); +}; + +export const MeshesGrid = ({ meshes }: { meshes: MeshSummary[] }) => { + if (meshes.length === 0) { + return ( +
+
+

+ You haven’t joined any meshes yet. +

+ + Create your first mesh + +
+
+ ); + } + + const [hero, ...rest] = meshes; + const heroMesh = hero!; + + return ( +
+ +
+

+ Your meshes +

+ + + New mesh + +
+
+ +
+ + + + +
+ {rest.slice(0, 2).map((m, i) => ( + + + + ))} +
+ + {rest.length > 2 ? ( +
+ {rest.slice(2).map((m, i) => ( + + + + ))} +
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/src/modules/dashboard/universe/reveal.tsx b/apps/web/src/modules/dashboard/universe/reveal.tsx new file mode 100644 index 0000000..3629246 --- /dev/null +++ b/apps/web/src/modules/dashboard/universe/reveal.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { motion, type Variants } from "motion/react"; +import type { ReactNode } from "react"; + +const fade: Variants = { + hidden: { opacity: 0, y: 20, filter: "blur(4px)" }, + visible: (i: number = 0) => ({ + opacity: 1, + y: 0, + filter: "blur(0px)", + transition: { + duration: 0.7, + ease: [0.22, 0.61, 0.36, 1], + delay: i * 0.08, + }, + }), +}; + +export const Reveal = ({ + children, + delay = 0, + className, +}: { + children: ReactNode; + delay?: number; + className?: string; +}) => ( + + {children} + +); diff --git a/apps/web/src/modules/dashboard/universe/welcome.tsx b/apps/web/src/modules/dashboard/universe/welcome.tsx new file mode 100644 index 0000000..9410ae8 --- /dev/null +++ b/apps/web/src/modules/dashboard/universe/welcome.tsx @@ -0,0 +1,77 @@ +import { Reveal } from "./reveal"; + +interface WelcomeProps { + name: string; + meshCount: number; + inviteCount: number; +} + +export const UniverseWelcome = ({ name, meshCount, inviteCount }: WelcomeProps) => { + const inviteLine = + inviteCount === 0 + ? null + : inviteCount === 1 + ? "1 invitation" + : `${inviteCount} invitations`; + + const firstName = name.split(" ")[0] ?? name; + + return ( +
+
+ +

+ Welcome back,{" "} + {firstName}. +
+ Your universe is{" "} + active. +

+
+ + +

+ You own or belong to{" "} + + {meshCount} {meshCount === 1 ? "mesh" : "meshes"} + + {inviteLine ? ( + <> + {" "}— and{" "} + + {inviteLine} + {" "} + waiting for an answer. + + ) : ( + "." + )} +

+
+
+ + +
+ + {meshCount} + / {meshCount + inviteCount} + + + + meshes · your reach + + updated just now +
+
+
+ ); +}; diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index 546fedf..15dcaf6 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -164,6 +164,38 @@ export const archiveMyMesh = async ({ return updated; }; +/** + * Decline an incoming pending invite addressed to this user's email. + * Marks the pending_invite row as revoked so it no longer surfaces + * in /invites/incoming. The underlying short-code invite is NOT revoked + * (inviter may re-send), only this user's copy is dismissed. + */ +export const declineIncomingInvite = async ({ + email, + pendingInviteId, +}: { + email: string; + pendingInviteId: string; +}) => { + const [updated] = await db + .update(pendingInvite) + .set({ revokedAt: new Date() }) + .where( + and( + eq(pendingInvite.id, pendingInviteId), + eq(pendingInvite.email, email), + isNull(pendingInvite.acceptedAt), + isNull(pendingInvite.revokedAt), + ), + ) + .returning({ id: pendingInvite.id }); + + if (!updated) { + throw new Error("Invitation not found or already resolved."); + } + return updated; +}; + export const leaveMyMesh = async ({ userId, meshId, diff --git a/packages/api/src/modules/mesh/queries.ts b/packages/api/src/modules/mesh/queries.ts index 12b2085..24684cf 100644 --- a/packages/api/src/modules/mesh/queries.ts +++ b/packages/api/src/modules/mesh/queries.ts @@ -5,6 +5,7 @@ import { desc, eq, getOrderByFromSort, + gt, ilike, isNull, or, @@ -16,7 +17,9 @@ import { mesh, meshMember, messageQueue, + pendingInvite, presence, + user, } from "@turbostarter/db/schema"; import { db } from "@turbostarter/db/server"; @@ -345,6 +348,48 @@ export const getMyExport = async ({ userId }: { userId: string }) => { }; }; +/** + * Pending invitations addressed to this user's email. A pending_invite row is + * created when someone calls `claudemesh share `; we join it against the + * underlying `invite` row to get role + expiry, and against `user` (inviter) + * and `mesh` (target) for display. Returned only when unaccepted, unrevoked, + * and not expired. + */ +export const getMyInvitesIncoming = async ({ email }: { email: string }) => { + const now = new Date(); + return db + .select({ + id: pendingInvite.id, + meshId: pendingInvite.meshId, + meshName: mesh.name, + meshSlug: mesh.slug, + code: pendingInvite.code, + role: invite.role, + expiresAt: invite.expiresAt, + sentAt: pendingInvite.sentAt, + inviterName: user.name, + inviterEmail: user.email, + memberCount: sql`( + SELECT COUNT(*)::int FROM mesh.member + WHERE mesh_id = ${pendingInvite.meshId} AND revoked_at IS NULL + )`, + }) + .from(pendingInvite) + .leftJoin(mesh, eq(pendingInvite.meshId, mesh.id)) + .leftJoin(invite, eq(pendingInvite.code, invite.code)) + .leftJoin(user, eq(pendingInvite.createdBy, user.id)) + .where( + and( + eq(pendingInvite.email, email), + isNull(pendingInvite.acceptedAt), + isNull(pendingInvite.revokedAt), + or(isNull(invite.expiresAt), gt(invite.expiresAt, now)), + ), + ) + .orderBy(desc(pendingInvite.sentAt)) + .limit(50); +}; + export const getMyInvitesSent = async ({ userId }: { userId: string }) => db .select({ diff --git a/packages/api/src/modules/mesh/router.ts b/packages/api/src/modules/mesh/router.ts index 7c0a752..8b6bbe6 100644 --- a/packages/api/src/modules/mesh/router.ts +++ b/packages/api/src/modules/mesh/router.ts @@ -15,10 +15,12 @@ import { createEmailInvite, createMyInvite, createMyMesh, + declineIncomingInvite, leaveMyMesh, } from "./mutations"; import { getMyExport, + getMyInvitesIncoming, getMyInvitesSent, getMyMeshById, getMyMeshStream, @@ -150,6 +152,29 @@ export const myRouter = new Hono() const user = c.var.user; return c.json({ sent: await getMyInvitesSent({ userId: user.id }) }); }) + .get("/invites/incoming", async (c) => { + const user = c.var.user; + if (!user.email) return c.json({ incoming: [] }); + return c.json({ + incoming: await getMyInvitesIncoming({ email: user.email }), + }); + }) + .delete("/invites/incoming/:id", async (c) => { + const user = c.var.user; + if (!user.email) return c.json({ error: "No email on session" }, 400); + try { + await declineIncomingInvite({ + email: user.email, + pendingInviteId: c.req.param("id"), + }); + return c.json({ ok: true }); + } catch (e) { + return c.json( + { error: e instanceof Error ? e.message : "Failed to decline." }, + 400, + ); + } + }) .get("/export", async (c) => { const user = c.var.user; const data = await getMyExport({ userId: user.id }); diff --git a/packages/api/src/schema/mesh-user.ts b/packages/api/src/schema/mesh-user.ts index 5f14a0a..4e90b49 100644 --- a/packages/api/src/schema/mesh-user.ts +++ b/packages/api/src/schema/mesh-user.ts @@ -296,3 +296,24 @@ export const getMyInvitesResponseSchema = z.object({ ), }); export type GetMyInvitesResponse = z.infer; + +export const getMyInvitesIncomingResponseSchema = z.object({ + incoming: z.array( + z.object({ + id: z.string(), + meshId: z.string(), + meshName: z.string().nullable(), + meshSlug: z.string().nullable(), + code: z.string(), + role: meshRoleEnum.nullable(), + expiresAt: z.coerce.date().nullable(), + sentAt: z.coerce.date(), + inviterName: z.string().nullable(), + inviterEmail: z.string().nullable(), + memberCount: z.number(), + }), + ), +}); +export type GetMyInvitesIncomingResponse = z.infer< + typeof getMyInvitesIncomingResponseSchema +>; diff --git a/prototypes/live-dashboard.html b/prototypes/live-dashboard.html new file mode 100644 index 0000000..ea7fe6a --- /dev/null +++ b/prototypes/live-dashboard.html @@ -0,0 +1,1001 @@ + + + + + + Your universe · claudemesh + + + + + +
+ + + + + +
+
+

+ Welcome back, Mou.
+ Your universe is active. +

+

+ You own or belong to 11 meshes, with + 17 peers online right now — and + 2 invitations waiting for an answer. +

+
+
+ 17 / 42 + peers · live now + updated just now +
+
+ + +
+
+

Invitations waiting

+ 2 PENDING · EXPIRES IN 7 DAYS +
+
+ +
+
From · Nedas Mikelionis
+

Join backend-ops

+

3 members · you’d join as member · EU-West broker

+
+
+ + +
+ EXPIRES IN 6D 14H +
+
+ +
+
From · Aleksandra Bakaite
+

Join design-review

+

5 members · you’d join as admin · EU-West broker

+
+
+ + +
+ EXPIRES IN 2D 8H +
+
+ +
+
+ + +
+
+

Your meshes

+ +
+ +
+ + +
+
+
+
alexis-mou
+
alexis-mou · id 7e2ad3b1…
+
+ owner · live +
+ +
+ Mou + Nedas + Lug-Nut + Alexis + Roberto + Juan + Kiko +
+ +
+
+
Envelopes · session
+
18,402
+
+
+
Rate · per minute
+
1,024
+
+
+
Latency · p50
+
84ms
+
+
+
+ + +
+
+
+
flexicar
+
flexicar
+
+ owner +
+
+ + Miguel + Diego + Carlos +
+
+ 3 ONLINE · 4 MEMBERS + 2D 4H +
+
+ +
+
+
+
nedas-mesh
+
nedas-mesh
+
+ admin +
+
+ Nedas + Mou + Sasha +
+
+ 3 ONLINE · 3 MEMBERS + 1D 11H +
+
+ + +
+ +
+
+
+
test2
+
test2
+
+ owner +
+
+ empty +
+
+ 0 ONLINE · 4 MEMBERS + IDLE +
+
+ +
+
+
+
prueba1
+
prueba1
+
+ owner +
+
+ Mou + sess-2 + sess-3 +
+
+ 3 ONLINE · 1 MEMBER + 14M +
+
+ +
+
+
+
minery
+
minery
+
+ owner +
+
+ sleeping +
+
+ 0 ONLINE · 1 MEMBER + IDLE · 2D +
+
+ +
+
+
+
Juan
+
juan
+
+ owner +
+
+ Juan + sess +
+
+ 0 ONLINE · 3 MEMBERS + IDLE · 4H +
+
+ +
+ +
+
+ + +
+
+

Recent activity

+ + Expand to live view + +
+ +
+
+ + +
+ claudemesh · your universe · v0.1.0-alpha + cipher · nacl-secretbox · audit chain ✓ +
+ +
+ + + +