A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is what make release already writes and what install.sh already downloads. The binary is checked against SHA256SUMS before it goes near the running one, and a token is sent when the source is behind a login, so a private Gitea serves its own fleet without opening the instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the installer never disagree about which build is newer. Settings gains an Updates card: installed, published, when it last looked, the source, release notes, Check for updates, and Install. The rail badges Settings when a newer build is out. Nothing installs unasked — it restarts the daemon and drops terminals. scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release. Deliberate, and not part of deploy: deploy pushes to the machines this hub owns, publishing offers the build to every install anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
26 KiB
TypeScript
270 lines
26 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 { demo_url?: string; 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 }
|
|
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 DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
|
|
|
|
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 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 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}`),
|
|
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),
|
|
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
|
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
|
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`),
|
|
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`)
|