feat: switch agent fetching to the server side

this brings the benefit of fitting in the revalidator lifecycle we have
created via the useLiveData hook.
This commit is contained in:
Aarnav Tale 2025-03-24 11:39:02 -04:00
parent 9a1051b9af
commit 73ea35980d
7 changed files with 30 additions and 75 deletions

View File

@ -53,5 +53,11 @@ func httpToWs(controlURL string) (string, error) {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme) return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
} }
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil return u.String(), nil
} }

View File

@ -11,9 +11,6 @@ export default [
route('/oidc/callback', 'routes/auth/oidc-callback.ts'), route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'), route('/oidc/start', 'routes/auth/oidc-start.ts'),
// API
route('/api/agent', 'routes/api/agent.ts'),
// All the main logged-in dashboard routes // All the main logged-in dashboard routes
// Double nested to separate error propagations // Double nested to separate error propagations
layout('layouts/shell.tsx', [ layout('layouts/shell.tsx', [

View File

@ -1,39 +0,0 @@
import { LoaderFunctionArgs } from 'react-router';
import { hp_getSingleton, hp_getSingletonUnsafe } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const data = hp_getSingletonUnsafe('ws_agent_data');
if (!data) {
return new Response(JSON.stringify({ error: 'Agent data unavailable' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
const qp = new URLSearchParams(request.url.split('?')[1]);
const nodeIds = qp.get('node_ids')?.split(',');
if (!nodeIds) {
return new Response(JSON.stringify({ error: 'No node IDs provided' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
const entries = data.toJSON();
const missing = nodeIds.filter((nodeID) => !entries[nodeID]);
if (missing.length > 0) {
const requestCall = hp_getSingleton('ws_fetch_data');
requestCall(missing);
}
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
},
});
}

View File

@ -8,7 +8,6 @@ import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types'; import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import useAgent from '~/utils/use-agent';
import { menuAction } from './action'; import { menuAction } from './action';
import MachineRow from './components/machine'; import MachineRow from './components/machine';
import NewMachine from './dialogs/new'; import NewMachine from './dialogs/new';
@ -45,6 +44,7 @@ export async function loader({
server: context.config.headscale.url, server: context.config.headscale.url,
publicServer: context.config.headscale.public_url, publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(), agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
}; };
} }
@ -54,7 +54,6 @@ export async function action(request: ActionFunctionArgs) {
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
const { data: stats } = useAgent(data.nodes.map((node) => node.nodeKey));
return ( return (
<> <>
@ -120,10 +119,10 @@ export default function Page() {
)} )}
users={data.users} users={data.users}
magic={data.magic} magic={data.magic}
stats={stats?.[machine.nodeKey]}
// If we pass undefined, the column will not be rendered // If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured // This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)} isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
/> />
))} ))}
</tbody> </tbody>

View File

@ -74,11 +74,17 @@ export default await createHonoServer({
return appLoadContext; return appLoadContext;
}, },
configure(server, { upgradeWebSocket }) { configure(app, { upgradeWebSocket }) {
if (appLoadContext.agents !== undefined) { const agentManager = appLoadContext.agents;
if (agentManager) {
app.get(
`${__PREFIX__}/_dial`,
// We need this since we cannot pass the WSEvents context // We need this since we cannot pass the WSEvents context
(upgradeWebSocket as UpgradeWebSocket<WebSocket>)( // Also important to not pass the callback directly
appLoadContext.agents.configureSocket, // since we need to retain `this` context
(upgradeWebSocket as UpgradeWebSocket<WebSocket>)((c) =>
agentManager.configureSocket(c),
),
); );
} }
}, },

View File

@ -49,10 +49,20 @@ class AgentManager {
return Array.from(this.agents.keys()); return Array.from(this.agents.keys());
} }
lookup(nodeIds: string[]) {
const entries = this.cache.toJSON();
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
if (missing.length > 0) {
this.requestData(missing);
}
return entries;
}
// Request data from all connected agents // Request data from all connected agents
// This does not return anything, but caches the data which then needs to be // This does not return anything, but caches the data which then needs to be
// queried by the caller separately. // queried by the caller separately.
requestData(nodeList: string[]) { private requestData(nodeList: string[]) {
const NodeIDs = [...new Set(nodeList)]; const NodeIDs = [...new Set(nodeList)];
NodeIDs.map((node) => { NodeIDs.map((node) => {
log.debug('agent', 'Requesting agent data for %s', node); log.debug('agent', 'Requesting agent data for %s', node);

View File

@ -1,24 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import { useFetcher } from 'react-router';
import { HostInfo } from '~/types';
export default function useAgent(nodeIds: string[]) {
const fetcher = useFetcher<Record<string, HostInfo>>();
const qp = useMemo(
() => new URLSearchParams({ node_ids: nodeIds.join(',') }),
[nodeIds],
);
const idRef = useRef<string[]>([]);
useEffect(() => {
if (idRef.current.join(',') !== nodeIds.join(',')) {
fetcher.load(`/api/agent?${qp.toString()}`);
idRef.current = nodeIds;
}
}, [qp.toString()]);
return {
data: fetcher.data,
isLoading: fetcher.state === 'loading',
};
}