feat: switch agent to a periodic dump rather than long running process

This commit is contained in:
Aarnav Tale
2026-03-27 13:19:16 -04:00
parent 2c57187628
commit ee59a2d06d
17 changed files with 521 additions and 464 deletions
+9 -2
View File
@@ -56,9 +56,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
return {
agent: context.agents?.agentID(),
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
magic,
node: enhancedNode,
@@ -77,7 +84,7 @@ export default function Page({
const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey);
const tags = uiTagsForNode(node, agent?.nodeKey === node.nodeKey);
return tags;
}, [node, agent]);
+14 -3
View File
@@ -47,9 +47,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
return {
agent: context.agents?.agentID(),
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
magic,
nodes,
@@ -211,7 +218,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<table className="w-full min-w-160 table-auto rounded-lg">
<thead className="text-mist-600 dark:text-mist-300">
<tr className="px-0.5 text-left">
<th
@@ -371,7 +378,11 @@ export default function Page({ loaderData }: Route.ComponentProps) {
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
+104
View File
@@ -0,0 +1,104 @@
import { useFetcher } from "react-router";
import Button from "~/components/button";
import Link from "~/components/link";
import Notice from "~/components/notice";
import StatusCircle from "~/components/status-circle";
import Text from "~/components/text";
import Title from "~/components/title";
import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!context.agents) {
return { enabled: false as const };
}
const sync = context.agents.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
nodeCount: sync.nodeCount,
error: sync.error,
};
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
}
await context.agents.triggerSync();
const sync = context.agents.lastSync();
return { success: !sync.error, error: sync.error };
}
export default function Page({ loaderData }: Route.ComponentProps) {
const fetcher = useFetcher<typeof action>();
const isSyncing = fetcher.state !== "idle";
if (!loaderData.enabled) {
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
documentation
</Link>
</Notice>
</div>
);
}
const hasError = Boolean(loaderData.error);
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<div className="flex w-full flex-col sm:w-2/3">
<Title>Headplane Agent</Title>
<Text>
The Headplane Agent syncs node information like OS version and connectivity details from
your Tailnet.
</Text>
</div>
<div className="flex items-center gap-3">
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
</div>
<div className="flex flex-col gap-2">
<Text>
<span className="font-medium">Last synced: </span>
{loaderData.syncedAt ? (
<span suppressHydrationWarning>{formatTimeDelta(new Date(loaderData.syncedAt))}</span>
) : (
"Never"
)}
</Text>
<Text>
<span className="font-medium">Nodes synced: </span>
{loaderData.nodeCount}
</Text>
</div>
{loaderData.error ? (
<Notice variant="error" title="Sync Error">
{loaderData.error}
</Notice>
) : undefined}
<fetcher.Form method="post">
<Button type="submit" variant="heavy" disabled={isSyncing}>
{isSyncing ? "Syncing…" : "Sync Now"}
</Button>
</fetcher.Form>
</div>
);
}
+13
View File
@@ -40,6 +40,19 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</Link>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Headplane Agent</h1>
<p>
The Headplane Agent syncs node information like OS version and connectivity details from
your Tailnet.
</p>
</div>
<Link to="/settings/agent">
<div className="flex items-center text-lg font-medium">
Agent Settings
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</Link>
{config && isOidcEnabled ? (
<>
<div className="flex w-full flex-col sm:w-2/3">
+7 -2
View File
@@ -8,6 +8,7 @@ import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
@@ -31,7 +32,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents?.agentID()) {
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
@@ -153,7 +154,7 @@ function generateHostname(username: string) {
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents?.agentID()) {
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
@@ -175,6 +176,10 @@ export async function action({ request, context }: Route.ActionArgs) {
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
context.agents?.triggerSync().catch((err) => {
log.debug("agent", "Background agent sync failed: %s", err);
});
}
export const links: Route.LinksFunction = () => [
+4 -1
View File
@@ -19,8 +19,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let closed = false;
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => {
if (!closed) {
if (closed) return;
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
closed = true;
}
};