Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md)
Widgets: a bad pattern no longer silences every widget; malformed layouts load. Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs. Files: names are one path segment; unsaved edits ask before page/machine/tab changes; only folders that opened are remembered. Terminals: unique default names; 64-char names. API: queries capped at 10 000 values; 32 streams per client, 512 in all; only /embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam. Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
36de4ad4a1
commit
8ee2a2d293
@@ -111,3 +111,94 @@ rejected. Duplicate machine add refused. Unicode + spaces in file names round-tr
|
||||
Upload byte-exact. Terminal scrollback survives switching. Offline node: switcher and
|
||||
header show it, pages show the reason. Remote terminals, logs, tasks and files through
|
||||
the proxy all work on all three nodes.
|
||||
|
||||
|
||||
# Bug bash — 2026-09-19
|
||||
|
||||
Found by driving a scratch instance (same binary, its own state, real fleet behind it) in
|
||||
headless Chromium at 1440 px and iPhone size: a sweep of every page on every machine for
|
||||
page errors, failed requests, overflow and small tap targets; then scripted use of every
|
||||
feature — terminal tabs, Files in a throwaway folder, Processes (killing a process the
|
||||
test spawned), Monitors customisation and widgets, Services, Cron, Updates, Disks,
|
||||
Machines, Settings, embeds, machine switching, session loss — and a hostile pass over the
|
||||
API. Host-changing actions (service control, cron edits, upgrades, power) were opened but
|
||||
not executed. Every fix below was re-checked by the same script that found it.
|
||||
|
||||
## Bugs — all ✅ fixed and verified
|
||||
|
||||
1. ✅ **One widget with a bad pattern silenced every custom widget.** They share one stream;
|
||||
`fan.[` made the whole query a 400, so all of them showed "…" forever with no reason
|
||||
given. The picker now asks the API before adding and shows its answer; a bad pattern
|
||||
already saved shows a notice instead of dots.
|
||||
2. ✅ **A saved layout of the wrong shape could not be loaded** ("could not load layout",
|
||||
and the page could no longer be customised). `normalize()` now accepts anything.
|
||||
3. ✅ **A lost session left pages saying "unauthenticated"** for up to 30 s. A 401 from the hub
|
||||
now returns to the login form at once, and logging in returns to the same page. (A 401
|
||||
relayed from a managed machine is that machine rejecting the hub, and doesn't.)
|
||||
4. ✅ **The machine-name rule in "Add a machine" was an invalid regular expression** under the
|
||||
browser's `v` mode, so it validated nothing and logged a console error on every render.
|
||||
5. ✅ **Two terminals opened in the same minute got the same name** ("shell 09:45" twice).
|
||||
Now "shell 09:45 (2)".
|
||||
6. ✅ **Names had no length limit** — a 328-character terminal tab, a 5000-character token
|
||||
name. 64 characters, refused with a reason; token names also refuse `/ \ < > "`.
|
||||
7. ✅ **"+ file", "+ folder" and Rename accepted `../x` and `a/b`**, quietly acting outside the
|
||||
folder on screen. A name is now one path segment; Move… is how things go elsewhere.
|
||||
8. ✅ **Unsaved file edits were thrown away without a word** when you picked another page or
|
||||
machine, or closed the tab. All three now ask; Cancel keeps you where you were.
|
||||
9. ✅ **A mistyped path in Files was remembered**, so a reload landed on the error. Only a
|
||||
folder that opened is remembered.
|
||||
10. ✅ **Escape closed nothing**: the machine menu, the file menu, the Power dialog, the widget
|
||||
picker. It closes all four.
|
||||
11. ✅ **Reset on Monitors removed every custom widget with no confirmation.** It asks, and
|
||||
says how many.
|
||||
12. ✅ **A few bytes of query bought 27 MB of answer** (`{"k0":"*",…×400}` with `full=1`) — and a
|
||||
read-only token is public on the demo page. A query may return 10 000 values (thirty
|
||||
whole machines); past that it is a 400 that says so.
|
||||
13. ✅ **No ceiling on live streams**: 300 from one token were accepted. 32 per client address,
|
||||
512 in all, then a 429 that says so; closed streams free their places.
|
||||
14. ✅ **Anything under `/embed/` lost the anti-framing headers**, not just the widget page.
|
||||
Only `/embed/widget` is frameable.
|
||||
15. ✅ **The new ZFS sensors ran three `sudo` commands a minute on every machine** — including
|
||||
`core`, which has the tools and no pool — and each wrote three lines to the journal:
|
||||
most of `hamlab.service`'s log was that. Pools are now read with one unprivileged
|
||||
`zpool list`; a source that finds nothing is asked again ten times less often; and
|
||||
the installer's quiet list covers `zpool|zfs list|status`.
|
||||
16. ✅ **Phone: the filter boxes on Processes and Files, the path box and the "/" crumb were
|
||||
under 30 px.** 36 px like every other control.
|
||||
17. ✅ **Disks → datasets on a phone: rows ended 50 px before their own cells** when scrolled
|
||||
sideways (`min-width` was less than the columns add up to).
|
||||
|
||||
Also: tab titles now say where you are ("Services · grid — Ham Lab"), so history and open
|
||||
tabs can be told apart.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
Terminal focus after every switch/close/return; scrollback after reload; exited shells;
|
||||
30 tabs with the + still reachable; re-fit on resize; Back/Forward; unknown machine or page
|
||||
in the URL; wrong password; XSS through terminal, token and file names (all rendered as
|
||||
text); hidden files, filter, symlinks (broken ones marked), binary files, upload and
|
||||
replace, folder delete needing the typed name, root toggle; process sort, filter and
|
||||
Terminate (asks first); themes; password-change validation; duplicate or unreachable
|
||||
machine; hub terminals not leaking into another machine's page; CORS only on sensor paths;
|
||||
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
|
||||
and `every=` values on the sensor API; phone drawer, key bar and picker.
|
||||
|
||||
## Quality of life — noted, not done
|
||||
|
||||
- **Selections aren't in the URL.** Services, Cron and Disks forget what was selected on
|
||||
reload, and can't be linked to. `#/grid/services/nginx.service` would fix both.
|
||||
- **Browser Back with unsaved file edits** still leaves without asking (page and machine
|
||||
changes ask; Back needs the history entry put back on Cancel).
|
||||
- **Prompts are browser `prompt()`/`confirm()` boxes** — rename, new file, delete, token
|
||||
name. They work, and look nothing like the rest of the console, worst on a phone.
|
||||
- **Two step token creation** (name, then patterns) wants to be one small dialog, with the
|
||||
patterns pickable from the catalog like the widget picker.
|
||||
- **Widgets can't be edited**, only removed and re-added; no way to change a title or type.
|
||||
- **The Widgets gallery has no search**, and 300+ tiles on `grid` is a long scroll.
|
||||
- **A custom widget's sparkline starts empty for states and counts** (no history kept for
|
||||
the background status sensors — only the sampler's ring remembers).
|
||||
- **Embeds poll nothing while hidden but say nothing when the token is wrong** beyond
|
||||
"check the token"; a 401 and an unreachable host look the same.
|
||||
- **Default widget titles for patterns are the raw pattern** ("CPU.CORE.*.USAGE").
|
||||
- **Terminal tabs can't be reordered**, and there's no keyboard shortcut to switch.
|
||||
- **The hub's own session cookie is 30 days sliding with no "log out everywhere".**
|
||||
|
||||
+73
-3
@@ -58,6 +58,7 @@ type Server struct {
|
||||
mgr *term.Manager
|
||||
metrics *metrics.Collector
|
||||
sensors *sensors.Registry
|
||||
streams streamGate
|
||||
store *store.Store
|
||||
svc *services.Manager
|
||||
cron *cron.Manager
|
||||
@@ -205,7 +206,7 @@ func (s *Server) Handler() http.Handler {
|
||||
h := w.Header()
|
||||
// The console must never be framed. An embedded widget exists to be: it is a page
|
||||
// that can only read sensors, and only with a read-only token in its URL.
|
||||
if !strings.HasPrefix(r.URL.Path, "/embed/") {
|
||||
if r.URL.Path != "/embed/widget" {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
}
|
||||
@@ -323,6 +324,10 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxNameLen bounds the names people give things (terminals, tokens): they are shown in
|
||||
// tabs and lists, and nothing good is 300 characters long.
|
||||
const maxNameLen = 64
|
||||
|
||||
const filterHeader = "X-Hamlab-Sensor-Filter"
|
||||
|
||||
type filterKey struct{}
|
||||
@@ -390,6 +395,10 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if len(name) > maxNameLen || strings.ContainsAny(name, "/\\<>\"") {
|
||||
jsonErr(w, 400, fmt.Sprintf("token names are at most %d characters, without / \\ < > or quotes", maxNameLen))
|
||||
return
|
||||
}
|
||||
tok, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "token error")
|
||||
@@ -842,6 +851,45 @@ func queryOf(w http.ResponseWriter, r *http.Request) (q json.RawMessage, full bo
|
||||
|
||||
var reservedParam = map[string]bool{"full": true, "token": true, "q": true, "every": true, "machines": true}
|
||||
|
||||
// A stream holds a goroutine and a connection for as long as the client likes, and a
|
||||
// read-only token may be public. So streams are counted: per client address and in all.
|
||||
const (
|
||||
maxStreamsPerClient = 32
|
||||
maxStreams = 512
|
||||
)
|
||||
|
||||
type streamGate struct {
|
||||
mu sync.Mutex
|
||||
by map[string]int
|
||||
total int
|
||||
}
|
||||
|
||||
func (g *streamGate) enter(who string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.by == nil {
|
||||
g.by = map[string]int{}
|
||||
}
|
||||
if g.total >= maxStreams || g.by[who] >= maxStreamsPerClient {
|
||||
return false
|
||||
}
|
||||
g.by[who]++
|
||||
g.total++
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *streamGate) leave(who string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.by[who] <= 0 {
|
||||
return
|
||||
}
|
||||
if g.by[who]--; g.by[who] == 0 {
|
||||
delete(g.by, who)
|
||||
}
|
||||
g.total--
|
||||
}
|
||||
|
||||
// sensorStream is a shaped query that keeps answering: the same object, pushed as a
|
||||
// server-sent event after every sample. ?every=<ms> spaces the events out for a consumer
|
||||
// that doesn't want them at the sampling rate.
|
||||
@@ -854,6 +902,12 @@ func (s *Server) sensorStream(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
who := clientKey(r)
|
||||
if !s.streams.enter(who) {
|
||||
jsonErr(w, 429, fmt.Sprintf("too many live streams (at most %d per client, %d in all); close one first", maxStreamsPerClient, maxStreams))
|
||||
return
|
||||
}
|
||||
defer s.streams.leave(who)
|
||||
tick, cancel, ok := s.sensors.Subscribe()
|
||||
fl, canFlush := w.(http.Flusher)
|
||||
if !ok || !canFlush {
|
||||
@@ -1843,8 +1897,20 @@ func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
name = "shell " + time.Now().Format("15:04")
|
||||
if len(name) > maxNameLen {
|
||||
jsonErr(w, 400, fmt.Sprintf("name longer than %d characters", maxNameLen))
|
||||
return
|
||||
}
|
||||
if name == "" { // "shell 09:45", then "shell 09:45 (2)" for the second one that minute
|
||||
base := "shell " + time.Now().Format("15:04")
|
||||
taken := map[string]bool{}
|
||||
for _, t := range s.mgr.List() {
|
||||
taken[t.Name] = true
|
||||
}
|
||||
name = base
|
||||
for n := 2; taken[name]; n++ {
|
||||
name = fmt.Sprintf("%s (%d)", base, n)
|
||||
}
|
||||
}
|
||||
sess, err := s.mgr.Create("", name)
|
||||
if err != nil {
|
||||
@@ -1863,6 +1929,10 @@ func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "name required")
|
||||
return
|
||||
}
|
||||
if len(strings.TrimSpace(body.Name)) > maxNameLen {
|
||||
jsonErr(w, 400, fmt.Sprintf("name longer than %d characters", maxNameLen))
|
||||
return
|
||||
}
|
||||
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
|
||||
@@ -53,6 +53,10 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
|
||||
p.mu.Lock()
|
||||
p.by[name] = got
|
||||
p.mu.Unlock()
|
||||
if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely
|
||||
time.Sleep(10 * every)
|
||||
continue
|
||||
}
|
||||
time.Sleep(every)
|
||||
}
|
||||
}
|
||||
@@ -207,9 +211,8 @@ func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||
}
|
||||
|
||||
func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor {
|
||||
z := p.s.storage.ZFS(ctx)
|
||||
var out []sensors.Sensor
|
||||
for _, pool := range z.Pools {
|
||||
for _, pool := range p.s.storage.Pools(ctx) {
|
||||
id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name}
|
||||
out = append(out,
|
||||
sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStreamGate(t *testing.T) {
|
||||
var g streamGate
|
||||
for i := 0; i < maxStreamsPerClient; i++ {
|
||||
if !g.enter("a") {
|
||||
t.Fatalf("stream %d for one client refused early", i)
|
||||
}
|
||||
}
|
||||
if g.enter("a") {
|
||||
t.Fatal("one client went past its ceiling")
|
||||
}
|
||||
if !g.enter("b") {
|
||||
t.Fatal("another client must still get in")
|
||||
}
|
||||
g.leave("a")
|
||||
if !g.enter("a") {
|
||||
t.Fatal("a closed stream must free its place")
|
||||
}
|
||||
for i := 0; i < maxStreamsPerClient+1; i++ {
|
||||
g.leave("a")
|
||||
}
|
||||
g.leave("b")
|
||||
if g.total != -1 && g.total != 0 { // leave() past zero must not wedge the gate open or shut
|
||||
t.Logf("total after over-leave: %d", g.total)
|
||||
}
|
||||
}
|
||||
@@ -209,10 +209,16 @@ func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) {
|
||||
if err := json.Unmarshal(query, &q); err != nil {
|
||||
return nil, errors.New("query is not valid JSON")
|
||||
}
|
||||
return shape(all, q, full, 0)
|
||||
budget := MaxValues
|
||||
return shape(all, q, full, 0, &budget)
|
||||
}
|
||||
|
||||
func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
// MaxValues is how many sensor values one query may return. A query is a few bytes that
|
||||
// can ask for everything many times over ({"a":"*","b":"*",…}); without a ceiling 10 KB of
|
||||
// request buys 27 MB of response. Ten thousand is thirty times a whole machine.
|
||||
const MaxValues = 10000
|
||||
|
||||
func shape(all []Sensor, q any, full bool, depth int, budget *int) (any, error) {
|
||||
if depth > 16 {
|
||||
return nil, errors.New("query nested too deep")
|
||||
}
|
||||
@@ -232,8 +238,12 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
if len(got) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
*budget--
|
||||
return leaf(got[0]), nil
|
||||
}
|
||||
if *budget -= len(got); *budget < 0 {
|
||||
return nil, fmt.Errorf("query returns more than %d values; ask for less", MaxValues)
|
||||
}
|
||||
m := make(map[string]any, len(got))
|
||||
for _, s := range got {
|
||||
m[s.ID] = leaf(s)
|
||||
@@ -242,7 +252,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(v))
|
||||
for k, sub := range v {
|
||||
r, err := shape(all, sub, full, depth+1)
|
||||
r, err := shape(all, sub, full, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,7 +262,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
||||
case []any:
|
||||
a := make([]any, len(v))
|
||||
for i, sub := range v {
|
||||
r, err := shape(all, sub, full, depth+1)
|
||||
r, err := shape(all, sub, full, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -75,3 +75,21 @@ func TestWriteProm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeBudget(t *testing.T) {
|
||||
all := make([]Sensor, 300)
|
||||
for i := range all {
|
||||
all[i] = Sensor{ID: "s." + strings.Repeat("x", i%7) + string(rune('a'+i%26)) + strings.Repeat("y", i/26), Value: 1.0}
|
||||
}
|
||||
q := map[string]string{}
|
||||
for i := 0; i < 40; i++ { // 40 × 300 = 12 000 values
|
||||
q["k"+strings.Repeat("z", i)] = "*"
|
||||
}
|
||||
b, _ := json.Marshal(q)
|
||||
if _, err := Shape(all, b, false); err == nil {
|
||||
t.Fatal("a query returning more than MaxValues must be refused")
|
||||
}
|
||||
if _, err := Shape(all, json.RawMessage(`{"a":"*","b":"*"}`), false); err != nil {
|
||||
t.Fatalf("an ordinary query must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +546,13 @@ func quietSudoers() {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bin := range []string{"zpool", "zfs"} { // listing only: scrubs and snapshots still log
|
||||
for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/bin", "/bin"} {
|
||||
if exists(dir + "/" + bin) {
|
||||
cmds = append(cmds, dir+"/"+bin+" list *", dir+"/"+bin+" status *")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -754,6 +754,36 @@ var snapRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
|
||||
func u64(s string) uint64 { v, _ := strconv.ParseUint(s, 10, 64); return v }
|
||||
func atoi(s string) int { v, _ := strconv.Atoi(strings.TrimSuffix(s, "%")); return v }
|
||||
|
||||
// Pools is the pool list alone, for callers that poll: one `zpool list`, tried without
|
||||
// root first (it needs none on Linux) so polling writes nothing to the auth log.
|
||||
func (m *Manager) Pools(ctx context.Context) []Pool {
|
||||
bin, ok := lookPath("zpool")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
args := []string{"list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup"}
|
||||
out, err := exec.CommandContext(ctx, bin, args...).Output()
|
||||
if err != nil {
|
||||
var code int
|
||||
if out, code, err = m.run(ctx, append([]string{"zpool"}, args...)...); err != nil || code != 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return parsePools(out)
|
||||
}
|
||||
|
||||
func parsePools(out []byte) []Pool {
|
||||
pools := []Pool{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 8 {
|
||||
continue
|
||||
}
|
||||
pools = append(pools, Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]})
|
||||
}
|
||||
return pools
|
||||
}
|
||||
|
||||
func (m *Manager) ZFS(ctx context.Context) ZFS {
|
||||
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
|
||||
if _, ok := lookPath("zpool"); !ok {
|
||||
|
||||
@@ -19,6 +19,12 @@ export default function App() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
// Any page that gets a 401 from the hub sends us straight back to the login form.
|
||||
useEffect(() => {
|
||||
const h = () => setMe((m) => (m ? null : m))
|
||||
window.addEventListener('hamlab:unauthenticated', h)
|
||||
return () => window.removeEventListener('hamlab:unauthenticated', h)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
const t = setInterval(refresh, 30000)
|
||||
|
||||
@@ -34,7 +34,10 @@ const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
|
||||
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
|
||||
|
||||
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
|
||||
function normalize(p: DashPrefs): Layout {
|
||||
function normalize(raw: DashPrefs): Layout {
|
||||
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
|
||||
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
|
||||
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
|
||||
const known = new Set(MODULES.map((m) => m.id))
|
||||
const order = (p.order ?? []).filter((id) => known.has(id))
|
||||
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
|
||||
@@ -378,7 +381,7 @@ export default function Dashboard() {
|
||||
})
|
||||
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
|
||||
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
|
||||
const reset = () => update(() => normalize({}))
|
||||
const reset = () => { if (window.confirm(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
||||
|
||||
const tools = (id: string, kind: Kind) => {
|
||||
if (!editing) return null
|
||||
@@ -425,6 +428,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
))}
|
||||
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
||||
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering ({live.error}). If one of them has a bad pattern, remove it.</div>}
|
||||
</div>
|
||||
)}
|
||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
||||
|
||||
+18
-6
@@ -1,7 +1,7 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo } from './api'
|
||||
import { fmtBytes } from './Dashboard'
|
||||
import { Btn, Loading, Spinner, toast } from './ui'
|
||||
import { Btn, Loading, Spinner, setUnsaved, toast, useEscape } from './ui'
|
||||
|
||||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||
@@ -64,7 +64,15 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [save])
|
||||
|
||||
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; onClose() }
|
||||
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; setUnsaved(''); onClose() }
|
||||
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
|
||||
useEffect(() => {
|
||||
setUnsaved(dirty ? name : '')
|
||||
if (!dirty) return
|
||||
const h = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
|
||||
window.addEventListener('beforeunload', h)
|
||||
return () => { window.removeEventListener('beforeunload', h); setUnsaved('') }
|
||||
}, [dirty, name])
|
||||
const dl = api.fileDownloadURL(path, sudo)
|
||||
|
||||
return (
|
||||
@@ -112,14 +120,16 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const l = await api.files(p, s)
|
||||
setEntries(l.entries); setErr('')
|
||||
canSudo.current = true
|
||||
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
setErr(msg); setEntries([])
|
||||
}
|
||||
}, [path, sudo])
|
||||
|
||||
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.path', path); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
|
||||
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
|
||||
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
|
||||
useEscape(() => setMenu(null), menu !== null)
|
||||
|
||||
const go = (p: string) => { setOpen(null); setPath(p || '/') }
|
||||
const crumbs = path === '/' ? [] : path.slice(1).split('/')
|
||||
@@ -136,9 +146,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const act = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
const mkdir = () => { const n = window.prompt('New folder name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = () => { const n = window.prompt('New file name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const rename = (e: FileEntry) => { const n = window.prompt('Rename to', e.name); if (n?.trim() && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
const mkdir = () => { const n = window.prompt('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = () => { const n = window.prompt('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const rename = (e: FileEntry) => { const n = window.prompt('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
const move = (e: FileEntry) => { const n = window.prompt('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||
const chmod = (e: FileEntry) => { const n = window.prompt(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||
const del = (e: FileEntry) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MachineStatus } from './api'
|
||||
import { useEscape } from './ui'
|
||||
|
||||
// The host picker at the top of the rail. The hub is always first; managed machines follow
|
||||
// with their live status. Picking one re-points the whole console at it.
|
||||
@@ -13,6 +14,7 @@ export default function MachineSwitcher({ hub, current, machines, onChange, onMa
|
||||
window.addEventListener('click', h)
|
||||
return () => window.removeEventListener('click', h)
|
||||
}, [])
|
||||
useEscape(() => setOpen(false), open)
|
||||
const cur = current ? machines.find((m) => m.name === current) : null
|
||||
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
|
||||
return (
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Add a machine</h2></div>
|
||||
<form className="card addform" onSubmit={add}>
|
||||
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9\\-]*" required /></label>
|
||||
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" required /></label>
|
||||
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
|
||||
<label>Its Ham Lab password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
|
||||
{addErr && <div className="error">{addErr}</div>}
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
import { Btn, Spinner, useEscape } from './ui'
|
||||
|
||||
// Header power control: a small button, a dialog that needs the hostname typed back.
|
||||
// `label` is what the user types (the machine name on a remote); `host` is what the node checks.
|
||||
@@ -8,6 +8,7 @@ export default function Power({ host, label, rebootNeeded }: { host: string; lab
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
||||
useEscape(() => { if (!sent) setOpen(false) }, open)
|
||||
const [err, setErr] = useState('')
|
||||
const ok = typed.trim() === label
|
||||
|
||||
|
||||
+5
-2
@@ -18,7 +18,7 @@ import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import Power from './Power'
|
||||
import { Btn, Toasts, toast } from './ui'
|
||||
import { Btn, Toasts, okToLeave, toast } from './ui'
|
||||
|
||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||
const FolderIcon = (
|
||||
@@ -91,6 +91,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
|
||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||
const switchMachine = (name: string | null) => {
|
||||
if (name !== machine && !okToLeave()) return
|
||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||
setMachineState(name)
|
||||
@@ -98,6 +99,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
}
|
||||
|
||||
// keep the hash in step, and follow it when the browser navigates
|
||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${machine ?? hubMe.host} — Ham Lab` }, [view, machine, hubMe.host])
|
||||
useEffect(() => {
|
||||
const want = `#/${machine ?? 'hub'}/${view}`
|
||||
if (location.hash !== want) history.pushState(null, '', want)
|
||||
@@ -172,7 +175,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} view={view}
|
||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }} />
|
||||
onView={(id) => { if (id !== view && !okToLeave()) return; setView(id as View); setDrawer(false) }} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Sensor, fetchCatalog, fmtValue } from './sensors'
|
||||
import { apiPath } from './api'
|
||||
import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
||||
import { Btn, Loading } from './ui'
|
||||
import { Btn, Loading, useEscape } from './ui'
|
||||
|
||||
// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it.
|
||||
export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) {
|
||||
@@ -11,13 +12,23 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
const [sensor, setSensor] = useState('')
|
||||
const [type, setType] = useState<WidgetType>('stat')
|
||||
const [title, setTitle] = useState('')
|
||||
const [bad, setBad] = useState('')
|
||||
// One bad pattern would 400 the stream every widget on the page shares, so ask the API first.
|
||||
const add = async () => {
|
||||
try {
|
||||
const r = await fetch(apiPath('/api/sensors/query'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ w: sensor }) })
|
||||
if (!r.ok) { setBad((await r.json().catch(() => ({}))).error ?? `the API refused it (${r.status})`); return }
|
||||
} catch { setBad('could not reach the API'); return }
|
||||
onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })
|
||||
}
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const shown = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase()
|
||||
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
|
||||
}, [cat, filter])
|
||||
useEscape(onClose)
|
||||
const pattern = /[*?[]/.test(sensor)
|
||||
const pick = (id: string) => { setSensor(id); if (/[*?[]/.test(id)) setType('list') }
|
||||
const pick = (id: string) => { setSensor(id); setBad(''); if (/[*?[]/.test(id)) setType('list') }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -44,8 +55,9 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
||||
<label className="muted">Title (optional)
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
|
||||
</label>
|
||||
{bad && <div className="error">{bad}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!sensor} onClick={() => onAdd({ id: 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })}>Add</Btn>
|
||||
<Btn className="btn on" disabled={!sensor} onClick={add}>Add</Btn>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,9 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
||||
track(-1)
|
||||
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
|
||||
if (r.status === 401) {
|
||||
// The hub itself said no: the session is gone. (A 401 relayed from a managed machine is that
|
||||
// machine rejecting the hub, which is not ours to fix by logging in again.)
|
||||
if (!mach && target !== '/api/login') window.dispatchEvent(new Event('hamlab:unauthenticated'))
|
||||
let msg = 'unauthenticated'
|
||||
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
||||
throw new Unauthenticated(msg)
|
||||
|
||||
+3
-1
@@ -245,7 +245,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.attrs td { padding: 3px 6px; border-bottom: 1px solid var(--border); } .attrs td.num, .attrs th:not(:nth-child(2)) { text-align: right; }
|
||||
.attrs tr.warn td { color: var(--s2); }
|
||||
.datasets { padding: 0; overflow-x: auto; }
|
||||
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 640px; }
|
||||
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */
|
||||
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.ds-row .num { text-align: right; }
|
||||
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -387,6 +387,8 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
@media (pointer: coarse) {
|
||||
.btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; }
|
||||
.icon-btn { min-width: 36px; }
|
||||
.path-input, .filter, .procs-tools input, .files-tools input { min-height: 36px; }
|
||||
.crumb { min-width: 32px; }
|
||||
:root { --tabs-h: 40px; }
|
||||
.ttab .icon-btn { min-width: 30px; }
|
||||
.ttab:not(.active) .icon-btn { display: none; }
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// ---- spinners ----
|
||||
export function Spinner({ size = 14 }: { size?: number }) {
|
||||
@@ -68,3 +68,22 @@ export function NetBar() {
|
||||
}, [])
|
||||
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
|
||||
}
|
||||
|
||||
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
|
||||
export function useEscape(fn: () => void, active = true) {
|
||||
const ref = useRef(fn); ref.current = fn
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') ref.current() }
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [active])
|
||||
}
|
||||
|
||||
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
|
||||
let unsaved = ''
|
||||
export function setUnsaved(what: string) { unsaved = what }
|
||||
export function okToLeave(): boolean {
|
||||
if (!unsaved || window.confirm(`Discard unsaved changes to ${unsaved}?`)) { unsaved = ''; return true }
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user