feat: add some basic mutations

This commit is contained in:
Aarnav Tale
2026-05-16 21:30:16 -04:00
parent ad7e58570f
commit 87a9219fd3
5 changed files with 623 additions and 27 deletions
+32 -16
View File
@@ -549,14 +549,29 @@ Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDa
- Removed the earlier `HeadplaneRuntime`, `HeadplaneDataContext`, and `HeadplaneLivePublisher` scaffolding.
- Added direct dependencies: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and `@vitejs/plugin-react`.
- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and a raw Fate client shell using `createClient` + `createHTTPTransport`.
- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and the generated raw Fate client from `react-fate/client`.
- Moved the old React Router Vite config to `vite-old.config.ts` and replaced `vite.config.ts` with a clean SPA config.
- Added Hono and `@hono/node-server`, plus a minimal Hono server shell in `app/server/hono-app.ts`, `app/server/hono-dev.ts`, and `app/server/hono-main.ts`.
- Added `app/server/fate.ts`, which exports a raw Fate server and live bus. Hono mounts Fate's `createHonoFateHandler(fate)` at `/admin/fate` and `/admin/fate/*`, passing the existing app context through Hono variables.
- Wired the official `react-fate/vite` plugin to `app/server/fate.ts`, ignored generated `.fate/` output, and made `pnpm run typecheck` run `fate generate` before `tsgo` so generated `react-fate/client` typings exist from a clean checkout.
- Added the first real Fate read roots: `machines` and `users`. They use Fate `dataView(...)`, `list(...)`, and source executors directly, call the existing principal-scoped Headscale runtime API client, and enforce existing `read_machines` / `read_users` capabilities.
- Added minimal SPA `/machines` and `/users` routes that fetch with raw `useRequest(...)`, render records with `useLiveView(...)`, and subscribe to root list connections with `useLiveListView(...)`. These routes intentionally do not port filters, actions, or optimistic updates yet.
- Bridged existing `hsLive` resource changes directly to Fate connection invalidations: `nodes` invalidates the `machines` root connection and `users` invalidates the `users` root connection. This is a temporary seam so converted routes can exercise Fate live primitives before the old live store is deleted.
- Added the first raw Fate mutation, `machine.rename`. It reuses existing `canManageNode` authorization, calls the Headscale API, refreshes the transitional `hsLive` nodes resource, emits direct Fate entity/list live events, and returns the client-selected `Machine` view.
- `pnpm dev` now runs the Hono/Vite middleware shell with local `.data` storage for the example config; `pnpm build` now runs `vite build` for the SPA.
- Fate's Drizzle peer currently warns against the repo's Drizzle `1.0.0-beta.21`; avoid Fate's Drizzle adapter until that compatibility is resolved, and start with a direct Headscale source/resolver instead.
### Fate context decision
The current Fate request context should stay pragmatic rather than heavily decomposed:
- expose `api`, the principal-scoped Headscale runtime client, as the primary data access path for remote Headscale data;
- keep `principal` and `request` available for authorization and future audit/session needs;
- keep `app` available during the migration so resolvers can reuse the existing auth/config/agent services without inventing a new service layer first;
- do not pass an unstructured context into every helper by default once a data domain settles. If a `machines`, `users`, or `authKeys` module becomes large, give that module explicit functions that accept the concrete pieces it uses.
In other words: full app context is acceptable as migration scaffolding, but the resolver code should prefer the smallest direct dependency (`ctx.api`, `ctx.app.auth`, etc.) and should not become a new `HeadplaneRuntime` abstraction.
### Recommended migration sequence
1. **Remove transitional abstractions** and make the branch clearly one-way toward SPA + raw Fate.
@@ -570,9 +585,11 @@ Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDa
- `/fate/live` for SSE and subscription control;
- request context resolves auth using the existing auth service and calls `context.hsApi.getRuntimeClient(...)` directly.
5. **Convert the machines page first** using raw Fate views and live list/view hooks.
6. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side.
7. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz.
8. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving.
6. **Add live updates to the converted lists** by publishing Fate list/entity invalidations from the existing polling/mutation seams. Keep this raw Fate live bus usage, not a Headplane live wrapper.
7. **Port one mutation at a time**, starting with a low-risk machine mutation such as rename. The mutation should call the existing Headscale API, return the selected entity, and emit the relevant Fate live event.
8. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side.
9. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz.
10. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving.
### Vite+ / VoidZero tooling assessment
@@ -586,15 +603,14 @@ Recommended Vite+ approach:
## Suggested immediate backlog
1. Fix OIDC self-linking authorization.
2. Make API-key sessions opaque/server-side and set explicit auth cookie flags.
3. Add capability checks to `/settings/agent` loader/action.
4. Fix live-data pause cleanup/refcounting.
5. Make `hsLive` use a stable server credential or principal-scoped cache.
6. Add raw Fate server/client dependencies and wire the Vite plugin.
7. Add the Vite SPA entry and TanStack Router skeleton.
8. Mount Fate's native `/fate` and `/fate/live` handlers directly.
9. Convert the machines page to raw Fate views/actions/live subscriptions.
10. Serialize config patches with a real mutex/queue and clone config arrays before editing.
11. Add CI `pnpm run typecheck` and `pnpm run lint`.
12. Add WebSSH top-level dispose and release Go `js.Func` values.
1. Replace the temporary `hsLive` bridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source.
2. Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists.
3. Port the next machine mutations: expire/delete/tags/routes, one at a time, each returning selected data or deleting/updating the normalized cache explicitly.
4. Fix OIDC self-linking authorization.
5. Make API-key sessions opaque/server-side and set explicit auth cookie flags.
6. Add capability checks to `/settings/agent` loader/action.
7. Fix live-data pause cleanup/refcounting for unconverted React Router routes.
8. Make `hsLive` use a stable server credential or principal-scoped cache while it still exists.
9. Serialize config patches with a real mutex/queue and clone config arrays before editing.
10. Add CI `pnpm run typecheck` and `pnpm run lint`.
11. Add WebSSH top-level dispose and release Go `js.Func` values.
+12 -1
View File
@@ -5,6 +5,7 @@ import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server";
import { live as fateLive } from "./fate";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
@@ -83,6 +84,16 @@ export async function createAppContext(config: HeadplaneConfig) {
}
: undefined;
const hsLive = createLiveStore([nodesResource, usersResource]);
hsLive.subscribe((resource, version) => {
const eventId = `${resource}:${version}`;
if (resource === nodesResource.key) {
fateLive.connection("machines").invalidate({ eventId });
} else if (resource === usersResource.key) {
fateLive.connection("users").invalidate({ eventId });
}
});
return {
config,
db,
@@ -91,7 +102,7 @@ export async function createAppContext(config: HeadplaneConfig) {
agents,
auth,
oidc,
hsLive: createLiveStore([nodesResource, usersResource]),
hsLive,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
+311 -4
View File
@@ -1,14 +1,24 @@
import {
createFateServer,
createLiveEventBus,
dataView,
FateRequestError,
list,
resolveSourceById,
type Entity,
type SourceDefinition,
type SourceRegistry,
} from "@nkzw/fate/server";
import type { Context } from "hono";
import type { Machine as HeadscaleMachine, User as HeadscaleUser } from "~/types";
import type { AppContext } from "./context";
import type { RuntimeApiClient } from "./headscale/api/endpoints";
import { isConnectionError, isDataWithApiError } from "./headscale/api/error-client";
import { nodesResource } from "./headscale/live-store";
import type { Principal } from "./web/auth";
import { Capabilities } from "./web/roles";
export interface HonoFateEnv {
Variables: {
@@ -27,12 +37,293 @@ type FateAdapterContext = Context<HonoFateEnv>;
export const live = createLiveEventBus();
export const roots = {};
type UserRecord = HeadscaleUser & Record<string, unknown>;
type MachineRecord = Omit<HeadscaleMachine, "user"> & {
user?: UserRecord;
} & Record<string, unknown>;
export const UserDataView = dataView<UserRecord>("User")({
createdAt: true,
displayName: true,
email: true,
id: true,
name: true,
profilePicUrl: true,
provider: true,
providerId: true,
});
export const MachineDataView = dataView<MachineRecord>("Machine")({
approvedRoutes: true,
availableRoutes: true,
createdAt: true,
discoKey: true,
expiry: true,
givenName: true,
id: true,
ipAddresses: true,
lastSeen: true,
machineKey: true,
name: true,
nodeKey: true,
online: true,
registerMethod: true,
subnetRoutes: true,
tags: true,
user: UserDataView,
});
export type User = Entity<typeof UserDataView, "User">;
export type Machine = Entity<
typeof MachineDataView,
"Machine",
{
user?: User;
}
>;
export type FateUser = User;
export type FateMachine = Machine;
const userSource = {
id: "id",
view: UserDataView,
} satisfies SourceDefinition<UserRecord>;
const machineSource = {
id: "id",
view: MachineDataView,
} satisfies SourceDefinition<MachineRecord>;
const machineList = list(MachineDataView, { orderBy: { givenName: "asc" } });
const userList = list(UserDataView, { orderBy: { name: "asc" } });
export const Root = {
machines: machineList,
users: userList,
};
export const roots = Root;
const queries = {};
const lists = {};
const mutations = {};
function apiFailureToFateError(error: unknown, fallback: string): FateRequestError {
if (error instanceof FateRequestError) {
return error;
}
if (isDataWithApiError(error)) {
const status = error.data.statusCode;
if (status === 401) {
return new FateRequestError("UNAUTHORIZED", "Headscale rejected the current API key.");
}
if (status === 403) {
return new FateRequestError("FORBIDDEN", "Headscale refused this operation.");
}
if (status === 404) {
return new FateRequestError("NOT_FOUND", "The requested Headscale resource was not found.");
}
return new FateRequestError(
"INTERNAL_ERROR",
`Headscale API request failed with status ${status}.`,
{ status: 502 },
);
}
const data = error && typeof error === "object" && "data" in error ? error.data : undefined;
if (isConnectionError(data)) {
return new FateRequestError(
"INTERNAL_ERROR",
`Unable to reach Headscale: ${data.errorMessage}`,
{ status: 502 },
);
}
return new FateRequestError("INTERNAL_ERROR", fallback);
}
interface RenameMachineInput {
id: string;
name: string;
}
const renameMachineInput = {
parse(input: unknown): RenameMachineInput {
if (!input || typeof input !== "object") {
throw new FateRequestError("VALIDATION_ERROR", "Machine rename input is required.");
}
const id = (input as Record<string, unknown>).id;
const name = (input as Record<string, unknown>).name;
if (typeof id !== "string" || id.trim() === "") {
throw new FateRequestError("VALIDATION_ERROR", "Machine ID is required.");
}
if (typeof name !== "string" || name.trim() === "") {
throw new FateRequestError("VALIDATION_ERROR", "Machine name is required.");
}
return {
id,
name: name.trim(),
};
},
};
const mutations = {
"machine.rename": {
input: renameMachineInput,
resolve: async ({
ctx,
input,
select,
}: {
ctx: FateContext;
input: RenameMachineInput;
select: Array<string>;
}): Promise<Machine | null> => {
let node;
try {
node = await ctx.api.getNode(input.id);
} catch (error) {
throw apiFailureToFateError(error, "Unable to load this machine.");
}
if (!ctx.app.auth.canManageNode(ctx.principal, node)) {
throw new FateRequestError(
"FORBIDDEN",
"You do not have permission to rename this machine.",
);
}
try {
await ctx.api.renameNode(input.id, input.name);
} catch (error) {
throw apiFailureToFateError(error, "Unable to rename this machine.");
}
void ctx.app.hsLive.refresh(nodesResource, ctx.api).catch(() => undefined);
const eventId = `machine:${input.id}:rename:${Date.now()}`;
live.update("Machine", input.id, { changed: ["givenName", "name"], eventId });
live.connection("machines").invalidate({ eventId });
return (await resolveSourceById({
ctx,
id: input.id,
input: { select },
registry,
source: machineSource,
})) as Machine | null;
},
type: "Machine",
},
};
type SourceByIdsOptions = {
ctx: FateContext;
ids: Array<string>;
};
type SourceConnectionOptions = {
ctx: FateContext;
cursor?: string;
direction: "backward" | "forward";
take: number;
};
function requireCapability(ctx: FateContext, capability: Capabilities) {
if (!ctx.app.auth.can(ctx.principal, capability)) {
throw new FateRequestError("FORBIDDEN", "You do not have permission to view this data.");
}
}
function compareText(a: string | undefined, b: string | undefined) {
return (a ?? "").localeCompare(b ?? "", undefined, { numeric: true, sensitivity: "base" });
}
function byRequestedId<T extends { id: string }>(items: Array<T>, ids: Array<string>) {
const byId = new Map(items.map((item) => [item.id, item]));
return ids.flatMap((id) => {
const item = byId.get(id);
return item ? [item] : [];
});
}
function pageByCursor<T extends { id: string }>(
items: Array<T>,
{ cursor, direction, take }: Omit<SourceConnectionOptions, "ctx">,
) {
if (!cursor) {
return direction === "backward"
? items.slice(Math.max(0, items.length - take))
: items.slice(0, take);
}
const cursorIndex = items.findIndex((item) => item.id === cursor);
if (cursorIndex < 0) {
return direction === "backward"
? items.slice(Math.max(0, items.length - take))
: items.slice(0, take);
}
if (direction === "backward") {
return items.slice(Math.max(0, cursorIndex - take), cursorIndex);
}
return items.slice(cursorIndex + 1, cursorIndex + 1 + take);
}
async function getMachineRecords(ctx: FateContext): Promise<Array<MachineRecord>> {
requireCapability(ctx, Capabilities.read_machines);
try {
return (await ctx.api.getNodes())
.map((machine) => machine as MachineRecord)
.sort(
(a, b) =>
compareText(a.givenName || a.name, b.givenName || b.name) || compareText(a.id, b.id),
);
} catch (error) {
throw apiFailureToFateError(error, "Unable to load machines from Headscale.");
}
}
async function getUserRecords(ctx: FateContext): Promise<Array<UserRecord>> {
requireCapability(ctx, Capabilities.read_users);
try {
return (await ctx.api.getUsers())
.map((user) => user as UserRecord)
.sort((a, b) => compareText(a.name, b.name) || compareText(a.id, b.id));
} catch (error) {
throw apiFailureToFateError(error, "Unable to load users from Headscale.");
}
}
const registry = new Map() as SourceRegistry<FateContext>;
registry.set(machineSource as SourceDefinition, {
byIds: async ({ ctx, ids }: SourceByIdsOptions) =>
byRequestedId(await getMachineRecords(ctx), ids),
connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) =>
pageByCursor(await getMachineRecords(ctx), { cursor, direction, take }),
});
registry.set(userSource as SourceDefinition, {
byIds: async ({ ctx, ids }: SourceByIdsOptions) => byRequestedId(await getUserRecords(ctx), ids),
connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) =>
pageByCursor(await getUserRecords(ctx), { cursor, direction, take }),
});
const sourceByView = new Map<unknown, SourceDefinition>([
[MachineDataView, machineSource as SourceDefinition],
[machineList, machineSource as SourceDefinition],
[UserDataView, userSource as SourceDefinition],
[userList, userSource as SourceDefinition],
]);
function isSourceDefinition(target: unknown): target is SourceDefinition {
return target !== null && typeof target === "object" && "view" in target;
}
export const fate = createFateServer<
FateContext,
@@ -48,7 +339,13 @@ export const fate = createFateServer<
}
const app = adapterContext.get("appContext");
const principal = await app.auth.require(request);
let principal;
try {
principal = await app.auth.require(request);
} catch {
throw new FateRequestError("UNAUTHORIZED", "Authentication required.");
}
const api = app.hsApi.getRuntimeClient(app.auth.getHeadscaleApiKey(principal));
return {
@@ -59,11 +356,21 @@ export const fate = createFateServer<
};
},
live,
mutations,
roots,
sources: {
registry,
getSource(target) {
return target as SourceDefinition;
if (isSourceDefinition(target)) {
return target;
}
const source = sourceByView.get(target);
if (!source) {
throw new Error("No Fate source registered for data view");
}
return source;
},
},
});
+6 -1
View File
@@ -1,4 +1,5 @@
import { RouterProvider } from "@tanstack/react-router";
import { Suspense } from "react";
import { FateClient } from "react-fate";
import { createFateClient } from "react-fate/client";
@@ -12,7 +13,11 @@ const fate = createFateClient({
export function App() {
return (
<FateClient client={fate}>
<RouterProvider router={router} />
<Suspense
fallback={<div className="min-h-screen bg-neutral-950 p-6 text-neutral-400">Loading</div>}
>
<RouterProvider router={router} />
</Suspense>
</FateClient>
);
}
+262 -5
View File
@@ -1,4 +1,66 @@
import { Link, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
import { useState, useTransition } from "react";
import {
useFateClient,
useLiveListView,
useLiveView,
useRequest,
view,
type ViewRef,
} from "react-fate";
import type { FateMachine, FateUser } from "../server/fate";
const UserView = view<FateUser>()({
displayName: true,
email: true,
id: true,
name: true,
});
const MachineView = view<FateMachine>()({
expiry: true,
givenName: true,
id: true,
ipAddresses: true,
lastSeen: true,
name: true,
online: true,
tags: true,
user: {
displayName: true,
id: true,
name: true,
},
});
const MachineConnectionView = {
args: { first: 100 },
items: {
cursor: true,
node: MachineView,
},
pagination: {
hasNext: true,
hasPrevious: true,
nextCursor: true,
previousCursor: true,
},
};
const UserConnectionView = {
args: { first: 100 },
items: {
cursor: true,
node: UserView,
},
pagination: {
hasNext: true,
hasPrevious: true,
nextCursor: true,
previousCursor: true,
},
};
const rootRoute = createRootRoute({
component: RootLayout,
@@ -16,7 +78,13 @@ const machinesRoute = createRoute({
component: MachinesPage,
});
const routeTree = rootRoute.addChildren([indexRoute, machinesRoute]);
const usersRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/users",
component: UsersPage,
});
const routeTree = rootRoute.addChildren([indexRoute, machinesRoute, usersRoute]);
export const router = createRouter({
basepath: __PREFIX__,
@@ -36,6 +104,7 @@ function RootLayout() {
<nav className="flex gap-4 text-sm">
<Link to="/">Home</Link>
<Link to="/machines">Machines</Link>
<Link to="/users">Users</Link>
</nav>
</header>
@@ -58,12 +127,200 @@ function HomePage() {
}
function MachinesPage() {
const { machines } = useRequest({ machines: { list: MachineConnectionView } });
const [machineItems, loadNext] = useLiveListView(MachineConnectionView, machines);
return (
<section className="space-y-2">
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Machines</h1>
<p className="text-neutral-400">
First migration target: replace the React Router loader/action/SSE model with raw Fate.
</p>
{machineItems.length === 0 ? (
<p className="rounded-lg border border-white/10 bg-white/5 p-4 text-neutral-400">
No machines returned from Headscale.
</p>
) : (
<div className="overflow-hidden rounded-xl border border-white/10">
<table className="min-w-full divide-y divide-white/10 text-left text-sm">
<thead className="bg-white/5 text-xs tracking-wide text-neutral-400 uppercase">
<tr>
<th className="px-4 py-3 font-medium">Machine</th>
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Addresses</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Last seen</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{machineItems.map(({ node: machine }) => (
<MachineRow key={machine.id} machine={machine} />
))}
</tbody>
</table>
</div>
)}
{loadNext ? (
<button
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-200 hover:bg-white/10"
onClick={() => void loadNext()}
type="button"
>
Load more machines
</button>
) : null}
</section>
);
}
function MachineRow({ machine: machineRef }: { machine: ViewRef<"Machine"> }) {
const fate = useFateClient();
const machine = useLiveView(MachineView, machineRef);
const currentName = machine.givenName || machine.name;
const [name, setName] = useState(currentName);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const rename = () => {
const nextName = name.trim();
if (!nextName || nextName === currentName) {
return;
}
startTransition(() => {
void (async () => {
setError(null);
const result = await fate.mutations.machine.rename({
input: { id: machine.id, name: nextName },
view: MachineView,
});
if (result.error) {
setError(result.error.message);
return;
}
setName(nextName);
})();
});
};
return (
<tr className="bg-neutral-950/80">
<td className="px-4 py-3 align-top">
<div className="font-medium text-white">{currentName}</div>
<div className="text-xs text-neutral-500">{machine.id}</div>
<form
className="mt-2 flex max-w-sm gap-2"
onSubmit={(event) => {
event.preventDefault();
rename();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-neutral-900 px-2 py-1 text-xs text-white outline-none focus:border-cyan-400"
disabled={isPending}
onChange={(event) => setName(event.currentTarget.value)}
value={name}
/>
<button
className="rounded-md border border-white/10 px-2 py-1 text-xs text-neutral-200 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isPending || !name.trim() || name.trim() === currentName}
type="submit"
>
{isPending ? "Saving" : "Rename"}
</button>
</form>
{error ? <div className="mt-1 text-xs text-red-300">{error}</div> : null}
{machine.tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{machine.tags.map((tag) => (
<span
className="rounded-full bg-cyan-400/10 px-2 py-0.5 text-xs text-cyan-200"
key={tag}
>
{tag}
</span>
))}
</div>
) : null}
</td>
<td className="px-4 py-3 align-top text-neutral-300">
{machine.user ? machine.user.displayName || machine.user.name : "Tag-owned"}
</td>
<td className="px-4 py-3 align-top text-neutral-300">
<div className="flex flex-col gap-1 font-mono text-xs">
{machine.ipAddresses.map((ip) => (
<span key={ip}>{ip}</span>
))}
</div>
</td>
<td className="px-4 py-3 align-top">
<span
className={
machine.online
? "rounded-full bg-green-400/10 px-2 py-1 text-xs text-green-200"
: "rounded-full bg-neutral-700 px-2 py-1 text-xs text-neutral-300"
}
>
{machine.online ? "Online" : "Offline"}
</span>
</td>
<td className="px-4 py-3 align-top text-neutral-300">{formatDate(machine.lastSeen)}</td>
</tr>
);
}
function UsersPage() {
const { users } = useRequest({ users: { list: UserConnectionView } });
const [userItems, loadNext] = useLiveListView(UserConnectionView, users);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Users</h1>
{userItems.length === 0 ? (
<p className="rounded-lg border border-white/10 bg-white/5 p-4 text-neutral-400">
No users returned from Headscale.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{userItems.map(({ node: user }) => (
<UserCard key={user.id} user={user} />
))}
</div>
)}
{loadNext ? (
<button
className="rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-200 hover:bg-white/10"
onClick={() => void loadNext()}
type="button"
>
Load more users
</button>
) : null}
</section>
);
}
function UserCard({ user: userRef }: { user: ViewRef<"User"> }) {
const user = useLiveView(UserView, userRef);
return (
<article className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="font-medium text-white">{user.displayName || user.name}</div>
<div className="text-sm text-neutral-400">{user.name}</div>
{user.email ? <div className="mt-2 text-sm text-neutral-300">{user.email}</div> : null}
<div className="mt-3 font-mono text-xs text-neutral-500">{user.id}</div>
</article>
);
}
function formatDate(value: string | null | undefined) {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}