feat: Mantine SPA (Cluster/Rooms/Users) + verified end-to-end

SPA (React 19 + Vite 6 + Mantine v9, dark/indigo, @fn_library-style):
- AdminShell: AppShell nav (Cluster/Rooms/Users), operator endpoint badge
- ClusterPage: per-node up/down + posture badges (enforce/acl/tls/cluster/store),
  10s auto-refresh
- RoomsPage: room table (E2E/cleartext, persist, signed, epoch, role), create
  modal, members drawer with kick(+rekey) and invite modal
- UsersPage: allowlist table (handle/role/status/sign_pub), add modal, revoke
  with confirmation, degraded state when no store backend
- api.ts: single repository layer hitting /api; gateway decides mock vs live

Verified end-to-end against a local membershipd in BOTH postures:
- auth-off: create room, list rooms, signed members GET, add/revoke user
- enforce + TLS + nkey (production posture): TLS-pinned healthz, nkey NATS
  connect, signed control-plane requests verified by the server, 403 surfaced
  for a non-member room

pnpm build green (tsc + vite); go build/vet green; dist embedded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-06-07 19:38:30 +02:00
parent 8d893d216b
commit df1c03a0be
22 changed files with 2845 additions and 1 deletions
+141
View File
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useState } from "react";
import {
ActionIcon,
Badge,
Card,
Group,
Indicator,
Loader,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconRefresh, IconServer2 } from "@tabler/icons-react";
import { api, ApiError } from "../api";
import type { NodeHealth, Posture } from "../types";
function PostureBadges({ p, up }: { p: Posture; up: boolean }) {
if (!up) return <Text c="dimmed" size="sm"></Text>;
const flag = (on: boolean, label: string) => (
<Badge key={label} size="sm" variant={on ? "filled" : "outline"} color={on ? "teal" : "gray"}>
{label}
</Badge>
);
return (
<Group gap={6} wrap="wrap">
{flag(p.enforce, "enforce")}
{flag(p.acl, "acl")}
{flag(p.tls, "tls")}
{flag(p.cluster, "cluster")}
<Badge size="sm" variant="light" color="brand" style={{ textTransform: "none" }}>
store: {p.store || "?"}
</Badge>
</Group>
);
}
export function ClusterPage() {
const [nodes, setNodes] = useState<NodeHealth[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(() => {
setLoading(true);
api
.cluster()
.then((n) => {
setNodes(n);
setErr(null);
})
.catch((e: ApiError) => setErr(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
const t = setInterval(load, 10_000);
return () => clearInterval(t);
}, [load]);
const upCount = nodes?.filter((n) => n.up).length ?? 0;
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<Title order={3}>Cluster</Title>
{nodes && (
<Badge color={upCount === nodes.length ? "teal" : upCount === 0 ? "red" : "yellow"} variant="light">
{upCount}/{nodes.length} up
</Badge>
)}
</Group>
<Tooltip label="Refrescar">
<ActionIcon variant="light" color="brand" onClick={load} loading={loading}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
</Group>
{err && <Text c="red">{err}</Text>}
{!nodes && !err && <Loader color="brand" />}
{nodes && (
<Card withBorder bg="dark.7" p={0} radius="md">
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nodo</Table.Th>
<Table.Th>Estado</Table.Th>
<Table.Th>Latencia</Table.Th>
<Table.Th>Posture</Table.Th>
<Table.Th>URL</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{nodes.map((n) => (
<Table.Tr key={n.name}>
<Table.Td>
<Group gap={8} wrap="nowrap">
<Indicator color={n.up ? "teal" : "red"} size={9} processing={n.up}>
<IconServer2 size={18} />
</Indicator>
<Text fw={600}>{n.name}</Text>
</Group>
</Table.Td>
<Table.Td>
{n.up ? (
<Badge color="teal" variant="light">up</Badge>
) : (
<Tooltip label={n.error || "sin respuesta"} multiline w={260}>
<Badge color="red" variant="light">down</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{n.up ? `${n.latency_ms} ms` : "—"}</Text>
</Table.Td>
<Table.Td>
<PostureBadges p={n.posture} up={n.up} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{n.url}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<Text size="xs" c="dimmed">
Posture leída de <code>GET /healthz</code> de cada nodo (enforce + ACL + TLS + cluster + backend de store).
El meta-leader y el tamaño de quórum requieren el endpoint de monitoreo de NATS (gap conocido).
</Text>
</Stack>
);
}
+312
View File
@@ -0,0 +1,312 @@
import { useCallback, useEffect, useState } from "react";
import {
ActionIcon,
Badge,
Button,
Card,
Drawer,
Group,
Loader,
Modal,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconPlus,
IconRefresh,
IconUsers,
IconLock,
IconLockOpen,
IconUserMinus,
IconUserPlus,
} from "@tabler/icons-react";
import { api, ApiError } from "../api";
import type { MemberView, RoomView } from "../types";
import { trunc } from "../util";
function notifyErr(e: unknown) {
notifications.show({ color: "red", title: "Error", message: e instanceof ApiError ? e.message : String(e) });
}
export function RoomsPage() {
const [rooms, setRooms] = useState<RoomView[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [createOpen, createCtl] = useDisclosure(false);
const [active, setActive] = useState<RoomView | null>(null);
const load = useCallback(() => {
setLoading(true);
api
.listRooms()
.then((r) => { setRooms(r); setErr(null); })
.catch((e: ApiError) => setErr(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<Title order={3}>Rooms</Title>
{rooms && <Badge color="brand" variant="light">{rooms.length}</Badge>}
</Group>
<Group gap="xs">
<Tooltip label="Refrescar">
<ActionIcon variant="light" color="brand" onClick={load} loading={loading}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={createCtl.open}>
Crear room
</Button>
</Group>
</Group>
{err && <Text c="red">{err}</Text>}
{!rooms && !err && <Loader color="brand" />}
{rooms && rooms.length === 0 && (
<Text c="dimmed">El admin no posee ni pertenece a ninguna room todavía.</Text>
)}
{rooms && rooms.length > 0 && (
<Card withBorder bg="dark.7" p={0} radius="md">
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Subject</Table.Th>
<Table.Th>Modo</Table.Th>
<Table.Th>Persist</Table.Th>
<Table.Th>Firmado</Table.Th>
<Table.Th>Epoch</Table.Th>
<Table.Th>Rol</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rooms.map((r) => (
<Table.Tr key={r.room_id}>
<Table.Td>
<Text fw={600}>{r.subject}</Text>
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(r.room_id, 14, 4)}</Text>
</Table.Td>
<Table.Td>
{r.encrypt ? (
<Badge color="teal" variant="light" leftSection={<IconLock size={12} />}>E2E</Badge>
) : (
<Badge color="orange" variant="light" leftSection={<IconLockOpen size={12} />}>cleartext</Badge>
)}
</Table.Td>
<Table.Td><Text size="sm" c="dimmed">{r.persist ? "sí" : "no"}</Text></Table.Td>
<Table.Td><Text size="sm" c="dimmed">{r.sign_msgs ? "sí" : "no"}</Text></Table.Td>
<Table.Td><Badge variant="default">{r.epoch}</Badge></Table.Td>
<Table.Td><Badge variant="dot" color={r.role === "owner" ? "brand" : "gray"}>{r.role}</Badge></Table.Td>
<Table.Td>
<Tooltip label="Ver miembros / claves">
<ActionIcon variant="subtle" color="brand" onClick={() => setActive(r)}>
<IconUsers size={18} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<CreateRoomModal opened={createOpen} onClose={createCtl.close} onCreated={load} />
<MembersDrawer room={active} onClose={() => setActive(null)} />
</Stack>
);
}
function CreateRoomModal({ opened, onClose, onCreated }: { opened: boolean; onClose: () => void; onCreated: () => void }) {
const [subject, setSubject] = useState("");
const [encrypt, setEncrypt] = useState(true);
const [persist, setPersist] = useState(true);
const [sign, setSign] = useState(true);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
try {
const r = await api.createRoom({ subject: subject.trim(), encrypt, persist, sign_msgs: sign });
notifications.show({ color: "teal", title: "Room creada", message: `${r.subject} (${trunc(r.room_id, 12, 4)})` });
setSubject("");
onClose();
onCreated();
} catch (e) {
notifyErr(e);
} finally {
setBusy(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title="Crear room" centered>
<Stack gap="md">
<TextInput
label="Subject"
description="Identificador del canal en NATS (ej. team.general)"
placeholder="team.general"
value={subject}
onChange={(e) => setSubject(e.currentTarget.value)}
data-autofocus
/>
<Switch
label="Cifrado de extremo a extremo (E2E)"
description="Recomendado. En despliegue público los nodos rechazan rooms en claro."
checked={encrypt}
onChange={(e) => setEncrypt(e.currentTarget.checked)}
/>
<Switch label="Persistente (JetStream / historial)" checked={persist} onChange={(e) => setPersist(e.currentTarget.checked)} />
<Switch label="Mensajes firmados" checked={sign} onChange={(e) => setSign(e.currentTarget.checked)} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancelar</Button>
<Button onClick={submit} loading={busy} disabled={subject.trim().length === 0}>Crear</Button>
</Group>
</Stack>
</Modal>
);
}
function MembersDrawer({ room, onClose }: { room: RoomView | null; onClose: () => void }) {
const [members, setMembers] = useState<MemberView[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [inviteOpen, inviteCtl] = useDisclosure(false);
const load = useCallback(() => {
if (!room) return;
setMembers(null);
setErr(null);
api
.listMembers(room.room_id)
.then(setMembers)
.catch((e: ApiError) => setErr(e.message));
}, [room]);
useEffect(() => { load(); }, [load]);
const kick = async (endpoint: string) => {
if (!room) return;
if (!window.confirm(`¿Expulsar a ${endpoint}? Esto rota la clave de la room (epoch nuevo) y el expulsado deja de poder descifrar.`)) return;
try {
await api.kick(room.room_id, endpoint);
notifications.show({ color: "teal", title: "Rekey", message: `Miembro expulsado y clave rotada` });
load();
} catch (e) {
notifyErr(e);
}
};
return (
<Drawer
opened={room !== null}
onClose={onClose}
position="right"
size="lg"
title={room ? <Text fw={700}>{room.subject}</Text> : ""}
>
{room && (
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">epoch {room.epoch} · {room.encrypt ? "E2E" : "cleartext"}</Text>
<Button size="xs" leftSection={<IconUserPlus size={14} />} onClick={inviteCtl.open} disabled={room.role !== "owner"}>
Invitar
</Button>
</Group>
{err && <Text c="red" size="sm">{err}</Text>}
{!members && !err && <Loader color="brand" size="sm" />}
{members && (
<Table verticalSpacing="xs" horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Endpoint</Table.Th>
<Table.Th>Rol</Table.Th>
<Table.Th>sign_pub</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{members.map((m) => (
<Table.Tr key={m.endpoint}>
<Table.Td><Text size="sm" style={{ fontFamily: "monospace" }}>{trunc(m.endpoint, 14, 6)}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="dot" color={m.role === "owner" ? "brand" : "gray"}>{m.role}</Badge></Table.Td>
<Table.Td>
<Tooltip label={m.sign_pub}>
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(m.sign_pub, 10, 6)}</Text>
</Tooltip>
</Table.Td>
<Table.Td>
{m.role !== "owner" && room.role === "owner" && (
<Tooltip label="Expulsar + rekey">
<ActionIcon variant="subtle" color="red" onClick={() => kick(m.endpoint)}>
<IconUserMinus size={16} />
</ActionIcon>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<InviteModal room={room} opened={inviteOpen} onClose={inviteCtl.close} onInvited={load} />
</Stack>
)}
</Drawer>
);
}
function InviteModal({ room, opened, onClose, onInvited }: { room: RoomView; opened: boolean; onClose: () => void; onInvited: () => void }) {
const [endpoint, setEndpoint] = useState("");
const [signPub, setSignPub] = useState("");
const [kexPub, setKexPub] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
try {
await api.invite(room.room_id, { endpoint: endpoint.trim(), sign_pub: signPub.trim(), kex_pub: kexPub.trim() });
notifications.show({ color: "teal", title: "Invitado", message: "Clave de room sellada para el nuevo miembro" });
setEndpoint(""); setSignPub(""); setKexPub("");
onClose();
onInvited();
} catch (e) {
notifyErr(e);
} finally {
setBusy(false);
}
};
const ready = signPub.trim().length === 64 && kexPub.trim().length === 64;
return (
<Modal opened={opened} onClose={onClose} title={`Invitar a ${room.subject}`} centered>
<Stack gap="sm">
<Text size="sm" c="dimmed">
Para una room E2E la clave se sella contra la clave X25519 del invitado, por eso se piden ambas claves públicas (hex de 64 chars).
</Text>
<TextInput label="Endpoint (opcional)" description="Se deriva de sign_pub si se deja vacío" value={endpoint} onChange={(e) => setEndpoint(e.currentTarget.value)} />
<TextInput label="sign_pub (hex, 64)" value={signPub} onChange={(e) => setSignPub(e.currentTarget.value)} error={signPub.length > 0 && signPub.trim().length !== 64 ? "64 chars hex" : undefined} />
<TextInput label="kex_pub (hex, 64)" value={kexPub} onChange={(e) => setKexPub(e.currentTarget.value)} error={kexPub.length > 0 && kexPub.trim().length !== 64 ? "64 chars hex" : undefined} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancelar</Button>
<Button onClick={submit} loading={busy} disabled={!ready}>Invitar</Button>
</Group>
</Stack>
</Modal>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useCallback, useEffect, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { IconPlus, IconRefresh, IconUserOff, IconInfoCircle } from "@tabler/icons-react";
import { api, ApiError } from "../api";
import type { UserView } from "../types";
import { fmtTime, trunc } from "../util";
function notifyErr(e: unknown) {
notifications.show({ color: "red", title: "Error", message: e instanceof ApiError ? e.message : String(e) });
}
export function UsersPage({ usersBackend }: { usersBackend: string }) {
const [users, setUsers] = useState<UserView[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [addOpen, addCtl] = useDisclosure(false);
const writable = usersBackend !== "none";
const load = useCallback(() => {
setLoading(true);
api
.listUsers()
.then((u) => { setUsers(u); setErr(null); })
.catch((e: ApiError) => setErr(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const revoke = async (u: UserView) => {
if (!window.confirm(`¿Revocar a "${u.handle}"? Pierde acceso al bus en AMBOS planos de inmediato (control y datos).`)) return;
try {
await api.revokeUser(u.sign_pub);
notifications.show({ color: "teal", title: "Revocado", message: u.handle });
load();
} catch (e) {
notifyErr(e);
}
};
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<Title order={3}>Users</Title>
{users && <Badge color="brand" variant="light">{users.length}</Badge>}
<Badge variant="outline" color={writable ? "teal" : "gray"} style={{ textTransform: "none" }}>
store: {usersBackend}
</Badge>
</Group>
<Group gap="xs">
<Tooltip label="Refrescar">
<ActionIcon variant="light" color="brand" onClick={load} loading={loading}>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={addCtl.open} disabled={!writable}>
Añadir user
</Button>
</Group>
</Group>
{!writable && (
<Alert icon={<IconInfoCircle size={18} />} color="yellow" variant="light" title="Gestión de users no disponible">
El plano de control no expone endpoint de users; viven solo en el store. Arranca el gateway con <code>--db</code>
(single-node) o con acceso KV admin del cluster para listar/dar de alta/revocar. Coordinar con la vía KV que
añade <code>quick/0011-deploy-gaps</code>.
</Alert>
)}
{err && writable && <Text c="red">{err}</Text>}
{!users && !err && writable && <Loader color="brand" />}
{users && (
<Card withBorder bg="dark.7" p={0} radius="md">
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Handle</Table.Th>
<Table.Th>Rol</Table.Th>
<Table.Th>Estado</Table.Th>
<Table.Th>sign_pub</Table.Th>
<Table.Th>Creado</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((u) => (
<Table.Tr key={u.sign_pub}>
<Table.Td><Text fw={600}>{u.handle}</Text></Table.Td>
<Table.Td><Badge variant="dot" color={u.role === "admin" ? "brand" : "gray"}>{u.role}</Badge></Table.Td>
<Table.Td>
<Badge variant="light" color={u.status === "active" ? "teal" : "red"}>{u.status}</Badge>
</Table.Td>
<Table.Td>
<Tooltip label={u.sign_pub}>
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>{trunc(u.sign_pub, 12, 8)}</Text>
</Tooltip>
</Table.Td>
<Table.Td><Text size="xs" c="dimmed">{fmtTime(u.created_at)}</Text></Table.Td>
<Table.Td>
{writable && u.status === "active" && (
<Tooltip label="Revocar acceso">
<ActionIcon variant="subtle" color="red" onClick={() => revoke(u)}>
<IconUserOff size={16} />
</ActionIcon>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<AddUserModal opened={addOpen} onClose={addCtl.close} onAdded={load} />
</Stack>
);
}
function AddUserModal({ opened, onClose, onAdded }: { opened: boolean; onClose: () => void; onAdded: () => void }) {
const [handle, setHandle] = useState("");
const [signPub, setSignPub] = useState("");
const [role, setRole] = useState<string>("member");
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
try {
await api.addUser({ handle: handle.trim(), sign_pub: signPub.trim(), role });
notifications.show({ color: "teal", title: "User añadido", message: handle });
setHandle(""); setSignPub(""); setRole("member");
onClose();
onAdded();
} catch (e) {
notifyErr(e);
} finally {
setBusy(false);
}
};
const ready = handle.trim().length > 0 && signPub.trim().length === 64;
return (
<Modal opened={opened} onClose={onClose} title="Añadir user al bus" centered>
<Stack gap="sm">
<TextInput label="Handle" placeholder="ana" value={handle} onChange={(e) => setHandle(e.currentTarget.value)} data-autofocus />
<TextInput
label="sign_pub (hex, 64)"
description="Clave pública Ed25519 del usuario (la misma que autentica control + datos)"
placeholder="48bc0dc8…"
value={signPub}
onChange={(e) => setSignPub(e.currentTarget.value)}
error={signPub.length > 0 && signPub.trim().length !== 64 ? "64 chars hex" : undefined}
/>
<Select label="Rol" data={[{ value: "member", label: "member" }, { value: "admin", label: "admin" }]} value={role} onChange={(v) => setRole(v || "member")} allowDeselect={false} />
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancelar</Button>
<Button onClick={submit} loading={busy} disabled={!ready}>Añadir</Button>
</Group>
</Stack>
</Modal>
);
}