import { useEffect, useRef, useState } from 'react'; import { Activity, AlertTriangle, Loader2, Play, PlugZap, RotateCw, ScrollText, Square, Trash2, Unplug, } from 'lucide-react'; import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared'; import { timeAgo } from './format'; import { useHeadscaleServers } from './useHeadscaleServers'; import { useCompanionAction, useCompanionHealth, useCompanionLogStream, useCompanionLogs, } from './useHeadscaleCompanion'; import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards'; import { ViewShell } from './ViewShell'; // What the admin API structurally cannot tell you: is the container running, what did it log on the way // down, and can we bring it back. All of it comes from the Officer Companion deployed alongside the server. // // The companion is optional and per-server, so "not deployed" is the ordinary case for a server that has // never had one, and is rendered as an explanation rather than an error. Note the two inversions this // section is built around: the companion's /health is ALWAYS HTTP 200 (read `verdict`, never the status), // and an unavailable companion says nothing about Headscale itself — the admin API on the same domain is // independent and the other sections may be working perfectly. const VERDICTS: Record = { ok: { tone: 'ok', label: 'Healthy', blurb: 'The container is running and Headscale is answering.' }, degraded: { tone: 'warn', label: 'Degraded', blurb: 'The container is running, but Headscale is not answering properly.', }, down: { tone: 'bad', label: 'Down', blurb: 'The container is not running.' }, unknown: { tone: 'idle', label: 'Unknown', blurb: 'Docker does not know this container.' }, }; const ACTIONS: { id: CompanionAction; label: string; icon: typeof RotateCw; confirm: string }[] = [ { id: 'restart', label: 'Restart', icon: RotateCw, confirm: 'Restart the Headscale container? Every node loses its control-plane connection until it is back.', }, { id: 'stop', label: 'Stop', icon: Square, confirm: 'Stop the Headscale container? Every node stays disconnected until you start it again.', }, { id: 'start', label: 'Start', icon: Play, confirm: 'Start the Headscale container?' }, ]; /** The RFC3339 zero date the companion passes through from docker for a container that never finished. */ const isZeroDate = (iso: string) => iso.startsWith('0001-'); const Row = ({ label, value }: { label: string; value: React.ReactNode }) => (
{label} {value}
); const ContainerFacts = ({ container }: { container: CompanionContainer }) => (
{!container.running && !isZeroDate(container.finishedAt) && ( )}
); /** Evidence, shown only when the verdict is not ok — likely causes first, then the raw tail behind them. */ const Evidence = ({ health }: { health: CompanionHealthBody }) => { const causes = health.likelyCauses ?? []; const recent = health.recentLogs ?? []; if (causes.length === 0 && recent.length === 0 && !health.healthcheckOutput) return null; return (
{causes.length > 0 && (
Likely causes
    {causes.map((cause) => (
  • {cause}
  • ))}
{/* The companion reads these out of the logs heuristically. Saying so is the difference between a hint the owner checks and a diagnosis they trust and then chase down the wrong hole. */}

Guessed from the logs — treat them as leads, not answers.

)} {health.healthcheckOutput && (
Healthcheck output
            {health.healthcheckOutput}
          
)} {recent.length > 0 && (
Last lines before now
            {recent.join('\n')}
          
)}
); }; const TAILS = [100, 500, 2000]; const LogViewer = () => { const [tail, setTail] = useState(200); const [follow, setFollow] = useState(false); const snapshot = useCompanionLogs(tail, !follow); const stream = useCompanionLogStream(follow, tail); const boxRef = useRef(null); const lines = follow ? stream.lines : snapshot.data?.available ? snapshot.data.lines : []; // Pin to the bottom while following. Only while following: scrolling a snapshot back to the top and having // it yanked down again would be the viewer fighting the reader. useEffect(() => { if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight; }, [follow, lines.length]); const unavailable = !follow && snapshot.data && !snapshot.data.available ? snapshot.data.reason : null; return (
Logs {follow && ( {stream.live ? 'live' : 'stopped'} )}
{TAILS.map((n) => ( ))}
{follow ? ( ) : ( )}
{stream.error && (
{stream.error}
)} {unavailable &&
{unavailable}
}
        {lines.length > 0
          ? lines.join('\n')
          : snapshot.isLoading
            ? 'Loading…'
            : follow
              ? 'Waiting for output…'
              : 'No log lines.'}
      
); }; const Lifecycle = ({ running }: { running: boolean | null }) => { const action = useCompanionAction(); const [pending, setPending] = useState(null); const run = async (id: CompanionAction, confirm: string) => { if (!window.confirm(confirm)) return; setPending(id); try { await action.mutateAsync(id); } catch { /* surfaced from action.error below */ } finally { setPending(null); } }; const result = action.data; return (
{ACTIONS.map(({ id, label, icon: Icon, confirm }) => ( ))} Acts on the container, not on Officer.
{action.error != null && The action could not be sent.} {result && !result.available && {result.reason}} {result && result.available && !result.ok && ( {result.error ?? 'Docker refused the action.'} )} {result && result.available && result.ok && (
{result.action}: {result.result}
)}
); }; export const DiagnosticsView = () => { const { active } = useHeadscaleServers(); const query = useCompanionHealth(); const result = query.data; return (
: undefined} /> {result && !result.available ? (
No companion on this server

{result.reason}. The Officer Companion is a small service deployed next to Headscale that can see its container — it is what makes health, logs and restart possible from here.

This says nothing about Headscale itself: it is served by the same domain but a different process, so the other sections may be working normally. When the companion is missing, the Console section is the way in.

) : result ? ( <>
{VERDICTS[result.health.verdict].label}

{result.health.reason ?? VERDICTS[result.health.verdict].blurb}

{result.health.probe && } {result.health.container && }
) : null}
); };