Five things the tab could not do, all on the container detail. Stats draws CPU and memory over time from the container sensors the sampler already keeps, so there is nothing new to collect. Health shows the check's verdict, the failing streak and what the last five probes actually printed, and says plainly when an image defines no health check at all rather than leaving a blank panel. Events asks Docker what has happened to the container over the last day, week or month — started, died, killed, health going bad, with exit codes — rather than keeping a copy that would only go stale. Shell opens a session inside the container as an ordinary terminal, so it reattaches, resizes and scrolls back like the rest and works on a managed machine through the same proxy. The terminal manager grew a CreateCmd for it; every session is the same thing afterwards. Limits changes memory, CPUs and restart policy on the running container with docker update, live and without recreating it. That is also the catch, and the dialog says so: it does not edit the compose file, so a deploy puts back whatever the file says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
291 lines
28 KiB
TypeScript
291 lines
28 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 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 { action: string; t: number; exitCode?: string; signal?: string; name?: string; image?: string }
|
|
|
|
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: { t: string; m: string }[] }>('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`),
|
|
dockerHealth: (id: string) => req<DockerHealth>('GET', `/api/docker/containers/${encodeURIComponent(id)}/health`),
|
|
dockerEvents: (id: string, since = '7d') => req<{ events: DockerEvent[]; since: string }>('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'),
|
|
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`)
|