Files
hamlab/web/src/api.ts
T
Mitchell-JohnsonandClaude Opus 5 09dc846730 docker: logs follow a held connection, and networks can be removed
Two gaps the Docker tab had against Dockhand and against its own Services
tab next door.

Logs: one `docker logs -f` per viewer over a WebSocket, the shape the
Services tab's journal tail already uses, instead of asking again every two
seconds. stdout and stderr both, with stderr marked so the page can colour
what went wrong. Docker replays the backlog down two pipes in no particular
order, so the opening 300 ms is held and sorted by the stamp Docker wrote;
after that the log is live and in order. A reconnect resumes from the last
line rather than from the top — `--since` is inclusive and two lines can
share a nanosecond, so the client drops what it has already shown by line,
not by "later than". A container that has stopped ends the stream at once,
so reconnects back off to 15 s.

Networks: remove and prune, which images and volumes have had all along.
The overview now says which containers are attached to each network, so the
✕ appears only on one nothing uses, and never on bridge, host or none.
`network rm` prints the real reason and then an aggregate "exit status 1",
so the message shown is the first line, not the last.

Prune is not exercised against `core`: several networks here belong to
stacks that are merely down, and pruning would give them new subnets on
their next deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:17:37 -07:00

303 lines
29 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; 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; 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 }
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[]; 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 }
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 }
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 }
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 }
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}`
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'),
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),
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),
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),
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' | '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}`),
}
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`)