Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md)
Widgets: a bad pattern no longer silences every widget; malformed layouts load. Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs. Files: names are one path segment; unsaved edits ask before page/machine/tab changes; only folders that opened are remembered. Terminals: unique default names; 64-char names. API: queries capped at 10 000 values; 32 streams per client, 512 in all; only /embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam. Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
36de4ad4a1
commit
8ee2a2d293
@@ -19,6 +19,12 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
// Any page that gets a 401 from the hub sends us straight back to the login form.
|
||||
useEffect(() => {
|
||||
const h = () => setMe((m) => (m ? null : m))
|
||||
window.addEventListener('hamlab:unauthenticated', h)
|
||||
return () => window.removeEventListener('hamlab:unauthenticated', h)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
const t = setInterval(refresh, 30000)
|
||||
|
||||
@@ -34,7 +34,10 @@ const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
|
||||
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
|
||||
|
||||
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
||||
function normalize(p: DashPrefs): Layout {
|
||||
function normalize(raw: DashPrefs): Layout {
|
||||
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
|
||||
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
|
||||
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
|
||||
const known = new Set(MODULES.map((m) => m.id))
|
||||
const order = (p.order ?? []).filter((id) => known.has(id))
|
||||
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
|
||||
@@ -378,7 +381,7 @@ export default function Dashboard() {
|
||||
})
|
||||
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
||||
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
||||
const reset = () => update(() => normalize({}))
|
||||
const reset = () => { if (window.confirm(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
||||
|
||||
const tools = (id: string, kind: Kind) => {
|
||||
if (!editing) return null
|
||||
@@ -425,6 +428,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
))}
|
||||
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
||||
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering ({live.error}). If one of them has a bad pattern, remove it.</div>}
|
||||
</div>
|
||||
)}
|
||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
||||
|
||||
+18
-6
@@ -1,7 +1,7 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo } from './api'
|
||||
import { fmtBytes } from './Dashboard'
|
||||
import { Btn, Loading, Spinner, toast } from './ui'
|
||||
import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui'
|
||||
|
||||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||
@@ -64,7 +64,15 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [save])
|
||||
|
||||
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; onClose() }
|
||||
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; setUnsaved(''); onClose() }
|
||||
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
|
||||
useEffect(() => {
|
||||
setUnsaved(dirty ? name : '')
|
||||
if (!dirty) return
|
||||
const h = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
|
||||
window.addEventListener('beforeunload', h)
|
||||
return () => { window.removeEventListener('beforeunload', h); setUnsaved('') }
|
||||
}, [dirty, name])
|
||||
const dl = api.fileDownloadURL(path, sudo)
|
||||
|
||||
return (
|
||||
@@ -112,14 +120,16 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const l = await api.files(p, s)
|
||||
setEntries(l.entries); setErr('')
|
||||
canSudo.current = true
|
||||
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
setErr(msg); setEntries([])
|
||||
}
|
||||
}, [path, sudo])
|
||||
|
||||
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.path', path); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
|
||||
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
|
||||
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
|
||||
useEscape(() => setMenu(null), menu !== null)
|
||||
|
||||
const go = (p: string) => { setOpen(null); setPath(p || '/') }
|
||||
const crumbs = path === '/' ? [] : path.slice(1).split('/')
|
||||
@@ -136,9 +146,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const act = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
const mkdir = () => { const n = window.prompt('New folder name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = () => { const n = window.prompt('New file name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const rename = (e: FileEntry) => { const n = window.prompt('Rename to', e.name); if (n?.trim() && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
const mkdir = () => { const n = window.prompt('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = () => { const n = window.prompt('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const rename = (e: FileEntry) => { const n = window.prompt('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
const move = (e: FileEntry) => { const n = window.prompt('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||
const chmod = (e: FileEntry) => { const n = window.prompt(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||
const del = (e: FileEntry) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MachineStatus } from './api'
|
||||
import { useEscape } from './ui'
|
||||
|
||||
// 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.
|
||||
@@ -13,6 +14,7 @@ export default function MachineSwitcher({ hub, current, machines, onChange, onMa
|
||||
window.addEventListener('click', h)
|
||||
return () => window.removeEventListener('click', h)
|
||||
}, [])
|
||||
useEscape(() => setOpen(false), open)
|
||||
const cur = current ? machines.find((m) => m.name === current) : null
|
||||
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
|
||||
return (
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
|
||||
<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>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" 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>}
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
import { Btn, Spinner, useEscape } from './ui'
|
||||
|
||||
// Header power control: a small button, a dialog that needs the hostname typed back.
|
||||
// `label` is what the user types (the machine name on a remote); `host` is what the node checks.
|
||||
@@ -8,6 +8,7 @@ export default function Power({ host, label, rebootNeeded }: { host: string; lab
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
||||
useEscape(() => { if (!sent) setOpen(false) }, open)
|
||||
const [err, setErr] = useState('')
|
||||
const ok = typed.trim() === label
|
||||
|
||||
|
||||
+5
-2
@@ -18,7 +18,7 @@ import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import Power from './Power'
|
||||
import { Btn, Toasts, toast } from './ui'
|
||||
import { Btn, Toasts, okToLeave, toast } from './ui'
|
||||
|
||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||
const FolderIcon = (
|
||||
@@ -91,6 +91,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
|
||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||
const switchMachine = (name: string | null) => {
|
||||
if (name !== machine && !okToLeave()) return
|
||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||
setMachineState(name)
|
||||
@@ -98,6 +99,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
}
|
||||
|
||||
// keep the hash in step, and follow it when the browser navigates
|
||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
|
||||
useEffect(() => {
|
||||
const want = `#/${machine ?? 'hub'}/${view}`
|
||||
if (location.hash !== want) history.pushState(null, '', want)
|
||||
@@ -172,7 +175,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} view={view}
|
||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }} />
|
||||
onView={(id) => { if (id !== view && !okToLeave()) return; setView(id as View); setDrawer(false) }} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Sensor, fetchCatalog, fmtValue } from './sensors'
|
||||
import { apiPath } from './api'
|
||||
import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
||||
import { Btn, Loading } from './ui'
|
||||
import { Btn, Loading, useEscape } from './ui'
|
||||
|
||||
// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it.
|
||||
export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) {
|
||||
@@ -11,13 +12,23 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
const [sensor, setSensor] = useState('')
|
||||
const [type, setType] = useState<WidgetType>('stat')
|
||||
const [title, setTitle] = useState('')
|
||||
const [bad, setBad] = useState('')
|
||||
// One bad pattern would 400 the stream every widget on the page shares, so ask the API first.
|
||||
const add = async () => {
|
||||
try {
|
||||
const r = await fetch(apiPath('/api/sensors/query'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ w: sensor }) })
|
||||
if (!r.ok) { setBad((await r.json().catch(() => ({}))).error ?? `the API refused it (${r.status})`); return }
|
||||
} catch { setBad('could not reach the API'); return }
|
||||
onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })
|
||||
}
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const shown = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase()
|
||||
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
|
||||
}, [cat, filter])
|
||||
useEscape(onClose)
|
||||
const pattern = /[*?[]/.test(sensor)
|
||||
const pick = (id: string) => { setSensor(id); if (/[*?[]/.test(id)) setType('list') }
|
||||
const pick = (id: string) => { setSensor(id); setBad(''); if (/[*?[]/.test(id)) setType('list') }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -44,8 +55,9 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
<label className="muted">Title (optional)
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
|
||||
</label>
|
||||
{bad && <div className="error">{bad}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!sensor} onClick={() => onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add</Btn>
|
||||
<Btn className="btn on" disabled={!sensor} onClick={add}>Add</Btn>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,9 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
||||
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)
|
||||
|
||||
+3
-1
@@ -245,7 +245,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.attrs td { padding: 3px 6px; border-bottom: 1px solid var(--border); } .attrs td.num, .attrs th:not(:nth-child(2)) { text-align: right; }
|
||||
.attrs tr.warn td { color: var(--s2); }
|
||||
.datasets { padding: 0; overflow-x: auto; }
|
||||
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 640px; }
|
||||
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */
|
||||
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.ds-row .num { text-align: right; }
|
||||
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -387,6 +387,8 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
@media (pointer: coarse) {
|
||||
.btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; }
|
||||
.icon-btn { min-width: 36px; }
|
||||
.path-input, .filter, .procs-tools input, .files-tools input { min-height: 36px; }
|
||||
.crumb { min-width: 32px; }
|
||||
:root { --tabs-h: 40px; }
|
||||
.ttab .icon-btn { min-width: 30px; }
|
||||
.ttab:not(.active) .icon-btn { display: none; }
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// ---- spinners ----
|
||||
export function Spinner({ size = 14 }: { size?: number }) {
|
||||
@@ -68,3 +68,22 @@ export function NetBar() {
|
||||
}, [])
|
||||
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
|
||||
}
|
||||
|
||||
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
|
||||
export function useEscape(fn: () => void, active = true) {
|
||||
const ref = useRef(fn); ref.current = fn
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') ref.current() }
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [active])
|
||||
}
|
||||
|
||||
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
|
||||
let unsaved = ''
|
||||
export function setUnsaved(what: string) { unsaved = what }
|
||||
export function okToLeave(): boolean {
|
||||
if (!unsaved || window.confirm(`Discard unsaved changes to ${unsaved}?`)) { unsaved = ''; return true }
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user