diff --git a/PLAN.md b/PLAN.md index 8e5819b..4a3305c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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`, `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 - 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 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 diff --git a/web/src/App.tsx b/web/src/App.tsx index ffefd94..378f504 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { Dialogs } from './ui' import { applyTheme, currentThemeId } from './themes' import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs' import { setAccents } from './accents' +import { setRailOrder } from './railorder' export default function App() { const [me, setMe] = useState(undefined) @@ -17,6 +18,7 @@ export default function App() { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false) + if (Array.isArray(p.rail)) setRailOrder(p.rail, false) }).catch(() => { /* keep local */ }) // 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. diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx index 3df34d2..2058155 100644 --- a/web/src/Cron.tsx +++ b/web/src/Cron.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' 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 { useHashSel } from './hashsel' @@ -48,7 +48,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () => } return (
- +

{srcLabel(name)}

{!editable &&
Read-only: writing this file needs passwordless sudo on the host.
} @@ -99,7 +99,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob; return (
- +

{job.command}

@@ -152,7 +152,7 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () => const B = ({ a, label }: { a: string; label: string }) => return (
- +

{t.unit}

{t.description}
diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index 75292a3..c6ef13c 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -8,7 +8,7 @@ import StackNew, { DupFrom } from './StackNew' import { Sensor, fmtValue, useSensorStream } from './sensors' import { useHashSel } from './hashsel' 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' 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) ?? [] return (
- +
{c.name}
Image{c.image}
@@ -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 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
{err}
+ if (err && !st) return
{err}
if (!st) return
return (
- +
{st.name}{st.running}/{st.containers.length} running
{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && · kept on {st.held_by}, deployed to this machine over {st.remote}}{st.via && · compose runs inside {st.via}, as it was deployed}{st.has_env && ' · has a .env beside it'}
{((st.host_files?.length ?? 0) > 1 || st.has_env) &&
{st.host_files!.map((f, i) => )}{st.has_env && }
} @@ -266,7 +266,7 @@ export default function Docker() { )}
- {shownTab === 'containers' && !cur && sel && !selStack &&
There is no container called {sel} on this machine. It may have been removed, renamed or recreated under another name.
} + {shownTab === 'containers' && !cur && sel && !selStack &&
setSel('')} />
There is no container called {sel} on this machine. It may have been removed, renamed or recreated under another name.
} {shownTab === 'containers' && (cur ? setSel('')} onChanged={load} /> : (sel && !selStack ? null :
Select a container.
))} {making && { 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 ? setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)} diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx index aed3096..6002761 100644 --- a/web/src/Rail.tsx +++ b/web/src/Rail.tsx @@ -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 // 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; 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) { + const saved = useRailOrder() + const [arranging, setArranging] = useState(false) + const [drag, setDrag] = useState<{ id: string; order: string[] } | null>(null) + const rows = useRef(new Map()) + const ordered = drag ? orderBy(groups, drag.order) : orderBy(groups, saved) + + const start = (id: string) => (e: React.PointerEvent) => { + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + setDrag({ id, order: ordered.map((g) => g.id) }) + } + const move = (e: React.PointerEvent) => { + 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 ( -