The tab reports one of four states for the selected machine (ready, repair, installable, unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the pool drivers the kernel has — and acts on that machine only. Install is one followed task: packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file, btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a restart. Instances list with live CPU and memory, create from the images: catalogue or a local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/ delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry, limits editing. Images: local store, catalogue search and download (exported into the library too), import a tarball or a VM disk from a path, publish an instance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
447 lines
42 KiB
TypeScript
447 lines
42 KiB
TypeScript
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/<name>/… 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<string, number> = {}
|
||
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<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
||
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<string, number>; mem?: number }
|
||
export interface GPU {
|
||
name: string; driver: string; bus?: string; integrated?: boolean
|
||
usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record<string, number>
|
||
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_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<string, { rx: number[]; tx: number[] }>
|
||
disk: Record<string, { r: number[]; w: number[]; u: number[] }>
|
||
temp: Record<string, number[]>
|
||
}
|
||
|
||
// 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<string, string> }
|
||
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<string, string[]> }
|
||
|
||
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<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
||
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; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
||
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: 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<string, string>; 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<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
||
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<string, string>
|
||
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<string, string>; devices: Record<string, Record<string, string>>; 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 }
|
||
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<ProcTable>('GET', '/api/procs'),
|
||
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
|
||
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
|
||
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
|
||
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
|
||
spaceForget: (disk: string) => req<void>('DELETE', `/api/space/${encodeURIComponent(disk)}`),
|
||
spaceTree: (disk: string, path: string, depth = 1, limit = 200, min = 0) => req<SpaceTree>('GET', `/api/space/${encodeURIComponent(disk)}/tree?path=${encodeURIComponent(path)}&depth=${depth}&limit=${limit}&min=${min}`),
|
||
spaceLargest: (disk: string, n = 500) => req<SpaceFile[]>('GET', `/api/space/${encodeURIComponent(disk)}/largest?n=${n}`),
|
||
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('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<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
|
||
fileRead: async (path: string, sudo: boolean): Promise<string> => {
|
||
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<void>((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<void>('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }),
|
||
fileTouch: (path: string, sudo: boolean) => req<void>('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }),
|
||
fileRename: (path: string, to: string, sudo: boolean) => req<void>('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }),
|
||
fileDelete: (path: string, recursive: boolean, sudo: boolean) => req<void>('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }),
|
||
fileChmod: (path: string, mode: string, sudo: boolean) => req<void>('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<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('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<KVM[]>('GET', '/api/kvms', undefined, true),
|
||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
||
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||
deleteZfsSchedule: (id: string) => req<void>('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<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }),
|
||
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
|
||
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
|
||
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
|
||
installPackages: (packages: string[]) => req<TaskInfo>('POST', '/api/updates/install', { packages }),
|
||
tasks: () => req<TaskInfo[]>('GET', '/api/tasks'),
|
||
task: (id: string) => req<TaskInfo>('GET', `/api/tasks/${id}`),
|
||
cancelTask: (id: string) => req<void>('POST', `/api/tasks/${id}/cancel`),
|
||
updates: () => req<UpdatesStatus>('GET', '/api/updates'),
|
||
refreshUpdates: () => req<TaskInfo>('POST', '/api/updates/refresh'),
|
||
upgrade: (packages: string[]) => req<TaskInfo>('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<void>('POST', `/api/power/${action}`, { confirm }),
|
||
cron: () => req<CronOverview>('GET', '/api/cron'),
|
||
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
|
||
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
|
||
cronJobLogs: (id: string, n = 200) => req<LogEntry[]>('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<void>('POST', `/api/cron/jobs/${id}/${enable ? 'enable' : 'disable'}`),
|
||
cronJobRun: (id: string) => req<CronRun>('POST', `/api/cron/jobs/${id}/run`),
|
||
cronRun: (id: string) => req<CronRun>('GET', `/api/cron/runs/${id}`),
|
||
cronTimerAction: (unit: string, action: string) => req<void>('POST', `/api/cron/timers/${encodeURIComponent(unit)}/${action}`),
|
||
services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units,
|
||
service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`),
|
||
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
|
||
serviceLogs: (unit: string, n = 200) => req<LogEntry[]>('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`),
|
||
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
|
||
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
|
||
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
|
||
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
|
||
users: () => req<User[]>('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<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
||
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
||
guests: () => req<Guest[]>('GET', '/api/guests', undefined, true),
|
||
createGuest: (minutes: number) => req<FreshGuest>('POST', '/api/guests', { minutes }, true),
|
||
revokeGuest: (name: string) => req<void>('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true),
|
||
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
||
revokeSession: (id: string) => req<void>('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<void>('POST', '/api/logout', undefined, true),
|
||
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
||
me: () => req<Me>('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<MachineStatus>('POST', '/api/nodes', { name, url, username, password }, true),
|
||
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
|
||
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
|
||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||
update: () => req<UpdateStatus>('GET', '/api/update'),
|
||
checkUpdate: () => req<UpdateStatus>('POST', '/api/update/check'),
|
||
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
|
||
tokens: () => req<TokenInfo[]>('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<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
||
network: () => req<NetworkInfo>('GET', '/api/network'),
|
||
vpn: () => req<VPNReport>('GET', '/api/vpn'),
|
||
addVPNPeer: (iface: string, p: NewVPNPeer) => req<CreatedVPNPeer>('POST', `/api/vpn/${encodeURIComponent(iface)}/peers`, p),
|
||
editVPNPeer: (iface: string, section: string, p: { name?: string; address?: string }) =>
|
||
req<void>('PATCH', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, p),
|
||
removeVPNPeer: (iface: string, section: string, confirm: string) =>
|
||
req<void>('DELETE', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, { confirm }),
|
||
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
|
||
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
|
||
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
|
||
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('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<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
||
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
|
||
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
|
||
saveUpdateJob: (j: Partial<UpdateJob>) => req<UpdateJob>('PUT', '/api/docker/schedules', j),
|
||
deleteUpdateJob: (id: string) => req<void>('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`),
|
||
runUpdateJob: (id: string) => req<{ output: string }>('POST', `/api/docker/schedules/${encodeURIComponent(id)}/run`),
|
||
dockerHealth: (id: string) => req<DockerHealth>('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<void>('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<TaskInfo>('POST', '/api/docker/images/pull', { image }),
|
||
removeImage: (image: string) => req<void>('POST', '/api/docker/images/remove', { image }),
|
||
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
||
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
||
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
||
removeNetwork: (name: string) => req<void>('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<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
|
||
stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
|
||
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||
uiPrefs: () => req<UIPrefs>('GET', '/api/prefs/ui', undefined, true),
|
||
saveUIPrefs: (p: UIPrefs) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
|
||
terminals: () => req<Term[]>('GET', '/api/terminals'),
|
||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
|
||
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
||
virt: () => req<VirtOverview>('GET', '/api/virt'),
|
||
virtInstall: (o: VirtInstallOpts) => req<TaskInfo>('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<TaskInfo>('POST', '/api/virt/images/download', { alias }),
|
||
virtImageImport: (path: string, alias: string, type: string) => req<TaskInfo>('POST', '/api/virt/images/import', { path, alias, type }),
|
||
virtImagePublish: (instance: string, alias: string) => req<TaskInfo>('POST', '/api/virt/images/publish', { instance, alias }),
|
||
virtImageDelete: (ref: string) => req<void>('POST', '/api/virt/images/delete', { ref }),
|
||
virtCreate: (c: VirtCreate) => req<TaskInfo>('POST', '/api/virt/instances', c),
|
||
virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`),
|
||
virtDelete: (name: string, force: boolean) => req<void>('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<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }),
|
||
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('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<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
|
||
}
|
||
|
||
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`)
|