diff --git a/apps/web/src/app/[locale]/admin/audit/page.tsx b/apps/web/src/app/[locale]/admin/audit/page.tsx new file mode 100644 index 0000000..743c5bd --- /dev/null +++ b/apps/web/src/app/[locale]/admin/audit/page.tsx @@ -0,0 +1,85 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, +} from "nuqs/server"; +import { Suspense } from "react"; + +import { getAuditResponseSchema } from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { pickBy } from "@turbostarter/shared/utils"; +import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton"; + +import { api } from "~/lib/api/server"; +import { getMetadata } from "~/lib/metadata"; +import { AuditDataTable } from "~/modules/admin/audit/data-table/audit-data-table"; +import { getSortingStateParser } from "~/modules/common/hooks/use-data-table/common"; +import { + DashboardHeader, + DashboardHeaderDescription, + DashboardHeaderTitle, +} from "~/modules/common/layout/dashboard/header"; + +const searchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(50), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + q: parseAsString, + eventType: parseAsArrayOf(parseAsString), + meshId: parseAsArrayOf(parseAsString), + createdAt: parseAsArrayOf(parseAsInteger), +}); + +export const generateMetadata = getMetadata({ + title: "Audit · Admin", + description: "Audit log of mesh events.", +}); + +export default async function AuditPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const { page, perPage, sort, ...rest } = + searchParamsCache.parse(searchParams); + + const filters = pickBy(rest, Boolean); + + const promise = handle(api.admin.audit.$get, { + schema: getAuditResponseSchema, + })({ + query: { + ...filters, + page: page.toString(), + perPage: perPage.toString(), + sort: JSON.stringify(sort), + }, + }); + + return ( + <> + +
+ Audit log + + Metadata-only event log — no message content, only routing. + +
+
+ + } + > + + + + ); +} diff --git a/apps/web/src/app/[locale]/admin/invites/page.tsx b/apps/web/src/app/[locale]/admin/invites/page.tsx new file mode 100644 index 0000000..446d643 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/invites/page.tsx @@ -0,0 +1,84 @@ +import { + createSearchParamsCache, + parseAsBoolean, + parseAsInteger, + parseAsString, +} from "nuqs/server"; +import { Suspense } from "react"; + +import { getInvitesResponseSchema } from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { pickBy } from "@turbostarter/shared/utils"; +import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton"; + +import { api } from "~/lib/api/server"; +import { getMetadata } from "~/lib/metadata"; +import { InvitesDataTable } from "~/modules/admin/invites/data-table/invites-data-table"; +import { getSortingStateParser } from "~/modules/common/hooks/use-data-table/common"; +import { + DashboardHeader, + DashboardHeaderDescription, + DashboardHeaderTitle, +} from "~/modules/common/layout/dashboard/header"; + +const searchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + q: parseAsString, + revoked: parseAsBoolean, + expired: parseAsBoolean, +}); + +export const generateMetadata = getMetadata({ + title: "Invites · Admin", + description: "Mesh invite tokens across the system.", +}); + +export default async function InvitesPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const { page, perPage, sort, ...rest } = + searchParamsCache.parse(searchParams); + + const filters = pickBy(rest, Boolean); + + const promise = handle(api.admin.invites.$get, { + schema: getInvitesResponseSchema, + })({ + query: { + ...filters, + page: page.toString(), + perPage: perPage.toString(), + sort: JSON.stringify(sort), + }, + }); + + return ( + <> + +
+ Invites + + Mesh invite tokens — active, revoked, expired, exhausted. + +
+
+ + } + > + + + + ); +} diff --git a/apps/web/src/app/[locale]/admin/layout.tsx b/apps/web/src/app/[locale]/admin/layout.tsx index dcb5bba..52e05f2 100644 --- a/apps/web/src/app/[locale]/admin/layout.tsx +++ b/apps/web/src/app/[locale]/admin/layout.tsx @@ -35,6 +35,31 @@ const menu = [ }, ], }, + { + label: "mesh", + items: [ + { + title: "meshes", + href: pathsConfig.admin.meshes.index, + icon: Icons.Share, + }, + { + title: "sessions", + href: pathsConfig.admin.sessions.index, + icon: Icons.Activity, + }, + { + title: "invites", + href: pathsConfig.admin.invites.index, + icon: Icons.Link, + }, + { + title: "audit", + href: pathsConfig.admin.audit.index, + icon: Icons.ScrollText, + }, + ], + }, ]; export default async function AdminLayout({ diff --git a/apps/web/src/app/[locale]/admin/meshes/[id]/page.tsx b/apps/web/src/app/[locale]/admin/meshes/[id]/page.tsx new file mode 100644 index 0000000..ef3d256 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/meshes/[id]/page.tsx @@ -0,0 +1,277 @@ +import { notFound } from "next/navigation"; + +import { getMeshResponseSchema } from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { Badge } from "@turbostarter/ui-web/badge"; + +import { api } from "~/lib/api/server"; +import { getMetadata } from "~/lib/metadata"; +import { + DashboardHeader, + DashboardHeaderDescription, + DashboardHeaderTitle, +} from "~/modules/common/layout/dashboard/header"; + +export const generateMetadata = getMetadata({ + title: "Mesh detail · Admin", + description: "Members, presences, invites, audit events for a mesh.", +}); + +export default async function MeshDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const data = await handle(api.admin.meshes[":id"].$get, { + schema: getMeshResponseSchema, + })({ param: { id } }).catch(() => null); + + if (!data || !data.mesh) notFound(); + + const { mesh, members, presences, invites, auditEvents } = data; + + return ( + <> + +
+ + + {mesh.name} + + {mesh.slug} + + + + + Owner: {mesh.ownerName ?? "—"} · {mesh.ownerEmail ?? "—"} · tier{" "} + {mesh.tier} · transport {mesh.transport} · visibility{" "} + {mesh.visibility} + +
+
+ +
+
+ + + + + + + + + + + + + {members.map((m) => ( + + + + + + + + + ))} + +
Display nameRolePubkeyJoinedLast seenStatus
{m.displayName} + {m.role} + + {m.peerPubkey.slice(0, 12)}… + + {new Date(m.joinedAt).toLocaleDateString()} + + {m.lastSeenAt + ? new Date(m.lastSeenAt).toLocaleString() + : "—"} + + {m.revokedAt ? ( + + revoked + + ) : ( + + active + + )} +
+
+ +
+ + + + + + + + + + + + {presences.map((p) => ( + + + + + + + + ))} + +
PeerStatusPIDCWDLast ping
+ {p.displayName ?? "—"} + + + {p.disconnectedAt ? "disconnected" : p.status} + + + {p.pid} + + {p.cwd} + + {new Date(p.lastPingAt).toLocaleTimeString()} +
+
+ +
+ + + + + + + + + + + + {invites.map((inv) => ( + + + + + + + + ))} + +
TokenRoleUsesExpiresStatus
+ {inv.token.slice(0, 12)}… + + {inv.role} + + {inv.usedCount} / {inv.maxUses} + + {new Date(inv.expiresAt).toLocaleDateString()} + + {inv.revokedAt ? ( + + revoked + + ) : new Date(inv.expiresAt) < new Date() ? ( + expired + ) : ( + + active + + )} +
+
+ +
+ + + + + + + + + + + {auditEvents.map((e) => ( + + + + + + + ))} + +
WhenEventActorTarget
+ {new Date(e.createdAt).toLocaleString()} + + + {e.eventType} + + + {e.actorPeerId?.slice(0, 12) ?? "—"} + + {e.targetPeerId?.slice(0, 12) ?? "—"} +
+
+
+ + ); +} + +function Section({ + title, + count, + empty, + children, +}: { + title: string; + count: number; + empty: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ + {count} + +
+ {count === 0 ? ( +

+ {empty} +

+ ) : ( +
{children}
+ )} +
+ ); +} diff --git a/apps/web/src/app/[locale]/admin/meshes/page.tsx b/apps/web/src/app/[locale]/admin/meshes/page.tsx new file mode 100644 index 0000000..45b11cd --- /dev/null +++ b/apps/web/src/app/[locale]/admin/meshes/page.tsx @@ -0,0 +1,93 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import { Suspense } from "react"; + +import { getMeshesResponseSchema } from "@turbostarter/api/schema"; +import { handle } from "@turbostarter/api/utils"; +import { pickBy } from "@turbostarter/shared/utils"; +import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton"; + +import { api } from "~/lib/api/server"; +import { getMetadata } from "~/lib/metadata"; +import { MeshesDataTable } from "~/modules/admin/meshes/data-table/meshes-data-table"; +import { getSortingStateParser } from "~/modules/common/hooks/use-data-table/common"; +import { + DashboardHeader, + DashboardHeaderDescription, + DashboardHeaderTitle, +} from "~/modules/common/layout/dashboard/header"; + +const TIER_VALUES = ["free", "pro", "team", "enterprise"] as const; +const TRANSPORT_VALUES = ["managed", "tailscale", "self_hosted"] as const; +const VISIBILITY_VALUES = ["private", "public"] as const; + +const searchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + q: parseAsString, + tier: parseAsArrayOf(parseAsStringEnum([...TIER_VALUES])), + transport: parseAsArrayOf(parseAsStringEnum([...TRANSPORT_VALUES])), + visibility: parseAsArrayOf(parseAsStringEnum([...VISIBILITY_VALUES])), + archived: parseAsBoolean, + createdAt: parseAsArrayOf(parseAsInteger), +}); + +export const generateMetadata = getMetadata({ + title: "Meshes · Admin", + description: "All meshes in the system.", +}); + +export default async function MeshesPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const { page, perPage, sort, ...rest } = + searchParamsCache.parse(searchParams); + + const filters = pickBy(rest, Boolean); + + const promise = handle(api.admin.meshes.$get, { + schema: getMeshesResponseSchema, + })({ + query: { + ...filters, + page: page.toString(), + perPage: perPage.toString(), + sort: JSON.stringify(sort), + }, + }); + + return ( + <> + +
+ Meshes + + All meshes across the system — tier, transport, owner, member count. + +
+
+ + } + > + + + + ); +} diff --git a/apps/web/src/app/[locale]/admin/page.tsx b/apps/web/src/app/[locale]/admin/page.tsx index 207bd27..4218392 100644 --- a/apps/web/src/app/[locale]/admin/page.tsx +++ b/apps/web/src/app/[locale]/admin/page.tsx @@ -35,12 +35,65 @@ export default async function AdminPage() { organizations: z.number(), customers: z.number(), }); + const meshSummarySchema = z.object({ + meshes: z.number(), + activeMeshes: z.number(), + totalPresences: z.number(), + activePresences: z.number(), + messages24h: z.number(), + }); - const data = await handle(api.admin.summary.$get, { - schema: adminSummarySchema, - })(); + const [base, mesh] = await Promise.all([ + handle(api.admin.summary.$get, { schema: adminSummarySchema })(), + handle(api.admin.summary.mesh.$get, { schema: meshSummarySchema })(), + ]); - const cards = ["users", "organizations", "customers"] as const; + const nf = new Intl.NumberFormat(i18n.language); + + const cards = [ + { + key: "users" as const, + title: t("common:users"), + description: t("home.summary.users"), + href: pathsConfig.admin.users.index, + value: base.users, + }, + { + key: "organizations" as const, + title: t("common:organizations"), + description: t("home.summary.organizations"), + href: pathsConfig.admin.organizations.index, + value: base.organizations, + }, + { + key: "customers" as const, + title: t("common:customers"), + description: t("home.summary.customers"), + href: pathsConfig.admin.customers.index, + value: base.customers, + }, + { + key: "meshes" as const, + title: "Meshes", + description: `${nf.format(mesh.activeMeshes)} active`, + href: pathsConfig.admin.meshes.index, + value: mesh.meshes, + }, + { + key: "sessions" as const, + title: "Sessions", + description: `${nf.format(mesh.activePresences)} live now`, + href: pathsConfig.admin.sessions.index, + value: mesh.totalPresences, + }, + { + key: "messages" as const, + title: "Messages (24h)", + description: "Routed through the broker", + href: pathsConfig.admin.audit.index, + value: mesh.messages24h, + }, + ]; return ( <> @@ -57,10 +110,10 @@ export default async function AdminPage() {