Quality of life: in-app dialogs, selections in the URL, editable widgets, token dialog, gallery search, status history, stream errors that say why, tab reordering and shortcuts, log out everywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8ee2a2d293
commit
d7ed4d8549
+2
-1
@@ -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 <div className="center muted">…</div>
|
||||
if (me === null) return <Login onLogin={refresh} />
|
||||
return <Shell me={me} onLoggedOut={() => setMe(null)} />
|
||||
return <><Dialogs /><Shell me={me} onLoggedOut={() => setMe(null)} /></>
|
||||
}
|
||||
|
||||
+9
-6
@@ -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<CronOverview | null>(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:<id>" or "timer:<unit>", 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<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
||||
@@ -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<WidgetSpec | null>(null)
|
||||
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
||||
|
||||
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
||||
@@ -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() {
|
||||
<div className="mod-tools">
|
||||
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
||||
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
||||
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
|
||||
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -428,9 +430,10 @@ 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>}
|
||||
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
|
||||
</div>
|
||||
)}
|
||||
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
|
||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
||||
{visible('card').length > 0 && (
|
||||
<div className="cards">
|
||||
|
||||
+2
-2
@@ -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') }
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export default function Embed() {
|
||||
{!sensor ? <div className="error">embed needs ?sensor=</div> : (
|
||||
<div className={'tile w-' + type}>
|
||||
<Widget spec={spec} data={live.data ? live.data.w : undefined} src={src} />
|
||||
{live.error && !live.data && <div className="sub">{src.token ? 'waiting for the stream — check the token' : 'no token given'}</div>}
|
||||
{live.error && !live.data && <div className="sub">{src.token ? live.error : 'no token in the URL (&token=…)'}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+17
-13
@@ -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<string>(() => 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<string>(() => (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<boolean>(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
|
||||
const [entries, setEntries] = useState<FileEntry[] | null>(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 })
|
||||
|
||||
+10
-5
@@ -35,6 +35,7 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
|
||||
const [cat, setCat] = useState<Sensor[] | null>(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<string, Sensor['value']>]>(['*'], { 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<string, Sensor[]>()
|
||||
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 (
|
||||
<div className="dash gallery">
|
||||
<div className="dash-bar">
|
||||
<span className="hint">{sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
||||
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
||||
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
||||
<span className="spacer" />
|
||||
{demoUrl && <a className="btn" href={demoUrl} target="_blank" rel="noreferrer" title="a page outside Ham Lab, built on the same sensor API">External demo ↗</a>}
|
||||
</div>
|
||||
|
||||
<h2 className="section">Widget types</h2>
|
||||
<div className="kpis">
|
||||
{!needle && <h2 className="section">Widget types</h2>}
|
||||
{!needle && <div className="kpis">
|
||||
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => <Tile key={t.id} spec={{ id: t.id, type: t.id, sensor: 'cpu.usage', title: t.label }} data={demo} note={`type=${t.id}`} />)}
|
||||
<Tile spec={{ id: 'list', type: 'list', sensor: 'cpu.core.*.usage', title: 'List' }} data={cores} note="type=list · cpu.core.*.usage" />
|
||||
</div>
|
||||
</div>}
|
||||
{needle && shown.length === 0 && <div className="muted">No sensor matches “{find}”.</div>}
|
||||
|
||||
{names.map((f) => (
|
||||
<section key={f}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, MachineStatus, Me } from './api'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
import { Btn, Loading, askYes, toast } from './ui'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
@@ -37,28 +37,28 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
finally { setAdding(false) }
|
||||
}
|
||||
async function update(m: MachineStatus) {
|
||||
if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
||||
if (!await askYes(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
||||
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
|
||||
catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
if (msg.startsWith('busy:') && window.confirm(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
||||
if (msg.startsWith('busy:') && await askYes(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
||||
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
|
||||
} else toast(`${m.name}: ${msg}`, 'err')
|
||||
}
|
||||
}
|
||||
async function updateAll() {
|
||||
const n = list?.filter(outdated).length ?? 0
|
||||
if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
||||
if (!await askYes(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
||||
try {
|
||||
let res = await api.updateAllMachines()
|
||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||
if (busy.length && window.confirm(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||
setTimeout(load, 4000)
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
async function remove(m: MachineStatus) {
|
||||
if (!window.confirm(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
|
||||
if (!await askYes(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
|
||||
try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { api, Proc, ProcTable } from './api'
|
||||
import { fmtBytes, fmtRate } from './Dashboard'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
import { Btn, Loading, askYes, toast } from './ui'
|
||||
import { useRefreshMs } from './prefs'
|
||||
|
||||
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
|
||||
@@ -78,7 +78,7 @@ export default function Procs() {
|
||||
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
|
||||
|
||||
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
|
||||
if (!window.confirm(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
|
||||
if (!await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
|
||||
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
||||
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'
|
||||
|
||||
type Filter = 'all' | 'running' | 'failed' | 'enabled'
|
||||
|
||||
@@ -118,7 +119,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
|
||||
|
||||
async function act(action: string) {
|
||||
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
|
||||
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
|
||||
if (confirmFor[action] && !await askYes(`${confirmFor[action]} ${name}?`)) return
|
||||
setBusy(action); setErr('')
|
||||
try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
|
||||
@@ -176,7 +177,8 @@ export default function Services() {
|
||||
const [err, setErr] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [sel, setSel] = useState<string | null>(null)
|
||||
const [hashSel, setHashSel] = useHashSel() // the selected unit lives in the URL
|
||||
const sel = hashSel || null, setSel = (v: string | null) => setHashSel(v ?? '')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
|
||||
+43
-9
@@ -1,7 +1,8 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './sensors'
|
||||
import { api, Me } from './api'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, toast } from './ui'
|
||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } from './prefs'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
@@ -33,6 +34,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
const [refresh, setRefresh] = useState(getRefreshMs())
|
||||
const [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null)
|
||||
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
||||
const [making, setMaking] = useState(false)
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw1, setPw1] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
@@ -59,7 +61,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
|
||||
}
|
||||
async function revoke(name: string) {
|
||||
if (!window.confirm(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||
if (!await askYes(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
@@ -118,16 +120,18 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
<Btn className="btn" onClick={async () => {
|
||||
const name = window.prompt('Name for the read-only token (what will use it)', 'glance')?.trim()
|
||||
if (!name) return
|
||||
const pats = window.prompt('Limit it to sensor patterns? Comma-separated, e.g. cpu.*, temp.*, zfs.tank.* — leave empty for every sensor', '')
|
||||
if (pats === null) return
|
||||
try { setFresh(await api.createReadToken(name, pats.split(',').map((x) => x.trim()).filter(Boolean))); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}}>+ Read-only sensor token</Btn>
|
||||
<Btn className="btn" onClick={() => setMaking(true)}>+ Read-only sensor token</Btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Sessions</h2></div>
|
||||
<section className="card">
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>Signs out every browser and phone that is logged in to this Ham Lab, this one included. Access tokens are not sessions and keep working.</div>
|
||||
<Btn className="btn danger" confirm="Log out everywhere? Every device, including this one, will need the password again." onClick={async () => { try { await api.logoutAll() } finally { window.dispatchEvent(new Event('hamlab:unauthenticated')) } }}>Log out everywhere</Btn>
|
||||
</section>
|
||||
|
||||
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
||||
|
||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||
<section className="card">
|
||||
<div className="props">
|
||||
@@ -141,3 +145,33 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One dialog for a read-only token: what will use it, and (optionally) which sensor
|
||||
// families it may see — picked from this machine's own catalog, or typed as patterns.
|
||||
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
|
||||
const [name, setName] = useState('glance')
|
||||
const [pats, setPats] = useState('')
|
||||
const [fams, setFams] = useState<string[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
useEffect(() => { fetchCatalog({ machine: null }).then((c) => setFams([...new Set(c.map((s) => s.id.split('.')[0]))].sort())).catch(() => {}) }, [])
|
||||
const list = pats.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
const toggle = (f: string) => { const p = f === 'uptime' ? f : f + '.*'; setPats((list.includes(p) ? list.filter((x) => x !== p) : [...list, p]).join(', ')) }
|
||||
const make = async () => { try { onMade(await api.createReadToken(name.trim(), list)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>
|
||||
<h3>Read-only sensor token</h3>
|
||||
<label className="muted">Name — what will use it
|
||||
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={64} spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
<label className="muted">Limit it to these sensors — empty means every sensor
|
||||
<input className="mono" value={pats} onChange={(e) => setPats(e.target.value)} placeholder="cpu.*, temp.*, zfs.tank.*" spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
<div className="chips">{fams.map((f) => <button type="button" key={f} className={'chip' + (list.includes(f === 'uptime' ? f : f + '.*') ? ' on' : '')} onClick={() => toggle(f)}>{f}</button>)}</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions"><button type="submit" className="btn on" disabled={!name.trim()}>Create</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+49
-12
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, Me, Term } from './api'
|
||||
import Rail, { Group } from './Rail'
|
||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||
@@ -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, okToLeave, toast } from './ui'
|
||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||
|
||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||
const FolderIcon = (
|
||||
@@ -48,7 +48,7 @@ const HUB_ONLY: View[] = ['machines', 'settings']
|
||||
// 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.
|
||||
function parseHash(): { machine: string | null; view: View } | null {
|
||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
|
||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
||||
if (!m || !VIEWS.includes(m[2] as View)) return null
|
||||
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
||||
}
|
||||
@@ -90,8 +90,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
||||
|
||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||
const switchMachine = (name: string | null) => {
|
||||
if (name !== machine && !okToLeave()) return
|
||||
const switchMachine = async (name: string | null) => {
|
||||
if (name !== machine && !(await 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)
|
||||
@@ -103,10 +103,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
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)
|
||||
if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want)
|
||||
}, [machine, view])
|
||||
useEffect(() => {
|
||||
const h = () => { const p = parseHash(); if (!p) return; if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) }
|
||||
const h = async () => {
|
||||
const p = parseHash(); if (!p) return
|
||||
if (hasUnsaved() && (p.machine !== machine || p.view !== view)) { // Back/Forward away from unsaved work: stay put until answered
|
||||
const there = location.hash
|
||||
history.pushState(null, '', `#/${machine ?? 'hub'}/${view}`)
|
||||
if (!(await okToLeave())) return
|
||||
history.replaceState(null, '', there)
|
||||
}
|
||||
if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view)
|
||||
}
|
||||
window.addEventListener('popstate', h)
|
||||
return () => window.removeEventListener('popstate', h)
|
||||
}) // no deps: needs the latest switchMachine/view each time
|
||||
@@ -131,13 +140,13 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
finally { setCreating(false) }
|
||||
}
|
||||
async function rename(t: Term) {
|
||||
const name = window.prompt('Rename terminal', t.name)
|
||||
const name = await askText('Rename terminal', t.name)
|
||||
if (name && name.trim() && name !== t.name) {
|
||||
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
}
|
||||
async function close(t: Term) {
|
||||
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
|
||||
if (t.running && !await askYes(`Close "${t.name}"? The shell will be hung up.`)) return
|
||||
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
|
||||
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
@@ -154,6 +163,33 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
window.setTimeout(() => send(20), 50)
|
||||
}
|
||||
|
||||
// Tab order is the user's, kept per machine in this browser; new terminals join at the end.
|
||||
const orderKey = `hamlab.termorder.${machine ?? 'hub'}`
|
||||
const [order, setOrder] = useState<string[]>([])
|
||||
useEffect(() => { try { setOrder(JSON.parse(localStorage.getItem(orderKey) ?? '[]')) } catch { setOrder([]) } }, [orderKey])
|
||||
const ordered = useMemo(() => [...terms].sort((a, b) => { const i = order.indexOf(a.id), j = order.indexOf(b.id); return (i < 0 ? 1e9 : i) - (j < 0 ? 1e9 : j) }), [terms, order])
|
||||
const moveTab = (id: string, before: string | null) => {
|
||||
const ids = ordered.map((t) => t.id).filter((x) => x !== id)
|
||||
const at = before ? ids.indexOf(before) : -1
|
||||
ids.splice(at < 0 ? ids.length : at, 0, id)
|
||||
setOrder(ids); localStorage.setItem(orderKey, JSON.stringify(ids))
|
||||
}
|
||||
// Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one. Caught before xterm sees the keys.
|
||||
useEffect(() => {
|
||||
if (view !== 'terminal') return
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
|
||||
if (dir && ordered.length > 1) { const i = ordered.findIndex((t) => t.id === active); setActive(ordered[(i + dir + ordered.length) % ordered.length].id) }
|
||||
else if (e.key.toLowerCase() === 't') create()
|
||||
else return
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}) // no deps: needs the latest tabs each time
|
||||
|
||||
const activeRef = useRef(active); activeRef.current = active
|
||||
const activeTerm = terms.find((t) => t.id === active)
|
||||
const termView = view === 'terminal'
|
||||
|
||||
@@ -175,7 +211,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) => { if (id !== view && !okToLeave()) return; setView(id as View); setDrawer(false) }} />
|
||||
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
@@ -189,8 +225,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
{view === 'updates' && <Updates />}
|
||||
{view === 'disks' && <Disks />}
|
||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||
{termView && <TermTabs terms={terms} active={active} creating={creating}
|
||||
onSelect={setActive} onNew={create} onRename={rename} onClose={close} />}
|
||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
||||
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
||||
{termView && terms.length === 0 && (
|
||||
<div className="center muted">
|
||||
<div>No terminals.</div>
|
||||
|
||||
+20
-7
@@ -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<string | null>(null)
|
||||
const [over, setOver] = useState<string | null>(null)
|
||||
const strip = useRef<HTMLDivElement>(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 (
|
||||
<div className="term-tabs" ref={strip} onMouseDown={(e) => 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.
|
||||
<div className="term-tabs" ref={strip} onMouseDown={(e) => { if ((e.target as HTMLElement).closest('button')) e.preventDefault() }}>
|
||||
{terms.map((t, i) => {
|
||||
const sel = t.id === active
|
||||
return (
|
||||
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||
onClick={() => onSelect(t.id)} onDoubleClick={() => onRename(t)}
|
||||
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited') + (over === t.id && dragging !== t.id ? ' drop' : '')}
|
||||
draggable onDragStart={(e) => { setDragging(t.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', t.id) }}
|
||||
onDragOver={(e) => { if (dragging) { e.preventDefault(); setOver(t.id) } }} onDragLeave={() => setOver((o) => (o === t.id ? null : o))}
|
||||
onDrop={(e) => { e.preventDefault(); if (dragging && dragging !== t.id) onMove(dragging, t.id); setDragging(null); setOver(null); onFocusTerm() }} onDragEnd={() => { setDragging(null); setOver(null) }}
|
||||
onClick={() => { onSelect(t.id); onFocusTerm() }} onDoubleClick={() => onRename(t)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="tname" title={t.name}>{t.name}</span>
|
||||
{sel && terms.length > 1 && <span className="ttab-move">
|
||||
<button className="icon-btn" title="move left" disabled={i === 0} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i - 1].id) }}>‹</button>
|
||||
<button className="icon-btn" title="move right" disabled={i === terms.length - 1} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i + 2]?.id ?? null) }}>›</button>
|
||||
</span>}
|
||||
{sel && <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>}
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button className="ttab new" title="new terminal" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
||||
<button className="ttab new" title="new terminal (Alt+Shift+T) · Alt+Shift+←/→ switches tabs" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
||||
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 }) {
|
||||
export default function WidgetPicker({ onAdd, onClose, initial }: { onAdd: (w: WidgetSpec) => void; onClose: () => void; initial?: WidgetSpec }) {
|
||||
const [cat, setCat] = useState<Sensor[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sensor, setSensor] = useState('')
|
||||
const [type, setType] = useState<WidgetType>('stat')
|
||||
const [title, setTitle] = useState('')
|
||||
const [sensor, setSensor] = useState(initial?.sensor ?? '')
|
||||
const [type, setType] = useState<WidgetType>(initial?.type ?? 'stat')
|
||||
const [title, setTitle] = useState(initial?.title ?? '')
|
||||
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 () => {
|
||||
@@ -19,7 +19,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
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 })
|
||||
onAdd({ id: initial?.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(() => {
|
||||
@@ -32,7 +32,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Add a widget</h3>
|
||||
<h3>{initial ? 'Edit widget' : 'Add a widget'}</h3>
|
||||
<input autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" />
|
||||
{err && <div className="error">{err}</div>}
|
||||
{!cat && !err && <Loading label="Reading the catalog…" />}
|
||||
@@ -57,7 +57,7 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
</label>
|
||||
{bad && <div className="error">{bad}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!sensor} onClick={add}>Add</Btn>
|
||||
<Btn className="btn on" disabled={!sensor} onClick={add}>{initial ? 'Save' : 'Add'}</Btn>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,7 @@ export const api = {
|
||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||
tokens: () => req<{ name: string; created: string; scope?: string; sensors?: string[] }[]>('GET', '/api/tokens', undefined, true),
|
||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||
logoutAll: () => req<{ ended: number }>('POST', '/api/logout/all', undefined, true),
|
||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||
uiPrefs: () => req<{ theme?: string; refreshMs?: number }>('GET', '/api/prefs/ui', undefined, true),
|
||||
saveUIPrefs: (p: { theme?: string; refreshMs?: number }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// The part of the URL after the page — #/grid/services/<this> — is that page's selection:
|
||||
// which unit, which job, which folder. Reload keeps it and a link can carry it.
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
const read = () => { try { return decodeURIComponent(location.hash.split('/').slice(3).join('/')) } catch { return '' } }
|
||||
|
||||
export function useHashSel(): [string, (v: string) => void] {
|
||||
const [sel, setSel] = useState(read)
|
||||
useEffect(() => {
|
||||
const h = () => setSel(read())
|
||||
window.addEventListener('popstate', h); window.addEventListener('hashchange', h)
|
||||
return () => { window.removeEventListener('popstate', h); window.removeEventListener('hashchange', h) }
|
||||
}, [])
|
||||
const set = useCallback((v: string) => {
|
||||
const base = location.hash.split('/').slice(0, 3).join('/')
|
||||
const want = v ? `${base}/${encodeURIComponent(v)}` : base
|
||||
if (location.hash !== want) history.replaceState(null, '', want)
|
||||
setSel(v)
|
||||
}, [])
|
||||
return [sel, set]
|
||||
}
|
||||
+8
-1
@@ -67,7 +67,14 @@ export function useSensorStream<T = unknown>(shape: Shape | null, opts: StreamOp
|
||||
if (everyMs) params.every = String(everyMs)
|
||||
const es = new EventSource(url(srcRef.current, '/api/sensors/stream', params))
|
||||
es.onmessage = (e) => { try { setData(JSON.parse(e.data)); setError('') } catch { /* skip a bad frame */ } }
|
||||
es.onerror = () => setError('reconnecting…') // EventSource retries by itself
|
||||
es.onerror = () => {
|
||||
setError('reconnecting…') // EventSource retries by itself, but never says why it failed — so ask once, plainly
|
||||
fetch(url(srcRef.current, '/api/sensors/query', { q: key }), { credentials: 'same-origin' }).then(async (r) => {
|
||||
if (r.ok) return
|
||||
const why = (await r.json().catch(() => ({}))).error
|
||||
setError(r.status === 401 ? 'the token was rejected' : r.status === 429 ? 'too many live streams are open' : r.status === 404 ? 'no such machine' : r.status === 502 ? 'that machine is unreachable' : why || `the API answered ${r.status}`)
|
||||
}).catch(() => setError("can't reach Ham Lab"))
|
||||
}
|
||||
return () => es.close()
|
||||
}, [key, srcKey, visible]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return { data, error, resumed }
|
||||
|
||||
@@ -430,3 +430,11 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.gallery-id { margin-top: auto; padding-top: 8px; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
|
||||
@media (max-width: 800px) { .gallery .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
|
||||
/* in-app dialogs, gallery search */
|
||||
.modal.ask { width: min(440px, 100%); } .ask-msg { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
|
||||
.gallery-find { flex: 1 1 200px; max-width: 340px; padding: 7px 10px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||
.gallery .dash-bar { flex-wrap: wrap; gap: 8px; }
|
||||
.ttab.drop { box-shadow: inset 3px 0 0 var(--accent2); }
|
||||
.ttab-move { display: none; } .ttab-move .icon-btn:disabled { opacity: .3; }
|
||||
@media (pointer: coarse) { .ttab-move { display: inline-flex; } } /* no drag and drop on a touch screen */
|
||||
|
||||
+49
-3
@@ -17,7 +17,7 @@ export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title
|
||||
return (
|
||||
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
|
||||
onClick={async () => {
|
||||
if (confirm && !window.confirm(confirm)) return
|
||||
if (confirm && !(await askYes(confirm))) return
|
||||
const r = onClick()
|
||||
if (r && typeof (r as Promise<unknown>).then === 'function') {
|
||||
setInner(true)
|
||||
@@ -80,10 +80,56 @@ export function useEscape(fn: () => void, active = true) {
|
||||
}, [active])
|
||||
}
|
||||
|
||||
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
|
||||
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
|
||||
// console, usable on a phone, closed by Escape or a tap outside.
|
||||
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
|
||||
let asks: Ask[] = []
|
||||
let nextId = 0
|
||||
const askListeners = new Set<(a: Ask[]) => void>()
|
||||
const emitAsks = () => askListeners.forEach((l) => l(asks))
|
||||
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
|
||||
return new Promise((resolve) => {
|
||||
const id = ++nextId
|
||||
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
|
||||
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
|
||||
emitAsks()
|
||||
})
|
||||
}
|
||||
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
|
||||
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
|
||||
|
||||
function AskBox({ a }: { a: Ask }) {
|
||||
const [val, setVal] = useState(a.input ?? '')
|
||||
const cancel = () => a.done(a.input !== undefined ? null : false)
|
||||
const accept = () => a.done(a.input !== undefined ? val : true)
|
||||
useEscape(cancel)
|
||||
return (
|
||||
<div className="modal-back" onClick={cancel}>
|
||||
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
|
||||
<div className="ask-msg">{a.msg}</div>
|
||||
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={cancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function Dialogs() {
|
||||
const [list, setList] = useState<Ask[]>(asks)
|
||||
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
|
||||
const top = list[list.length - 1]
|
||||
return top ? <AskBox key={top.id} a={top} /> : null
|
||||
}
|
||||
|
||||
// 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 }
|
||||
export const hasUnsaved = () => unsaved !== ''
|
||||
export async function okToLeave(): Promise<boolean> {
|
||||
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
||||
return false
|
||||
}
|
||||
|
||||
+9
-1
@@ -15,6 +15,14 @@ export const WIDGET_TYPES: { id: WidgetType; label: string; many?: boolean }[] =
|
||||
export type WidgetData = Sensor | Record<string, Sensor> | null | undefined
|
||||
const isSensor = (d: WidgetData): d is Sensor => !!d && typeof (d as Sensor).id === 'string'
|
||||
|
||||
// "cpu.core.*.usage" → "CPU core usage", "fan.*" → "Fans": what a pattern is called when nobody named it.
|
||||
const FAMILY_TITLE: Record<string, string> = { cpu: 'CPU', mem: 'Memory', swap: 'Swap', temp: 'Temperatures', fan: 'Fans', net: 'Network', disk: 'Disks', fs: 'Filesystems', ups: 'UPS', zfs: 'ZFS', smart: 'Drive health', service: 'Services', services: 'Services', container: 'Containers', containers: 'Containers', updates: 'Updates', load: 'Load' }
|
||||
export function patternTitle(sel: string): string {
|
||||
const [first, ...rest] = sel.split('.')
|
||||
const tail = rest.filter((x) => !/[*?[]/.test(x)).join(' ').replace(/_/g, ' ')
|
||||
return [FAMILY_TITLE[first] ?? first, tail].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const MAX_POINTS = 300
|
||||
|
||||
function Gauge({ s }: { s: Sensor }) {
|
||||
@@ -49,7 +57,7 @@ function Spark({ s, src }: { s: Sensor; src?: Source }) {
|
||||
}
|
||||
|
||||
export function Widget({ spec, data, src }: { spec: WidgetSpec; data: WidgetData; src?: Source }) {
|
||||
const title = spec.title || (isSensor(data) ? data.name : spec.sensor)
|
||||
const title = spec.title || (isSensor(data) ? data.name : /[*?[]/.test(spec.sensor) ? patternTitle(spec.sensor) : spec.sensor)
|
||||
let body
|
||||
if (data === undefined) body = <div className="value muted">…</div>
|
||||
else if (data === null) body = <div className="sub">no sensor <span className="mono">{spec.sensor}</span> on this machine</div>
|
||||
|
||||
Reference in New Issue
Block a user