export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } } // Which machine the console is looking at. '' is this host; otherwise every /api/… call // is rewritten to /api/nodes//… and the hub proxies it. Hub-only calls (login, // the machine list itself) pass local=true and are never rewritten. let base = '' export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' } export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p) // A machine that just failed to answer is left alone for a few seconds instead of // being hit again on every tick of every page. const downUntil: Record = {} function machineOf(p: string): string | null { const m = p.match(/^\/api\/nodes\/([a-z0-9-]+)\//); return m ? m[1] : null } let inflight = 0 function track(delta: number) { inflight += delta window.dispatchEvent(new CustomEvent('hamlab:inflight', { detail: inflight })) } async function req(method: string, path: string, body?: unknown, local = false): Promise { const target = local ? path : apiPath(path) const mach = machineOf(target) if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`) track(1) let r: Response try { r = await fetch(target, { method, credentials: 'same-origin', headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined, }) } catch (e) { track(-1) throw new Error('network error') } track(-1) if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000 if (r.status === 401) { // The hub itself said no: the session is gone. (A 401 relayed from a managed machine is that // machine rejecting the hub, which is not ours to fix by logging in again.) if (!mach && target !== '/api/login') window.dispatchEvent(new Event('hamlab:unauthenticated')) let msg = 'unauthenticated' try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } throw new Unauthenticated(msg) } if (!r.ok) { let msg = r.statusText try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } throw new Error(msg) } if (r.status === 204) return undefined as T return r.json() } export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string } export interface Me { addr?: string; host: string; user: string; account?: string; guest?: boolean; guest_until?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } export interface PushResult { name: string; ok: boolean; version?: string; error?: string } // What a machine is called on screen: the hostname it reports, which follows a rename within // a poll. The name it was added under stays its identity — the URL, its accent colour, and // what the hub's own API calls it — and is what we fall back to until it has been reached. export const titleOf = (name: string | null, machines: MachineStatus[], hubHost: string): string => name ? machines.find((m) => m.name === name)?.host || name : hubHost export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string } export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number } export interface Fan { chip: string; label: string; rpm: number } export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number } export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number } export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string } export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string } export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; gpu?: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number } export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number } // A graphics device: usage_src says what measures it ("idle" residency, the driver's own "busy", or // the busiest "clients" engine) and is absent when nothing does; temp_src is "gpu" for the device's own // sensor or "package" for the CPU die an integrated GPU shares. export interface GPUClient { pid: number; name: string; usage: number; engines?: Record; mem?: number } export interface GPU { name: string; driver: string; bus?: string; integrated?: boolean usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number clients: GPUClient[]; clients_ok: boolean; stalled?: boolean } export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number } export interface Sample { t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number] mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number } temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number } export interface Series { t: number[]; cpu: number[]; mem: number[] net: Record disk: Record temp: Record } // Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched // off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label"). export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number } export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean } export interface ServiceDetail extends ServiceUnit { props: Record } export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string } export interface CronJob { id: string; source: string; line: number; user: string; schedule: string; command: string; enabled: boolean; next?: number; last?: number; out_file?: string; note?: string; editable: boolean } export interface CronTimer { unit: string; activates: string; description: string; active: string; sub: string; enabled: string; next?: number; last?: number } export interface CronOverview { jobs: CronJob[]; timers: CronTimer[]; logging: boolean; anacron: boolean; sudo: boolean } export interface CronRun { id: string; job_id: string; command: string; user: string; started: string; finished?: string; running: boolean; exit_code: number; output: string } export interface TaskInfo { id: string; kind: string; title: string; command: string; started: string; finished?: string; running: boolean; exit_code: number; error?: string; output?: string } export interface UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string } export interface UpdatesStatus { manager: string; can_upgrade: boolean; selective: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean } export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number } export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] } export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number } export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number } export interface UpdateJob { id: string; name: string; stack: string; machine?: string; services?: string[]; cron: string; apply: boolean; enabled: boolean } export interface UpdateJobRow { job: UpdateJob; next?: string; last?: { at?: string; taken?: string; error?: string } } export interface MachineUpdates { images: ImageCheck[]; checked: number; stacks: Record } export interface DockerHealthProbe { Start: string; End: string; ExitCode: number; Output: string } export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] } export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string } // ---- hardware (the Hardware tab: what the machine is made of) ---- export interface HWSystem { model?: string; serial?: string; family?: string; kernel?: string; arch?: string; virt?: string } export interface HWSlot { designation: string; type?: string; usage?: string; length?: string; width?: string; bus?: string; device?: string } export interface HWBoard { maker?: string; product?: string; version?: string; serial?: string; asset_tag?: string; type?: string; location?: string chassis?: string; chassis_maker?: string; bios?: string; bios_date?: string; bios_revision?: string; rom_size?: string uefi?: boolean; slots?: HWSlot[] } export interface HWCache { level: string; size: string } export interface HWCPU { model?: string; vendor?: string; arch?: string; sockets?: number; socket_names?: string[] cores?: number; threads?: number; threads_per_core?: number; min_mhz?: number; max_mhz?: number family?: string; model_id?: string; stepping?: string; microcode?: string; virtualization?: string caches?: HWCache[]; flags?: string[] } export interface HWModule { locator: string; bank?: string; empty?: boolean; size?: number; type?: string; form_factor?: string speed_mts?: number; running_mts?: number; maker?: string; part?: string; serial?: string; rank?: number; voltage?: string } export interface HWMemory { total: number; installed?: number; max_capacity?: number; slots?: number; filled?: number; ecc?: string; modules?: HWModule[] } export interface HWDrive { name: string; kind: string; model?: string; vendor?: string; serial?: string; firmware?: string; tran?: string; size: number; rotational?: boolean; removable?: boolean } export interface HWGPU { model?: string; vendor?: string; slot?: string; driver?: string; node?: string; memory?: number; class?: string } export interface Hardware { at: string; system: HWSystem; board: HWBoard; cpu: HWCPU; memory: HWMemory; drives: HWDrive[] | null; gpus: HWGPU[] | null; notes?: string[] } export interface DockerLogLine { t: string; m: string; err?: boolean } export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record[]; volumes: Record[]; networks: Record[] } export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; editable: boolean; note?: string; has_env: boolean } export interface StackHome { dir: string; inner: string; via: string; stacks: number; default: boolean } export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string } export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string } export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string } export interface AlertChannel { type: 'ntfy' | 'webhook'; url: string } export interface AlertEvent { t: number; kind: 'firing' | 'resolved'; rule: string; rule_id: string; machine: string; sensor: string; value: unknown; severity: string } export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; events: AlertEvent[]; firing: (AlertEvent & { since: number })[]; checked: number; every_sec: number } export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] } export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record; rail?: string[] } export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean } export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean } export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string } // A guest login: a full account with a clock on it, gone at `expires` or when revoked. export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean } export interface FreshGuest { name: string; password: string; expires: string; minutes: number } export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string } export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record } export interface SpaceTarget { id: string; kind: 'disk' | 'pool'; name: string; model?: string; layout?: string; health?: string; size: number; used?: number; disks?: string[]; roots: string[]; scan?: SpaceScan } export interface SpaceNode { name: string; dir: boolean; size: number; apparent: number; mtime: number; files: number; dirs: number; small_n?: number; small_size?: number; mount?: boolean; denied?: boolean; partial?: boolean } export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } export interface KVM { id: string; name: string; url: string; machine: string; added: string } // ---- the Network tab ---- export interface IfAddr { ip: string; prefix: number; v6?: boolean } export interface Iface { name: string; kind: string; mac?: string; mtu: number; up: boolean; carrier: boolean speed_mbps?: number; duplex?: string; driver?: string; signal_dbm?: number addrs: IfAddr[]; rx_total: number; tx_total: number; default?: boolean } export interface NetReport { at: string; host?: string; ifaces: Iface[]; gateway?: string; gateway_via?: string; dns?: string[]; search?: string[] } export interface Outside { at: string; ip?: string; colo?: string; country?: string; error?: string } export interface SpeedResult { at: string; down_mbps: number; up_mbps: number; latency_ms: number; jitter_ms: number down_bytes: number; up_bytes: number; took_sec: number; colo?: string; ip?: string; source?: string; note?: string; error?: string } // A test in progress, or the last one to have finished. `done` is 0–1 across the whole test // and `mbps` is the live figure for the phase under way. export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult } export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string } // ---- the VPN tab ---- // A peer is a device that dials in (on a server tunnel) or the way out (on a client one). // `online` is a handshake in the last few minutes, not a connection: WireGuard has none. export interface VPNPeer { name?: string; public_key: string; endpoint?: string; allowed_ips: string[] handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean // The configuration section holding it, and the id anything that edits it uses. Absent when // the peer is not in a configuration Ham Lab can edit. section?: string } export interface VPNTunnel { name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string // Whether devices on this tunnel can be added and removed from here. managed?: boolean } export interface NewVPNPeer { name: string; address?: string; endpoint: string; dns?: string; split?: boolean } // A device just made, and the one chance to read its profile: the private key in it is // generated for that call and kept nowhere. export interface CreatedVPNPeer { section: string; name: string; public_key: string; address: string; config: string } export interface VPNReport { at: string; tunnels: VPNTunnel[]; tool: boolean; root: boolean; note?: string } export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean } export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string } export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number } export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null } // The ARC as the sampler sees it (sizes in bytes; hit_pct over the last sampling interval). export interface ARCLive { size: number; target: number; min: number; max: number mru: number; mfu: number; anon: number; data: number; metadata: number; meta_used: number; headers: number compressed: number; uncompressed: number; mru_ghost: number; mfu_ghost: number; l2_size: number; l2_asize: number reads_per_sec: number; misses_per_sec: number; hit_pct: number; hit_pct_known: boolean; l2_hit_pct: number hits: number; misses: number; lifetime_hit_pct: number; no_grow: boolean; throttles: number; prunes: number; evict_skip: number } export interface CacheDev { name: string; size?: number; alloc?: number; health?: string } export interface PoolCaching { pool: string; cache: CacheDev[]; log: CacheDev[]; special: CacheDev[]; dedup: CacheDev[]; spare: CacheDev[] primarycache: string; secondarycache: string; sync: string; logbias: string; dedup_prop: string; compression: string; recordsize: string; dedup_ratio: string } export interface ARCDetail { available: boolean; mem_total: number; tunables: Record lifetime: { Hits: number; Misses: number; DemandDataHits: number; DemandDataMisses: number; DemandMetaHits: number; DemandMetaMisses: number; PrefetchDataHits: number; PrefetchDataMisses: number; PrefetchMetaHits: number; PrefetchMetaMisses: number; L2Hits: number; L2Misses: number; L2ReadBytes: number; L2WriteBytes: number; L2Feeds: number; L2RebuildSize: number; EvictL2Eligible: number; Deleted: number; EvictSkip: number; MutexMiss: number; Prune: number; Throttle: number } zfetch: { Hits: number; Misses: number; IOIssued: number } zil: { commits: number; NormalBytes: number; NormalWrite: number; SlogBytes: number; SlogWrite: number } dbuf: { Size: number; Target: number; Hits: number; Misses: number } pools: PoolCaching[] } export interface ARCView { detail: ARCDetail; live: ARCLive | null } export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean } export interface FileInfo extends FileEntry { path: string; text: boolean } const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}` // ---- Virtualization: Incus, per machine ---- export interface VirtLayout { driver: string; pool_source: string; pool_size_gib?: number; bridge: string; subnet: string; docker_rules: boolean; arc_max_gib?: number; attempted: string; hamlab_version?: string } export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unavailable'; reason?: string; kvm: boolean; arch: string; version?: string; pm?: string; drivers: string[]; docker: boolean; zfs_host: boolean; arc_max_gib?: number; mem_total: number; mem_avail: number; disk_free: number; dir: string; subnet: string; pool_size_gib?: number; layout?: VirtLayout; user: string; root: boolean; unit?: string; btrfs_tools: boolean } export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number } export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record; devices: Record>; schedule?: string; expiry?: string } export interface VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean } export interface VirtPool { name: string; driver: string; source: string; used: number; total: number; used_by: number } export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number } export interface LibraryFile { name: string; size: number; modified: string; attached_to?: string[] } export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string } export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number } export interface ExportFile { instance: string; name: string; size: number; modified: string } export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string } export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; network: string; secureboot: boolean; start: boolean } export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } } export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number } export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean } export const api = { procs: () => req('GET', '/api/procs'), space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'), spaceStatus: (disk: string) => req('GET', `/api/space/${encodeURIComponent(disk)}`), spaceScan: (disk: string) => req('POST', `/api/space/${encodeURIComponent(disk)}/scan`), spaceCancel: (disk: string) => req('POST', `/api/space/${encodeURIComponent(disk)}/cancel`), spaceForget: (disk: string) => req('DELETE', `/api/space/${encodeURIComponent(disk)}`), spaceTree: (disk: string, path: string, depth = 1, limit = 200, min = 0) => req('GET', `/api/space/${encodeURIComponent(disk)}/tree?path=${encodeURIComponent(path)}&depth=${depth}&limit=${limit}&min=${min}`), spaceLargest: (disk: string, n = 500) => req('GET', `/api/space/${encodeURIComponent(disk)}/largest?n=${n}`), procSignal: (pid: number, signal: 'TERM' | 'KILL') => req('POST', `/api/procs/${pid}/signal`, { signal }), files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`), fileStat: (path: string, sudo: boolean) => req('GET', `/api/files/stat?${q(path, sudo)}`), fileRead: async (path: string, sudo: boolean): Promise => { const r = await fetch(apiPath(`/api/files/read?${q(path, sudo, '&max=2097152')}`), { credentials: 'same-origin' }) if (r.status === 401) throw new Unauthenticated() if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) } return r.text() }, fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`), fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`), fileWrite: async (path: string, text: string, sudo: boolean) => { const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } }) if (r.status === 401) throw new Unauthenticated() if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) } }, fileUpload: (dir: string, file: File, sudo: boolean, onProgress: (pct: number) => void, overwrite = false) => new Promise((resolve, reject) => { const x = new XMLHttpRequest() x.open('PUT', apiPath(`/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}${overwrite ? '&overwrite=1' : ''}`)) x.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) } x.onload = () => { if (x.status >= 200 && x.status < 300) resolve(); else { let m = x.statusText; try { m = JSON.parse(x.responseText).error ?? m } catch { /* */ } reject(new Error(m)) } } x.onerror = () => reject(new Error('network error')) x.send(file) }), fileMkdir: (path: string, sudo: boolean) => req('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }), fileTouch: (path: string, sudo: boolean) => req('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }), fileRename: (path: string, to: string, sudo: boolean) => req('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }), fileDelete: (path: string, recursive: boolean, sudo: boolean) => req('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }), fileChmod: (path: string, mode: string, sudo: boolean) => req('POST', `/api/files/chmod${sudo ? '?sudo=1' : ''}`, { path, mode }), storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'), smart: (dev: string, force = false) => req('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`), smartTest: (dev: string, kind: 'short' | 'long') => req('POST', `/api/storage/smart/${dev}/test/${kind}`), // Always the hub's: it holds every machine's KVM, so the tab works when the machine does not. kvms: () => req('GET', '/api/kvms', undefined, true), addKVM: (name: string, url: string, machine: string) => req('POST', '/api/kvms', { name, url, machine }, true), removeKVM: (id: string) => req('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true), zfsOverview: () => req('GET', '/api/zfs'), zfsARC: () => req('GET', '/api/zfs/arc'), saveZfsSchedule: (p: Partial) => req('PUT', '/api/zfs/schedules', p), deleteZfsSchedule: (id: string) => req('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`), runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`), deleteZfsSnapshot: (name: string, recursive: boolean) => req('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }), zfsSnapshots: (dataset: string) => req('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`), zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`), zfsSnapshot: (dataset: string, name: string) => req('POST', '/api/storage/zfs/snapshot', { dataset, name }), installPackages: (packages: string[]) => req('POST', '/api/updates/install', { packages }), tasks: () => req('GET', '/api/tasks'), task: (id: string) => req('GET', `/api/tasks/${id}`), cancelTask: (id: string) => req('POST', `/api/tasks/${id}/cancel`), updates: () => req('GET', '/api/updates'), refreshUpdates: () => req('POST', '/api/updates/refresh'), upgrade: (packages: string[]) => req('POST', '/api/updates/upgrade', { packages }), changelog: (pkg: string) => req<{ text: string }>('GET', `/api/updates/changelog?pkg=${encodeURIComponent(pkg)}`), power: (action: 'reboot' | 'poweroff', confirm: string) => req('POST', `/api/power/${action}`, { confirm }), cron: () => req('GET', '/api/cron'), cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`), cronWriteSource: (name: string, text: string) => req('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }), cronJobLogs: (id: string, n = 200) => req('GET', `/api/cron/jobs/${id}/logs?n=${n}`), cronJobOutput: (id: string, n = 200) => req<{ file: string; text: string }>('GET', `/api/cron/jobs/${id}/output?n=${n}`), cronJobToggle: (id: string, enable: boolean) => req('POST', `/api/cron/jobs/${id}/${enable ? 'enable' : 'disable'}`), cronJobRun: (id: string) => req('POST', `/api/cron/jobs/${id}/run`), cronRun: (id: string) => req('GET', `/api/cron/runs/${id}`), cronTimerAction: (unit: string, action: string) => req('POST', `/api/cron/timers/${encodeURIComponent(unit)}/${action}`), services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units, service: (unit: string) => req('GET', `/api/services/${encodeURIComponent(unit)}`), serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`), serviceLogs: (unit: string, n = 200) => req('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`), serviceAction: (unit: string, action: string) => req('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), historyWindow: () => req('GET', '/api/prefs/history', undefined, true), setHistoryWindow: (seconds: number) => req('PUT', '/api/prefs/history', { seconds }, true), login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true), users: () => req('GET', '/api/users', undefined, true), addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true), removeUser: (name: string, current: string) => req('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true), resetPassword: (name: string, current: string, password: string) => req('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true), guests: () => req('GET', '/api/guests', undefined, true), createGuest: (minutes: number) => req('POST', '/api/guests', { minutes }, true), revokeGuest: (name: string) => req('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true), sessions: () => req('GET', '/api/sessions', undefined, true), revokeSession: (id: string) => req('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true), revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true), revokeIdleSessions: (hours = 24) => req<{ revoked: number }>('POST', `/api/sessions/revoke-idle?hours=${hours}`, undefined, true), logout: () => req('POST', '/api/logout', undefined, true), meHere: () => req('GET', '/api/me', undefined, true), me: () => req('GET', '/api/me'), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), addMachine: (name: string, url: string, username: string, password: string) => req('POST', '/api/nodes', { name, url, username, password }, true), updateMachine: (name: string, force = false) => req('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true), updateAllMachines: (force = false) => req('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true), changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), update: () => req('GET', '/api/update'), checkUpdate: () => req('POST', '/api/update/check'), installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }), tokens: () => req('GET', '/api/tokens', undefined, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), hardware: (refresh = false) => req('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`), network: () => req('GET', '/api/network'), vpn: () => req('GET', '/api/vpn'), addVPNPeer: (iface: string, p: NewVPNPeer) => req('POST', `/api/vpn/${encodeURIComponent(iface)}/peers`, p), editVPNPeer: (iface: string, section: string, p: { name?: string; address?: string }) => req('PATCH', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, p), removeVPNPeer: (iface: string, section: string, confirm: string) => req('DELETE', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, { confirm }), speedtest: () => req('GET', '/api/network/speedtest'), startSpeedtest: () => req('POST', '/api/network/speedtest'), cancelSpeedtest: () => req('POST', '/api/network/speedtest/cancel'), docker: () => req('GET', '/api/docker'), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`), dockerInspect: (id: string) => req>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`), machineUpdates: (refresh = false) => req('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`), updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'), saveUpdateJob: (j: Partial) => req('PUT', '/api/docker/schedules', j), deleteUpdateJob: (id: string) => req('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`), runUpdateJob: (id: string) => req<{ output: string }>('POST', `/api/docker/schedules/${encodeURIComponent(id)}/run`), dockerHealth: (id: string) => req('GET', `/api/docker/containers/${encodeURIComponent(id)}/health`), dockerEvents: (id: string, since = '7d') => req<{ events: DockerEvent[]; since: string; kept: number; capacity: number }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/events?since=${since}`), dockerExec: (id: string) => req<{ id: string; name: string }>('POST', `/api/docker/containers/${encodeURIComponent(id)}/exec`, {}), dockerLimits: (id: string, p: { memory?: string; cpus?: string; restart?: string }) => req('POST', `/api/docker/containers/${encodeURIComponent(id)}/limits`, p), stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'), stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'), createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p), deleteStack: (name: string) => req<{ trash: string; moved: number }>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/delete`, { confirm: name }), pullImage: (image: string) => req('POST', '/api/docker/images/pull', { image }), removeImage: (image: string) => req('POST', '/api/docker/images/remove', { image }), pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }), removeVolume: (name: string) => req('POST', '/api/docker/volumes/remove', { name, confirm: name }), pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'), removeNetwork: (name: string) => req('POST', '/api/docker/networks/remove', { name }), pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/prune'), stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`), stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`), stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`), saveStack: (name: string, file: number | 'env', text: string, sum: string) => req('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }), stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`), alerts: () => req('GET', '/api/alerts', undefined, true), saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req('PUT', '/api/alerts', p, true), testAlerts: () => req('POST', '/api/alerts/test', undefined, true), revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), uiPrefs: () => req('GET', '/api/prefs/ui', undefined, true), saveUIPrefs: (p: UIPrefs) => req('PUT', '/api/prefs/ui', p, true), removeMachine: (name: string) => req('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), terminals: () => req('GET', '/api/terminals'), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), renameTerminal: (id: string, name: string) => req('PATCH', `/api/terminals/${id}`, { name }), deleteTerminal: (id: string) => req('DELETE', `/api/terminals/${id}`), virt: () => req('GET', '/api/virt'), virtInstall: (o: VirtInstallOpts) => req('POST', '/api/virt/install', o), virtRemote: (type: string, refresh = false) => req<{ images: VirtImage[] }>('GET', `/api/virt/images/remote?type=${type}${refresh ? '&refresh=1' : ''}`), virtImageDownload: (alias: string) => req('POST', '/api/virt/images/download', { alias }), virtImageImport: (path: string, alias: string, type: string) => req('POST', '/api/virt/images/import', { path, alias, type }), virtImagePublish: (instance: string, alias: string) => req('POST', '/api/virt/images/publish', { instance, alias }), virtImageDelete: (ref: string) => req('POST', '/api/virt/images/delete', { ref }), virtCreate: (c: VirtCreate) => req('POST', '/api/virt/instances', c), virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`), virtDelete: (name: string, force: boolean) => req('POST', `/api/virt/instances/${encodeURIComponent(name)}/delete`, { confirm: name, force }), virtTerminal: (name: string, kind: 'console' | 'exec') => req<{ id: string; name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${kind}`), virtRename: (name: string, to: string) => req('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }), virtConfig: (name: string, cfg: Record) => req('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg), virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }), virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`), virtISOFetch: (url: string, sha256: string, name: string) => req('POST', '/api/virt/isos/fetch', { url, sha256, name }), virtISOImport: (path: string) => req('POST', '/api/virt/isos/import', { path }), virtISODelete: (name: string) => req('POST', '/api/virt/isos/delete', { name }), virtISORename: (name: string, to: string) => req('POST', '/api/virt/isos/rename', { name, to }), virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }), virtISODetach: (inst: string, device: string) => req('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }), virtFromISO: (f: FromISO) => req('POST', '/api/virt/instances/from-iso', f), virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m), virtMountRemove: (inst: string, device: string) => req('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }), virtExports: () => req('GET', '/api/virt/exports'), virtExportsPut: (c: ExportConfig) => req('PUT', '/api/virt/exports', c), virtExportsRun: (instance = '') => req('POST', '/api/virt/exports/run', { instance }), virtExportDelete: (instance: string, name: string) => req('POST', '/api/virt/exports/delete', { instance, name }), virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'), } export function wsURLPath(path: string): string { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' return `${proto}//${location.host}${apiPath(path)}` } export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`)