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;
}
}