Beside Network, per machine. Every WireGuard interface on the machine in the switcher, with its address, listen port and public key, and under it the peers: name, allowed addresses, last handshake, where they connect from, and what they have moved. Per machine is the point rather than a limitation. The tunnel devices dial into and a tunnel a machine dials out through are different boxes here, and putting them on one page would invite the question of which box you were looking at. The kernel is the truth and the configuration only supplies names: wg show all dump has the handshakes and the counters and knows nothing but public keys, so the # comment above a peer in a wg-quick file, or a UCI section's description on OpenWrt, is read alongside purely to label them. Whether a tunnel is dialled or dialling is worked out from the shape of its peers, and says neither when it cannot tell. Online means a handshake in the last three minutes, and the tab says so in as many words: WireGuard is silent when idle, so there is no connection to be in. Read-only, and no secrets leave: the private key is the second column of the dump and is read past, pre-shared keys are noted as present and never read. The Network tab keeps listing tunnels as the interfaces they are, and hands off to here for anything about them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
370 lines
34 KiB
TypeScript
370 lines
34 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; 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 }
|
||
|
||
// ---- 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
|
||
}
|
||
export interface VPNTunnel {
|
||
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
|
||
fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: 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 }
|
||
|
||
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),
|
||
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),
|
||
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'),
|
||
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}`),
|
||
}
|
||
|
||
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`)
|