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. - 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 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. - 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 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. - 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. - 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. - `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'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 ### Recommended migration sequence
1. **Remove transitional abstractions** and make the branch clearly one-way toward SPA + raw Fate. 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; - `/fate/live` for SSE and subscription control;
- request context resolves auth using the existing auth service and calls `context.hsApi.getRuntimeClient(...)` directly. - 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. 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. 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. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz. 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. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving. 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 ### Vite+ / VoidZero tooling assessment
@@ -586,15 +603,14 @@ Recommended Vite+ approach:
## Suggested immediate backlog ## Suggested immediate backlog
1. Fix OIDC self-linking authorization. 1. Replace the temporary `hsLive` bridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source.
2. Make API-key sessions opaque/server-side and set explicit auth cookie flags. 2. Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists.
3. Add capability checks to `/settings/agent` loader/action. 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 live-data pause cleanup/refcounting. 4. Fix OIDC self-linking authorization.
5. Make `hsLive` use a stable server credential or principal-scoped cache. 5. Make API-key sessions opaque/server-side and set explicit auth cookie flags.
6. Add raw Fate server/client dependencies and wire the Vite plugin. 6. Add capability checks to `/settings/agent` loader/action.
7. Add the Vite SPA entry and TanStack Router skeleton. 7. Fix live-data pause cleanup/refcounting for unconverted React Router routes.
8. Mount Fate's native `/fate` and `/fate/live` handlers directly. 8. Make `hsLive` use a stable server credential or principal-scoped cache while it still exists.
9. Convert the machines page to raw Fate views/actions/live subscriptions. 9. Serialize config patches with a real mutex/queue and clone config arrays before editing.
10. 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 CI `pnpm run typecheck` and `pnpm run lint`. 11. Add WebSSH top-level dispose and release Go `js.Func` values.
12. 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 type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration"; import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server"; import { createDbClient } from "./db/client.server";
import { live as fateLive } from "./fate";
import { createHeadscaleInterface } from "./headscale/api"; import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader"; import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
@@ -83,6 +84,16 @@ export async function createAppContext(config: HeadplaneConfig) {
} }
: undefined; : 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 { return {
config, config,
db, db,
@@ -91,7 +102,7 @@ export async function createAppContext(config: HeadplaneConfig) {
agents, agents,
auth, auth,
oidc, oidc,
hsLive: createLiveStore([nodesResource, usersResource]), hsLive,
hs: await loadHeadscaleConfig( hs: await loadHeadscaleConfig(
config.headscale.config_path, config.headscale.config_path,
config.headscale.config_strict, config.headscale.config_strict,
+311 -4
View File
@@ -1,14 +1,24 @@
import { import {
createFateServer, createFateServer,
createLiveEventBus, createLiveEventBus,
dataView,
FateRequestError,
list,
resolveSourceById,
type Entity,
type SourceDefinition, type SourceDefinition,
type SourceRegistry, type SourceRegistry,
} from "@nkzw/fate/server"; } from "@nkzw/fate/server";
import type { Context } from "hono"; import type { Context } from "hono";
import type { Machine as HeadscaleMachine, User as HeadscaleUser } from "~/types";
import type { AppContext } from "./context"; import type { AppContext } from "./context";
import type { RuntimeApiClient } from "./headscale/api/endpoints"; 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 type { Principal } from "./web/auth";
import { Capabilities } from "./web/roles";
export interface HonoFateEnv { export interface HonoFateEnv {
Variables: { Variables: {
@@ -27,12 +37,293 @@ type FateAdapterContext = Context<HonoFateEnv>;
export const live = createLiveEventBus(); 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 queries = {};
const lists = {}; 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>; 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< export const fate = createFateServer<
FateContext, FateContext,
@@ -48,7 +339,13 @@ export const fate = createFateServer<
} }
const app = adapterContext.get("appContext"); 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)); const api = app.hsApi.getRuntimeClient(app.auth.getHeadscaleApiKey(principal));
return { return {
@@ -59,11 +356,21 @@ export const fate = createFateServer<
}; };
}, },
live, live,
mutations,
roots, roots,
sources: { sources: {
registry, registry,
getSource(target) { 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 { RouterProvider } from "@tanstack/react-router";
import { Suspense } from "react";
import { FateClient } from "react-fate"; import { FateClient } from "react-fate";
import { createFateClient } from "react-fate/client"; import { createFateClient } from "react-fate/client";
@@ -12,7 +13,11 @@ const fate = createFateClient({
export function App() { export function App() {
return ( return (
<FateClient client={fate}> <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> </FateClient>
); );
} }
+262 -5
View File
@@ -1,4 +1,66 @@
import { Link, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; 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({ const rootRoute = createRootRoute({
component: RootLayout, component: RootLayout,
@@ -16,7 +78,13 @@ const machinesRoute = createRoute({
component: MachinesPage, 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({ export const router = createRouter({
basepath: __PREFIX__, basepath: __PREFIX__,
@@ -36,6 +104,7 @@ function RootLayout() {
<nav className="flex gap-4 text-sm"> <nav className="flex gap-4 text-sm">
<Link to="/">Home</Link> <Link to="/">Home</Link>
<Link to="/machines">Machines</Link> <Link to="/machines">Machines</Link>
<Link to="/users">Users</Link>
</nav> </nav>
</header> </header>
@@ -58,12 +127,200 @@ function HomePage() {
} }
function MachinesPage() { function MachinesPage() {
const { machines } = useRequest({ machines: { list: MachineConnectionView } });
const [machineItems, loadNext] = useLiveListView(MachineConnectionView, machines);
return ( return (
<section className="space-y-2"> <section className="space-y-4">
<h1 className="text-2xl font-semibold">Machines</h1> <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. {machineItems.length === 0 ? (
</p> <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> </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();
}