Arrange the rail: drag pages into your own order, kept with the account

The close cross on a detail pane is drawn, not a glyph, and sits in no box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-22 19:59:25 -07:00
co-authored by Claude Fable 5.1
parent b8ea7abcc7
commit 7e3e916f25
11 changed files with 122 additions and 22 deletions
+5 -1
View File
@@ -299,7 +299,11 @@ something long in one, switch away and back, refresh — it's all still there.
(`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`, (`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`,
`Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's `Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's
request, holds everything about accounts: your own password, your sessions, the request, holds everything about accounts: your own password, your sessions, the
accounts; Settings keeps appearance, rates, tokens and About). The login is a user name and a password. `config.json` holds `users` accounts; Settings keeps appearance, rates, tokens and About). **Rail order is per account** too (2026-09-22,
`railorder.ts`, `Rail.tsx`): "⇅ Arrange" at the foot of the rail puts a handle on every
page; drag one up or down (pointer events, so a finger works), let go and the order is
saved in the UI prefs with the theme; "Reset" restores the built-in order; pages added
in a later build go after the ones the saved order names. The login is a user name and a password. `config.json` holds `users`
(name, argon2id hash, created, last seen); the old single `password_hash` becomes the (name, argon2id hash, created, last seen); the old single `password_hash` becomes the
account named after the OS user the console runs as on first boot, its signed-in account named after the OS user the console runs as on first boot, its signed-in
sessions become that account's, and the machine-wide theme/accents/dashboard become sessions become that account's, and the machine-wide theme/accents/dashboard become
+2
View File
@@ -6,6 +6,7 @@ import { Dialogs } from './ui'
import { applyTheme, currentThemeId } from './themes' import { applyTheme, currentThemeId } from './themes'
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs' import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
import { setAccents } from './accents' import { setAccents } from './accents'
import { setRailOrder } from './railorder'
export default function App() { export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined) const [me, setMe] = useState<Me | null | undefined>(undefined)
@@ -17,6 +18,7 @@ export default function App() {
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false) if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false)
if (Array.isArray(p.rail)) setRailOrder(p.rail, false)
}).catch(() => { /* keep local */ }) }).catch(() => { /* keep local */ })
// The graph window is the machines' own setting; the copy here only labels the curves // The graph window is the machines' own setting; the copy here only labels the curves
// and spaces the live points, so take the hub's word for it at every login. // and spaces the live points, so take the hub's word for it at every login.
+4 -4
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services' import { Logs } from './Services'
import { Btn, Loading, Spinner, askYes, toast } from './ui' import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs' import { LIST_FLOOR, useRefreshMs } from './prefs'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
@@ -48,7 +48,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
} }
return ( return (
<div className="cron-detail"> <div className="cron-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onClose}>×</button> <CloseBtn onClick={onClose} />
<button className="btn back" onClick={onClose}>‹ Back</button> <button className="btn back" onClick={onClose}>‹ Back</button>
<h2>{srcLabel(name)}</h2> <h2>{srcLabel(name)}</h2>
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>} {!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
@@ -99,7 +99,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
return ( return (
<div className="cron-detail"> <div className="cron-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button> <CloseBtn onClick={onBack} />
<button className="btn back" onClick={onBack}>‹ Cron</button> <button className="btn back" onClick={onBack}>‹ Cron</button>
<h2>{job.command}</h2> <h2>{job.command}</h2>
<div className="props"> <div className="props">
@@ -152,7 +152,7 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button> const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
return ( return (
<div className="cron-detail"> <div className="cron-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button> <CloseBtn onClick={onBack} />
<button className="btn back" onClick={onBack}>‹ Cron</button> <button className="btn back" onClick={onBack}>‹ Cron</button>
<h2>{t.unit}</h2> <h2>{t.unit}</h2>
<div className="muted">{t.description}</div> <div className="muted">{t.description}</div>
+5 -5
View File
@@ -8,7 +8,7 @@ import StackNew, { DupFrom } from './StackNew'
import { Sensor, fmtValue, useSensorStream } from './sensors' import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { Btn, Loading, askText, askYes, setUnsaved, toast } from './ui' import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui'
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks' type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks'
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok') const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
@@ -60,7 +60,7 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? [] const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
return ( return (
<div className="svc-detail"> <div className="svc-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button> <CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div> <div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
<div className="props"> <div className="props">
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div> <div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
@@ -141,11 +141,11 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
} }
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
if (err && !st) return <div className="svc-detail"><button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div> if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div> if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return ( return (
<div className="svc-detail"> <div className="svc-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button> <CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div> <div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div> <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>} {((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
@@ -266,7 +266,7 @@ export default function Docker() {
)} )}
</div> </div>
</div> </div>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><button className="icon-btn detail-close" title="Close" aria-label="close" onClick={() => setSel('')}>×</button><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>} {shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))} {shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)} {shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
+44 -5
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react' import { ReactNode, useRef, useState } from 'react'
import { orderBy, resetRailOrder, setRailOrder, useRailOrder } from './railorder'
// Every group is a page: clicking it opens it. `soon` groups are inert placeholders // Every group is a page: clicking it opens it. `soon` groups are inert placeholders
// for the roadmap. // for the roadmap.
@@ -6,14 +7,48 @@ export interface Group { id: string; label: string; icon: ReactNode; soon?: bool
interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record<string, number>; onView: (id: string) => void } interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record<string, number>; onView: (id: string) => void }
// The pages are in the order the account keeps (railorder.ts). "Arrange" at the foot puts a
// handle on each; drag one up or down — pointer events, so a finger works as well as a mouse —
// and the order is saved with the UI prefs when it is let go.
export default function Rail({ groups, view, header, badges, onView }: Props) { export default function Rail({ groups, view, header, badges, onView }: Props) {
const saved = useRailOrder()
const [arranging, setArranging] = useState(false)
const [drag, setDrag] = useState<{ id: string; order: string[] } | null>(null)
const rows = useRef(new Map<string, HTMLElement>())
const ordered = drag ? orderBy(groups, drag.order) : orderBy(groups, saved)
const start = (id: string) => (e: React.PointerEvent<HTMLElement>) => {
e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId)
setDrag({ id, order: ordered.map((g) => g.id) })
}
const move = (e: React.PointerEvent<HTMLElement>) => {
if (!drag) return
// where the pointer is among the rows: the row whose middle it has crossed
const y = e.clientY
const order = drag.order.slice()
const from = order.indexOf(drag.id)
let to = from
order.forEach((id, i) => {
const el = rows.current.get(id)
if (!el || id === drag.id) return
const r = el.getBoundingClientRect()
const mid = r.top + r.height / 2
if (i < from && y < mid && i < to) to = i
if (i > from && y > mid && i > to) to = i
})
if (to !== from) { order.splice(from, 1); order.splice(to, 0, drag.id); setDrag({ id: drag.id, order }) }
}
const end = () => { if (drag) { setRailOrder(drag.order); setDrag(null) } }
return ( return (
<nav className="rail"> <nav className={'rail' + (arranging ? ' arranging' : '')}>
{header} {header}
{groups.map((g) => ( {ordered.map((g) => (
<div key={g.id} className={'group' + (g.soon ? ' soon' : '')}> <div key={g.id} ref={(el) => { if (el) rows.current.set(g.id, el); else rows.current.delete(g.id) }} className={'group' + (g.soon ? ' soon' : '') + (drag?.id === g.id ? ' dragging' : '')}>
<button className={'group-head' + (view === g.id ? ' active' : '')} disabled={g.soon} <button className={'group-head' + (view === g.id ? ' active' : '')} disabled={g.soon}
onClick={() => { if (!g.soon) onView(g.id) }}> onClick={() => { if (!g.soon && !arranging) onView(g.id) }}>
<span className="handle" title="drag to move" onPointerDown={start(g.id)} onPointerMove={move} onPointerUp={end} onPointerCancel={end}>⋮⋮</span>
<span className="gicon">{g.icon}</span> <span className="gicon">{g.icon}</span>
<span>{g.label}</span> <span>{g.label}</span>
{g.soon && <span className="badge">soon</span>} {g.soon && <span className="badge">soon</span>}
@@ -21,6 +56,10 @@ export default function Rail({ groups, view, header, badges, onView }: Props) {
</button> </button>
</div> </div>
))} ))}
<div className="rail-foot">
<button className={arranging ? 'on' : ''} title="put the pages in your own order" onClick={() => setArranging(!arranging)}>{arranging ? 'Done' : '⇅ Arrange'}</button>
{arranging && saved.length > 0 && <button onClick={() => resetRailOrder()}>Reset</button>}
</div>
</nav> </nav>
) )
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api' import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
import { Btn, Loading, Spinner, askYes, toast } from './ui' import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs' import { LIST_FLOOR, useRefreshMs } from './prefs'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
@@ -137,7 +137,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
return ( return (
<div className="svc-detail"> <div className="svc-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button> <CloseBtn onClick={onBack} />
<button className="btn back" onClick={onBack}>‹ Services</button> <button className="btn back" onClick={onBack}>‹ Services</button>
<div className="svc-head"> <div className="svc-head">
<span className={'sdot ' + stateClass(d)} /> <span className={'sdot ' + stateClass(d)} />
+2 -1
View File
@@ -4,6 +4,7 @@
import { api } from './api' import { api } from './api'
import { currentThemeId } from './themes' import { currentThemeId } from './themes'
import { getRefreshMs } from './prefs' import { getRefreshMs } from './prefs'
import { getRailOrder } from './railorder'
export const ACCENTS = ['#e3a33b', '#3fb950', '#58a6ff', '#f778ba', '#ff7b72', '#a371f7', '#39c5cf', '#d4d44a', '#ff9e64', '#9aa7b4'] export const ACCENTS = ['#e3a33b', '#3fb950', '#58a6ff', '#f778ba', '#ff7b72', '#a371f7', '#39c5cf', '#d4d44a', '#ff9e64', '#9aa7b4']
const KEY = 'hamlab.accents' const KEY = 'hamlab.accents'
@@ -22,4 +23,4 @@ export function setAccents(all: Record<string, string>, save = true) {
if (save) savePrefs() if (save) savePrefs()
} }
// Everything the hub remembers about how the console looks, saved as one object. // Everything the hub remembers about how the console looks, saved as one object.
export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents() }).catch(() => { /* local still applied */ }) } export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents(), rail: getRailOrder() }).catch(() => { /* local still applied */ }) }
+3 -2
View File
@@ -121,6 +121,7 @@ export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; even
export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSSnapshot { name: string; used: number; created: number }
export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }
export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<string, string>; rail?: string[] }
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean } export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean } export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string } export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
@@ -234,8 +235,8 @@ export const api = {
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true), saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true), testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
uiPrefs: () => req<{ theme?: string; refreshMs?: number; accents?: Record<string, string> }>('GET', '/api/prefs/ui', undefined, true), uiPrefs: () => req<UIPrefs>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: { theme?: string; refreshMs?: number; accents?: Record<string, string> }) => req<void>('PUT', '/api/prefs/ui', p, true), saveUIPrefs: (p: UIPrefs) => req<void>('PUT', '/api/prefs/ui', p, true),
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
terminals: () => req<Term[]>('GET', '/api/terminals'), terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
+35
View File
@@ -0,0 +1,35 @@
// The order of the pages in the rail is the account's: kept here for instant use and saved
// with the UI prefs, so it follows the login. Pages the saved order doesn't name (added since)
// go after the ones it does, in their built-in order.
import { useEffect, useState } from 'react'
const KEY = 'hamlab.rail'
export function getRailOrder(): string[] {
try { const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] }
}
export function setRailOrder(ids: string[], save = true) {
localStorage.setItem(KEY, JSON.stringify(ids))
window.dispatchEvent(new Event('hamlab:rail'))
if (save) import('./accents').then((m) => m.savePrefs())
}
export function resetRailOrder() { localStorage.removeItem(KEY); window.dispatchEvent(new Event('hamlab:rail')); import('./accents').then((m) => m.savePrefs()) }
export function useRailOrder(): string[] {
const [order, setOrder] = useState(getRailOrder)
useEffect(() => {
const h = () => setOrder(getRailOrder())
window.addEventListener('hamlab:rail', h)
return () => window.removeEventListener('hamlab:rail', h)
}, [])
return order
}
export function orderBy<T extends { id: string }>(items: T[], order: string[]): T[] {
const by = new Map(items.map((g) => [g.id, g]))
const named = order.map((id) => by.get(id)).filter((g): g is T => !!g)
const seen = new Set(named.map((g) => g.id))
return [...named, ...items.filter((g) => !seen.has(g.id))]
}
+10 -2
View File
@@ -152,8 +152,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
/* The × that closes a detail pane on a wide screen, where the list stays beside it and there is /* The × that closes a detail pane on a wide screen, where the list stays beside it and there is
no Back. Sits in the pane's top-right corner; the header keeps clear of it. */ no Back. Sits in the pane's top-right corner; the header keeps clear of it. */
.svc-detail, .cron-detail { position: relative; } .svc-detail, .cron-detail { position: relative; }
.detail-close { position: absolute; top: 8px; right: 10px; z-index: 1; width: 28px; height: 28px; line-height: 28px; font-size: 20px; color: var(--muted); border-radius: 6px; } .detail-close { position: absolute; top: 10px; right: 10px; z-index: 1; width: 26px; height: 26px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); background: none; border: 0; border-radius: 50%; opacity: .7; }
.detail-close:hover { color: var(--fg); background: var(--bg3); } .detail-close:hover { color: var(--fg); background: var(--bg3); opacity: 1; }
.svc-detail > .svc-head, .cron-detail > h2 { padding-right: 36px; } .svc-detail > .svc-head, .cron-detail > h2 { padding-right: 36px; }
/* Nothing selected: the list has the window to itself rather than a 360px column beside an /* Nothing selected: the list has the window to itself rather than a 360px column beside an
empty pane. The names, descriptions and commands that get an ellipsis are what it's for. empty pane. The names, descriptions and commands that get an ellipsis are what it's for.
@@ -517,6 +517,14 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.rule-cond { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .rule-cond select, .rule-cond input, .chan-add select, .chan-add input { padding: 8px 9px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; } .rule-cond input.mono { flex: 1 1 120px; min-width: 0; } .rule-cond { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .rule-cond select, .rule-cond input, .chan-add select, .chan-add input { padding: 8px 9px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; } .rule-cond input.mono { flex: 1 1 120px; min-width: 0; }
.chan-add { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .chan-add input { flex: 1 1 220px; min-width: 0; } .chan-add { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .chan-add input { flex: 1 1 220px; min-width: 0; }
.ev { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; } .ev:first-child { border-top: 0; } .ev-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } .bad-text { color: var(--bad) !important; } .ev { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; } .ev:first-child { border-top: 0; } .ev-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } .bad-text { color: var(--bad) !important; }
/* Arranging the rail: a foot row toggles it; each page then carries a handle to drag by. */
.rail-foot { margin-top: 8px; padding: 6px 12px; display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--muted); border-top: 1px solid var(--border); }
.rail-foot button { color: var(--muted); } .rail-foot button:hover { color: var(--fg); } .rail-foot button.on { color: var(--accent); }
.rail.arranging .group-head { cursor: default; } .rail.arranging .group-head:hover { background: none; }
.rail .handle { display: none; width: 18px; margin: -4px 0 -4px -4px; align-self: stretch; align-items: center; justify-content: center; color: var(--muted); cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; font-size: 14px; letter-spacing: -2px; }
.rail.arranging .handle { display: inline-flex; } .rail.arranging .handle:hover { color: var(--fg); }
.rail .group.dragging .group-head { background: var(--bg3); opacity: .75; box-shadow: inset 3px 0 0 var(--accent2); }
.rail.arranging .group { transition: transform .12s ease; }
.rail-badge { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--bad); color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 18px; } .rail-badge { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--bad); color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 18px; }
@media (pointer: coarse) { .toggle { width: 52px; height: 32px; border-radius: 16px; } .toggle i { width: 26px; height: 26px; } .toggle.on i { left: 22px; } .swatch { width: 34px; height: 34px; } .swatches input[type=color] { width: 36px; height: 36px; } } @media (pointer: coarse) { .toggle { width: 52px; height: 32px; border-radius: 16px; } .toggle i { width: 26px; height: 26px; } .toggle.on i { left: 22px; } .swatch { width: 34px; height: 34px; } .swatches input[type=color] { width: 36px; height: 36px; } }
+10
View File
@@ -4,6 +4,16 @@ import { ReactNode, useEffect, useRef, useState } from 'react'
export function Spinner({ size = 14 }: { size?: number }) { export function Spinner({ size = 14 }: { size?: number }) {
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" /> return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
} }
// The × in the corner of a detail pane. A drawn cross rather than the × glyph, which sits
// differently in every font and theme.
export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; title?: string }) {
return (
<button type="button" className="detail-close" title={title} aria-label={title} onClick={onClick}>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" /></svg>
</button>
)
}
export function Loading({ label = 'Loading…' }: { label?: string }) { export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div> return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
} }