Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3441ae718 | ||
|
|
70fcf86283 |
@@ -320,6 +320,34 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||
had taken left untouched and fully readable here — which is what adopting its naming
|
||||
bought. See `hamlet/docs/hosts.md`.
|
||||
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
|
||||
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
|
||||
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
|
||||
at `10.0.0.217`, but none of that is in the code.
|
||||
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
|
||||
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
|
||||
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
|
||||
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
|
||||
**The general problem, and the answer.** A device UI asks for its assets by absolute path
|
||||
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
|
||||
bodies per device would not be general. So the prefix is only the entry point: any request
|
||||
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
|
||||
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
|
||||
Ham Lab's own routes always match first, so a device can never shadow the console, and the
|
||||
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
|
||||
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
|
||||
now. Redirects to the device's root are rewritten back through the prefix.
|
||||
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
|
||||
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
|
||||
refused before it loaded.
|
||||
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
|
||||
does not need to, since one with focus receives them directly. Fullscreen plus
|
||||
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
|
||||
that is Chromium-only, and the tab says which version you are getting rather than
|
||||
pretending. The list is **hub-side** on purpose — a KVM earns its keep when the machine is
|
||||
down, which is when that machine's own Ham Lab cannot answer — and a KVM need not belong to
|
||||
a machine Ham Lab manages at all. Not done: holding the device's own login on the hub, so
|
||||
its page does not ask for a second password.
|
||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
@@ -585,39 +613,6 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
||||
|
||||
- **A KVM tab** — asked for 2026-09-24, not started. **Dead simple is the brief**: paste one
|
||||
HTTP link, the tab shows whatever UI that KVM serves, and the keyboard goes to it. **This
|
||||
is KVM the keyboard-video-mouse switch, not KVM the hypervisor** — the Virtualization item
|
||||
above means the other one.
|
||||
- **The device**: a **Sipeed NanoKVM** attached to `grid` at **`10.0.0.217`** (MAC
|
||||
`48:da:35:6f:d0:31`), web UI on port 80. The only one so far, so store a list, not one URL.
|
||||
- **Framing works.** Probed 2026-09-24: it sends **no `X-Frame-Options` and no
|
||||
`frame-ancestors`**, so it does not refuse to be embedded. No fallback needed.
|
||||
- **Proxy it through the daemon — that is what makes the simple version possible.** The
|
||||
KVM is **HTTP only**, and Ham Lab is served over TLS, so a bare `http://…` iframe is
|
||||
blocked as mixed content and the "just paste a link" idea dies there. Serving it from Ham
|
||||
Lab's own origin (`/api/kvm/<id>/…`, on the `proxyNode` machinery, which already carries
|
||||
WebSockets — exactly what the video and input streams are) fixes three things at once:
|
||||
no mixed content, the KVM sits behind **Ham Lab's** login rather than a second one
|
||||
(`/api/vm/info` answers 401, so it has its own, which the hub can hold once), and the
|
||||
iframe becomes **same-origin**, which matters for the keyboard below.
|
||||
- **The keyboard needs less than it sounds.** A parent page *cannot* capture keys and
|
||||
forward them into a **cross-origin** iframe — the browser forbids synthetic events across
|
||||
origins — but it does not need to: an iframe with focus receives keystrokes directly, and
|
||||
the NanoKVM's own page already handles them. So "capture and give it to the iframe" is
|
||||
really: click-to-focus, keep focus there, and stop the console's own shortcuts from
|
||||
eating keys while it is focused. What is genuinely worth adding is **fullscreen** plus
|
||||
`navigator.keyboard.lock()`, which is what stops the *browser* swallowing Escape, Tab and
|
||||
Ctrl-W so they reach the guest — note it is Chromium-only, so Firefox and iOS Safari get
|
||||
the plain version. Proxying makes the iframe same-origin anyway, so real forwarding stays
|
||||
available if some KVM ever needs it.
|
||||
- **Where the list lives is the one real design decision.** A KVM earns its keep when the
|
||||
machine is *down* — no OS, a bad kernel, a BIOS screen — which is when that host's Ham Lab
|
||||
cannot answer. So it is **hub-side** and must work for machines running no Ham Lab at all.
|
||||
A `kvm_url` in each node's own `config.json` would fail in exactly the case it is for.
|
||||
- **Do not give it an NPM host** without deciding to: `*.bigham.dev` is a wildcard to the
|
||||
public IP, so that would put BIOS-level control of `grid` on the internet behind only the
|
||||
KVM's own login. Behind Ham Lab's proxy it inherits Ham Lab's auth instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
+41
-3
@@ -200,6 +200,13 @@ func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func machineLabel(m string) string {
|
||||
if m == "" {
|
||||
return "this host"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- the list ----
|
||||
|
||||
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -208,6 +215,20 @@ func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
// kvmForMachine is the KVM attached to a machine, "" being the hub itself.
|
||||
func (s *Server) kvmForMachine(machine string) (config.KVM, bool) {
|
||||
var found config.KVM
|
||||
ok := false
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, k := range c.KVMs {
|
||||
if k.Machine == machine {
|
||||
found, ok = k, true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found, ok
|
||||
}
|
||||
|
||||
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, URL, Machine string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
@@ -236,12 +257,29 @@ func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: strings.TrimSpace(body.Machine), Added: time.Now()}
|
||||
if err := s.cfg.Update(func(c *config.Config) { c.KVMs = append(c.KVMs, k) }); err != nil {
|
||||
machine := strings.TrimSpace(body.Machine)
|
||||
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()}
|
||||
// One per machine: a second one for the same host replaces the first rather than sitting
|
||||
// beside it, since the tab shows the machine you are looking at.
|
||||
var replaced string
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
kept := c.KVMs[:0]
|
||||
for _, old := range c.KVMs {
|
||||
if old.Machine == machine {
|
||||
replaced = old.ID
|
||||
continue
|
||||
}
|
||||
kept = append(kept, old)
|
||||
}
|
||||
c.KVMs = append(kept, k)
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("kvm %q added (%s) by %s", k.Name, k.URL, clientIP(r))
|
||||
if replaced != "" {
|
||||
s.forgetKVMProxy(replaced)
|
||||
}
|
||||
log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), k.URL, clientIP(r))
|
||||
jsonOK(w, 201, k)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,9 +70,11 @@ type KVM struct {
|
||||
Name string `json:"name"`
|
||||
// URL is the device's own address, as you would type it into a browser on the LAN.
|
||||
URL string `json:"url"`
|
||||
// Machine is which host it is attached to, for display only — it need not be a machine
|
||||
// Ham Lab manages, and usually the point is that it is not answering.
|
||||
Machine string `json:"machine,omitempty"`
|
||||
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
|
||||
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
||||
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
|
||||
// answer for itself.
|
||||
Machine string `json:"machine"`
|
||||
Added time.Time `json:"added"`
|
||||
}
|
||||
|
||||
|
||||
+49
-53
@@ -1,8 +1,12 @@
|
||||
// IP-KVMs: a device's own web page, shown inside Ham Lab.
|
||||
// The KVM of the machine you are looking at: the device's own web page, shown in the tab.
|
||||
//
|
||||
// Paste the address, and the device appears. Nothing here knows anything about any
|
||||
// particular KVM — it is an iframe pointed at Ham Lab's own proxy, so whatever web UI the
|
||||
// device serves is what you get.
|
||||
// One per machine, and it follows the machine switcher — switch to `grid` and you get
|
||||
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
|
||||
// its keep exactly when that machine is down and cannot answer for itself; the hub proxies
|
||||
// the device either way.
|
||||
//
|
||||
// Nothing here knows anything about any particular KVM. It is an iframe pointed at Ham
|
||||
// Lab's own proxy, so whatever web UI the device serves is what you get.
|
||||
//
|
||||
// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not
|
||||
// need to. An iframe with focus receives them directly. What the browser itself swallows —
|
||||
@@ -10,7 +14,7 @@
|
||||
// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain
|
||||
// version, which is most keys, and the device's own on-screen buttons for the rest.
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { KVM as Device, MachineStatus, api } from './api'
|
||||
import { KVM as Device, api } from './api'
|
||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
|
||||
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
|
||||
@@ -18,19 +22,19 @@ import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
||||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||||
|
||||
export default function KVM({ machines }: { machines: MachineStatus[] }) {
|
||||
const [list, setList] = useState<Device[] | null>(null)
|
||||
const [sel, setSel] = useState<string>('')
|
||||
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
||||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [full, setFull] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const frame = useRef<HTMLIFrameElement>(null)
|
||||
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { const l = await api.kvms(); setList(l); setSel((s) => (l.some((k) => k.id === s) ? s : l[0]?.id ?? '')) }
|
||||
try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
}, [key])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
|
||||
@@ -53,97 +57,89 @@ export default function KVM({ machines }: { machines: MachineStatus[] }) {
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') }
|
||||
}
|
||||
|
||||
const remove = async (k: Device) => {
|
||||
if (!await askYes(`Remove ${k.name}? The device is not touched — this only forgets its address.`, 'Remove')) return
|
||||
try { await api.removeKVM(k.id); toast(`${k.name} removed`); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
const remove = async () => {
|
||||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
||||
try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (err && !list) return <div className="dash"><div className="error">{err}</div></div>
|
||||
if (!list) return <div className="dash"><Loading label="Reading the list…" /></div>
|
||||
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||||
|
||||
const cur = list.find((k) => k.id === sel)
|
||||
const locks = !!keyboardAPI()?.lock
|
||||
|
||||
return (
|
||||
<div className="kvm">
|
||||
<div className="kvm-bar">
|
||||
{list.length > 0 && (
|
||||
<span className="chips">
|
||||
{list.map((k) => (
|
||||
<button key={k.id} className={'chip' + (k.id === sel ? ' on' : '')} onClick={() => setSel(k.id)}
|
||||
title={`${k.url}${k.machine ? ` — attached to ${k.machine}` : ''}`}>{k.name}</button>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="dname">{title}</span>
|
||||
{kvm
|
||||
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
||||
: <span className="muted small">no KVM</span>}
|
||||
<span className="spacer" />
|
||||
{cur && <>
|
||||
<span className="muted small mono kvm-url">{cur.url}</span>
|
||||
{kvm && <>
|
||||
<Btn className="btn" onClick={goFullscreen} title={locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>Fullscreen</Btn>
|
||||
<a className="btn" href={cur.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab">Open ↗</a>
|
||||
<Btn className="btn danger" onClick={() => remove(cur)}>Remove</Btn>
|
||||
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
||||
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</>}
|
||||
<Btn className="btn on" onClick={() => setAdding(true)}>+ Add</Btn>
|
||||
{!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
|
||||
</div>
|
||||
|
||||
{cur ? (
|
||||
{kvm ? (
|
||||
<div className={'kvm-frame' + (full ? ' full' : '')} ref={box}>
|
||||
<iframe ref={frame} key={cur.id} src={`/kvm/${cur.id}/`} title={cur.name}
|
||||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
||||
allow="fullscreen; clipboard-read; clipboard-write" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="svc-empty muted">
|
||||
{list.length === 0
|
||||
? 'No KVM yet. Add one with its address and its web page appears here.'
|
||||
: 'Pick a KVM.'}
|
||||
<div className="svc-empty muted kvm-none">
|
||||
<div>
|
||||
<p>No KVM for <b>{title}</b>.</p>
|
||||
<p className="small">Add one with its address and its page appears here. The address is kept on the hub,
|
||||
so it still works when {title} does not — which is when you will want it.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cur && !full && (
|
||||
{kvm && !full && (
|
||||
<div className="kvm-hint muted small">
|
||||
Click the picture, then type — the keys go to the machine.
|
||||
Click the picture, then type — the keys go to {title}.
|
||||
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
||||
</div>
|
||||
)}
|
||||
{adding && <AddDialog machines={machines} onClose={() => setAdding(false)} onAdded={(k) => { setAdding(false); setSel(k.id); load() }} />}
|
||||
{adding && <AddDialog machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDialog({ machines, onClose, onAdded }: { machines: MachineStatus[]; onClose: () => void; onAdded: (k: Device) => void }) {
|
||||
const [url, setUrl] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [machine, setMachine] = useState('')
|
||||
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
||||
const [url, setUrl] = useState(existing?.url ?? '')
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEscape(onClose)
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr(''); setBusy(true)
|
||||
try { onAdded(await api.addKVM(name.trim(), url.trim(), machine)) }
|
||||
try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>Add a KVM</h3>
|
||||
<h3>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</h3>
|
||||
<label className="muted">Address — whatever you would type into a browser
|
||||
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
||||
</label>
|
||||
<label className="muted">Name — optional; the address is used otherwise
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="mac mini KVM" maxLength={64} />
|
||||
</label>
|
||||
<label className="muted">Attached to — optional, and only a label
|
||||
<select value={machine} onChange={(e) => setMachine(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{machines.map((m) => <option key={m.name} value={m.name}>{m.host || m.name}</option>)}
|
||||
</select>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
|
||||
</label>
|
||||
<div className="muted small">
|
||||
Ham Lab serves the device from its own address, so a KVM that only speaks plain HTTP
|
||||
Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
|
||||
still works here, and it is behind this login rather than needing a second one. The
|
||||
address is kept on the hub, so it still reaches the device when {title} is down. The
|
||||
device itself is not changed and is never reachable from outside.
|
||||
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={busy || !url.trim()}>Add</button>
|
||||
<button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -63,8 +63,8 @@ const GROUPS: Group[] = [
|
||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||
{ id: 'users', label: 'Users', icon: '⚇' },
|
||||
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
@@ -72,7 +72,7 @@ const GROUPS: Group[] = [
|
||||
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'kvm', 'users', 'settings', 'alerts']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||
@@ -271,7 +271,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'users' && <Users me={hubMe} />}
|
||||
{view === 'kvm' && <KVM machines={machines} />}
|
||||
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
||||
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||
{view === 'widgets' && <Gallery />}
|
||||
|
||||
+2
-1
@@ -133,7 +133,7 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
||||
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 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 }
|
||||
@@ -186,6 +186,7 @@ export const api = {
|
||||
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),
|
||||
|
||||
@@ -750,3 +750,4 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.kvm-frame.full { background: #000; }
|
||||
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
||||
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
||||
|
||||
Reference in New Issue
Block a user