import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useClient, getHeaders } from 'hooks/useClient'; import type { CompanionAction, CompanionActionResult, CompanionHealthResult, CompanionLogsResult } from './shared'; // Client for the active server's Officer Companion. Everything here goes through the headscale sidecar, // because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and // decryptable only there. The browser never sees it and never talks to the companion directly. const BASE = '/offscale/_officer/companion'; const HEALTH_KEY = ['headscale', 'companion', 'health'] as const; /** * The container's health, polled. * * Polling is the point rather than a convenience: this section is what you have open while waiting for a * restart to take, so it has to move on its own. 10s is fast enough to watch a container come back and slow * enough that a degraded server isn't being hammered while it struggles. */ export function useCompanionHealth() { const { get } = useClient(); return useQuery({ queryKey: HEALTH_KEY, queryFn: () => get(`${BASE}/health`), refetchInterval: 10_000, // A verdict from ten seconds ago is stale by definition; never show one on remount without refetching. staleTime: 0, }); } /** A snapshot of the last N lines. The live tail is a separate thing — see useCompanionLogStream. */ export function useCompanionLogs(tail: number, enabled: boolean) { const { get } = useClient(); return useQuery({ queryKey: ['headscale', 'companion', 'logs', tail], queryFn: () => get(`${BASE}/logs?tail=${tail}`), enabled, staleTime: 0, }); } /** * Restart / stop / start the Headscale container. * * Every one of these drops every node's control-plane connection, so nothing here retries and nothing here * fires without the owner having confirmed. The health query is invalidated on settle — including on * failure, where "did it happen anyway?" is exactly the question. */ export function useCompanionAction() { const { post } = useClient(); const qc = useQueryClient(); return useMutation({ mutationFn: (action: CompanionAction) => post(`${BASE}/${action}`), onSettled: () => qc.invalidateQueries({ queryKey: HEALTH_KEY }), }); } /** How many lines the viewer keeps. Beyond this the browser, not the server, becomes the bottleneck. */ const MAX_LINES = 5000; export type LogStream = { lines: string[]; /** Set when the stream ended badly — the companion's own `event: error` frame, or a dropped connection. */ error: string | null; /** True between opening the request and the stream ending, however it ends. */ live: boolean; clear: () => void; }; /** * The live log tail, over `fetch()` rather than `EventSource`. * * `EventSource` cannot send an `Authorization` header and every hop of this chain needs one — Officer's own * bearer token to reach the platform, and the Headscale admin key from there on. So the SSE framing is * parsed by hand: split on blank lines, read `data:` and `event:`. It is a small parser and it only has to * handle what the companion emits (one line per frame, an `event: error` frame before a fatal close). * * `follow` changing off aborts mid-stream; the abort is deliberately not reported as an error, since it is * the owner switching the toggle rather than anything going wrong. */ export function useCompanionLogStream(follow: boolean, tail: number): LogStream { const [lines, setLines] = useState([]); const [error, setError] = useState(null); const [live, setLive] = useState(false); // Batched through a ref: a busy container emits faster than React can render, and one setState per line // would spend the whole frame budget on log output. const pending = useRef([]); useEffect(() => { if (!follow) return; const controller = new AbortController(); let flushTimer: ReturnType | null = null; setError(null); setLive(true); const flush = () => { if (pending.current.length === 0) return; const batch = pending.current; pending.current = []; setLines((prev) => { const next = prev.concat(batch); return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next; }); }; const run = async () => { try { const res = await fetch(`/api${BASE}/logs/stream?tail=${tail}`, { headers: getHeaders(), signal: controller.signal, }); if (!res.ok || !res.body) { setError(`the log stream returned ${res.status}`); return; } flushTimer = setInterval(flush, 200); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Frames are separated by a blank line; anything after the last one is a partial frame and waits. const frames = buffer.split('\n\n'); buffer = frames.pop() ?? ''; for (const frame of frames) { let event = 'message'; const data: string[] = []; for (const rawLine of frame.split('\n')) { if (rawLine.startsWith('event:')) event = rawLine.slice(6).trim(); else if (rawLine.startsWith('data:')) data.push(rawLine.slice(5).replace(/^ /, '')); } if (data.length === 0) continue; const text = data.join('\n'); if (event === 'error') setError(text); else pending.current.push(text); } } } catch (err) { if ((err as Error | null)?.name !== 'AbortError') setError('the log stream disconnected'); } finally { if (flushTimer) clearInterval(flushTimer); flush(); // An aborted stream is already being torn down by the effect that replaced this one; letting it set // state here would flash "not live" onto a stream that is about to reopen. if (!controller.signal.aborted) setLive(false); } }; void run(); return () => { controller.abort(); if (flushTimer) clearInterval(flushTimer); setLive(false); }; }, [follow, tail]); return { lines, error, live, clear: () => setLines([]) }; }