diff --git a/BUGS.md b/BUGS.md
index 08db196..20b4569 100644
--- a/BUGS.md
+++ b/BUGS.md
@@ -183,22 +183,26 @@ machine; hub terminals not leaking into another machine's page; CORS only on sen
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
and `every=` values on the sensor API; phone drawer, key bar and picker.
-## Quality of life — noted, not done
+## Quality of life — all ✅ done 2026-09-19, each verified in the browser
-- **Selections aren't in the URL.** Services, Cron and Disks forget what was selected on
- reload, and can't be linked to. `#/grid/services/nginx.service` would fix both.
-- **Browser Back with unsaved file edits** still leaves without asking (page and machine
- changes ask; Back needs the history entry put back on Cancel).
-- **Prompts are browser `prompt()`/`confirm()` boxes** — rename, new file, delete, token
- name. They work, and look nothing like the rest of the console, worst on a phone.
-- **Two step token creation** (name, then patterns) wants to be one small dialog, with the
- patterns pickable from the catalog like the widget picker.
-- **Widgets can't be edited**, only removed and re-added; no way to change a title or type.
-- **The Widgets gallery has no search**, and 300+ tiles on `grid` is a long scroll.
-- **A custom widget's sparkline starts empty for states and counts** (no history kept for
- the background status sensors — only the sampler's ring remembers).
-- **Embeds poll nothing while hidden but say nothing when the token is wrong** beyond
- "check the token"; a 401 and an unreachable host look the same.
-- **Default widget titles for patterns are the raw pattern** ("CPU.CORE.*.USAGE").
-- **Terminal tabs can't be reordered**, and there's no keyboard shortcut to switch.
-- **The hub's own session cookie is 30 days sliding with no "log out everywhere".**
+1. ✅ **Selections are in the URL.** `#/grid/services/ssh.service`, `#/hub/cron/job:…`,
+ `#/hub/files/%2Fetc`: reload keeps them, links carry them, leaving a page drops them.
+2. ✅ **Browser Back with unsaved file edits asks**, like page and machine changes do.
+3. ✅ **No more browser `prompt()`/`confirm()` boxes** — all 30 are in-app dialogs (`askYes`,
+ `askText` in `ui.tsx`): Enter submits, Escape or a tap outside cancels, destructive
+ ones are red and say what they do ("Close", "Delete"), and they fit a phone.
+4. ✅ **Token creation is one dialog**: name, patterns, and the machine's own sensor families
+ as chips to click.
+5. ✅ **Widgets can be edited** — sensor, type and title — in place, from Customize.
+6. ✅ **The Widgets gallery has a search** over IDs, names and labels (so "gitea" finds its
+ container's sensors).
+7. ✅ **Status sensors keep a history** (300 readings each: an hour of container memory, two
+ days of drive wear), so their sparklines have a past.
+8. ✅ **A stream that fails says why**: "the token was rejected", "too many live streams are
+ open", "that machine is unreachable", "can't reach Ham Lab" — in embeds and on Monitors.
+9. ✅ **Pattern widgets get readable default titles**: `cpu.core.*.usage` → "CPU core usage".
+10. ✅ **Terminal tabs reorder** — drag on a desktop, ‹ › on a touch screen, remembered per
+ machine in the browser — and **Alt+Shift+←/→** switches tabs, **Alt+Shift+T** opens one,
+ with the keyboard left in the terminal each time.
+11. ✅ **"Log out everywhere"** in Settings ends every session on every device (tokens are
+ not sessions and keep working).
diff --git a/internal/api/server.go b/internal/api/server.go
index bfa2298..b3455c4 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -107,6 +107,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
m.HandleFunc("POST /api/logout", s.auth(s.logout))
+ m.HandleFunc("POST /api/logout/all", s.auth(s.logoutAll))
m.HandleFunc("GET /api/me", s.auth(s.me))
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
@@ -708,6 +709,15 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
+// logoutAll ends every session, this one included: a lost phone, a borrowed laptop.
+// Tokens are not sessions and are untouched; revoke those by name.
+func (s *Server) logoutAll(w http.ResponseWriter, r *http.Request) {
+ n := s.sessions.DeleteAll()
+ log.Printf("all %d session(s) ended (from %s)", n, clientIP(r))
+ s.setCookie(w, r, "", -1)
+ jsonOK(w, 200, map[string]int{"ended": n})
+}
+
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
// reverse proxy set it, else the socket peer.
func clientKey(r *http.Request) string {
diff --git a/internal/api/status_sensors.go b/internal/api/status_sensors.go
index d48f946..f1f5ba3 100644
--- a/internal/api/status_sensors.go
+++ b/internal/api/status_sensors.go
@@ -25,8 +25,14 @@ type statusSensors struct {
by map[string][]sensors.Sensor // source → its sensors
cpuPrev map[string]cpuMark // container ID → CPU time at the previous read
+
+ hist map[string]*sensors.Series // numeric sensors only, statusHistory points each
}
+// statusHistory is how many readings each status sensor keeps: at 15 s that is over an
+// hour of container memory; at 10 min, two days of drive wear.
+const statusHistory = 300
+
const (
servicesEvery = 30 * time.Second
containersEvery = 15 * time.Second
@@ -36,7 +42,7 @@ const (
)
func newStatusSensors(s *Server) *statusSensors {
- p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}}
+ p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}}
go p.loop("services", servicesEvery, p.services)
go p.loop("containers", containersEvery, p.containers)
go p.loop("updates", updatesEvery, p.updates)
@@ -52,6 +58,28 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
cancel()
p.mu.Lock()
p.by[name] = got
+ live := map[string]bool{}
+ for _, x := range got {
+ v, ok := x.Value.(float64)
+ if !ok {
+ continue
+ }
+ live[x.ID] = true
+ h := p.hist[x.ID]
+ if h == nil {
+ h = &sensors.Series{ID: x.ID, Unit: x.Unit}
+ p.hist[x.ID] = h
+ }
+ h.T, h.V = append(h.T, x.T), append(h.V, v)
+ if len(h.T) > statusHistory {
+ h.T, h.V = h.T[len(h.T)-statusHistory:], h.V[len(h.V)-statusHistory:]
+ }
+ }
+ for id := range p.hist { // a container that is gone takes its history with it
+ if strings.HasPrefix(id, prefixOf[name]) && !live[id] && prefixOf[name] != "" {
+ delete(p.hist, id)
+ }
+ }
p.mu.Unlock()
if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely
time.Sleep(10 * every)
@@ -61,6 +89,22 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
}
}
+// prefixOf says which IDs a source owns, so its loop forgets only its own departed sensors.
+var prefixOf = map[string]string{"containers": "container.", "smart": "smart.", "zfs": "zfs."}
+
+// SensorHistories implements sensors.Historian.
+func (p *statusSensors) SensorHistories(match func(id string) bool) []sensors.Series {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ var out []sensors.Series
+ for id, h := range p.hist {
+ if match(id) {
+ out = append(out, sensors.Series{ID: h.ID, Unit: h.Unit, T: append([]int64{}, h.T...), V: append([]float64{}, h.V...)})
+ }
+ }
+ return out
+}
+
func (p *statusSensors) Sensors() []sensors.Sensor {
p.mu.RLock()
defer p.mu.RUnlock()
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 80a5bd1..8343180 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -146,6 +146,15 @@ func (s *Sessions) Valid(tok string) bool {
return ok
}
+// DeleteAll ends every session on every device and says how many there were.
+func (s *Sessions) DeleteAll() (n int) {
+ _ = s.st.Update(func(state *store.State) {
+ n = len(state.Sessions)
+ state.Sessions = map[string]time.Time{}
+ })
+ return n
+}
+
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 8397cb4..b967452 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { api, Me, Unauthenticated } from './api'
import Login from './Login'
import Shell from './Shell'
+import { Dialogs } from './ui'
import { applyTheme, currentThemeId } from './themes'
import { getRefreshMs, setRefreshMs } from './prefs'
@@ -33,5 +34,5 @@ export default function App() {
if (me === undefined) return
…
if (me === null) return
- return setMe(null)} />
+ return <> setMe(null)} />>
}
diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx
index dfe711b..e78310a 100644
--- a/web/src/Cron.tsx
+++ b/web/src/Cron.tsx
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services'
-import { Btn, Loading, Spinner, toast } from './ui'
+import { Btn, Loading, Spinner, askYes, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs'
+import { useHashSel } from './hashsel'
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
function rel(ms: number): string {
@@ -77,12 +78,12 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
useEffect(() => () => clearTimeout(poll.current), [])
async function toggle() {
- if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
+ if (job.enabled && !await askYes(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
setBusy('toggle'); setErr('')
try { await api.cronJobToggle(job.id, !job.enabled); toast(job.enabled ? 'job disabled' : 'job enabled'); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
}
async function runNow() {
- if (!window.confirm(`Run now as ${job.user}?\n\n${job.command}`)) return
+ if (!await askYes(`Run now as ${job.user}?\n\n${job.command}`)) return
setBusy('run'); setErr('')
try {
const r = await api.cronJobRun(job.id)
@@ -141,8 +142,8 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function act(a: string) {
- if ((a === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return
- if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return
+ if ((a === 'stop' || a === 'disable') && !await askYes(`${a} ${t.unit}?`)) return
+ if (a === 'run' && !await askYes(`Start ${t.activates} now?`)) return
setBusy(a); setErr('')
try { await api.cronTimerAction(t.unit, a); toast(`${t.unit}: ${a} ok`); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${t.unit}: ${a} failed`, 'err') } finally { setBusy('') }
}
@@ -175,7 +176,9 @@ export default function Cron() {
const [ov, setOv] = useState(null)
const [err, setErr] = useState('')
const [q, setQ] = useState('')
- const [sel, setSel] = useState<{ kind: 'job' | 'timer'; id: string } | null>(null)
+ const [hashSel, setHashSel] = useHashSel() // "job:" or "timer:", in the URL
+ const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
+ const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
const [editing, setEditing] = useState(null)
const load = useCallback(async () => {
diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx
index ffefdec..964a76a 100644
--- a/web/src/Dashboard.tsx
+++ b/web/src/Dashboard.tsx
@@ -1,7 +1,7 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
-import { Loading, Spinner, toast } from './ui'
+import { Loading, Spinner, askYes, toast } from './ui'
import { fmtMs, useRefreshMs } from './prefs'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
@@ -352,6 +352,7 @@ export default function Dashboard() {
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
const [picking, setPicking] = useState(false)
+ const [editingWidget, setEditingWidget] = useState(null)
const live = useSensorStream>(shapeOf(layout.widgets))
if (!latest) return
{err ?
{err}
: }
@@ -381,7 +382,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 = () => { 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 reset = async () => { if (await askYes(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
@@ -421,6 +422,7 @@ export default function Dashboard() {
diff --git a/web/src/Disks.tsx b/web/src/Disks.tsx
index a3dbe67..00229b2 100644
--- a/web/src/Disks.tsx
+++ b/web/src/Disks.tsx
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
import { fmtBytes, fmtRate } from './Dashboard'
import TaskOutput from './TaskOutput'
-import { Btn, Loading, Spinner, toast } from './ui'
+import { Btn, Loading, Spinner, askText, toast } from './ui'
import { useRefreshMs } from './prefs'
import { Sensor, useSensorStream } from './sensors'
@@ -150,7 +150,7 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
}
async function snapshot() {
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
- const name = window.prompt(`Snapshot name for ${d.name}@`, def)
+ const name = await askText(`Snapshot name for ${d.name}@`, def)
if (!name) return
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
diff --git a/web/src/Embed.tsx b/web/src/Embed.tsx
index 66ac294..b972ec3 100644
--- a/web/src/Embed.tsx
+++ b/web/src/Embed.tsx
@@ -24,7 +24,7 @@ export default function Embed() {
{!sensor ?
embed needs ?sensor=
: (
- {live.error && !live.data &&
{src.token ? 'waiting for the stream — check the token' : 'no token given'}
}
+ {live.error && !live.data &&
{src.token ? live.error : 'no token in the URL (&token=…)'}
}
)}
diff --git a/web/src/Files.tsx b/web/src/Files.tsx
index 0a45ad1..717872f 100644
--- a/web/src/Files.tsx
+++ b/web/src/Files.tsx
@@ -1,7 +1,8 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api'
import { fmtBytes } from './Dashboard'
-import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui'
+import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
+import { useHashSel } from './hashsel'
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 +65,7 @@ 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; setUnsaved(''); onClose() }
+ const close = async () => { if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return; setUnsaved(''); onClose() }
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
useEffect(() => {
setUnsaved(dirty ? name : '')
@@ -100,7 +101,10 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
// ---- page ----
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
- const [path, setPath] = useState(() => sessionStorage.getItem('hamlab.files.path') || '/')
+ const [hashPath, setHashPath] = useHashSel() // the folder is in the URL; without one, the last folder that opened
+ const [path, setPath] = useState(() => (hashPath.startsWith('/') ? hashPath : sessionStorage.getItem('hamlab.files.path') || '/'))
+ useEffect(() => { setHashPath(path) }, [path, setHashPath])
+ useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps
const [sudo, setSudo] = useState(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
const [entries, setEntries] = useState(null)
const [err, setErr] = useState('')
@@ -148,20 +152,20 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
}
// 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) => {
+ const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
+ const touch = async () => { const n = await askText('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 = async (e: FileEntry) => { const n = await askText('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 = async (e: FileEntry) => { const n = await askText('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 = async (e: FileEntry) => { const n = await askText(`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 = async (e: FileEntry) => {
const full = join(path, e.name)
if (e.type === 'dir' && !e.target) {
- const typed = window.prompt(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
+ const typed = await askText(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
if (typed !== e.name) return
act(`deleted ${e.name}`, () => api.fileDelete(full, true, sudo))
- } else if (window.confirm(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
+ } else if (await askYes(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
}
- const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { window.prompt('Path', join(path, e.name)) } }
+ const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { await askText('Path', join(path, e.name)) } }
const openTerm = (p: string) => { onOpenInTerminal?.(`cd ${shellQuote(p)}`); }
// ---- upload ----
@@ -171,7 +175,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const f = list[i]
let overwrite = false
if ((entries ?? []).some((e) => e.name === f.name)) {
- if (!window.confirm(`"${f.name}" already exists here. Replace it?`)) continue
+ if (!await askYes(`"${f.name}" already exists here. Replace it?`)) continue
overwrite = true
}
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
diff --git a/web/src/Gallery.tsx b/web/src/Gallery.tsx
index 093f2df..203565d 100644
--- a/web/src/Gallery.tsx
+++ b/web/src/Gallery.tsx
@@ -35,6 +35,7 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
const [cat, setCat] = useState(null)
const [err, setErr] = useState('')
const rate = useRefreshMs()
+ const [find, setFind] = useState('')
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
// Values only, at most once a second: the catalog already holds everything that doesn't change.
const live = useSensorStream<[Record]>(['*'], { full: false, everyMs: Math.max(1000, rate) })
@@ -47,7 +48,9 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
const byId = new Map(sensors.map((s) => [s.id, s]))
const families = new Map()
- for (const s of sensors) { const f = s.id.split('.')[0]; families.set(f, [...(families.get(f) ?? []), s]) }
+ const needle = find.trim().toLowerCase()
+ const shown = needle ? sensors.filter((s) => s.id.includes(needle) || s.name.toLowerCase().includes(needle) || Object.values(s.labels ?? {}).some((l) => l.toLowerCase().includes(needle))) : sensors
+ for (const s of shown) { const f = s.id.split('.')[0]; families.set(f, [...(families.get(f) ?? []), s]) }
const names = [...families.keys()].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b))
const demo = byId.get('cpu.usage')
const cores = Object.fromEntries(sensors.filter((s) => /^cpu\.core\.\d+\.usage$/.test(s.id)).map((s) => [s.id, s]))
@@ -55,16 +58,18 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
return (
- {sensors.length} sensors on this machine · live · tap a tile to copy its ID
+ {needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID
+ setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
{demoUrl && External demo ↗}
diff --git a/web/src/TermTabs.tsx b/web/src/TermTabs.tsx
index 08c28da..189f411 100644
--- a/web/src/TermTabs.tsx
+++ b/web/src/TermTabs.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Term } from './api'
import { Spinner } from './ui'
@@ -6,29 +6,42 @@ interface Props {
terms: Term[]; active: string | null; creating?: boolean
onSelect: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void
+ onMove: (id: string, before: string | null) => void // put a tab before another, or (null) at the end
+ onFocusTerm: () => void
}
// Tab strip across the top of the terminal page: one tab per session, "+" at the end.
-export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose }: Props) {
+export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose, onMove, onFocusTerm }: Props) {
+ const [dragging, setDragging] = useState(null)
+ const [over, setOver] = useState(null)
const strip = useRef(null)
// keep the active tab in sight when the strip overflows
useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length])
return (
-
e.preventDefault()}>
- {terms.map((t) => {
+ // Buttons must not take focus from the terminal; the tabs themselves must stay draggable, which a
+ // prevented mousedown would stop — so a tab click hands focus back afterwards instead.
+
{ if ((e.target as HTMLElement).closest('button')) e.preventDefault() }}>
+ {terms.map((t, i) => {
const sel = t.id === active
return (
-