mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 23:47:31 +00:00
perf: switch to SSE dispatched changes
Previously we would use naive revalidators which would invalidate EVERY SINGLE action loader every 3 seconds, resulting in several fetches. It would also bubble fetches across the layout actions into the individual pages. This new approach selectively has live stores of resources which then poll for changes on the server side and then dispatches updates to the client via a new /events/live SSE endpoint.
This commit is contained in:
+7
-4
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user