Multi-host phase 1: API tokens, auto TLS with pinned fingerprints, hub-side node manager + reverse proxy (WebSockets included), machine switcher in the rail, Machines page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
fc42675e9f
commit
7dcdb5dc98
+23
-11
@@ -1,16 +1,23 @@
|
||||
export class Unauthenticated extends Error {}
|
||||
|
||||
// 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)
|
||||
|
||||
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): Promise<T> {
|
||||
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
||||
track(1)
|
||||
let r: Response
|
||||
try {
|
||||
r = await fetch(path, {
|
||||
r = await fetch(local ? path : apiPath(path), {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
@@ -31,7 +38,8 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean }
|
||||
export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string }
|
||||
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 }
|
||||
@@ -90,21 +98,21 @@ export const api = {
|
||||
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(`/api/files/read?${q(path, sudo, '&max=2097152')}`, { credentials: 'same-origin' })
|
||||
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) => `/api/files/read?${q(path, sudo)}`,
|
||||
fileDownloadURL: (path: string, sudo: boolean) => `/api/files/download?${q(path, sudo)}`,
|
||||
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(`/api/files/write?${q(path, sudo)}`, { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||
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) => new Promise<void>((resolve, reject) => {
|
||||
const x = new XMLHttpRequest()
|
||||
x.open('PUT', `/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}`)
|
||||
x.open('PUT', apiPath(`/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}`))
|
||||
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'))
|
||||
@@ -148,9 +156,13 @@ export const api = {
|
||||
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
||||
metrics: () => req<Sample>('GET', '/api/metrics'),
|
||||
metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
|
||||
login: (password: string) => req<void>('POST', '/api/login', { password }),
|
||||
logout: () => req<void>('POST', '/api/logout'),
|
||||
login: (password: string) => req<void>('POST', '/api/login', { password }, 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, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, 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 }),
|
||||
@@ -159,6 +171,6 @@ export const api = {
|
||||
|
||||
export function wsURLPath(path: string): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${location.host}${path}`
|
||||
return `${proto}//${location.host}${apiPath(path)}`
|
||||
}
|
||||
export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`)
|
||||
|
||||
Reference in New Issue
Block a user