a73x

web/src/lib/fleet.svelte.ts

Ref:   Size: 38.3 KiB   History

// Shared reactive fleet state: live host/VM snapshot over SSE, plus the admin
// API client. The Go server serves this SPA and the API same-origin, so all
// requests are relative.

// Wire types are generated from the server's OpenAPI spec (docs/openapi.json →
// api-types.ts via `npm run gen:api`); re-exported here so the rest of the app
// keeps one import point and cannot drift from the API.
import type { components } from './api-types';

export type Capacity = components['schemas']['Capacity'];
export type Metrics = components['schemas']['Metrics'];
export type Host = components['schemas']['Host'];
export type VM = components['schemas']['VM'];

/** VMEvent is one lifecycle event (see vmEvents); detail is embedded raw JSON
 *  on the wire, so after res.json() it is already a decoded object. */
export type VMEvent = components['schemas']['AuditEvent'];

/** UserCA is one registered per-tenant SSH user CA (BYO). The server returns
 *  the canonical pubkey line, an optional label, and the SHA256 fingerprint. */
export type UserCA = components['schemas']['UserCA'];

/** Exposure is one published guest port: the fleet binds host_port on the VM's
 *  host and pipes it to guest_port inside the guest, in protocol ('tcp' or
 *  'udp'). host_addr is the address to dial; state is what the host says its
 *  socket is doing. */
export type Exposure = components['schemas']['Exposure'];

export type CreateVMRequest = components['schemas']['CreateVMRequest'];

/** Me is the signed-in identity: the caller's tenant handle and bound email,
 *  plus the plane's SSH gate address (empty when it runs none). */
export type Me = components['schemas']['Me'];

/** APIToken is a personal access token's metadata (never the secret). */
export type APIToken = components['schemas']['APIToken'];

/** CreateAPITokenResponse carries the freshly minted PAT secret, shown once. */
export type CreateAPITokenResponse = components['schemas']['CreateAPITokenResponse'];

export const fleet = $state({
	me: null as Me | null,
	hosts: [] as Host[],
	vms: [] as VM[],
	userCAs: [] as UserCA[],
	// userCAsLoaded separates "no CA registered" from "not asked yet". An empty
	// list means both until the first fetch returns, and a UI that warns on the
	// empty one would warn at every tenant for the moment before its CAs land.
	userCAsLoaded: false,
	connected: false,
	error: '',
	server_version: '',
	latest_version: ''
});

/** tenant is the signed-in tenant handle, used to build the per-tenant user-CA
 *  paths. fetchMe() runs before any CA call, so me is set; the throw only ever
 *  fires on a programming slip (a CA call before sign-in resolved). */
function tenant(): string {
	if (!fleet.me) throw new Error('not signed in');
	return fleet.me.tenant;
}

// clock is a single shared ticking wall-clock (unix seconds). Countdowns read
// clock.now instead of each spinning its own interval, so every countdown on
// the page ticks in lockstep and none drifts. One interval feeds them all.
// Starts at 0 (no Date.now() at module scope, so static prerender stays pure);
// startClock() sets the real time in the browser before any countdown shows.
export const clock = $state({ now: 0 });

function nowSec(): number {
	return Math.floor(Date.now() / 1000);
}

let clockTimer: ReturnType<typeof setInterval> | null = null;

/** startClock arms the shared 1s clock tick. Idempotent; call from a browser
 *  context (onMount) only — module code runs during static prerender, where
 *  there is no window and Date.now() must not drive render. */
export function startClock() {
	if (typeof window === 'undefined' || clockTimer) return;
	clock.now = nowSec();
	clockTimer = setInterval(() => {
		clock.now = nowSec();
	}, 1000);
}

let es: EventSource | null = null;

// sseParseError marks that fleet.error came from the SSE stream itself (not a
// user action), so the next well-formed event self-heals it — action errors
// stay sticky until dismissed.
let sseParseError = false;

// redirecting guards against stacked navigations: a batch of concurrent
// requests (e.g. the startup me + hosts + vms fetches) can all 401 at once,
// but only the first hands off to sign-in.
let redirecting = false;

async function req(method: string, path: string, body?: unknown): Promise<Response> {
	const res = await fetch(path, {
		method,
		headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
		body: body === undefined ? undefined : JSON.stringify(body)
	});
	if (res.status === 401) {
		// Session absent or expired: the same-origin cookie either wasn't sent
		// or no longer resolves. Hand off to the OIDC flow, which bounces
		// through the IdP and back — the one and only redirect point.
		if (typeof window !== 'undefined' && !redirecting) {
			redirecting = true;
			window.location.href = '/auth/login';
		}
		throw new Error('401: unauthenticated');
	}
	if (!res.ok) {
		const text = await res.text();
		throw new Error(`${res.status}: ${text.trim() || res.statusText}`);
	}
	return res;
}

/** fetchMe loads the signed-in identity into fleet.me. Call before connect()
 *  (the SSE ticket mint needs the session too). A 401 redirects to sign-in via
 *  req(); the caller treats a throw as "not signed in, redirecting". */
export async function fetchMe() {
	fleet.me = await (await req('GET', '/api/v1/me')).json();
}

/** mintTicket fetches a one-time stream ticket (SSE + console WS auth): the
 *  session cookie never rides in a URL. Throws on failure. */
export async function mintTicket(): Promise<string> {
	const r = await (await req('POST', '/api/v1/stream-tickets')).json();
	if (typeof r.ticket !== 'string' || !r.ticket) throw new Error('malformed ticket response');
	return r.ticket;
}

let reconnectTimer: ReturnType<typeof setTimeout> | null = null;

// connectGen guards against overlapping async connect() calls: only the
// newest invocation may install its EventSource — a stale one closes its
// orphan instead of clobbering (or later killing) the current stream.
let connectGen = 0;

/** connect mints a one-time stream ticket (so the session never rides in a URL)
 *  and opens the SSE stream. Tickets are single-use, so the browser's built-in
 *  EventSource retry cannot work — on error we close the stream and reconnect
 *  ourselves with a fresh ticket. */
export async function connect() {
	es?.close();
	if (reconnectTimer) {
		clearTimeout(reconnectTimer);
		reconnectTimer = null;
	}
	const gen = ++connectGen;
	let ticket: string;
	try {
		ticket = await mintTicket();
	} catch {
		if (gen !== connectGen) return; // superseded while minting
		fleet.connected = false;
		scheduleReconnect();
		return;
	}
	if (gen !== connectGen) return; // superseded while minting — drop the ticket
	const mine = new EventSource(`/api/v1/events?ticket=${encodeURIComponent(ticket)}`);
	es = mine;
	mine.addEventListener('state', (e) => {
		try {
			const snap = JSON.parse((e as MessageEvent).data);
			fleet.hosts = snap.hosts ?? [];
			fleet.vms = snap.vms ?? [];
			fleet.server_version = snap.server_version ?? '';
			fleet.latest_version = snap.latest_version ?? '';
			fleet.connected = true;
			// Deliberately does NOT clear action errors: state events arrive
			// ~1/s, so auto-clearing here made action failures flash for under
			// a second — invisible in practice. They persist until dismissed
			// (dismissError) or the next successful refresh() (page load /
			// token re-set). Only the stream's OWN parse errors self-heal.
			if (sseParseError) {
				fleet.error = '';
				sseParseError = false;
			}
		} catch (err) {
			fleet.error = String(err);
			sseParseError = true;
		}
	});
	mine.onerror = () => {
		// Only the CURRENT stream may trigger reconnect churn — an orphaned
		// EventSource from a superseded connect() closes itself and stops.
		mine.close();
		if (es !== mine) return;
		fleet.connected = false;
		// The ticket is consumed: the built-in retry would replay a dead URL.
		// Reconnect with a fresh ticket instead; keep last data.
		scheduleReconnect();
	};
}

/** scheduleReconnect arms a single delayed reconnect (fresh ticket). */
function scheduleReconnect() {
	if (reconnectTimer) return;
	reconnectTimer = setTimeout(() => {
		reconnectTimer = null;
		void connect();
	}, 3000);
}

/** copyText puts s on the clipboard and reports whether it landed there.
 *
 *  navigator.clipboard only exists in secure contexts; a console served over
 *  plain http on a LAN origin doesn't get it. Fall back to selecting the
 *  element holding the text so a manual Ctrl-C works in one keystroke, and say
 *  so in the banner — what names the noun that message uses. */
export async function copyText(
	s: string,
	shown?: HTMLElement | null,
	what = 'text'
): Promise<boolean> {
	try {
		if (navigator.clipboard?.writeText) {
			await navigator.clipboard.writeText(s);
			return true;
		}
		if (shown) {
			window.getSelection()?.selectAllChildren(shown);
			fleet.error = `Clipboard unavailable over http—${what} selected, press Ctrl-C`;
		}
	} catch (err) {
		fleet.error = String(err);
	}
	return false;
}

/** dismissError clears the sticky error banner. */
export function dismissError() {
	fleet.error = '';
}

/** action runs a fire-and-forget API call from a UI handler, capturing any
 *  failure into fleet.error (the app's only failure surface) exactly as the
 *  ten call sites used to do by hand. Returns whether it succeeded, so the
 *  caller can gate success-only follow-up (closing a dialog, resetting a
 *  form) without needing its own try/catch. */
export async function action(fn: () => Promise<unknown>): Promise<boolean> {
	try {
		await fn();
		return true;
	} catch (err) {
		fleet.error = String(err);
		return false;
	}
}

/** refresh does a one-shot fetch (used before SSE connects or as a fallback).
 *  It routes through action() for the shared catch, and clears the banner on
 *  success from inside the work — a clean load is the one refresh that also
 *  wipes a stale error, which the plain action() success path does not do. */
export async function refresh() {
	await action(async () => {
		const [h, v] = await Promise.all([
			req('GET', '/api/v1/hosts').then((r) => r.json()),
			req('GET', '/api/v1/vms').then((r) => r.json())
		]);
		fleet.hosts = h;
		fleet.vms = v;
		fleet.error = '';
	});
}

export async function createVM(body: CreateVMRequest): Promise<{ id: string; name: string }> {
	return (await req('POST', '/api/v1/vms', body)).json();
}

export async function setPower(id: string, power: 'running' | 'stopped') {
	await req('PATCH', `/api/v1/vms/${id}`, { power_state: power });
}

export async function deleteVM(id: string) {
	await req('DELETE', `/api/v1/vms/${id}`);
}

/** restoreVM un-deletes a VM still within its teardown grace window. req throws
 *  on non-2xx, so a 409 (grace window closed) surfaces to the caller. */
export async function restoreVM(id: string) {
	await req('POST', `/api/v1/vms/${id}/restore`);
}

/** vmEvents fetches the VM's lifecycle events, newest-first. */
export async function vmEvents(id: string): Promise<VMEvent[]> {
	return (await req('GET', `/api/v1/vms/${id}/events`)).json();
}

/** listExposures fetches the VM's published ports, lowest host port first. */
export async function listExposures(id: string): Promise<Exposure[]> {
	return (await req('GET', `/api/v1/vms/${id}/exposures`)).json();
}

/** createExposure publishes a guest port over protocol ('tcp' or 'udp').
 *  hostPort 0 asks the control plane to allocate one from the reserved range. */
export async function createExposure(
	id: string,
	guestPort: number,
	hostPort: number,
	protocol: string
): Promise<Exposure> {
	return (
		await req('POST', `/api/v1/vms/${id}/exposures`, {
			guest_port: guestPort,
			host_port: hostPort,
			protocol
		})
	).json();
}

/** deleteExposure revokes an exposure; its host closes the listener. */
export async function deleteExposure(id: string) {
	await req('DELETE', `/api/v1/exposures/${id}`);
}

/** listUserCAs fetches the tenant's registered SSH user CAs. */
export async function listUserCAs(): Promise<UserCA[]> {
	return (await req('GET', `/api/v1/tenants/${tenant()}/user-cas`)).json();
}

/** uploadUserCA registers a CA public key for the tenant. label is optional. */
export async function uploadUserCA(body: { public_key: string; label?: string }) {
	await req('POST', `/api/v1/tenants/${tenant()}/user-cas`, body);
}

/** deleteUserCA removes a registered CA by its public-key line. The endpoint
 *  takes a JSON body (unlike deleteVM). */
export async function deleteUserCA(public_key: string) {
	await req('DELETE', `/api/v1/tenants/${tenant()}/user-cas`, { public_key });
}

/** listTokens fetches the tenant's personal access tokens (metadata only). */
export async function listTokens(): Promise<APIToken[]> {
	return (await req('GET', '/api/v1/tokens')).json();
}

/** createToken mints a PAT; the returned secret is shown once and never again.
 *  ttlSeconds undefined (or 0) mints a non-expiring token. */
export async function createToken(
	name: string,
	ttlSeconds?: number
): Promise<CreateAPITokenResponse> {
	const body: components['schemas']['CreateAPITokenRequest'] = { name };
	if (ttlSeconds !== undefined) body.ttl_seconds = ttlSeconds;
	return (await req('POST', '/api/v1/tokens', body)).json();
}

/** revokeToken revokes a PAT by id. */
export async function revokeToken(id: string) {
	await req('DELETE', `/api/v1/tokens/${id}`);
}

/** refreshUserCAs loads the tenant's CAs into fleet.userCAs. CAs are NOT part
 *  of the SSE snapshot, so callers invoke this on mount and after each mutation. */
export async function refreshUserCAs() {
	await action(async () => {
		fleet.userCAs = await listUserCAs();
		fleet.userCAsLoaded = true;
	});
}

export async function decommissionHost(id: string) {
	await req('DELETE', `/api/v1/hosts/${id}`);
}

/** upgradeAgent asks the given host's agent to self-upgrade and re-exec. req
 *  throws on non-2xx (409/503 plain-text body), so a busy/unavailable host
 *  surfaces to the caller like any other action. */
export async function upgradeAgent(id: string) {
	await req('POST', `/api/v1/hosts/${id}/upgrade-agent`);
}

export async function createJoinBlob(): Promise<string> {
	const r = await (await req('POST', '/api/v1/enroll-tokens')).json();
	return r.join;
}

export function vmsForHost(id: string): VM[] {
	return fleet.vms.filter((v) => v.host_id === id);
}

// Host enrolment — which release artifact a new host takes, and what it runs.

/** HostPlatform is a machine a host can enrol on, spelled the way
 *  scripts/release.sh spells the artifact for it. darwin/amd64 is absent
 *  because no release carries an agent for it: shipping one would put an
 *  enrolment recipe in front of an Intel Mac that has never booted a guest. */
export type HostPlatform = 'linux/amd64' | 'linux/arm64' | 'darwin/arm64';

/** HOST_PLATFORMS is what the join flow offers, in the order it offers it. */
export const HOST_PLATFORMS: { id: HostPlatform; label: string }[] = [
	{ id: 'linux/amd64', label: 'Linux · amd64' },
	{ id: 'linux/arm64', label: 'Linux · arm64' },
	{ id: 'darwin/arm64', label: 'macOS · Apple silicon' }
];

/** HostBundle is the one release artifact a host of a given platform enrols
 *  from: the tarball to fetch, the directory it unpacks into, and the two parts
 *  of the name — the stem (`eitri-server` / `eitri-agent`) and the platform
 *  suffix (`linux_amd64` / `darwin_arm64`) — held apart so the join recipe's
 *  SHA256SUMS regex reads the version out of the same name this builds, rather
 *  than re-deriving a parallel one that a rename here could silently strand. */
export type HostBundle = { file: string; dir: string; stem: string; suffix: string };

/** hostBundle names that artifact.
 *
 *  A Linux host takes `eitri-server_<v>_linux_<arch>.tar.gz`. The tarball is
 *  named for the server it also carries, and that is the whole of the trap
 *  this names its way out of: the `eitri-agent` inside it is the binary a host
 *  installs, whether or not that box ever runs a server. A Mac takes
 *  `eitri-agent_<v>_darwin_arm64.tar.gz`, which carries no server at all —
 *  a Mac joins a fleet only as a host.
 *
 *  An empty version is a plane that has not read the release manifest yet. The
 *  names then carry `${V}`, a shell variable the recipe reads out of
 *  SHA256SUMS, because the release's own filenames are the only place the
 *  version is written: `/dl/latest/` is a directory alias, and
 *  `eitri-server_latest_linux_amd64.tar.gz` is a URL that 404s. */
export function hostBundle(platform: HostPlatform, version: string): HostBundle {
	const mac = platform === 'darwin/arm64';
	const stem = mac ? 'eitri-agent' : 'eitri-server';
	const suffix = mac ? 'darwin_arm64' : `linux_${platform.split('/')[1]}`;
	const v = version || '${V}';
	const dir = `${stem}_${v}_${suffix}`;
	return { file: `${dir}.tar.gz`, dir, stem, suffix };
}

/** joinCommands is the enrolment recipe for one join token on one platform:
 *  fetch, verify, install, join, start. It is the same sequence docs/joining
 *  prints, with this fleet's release in it.
 *
 *  The two platforms differ in more than a filename, which is why the flow asks
 *  rather than guessing. A Linux host installs into system paths under sudo and
 *  runs the agent under systemd; a Mac installs into the running account's own
 *  space with no sudo anywhere, runs it as a LaunchAgent, and needs vfkit from
 *  Homebrew first — the agent bootstraps cloud-hypervisor for itself but cannot
 *  do the same for vfkit, which only works carrying Apple's virtualization
 *  entitlement. macOS also ships no `sha256sum`, so verification there is
 *  `shasum` over the one line of SHA256SUMS that names this bundle. */
export function joinCommands(platform: HostPlatform, version: string, blob: string): string[] {
	const { file, dir, stem, suffix } = hostBundle(platform, version);
	const base = `https://eitri.sh/dl/${version || 'latest'}`;
	const mac = platform === 'darwin/arm64';

	const cmds = mac ? ['brew install vfkit'] : [];
	cmds.push(`curl -fsSLO ${base}/SHA256SUMS`);
	if (!version) {
		cmds.push(`V=$(sed -n 's/.*${stem}_\\(v[^_]*\\)_${suffix}\\.tar\\.gz$/\\1/p' SHA256SUMS)`);
	}
	cmds.push(`curl -fsSLO "${base}/${file}"`);
	cmds.push(
		mac
			? `grep " ${file}$" SHA256SUMS | shasum -a 256 -c -`
			: 'sha256sum -c SHA256SUMS --ignore-missing'
	);
	cmds.push(`tar xzf ${file} && cd ${dir}`);
	if (mac) {
		cmds.push('mkdir -p ~/.local/bin && cp eitri-agent ~/.local/bin/');
		cmds.push(`~/.local/bin/eitri-agent join ${blob}`);
		cmds.push('./eitri-agent-launchagent.sh install ~/.local/bin/eitri-agent');
	} else {
		cmds.push('sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent');
		cmds.push('sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service');
		cmds.push(`sudo eitri-agent --state-dir /var/lib/eitri-agent join ${blob}`);
		cmds.push('sudo systemctl daemon-reload');
		cmds.push('sudo systemctl enable --now eitri-agent');
	}
	return cmds;
}

// VM display derivations — the canonical "what phase / power / address is this
// VM" rules, shared by every view so they cannot drift.

/** vmPhase is the phase to display: "deleting" once tombstoned, else the
 *  reported phase falling back to the desired status. */
export function vmPhase(vm: VM): string {
	return vm.deleted ? 'deleting' : vm.phase || vm.status;
}

/** vmPower is the power to display: the agent-observed power, falling back to
 *  the desired power_state when no actual is reported yet. */
export function vmPower(vm: VM): string {
	// A dark host reports no power, and the columns that survive it are a
	// memory: power_state is what the operator ASKED for, actual_power is
	// blank because nothing observed it. Falling through to either would print
	// "running" in the same row that says the plane cannot reach this VM.
	if (vmStatus(vm) === 'unreachable') return '—';
	return vm.actual_power || vm.power_state;
}

/** vmDetail is the sentence to show beneath a VM's status: what its host is
 *  doing about it right now, in the host's own words.
 *
 *  Only a VM that is still being created gets one. The sentence describes work
 *  in flight — "downloading image 1.2/3.7 GiB" — and a settled VM showing the
 *  last thing its host was doing would be presenting the past as the present.
 *  Everything else returns '' and the row is not drawn at all.
 *
 *  An empty detail is the normal case in two situations that look identical
 *  here and should: a host with nothing to add, and a host running an agent old
 *  enough that it never sends the field. Neither gets a placeholder.
 *
 *  `unreachable` is the exception that is not the host's words at all: the
 *  status is about the plane's reach rather than the guest, so alone it reads
 *  as something wrong with the VM. This names whose silence it is — and it
 *  REPLACES any status_detail the row still carries, because that sentence
 *  described work a host that has since gone quiet was doing, and presenting it
 *  now would be the same lie the status just refused to tell. */
export function vmDetail(vm: VM): string {
	const status = vmStatus(vm);
	if (status === 'unreachable') return "eitri can't reach this VM's host";
	if (status !== 'creating') return '';
	return vm.status_detail ?? '';
}

/** vmIP is the assigned IP, or an em-dash placeholder when unassigned. */
export function vmIP(vm: VM): string {
	return vm.assigned_ip || '—';
}

/** VM_IP_HINT is the note beside a VM's address. One sentence for every guest,
 *  because every guest has this NIC and it says the same thing on all of them:
 *  an address on the host's private bridge, NAT'd out, reachable off that host
 *  only through the gate or a published port. A named network does not change
 *  this address — it adds a second one, which vmNetworkAddr reports. */
export const VM_IP_HINT = '(host bridge, NAT—not reachable off-host)';

/** vmNetworkAddr is the address a guest's named network granted its second
 *  NIC, or '' when there is nothing to show: a guest that asked for no
 *  network, or one whose network has not answered yet. Used by vmNetworkValue
 *  to extend the Network row's value once the address is known, and by the
 *  fleet-table search box, which matches on it directly.
 *
 *  Both fields are checked because they can disagree: a VM created before
 *  the freeze, or one a stale row otherwise carries a leftover network_ip
 *  for, must not read as networked once vm.network is empty — an address
 *  with no network to hang it on is not a fact worth showing. */
export function vmNetworkAddr(vm: VM): string {
	return vm.network && vm.network_ip ? vm.network_ip : '';
}

/** vmNetworkAddrHint names the network that did the addressing, because the
 *  address alone does not say which of the host's networks it came from. */
export function vmNetworkAddrHint(vm: VM): string {
	return `(on ${vm.network}, addressed by that network's DHCP)`;
}

/** vmNetworkValue is the Network row's whole value: the operator's own name
 *  for the network alone, until that network's DHCP has answered — then the
 *  name and the address it granted, joined the way the console joins a
 *  primary value onto a secondary one (host status's "· offline", the
 *  platform labels, os·kernel, sessionSummary). One row, one fact, and no
 *  word in it that eitri coined; '' when the guest asked for no network. */
export function vmNetworkValue(vm: VM): string {
	if (!vm.network) return '';
	const addr = vmNetworkAddr(vm);
	return addr ? `${vm.network} · ${addr}` : vm.network;
}

/** vmStatus is the single lifecycle status folded from the orthogonal state axes.
 *
 *  The server owns this derivation (deriveLifecycle in internal/server/api)
 *  and always ships the result as vm.lifecycle; this SPA is served
 *  same-origin by that same binary, so there's no version-skew case to
 *  degrade for. The fallback below only covers a VM record missing the field
 *  entirely (e.g. a malformed/partial snapshot). */
export function vmStatus(vm: VM): string {
	return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown');
}

/** hostStatusLabel is the words beside a host's status dot: the host's status,
 *  with a '· offline' tail when it is dark. The dot's own colour is set from
 *  host.online at the callsite (the on/off class), the same split vmStatus
 *  leaves to its callers; this folds the one bit of text logic both the fleet
 *  table and the host page were spelling by hand. */
export function hostStatusLabel(h: Host): string {
	return `${h.status}${h.online ? '' : ' · offline'}`;
}

/** hostNetworkOptions is the named networks a create may ask a host for: the
 *  last set that host advertised, whether or not it is online this instant.
 *
 *  Online is deliberately not consulted. Gating on it empties the list for
 *  every SSE tick a host spends dark, and an emptied list is how a create for
 *  a named network turns into a NAT VM without anyone saying so; a create
 *  against a host that really is gone is refused by the server, which is the
 *  right place for that refusal. A host not in the fleet (or none selected)
 *  offers nothing. */
export function hostNetworkOptions(hosts: Host[], hostID: string | undefined): string[] {
	return hosts.find((h) => h.id === hostID)?.host_networks ?? [];
}

/** shortFingerprint abbreviates an OpenSSH SHA256 fingerprint for a table cell,
 *  keeping the SHA256: prefix (so it still reads as a fingerprint and not a
 *  hash of some other kind) and enough of the digest to tell two CAs apart by
 *  eye. Settings shows the full string; this is for places listing several.
 *  Anything that is not a recognisable fingerprint is returned untouched. */
export function shortFingerprint(fp: string): string {
	if (!fp.startsWith('SHA256:') || fp.length <= 19) return fp;
	return fp.slice(0, 19) + '…';
}

/** vmTrustStale reports whether a guest was created before the tenant's current
 *  CA set was complete — the tenant has since registered a CA this guest does
 *  not trust, and a certificate signed by that CA will not open it. A guest's
 *  trust is fixed at create, so this never resolves on its own: the remedy is
 *  to recreate the VM.
 *
 *  Three cases return false, all of them for the same reason — the comparison
 *  would be asserting more than is known:
 *
 *  - The CAs have not been fetched yet. Every guest would read as stale against
 *    an empty list for the moment before they land, which is the same trap
 *    userCAsLoaded already exists to avoid.
 *  - The VM has no record (created before the freeze). There is nothing to
 *    compare, and inferring staleness from silence would flag every legacy VM
 *    on the fleet, including the ones that are perfectly current.
 *  - A CA on either side has no readable fingerprint. Fingerprints are how the
 *    two sides are matched; an unreadable one is unmatchable, so counting it as
 *    missing would report staleness that may not exist.
 */
export function vmTrustStale(vm: VM): boolean {
	if (!fleet.userCAsLoaded || !vm.trusted_cas) return false;
	const trusted = new Set(vm.trusted_cas.map((ca) => ca.fingerprint).filter(Boolean));
	return fleet.userCAs.some((ca) => ca.fingerprint && !trusted.has(ca.fingerprint));
}

/** UPGRADE_STUCK_S is how long an offered agent upgrade may stand before the
 *  console stops calling it "in flight" and starts calling it stuck.
 *
 *  The work behind an offer is a download, a checksum, a binary swap and a
 *  re-exec, and the offer itself rides the host's next snapshot — one agent
 *  tick, ~10s. On a LAN the whole thing is done inside a few tens of seconds,
 *  and the new version is in the following Hello. Three minutes is an order of
 *  magnitude past that: far enough out that a slow link or a large artifact is
 *  never accused, close enough that an operator who clicked and looked away
 *  learns it failed while they are still the person who clicked. */
export const UPGRADE_STUCK_S = 180;

/** upgradeStuck reports that a host's pending upgrade is not going to land on
 *  its own.
 *
 *  Three things have to be true together. The offer has stood past
 *  UPGRADE_STUCK_S. The host is online — it is reporting NOW, which means it
 *  has reported since the offer was made, so the agent has seen the offer and
 *  is still running. And it is still on the old version, so what it saw it did
 *  not take. That combination is not a wait; it is a failure, and it lives in
 *  the host's own log.
 *
 *  An offline host is deliberately never stuck. It cannot have failed an
 *  upgrade it has not been handed: the offer waits in memory for the agent to
 *  come back, and the honest reading of a dark host is that nobody has heard
 *  from it, not that something broke. */
export function upgradeStuck(h: Host): boolean {
	const pending = h.pending_upgrade;
	if (!pending || !h.online) return false;
	if (h.agent_version === pending.version) return false;
	return pending.age_s >= UPGRADE_STUCK_S;
}

/** A duration unit: how many seconds it spans and the letter it prints in. */
type DurationUnit = { secs: number; label: string };
const SEC: DurationUnit = { secs: 1, label: 's' };
const MIN: DurationUnit = { secs: 60, label: 'm' };
const HOUR: DurationUnit = { secs: 3600, label: 'h' };
const DAY: DurationUnit = { secs: 86400, label: 'd' };

/** formatDuration renders a coarse, round-down duration on a caller-supplied
 *  ladder. Each tier lists the units it prints, largest first: the first tier
 *  whose lead unit the value fills wins, and a one-unit tier prints just that
 *  unit ("4m") where a two-unit tier prints both ("1h 5m"). The last tier is
 *  the fallback for anything below them all. The lead count floors toward zero
 *  and never goes negative; each following count is the remainder within the
 *  unit above it. One formatter, two ladders: upgradeAge and formatUptime. */
function formatDuration(seconds: number, ladder: DurationUnit[][]): string {
	const tier = ladder.find((t) => seconds >= t[0].secs) ?? ladder[ladder.length - 1];
	return tier
		.map((u, i) => {
			const count =
				i === 0
					? Math.max(0, Math.floor(seconds / u.secs))
					: Math.floor((seconds % tier[i - 1].secs) / u.secs);
			return `${count}${u.label}`;
		})
		.join(' ');
}

/** upgradeAge renders how long an offer has stood, coarsely — "40s", "4m",
 *  "1h 5m". The number is evidence that something is wrong, not a stopwatch,
 *  so it rounds down to the unit that makes the point. Seconds under a minute,
 *  whole minutes under an hour, then hours and minutes. */
export function upgradeAge(seconds: number): string {
	return formatDuration(seconds, [[HOUR, MIN], [MIN], [SEC]]);
}

/** formatUptime renders a host's uptime, coarsely — "3d 4h", "5h 12m", "40m".
 *  Days and hours once it has run a day, hours and minutes once an hour, whole
 *  minutes below that. Coarser than upgradeAge on purpose: an uptime is context,
 *  not a fault clock, so it never counts down to the second. */
export function formatUptime(seconds: number): string {
	return formatDuration(seconds, [[DAY, HOUR], [HOUR, MIN], [MIN]]);
}

/** agentLogHint is where this host keeps the agent's log, in the exact form you
 *  would type or open. A Linux host runs the agent under systemd and its log is
 *  in the journal; a Mac runs it as a LaunchAgent, which has no journal and
 *  writes to a file (docs/joining.md). A host whose OS we have not been told
 *  gets no hint at all rather than a guess: sending an operator to a command
 *  their machine does not have wastes exactly the time this message exists to
 *  save. Returns '' in that case, and callers drop the parenthetical. */
function agentLogHint(os: string): string {
	if (os === 'darwin') return '~/Library/Logs/eitri-agent.log';
	if (os === 'linux') return 'journalctl -u eitri-agent';
	return '';
}

/** upgradeStuckNote is what the console says about a stuck upgrade, or '' when
 *  the host has none. One sentence of fact and one of where to look: the
 *  failure happened on the host, so the host's log is the only place holding
 *  the reason, and nothing the control plane knows will add to it. */
export function upgradeStuckNote(h: Host): string {
	if (!upgradeStuck(h)) return '';
	const pending = h.pending_upgrade!;
	const hint = agentLogHint(h.os);
	return (
		`Upgrade to ${pending.version} was offered ${upgradeAge(pending.age_s)} ago and this host ` +
		`is still reporting ${h.agent_version || 'no version'}—the agent is not taking it. ` +
		`Read the agent's log on the host${hint ? ` (${hint})` : ''} for the reason, then offer it again.`
	);
}

/** vmPowerAction is the power flip a VM's controls may offer, or null when
 *  offering one would be a lie.
 *
 *  Only a VM the plane has settled — `ready` or `stopped` — has a power flip
 *  that means anything. `creating` and `deleting` are mid-flight, and the plane
 *  is already driving them somewhere. `failed` cannot be started: it is the
 *  state a host reports when it has given up on a guest, and a create the
 *  host's budget refused reconsiders only on a spec change, which power is not.
 *  An unrecognized state is one this console does not understand well enough to
 *  act on.
 *
 *  For the two that do get a control, it reads the DESIRED power (power_state),
 *  not the observed one: the control sets desired state, so a VM already on its
 *  way to running has no Start to press. */
export function vmPowerAction(vm: VM): 'start' | 'stop' | null {
	const status = vmStatus(vm);
	if (status !== 'ready' && status !== 'stopped') return null;
	return vm.power_state === 'running' ? 'stop' : 'start';
}

/** deleteConfirm is the question a delete asks before it runs. A create in
 *  flight is the one delete worth spelling out: the VM the dialog names does
 *  not exist yet, and pressing through cancels the work rather than removing a
 *  machine. Shared, so the fleet table and the VM page ask the same thing. */
export function deleteConfirm(vm: VM): string {
	return vmStatus(vm) === 'creating'
		? `Delete VM ${vm.name}? It is still being created—deleting cancels the create.`
		: `Delete VM ${vm.name}?`;
}

/** teardownApprox renders a calm, coarse estimate of the time left to undo a
 *  deletion before the agent destroys the VM — "~30s", "~2m", "any moment" — or
 *  '' when the destroy clock hasn't started yet (destroy_at 0, guest still
 *  shutting down). Rounded to 10s buckets so it doesn't jitter every second;
 *  the emphasis is "you can still undo", not a precise doom clock. Pass clock.now. */
export function teardownApprox(vm: VM, nowSec: number): string {
	if (!vm.destroy_at) return '';
	const left = vm.destroy_at - nowSec;
	if (left <= 0) return 'any moment';
	if (left < 60) return `~${Math.max(10, Math.round(left / 10) * 10)}s`;
	return `~${Math.ceil(left / 60)}m`;
}

/** formatHostPort renders a dial target from an address and a port. The API
 *  ships the two apart precisely so the renderer owns this: an IPv6 address
 *  (the only form carrying a colon) has to be bracketed, or the result reads
 *  as one more hextet. */
export function formatHostPort(addr: string, port: number): string {
	return addr.includes(':') ? `[${addr}]:${port}` : `${addr}:${port}`;
}

/** sessionSummary is what an exposure row says about the traffic its port has
 *  carried, or '' when the row should say nothing at all.
 *
 *  Saying nothing is the interesting half. The counters are absent for an
 *  exposure no host has reported on yet, and for one served by an agent that
 *  predates them — and in both cases the honest answer is silence. Rendering
 *  zeros instead would state that this port has turned nobody away, which is a
 *  claim nothing on the wire supports; a real zero, reported as zero, says
 *  exactly that and is worth showing.
 *
 *  "since agent start" is not decoration. The two totals reset when the agent
 *  restarts, so a number read without that clause is a number over an unknown
 *  window. */
export function sessionSummary(e: Exposure): string {
	const s = e.sessions;
	if (!s) return '';
	return `${s.active} open · ${s.refused} refused, ${s.dropped} dropped since agent start`;
}

/** CapacityReading is one allocation read against the capacity its host
 *  reports: what to draw, and what to say about the difference. */
export type CapacityReading = {
	/** pct fills the meter, clamped to 0–100 — a bar cannot overflow its track. */
	pct: number;
	/** free is what is left, and it stops at zero. Below that is not less room;
	 *  it is a different fact, and `over` is the one that states it. */
	free: number;
	/** over is by how much the allocation exceeds capacity, 0 when it fits. */
	over: number;
	level: 'ok' | 'warm' | 'hot';
};

/** capacityReading folds an allocation and a reported capacity into what a
 *  gauge needs. Past 90% the level turns, the same wall the fleet table's load
 *  meter marks.
 *
 *  Over-allocation is a state the console has to be able to draw, and a normal
 *  one. A host that declares no cap advertises the machine's totals and its
 *  agent admits past them on purpose — sparse disks, memory that is not
 *  preallocated — so allocation over capacity is what deliberate overcommit
 *  looks like from here. A capped host is placed against its cap, but one that
 *  re-reports a smaller capacity (a disk shrank, an operator lowered the
 *  agent's cap) puts VMs that were placed honestly over the line just the same.
 *  Rendering that as "-2 free" states it as a negative amount of room,
 *  which is not a thing; "over by 2" is the same number said truthfully.
 *
 *  A total of 0 is a host that has reported no capacity, not a host with none —
 *  the reading is empty and the caller says "offline" rather than drawing a
 *  full bar over an unknown. */
export function capacityReading(used: number, total: number): CapacityReading {
	if (total <= 0) return { pct: 0, free: 0, over: 0, level: 'ok' };
	const pct = Math.min(100, Math.round((used / total) * 100));
	return {
		pct,
		free: Math.max(0, total - used),
		over: Math.max(0, used - total),
		level: pct >= 90 ? 'hot' : pct >= 70 ? 'warm' : 'ok'
	};
}

/** eventLabel maps a lifecycle VMEvent to a human label, folding in the one
 *  detail that matters per action. detail arrives already decoded (raw JSON on
 *  the wire); guard the shape defensively rather than parsing. */
export function eventLabel(ev: VMEvent): string {
	const detail =
		ev.detail && typeof ev.detail === 'object' ? (ev.detail as Record<string, unknown>) : {};
	switch (ev.action) {
		case 'vm.create':
			return 'Created';
		case 'vm.power':
			return `Power → ${detail.power ?? '?'}`;
		case 'vm.delete':
			return 'Deleted';
		case 'vm.restore':
			return 'Restored';
		case 'vm.reap':
			return `Destroyed (${detail.reason ?? '?'})`;
		case 'exposure.create':
			return `Exposed guest :${detail.guest_port ?? '?'}/${detail.protocol ?? 'tcp'} on host :${detail.host_port ?? '?'}`;
		case 'exposure.delete':
			return `Exposure removed (guest :${detail.guest_port ?? '?'}/${detail.protocol ?? 'tcp'})`;
		default:
			return ev.action;
	}
}