diff --git a/app/components/ProgressBar.tsx b/app/components/ProgressBar.tsx deleted file mode 100644 index 8f8657e..0000000 --- a/app/components/ProgressBar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useProgressBar } from "react-aria"; - -import cn from "~/utils/cn"; - -export interface ProgressBarProps { - isVisible: boolean; -} - -export default function ProgressBar(props: ProgressBarProps) { - const { isVisible } = props; - const { progressBarProps } = useProgressBar({ - label: "Loading...", - isIndeterminate: true, - }); - - return ( -
- ); -} diff --git a/app/components/link.tsx b/app/components/link.tsx index b8496cf..c4882c8 100644 --- a/app/components/link.tsx +++ b/app/components/link.tsx @@ -42,7 +42,7 @@ export default function Link(props: LinkProps): JSX.Element { } return ( - + {props.children} ); diff --git a/app/components/menu.tsx b/app/components/menu.tsx index 5368903..d09ff07 100644 --- a/app/components/menu.tsx +++ b/app/components/menu.tsx @@ -1,4 +1,3 @@ -// oxlint-disable react/no-multi-comp import { Menu as BaseMenu } from "@base-ui/react/menu"; import type { ComponentProps, JSX, ReactNode } from "react"; diff --git a/app/layout/app.tsx b/app/layout/app.tsx index 5b49b25..89a3e5e 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -1,9 +1,10 @@ -import { Outlet, redirect } from "react-router"; +import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router"; import { ErrorBanner } from "~/components/error-banner"; import StatusBanner from "~/components/status-banner"; import { pruneEphemeralNodes } from "~/server/db/pruner"; import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; +import { usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import log from "~/utils/log"; @@ -11,6 +12,24 @@ import type { Route } from "./+types/app"; import Footer from "./footer"; import Header from "./header"; +export const shouldRevalidate: ShouldRevalidateFunction = ({ + currentUrl, + nextUrl, + formAction, + defaultShouldRevalidate, +}) => { + if (formAction) { + return defaultShouldRevalidate; + } + + // Allow programmatic revalidations (e.g. SSE-triggered) where the URL hasn't changed + if (currentUrl.href === nextUrl.href) { + return defaultShouldRevalidate; + } + + return false; +}; + export async function loader({ request, context, ...rest }: Route.LoaderArgs) { try { const principal = await context.auth.require(request); @@ -52,8 +71,8 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) { // stale link so the user gets prompted to re-link. if (principal.kind === "oidc" && principal.user.headscaleUserId) { try { - const hsUsers = await api.getUsers(); - if (!hsUsers.some((u) => u.id === principal.user.headscaleUserId)) { + const usersSnap = await context.hsLive.get(usersResource, api); + if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) { await context.auth.unlinkHeadscaleUser(principal.user.id); } } catch { diff --git a/app/root.tsx b/app/root.tsx index 9c2f274..77fa08d 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,9 +1,8 @@ import type { LinksFunction, MetaFunction } from "react-router"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; import "@fontsource-variable/inter"; import { ExternalScripts } from "remix-utils/external-scripts"; -import ProgressBar from "~/components/ProgressBar"; import ToastProvider from "~/components/ToastProvider"; import { LiveDataProvider } from "~/utils/live-data"; import { useToastQueue } from "~/utils/toast"; @@ -60,12 +59,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { } export default function App() { - const nav = useNavigation(); - - return ( - <> - - - - ); + return ; } diff --git a/app/routes.ts b/app/routes.ts index 7ded34c..1c919eb 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -6,6 +6,7 @@ export default [ // API Routes ...prefix("/api", [route("/info", "routes/util/info.ts")]), + ...prefix("/events", [route("/live", "routes/util/live.ts")]), // Authentication Routes route("/login", "routes/auth/login/page.tsx"), diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 20340c6..d4fe53a 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -10,6 +10,7 @@ import Button from "~/components/button"; import Card from "~/components/Card"; import Link from "~/components/link"; import LinkAccount from "~/layout/link-account"; +import { usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; import toast from "~/utils/toast"; @@ -33,10 +34,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { let headscaleUsers: { id: string; name: string }[] = []; try { - const [apiUsers, claimed] = await Promise.all([ - api.getUsers(), + const [usersSnap, claimed] = await Promise.all([ + context.hsLive.get(usersResource, api), context.auth.claimedHeadscaleUserIds(), ]); + + const apiUsers = usersSnap.data; headscaleUsers = apiUsers .filter((u) => !claimed.has(u.id)) .map((u) => ({ id: u.id, name: getUserDisplayName(u) })); @@ -68,8 +71,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { let linkedUserName: string | undefined; if (principal.kind === "oidc" && principal.user.headscaleUserId) { try { - const users = await api.getUsers(); - const hsUser = users.find((u) => u.id === principal.user.headscaleUserId); + const usersSnap = await context.hsLive.get(usersResource, api); + const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId); linkedUserName = hsUser?.name; } catch { // API unavailable, skip linked user resolution diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 0b8254f..7f519c3 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -1,6 +1,7 @@ import { data, redirect } from "react-router"; import { isDataWithApiError } from "~/server/headscale/api/error-client"; +import { nodesResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/machine"; @@ -43,6 +44,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } const node = await api.registerNode(user, registrationKey); + await context.hsLive.refresh(nodesResource, api); return redirect(`/machines/${node.id}`); } @@ -78,16 +80,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) { const name = String(formData.get("name")); await api.renameNode(nodeId, name); + await context.hsLive.refresh(nodesResource, api); return { message: "Machine renamed" }; } case "delete": { await api.deleteNode(nodeId); + await context.hsLive.refresh(nodesResource, api); return redirect("/machines"); } case "expire": { await api.expireNode(nodeId); + await context.hsLive.refresh(nodesResource, api); return { message: "Machine expired" }; } @@ -105,6 +110,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { tags.map((tag) => tag.trim()).filter((tag) => tag !== ""), ); + await context.hsLive.refresh(nodesResource, api); return { success: true as const, message: "Tags updated" }; } catch (error) { if (isDataWithApiError(error) && error.data.statusCode === 400) { @@ -169,6 +175,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } await api.approveNodeRoutes(nodeId, newApproved); + await context.hsLive.refresh(nodesResource, api); return { message: "Routes updated" }; } @@ -181,6 +188,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } await api.setNodeUser(nodeId, user); + await context.hsLive.refresh(nodesResource, api); return { message: "Machine reassigned" }; } diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 1903471..0df5995 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -9,6 +9,7 @@ import Chip from "~/components/Chip"; import Link from "~/components/link"; import StatusCircle from "~/components/StatusCircle"; import Tooltip from "~/components/Tooltip"; +import { nodesResource, usersResource } from "~/server/headscale/live-store"; import cn from "~/utils/cn"; import { getOSInfo, getTSVersion } from "~/utils/host-info"; import { mapNodes, sortNodeTags } from "~/utils/node-info"; @@ -40,7 +41,12 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const api = context.hsApi.getRuntimeClient( context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey), ); - const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); + const [nodesSnap, usersSnap] = await Promise.all([ + context.hsLive.get(nodesResource, api), + context.hsLive.get(usersResource, api), + ]); + const nodes = nodesSnap.data; + const users = usersSnap.data; const node = nodes.find((node) => node.id === params.id); const lookup = await context.agents?.lookup([node.nodeKey]); diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 01fc91e..faff4d3 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -6,6 +6,7 @@ import Input from "~/components/Input"; import Link from "~/components/link"; import PageError from "~/components/page-error"; import Tooltip from "~/components/Tooltip"; +import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; import { mapNodes, sortNodeTags } from "~/utils/node-info"; @@ -29,7 +30,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { const api = context.hsApi.getRuntimeClient( context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey), ); - const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); + const [nodesSnap, usersSnap] = await Promise.all([ + context.hsLive.get(nodesResource, api), + context.hsLive.get(usersResource, api), + ]); + const nodes = nodesSnap.data; + const users = usersSnap.data; let magic: string | undefined; if (context.hs.readable()) { diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index 0293cab..6dd9f85 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -6,6 +6,7 @@ import Link from "~/components/link"; import Notice from "~/components/Notice"; import Select from "~/components/Select"; import TableList from "~/components/TableList"; +import { usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import type { PreAuthKey } from "~/types"; import type { User } from "~/types/User"; @@ -22,7 +23,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); const api = context.hsApi.getRuntimeClient(apiKey); - const users = await api.getUsers(); + const usersSnap = await context.hsLive.get(usersResource, api); + const users = usersSnap.data; let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[]; let missing: { user: User; error: unknown }[] = []; diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 2782303..d0d8a62 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import PageError from "~/components/page-error"; +import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { Capabilities, Roles } from "~/server/web/roles"; import type { Role } from "~/server/web/roles"; import type { Machine, User } from "~/types"; @@ -55,7 +56,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { try { const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); const api = context.hsApi.getRuntimeClient(apiKey); - [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); + const [nodesSnap, usersSnap] = await Promise.all([ + context.hsLive.get(nodesResource, api), + context.hsLive.get(usersResource, api), + ]); + nodes = nodesSnap.data; + apiUsers = usersSnap.data; } catch (error) { log.warn("api", "Failed to fetch Headscale API data: %s", String(error)); apiError = diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index f146d99..c261496 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -1,5 +1,6 @@ import { data } from "react-router"; +import { usersResource } from "~/server/headscale/live-store"; import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; import type { Role } from "~/server/web/roles"; @@ -38,6 +39,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { } await api.createUser(name, email, displayName); + await context.hsLive.refresh(usersResource, api); return { message: "User created successfully" }; } case "delete_user": { @@ -49,6 +51,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { } await api.deleteUser(userId); + await context.hsLive.refresh(usersResource, api); return { message: "User deleted successfully" }; } case "rename_user": { @@ -72,6 +75,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { } await api.renameUser(userId, newName); + await context.hsLive.refresh(usersResource, api); return { message: "User renamed successfully" }; } case "reassign_user": { diff --git a/app/routes/util/live.ts b/app/routes/util/live.ts new file mode 100644 index 0000000..bb1d438 --- /dev/null +++ b/app/routes/util/live.ts @@ -0,0 +1,60 @@ +import { nodesResource, usersResource } from "~/server/headscale/live-store"; +import log from "~/utils/log"; + +import type { Route } from "./+types/live"; + +export async function loader({ request, context }: Route.LoaderArgs) { + const principal = await context.auth.require(request); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + + // Ensure resources are loaded before streaming + await Promise.all([ + context.hsLive.get(nodesResource, api), + context.hsLive.get(usersResource, api), + ]); + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const send = (event: string, data: unknown) => { + controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); + }; + + const versions = context.hsLive.getVersions(); + log.debug("sse", "Client connected, sending hello with versions: %o", versions); + send("hello", versions); + + const unsubscribe = context.hsLive.subscribe((resource, version) => { + log.debug("sse", "Sending change event: %s v%s", resource, version); + send("changed", { resource, version }); + }); + + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(": heartbeat\n\n")); + } catch { + clearInterval(heartbeat); + } + }, 30_000); + + request.signal.addEventListener("abort", () => { + log.debug("sse", "Client disconnected"); + unsubscribe(); + clearInterval(heartbeat); + try { + controller.close(); + } catch {} + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts index f1b32e7..0ad98fc 100644 --- a/app/server/db/pruner.ts +++ b/app/server/db/pruner.ts @@ -1,5 +1,6 @@ import { eq, isNotNull } from "drizzle-orm"; +import { nodesResource } from "~/server/headscale/live-store"; import log from "~/utils/log"; import type { Route } from "../../layout/+types/app"; @@ -45,4 +46,8 @@ export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs }); await Promise.all(promises.map((p) => p())); + + if (toPrune.length > 0) { + await context.hsLive.refresh(nodesResource, api); + } } diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts index 056351f..fd72432 100644 --- a/app/server/headscale/api/index.ts +++ b/app/server/headscale/api/index.ts @@ -1,14 +1,17 @@ -import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import { dereference } from '@readme/openapi-parser'; -import type { OpenAPIV2 } from 'openapi-types'; -import { data } from 'react-router'; -import { Agent, type Dispatcher, request } from 'undici'; -import log from '~/utils/log'; -import endpointSets, { RuntimeApiClient } from './endpoints'; -import { undiciToFriendlyError } from './error'; -import { HeadscaleAPIError, isApiError } from './error-client'; -import { detectApiVersion, isAtLeast, type Version } from './version'; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +import { dereference } from "@readme/openapi-parser"; +import type { OpenAPIV2 } from "openapi-types"; +import { data } from "react-router"; +import { Agent, type Dispatcher, request } from "undici"; + +import log from "~/utils/log"; + +import endpointSets, { RuntimeApiClient } from "./endpoints"; +import { undiciToFriendlyError } from "./error"; +import { HeadscaleAPIError, isApiError } from "./error-client"; +import { detectApiVersion, isAtLeast, type Version } from "./version"; /** * A low-level composed interface for interacting with the Headscale API. @@ -19,82 +22,82 @@ import { detectApiVersion, isAtLeast, type Version } from './version'; * determine the implementations of API methods when requested for use. */ export interface HeadscaleApiInterface { - /** - * The underlying Undici agent used for making requests. - */ - undiciAgent: Agent; + /** + * The underlying Undici agent used for making requests. + */ + undiciAgent: Agent; - /** - * The base URL of the Headscale API. - */ - baseUrl: string; + /** + * The base URL of the Headscale API. + */ + baseUrl: string; - /** - * The OpenAPI hashes retrieved from the Headscale instance at runtime. - * This is used to determine which implementations of API methods to use. - */ - openapiHashes: Record | null; + /** + * The OpenAPI hashes retrieved from the Headscale instance at runtime. + * This is used to determine which implementations of API methods to use. + */ + openapiHashes: Record | null; - /** - * The detected API version of the connected Headscale instance. - */ - apiVersion: Version; + /** + * The detected API version of the connected Headscale instance. + */ + apiVersion: Version; - /** - * Retrieves a runtime API client for the given API key. - * - * @param apiKey The API key to use for authentication. - * @returns A `RuntimeApiClient` instance for interacting with the API. - */ - getRuntimeClient(apiKey: string): RuntimeApiClient; + /** + * Retrieves a runtime API client for the given API key. + * + * @param apiKey The API key to use for authentication. + * @returns A `RuntimeApiClient` instance for interacting with the API. + */ + getRuntimeClient(apiKey: string): RuntimeApiClient; - /** - * A set of helper methods made available to API method implementations. - * The idea is to make interacting with the API easier by providing - * common functionality that can be reused across multiple methods. - */ - clientHelpers: { - /** - * Checks if the connected Headscale instance's API version - * is at least the specified version. - * - * @param version The version to check against. - * @returns `true` if the API version is at least the specified version, `false` otherwise. - */ - isAtleast(version: Version): boolean; + /** + * A set of helper methods made available to API method implementations. + * The idea is to make interacting with the API easier by providing + * common functionality that can be reused across multiple methods. + */ + clientHelpers: { + /** + * Checks if the connected Headscale instance's API version + * is at least the specified version. + * + * @param version The version to check against. + * @returns `true` if the API version is at least the specified version, `false` otherwise. + */ + isAtleast(version: Version): boolean; - /** - * Makes a raw fetch request to the Headscale API via the Undici agent. - * This method is used internally by API method implementations - * to make requests to the Headscale API. - * - * @param path The API path to request. - * @param options Optional request options. - * @returns A promise that resolves to the response data. - */ - rawFetch( - path: string, - options?: Partial, - ): Promise; + /** + * Makes a raw fetch request to the Headscale API via the Undici agent. + * This method is used internally by API method implementations + * to make requests to the Headscale API. + * + * @param path The API path to request. + * @param options Optional request options. + * @returns A promise that resolves to the response data. + */ + rawFetch( + path: string, + options?: Partial, + ): Promise; - /** - * Makes a typed API fetch request to the Headscale API. - * This method is used internally by API method implementations - * to make requests to the Headscale API and parse the response. - * - * @param method The HTTP method to use. - * @param apiPath The API path to request. - * @param apiKey The API key to use for authentication. - * @param bodyOrQuery Optional body or query parameters. - * @returns A promise that resolves to the typed response data. - */ - apiFetch( - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', - apiPath: `v1/${string}`, - apiKey: string, - bodyOrQuery?: Record, - ): Promise; - }; + /** + * Makes a typed API fetch request to the Headscale API. + * This method is used internally by API method implementations + * to make requests to the Headscale API and parse the response. + * + * @param method The HTTP method to use. + * @param apiPath The API path to request. + * @param apiKey The API key to use for authentication. + * @param bodyOrQuery Optional body or query parameters. + * @returns A promise that resolves to the typed response data. + */ + apiFetch( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + apiPath: `v1/${string}`, + apiKey: string, + bodyOrQuery?: Record, + ): Promise; + }; } /** @@ -105,183 +108,168 @@ export interface HeadscaleApiInterface { * @returns A promise that resolves to a `HeadscaleApiClient` instance. */ export async function createHeadscaleInterface( - baseUrl: string, - certPath?: string, + baseUrl: string, + certPath?: string, ): Promise { - const undiciAgent = await createUndiciAgent(certPath); - let openapiHashes: Record | null = null; - let apiVersion: Version; + const undiciAgent = await createUndiciAgent(certPath); + let openapiHashes: Record | null = null; + let apiVersion: Version; - const rawFetch = async ( - url: string, - options?: Partial, - ): Promise => { - const method = options?.method ?? 'GET'; - log.debug('api', '%s %s', method, url); + const rawFetch = async ( + url: string, + options?: Partial, + ): Promise => { + const method = options?.method ?? "GET"; + log.debug("api", "%s %s", method, url); - try { - const res = await request(new URL(url, baseUrl), { - dispatcher: undiciAgent, - headers: { - ...options?.headers, - Accept: 'application/json', - 'User-Agent': `Headplane/${__VERSION__}`, - }, + try { + const res = await request(new URL(url, baseUrl), { + dispatcher: undiciAgent, + headers: { + ...options?.headers, + Accept: "application/json", + "User-Agent": `Headplane/${__VERSION__}`, + }, - body: options?.body, - method, - }); + body: options?.body, + method, + }); - return res; - } catch (error) { - const errorBody = undiciToFriendlyError(error, `${method} ${url}`); - throw data(errorBody, { - status: 502, - statusText: 'Bad Gateway', - }); - } - }; + return res; + } catch (error) { + const errorBody = undiciToFriendlyError(error, `${method} ${url}`); + throw data(errorBody, { + status: 502, + statusText: "Bad Gateway", + }); + } + }; - const apiFetch = async ( - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', - apiPath: `v1/${string}`, - apiKey: string, - bodyOrQuery?: Record, - ): Promise => { - let url = `/api/${apiPath}`; - const options: Partial = { - method: method, - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }; + const apiFetch = async ( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + apiPath: `v1/${string}`, + apiKey: string, + bodyOrQuery?: Record, + ): Promise => { + let url = `/api/${apiPath}`; + const options: Partial = { + method: method, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }; - if (bodyOrQuery) { - if (method === 'GET' || method === 'DELETE') { - // Filter out undefined values + if (bodyOrQuery) { + if (method === "GET" || method === "DELETE") { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(bodyOrQuery)) { + if (value !== undefined) { + params.append(key, String(value)); + } + } - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(bodyOrQuery)) { - if (value !== undefined) { - params.append(key, String(value)); - } - } + if ([...params.keys()].length > 0) { + url += `?${params.toString()}`; + } + } else { + options.body = JSON.stringify(bodyOrQuery); + options.headers = { + ...options.headers, + "Content-Type": "application/json", + }; + } + } - if ([...params.keys()].length > 0) { - url += `?${params.toString()}`; - } - } else { - options.body = JSON.stringify(bodyOrQuery); - options.headers = { - ...options.headers, - 'Content-Type': 'application/json', - }; - } - } + const res = await rawFetch(url, options); + if (res.statusCode >= 400) { + log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode); + const rawData = await res.body.text(); + const jsonData = (() => { + try { + return JSON.parse(rawData) as Record; + } catch { + return null; + } + })(); - const res = await rawFetch(url, options); - if (res.statusCode >= 400) { - log.debug( - 'api', - '%s %s failed with status %d', - method, - apiPath, - res.statusCode, - ); + throw data( + { + requestUrl: `${method} ${apiPath}`, + statusCode: res.statusCode, + rawData, + data: jsonData, + } satisfies HeadscaleAPIError, + { status: 502, statusText: "Bad Gateway" }, + ); + } - const rawData = await res.body.text(); - const jsonData = (() => { - try { - return JSON.parse(rawData) as Record; - } catch { - return null; - } - })(); + return res.body.json() as Promise; + }; - throw data( - { - requestUrl: `${method} ${apiPath}`, - statusCode: res.statusCode, - rawData, - data: jsonData, - } satisfies HeadscaleAPIError, - { - status: 502, - statusText: 'Bad Gateway', - }, - ); - } + /** + * Polls the OpenAPI spec endpoint and generates operation hashes. + * This is used to determine which implementations of API methods to use. + * + * @returns A promise that resolves to the OpenAPI operation hashes. + */ + async function fetchAndHashOpenapi(): Promise | null> { + try { + const res = await rawFetch("/swagger/v1/openapiv2.json"); + if (res.statusCode !== 200) { + log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode); + return null; + } - return res.body.json() as Promise; - }; + const body = await res.body.json(); + const spec = await dereference(body as OpenAPIV2.Document); + const hashes = generateSpecHashes(spec); + log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length); + return hashes; + } catch (error) { + if (isApiError(error)) { + log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode); + } - /** - * Polls the OpenAPI spec endpoint and generates operation hashes. - * This is used to determine which implementations of API methods to use. - * - * @returns A promise that resolves to the OpenAPI operation hashes. - */ - async function fetchAndHashOpenapi(): Promise | null> { - try { - const res = await rawFetch('/swagger/v1/openapiv2.json'); - if (res.statusCode !== 200) { - log.error('api', 'Failed to fetch OpenAPI spec: %d', res.statusCode); - return null; - } + return null; + } + } - const body = await res.body.json(); - const spec = await dereference(body as OpenAPIV2.Document); - const hashes = generateSpecHashes(spec); - log.debug( - 'api', - 'OpenAPI hashes updated (%d endpoints)', - Object.keys(hashes).length, - ); - return hashes; - } catch (error) { - if (isApiError(error)) { - log.debug('api', 'Failed to fetch OpenAPI spec: %d', error.statusCode); - } + const isAtleast = (version: Version): boolean => { + return isAtLeast(apiVersion, version); + }; - return null; - } - } + openapiHashes = await fetchAndHashOpenapi(); + apiVersion = detectApiVersion(openapiHashes); - const isAtleast = (version: Version): boolean => { - return isAtLeast(apiVersion, version); - }; + setInterval(async () => { + const hashes = await fetchAndHashOpenapi(); + if (hashes) { + openapiHashes = hashes; + apiVersion = detectApiVersion(openapiHashes); + } + }, 60_000); // every 60 seconds - openapiHashes = await fetchAndHashOpenapi(); - apiVersion = detectApiVersion(openapiHashes); - - setInterval(async () => { - const hashes = await fetchAndHashOpenapi(); - if (hashes) { - openapiHashes = hashes; - apiVersion = detectApiVersion(openapiHashes); - } - }, 60_000); // every 60 seconds - - return { - undiciAgent, - baseUrl, - openapiHashes, - apiVersion, - getRuntimeClient: (apiKey: string) => - endpointSets( - { - rawFetch, - apiFetch, - isAtleast, - }, - apiKey, - ), - clientHelpers: { - rawFetch, - apiFetch, - isAtleast, - }, - }; + return { + undiciAgent, + baseUrl, + openapiHashes, + apiVersion, + getRuntimeClient: (apiKey: string) => { + return endpointSets( + { + rawFetch, + apiFetch, + isAtleast, + }, + apiKey, + ); + }, + clientHelpers: { + rawFetch, + apiFetch, + isAtleast, + }, + }; } /** @@ -291,50 +279,50 @@ export async function createHeadscaleInterface( * @returns A promise that resolves to an `Agent` instance. */ async function createUndiciAgent(certPath?: string): Promise { - if (!certPath) { - return new Agent(); - } + if (!certPath) { + return new Agent(); + } - try { - log.debug('config', 'Loading certificate from %s', certPath); - const data = await readFile(certPath, 'utf8'); + try { + log.debug("config", "Loading certificate from %s", certPath); + const data = await readFile(certPath, "utf8"); - log.info('config', 'Using certificate from %s', certPath); - return new Agent({ connect: { ca: data.trim() } }); - } catch (error) { - log.error('config', 'Failed to load Headscale TLS cert: %s', error); - log.debug('config', 'Error Details: %o', error); - return new Agent(); - } + log.info("config", "Using certificate from %s", certPath); + return new Agent({ connect: { ca: data.trim() } }); + } catch (error) { + log.error("config", "Failed to load Headscale TLS cert: %s", error); + log.debug("config", "Error Details: %o", error); + return new Agent(); + } } function generateSpecHashes(spec: OpenAPIV2.Document) { - const hashes: Record = {}; - const seen = new Set(); + const hashes: Record = {}; + const seen = new Set(); - for (const [path, item] of Object.entries(spec.paths)) { - for (const [method, operation] of Object.entries(item)) { - if (typeof operation !== 'object') { - continue; - } + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (typeof operation !== "object") { + continue; + } - const { parameters, responses } = operation as OpenAPIV2.OperationObject; - const raw = JSON.stringify( - { - path, - method: method.toUpperCase(), - parameters, - responses, - }, - Object.keys({ path, method, parameters, responses }).sort(), - ); + const { parameters, responses } = operation as OpenAPIV2.OperationObject; + const raw = JSON.stringify( + { + path, + method: method.toUpperCase(), + parameters, + responses, + }, + Object.keys({ path, method, parameters, responses }).sort(), + ); - const hash = createHash('md5').update(raw).digest('hex').slice(0, 16); - const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; - seen.add(final); - hashes[`${method.toUpperCase()} ${path}`] = final; - } - } + const hash = createHash("md5").update(raw).digest("hex").slice(0, 16); + const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; + seen.add(final); + hashes[`${method.toUpperCase()} ${path}`] = final; + } + } - return hashes; + return hashes; } diff --git a/app/server/headscale/live-store.ts b/app/server/headscale/live-store.ts new file mode 100644 index 0000000..92b83af --- /dev/null +++ b/app/server/headscale/live-store.ts @@ -0,0 +1,193 @@ +import log from "~/utils/log"; + +import type { RuntimeApiClient } from "./api/endpoints"; + +/** + * Defines a resource that can be fetched and polled by the live store. + */ +export interface ResourceDefinition { + /** + * A unique key to identify the resource + */ + readonly key: string; + + /** + * How often to poll for changes (in milliseconds) + */ + readonly pollInterval: number; + + /** + * A callback to fire to get the latest data for this resource + */ + readonly fetch: (client: RuntimeApiClient) => Promise; +} + +/** + * Helper function to define a resource with proper typing to be used + * as a keying input for the live store. + * @param key A unique key to identify the resource + * @param config The resource configuration + */ +export function defineResource( + key: string, + config: Omit, "key">, +): ResourceDefinition { + return { key, ...config }; +} + +export const nodesResource = defineResource("nodes", { + pollInterval: 5_000, + fetch: (api) => api.getNodes(), +}); + +export const usersResource = defineResource("users", { + pollInterval: 15_000, + fetch: (api) => api.getUsers(), +}); + +interface Snapshot { + data: T; + version: string; + fetchedAt: number; +} + +type ChangeListener = (resourceKey: string, version: string) => void; + +export interface LiveStore { + get(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise>; + refresh(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise; + getVersions(): Record; + subscribe(listener: ChangeListener): () => void; + dispose(): void; +} + +export function createLiveStore(resources: ResourceDefinition[]): LiveStore { + const snapshots = new Map>(); + const serializedCache = new Map(); + const listeners = new Set(); + const intervals = new Map>(); + let storedApiClient: RuntimeApiClient | undefined; + let versionCounter = 0; + + function notifyListeners(resourceKey: string, version: string) { + for (const listener of listeners) { + listener(resourceKey, version); + } + } + + async function fetchResource( + resource: ResourceDefinition, + apiClient: RuntimeApiClient, + ): Promise { + const data = await resource.fetch(apiClient); + const json = JSON.stringify(data); + const previousJson = serializedCache.get(resource.key); + + if (previousJson === json) { + log.debug("api", "Live store: %s unchanged", resource.key); + return; + } + + const version = String(++versionCounter); + serializedCache.set(resource.key, json); + + const snapshot: Snapshot = { + data, + version, + fetchedAt: Date.now(), + }; + + snapshots.set(resource.key, snapshot); + log.debug("api", "Live store: %s updated (v%s)", resource.key, version); + + if (previousJson !== undefined) { + notifyListeners(resource.key, version); + } + } + + function ensurePolling(resource: ResourceDefinition) { + if (intervals.has(resource.key)) { + return; + } + + const interval = setInterval(async () => { + if (!storedApiClient) { + return; + } + + try { + await fetchResource(resource, storedApiClient); + } catch (error) { + log.error("api", "Live store: failed to poll %s", resource.key, error); + } + }, resource.pollInterval); + + intervals.set(resource.key, interval); + log.debug( + "api", + "Live store: started polling %s every %dms", + resource.key, + resource.pollInterval, + ); + } + + function findResource(key: string): ResourceDefinition | undefined { + return resources.find((r) => r.key === key); + } + + return { + async get( + resource: ResourceDefinition, + apiClient: RuntimeApiClient, + ): Promise> { + storedApiClient = apiClient; + const def = findResource(resource.key); + if (!def) { + throw new Error(`LiveStore: unknown resource "${resource.key}"`); + } + + if (!snapshots.has(resource.key)) { + await fetchResource(def, apiClient); + } + + ensurePolling(def); + return snapshots.get(resource.key) as Snapshot; + }, + + async refresh(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise { + storedApiClient = apiClient; + const def = findResource(resource.key); + if (!def) { + throw new Error(`LiveStore: unknown resource "${resource.key}"`); + } + + await fetchResource(def, apiClient); + }, + + getVersions(): Record { + const versions: Record = {}; + for (const [key, snapshot] of snapshots) { + versions[key] = snapshot.version; + } + return versions; + }, + + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + dispose() { + for (const interval of intervals.values()) { + clearInterval(interval); + } + intervals.clear(); + snapshots.clear(); + serializedCache.clear(); + listeners.clear(); + storedApiClient = undefined; + }, + }; +} diff --git a/app/server/index.ts b/app/server/index.ts index f66cbc5..6fe2772 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -10,6 +10,7 @@ import { loadConfig } from "./config/load"; import { createDbClient } from "./db/client.server"; import { createHeadscaleInterface } from "./headscale/api"; import { loadHeadscaleConfig } from "./headscale/config-loader"; +import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; import { createHeadplaneAgent } from "./hp-agent"; import { createAuthService } from "./web/auth"; @@ -53,8 +54,11 @@ declare module "react-router" { interface AppLoadContext extends LoadContext {} } +const hsLive = createLiveStore([nodesResource, usersResource]); + const appLoadContext = { config, + hsLive, hs: await loadHeadscaleConfig( config.headscale.config_path, config.headscale.config_strict, diff --git a/app/utils/live-data.tsx b/app/utils/live-data.tsx index 910966c..7467657 100644 --- a/app/utils/live-data.tsx +++ b/app/utils/live-data.tsx @@ -1,67 +1,142 @@ -import { createContext, useContext, useEffect, useState } from 'react'; -import { useRevalidator } from 'react-router'; -import { useInterval } from 'usehooks-ts'; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useRevalidator } from "react-router"; -const LiveDataPausedContext = createContext({ - paused: false, - setPaused: (_: boolean) => {}, +const LiveDataContext = createContext({ + paused: false, + setPaused: (_: boolean) => {}, }); interface LiveDataProps { - children: React.ReactNode; + children: React.ReactNode; +} + +type Versions = Record; +interface ChangedEvent { + resource: string; + version: string; } export function LiveDataProvider({ children }: LiveDataProps) { - const [paused, setPaused] = useState(false); - const revalidator = useRevalidator(); + const revalidator = useRevalidator(); + const [paused, setPaused] = useState(false); - // Document is marked as optional here because it's not available in SSR - // The optional chain means if document is not defined, visible is false - const [visible, setVisible] = useState( - () => - typeof document !== 'undefined' && document.visibilityState === 'visible', - ); + // This ref is a bit sus but it's needed to ensure the SSE handshake does + // not re-establish on every revalidation. The SSE stream is always stable + const revalidatorRef = useRef(revalidator); + revalidatorRef.current = revalidator; - // Function to revalidate safely - const revalidateIfIdle = () => { - if (revalidator.state === 'idle') { - revalidator.revalidate(); - } - }; + const versionsRef = useRef({}); + const isTabDirtyRef = useRef(false); - useEffect(() => { - const handleVisibilityChange = () => { - setVisible(document.visibilityState === 'visible'); - if (!paused && document.visibilityState === 'visible') { - revalidateIfIdle(); - } - }; + const eventSourceRef = useRef(null); + const reconnectTimer = useRef | null>(null); + const backoffRef = useRef(1000); - window.addEventListener('online', revalidateIfIdle); - document.addEventListener('focus', revalidateIfIdle); - document.addEventListener('visibilitychange', handleVisibilityChange); + const revalidateIfIdle = useCallback(() => { + if (revalidatorRef.current.state === "idle") { + revalidatorRef.current.revalidate(); + } + }, []); - return () => { - window.removeEventListener('online', revalidateIfIdle); - document.removeEventListener('focus', revalidateIfIdle); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [paused, revalidator]); + // SSE connection + useEffect(() => { + if (paused) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } - // Poll only when visible and not paused - useInterval(revalidateIfIdle, visible && !paused ? 3000 : null); + return; + } - return ( - - {children} - - ); + function connect() { + const sse = new EventSource(`${__PREFIX__}/events/live`); + eventSourceRef.current = sse; + + sse.addEventListener("hello", (e) => { + backoffRef.current = 1000; + try { + versionsRef.current = JSON.parse(e.data) as Versions; + } catch {} + }); + + sse.addEventListener("changed", (e) => { + try { + const data = JSON.parse(e.data) as ChangedEvent; + const current = versionsRef.current[data.resource]; + if (current !== undefined && data.version === current) { + return; + } + + versionsRef.current = { + ...versionsRef.current, + [data.resource]: data.version, + }; + + if (document.visibilityState !== "visible") { + isTabDirtyRef.current = true; + return; + } + + revalidateIfIdle(); + } catch {} + }); + + sse.onerror = () => { + sse.close(); + eventSourceRef.current = null; + const delay = backoffRef.current; + backoffRef.current = Math.min(delay * 2, 30_000); + reconnectTimer.current = setTimeout(connect, delay); + }; + } + + connect(); + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + reconnectTimer.current = null; + } + }; + }, [paused, revalidateIfIdle]); + + // If the tab becomes visible and is marked dirty, revalidate + useEffect(() => { + const visibilityCallback = () => { + if (document.visibilityState === "visible" && isTabDirtyRef.current) { + isTabDirtyRef.current = false; + revalidateIfIdle(); + } + }; + + document.addEventListener("visibilitychange", visibilityCallback); + return () => { + document.removeEventListener("visibilitychange", visibilityCallback); + }; + }, [revalidateIfIdle]); + + // Force a revalidation when the app comes back online + useEffect(() => { + window.addEventListener("online", revalidateIfIdle); + return () => { + window.removeEventListener("online", revalidateIfIdle); + }; + }, [revalidateIfIdle]); + + return ( + {children} + ); } export function useLiveData() { - const context = useContext(LiveDataPausedContext); - return { - pause: () => context.setPaused(true), - resume: () => context.setPaused(false), - }; + const context = useContext(LiveDataContext); + return { + pause: () => context.setPaused(true), + resume: () => context.setPaused(false), + }; } diff --git a/package.json b/package.json index c2c4fbe..318a951 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "typescript": "^5.9.3", "ulidx": "2.4.1", "undici": "7.22.0", - "usehooks-ts": "^3.1.1", "vite": "8.0.0-beta.0", "vite-tsconfig-paths": "^6.0.4", "vitepress": "next", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae9e632..df470e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,9 +209,6 @@ importers: undici: specifier: 7.22.0 version: 7.22.0 - usehooks-ts: - specifier: ^3.1.1 - version: 3.1.1(react@19.2.4) vite: specifier: 8.0.0-beta.0 version: 8.0.0-beta.0(@types/node@25.3.1)(esbuild@0.25.12)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2) @@ -4073,9 +4070,6 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -4813,12 +4807,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - usehooks-ts@3.1.1: - resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==} - engines: {node: '>=16.15.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc - utf-8-validate@5.0.10: resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} engines: {node: '>=6.14.2'} @@ -8923,8 +8911,6 @@ snapshots: lodash.camelcase@4.3.0: {} - lodash.debounce@4.0.8: {} - lodash@4.17.23: {} long@5.3.2: {} @@ -9827,11 +9813,6 @@ snapshots: dependencies: react: 19.2.4 - usehooks-ts@3.1.1(react@19.2.4): - dependencies: - lodash.debounce: 4.0.8 - react: 19.2.4 - utf-8-validate@5.0.10: dependencies: node-gyp-build: 4.8.4