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
+1
-1
@@ -7,7 +7,7 @@ export default function App() {
|
||||
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||
try { setMe(await api.meHere()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MachineStatus } from './api'
|
||||
|
||||
// The host picker at the top of the rail. The hub is always first; managed machines follow
|
||||
// with their live status. Picking one re-points the whole console at it.
|
||||
export default function MachineSwitcher({ hub, current, machines, onChange, onManage }: {
|
||||
hub: string; current: string | null; machines: MachineStatus[]; onChange: (name: string | null) => void; onManage: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false) }
|
||||
window.addEventListener('click', h)
|
||||
return () => window.removeEventListener('click', h)
|
||||
}, [])
|
||||
const cur = current ? machines.find((m) => m.name === current) : null
|
||||
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
|
||||
return (
|
||||
<div className="mswitch" ref={box}>
|
||||
<button className="mswitch-btn" onClick={() => setOpen(!open)} title="switch machine">
|
||||
<span className={'dot ' + (current ? dotFor(cur) : 'ok')} />
|
||||
<span className="mname">{current ?? hub}</span>
|
||||
<span className="muted small">{current ? (cur?.online ? 'remote' : 'offline') : 'this host'}</span>
|
||||
<span className="chev">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mmenu">
|
||||
<button className={'mitem' + (!current ? ' on' : '')} onClick={() => { setOpen(false); onChange(null) }}>
|
||||
<span className="dot ok" /><span className="mname">{hub}</span><span className="muted small">this host</span>
|
||||
</button>
|
||||
{machines.map((m) => (
|
||||
<button key={m.name} className={'mitem' + (current === m.name ? ' on' : '')} onClick={() => { setOpen(false); onChange(m.name) }} title={m.error || m.url}>
|
||||
<span className={'dot ' + dotFor(m)} /><span className="mname">{m.name}</span><span className="muted small">{m.online ? m.load : 'offline'}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="mitem manage" onClick={() => { setOpen(false); onManage() }}>+ manage machines…</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, MachineStatus, Me } from './api'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
const ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
||||
const shortFp = (fp?: string) => (fp ? fp.split(':').slice(0, 6).join(':') + '…' : '')
|
||||
|
||||
export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChanged: () => void; onOpen: (name: string) => void }) {
|
||||
const [list, setList] = useState<MachineStatus[] | null>(null)
|
||||
const [fp, setFp] = useState<string | undefined>()
|
||||
const [err, setErr] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('https://')
|
||||
const [pw, setPw] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addErr, setAddErr] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { const r = await api.machines(); setList(r.nodes); setFp(r.fingerprint); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setList([]) }
|
||||
}, [])
|
||||
useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t) }, [load])
|
||||
|
||||
async function add(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setAdding(true); setAddErr('')
|
||||
try {
|
||||
const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), pw)
|
||||
toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`)
|
||||
setName(''); setUrl('https://'); setPw('')
|
||||
await load(); onChanged()
|
||||
} catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setAdding(false) }
|
||||
}
|
||||
async function remove(m: MachineStatus) {
|
||||
if (!window.confirm(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
|
||||
try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (!list) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Loading machines…" />}</div>
|
||||
return (
|
||||
<div className="dash machines">
|
||||
<div className="dash-bar"><h2 className="section">This host</h2></div>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dot ok" /><span className="dname">{hub.host}</span><span className="badge">hub</span><span className="spacer" /><span className="num muted">v{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}</span></div>
|
||||
<div className="muted small">{fp ? <>Certificate <span className="mono">{fp}</span></> : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}</div>
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2></div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{list.length === 0 && <div className="notice">No machines yet. Install Ham Lab on another host, set its password, then add it below.</div>}
|
||||
<div className="cards">
|
||||
{list.map((m) => (
|
||||
<section className="card" key={m.name}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (m.online ? 'ok' : 'bad')} />
|
||||
<span className="dname">{m.name}</span>
|
||||
<span className="muted small mono">{m.url}</span>
|
||||
<span className="spacer" />
|
||||
{m.reboot_needed && <span className="badge" title="reboot required">⟳</span>}
|
||||
</div>
|
||||
<div className="props">
|
||||
<div><span className="k">Status</span><span className={m.online ? 'good' : 'warn-text'}>{m.online ? 'online' : m.error || 'offline'}</span></div>
|
||||
{m.host && <div><span className="k">Host</span><span>{m.host} · v{(m.version ?? '').replace(/^v/, '')}</span></div>}
|
||||
{m.online && <div><span className="k">Load</span><span>{m.load} · up {fmtUptime(m.uptime ?? 0)}</span></div>}
|
||||
<div><span className="k">Seen</span><span>{ago(m.last_seen)}</span></div>
|
||||
{m.fingerprint && <div><span className="k">Cert</span><span className="mono" title={m.fingerprint}>{shortFp(m.fingerprint)}</span></div>}
|
||||
<div><span className="k">Added</span><span>{new Date(m.added).toLocaleDateString()}</span></div>
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
|
||||
<Btn className="btn danger" onClick={() => remove(m)}>Remove</Btn>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Add a machine</h2></div>
|
||||
<form className="card addform" onSubmit={add}>
|
||||
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9-]*" required /></label>
|
||||
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
|
||||
<label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
|
||||
{addErr && <div className="error">{addErr}</div>}
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={adding || !name || !pw}>{adding ? 'Pairing…' : 'Add machine'}</button>
|
||||
<span className="muted small">The password is used once to obtain an access token and is not stored. The machine's certificate is pinned on first contact.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -7,15 +7,16 @@ import { Spinner } from './ui'
|
||||
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean; page?: boolean }
|
||||
|
||||
interface Props {
|
||||
groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean
|
||||
groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean; header?: ReactNode
|
||||
onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
|
||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||
}
|
||||
|
||||
export default function Rail({ groups, terms, active, view, creating, onSelect, onView, onNew, onRename, onClose }: Props) {
|
||||
export default function Rail({ groups, terms, active, view, creating, header, onSelect, onView, onNew, onRename, onClose }: Props) {
|
||||
const [open, setOpen] = useState<string>('terminal')
|
||||
return (
|
||||
<nav className="rail">
|
||||
{header}
|
||||
{groups.map((g) => {
|
||||
const expanded = !g.page && open === g.id
|
||||
const isPage = !!g.page && view === g.id
|
||||
|
||||
+45
-9
@@ -10,6 +10,9 @@ import Updates from './Updates'
|
||||
import Disks from './Disks'
|
||||
import Files from './Files'
|
||||
import Procs from './Procs'
|
||||
import Machines from './Machines'
|
||||
import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import Power from './Power'
|
||||
import { Btn, Toasts, toast } from './ui'
|
||||
|
||||
@@ -29,16 +32,24 @@ const GROUPS: Group[] = [
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤', page: true },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂', page: true },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs'
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines'
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||
// Which machine we're looking at. null = the hub itself. Set before anything renders so
|
||||
// every page's first request already goes to the right place.
|
||||
const [machine, setMachineState] = useState<string | null>(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
||||
const [terms, setTerms] = useState<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
@@ -48,13 +59,35 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await api.terminals()
|
||||
setTerms(list)
|
||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||
}, [])
|
||||
try {
|
||||
const list = await api.terminals()
|
||||
setTerms(list)
|
||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
||||
}, [machine])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
||||
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, 15000); return () => clearInterval(t) }, [loadMachines])
|
||||
|
||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||
const switchMachine = (name: string | null) => {
|
||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false)
|
||||
setMachineState(name)
|
||||
if (view === 'machines') setView('dashboard')
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!machine) { setRemoteMe(null); return }
|
||||
let stop = false
|
||||
const tick = async () => { try { const m = await api.me(); if (!stop) { setRemoteMe(m); setRemoteErr('') } } catch (e) { if (!stop) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } }
|
||||
tick()
|
||||
const t = setInterval(tick, 30000)
|
||||
return () => { stop = true; clearInterval(t) }
|
||||
}, [machine])
|
||||
|
||||
async function create() {
|
||||
setCreating(true)
|
||||
try {
|
||||
@@ -100,6 +133,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
|
||||
<span className="brand">Ham Lab</span>
|
||||
<span className="host">{me.user}@{me.host}</span>
|
||||
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
||||
<span className="spacer" />
|
||||
<span className="stat" title="load average">{me.load}</span>
|
||||
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
||||
@@ -109,12 +143,14 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating}
|
||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }}
|
||||
onNew={create} onRename={rename} onClose={close} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main">
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'procs' && <Procs />}
|
||||
{view === 'services' && <Services />}
|
||||
@@ -141,10 +177,10 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
|
||||
|
||||
<footer className="footer">
|
||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
+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`)
|
||||
|
||||
@@ -334,3 +334,17 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.graph { font-size: 16px; }
|
||||
.netrow { grid-template-columns: 70px 1fr; } .netrow > :nth-child(3) { display: none; }
|
||||
}
|
||||
|
||||
/* machine switcher + machines page */
|
||||
.mswitch { position: relative; padding: 4px 8px 8px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
||||
.mswitch-btn { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; text-align: left; }
|
||||
.mswitch-btn .mname { font-weight: 700; color: var(--accent); }
|
||||
.mswitch-btn .chev { margin-left: auto; color: var(--muted); }
|
||||
.mmenu { position: absolute; left: 8px; right: 8px; top: calc(100% - 4px); z-index: 20; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.5); overflow: hidden; }
|
||||
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
|
||||
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
||||
.addform { display: flex; flex-direction: column; gap: 10px; }
|
||||
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
||||
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
||||
|
||||
Reference in New Issue
Block a user