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
|
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
|
header show it, pages show the reason. Remote terminals, logs, tasks and files through
|
||||||
the proxy all work on all three nodes.
|
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
|
mgr *term.Manager
|
||||||
metrics *metrics.Collector
|
metrics *metrics.Collector
|
||||||
sensors *sensors.Registry
|
sensors *sensors.Registry
|
||||||
|
streams streamGate
|
||||||
store *store.Store
|
store *store.Store
|
||||||
svc *services.Manager
|
svc *services.Manager
|
||||||
cron *cron.Manager
|
cron *cron.Manager
|
||||||
@@ -205,7 +206,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
h := w.Header()
|
h := w.Header()
|
||||||
// The console must never be framed. An embedded widget exists to be: it is a page
|
// 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.
|
// 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("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
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"
|
const filterHeader = "X-Hamlab-Sensor-Filter"
|
||||||
|
|
||||||
type filterKey struct{}
|
type filterKey struct{}
|
||||||
@@ -390,6 +395,10 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(body.Name)
|
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()
|
tok, hash, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, "token error")
|
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}
|
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
|
// 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
|
// server-sent event after every sample. ?every=<ms> spaces the events out for a consumer
|
||||||
// that doesn't want them at the sampling rate.
|
// 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())
|
jsonErr(w, 400, err.Error())
|
||||||
return
|
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()
|
tick, cancel, ok := s.sensors.Subscribe()
|
||||||
fl, canFlush := w.(http.Flusher)
|
fl, canFlush := w.(http.Flusher)
|
||||||
if !ok || !canFlush {
|
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)
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
name := strings.TrimSpace(body.Name)
|
name := strings.TrimSpace(body.Name)
|
||||||
if name == "" {
|
if len(name) > maxNameLen {
|
||||||
name = "shell " + time.Now().Format("15:04")
|
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)
|
sess, err := s.mgr.Create("", name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1863,6 +1929,10 @@ func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 400, "name required")
|
jsonErr(w, 400, "name required")
|
||||||
return
|
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 {
|
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
|
||||||
jsonErr(w, 404, err.Error())
|
jsonErr(w, 404, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func (p *statusSensors) loop(name string, every time.Duration, read func(context
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.by[name] = got
|
p.by[name] = got
|
||||||
p.mu.Unlock()
|
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)
|
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 {
|
func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor {
|
||||||
z := p.s.storage.ZFS(ctx)
|
|
||||||
var out []sensors.Sensor
|
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}
|
id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name}
|
||||||
out = append(out,
|
out = append(out,
|
||||||
sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l},
|
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 {
|
if err := json.Unmarshal(query, &q); err != nil {
|
||||||
return nil, errors.New("query is not valid JSON")
|
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 {
|
if depth > 16 {
|
||||||
return nil, errors.New("query nested too deep")
|
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 {
|
if len(got) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
*budget--
|
||||||
return leaf(got[0]), nil
|
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))
|
m := make(map[string]any, len(got))
|
||||||
for _, s := range got {
|
for _, s := range got {
|
||||||
m[s.ID] = leaf(s)
|
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:
|
case map[string]any:
|
||||||
m := make(map[string]any, len(v))
|
m := make(map[string]any, len(v))
|
||||||
for k, sub := range 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -252,7 +262,7 @@ func shape(all []Sensor, q any, full bool, depth int) (any, error) {
|
|||||||
case []any:
|
case []any:
|
||||||
a := make([]any, len(v))
|
a := make([]any, len(v))
|
||||||
for i, sub := range 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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if len(cmds) == 0 {
|
||||||
return
|
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 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 }
|
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 {
|
func (m *Manager) ZFS(ctx context.Context) ZFS {
|
||||||
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
|
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
|
||||||
if _, ok := lookPath("zpool"); !ok {
|
if _, ok := lookPath("zpool"); !ok {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { refresh() }, [refresh])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!me) return
|
if (!me) return
|
||||||
const t = setInterval(refresh, 30000)
|
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[] }
|
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.
|
// 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 known = new Set(MODULES.map((m) => m.id))
|
||||||
const order = (p.order ?? []).filter((id) => known.has(id))
|
const order = (p.order ?? []).filter((id) => known.has(id))
|
||||||
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.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 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 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) => {
|
const tools = (id: string, kind: Kind) => {
|
||||||
if (!editing) return null
|
if (!editing) return null
|
||||||
@@ -425,6 +428,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
{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 { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, FileEntry, FileInfo } from './api'
|
import { api, FileEntry, FileInfo } from './api'
|
||||||
import { fmtBytes } from './Dashboard'
|
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 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' }) }
|
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)
|
return () => window.removeEventListener('keydown', h)
|
||||||
}, [save])
|
}, [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)
|
const dl = api.fileDownloadURL(path, sudo)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,14 +120,16 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
const l = await api.files(p, s)
|
const l = await api.files(p, s)
|
||||||
setEntries(l.entries); setErr('')
|
setEntries(l.entries); setErr('')
|
||||||
canSudo.current = true
|
canSudo.current = true
|
||||||
|
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'failed'
|
const msg = e instanceof Error ? e.message : 'failed'
|
||||||
setErr(msg); setEntries([])
|
setErr(msg); setEntries([])
|
||||||
}
|
}
|
||||||
}, [path, sudo])
|
}, [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) }, [])
|
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 go = (p: string) => { setOpen(null); setPath(p || '/') }
|
||||||
const crumbs = path === '/' ? [] : path.slice(1).split('/')
|
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>) => {
|
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') }
|
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)) }
|
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||||
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 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 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)) }
|
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 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 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 del = (e: FileEntry) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { MachineStatus } from './api'
|
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
|
// 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.
|
// 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)
|
window.addEventListener('click', h)
|
||||||
return () => window.removeEventListener('click', h)
|
return () => window.removeEventListener('click', h)
|
||||||
}, [])
|
}, [])
|
||||||
|
useEscape(() => setOpen(false), open)
|
||||||
const cur = current ? machines.find((m) => m.name === current) : null
|
const cur = current ? machines.find((m) => m.name === current) : null
|
||||||
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
|
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
|
||||||
return (
|
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>
|
<div className="dash-bar"><h2 className="section">Add a machine</h2></div>
|
||||||
<form className="card addform" onSubmit={add}>
|
<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>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>
|
<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>}
|
{addErr && <div className="error">{addErr}</div>}
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { api } from './api'
|
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.
|
// 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.
|
// `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 [open, setOpen] = useState(false)
|
||||||
const [typed, setTyped] = useState('')
|
const [typed, setTyped] = useState('')
|
||||||
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
||||||
|
useEscape(() => { if (!sent) setOpen(false) }, open)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const ok = typed.trim() === label
|
const ok = typed.trim() === label
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -18,7 +18,7 @@ import MachineSwitcher from './MachineSwitcher'
|
|||||||
import { MachineStatus, setMachine } from './api'
|
import { MachineStatus, setMachine } from './api'
|
||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
import Power from './Power'
|
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).
|
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||||
const FolderIcon = (
|
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.
|
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||||
const switchMachine = (name: string | null) => {
|
const switchMachine = (name: string | null) => {
|
||||||
|
if (name !== machine && !okToLeave()) return
|
||||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
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)
|
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||||
setMachineState(name)
|
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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const want = `#/${machine ?? 'hub'}/${view}`
|
const want = `#/${machine ?? 'hub'}/${view}`
|
||||||
if (location.hash !== want) history.pushState(null, '', want)
|
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">
|
<div className="body">
|
||||||
<Rail groups={GROUPS} view={view}
|
<Rail groups={GROUPS} view={view}
|
||||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
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)} />
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|
||||||
<main className="main" key={machine ?? 'hub'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Sensor, fetchCatalog, fmtValue } from './sensors'
|
import { Sensor, fetchCatalog, fmtValue } from './sensors'
|
||||||
|
import { apiPath } from './api'
|
||||||
import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
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.
|
// 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 }: { onAdd: (w: WidgetSpec) => void; onClose: () => void }) {
|
||||||
@@ -11,13 +12,23 @@ export default function WidgetPicker({ onAdd, onClose }: { onAdd: (w: WidgetSpec
|
|||||||
const [sensor, setSensor] = useState('')
|
const [sensor, setSensor] = useState('')
|
||||||
const [type, setType] = useState<WidgetType>('stat')
|
const [type, setType] = useState<WidgetType>('stat')
|
||||||
const [title, setTitle] = useState('')
|
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')) }, [])
|
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||||
const shown = useMemo(() => {
|
const shown = useMemo(() => {
|
||||||
const f = filter.trim().toLowerCase()
|
const f = filter.trim().toLowerCase()
|
||||||
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
|
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
|
||||||
}, [cat, filter])
|
}, [cat, filter])
|
||||||
|
useEscape(onClose)
|
||||||
const pattern = /[*?[]/.test(sensor)
|
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 (
|
return (
|
||||||
<div className="modal-back" onClick={onClose}>
|
<div className="modal-back" onClick={onClose}>
|
||||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
<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)
|
<label className="muted">Title (optional)
|
||||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
|
||||||
</label>
|
</label>
|
||||||
|
{bad && <div className="error">{bad}</div>}
|
||||||
<div className="svc-actions">
|
<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" />
|
<span className="spacer" />
|
||||||
<button className="btn" onClick={onClose}>Cancel</button>
|
<button className="btn" onClick={onClose}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
track(-1)
|
track(-1)
|
||||||
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
|
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
|
||||||
if (r.status === 401) {
|
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'
|
let msg = 'unauthenticated'
|
||||||
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
||||||
throw new Unauthenticated(msg)
|
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 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); }
|
.attrs tr.warn td { color: var(--s2); }
|
||||||
.datasets { padding: 0; overflow-x: auto; }
|
.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.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
.ds-row .num { text-align: right; }
|
.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; }
|
.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) {
|
@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; }
|
.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; }
|
.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; }
|
:root { --tabs-h: 40px; }
|
||||||
.ttab .icon-btn { min-width: 30px; }
|
.ttab .icon-btn { min-width: 30px; }
|
||||||
.ttab:not(.active) .icon-btn { display: none; }
|
.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 ----
|
// ---- spinners ----
|
||||||
export function Spinner({ size = 14 }: { size?: number }) {
|
export function Spinner({ size = 14 }: { size?: number }) {
|
||||||
@@ -68,3 +68,22 @@ export function NetBar() {
|
|||||||
}, [])
|
}, [])
|
||||||
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
|
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