The KVM tab is per machine, and follows the machine switcher
It was a hub-side list you pointed at machines, which had it backwards. A machine has one KVM; switch to grid and you get grid's. The address is still kept on the hub rather than on the machine, because a KVM earns its keep exactly when that machine is down and cannot answer for itself — so the tab reaches the device either way. Adding a second KVM for a machine replaces the first rather than sitting beside it. The tab is no longer hub-only and now sits beside Machines in the rail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
70fcf86283
commit
f3441ae718
+41
-3
@@ -200,6 +200,13 @@ func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func machineLabel(m string) string {
|
||||||
|
if m == "" {
|
||||||
|
return "this host"
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// ---- the list ----
|
// ---- the list ----
|
||||||
|
|
||||||
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
|
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)
|
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) {
|
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct{ Name, URL, Machine string }
|
var body struct{ Name, URL, Machine string }
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
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())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
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()}
|
machine := strings.TrimSpace(body.Machine)
|
||||||
if err := s.cfg.Update(func(c *config.Config) { c.KVMs = append(c.KVMs, k) }); err != nil {
|
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())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
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)
|
jsonOK(w, 201, k)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ type KVM struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// URL is the device's own address, as you would type it into a browser on the LAN.
|
// URL is the device's own address, as you would type it into a browser on the LAN.
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
// Machine is which host it is attached to, for display only — it need not be a machine
|
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
|
||||||
// Ham Lab manages, and usually the point is that it is not answering.
|
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
||||||
Machine string `json:"machine,omitempty"`
|
// 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"`
|
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
|
// One per machine, and it follows the machine switcher — switch to `grid` and you get
|
||||||
// particular KVM — it is an iframe pointed at Ham Lab's own proxy, so whatever web UI the
|
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
|
||||||
// device serves is what you get.
|
// 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
|
// 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 —
|
// 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
|
// 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.
|
// 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 { 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'
|
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||||
|
|
||||||
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
|
// 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 }
|
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
||||||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||||||
|
|
||||||
export default function KVM({ machines }: { machines: MachineStatus[] }) {
|
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
||||||
const [list, setList] = useState<Device[] | null>(null)
|
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
||||||
const [sel, setSel] = useState<string>('')
|
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [full, setFull] = useState(false)
|
const [full, setFull] = useState(false)
|
||||||
const box = useRef<HTMLDivElement>(null)
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const frame = useRef<HTMLIFrameElement>(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 () => {
|
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') }
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
}, [])
|
}, [key])
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
|
// 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') }
|
} catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') }
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (k: Device) => {
|
const remove = async () => {
|
||||||
if (!await askYes(`Remove ${k.name}? The device is not touched — this only forgets its address.`, 'Remove')) return
|
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(k.id); toast(`${k.name} removed`); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
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 (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
||||||
if (!list) return <div className="dash"><Loading label="Reading the list…" /></div>
|
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||||||
|
|
||||||
const cur = list.find((k) => k.id === sel)
|
|
||||||
const locks = !!keyboardAPI()?.lock
|
const locks = !!keyboardAPI()?.lock
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kvm">
|
<div className="kvm">
|
||||||
<div className="kvm-bar">
|
<div className="kvm-bar">
|
||||||
{list.length > 0 && (
|
<span className="dname">{title}</span>
|
||||||
<span className="chips">
|
{kvm
|
||||||
{list.map((k) => (
|
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
||||||
<button key={k.id} className={'chip' + (k.id === sel ? ' on' : '')} onClick={() => setSel(k.id)}
|
: <span className="muted small">no KVM</span>}
|
||||||
title={`${k.url}${k.machine ? ` — attached to ${k.machine}` : ''}`}>{k.name}</button>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{cur && <>
|
{kvm && <>
|
||||||
<span className="muted small mono kvm-url">{cur.url}</span>
|
|
||||||
<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>
|
<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>
|
<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 danger" onClick={() => remove(cur)}>Remove</Btn>
|
<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>
|
</div>
|
||||||
|
|
||||||
{cur ? (
|
{kvm ? (
|
||||||
<div className={'kvm-frame' + (full ? ' full' : '')} ref={box}>
|
<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" />
|
allow="fullscreen; clipboard-read; clipboard-write" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="svc-empty muted">
|
<div className="svc-empty muted kvm-none">
|
||||||
{list.length === 0
|
<div>
|
||||||
? 'No KVM yet. Add one with its address and its web page appears here.'
|
<p>No KVM for <b>{title}</b>.</p>
|
||||||
: 'Pick a KVM.'}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{cur && !full && (
|
{kvm && !full && (
|
||||||
<div className="kvm-hint muted small">
|
<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.'}
|
{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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddDialog({ machines, onClose, onAdded }: { machines: MachineStatus[]; onClose: () => void; onAdded: (k: Device) => void }) {
|
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState(existing?.url ?? '')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState(existing?.name ?? '')
|
||||||
const [machine, setMachine] = useState('')
|
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
useEscape(onClose)
|
useEscape(onClose)
|
||||||
const save = async (e: FormEvent) => {
|
const save = async (e: FormEvent) => {
|
||||||
e.preventDefault(); setErr(''); setBusy(true)
|
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) }
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="modal-back" onClick={onClose}>
|
<div className="modal-back" onClick={onClose}>
|
||||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
<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
|
<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 />
|
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
||||||
</label>
|
</label>
|
||||||
<label className="muted">Name — optional; the address is used otherwise
|
<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} />
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} 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>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="muted small">
|
<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
|
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.
|
device itself is not changed and is never reachable from outside.
|
||||||
|
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
||||||
</div>
|
</div>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
<div className="svc-actions">
|
<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" />
|
<span className="spacer" />
|
||||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -63,8 +63,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
{ id: 'users', label: 'Users', icon: '⚇' },
|
{ id: 'users', label: 'Users', icon: '⚇' },
|
||||||
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
||||||
{ id: 'settings', label: 'Settings', 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'
|
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 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
|
// 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.
|
// 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'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users 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 === 'usage' && <Usage key={machine ?? ''} />}
|
||||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||||
{view === 'widgets' && <Gallery />}
|
{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 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 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 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 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'),
|
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' : ''}`),
|
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}`),
|
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),
|
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
|
||||||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, 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),
|
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-frame.full { background: #000; }
|
||||||
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||||
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
@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