Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0ec5e018 | ||
|
|
a07a444170 | ||
|
|
dfa9e88f5b | ||
|
|
260d5d4880 | ||
|
|
72c7f45e19 | ||
|
|
2e4f3a7fe6 | ||
|
|
e0625be684 | ||
|
|
bf0bdd54af | ||
|
|
9bf60deffc |
@@ -259,6 +259,23 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
|
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
|
||||||
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
|
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
|
||||||
Top sat rather than falling to the bottom.
|
Top sat rather than falling to the bottom.
|
||||||
|
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
|
||||||
|
merged Top into Processes: the app repeated itself. The customisable module list is gone
|
||||||
|
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
|
||||||
|
per-user `dashboard` blob in `state.json`. The Processes tab already shows those numbers
|
||||||
|
on one screen. `fmtBytes`/`fmtRate` moved to `fmt.ts` (Disks, Files and Space use them);
|
||||||
|
`MONITOR_HISTORY` and `seriesOf` went with the page. The default view after login, after
|
||||||
|
switching machines, and when a hub-only page is left behind, is now **Processes**. A saved
|
||||||
|
rail order naming `dashboard` degrades quietly — `orderBy` drops ids with no page.
|
||||||
|
**Widgets stays and says what it is for**: a notice at the top explains these are for
|
||||||
|
dashboards *outside* Ham Lab — Glance, Home Assistant, a wall display — served from
|
||||||
|
`/embed/widget` with a read-only sensor token, not a second copy of the console's screens.
|
||||||
|
**It also closed a real bug.** `applyPrefs` set the machine's sampling rate from
|
||||||
|
`intervalMs` inside the dashboard layout, and its `else` branch reset the sampler to the
|
||||||
|
default whenever that field was absent. Since layouts became per-user, one account saving
|
||||||
|
a dashboard could silently reset the sampling rate for the whole machine, overriding the
|
||||||
|
fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI
|
||||||
|
prefs, which the hub fans out, remembered per machine as `sampler_ms`.
|
||||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
@@ -531,23 +548,45 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
||||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
at all, which is every host here today. Worth scoping as its own plan before starting.
|
||||||
|
|
||||||
- **Deprecate the Dashboard tab** — asked for 2026-09-23, not started. Mitchell: it is nice
|
|
||||||
but the app repeats itself — the same reasoning that merged Top into Processes the same
|
|
||||||
day. Dashboard is the customisable module list (CPU, memory, load, uptime, temps, fans,
|
|
||||||
network, disks, filesystems, UPS) plus custom sensor widgets; the Processes tab shows
|
|
||||||
most of the same numbers on one screen already. **Widgets stays**, reframed: its page should say plainly that the
|
|
||||||
widgets there are for **external dashboards** (Glance, Home Assistant, a wall display)
|
|
||||||
via `/embed/widget` and read-only sensor tokens, not a second copy of this console's own
|
|
||||||
screens. Care needed on the way out: the dashboard layout is per-user in `state.json`
|
|
||||||
(`users.<name>.dashboard`) and carries `intervalMs`, which the server reads in
|
|
||||||
`applyPrefs` to set the sampler — that wiring has to move or the sampling rate loses its
|
|
||||||
source. `dashboard` is also the default view after login and after switching machines.
|
|
||||||
|
|
||||||
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
|
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
|
||||||
General polish and ease of use. The **Deprecate copyparty**
|
General polish and ease of use. The **Deprecate copyparty**
|
||||||
item names the heavier pieces (multi-select and bulk actions, folder download, resumable
|
item names the heavier pieces (multi-select and bulk actions, folder download, resumable
|
||||||
uploads, a gallery, search) — worth doing the two together, since they are the same tab.
|
uploads, a gallery, search) — worth doing the two together, since they are the same tab.
|
||||||
|
|
||||||
|
- **A KVM tab** — asked for 2026-09-24, not started. **Dead simple is the brief**: paste one
|
||||||
|
HTTP link, the tab shows whatever UI that KVM serves, and the keyboard goes to it. **This
|
||||||
|
is KVM the keyboard-video-mouse switch, not KVM the hypervisor** — the Virtualization item
|
||||||
|
above means the other one.
|
||||||
|
- **The device**: a **Sipeed NanoKVM** attached to `grid` at **`10.0.0.217`** (MAC
|
||||||
|
`48:da:35:6f:d0:31`), web UI on port 80. The only one so far, so store a list, not one URL.
|
||||||
|
- **Framing works.** Probed 2026-09-24: it sends **no `X-Frame-Options` and no
|
||||||
|
`frame-ancestors`**, so it does not refuse to be embedded. No fallback needed.
|
||||||
|
- **Proxy it through the daemon — that is what makes the simple version possible.** The
|
||||||
|
KVM is **HTTP only**, and Ham Lab is served over TLS, so a bare `http://…` iframe is
|
||||||
|
blocked as mixed content and the "just paste a link" idea dies there. Serving it from Ham
|
||||||
|
Lab's own origin (`/api/kvm/<id>/…`, on the `proxyNode` machinery, which already carries
|
||||||
|
WebSockets — exactly what the video and input streams are) fixes three things at once:
|
||||||
|
no mixed content, the KVM sits behind **Ham Lab's** login rather than a second one
|
||||||
|
(`/api/vm/info` answers 401, so it has its own, which the hub can hold once), and the
|
||||||
|
iframe becomes **same-origin**, which matters for the keyboard below.
|
||||||
|
- **The keyboard needs less than it sounds.** A parent page *cannot* capture keys and
|
||||||
|
forward them into a **cross-origin** iframe — the browser forbids synthetic events across
|
||||||
|
origins — but it does not need to: an iframe with focus receives keystrokes directly, and
|
||||||
|
the NanoKVM's own page already handles them. So "capture and give it to the iframe" is
|
||||||
|
really: click-to-focus, keep focus there, and stop the console's own shortcuts from
|
||||||
|
eating keys while it is focused. What is genuinely worth adding is **fullscreen** plus
|
||||||
|
`navigator.keyboard.lock()`, which is what stops the *browser* swallowing Escape, Tab and
|
||||||
|
Ctrl-W so they reach the guest — note it is Chromium-only, so Firefox and iOS Safari get
|
||||||
|
the plain version. Proxying makes the iframe same-origin anyway, so real forwarding stays
|
||||||
|
available if some KVM ever needs it.
|
||||||
|
- **Where the list lives is the one real design decision.** A KVM earns its keep when the
|
||||||
|
machine is *down* — no OS, a bad kernel, a BIOS screen — which is when that host's Ham Lab
|
||||||
|
cannot answer. So it is **hub-side** and must work for machines running no Ham Lab at all.
|
||||||
|
A `kvm_url` in each node's own `config.json` would fail in exactly the case it is for.
|
||||||
|
- **Do not give it an NPM host** without deciding to: `*.bigham.dev` is a wildcard to the
|
||||||
|
public IP, so that would put BIOS-level control of `grid` on the internet behind only the
|
||||||
|
KVM's own login. Behind Ham Lab's proxy it inherits Ham Lab's auth instead.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
||||||
|
|||||||
+11
-58
@@ -102,18 +102,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
s.sensors.Add(newStatusSensors(s))
|
s.sensors.Add(newStatusSensors(s))
|
||||||
s.alerts = newAlertEngine(s)
|
s.alerts = newAlertEngine(s)
|
||||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||||
var raw json.RawMessage
|
|
||||||
var histSec int
|
var histSec int
|
||||||
st.View(func(x *store.State) {
|
st.View(func(x *store.State) { histSec = x.HistorySec })
|
||||||
raw, histSec = x.Dashboard, x.HistorySec
|
|
||||||
if len(raw) == 0 {
|
|
||||||
for _, u := range x.Users {
|
|
||||||
raw = u.Dashboard
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
s.applyPrefs(raw)
|
|
||||||
if histSec > 0 {
|
if histSec > 0 {
|
||||||
col.SetWindow(time.Duration(histSec) * time.Second)
|
col.SetWindow(time.Duration(histSec) * time.Second)
|
||||||
}
|
}
|
||||||
@@ -147,6 +137,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
go s.keepFleetInStep()
|
go s.keepFleetInStep()
|
||||||
}
|
}
|
||||||
go s.watchForUpdates(context.Background())
|
go s.watchForUpdates(context.Background())
|
||||||
|
go s.runSchedules(context.Background())
|
||||||
|
|
||||||
m := http.NewServeMux()
|
m := http.NewServeMux()
|
||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||||
@@ -261,6 +252,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
||||||
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
||||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||||
|
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
|
||||||
|
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
|
||||||
|
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
|
||||||
|
m.HandleFunc("GET /api/zfs/schedules/{id}/expired", s.auth(s.zfsPreview))
|
||||||
|
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
|
||||||
|
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete))
|
||||||
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
|
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
|
||||||
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
|
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
|
||||||
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
||||||
@@ -289,8 +286,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||||
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
|
||||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
|
||||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
||||||
m.HandleFunc("/", s.static)
|
m.HandleFunc("/", s.static)
|
||||||
s.mux = m
|
s.mux = m
|
||||||
@@ -930,15 +925,11 @@ func (s *Server) migrateUsers() {
|
|||||||
st.Sessions[tok] = ses
|
st.Sessions[tok] = ses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(st.UI) > 0 || len(st.Dashboard) > 0 {
|
if len(st.UI) > 0 {
|
||||||
u := st.UserState(first)
|
if u := st.UserState(first); len(u.UI) == 0 {
|
||||||
if len(u.UI) == 0 {
|
|
||||||
u.UI = st.UI
|
u.UI = st.UI
|
||||||
}
|
}
|
||||||
if len(u.Dashboard) == 0 {
|
st.UI = nil
|
||||||
u.Dashboard = st.Dashboard
|
|
||||||
}
|
|
||||||
st.UI, st.Dashboard = nil, nil
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2282,44 +2273,6 @@ func (s *Server) pushHistoryWindow(seconds int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
|
||||||
|
|
||||||
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
b, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
|
|
||||||
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
|
|
||||||
jsonErr(w, 400, "expected a JSON object")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user := actorOf(r).User; user != "" {
|
|
||||||
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.applyPrefs(b)
|
|
||||||
w.WriteHeader(204)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyPrefs picks the few layout fields the server acts on (just the interval).
|
|
||||||
func (s *Server) applyPrefs(raw []byte) {
|
|
||||||
var p struct {
|
|
||||||
IntervalMs int `json:"intervalMs"`
|
|
||||||
}
|
|
||||||
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p.IntervalMs > 0 {
|
|
||||||
s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond)
|
|
||||||
} else {
|
|
||||||
s.metrics.SetInterval(metrics.DefaultInterval)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- terminals ----
|
// ---- terminals ----
|
||||||
|
|
||||||
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The ZFS tab: pools, datasets, snapshots, and a schedule for taking and expiring them.
|
||||||
|
//
|
||||||
|
// grid already runs zfSnap from root cron and has done for longer than this repo has
|
||||||
|
// existed, so this does not replace it. It reads the same snapshot names, expires by the
|
||||||
|
// same rule, and surfaces the cron job that drives it — so what is on screen is the truth
|
||||||
|
// about the machine, whether Ham Lab or zfSnap is doing the work. A Ham Lab schedule is for
|
||||||
|
// a machine that has no such tool, and the page says plainly when both are running.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/storage"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type zfsState struct {
|
||||||
|
Policies []storage.Policy `json:"policies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zfsEngine struct {
|
||||||
|
s *Server
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) zfsLoad() zfsState {
|
||||||
|
var raw json.RawMessage
|
||||||
|
s.store.View(func(st *store.State) { raw = st.ZFS })
|
||||||
|
var z zfsState
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &z)
|
||||||
|
}
|
||||||
|
if z.Policies == nil {
|
||||||
|
z.Policies = []storage.Policy{}
|
||||||
|
}
|
||||||
|
return z
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) zfsSave(z zfsState) error {
|
||||||
|
b, err := json.Marshal(z)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.Update(func(st *store.State) { st.ZFS = b })
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalSnapshotJob finds a cron job that is already taking snapshots on this machine, so
|
||||||
|
// the page can say so rather than let two schedules quietly double up.
|
||||||
|
func (s *Server) externalSnapshotJob(ctx context.Context) map[string]any {
|
||||||
|
if s.cron == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ov := s.cron.Overview(ctx)
|
||||||
|
for _, j := range ov.Jobs {
|
||||||
|
if !j.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := strings.ToLower(j.Command)
|
||||||
|
if !strings.Contains(c, "zfsnap") && !strings.Contains(c, "zfs-auto-snapshot") && !strings.Contains(c, "sanoid") &&
|
||||||
|
!(strings.Contains(c, "zfs ") && strings.Contains(c, "snapshot")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"tool": toolName(c), "schedule": j.Schedule, "command": j.Command,
|
||||||
|
"user": j.User, "source": j.Source, "id": j.ID, "next": j.Next, "last": j.Last,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolName(cmd string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(cmd, "zfsnap"):
|
||||||
|
return "zfSnap"
|
||||||
|
case strings.Contains(cmd, "zfs-auto-snapshot"):
|
||||||
|
return "zfs-auto-snapshot"
|
||||||
|
case strings.Contains(cmd, "sanoid"):
|
||||||
|
return "sanoid"
|
||||||
|
}
|
||||||
|
return "a cron job"
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomID is a short opaque id for a schedule.
|
||||||
|
func randomID() (string, error) {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the schedule runs itself ----
|
||||||
|
|
||||||
|
// runSchedules takes the snapshots that are due. Every 30 s, which is fine for a schedule
|
||||||
|
// whose finest grain is hourly, and cheap when nothing is due.
|
||||||
|
func (s *Server) runSchedules(ctx context.Context) {
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
z := s.zfsLoad()
|
||||||
|
now := time.Now()
|
||||||
|
changed := false
|
||||||
|
for i := range z.Policies {
|
||||||
|
p := &z.Policies[i]
|
||||||
|
if !p.Due(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, err := s.storage.Run(ctx, p)
|
||||||
|
p.LastRun = now
|
||||||
|
p.LastTaken, p.Pruned = res.Taken, len(res.Pruned)
|
||||||
|
if err != nil {
|
||||||
|
p.LastError = err.Error()
|
||||||
|
log.Printf("zfs schedule %q: %v", p.Name, err)
|
||||||
|
} else {
|
||||||
|
p.LastError = ""
|
||||||
|
log.Printf("zfs schedule %q: took %s, expired %d", p.Name, res.Taken, len(res.Pruned))
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := s.zfsSave(z); err != nil {
|
||||||
|
log.Printf("zfs schedule: saving: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- endpoints ----
|
||||||
|
|
||||||
|
func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
z := s.zfsLoad()
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]map[string]any, 0, len(z.Policies))
|
||||||
|
for i := range z.Policies {
|
||||||
|
p := z.Policies[i]
|
||||||
|
m := map[string]any{"policy": p}
|
||||||
|
if p.Enabled {
|
||||||
|
m["next"] = p.NextRun(now)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{
|
||||||
|
"zfs": s.storage.ZFS(r.Context()), "policies": out, "external": s.externalSnapshotJob(r.Context()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p storage.Policy
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z := s.zfsLoad()
|
||||||
|
found := false
|
||||||
|
for i := range z.Policies {
|
||||||
|
if z.Policies[i].ID == p.ID && p.ID != "" {
|
||||||
|
p.LastRun, p.LastError, p.LastTaken, p.Pruned = z.Policies[i].LastRun, z.Policies[i].LastError, z.Policies[i].LastTaken, z.Policies[i].Pruned
|
||||||
|
z.Policies[i] = p
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
if p.ID == "" {
|
||||||
|
id, err := randomID()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.ID = id
|
||||||
|
}
|
||||||
|
z.Policies = append(z.Policies, p)
|
||||||
|
}
|
||||||
|
if err := s.zfsSave(z); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("zfs schedule %q saved by %s (%s, %s, keep %s)", p.Name, clientIP(r), p.Dataset, p.Every, p.KeepFor)
|
||||||
|
jsonOK(w, 200, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
z := s.zfsLoad()
|
||||||
|
kept := z.Policies[:0]
|
||||||
|
found := false
|
||||||
|
for _, p := range z.Policies {
|
||||||
|
if p.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, p)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z.Policies = kept
|
||||||
|
if err := s.zfsSave(z); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
|
||||||
|
for _, p := range s.zfsLoad().Policies {
|
||||||
|
if p.ID == id {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storage.Policy{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted.
|
||||||
|
func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := s.zfsPolicy(r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doomed, err := s.storage.Doomed(r.Context(), &p)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doomed == nil {
|
||||||
|
doomed = []storage.Snapshot{}
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"expired": doomed})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
p, ok := s.zfsPolicy(id)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := s.storage.Run(r.Context(), &p)
|
||||||
|
now := time.Now()
|
||||||
|
z := s.zfsLoad()
|
||||||
|
for i := range z.Policies {
|
||||||
|
if z.Policies[i].ID == id {
|
||||||
|
z.Policies[i].LastRun, z.Policies[i].LastTaken, z.Policies[i].Pruned = now, res.Taken, len(res.Pruned)
|
||||||
|
z.Policies[i].LastError = ""
|
||||||
|
if err != nil {
|
||||||
|
z.Policies[i].LastError = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.zfsSave(z)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("zfs schedule %q run by %s: took %s, expired %d", p.Name, clientIP(r), res.Taken, len(res.Pruned))
|
||||||
|
jsonOK(w, 200, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zfsSnapshotDelete removes one snapshot, named in full and typed back by the caller.
|
||||||
|
func (s *Server) zfsSnapshotDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
Recursive bool `json:"recursive"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Confirm != body.Name {
|
||||||
|
jsonErr(w, 400, "type the snapshot's full name to confirm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.storage.DestroySnapshot(r.Context(), body.Name, body.Recursive); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("zfs snapshot %s destroyed by %s%s", body.Name, clientIP(r), map[bool]string{true: " (recursive)", false: ""}[body.Recursive])
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
|
||||||
|
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
|
||||||
|
//
|
||||||
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
|
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
|
||||||
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
|
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
|
||||||
// timers. It computes next runs from the cron expressions, pulls last runs from the
|
// timers. It computes next runs from the cron expressions, pulls last runs from the
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ type FS struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Size uint64 `json:"size"`
|
Size uint64 `json:"size"`
|
||||||
Used uint64 `json:"used"`
|
Used uint64 `json:"used"`
|
||||||
|
// Remote is set when the filesystem lives on another machine — a CIFS or NFS share,
|
||||||
|
// sshfs, and so on. Its bytes are somebody else's disk, so a panel about this machine's
|
||||||
|
// hardware leaves it out, while the sensor stays for anyone who does want it.
|
||||||
|
Remote bool `json:"remote,omitempty"`
|
||||||
|
// Disk is the physical drive these bytes are on ("nvme0", "sda"), named as the sampler
|
||||||
|
// names it; empty when that cannot be
|
||||||
|
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
|
||||||
|
// a remote mount is on none of them.
|
||||||
|
Disk string `json:"disk,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UPS struct {
|
type UPS struct {
|
||||||
@@ -766,6 +775,15 @@ func foldNVMe(devs []Disk) []Disk {
|
|||||||
|
|
||||||
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
|
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
|
||||||
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
|
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
|
||||||
|
// remoteFS is every filesystem type whose storage is not in this machine.
|
||||||
|
var remoteFS = map[string]bool{
|
||||||
|
"cifs": true, "smb3": true, "smbfs": true, "nfs": true, "nfs4": true, "afs": true,
|
||||||
|
"ceph": true, "glusterfs": true, "9p": true, "afpfs": true, "davfs": true,
|
||||||
|
"fuse.sshfs": true, "sshfs": true, "fuse.rclone": true, "fuse.s3fs": true,
|
||||||
|
"fuse.davfs2": true, "fuse.curlftpfs": true, "fuse.gcsfuse": true, "beegfs": true,
|
||||||
|
"lustre": true, "orangefs": true, "pvfs2": true, "coda": true, "ncpfs": true,
|
||||||
|
}
|
||||||
|
|
||||||
var pseudoFS = map[string]bool{
|
var pseudoFS = map[string]bool{
|
||||||
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
|
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
|
||||||
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
|
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
|
||||||
@@ -812,7 +830,7 @@ func filesystems() []FS {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
bs := uint64(st.Bsize)
|
bs := uint64(st.Bsize)
|
||||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs}
|
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case r := <-done:
|
case r := <-done:
|
||||||
@@ -820,12 +838,52 @@ func filesystems() []FS {
|
|||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
out = append(out, FS{Mount: mount, Type: fs[2]}) // unreachable: size 0 signals it
|
out = append(out, FS{Mount: mount, Type: fs[2], Remote: remoteFS[fs[2]]}) // unreachable: size 0 signals it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diskOf names the physical drive a mount's source device sits on — "/dev/nvme0n1p2" is on
|
||||||
|
// "nvme0n1". It follows one layer of device-mapper or md (LUKS on a partition, say) but
|
||||||
|
// gives up when a thing spans several drives, because then no single drive is the answer.
|
||||||
|
func diskOf(source string) string {
|
||||||
|
if !strings.HasPrefix(source, "/dev/") {
|
||||||
|
return "" // a ZFS pool, a network share, a tmpfs: not a block device at all
|
||||||
|
}
|
||||||
|
d := parentDisk(strings.TrimPrefix(source, "/dev/"), 0)
|
||||||
|
// the sampler reports one drive per NVMe controller, so name it the same way
|
||||||
|
if m := nsRe.FindStringSubmatch(d); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func parentDisk(name string, depth int) string {
|
||||||
|
if name == "" || depth > 4 || strings.Contains(name, "/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := "/sys/class/block/" + name
|
||||||
|
if _, err := os.Stat(base); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// a partition: its parent directory in sysfs is the whole drive
|
||||||
|
if _, err := os.Stat(base + "/partition"); err == nil {
|
||||||
|
if target, err := os.Readlink(base); err == nil {
|
||||||
|
return filepath.Base(filepath.Dir(target))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// device-mapper or md: one drive underneath is an answer, several is not
|
||||||
|
if ents, err := os.ReadDir(base + "/slaves"); err == nil && len(ents) > 0 {
|
||||||
|
if len(ents) > 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parentDisk(ents[0].Name(), depth+1)
|
||||||
|
}
|
||||||
|
return name // a whole drive, used without partitioning
|
||||||
|
}
|
||||||
|
|
||||||
// ---- UPS ----
|
// ---- UPS ----
|
||||||
|
|
||||||
func runOut(args ...string) (string, bool) {
|
func runOut(args ...string) (string, bool) {
|
||||||
|
|||||||
@@ -117,9 +117,18 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
|
|
||||||
for _, x := range s.FS {
|
for _, x := range s.FS {
|
||||||
p := "fs." + sensors.Seg(x.Mount)
|
p := "fs." + sensors.Seg(x.Mount)
|
||||||
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, "mount", x.Mount, "fstype", x.Type)
|
// remote says the bytes are on another machine (a CIFS or NFS share), so a view of
|
||||||
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), "mount", x.Mount, "fstype", x.Type)
|
// this machine's hardware can leave it out without losing the sensor
|
||||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, "mount", x.Mount, "fstype", x.Type)
|
lbl := []string{"mount", x.Mount, "fstype", x.Type}
|
||||||
|
if x.Remote {
|
||||||
|
lbl = append(lbl, "remote", "1")
|
||||||
|
}
|
||||||
|
if x.Disk != "" {
|
||||||
|
lbl = append(lbl, "disk", x.Disk)
|
||||||
|
}
|
||||||
|
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
||||||
|
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
||||||
|
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if u := s.UPS; u != nil {
|
if u := s.UPS; u != nil {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Package space measures what is using a disk: a parallel walk of the filesystems a disk
|
// Package space measures what is using a disk — it is what the UI calls the **Usage** tab
|
||||||
|
// (renamed from "Space" on 2026-09-24; the package and its /api/space endpoints kept the
|
||||||
|
// old name rather than churn a working API): a parallel walk of the filesystems a disk
|
||||||
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
|
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
|
||||||
//
|
//
|
||||||
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// Scheduled snapshots with a retention period: take a snapshot of a dataset on a schedule,
|
||||||
|
// and drop the ones that have aged out.
|
||||||
|
//
|
||||||
|
// **The naming is zfSnap's, deliberately.** grid has run zfSnap v1.11.1 from root cron since
|
||||||
|
// before this repo existed and it works well, so rather than invent a second convention and
|
||||||
|
// have two tools disagree about what a snapshot is, this writes and reads exactly what
|
||||||
|
// zfSnap does: `<dataset>@2026-09-24_02.30.01--6w`, with the retention encoded in the name.
|
||||||
|
// That has three consequences worth knowing. Ham Lab can show and manage the snapshots
|
||||||
|
// zfSnap already took. The two can run side by side without fighting, because both only
|
||||||
|
// ever destroy snapshots that have passed the expiry written into their own name. And
|
||||||
|
// changing a policy's retention does not retroactively re-date old snapshots — each one
|
||||||
|
// carries the rule it was taken under, which is the behaviour you want from a backup.
|
||||||
|
//
|
||||||
|
// Two rules make this safe to run unattended. Only a snapshot whose name parses as a stamp
|
||||||
|
// and a TTL is ever a candidate — anything taken by hand or named anything else is invisible
|
||||||
|
// to it. And every policy can be asked what it would delete, before it deletes anything.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01.
|
||||||
|
const stampLayout = "2006-01-02_15.04.05"
|
||||||
|
|
||||||
|
// ttlSep separates the stamp from the retention in a snapshot name, as zfSnap writes it.
|
||||||
|
const ttlSep = "--"
|
||||||
|
|
||||||
|
// Policy is a standing instruction: snapshot this dataset on this schedule, keep the
|
||||||
|
// snapshots this long.
|
||||||
|
type Policy struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Dataset string `json:"dataset"`
|
||||||
|
// Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r).
|
||||||
|
Recursive bool `json:"recursive"`
|
||||||
|
// Every is hourly | daily | weekly | monthly. At is "HH:MM" (the minute alone is used
|
||||||
|
// for hourly). Weekday is 0=Sunday, Day is 1–28.
|
||||||
|
Every string `json:"every"`
|
||||||
|
At string `json:"at"`
|
||||||
|
Weekday int `json:"weekday"`
|
||||||
|
Day int `json:"day"`
|
||||||
|
// KeepFor is how long a snapshot lives: "6w", "30d", "12h". It is written into the
|
||||||
|
// snapshot's own name, zfSnap-style, so each one carries the rule it was taken under.
|
||||||
|
// KeepMin is a floor — that many newest snapshots survive however old they are, so a
|
||||||
|
// dataset is never left with none.
|
||||||
|
KeepFor string `json:"keep_for"`
|
||||||
|
KeepMin int `json:"keep_min"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
LastRun time.Time `json:"last_run,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
LastTaken string `json:"last_taken,omitempty"` // the snapshot the last run made
|
||||||
|
Pruned int `json:"pruned,omitempty"` // how many the last run destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
|
||||||
|
|
||||||
|
// Validate reports what is wrong with a policy, or nil.
|
||||||
|
func (p *Policy) Validate() error {
|
||||||
|
if !datasetRe.MatchString(p.Dataset) {
|
||||||
|
return errors.New("dataset: a pool or pool/dataset name")
|
||||||
|
}
|
||||||
|
if pre := p.prefix(); pre != "" && (!snapRe.MatchString(pre) || strings.Contains(pre, ttlSep)) {
|
||||||
|
return errors.New("prefix: letters, digits, . _ : and - only, and no --")
|
||||||
|
}
|
||||||
|
switch p.Every {
|
||||||
|
case "hourly", "daily", "weekly", "monthly":
|
||||||
|
default:
|
||||||
|
return errors.New("every: hourly, daily, weekly or monthly")
|
||||||
|
}
|
||||||
|
if _, _, err := p.clock(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.Every == "weekly" && (p.Weekday < 0 || p.Weekday > 6) {
|
||||||
|
return errors.New("weekday: 0 (Sunday) to 6")
|
||||||
|
}
|
||||||
|
if p.Every == "monthly" && (p.Day < 1 || p.Day > 28) {
|
||||||
|
return errors.New("day: 1 to 28, so every month has one")
|
||||||
|
}
|
||||||
|
if p.KeepFor != "" {
|
||||||
|
if _, err := ParseAge(p.KeepFor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.KeepMin < 0 {
|
||||||
|
return errors.New("keep at least: 0 or more")
|
||||||
|
}
|
||||||
|
if p.KeepFor == "" {
|
||||||
|
return errors.New("a retention period is required — it is written into each snapshot's name, and is what lets it be cleaned up later")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Policy) clock() (hour, min int, err error) {
|
||||||
|
at := strings.TrimSpace(p.At)
|
||||||
|
if at == "" {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
h, m, ok := strings.Cut(at, ":")
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, errors.New("at: HH:MM")
|
||||||
|
}
|
||||||
|
hour, err = strconv.Atoi(h)
|
||||||
|
if err == nil {
|
||||||
|
min, err = strconv.Atoi(m)
|
||||||
|
}
|
||||||
|
if err != nil || hour < 0 || hour > 23 || min < 0 || min > 59 {
|
||||||
|
return 0, 0, errors.New("at: HH:MM, 00:00 to 23:59")
|
||||||
|
}
|
||||||
|
return hour, min, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAge reads "6w", "30d", "12h", "90m" as a duration. Weeks and days are what a
|
||||||
|
// retention period is actually written in, and time.ParseDuration knows neither.
|
||||||
|
func ParseAge(s string) (time.Duration, error) {
|
||||||
|
s = strings.TrimSpace(strings.ToLower(s))
|
||||||
|
if s == "" {
|
||||||
|
return 0, errors.New("empty period")
|
||||||
|
}
|
||||||
|
unit := s[len(s)-1]
|
||||||
|
n, err := strconv.Atoi(s[:len(s)-1])
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, fmt.Errorf("%q: a number and a unit, like 6w, 30d or 12h", s)
|
||||||
|
}
|
||||||
|
switch unit {
|
||||||
|
case 'm':
|
||||||
|
return time.Duration(n) * time.Minute, nil
|
||||||
|
case 'h':
|
||||||
|
return time.Duration(n) * time.Hour, nil
|
||||||
|
case 'd':
|
||||||
|
return time.Duration(n) * 24 * time.Hour, nil
|
||||||
|
case 'w':
|
||||||
|
return time.Duration(n) * 7 * 24 * time.Hour, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%q: the unit is m, h, d or w", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastDue is the most recent moment this policy was supposed to run, at or before now.
|
||||||
|
// A policy whose LastRun is older than that is due — so a machine that was off through
|
||||||
|
// its window takes one snapshot when it comes back, rather than skipping silently.
|
||||||
|
func (p *Policy) lastDue(now time.Time) time.Time {
|
||||||
|
hour, min, _ := p.clock()
|
||||||
|
y, mo, d := now.Date()
|
||||||
|
loc := now.Location()
|
||||||
|
switch p.Every {
|
||||||
|
case "hourly":
|
||||||
|
t := time.Date(y, mo, d, now.Hour(), min, 0, 0, loc)
|
||||||
|
if t.After(now) {
|
||||||
|
t = t.Add(-time.Hour)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case "daily":
|
||||||
|
t := time.Date(y, mo, d, hour, min, 0, 0, loc)
|
||||||
|
if t.After(now) {
|
||||||
|
t = t.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case "weekly":
|
||||||
|
t := time.Date(y, mo, d, hour, min, 0, 0, loc)
|
||||||
|
back := (int(t.Weekday()) - p.Weekday + 7) % 7
|
||||||
|
t = t.AddDate(0, 0, -back)
|
||||||
|
if t.After(now) {
|
||||||
|
t = t.AddDate(0, 0, -7)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case "monthly":
|
||||||
|
t := time.Date(y, mo, p.Day, hour, min, 0, 0, loc)
|
||||||
|
if t.After(now) {
|
||||||
|
t = t.AddDate(0, -1, 0)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextRun is when this policy runs next, for the UI to show.
|
||||||
|
func (p *Policy) NextRun(now time.Time) time.Time {
|
||||||
|
last := p.lastDue(now)
|
||||||
|
switch p.Every {
|
||||||
|
case "hourly":
|
||||||
|
return last.Add(time.Hour)
|
||||||
|
case "daily":
|
||||||
|
return last.AddDate(0, 0, 1)
|
||||||
|
case "weekly":
|
||||||
|
return last.AddDate(0, 0, 7)
|
||||||
|
case "monthly":
|
||||||
|
return last.AddDate(0, 1, 0)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due reports whether the policy should run now.
|
||||||
|
func (p *Policy) Due(now time.Time) bool {
|
||||||
|
if !p.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d := p.lastDue(now)
|
||||||
|
return !d.IsZero() && p.LastRun.Before(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken,
|
||||||
|
// when it expires, and whether the name parsed at all. A name without a TTL never expires.
|
||||||
|
// An optional prefix in front of the stamp is allowed, so a policy can label its own.
|
||||||
|
func SnapTime(name string) (taken time.Time, expires time.Time, ok bool) {
|
||||||
|
_, snap, cut := strings.Cut(name, "@")
|
||||||
|
if !cut {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, ttl, hasTTL := strings.Cut(snap, ttlSep)
|
||||||
|
// an optional "<prefix>-" before the stamp
|
||||||
|
stamp := body
|
||||||
|
if len(body) > len(stampLayout) {
|
||||||
|
stamp = body[len(body)-len(stampLayout):]
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation(stampLayout, stamp, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, false
|
||||||
|
}
|
||||||
|
taken, ok = t, true
|
||||||
|
if hasTTL {
|
||||||
|
if d, err := ParseAge(ttl); err == nil {
|
||||||
|
expires = t.Add(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doomed is which of this dataset's snapshots have passed the expiry written into their own
|
||||||
|
// names, oldest first — exactly what zfSnap -d would remove. This is what the UI shows
|
||||||
|
// before anything is deleted, and what Run then acts on. KeepMin holds back that many of the
|
||||||
|
// newest whatever their names say, so a dataset is never left with none.
|
||||||
|
func (m *Manager) Doomed(ctx context.Context, p *Policy) ([]Snapshot, error) {
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all, err := m.Snapshots(ctx, p.Dataset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var dated []Snapshot
|
||||||
|
expiry := map[string]time.Time{}
|
||||||
|
for _, s := range all {
|
||||||
|
taken, exp, ok := SnapTime(s.Name)
|
||||||
|
if !ok || exp.IsZero() {
|
||||||
|
continue // hand-made, or kept forever: not ours to remove
|
||||||
|
}
|
||||||
|
_ = taken
|
||||||
|
dated = append(dated, s)
|
||||||
|
expiry[s.Name] = exp
|
||||||
|
}
|
||||||
|
sort.Slice(dated, func(i, j int) bool { return dated[i].Created > dated[j].Created }) // newest first
|
||||||
|
now := time.Now()
|
||||||
|
var doomed []Snapshot
|
||||||
|
for i, s := range dated {
|
||||||
|
if i < p.KeepMin {
|
||||||
|
continue // the floor: these survive however old they are
|
||||||
|
}
|
||||||
|
if expiry[s.Name].Before(now) {
|
||||||
|
doomed = append(doomed, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(doomed, func(i, j int) bool { return doomed[i].Created < doomed[j].Created }) // oldest first
|
||||||
|
return doomed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestroySnapshot removes one snapshot. recursive also removes the same-named snapshot on
|
||||||
|
// every dataset beneath it, which is what a recursive policy created.
|
||||||
|
func (m *Manager) DestroySnapshot(ctx context.Context, full string, recursive bool) error {
|
||||||
|
dataset, snap, ok := strings.Cut(full, "@")
|
||||||
|
if !ok || !datasetRe.MatchString(dataset) || !snapRe.MatchString(snap) {
|
||||||
|
return errors.New("bad snapshot name")
|
||||||
|
}
|
||||||
|
args := []string{"zfs", "destroy"}
|
||||||
|
if recursive {
|
||||||
|
args = append(args, "-r")
|
||||||
|
}
|
||||||
|
out, code, err := m.run(ctx, append(args, full)...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return errors.New(strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotRecursive takes dataset@name, optionally of everything beneath it too.
|
||||||
|
func (m *Manager) SnapshotRecursive(ctx context.Context, dataset, name string, recursive bool) error {
|
||||||
|
if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) {
|
||||||
|
return errors.New("bad snapshot name")
|
||||||
|
}
|
||||||
|
args := []string{"zfs", "snapshot"}
|
||||||
|
if recursive {
|
||||||
|
args = append(args, "-r")
|
||||||
|
}
|
||||||
|
out, code, err := m.run(ctx, append(args, dataset+"@"+name)...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return errors.New(strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult is what one pass of a policy did.
|
||||||
|
type RunResult struct {
|
||||||
|
Taken string `json:"taken"`
|
||||||
|
Pruned []string `json:"pruned"`
|
||||||
|
Skipped int `json:"skipped"` // snapshots that are not this policy's, left alone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run takes the snapshot and prunes what has aged out. It is the same code the scheduler
|
||||||
|
// calls, so "Run now" and the timer cannot drift apart.
|
||||||
|
func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) {
|
||||||
|
var res RunResult
|
||||||
|
if err := p.Validate(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
name := time.Now().Format(stampLayout)
|
||||||
|
if pre := strings.TrimSpace(p.Prefix); pre != "" {
|
||||||
|
name = pre + "-" + name
|
||||||
|
}
|
||||||
|
if p.KeepFor != "" {
|
||||||
|
name += ttlSep + strings.TrimSpace(strings.ToLower(p.KeepFor)) // zfSnap's convention: the retention lives in the name
|
||||||
|
}
|
||||||
|
if err := m.SnapshotRecursive(ctx, p.Dataset, name, p.Recursive); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Taken = p.Dataset + "@" + name
|
||||||
|
doomed, err := m.Doomed(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return res, err // the snapshot was taken; the prune is what failed
|
||||||
|
}
|
||||||
|
for _, s := range doomed {
|
||||||
|
if err := m.DestroySnapshot(ctx, s.Name, p.Recursive); err != nil {
|
||||||
|
return res, fmt.Errorf("destroying %s: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
res.Pruned = append(res.Pruned, s.Name)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapTimeReadsZfSnapNames(t *testing.T) {
|
||||||
|
// the real thing off grid
|
||||||
|
taken, exp, ok := SnapTime("tank@2026-09-24_02.30.01--6w")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("zfSnap's own name must parse")
|
||||||
|
}
|
||||||
|
if taken.Format("2006-01-02 15:04:05") != "2026-09-24 02:30:01" {
|
||||||
|
t.Fatalf("taken = %v", taken)
|
||||||
|
}
|
||||||
|
if got := exp.Sub(taken); got != 6*7*24*time.Hour {
|
||||||
|
t.Fatalf("ttl = %v, want 6w", got)
|
||||||
|
}
|
||||||
|
// a labelled one from a policy with a prefix
|
||||||
|
if _, e, ok := SnapTime("tank/apps@nightly-2026-09-24_02.30.01--30d"); !ok || e.IsZero() {
|
||||||
|
t.Fatal("a prefixed name must parse and expire")
|
||||||
|
}
|
||||||
|
// no TTL: never expires
|
||||||
|
if _, e, ok := SnapTime("tank@2026-09-24_02.30.01"); !ok || !e.IsZero() {
|
||||||
|
t.Fatal("a name without a TTL must parse and never expire")
|
||||||
|
}
|
||||||
|
// things that are not ours
|
||||||
|
for _, n := range []string{"tank@before-upgrade", "tank@manual", "tank", "tank@2026-13-45_99.99.99--6w"} {
|
||||||
|
if _, _, ok := SnapTime(n); ok {
|
||||||
|
t.Errorf("%q must not parse as a scheduled snapshot", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduleIsDueAndNext(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local)
|
||||||
|
daily := &Policy{Every: "daily", At: "02:30", Enabled: true}
|
||||||
|
if d := daily.lastDue(now); d.Hour() != 2 || d.Day() != 24 {
|
||||||
|
t.Fatalf("daily lastDue = %v", d)
|
||||||
|
}
|
||||||
|
if !daily.Due(now) {
|
||||||
|
t.Fatal("never run: must be due")
|
||||||
|
}
|
||||||
|
daily.LastRun = now.Add(-time.Hour) // ran at 08:15 today, after 02:30
|
||||||
|
if daily.Due(now) {
|
||||||
|
t.Fatal("already ran since the window: not due")
|
||||||
|
}
|
||||||
|
if n := daily.NextRun(now); n.Day() != 25 || n.Hour() != 2 {
|
||||||
|
t.Fatalf("daily NextRun = %v", n)
|
||||||
|
}
|
||||||
|
// a machine that was off through its window runs once when it comes back, not never
|
||||||
|
daily.LastRun = now.AddDate(0, 0, -2)
|
||||||
|
if !daily.Due(now) {
|
||||||
|
t.Fatal("a missed window must still be due")
|
||||||
|
}
|
||||||
|
// disabled never runs
|
||||||
|
daily.Enabled = false
|
||||||
|
if daily.Due(now) {
|
||||||
|
t.Fatal("disabled must never be due")
|
||||||
|
}
|
||||||
|
weekly := &Policy{Every: "weekly", At: "04:00", Weekday: int(time.Sunday), Enabled: true}
|
||||||
|
if d := weekly.lastDue(now); d.Weekday() != time.Sunday || d.After(now) {
|
||||||
|
t.Fatalf("weekly lastDue = %v (%v)", d, d.Weekday())
|
||||||
|
}
|
||||||
|
hourly := &Policy{Every: "hourly", At: "00:30", Enabled: true}
|
||||||
|
if d := hourly.lastDue(now); d.Minute() != 30 || d.Hour() != 8 {
|
||||||
|
t.Fatalf("hourly lastDue at 09:15 with minute 30 = %v, want 08:30", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRefusesNonsense(t *testing.T) {
|
||||||
|
ok := Policy{Dataset: "tank/apps", Every: "daily", At: "02:30", KeepFor: "6w"}
|
||||||
|
if err := ok.Validate(); err != nil {
|
||||||
|
t.Fatalf("a sane policy: %v", err)
|
||||||
|
}
|
||||||
|
bad := []Policy{
|
||||||
|
{Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"},
|
||||||
|
{Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"},
|
||||||
|
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
|
||||||
|
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
|
||||||
|
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
|
||||||
|
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
|
||||||
|
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
|
||||||
|
}
|
||||||
|
for i, p := range bad {
|
||||||
|
if err := p.Validate(); err == nil {
|
||||||
|
t.Errorf("bad policy %d was accepted: %+v", i, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAge(t *testing.T) {
|
||||||
|
for s, want := range map[string]time.Duration{
|
||||||
|
"6w": 6 * 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour,
|
||||||
|
"12h": 12 * time.Hour, "90m": 90 * time.Minute,
|
||||||
|
} {
|
||||||
|
if got, err := ParseAge(s); err != nil || got != want {
|
||||||
|
t.Errorf("ParseAge(%q) = %v, %v; want %v", s, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{"", "6", "w", "-1d", "0w", "6y", "six weeks"} {
|
||||||
|
if _, err := ParseAge(s); err == nil {
|
||||||
|
t.Errorf("ParseAge(%q) should fail", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,19 +41,18 @@ func (s *Session) UnmarshalJSON(b []byte) error {
|
|||||||
// UserState is what follows an account rather than the machine: how the console looks
|
// UserState is what follows an account rather than the machine: how the console looks
|
||||||
// to that person and how their dashboard is laid out.
|
// to that person and how their dashboard is laid out.
|
||||||
type UserState struct {
|
type UserState struct {
|
||||||
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Terminals []Terminal `json:"terminals"`
|
Terminals []Terminal `json:"terminals"`
|
||||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||||
Users map[string]*UserState `json:"users,omitempty"`
|
Users map[string]*UserState `json:"users,omitempty"`
|
||||||
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they
|
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
||||||
// move to the first account. Kept so an old state file still reads.
|
// account. Kept so an old state file still reads.
|
||||||
Dashboard json.RawMessage `json:"dashboard,omitempty"`
|
|
||||||
UI json.RawMessage `json:"ui,omitempty"`
|
UI json.RawMessage `json:"ui,omitempty"`
|
||||||
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
Alerts json.RawMessage `json:"alerts,omitempty"`
|
||||||
|
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go) // rules, channels and recent events (internal/api/alerts.go)
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { api, DashPrefs, Sample, Series, Temp } from './api'
|
|
||||||
import Sparkline from './Sparkline'
|
|
||||||
import { Loading, Spinner, askYes, toast } from './ui'
|
|
||||||
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
|
||||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
|
||||||
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
|
|
||||||
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
|
|
||||||
import WidgetPicker from './WidgetPicker'
|
|
||||||
|
|
||||||
const MAX = 300
|
|
||||||
const S1 = 'var(--s1)', S2 = 'var(--s2)'
|
|
||||||
|
|
||||||
// ---- module registry: the customizable blocks, in default order ----
|
|
||||||
type Kind = 'tile' | 'card'
|
|
||||||
interface Module { id: string; label: string; kind: Kind }
|
|
||||||
const MODULES: Module[] = [
|
|
||||||
{ id: 'cpu', label: 'CPU', kind: 'tile' },
|
|
||||||
{ id: 'mem', label: 'Memory', kind: 'tile' },
|
|
||||||
{ id: 'load', label: 'Load', kind: 'tile' },
|
|
||||||
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
|
|
||||||
{ id: 'temps', label: 'Temperatures', kind: 'card' },
|
|
||||||
{ id: 'fans', label: 'Fans', kind: 'card' },
|
|
||||||
{ id: 'net', label: 'Network', kind: 'card' },
|
|
||||||
{ id: 'disks', label: 'Disks', kind: 'card' },
|
|
||||||
{ id: 'fs', label: 'Filesystems', kind: 'card' },
|
|
||||||
{ id: 'ups', label: 'UPS', kind: 'card' },
|
|
||||||
]
|
|
||||||
const byId = (id: string) => MODULES.find((m) => m.id === id)
|
|
||||||
|
|
||||||
const DEFAULT_INTERVAL = 2000
|
|
||||||
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(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)
|
|
||||||
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
|
|
||||||
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
|
|
||||||
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
|
|
||||||
}
|
|
||||||
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
|
|
||||||
|
|
||||||
// ---- formatting ----
|
|
||||||
// A live value either starts a new point or is averaged into the one being built — the same
|
|
||||||
// rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a
|
|
||||||
// minute of 2-second samples crushed against the right edge. n is how many values this point
|
|
||||||
// has had, including this one.
|
|
||||||
function pushPoint(arr: number[], v: number, fresh: boolean, n: number) {
|
|
||||||
if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return }
|
|
||||||
arr.push(v)
|
|
||||||
if (arr.length > MAX) arr.shift()
|
|
||||||
}
|
|
||||||
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
|
|
||||||
|
|
||||||
export function fmtBytes(b: number): string {
|
|
||||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
||||||
let i = 0
|
|
||||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
|
||||||
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
|
||||||
}
|
|
||||||
export function fmtRate(bps: number): string {
|
|
||||||
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
|
|
||||||
let i = 0
|
|
||||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
|
|
||||||
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
|
|
||||||
}
|
|
||||||
function fmtUptime(s: number): string {
|
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
|
||||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
||||||
}
|
|
||||||
const pct = (v: number) => `${Math.round(v)}%`
|
|
||||||
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' { return frac >= bad ? 'bad' : frac >= warn ? 'warn' : '' }
|
|
||||||
|
|
||||||
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
|
|
||||||
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
|
|
||||||
const byChip = new Map<string, Temp[]>()
|
|
||||||
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
|
|
||||||
const out: (Temp & { key: string })[] = []
|
|
||||||
for (const [chip, list] of byChip) {
|
|
||||||
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
|
|
||||||
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
|
|
||||||
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- small building blocks ----
|
|
||||||
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
|
||||||
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
|
||||||
}
|
|
||||||
function Legend({ items }: { items: { name: string; color: string }[] }) {
|
|
||||||
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Edit {
|
|
||||||
on: boolean
|
|
||||||
hideItem: (key: string) => void
|
|
||||||
unhideItem: (key: string) => void
|
|
||||||
hiddenItems: Set<string>
|
|
||||||
}
|
|
||||||
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
|
|
||||||
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
|
|
||||||
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
|
|
||||||
}
|
|
||||||
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
|
|
||||||
if (!edit.on) return null
|
|
||||||
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
|
|
||||||
if (keys.length === 0) return null
|
|
||||||
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- module bodies ----
|
|
||||||
interface Ctx { s: Sample; h: Series | null; edit: Edit }
|
|
||||||
|
|
||||||
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
|
|
||||||
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
|
||||||
const shown = (key: string) => !edit.hiddenItems.has(key)
|
|
||||||
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
|
|
||||||
switch (id) {
|
|
||||||
case 'cpu':
|
|
||||||
return (
|
|
||||||
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
|
|
||||||
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
|
|
||||||
</Tile>
|
|
||||||
)
|
|
||||||
case 'mem':
|
|
||||||
return (
|
|
||||||
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
|
|
||||||
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
|
|
||||||
</Tile>
|
|
||||||
)
|
|
||||||
case 'load':
|
|
||||||
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
|
|
||||||
case 'uptime':
|
|
||||||
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
|
|
||||||
|
|
||||||
case 'temps': {
|
|
||||||
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Temperatures</h2>
|
|
||||||
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
|
|
||||||
{rows.map((t) => {
|
|
||||||
const limit = t.crit || t.max || 100
|
|
||||||
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
|
|
||||||
return (
|
|
||||||
<div className="row" key={t.key}>
|
|
||||||
<span className="k">{t.label}<small>{t.chip}</small></span>
|
|
||||||
<Meter frac={t.c / limit} cls={cls} />
|
|
||||||
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'fans': {
|
|
||||||
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Fans</h2>
|
|
||||||
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
|
|
||||||
{rows.map((f) => (
|
|
||||||
<div className="row" key={`${f.chip}/${f.label}`}>
|
|
||||||
<span className="k">{f.label}<small>{f.chip}</small></span>
|
|
||||||
<span />
|
|
||||||
<span className="num">{f.rpm} rpm<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'net':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Network</h2>
|
|
||||||
{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
|
|
||||||
<div className="sub-card" key={x.iface}>
|
|
||||||
<div className="sub-head">
|
|
||||||
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
|
||||||
<span>{x.iface}</span>
|
|
||||||
{!x.up && <span className="muted small">down</span>}
|
|
||||||
<span className="spacer" />
|
|
||||||
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
|
|
||||||
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
|
||||||
</div>
|
|
||||||
{x.up && h?.net[x.iface] && (
|
|
||||||
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
|
||||||
)}
|
|
||||||
{x.up && <div className="legend">
|
|
||||||
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
|
||||||
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
case 'disks':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Disks</h2>
|
|
||||||
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
|
|
||||||
{s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
|
|
||||||
<div className="sub-card" key={d.name}>
|
|
||||||
<div className="sub-head">
|
|
||||||
<span>{d.name}</span>
|
|
||||||
<span className="model">{d.model}</span>
|
|
||||||
<span className="spacer" />
|
|
||||||
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
|
|
||||||
<HideBtn k={`disk:${d.name}`} edit={edit} />
|
|
||||||
</div>
|
|
||||||
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
|
|
||||||
{h?.disk[d.name] && (
|
|
||||||
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
|
|
||||||
)}
|
|
||||||
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
case 'fs':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Filesystems</h2>
|
|
||||||
{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
|
|
||||||
<div className="row" key={f.mount}>
|
|
||||||
<span className="k">{f.mount}<small>{f.type}</small></span>
|
|
||||||
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
|
|
||||||
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
case 'ups':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>UPS</h2>
|
|
||||||
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
|
|
||||||
{s.ups && (
|
|
||||||
<>
|
|
||||||
<div className="sub-head">
|
|
||||||
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
|
|
||||||
<span>{s.ups.state} · {s.ups.source}</span>
|
|
||||||
<span className="spacer" />
|
|
||||||
<span className="model">{s.ups.model} · {s.ups.driver}</span>
|
|
||||||
</div>
|
|
||||||
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
|
|
||||||
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
|
|
||||||
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
|
|
||||||
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the dashboard ----
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [latest, setLatest] = useState<Sample | null>(null)
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const [layout, setLayout] = useState<Layout>(() => normalize({}))
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [loaded, setLoaded] = useState(false)
|
|
||||||
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
|
|
||||||
const savedTimer = useRef<number | undefined>(undefined)
|
|
||||||
const hist = useRef<Series | null>(null)
|
|
||||||
const saveTimer = useRef<number | undefined>(undefined)
|
|
||||||
const refreshMs = useRefreshMs()
|
|
||||||
const historySec = useHistorySec()
|
|
||||||
|
|
||||||
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
|
|
||||||
const update = useCallback((fn: (l: Layout) => Layout) => {
|
|
||||||
setLayout((prev) => {
|
|
||||||
const next = fn(prev)
|
|
||||||
clearTimeout(saveTimer.current)
|
|
||||||
setSaving('saving')
|
|
||||||
saveTimer.current = window.setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await api.saveDashPrefs(toPrefs(next))
|
|
||||||
setSaving('saved')
|
|
||||||
clearTimeout(savedTimer.current)
|
|
||||||
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
|
|
||||||
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
|
||||||
}, 300)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let stop = false
|
|
||||||
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
|
|
||||||
return () => { stop = true }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Everything on this page comes from the sensor API: the catalog for names, labels and
|
|
||||||
// thresholds; one batch of histories to seed the curves; one stream for the values.
|
|
||||||
const catalog = useRef<Catalog>(new Map())
|
|
||||||
const refetching = useRef(false)
|
|
||||||
const binN = useRef(1) // values averaged into the point currently being built
|
|
||||||
const lastPush = useRef(0) // when that point was started, by this browser's clock
|
|
||||||
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
|
|
||||||
const seed = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
|
|
||||||
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
|
|
||||||
lastPush.current = Date.now(); binN.current = 1
|
|
||||||
setErr('')
|
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
||||||
}, [])
|
|
||||||
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
|
|
||||||
useEffect(() => {
|
|
||||||
if (!stock.data || catalog.current.size === 0) return
|
|
||||||
const v: Values = mergeValues(stock.data)
|
|
||||||
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
|
|
||||||
refetching.current = true
|
|
||||||
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
|
|
||||||
}
|
|
||||||
const s = sampleOf(v, catalog.current)
|
|
||||||
const h = hist.current
|
|
||||||
if (h) {
|
|
||||||
// The stride is timed on this clock alone (lastPush), never against the history's own
|
|
||||||
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
|
|
||||||
// otherwise wait those minutes before its curves gained a point.
|
|
||||||
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
|
|
||||||
const fresh = np.fresh
|
|
||||||
lastPush.current = np.start
|
|
||||||
binN.current = fresh ? 1 : binN.current + 1
|
|
||||||
if (fresh) pushPoint(h.t, s.t, true, 1)
|
|
||||||
const n = h.t.length
|
|
||||||
// a curve that has no point in this one yet starts one, whatever the others are doing:
|
|
||||||
// an interface or disk that appeared mid-point must still line up with the time axis
|
|
||||||
const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current)
|
|
||||||
push(h.cpu, s.cpu); push(h.mem, s.mem.used)
|
|
||||||
for (const x of s.net) {
|
|
||||||
const c = (h.net[x.iface] ??= { rx: [], tx: [] })
|
|
||||||
pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
|
|
||||||
}
|
|
||||||
for (const d of s.disks) {
|
|
||||||
const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
|
|
||||||
pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
|
|
||||||
}
|
|
||||||
for (const t of s.temps) {
|
|
||||||
const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
|
|
||||||
pad(c, n - 1); push(c, t.c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setLatest(s)
|
|
||||||
}, [stock.data, historySec, refreshMs])
|
|
||||||
useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
|
|
||||||
|
|
||||||
// The sampler on this machine follows the global rate (clamped to what it supports).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loaded) return
|
|
||||||
const want = clampSampler(refreshMs)
|
|
||||||
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
|
|
||||||
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
|
|
||||||
const [picking, setPicking] = useState(false)
|
|
||||||
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
|
|
||||||
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
|
|
||||||
|
|
||||||
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
|
|
||||||
|
|
||||||
const edit: Edit = {
|
|
||||||
on: editing,
|
|
||||||
hiddenItems: layout.hiddenItems,
|
|
||||||
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
|
|
||||||
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
|
||||||
}
|
|
||||||
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
|
||||||
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
|
|
||||||
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
|
|
||||||
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
|
|
||||||
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
|
||||||
|
|
||||||
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
|
||||||
const move = (id: string, dir: -1 | 1) => update((l) => {
|
|
||||||
const kind = byId(id)!.kind
|
|
||||||
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
|
|
||||||
const i = same.indexOf(id), j = i + dir
|
|
||||||
if (j < 0 || j >= same.length) return l
|
|
||||||
const order = [...l.order]
|
|
||||||
const a = order.indexOf(id), b = order.indexOf(same[j])
|
|
||||||
;[order[a], order[b]] = [order[b], order[a]]
|
|
||||||
return { ...l, order }
|
|
||||||
})
|
|
||||||
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 = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
|
|
||||||
|
|
||||||
const tools = (id: string, kind: Kind) => {
|
|
||||||
if (!editing) return null
|
|
||||||
const same = visible(kind), i = same.indexOf(id)
|
|
||||||
return (
|
|
||||||
<div className="mod-tools">
|
|
||||||
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
|
|
||||||
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
|
|
||||||
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'dash' + (editing ? ' editing' : '')}>
|
|
||||||
<div className="dash-bar">
|
|
||||||
{err && <span className="error">{err}</span>}
|
|
||||||
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
|
|
||||||
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
|
|
||||||
<span className="spacer" />
|
|
||||||
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
|
|
||||||
{saving === 'saved' && <span className="saved">saved ✓</span>}
|
|
||||||
{editing && <button className="btn" onClick={reset}>Reset</button>}
|
|
||||||
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visible('tile').length > 0 && (
|
|
||||||
<div className="kpis">
|
|
||||||
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(layout.widgets.length > 0 || editing) && (
|
|
||||||
<div className="kpis widgets">
|
|
||||||
{layout.widgets.map((w, i) => (
|
|
||||||
<div className={'tile w-' + w.type} key={w.id}>
|
|
||||||
{editing && (
|
|
||||||
<div className="mod-tools">
|
|
||||||
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
|
|
||||||
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
|
|
||||||
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
|
|
||||||
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
|
|
||||||
</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}.</div>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
|
|
||||||
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
|
|
||||||
{visible('card').length > 0 && (
|
|
||||||
<div className="cards">
|
|
||||||
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing && hiddenMods.length > 0 && (
|
|
||||||
<div className="card">
|
|
||||||
<h2>Hidden modules</h2>
|
|
||||||
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+4
-93
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
|
import { api, SmartInfo, StorageDevice } from './api'
|
||||||
import { fmtBytes, fmtRate } from './Dashboard'
|
import { fmtBytes, fmtRate } from './fmt'
|
||||||
import TaskOutput from './TaskOutput'
|
import TaskOutput from './TaskOutput'
|
||||||
import { Btn, Loading, Spinner, askText, toast } from './ui'
|
import { askText, Btn, Loading, Meter, Spinner, toast } from './ui'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
import { Sensor, useSensorStream } from './sensors'
|
import { Sensor, useSensorStream } from './sensors'
|
||||||
|
|
||||||
@@ -11,10 +11,6 @@ const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb
|
|||||||
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
|
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
|
||||||
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
||||||
|
|
||||||
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
|
||||||
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
|
|
||||||
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- SMART panel ----
|
// ---- SMART panel ----
|
||||||
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
|
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
|
||||||
@@ -109,82 +105,9 @@ function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ZFS ----
|
|
||||||
function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) {
|
|
||||||
const scrubbing = /scrub in progress/i.test(p.scan ?? '')
|
|
||||||
const paused = /scrub paused/i.test(p.scan ?? '')
|
|
||||||
const [cfg, setCfg] = useState(false)
|
|
||||||
const act = (a: 'start' | 'pause' | 'stop') => async () => {
|
|
||||||
try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<div className="sub-head">
|
|
||||||
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
|
|
||||||
<span className="dname">{p.name}</span>
|
|
||||||
<span className="badge">{p.health}</span>
|
|
||||||
<span className="spacer" />
|
|
||||||
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
|
|
||||||
</div>
|
|
||||||
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
|
|
||||||
<div className="props" style={{ marginTop: 8 }}>
|
|
||||||
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
|
|
||||||
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
|
|
||||||
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
|
|
||||||
</div>
|
|
||||||
<div className="svc-actions">
|
|
||||||
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
|
|
||||||
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
|
|
||||||
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
|
|
||||||
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
|
|
||||||
</div>
|
|
||||||
{cfg && <pre className="unit-file">{p.config}</pre>}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
|
||||||
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
async function toggle() {
|
|
||||||
setOpen(!open)
|
|
||||||
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
|
|
||||||
}
|
|
||||||
async function snapshot() {
|
|
||||||
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
|
||||||
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
|
||||||
if (!name) return
|
|
||||||
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
|
||||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
||||||
}
|
|
||||||
const depth = d.name.split('/').length - 1
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
|
||||||
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
|
||||||
<span className="num">{fmtBytes(d.used)}</span>
|
|
||||||
<span className="num muted">{fmtBytes(d.refer)}</span>
|
|
||||||
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
|
||||||
<span className="num muted">{d.snapshots}</span>
|
|
||||||
<span className="num muted">{d.compressratio}</span>
|
|
||||||
<span className="ds-mount muted">{d.mount}</span>
|
|
||||||
</div>
|
|
||||||
{open && (
|
|
||||||
<div className="ds-snaps">
|
|
||||||
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
|
|
||||||
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
|
||||||
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- page ----
|
// ---- page ----
|
||||||
export default function Disks() {
|
export default function Disks() {
|
||||||
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
||||||
const [zfs, setZfs] = useState<ZFSInfo | null>(null)
|
|
||||||
const [hasSmart, setHasSmart] = useState(true)
|
const [hasSmart, setHasSmart] = useState(true)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [task, setTask] = useState<string | null>(null)
|
const [task, setTask] = useState<string | null>(null)
|
||||||
@@ -194,7 +117,7 @@ export default function Disks() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.storage()
|
const r = await api.storage()
|
||||||
setDevs(r.devices); setZfs(r.zfs); setHasSmart(r.smartctl); setErr('')
|
setDevs(r.devices); setHasSmart(r.smartctl); setErr('')
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
@@ -237,18 +160,6 @@ export default function Disks() {
|
|||||||
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
|
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dash-bar"><h2 className="section">ZFS</h2></div>
|
|
||||||
{zfs && !zfs.available && <div className="notice">{zfs.message || 'No ZFS pools on this host.'}</div>}
|
|
||||||
{zfs?.available && zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
|
||||||
{zfs?.available && zfs.pools.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
|
||||||
<section className="card datasets">
|
|
||||||
<div className="ds-row head"><span>Dataset</span><span className="num">Used</span><span className="num">Refer</span><span className="num">In snaps</span><span className="num">Snaps</span><span className="num">Ratio</span><span>Mount</span></div>
|
|
||||||
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
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 './fmt'
|
||||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ export default function Gallery() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash gallery">
|
<div className="dash gallery">
|
||||||
|
<div className="notice" style={{ marginBottom: 10 }}>
|
||||||
|
These widgets are for <b>dashboards outside Ham Lab</b> — Glance, Home Assistant, a wall
|
||||||
|
display. Each one is a page at <span className="mono">/embed/widget</span> you put in an
|
||||||
|
iframe, reading this machine through a <b>read-only sensor token</b> (Settings → Access
|
||||||
|
tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them
|
||||||
|
to something else.
|
||||||
|
</div>
|
||||||
<div className="dash-bar">
|
<div className="dash-bar">
|
||||||
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
||||||
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
||||||
|
|||||||
@@ -175,11 +175,11 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- page ----
|
// ---- page ----
|
||||||
export default function Cron() {
|
export default function Jobs() {
|
||||||
const [ov, setOv] = useState<CronOverview | null>(null)
|
const [ov, setOv] = useState<CronOverview | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [hashSel, setHashSel] = useHashSel('cron') // "job:<id>" or "timer:<unit>", in the URL
|
const [hashSel, setHashSel] = useHashSel('jobs') // "job:<id>" or "timer:<unit>", in the URL
|
||||||
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
||||||
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||||||
const [editing, setEditing] = useState<string | null>(null)
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
@@ -206,7 +206,7 @@ export default function Cron() {
|
|||||||
else pane = <div className="svc-empty muted">Select a job or timer.</div>
|
else pane = <div className="svc-empty muted">Select a job or timer.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
|
<div className={'cron jobs' + (sel || editing ? ' has-sel' : '')}>
|
||||||
<div className="cron-list">
|
<div className="cron-list">
|
||||||
<div className="svc-tools">
|
<div className="svc-tools">
|
||||||
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
||||||
@@ -218,7 +218,7 @@ export default function Cron() {
|
|||||||
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
|
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
|
||||||
<div className="cron-rows">
|
<div className="cron-rows">
|
||||||
{!ov && !err && <Loading label="Reading crontabs…" />}
|
{!ov && !err && <Loading label="Reading crontabs…" />}
|
||||||
{ov && <h3>Cron jobs · {jobs.length}</h3>}
|
{ov && <h3>Scheduled jobs · {jobs.length}</h3>}
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
|
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
|
||||||
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
|
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
|
||||||
+22
-17
@@ -4,19 +4,19 @@ import Rail, { Group } from './Rail'
|
|||||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||||
import TermTabs from './TermTabs'
|
import TermTabs from './TermTabs'
|
||||||
import KeyBar from './KeyBar'
|
import KeyBar from './KeyBar'
|
||||||
import Dashboard from './Dashboard'
|
|
||||||
import Gallery from './Gallery'
|
import Gallery from './Gallery'
|
||||||
import Docker from './Docker'
|
import Docker from './Docker'
|
||||||
import Alerts from './Alerts'
|
import Alerts from './Alerts'
|
||||||
import Services from './Services'
|
import Services from './Services'
|
||||||
import Cron from './Cron'
|
import Jobs from './Jobs'
|
||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
|
import ZFS from './ZFS'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
import Top from './Top'
|
import Top from './Top'
|
||||||
import Machines from './Machines'
|
import Machines from './Machines'
|
||||||
import Settings from './Settings'
|
import Settings from './Settings'
|
||||||
import Space from './Space'
|
import Usage from './Usage'
|
||||||
import Users from './Users'
|
import Users from './Users'
|
||||||
import MachineSwitcher from './MachineSwitcher'
|
import MachineSwitcher from './MachineSwitcher'
|
||||||
import { MachineStatus, setMachine } from './api'
|
import { MachineStatus, setMachine } from './api'
|
||||||
@@ -51,15 +51,15 @@ const BellIcon = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
|
|
||||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', icon: '◈' },
|
{ id: 'services', label: 'Services', icon: '◈' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
{ id: 'cron', label: 'Cron', icon: ClockIcon },
|
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||||
{ id: 'space', label: 'Space', icon: '▧' },
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
@@ -68,16 +68,21 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'dashboard' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||||
|
// Pages that were renamed, so a link or a bookmark made before it still lands where it meant.
|
||||||
|
const RENAMED_VIEWS: Record<string, View> = { space: 'usage', cron: 'jobs', top: 'procs', dashboard: 'procs' }
|
||||||
|
|
||||||
function parseHash(): { machine: string | null; view: View } | null {
|
function parseHash(): { machine: string | null; view: View } | null {
|
||||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
||||||
if (!m || !VIEWS.includes(m[2] as View)) return null
|
if (!m) return null
|
||||||
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
const view = VIEWS.includes(m[2] as View) ? (m[2] as View) : RENAMED_VIEWS[m[2]]
|
||||||
|
if (!view) return null
|
||||||
|
return { machine: m[1] === 'hub' ? null : m[1], view }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtUptime(s: number): string {
|
function fmtUptime(s: number): string {
|
||||||
@@ -102,7 +107,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const listRate = useRefreshMs(LIST_FLOOR)
|
const listRate = useRefreshMs(LIST_FLOOR)
|
||||||
const [terms, setTerms] = useState<Term[]>([])
|
const [terms, setTerms] = useState<Term[]>([])
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard')
|
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
||||||
const [drawer, setDrawer] = useState(false)
|
const [drawer, setDrawer] = useState(false)
|
||||||
const [online, setOnline] = useState(true)
|
const [online, setOnline] = useState(true)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -142,7 +147,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
||||||
// inside the machine change read the page we were leaving — so a link from Settings to a
|
// inside the machine change read the page we were leaving — so a link from Settings to a
|
||||||
// machine's Disks arrived at its Dashboard.
|
// machine's Disks arrived at its Dashboard.
|
||||||
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('dashboard') }, [machine, view])
|
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
||||||
|
|
||||||
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
||||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||||
@@ -264,17 +269,17 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<main className="main" key={machine ?? 'hub'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users me={hubMe} />}
|
{view === 'users' && <Users me={hubMe} />}
|
||||||
{view === 'space' && <Space key={machine ?? ''} />}
|
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
|
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||||
{view === 'dashboard' && <Dashboard />}
|
|
||||||
{view === 'widgets' && <Gallery />}
|
{view === 'widgets' && <Gallery />}
|
||||||
{view === 'docker' && <Docker />}
|
{view === 'docker' && <Docker />}
|
||||||
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
||||||
{view === 'procs' && <Top />}
|
{view === 'procs' && <Top />}
|
||||||
{view === 'services' && <Services />}
|
{view === 'services' && <Services />}
|
||||||
{view === 'cron' && <Cron />}
|
{view === 'jobs' && <Jobs />}
|
||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
{view === 'disks' && <Disks />}
|
{view === 'disks' && <Disks />}
|
||||||
|
{view === 'zfs' && <ZFS />}
|
||||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||||
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
||||||
@@ -301,7 +306,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+56
-15
@@ -250,6 +250,20 @@ export default function Top() {
|
|||||||
|
|
||||||
// the process table, and the CPU model, come from the same place the Processes page reads
|
// the process table, and the CPU model, come from the same place the Processes page reads
|
||||||
const [pt, setPt] = useState<ProcTable | null>(null)
|
const [pt, setPt] = useState<ProcTable | null>(null)
|
||||||
|
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
||||||
|
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
||||||
|
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
||||||
|
const [pools, setPools] = useState<Record<string, string>>({})
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
api.storage().then((r) => {
|
||||||
|
if (!live) return
|
||||||
|
const m: Record<string, string> = {}
|
||||||
|
for (const d of r.devices) for (const part of d.parts) if (part.fstype === 'zfs_member' && part.label) m[d.name] = part.label
|
||||||
|
setPools(m)
|
||||||
|
}).catch(() => { /* the rows simply say less */ })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [])
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stop = false
|
let stop = false
|
||||||
@@ -319,8 +333,12 @@ export default function Top() {
|
|||||||
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
|
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
|
||||||
const swap = s?.swap ?? pt?.swap
|
const swap = s?.swap ?? pt?.swap
|
||||||
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
|
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
|
||||||
const iface = net[0]
|
// the one that has actually carried traffic, not whichever the kernel listed first — wg0
|
||||||
const fs = s?.fs ?? []
|
// and a dormant wlan should not push the real NIC out of the way. Tapping a name switches.
|
||||||
|
const busiest = net.length ? net.reduce((a, b) => (b.rx_total + b.tx_total > a.rx_total + a.tx_total ? b : a)) : undefined
|
||||||
|
const iface = net.find((n) => n.iface === pickedIf) ?? busiest
|
||||||
|
// the disks box is this machine's own storage: a CIFS or NFS share is somebody else's disk
|
||||||
|
const fs = (s?.fs ?? []).filter((f) => !f.remote)
|
||||||
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
||||||
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
|
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
|
||||||
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
||||||
@@ -378,18 +396,26 @@ export default function Top() {
|
|||||||
|
|
||||||
<Box n={3} name="disks" className="top-disks" right="io">
|
<Box n={3} name="disks" className="top-disks" right="io">
|
||||||
<div className="t-disks">
|
<div className="t-disks">
|
||||||
{swap && swap.total > 0 && <Disk name="swap" size={swap.total} used={swap.used} />}
|
{(s?.disks ?? []).map((d) => {
|
||||||
{fs.map((f) => {
|
// what is on this drive: the filesystems the sampler could attribute to it. A ZFS
|
||||||
// which drive is behind a mount isn't in the sample, so only the root filesystem
|
// pool or an md array spans several drives, so nothing is attributed and the row
|
||||||
// gets an IO line, against the machine's largest drive — which is the one it is on
|
// shows what is true of the hardware — how busy it is, and how hot.
|
||||||
const d = f.mount === '/' ? [...(s?.disks ?? [])].sort((a, b) => b.size - a.size)[0] : undefined
|
const on = fs.filter((f) => f.disk === d.name)
|
||||||
return <Disk key={f.mount} name={f.mount === '/' ? 'root' : f.mount.split('/').filter(Boolean).pop() ?? f.mount} size={f.size} used={f.used} io={d ? d.util_pct : undefined} />
|
const used = on.reduce((a, f) => a + f.used, 0)
|
||||||
|
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
||||||
})}
|
})}
|
||||||
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>}
|
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</span> : undefined}>
|
<Box n={4} name="net" className="top-net" right={net.length ? (
|
||||||
|
<span className="t-ifs">
|
||||||
|
{net.map((n) => (
|
||||||
|
<button key={n.iface} className={'t-if' + (iface && n.iface === iface.iface ? ' on' : '')}
|
||||||
|
title={`${n.iface} — ${bytes(n.rx_total)} in, ${bytes(n.tx_total)} out`} onClick={() => setPickedIf(n.iface)}>{n.iface}</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : undefined}>
|
||||||
{iface ? (
|
{iface ? (
|
||||||
<div className="t-net">
|
<div className="t-net">
|
||||||
<div className="t-net-graphs">
|
<div className="t-net-graphs">
|
||||||
@@ -470,14 +496,29 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) {
|
// One physical drive. `used` is the bytes of the filesystems on it, left out when none can
|
||||||
const upct = size > 0 ? (used / size) * 100 : 0
|
// be attributed to it — a pool member's share of a pool is not a number worth inventing.
|
||||||
|
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
|
||||||
|
const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div className="t-disk">
|
<div className="t-disk">
|
||||||
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div>
|
<div className="t-row">
|
||||||
|
<b>{name}</b>
|
||||||
|
{note && <span className="t-dim t-model" title={note}>{note}</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
{temp !== undefined && temp > 0 && <span className="t-temp">{Math.round(temp)}°C</span>}
|
||||||
|
{bytes(size)}
|
||||||
|
</div>
|
||||||
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
|
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
|
||||||
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
|
{used !== undefined ? (
|
||||||
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
|
<>
|
||||||
|
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
|
||||||
|
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
|
||||||
|
{mounts && mounts.length > 0 && <div className="t-row t-sub t-dim t-mounts" title={mounts.join(' ')}>{mounts.join(' ')}</div>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="t-row t-sub t-dim">{pool ? `member of ZFS pool ${pool}` : 'nothing mounted from it'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Space: what is using a disk, WizTree-style. Pick a disk, scan it (a parallel walk of every
|
// Usage: what is using a disk, WizTree-style. (The tab was called Space until 2026-09-24;
|
||||||
|
// the Go package and the /api/space endpoints behind it still carry the old name.) Pick a disk, scan it (a parallel walk of every
|
||||||
// filesystem the disk backs, as root when the host allows), then read the result two ways:
|
// filesystem the disk backs, as root when the host allows), then read the result two ways:
|
||||||
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every
|
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every
|
||||||
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
|
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
|
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
|
||||||
import { fmtBytes } from './Dashboard'
|
import { fmtBytes } from './fmt'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||||
@@ -45,10 +46,10 @@ const ago = (iso?: string) => {
|
|||||||
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
|
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
|
||||||
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
||||||
|
|
||||||
export default function Space() {
|
export default function Usage() {
|
||||||
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
|
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [sel, setSel] = useHashSel('space')
|
const [sel, setSel] = useHashSel('usage')
|
||||||
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
||||||
const rate = useRefreshMs()
|
const rate = useRefreshMs()
|
||||||
|
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
// ZFS: pools, datasets, snapshots, and a schedule for taking and expiring them.
|
||||||
|
//
|
||||||
|
// Split out of the Disks tab on 2026-09-24. Disks is the hardware; this is the filesystem
|
||||||
|
// on top of it, which has enough of its own to say.
|
||||||
|
//
|
||||||
|
// On the schedule: `grid` has run zfSnap from root cron since before Ham Lab existed, and it
|
||||||
|
// works. So this reads and writes the same snapshot names — `2026-09-24_02.30.01--6w`, with
|
||||||
|
// the retention in the name — rather than inventing a second convention for the two tools to
|
||||||
|
// disagree over. An existing cron job is detected and shown, so the page tells the truth
|
||||||
|
// about the machine whether Ham Lab or something else is doing the work.
|
||||||
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSSnapshot } from './api'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { useRefreshMs } from './prefs'
|
||||||
|
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
||||||
|
|
||||||
|
const ago = (t?: string | number) => {
|
||||||
|
if (!t) return 'never'
|
||||||
|
const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000)
|
||||||
|
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`
|
||||||
|
}
|
||||||
|
const soon = (t?: string) => {
|
||||||
|
if (!t) return '—'
|
||||||
|
const s = (new Date(t).getTime() - Date.now()) / 1000
|
||||||
|
return s < 60 ? 'any moment' : s < 3600 ? `in ${Math.round(s / 60)} min` : s < 86400 ? `in ${Math.round(s / 3600)} h` : `in ${Math.round(s / 86400)} d`
|
||||||
|
}
|
||||||
|
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||||
|
const describe = (p: ZFSPolicy) => {
|
||||||
|
const when = p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
|
||||||
|
: p.every === 'daily' ? `every day at ${p.at}`
|
||||||
|
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
|
||||||
|
: `on day ${p.day} of the month at ${p.at}`
|
||||||
|
return `${when} · keep ${p.keep_for}${p.recursive ? ' · recursive' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pools and datasets (moved from the Disks tab) ----
|
||||||
|
function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) {
|
||||||
|
const scrubbing = /scrub in progress/i.test(p.scan ?? '')
|
||||||
|
const paused = /scrub paused/i.test(p.scan ?? '')
|
||||||
|
const [cfg, setCfg] = useState(false)
|
||||||
|
const act = (a: 'start' | 'pause' | 'stop') => async () => {
|
||||||
|
try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
|
||||||
|
<span className="dname">{p.name}</span>
|
||||||
|
<span className="badge">{p.health}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
|
||||||
|
</div>
|
||||||
|
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
|
||||||
|
<div className="props" style={{ marginTop: 8 }}>
|
||||||
|
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
|
||||||
|
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
|
||||||
|
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
|
||||||
|
</div>
|
||||||
|
<div className="svc-actions">
|
||||||
|
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
|
||||||
|
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
|
||||||
|
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
|
||||||
|
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
|
||||||
|
</div>
|
||||||
|
{cfg && <pre className="unit-file">{p.config}</pre>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
||||||
|
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
async function toggle() {
|
||||||
|
setOpen(!open)
|
||||||
|
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
|
||||||
|
}
|
||||||
|
async function snapshot() {
|
||||||
|
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
||||||
|
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
||||||
|
if (!name) return
|
||||||
|
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
const depth = d.name.split('/').length - 1
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
||||||
|
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
||||||
|
<span className="num">{fmtBytes(d.used)}</span>
|
||||||
|
<span className="num muted">{fmtBytes(d.refer)}</span>
|
||||||
|
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
||||||
|
<span className="num muted">{d.snapshots}</span>
|
||||||
|
<span className="num muted">{d.compressratio}</span>
|
||||||
|
<span className="ds-mount muted">{d.mount}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="ds-snaps">
|
||||||
|
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
|
||||||
|
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
||||||
|
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- schedules ----
|
||||||
|
|
||||||
|
function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) {
|
||||||
|
return (
|
||||||
|
<div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}>
|
||||||
|
<b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>:
|
||||||
|
<div className="mono small" style={{ margin: '4px 0', wordBreak: 'break-all' }}>{x.schedule} {x.command}</div>
|
||||||
|
<span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span>
|
||||||
|
{mine > 0
|
||||||
|
? <div className="warn" style={{ marginTop: 6 }}>A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.</div>
|
||||||
|
: <div className="muted small" style={{ marginTop: 6 }}>Ham Lab reads the snapshots it makes and shows when each expires. Add a schedule here only if you mean to replace it; edit this one on the Jobs tab.</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type ExternalJob = ZFSExternal
|
||||||
|
|
||||||
|
function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: string; onChanged: () => void; onEdit: () => void }) {
|
||||||
|
const [expired, setExpired] = useState<ZFSSnapshot[] | null>(null)
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const preview = async () => {
|
||||||
|
setBusy('preview')
|
||||||
|
try { setExpired((await api.zfsExpired(p.id)).expired) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||||
|
}
|
||||||
|
const run = async () => {
|
||||||
|
const n = expired?.length
|
||||||
|
const warn = n ? ` It will also destroy ${n} expired snapshot${n === 1 ? '' : 's'}.` : ''
|
||||||
|
if (!await askYes(`Take a snapshot of ${p.dataset} now?${warn}`, 'Run now')) return
|
||||||
|
setBusy('run')
|
||||||
|
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); onChanged() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||||
|
}
|
||||||
|
const remove = async () => {
|
||||||
|
if (!await askYes(`Remove the schedule "${p.name}"? The snapshots it already took are left exactly as they are.`, 'Remove')) return
|
||||||
|
try { await api.deleteZfsSchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
const toggle = async () => {
|
||||||
|
try { await api.saveZfsSchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'dot ' + (p.last_error ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||||
|
<span className="dname">{p.name || p.dataset}</span>
|
||||||
|
{!p.enabled && <span className="badge">paused</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props" style={{ marginTop: 6 }}>
|
||||||
|
<div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div>
|
||||||
|
<div><span className="k">When</span><span>{describe(p)}</span></div>
|
||||||
|
<div><span className="k">Last run</span><span>{ago(p.last_run)}{p.pruned ? ` · ${p.pruned} expired removed` : ''}</span></div>
|
||||||
|
{p.last_taken && <div><span className="k">Last snapshot</span><span className="mono small">{p.last_taken.split('@')[1]}</span></div>}
|
||||||
|
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
||||||
|
</div>
|
||||||
|
{p.last_error && <div className="error" style={{ marginTop: 8 }}>{p.last_error}</div>}
|
||||||
|
{expired && (
|
||||||
|
<div className="notice" style={{ marginTop: 8 }}>
|
||||||
|
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
|
||||||
|
<>
|
||||||
|
<b>{expired.length}</b> snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now:
|
||||||
|
<div className="snap-list" style={{ marginTop: 6, maxHeight: 180, overflow: 'auto' }}>
|
||||||
|
{expired.map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}</span></div>)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<Btn className="btn" busy={busy === 'preview'} onClick={preview}>What would it delete?</Btn>
|
||||||
|
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
|
||||||
|
<button className="btn" onClick={onEdit}>Edit</button>
|
||||||
|
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||||
|
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK: Partial<ZFSPolicy> = { name: '', dataset: '', recursive: true, every: 'daily', at: '02:30', weekday: 0, day: 1, keep_for: '6w', keep_min: 1, enabled: true }
|
||||||
|
|
||||||
|
function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolicy>; datasets: string[]; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const [f, setF] = useState<Partial<ZFSPolicy>>({ ...BLANK, ...p })
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEscape(onClose)
|
||||||
|
const set = (k: keyof ZFSPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr('')
|
||||||
|
try { await api.saveZfsSchedule({ ...f, name: (f.name || '').trim() || f.dataset }); onSaved() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<h3>{f.id ? 'Edit schedule' : 'New snapshot schedule'}</h3>
|
||||||
|
<label className="muted">Dataset
|
||||||
|
<select value={f.dataset} onChange={(e) => set('dataset', e.target.value)} required>
|
||||||
|
<option value="">choose…</option>
|
||||||
|
{datasets.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="muted">Name — what this schedule is for
|
||||||
|
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder={f.dataset || 'nightly'} maxLength={64} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">How often
|
||||||
|
<span className="chips">
|
||||||
|
{(['hourly', 'daily', 'weekly', 'monthly'] as const).map((k) => (
|
||||||
|
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="muted">At
|
||||||
|
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{f.every === 'weekly' && (
|
||||||
|
<label className="muted">On
|
||||||
|
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
|
||||||
|
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{f.every === 'monthly' && (
|
||||||
|
<label className="muted">Day of the month — 1 to 28, so every month has one
|
||||||
|
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h
|
||||||
|
<input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Always keep the newest — a floor, whatever their age
|
||||||
|
<input type="number" min={0} max={99} value={f.keep_min} onChange={(e) => set('keep_min', Number(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={!!f.recursive} onChange={(e) => set('recursive', e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Recursive — one atomic snapshot of this dataset and everything under it
|
||||||
|
</label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={!f.dataset || !f.keep_for}>Save</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- page ----
|
||||||
|
|
||||||
|
export default function ZFS() {
|
||||||
|
const [ov, setOv] = useState<ZFSOverview | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [editing, setEditing] = useState<Partial<ZFSPolicy> | null>(null)
|
||||||
|
const rate = useRefreshMs()
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setOv(await api.zfsOverview()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
useEffect(() => { const t = setInterval(load, Math.max(5000, rate * 10)); return () => clearInterval(t) }, [load, rate])
|
||||||
|
|
||||||
|
if (err && !ov) return <div className="dash"><div className="error">{err}</div></div>
|
||||||
|
if (!ov) return <div className="dash"><Loading label="Reading ZFS…" /></div>
|
||||||
|
const zfs: ZFSInfo = ov.zfs
|
||||||
|
const datasets = zfs.datasets.filter((d) => d.type !== 'volume').map((d) => d.name)
|
||||||
|
const mine = ov.policies.filter((x) => x.policy.enabled).length
|
||||||
|
|
||||||
|
if (!zfs.available) return <div className="dash"><div className="notice">{zfs.message || 'No ZFS on this host.'}</div></div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash">
|
||||||
|
<div className="dash-bar"><h2 className="section">Pools</h2></div>
|
||||||
|
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
||||||
|
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||||||
|
|
||||||
|
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
||||||
|
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||||
|
</div>
|
||||||
|
{ov.external && <ExternalNotice x={ov.external} mine={mine} />}
|
||||||
|
{ov.policies.length === 0 && !ov.external && <div className="notice">Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.</div>}
|
||||||
|
<div className="cards">{ov.policies.map(({ policy, next }) => (
|
||||||
|
<ScheduleCard key={policy.id} p={policy} next={next} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||||
|
))}</div>
|
||||||
|
|
||||||
|
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></div>
|
||||||
|
<section className="card ds-table">
|
||||||
|
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{editing && <ScheduleDialog p={editing} datasets={datasets.length ? datasets : zfs.pools.map((p) => p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
-4
@@ -70,7 +70,7 @@ export interface Temp { chip: string; label: string; c: number; max?: number; cr
|
|||||||
export interface Fan { chip: string; label: string; rpm: number }
|
export interface Fan { chip: string; label: string; rpm: number }
|
||||||
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
||||||
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||||
export interface FS { mount: string; type: string; size: number; used: number }
|
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
|
||||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||||
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
||||||
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
|
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
|
||||||
@@ -90,7 +90,6 @@ export interface Series {
|
|||||||
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
|
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
|
||||||
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
||||||
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
|
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
|
||||||
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
|
|
||||||
|
|
||||||
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
||||||
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
||||||
@@ -134,6 +133,10 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
|||||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||||
|
|
||||||
|
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly'; at: string; weekday: number; day: number; keep_for: string; keep_min: number; prefix?: string; enabled: boolean; last_run?: string; last_error?: string; last_taken?: string; pruned?: number }
|
||||||
|
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||||
|
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; next?: string }[]; external?: ZFSExternal | null }
|
||||||
|
|
||||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
|
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
|
||||||
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
||||||
|
|
||||||
@@ -180,6 +183,12 @@ export const api = {
|
|||||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||||
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||||||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
||||||
|
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||||
|
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||||
|
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||||
|
zfsExpired: (id: string) => req<{ expired: ZFSSnapshot[] }>('GET', `/api/zfs/schedules/${encodeURIComponent(id)}/expired`),
|
||||||
|
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||||
|
deleteZfsSnapshot: (name: string, recursive: boolean) => req<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }),
|
||||||
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
|
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
|
||||||
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
|
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
|
||||||
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
|
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
|
||||||
@@ -208,8 +217,6 @@ export const api = {
|
|||||||
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
|
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
|
||||||
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
|
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
|
||||||
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
|
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
|
||||||
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
|
||||||
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
|
||||||
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
|
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
|
||||||
users: () => req<User[]>('GET', '/api/users', undefined, true),
|
users: () => req<User[]>('GET', '/api/users', undefined, true),
|
||||||
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Sizes and rates, shown the way the rest of the console shows them. These lived in
|
||||||
|
// Dashboard.tsx until that page was retired (2026-09-23); Disks, Files and Space all use
|
||||||
|
// them, so they belong on their own.
|
||||||
|
|
||||||
|
// fmtBytes is binary — what a disk or a file actually occupies.
|
||||||
|
export function fmtBytes(b: number): string {
|
||||||
|
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||||
|
let i = 0
|
||||||
|
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
||||||
|
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtRate is decimal — how throughput is quoted everywhere else.
|
||||||
|
export function fmtRate(bps: number): string {
|
||||||
|
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
|
||||||
|
let i = 0
|
||||||
|
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
|
||||||
|
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
|
||||||
|
}
|
||||||
+18
-1
@@ -5,8 +5,25 @@ import { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
const KEY = 'hamlab.rail'
|
const KEY = 'hamlab.rail'
|
||||||
|
|
||||||
|
// Pages that were renamed, so an order saved before it still puts the page where it was put:
|
||||||
|
// Top became Processes, Space became Usage, Cron became Jobs. A page that was removed
|
||||||
|
// outright (Dashboard) is not here — orderBy drops ids that match no page.
|
||||||
|
const RENAMED: Record<string, string> = { top: 'procs', space: 'usage', cron: 'jobs' }
|
||||||
|
|
||||||
export function getRailOrder(): string[] {
|
export function getRailOrder(): string[] {
|
||||||
try { const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] }
|
let raw: unknown
|
||||||
|
try { raw = JSON.parse(localStorage.getItem(KEY) ?? '[]') } catch { return [] }
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const x of raw) {
|
||||||
|
if (typeof x !== 'string') continue
|
||||||
|
const id = RENAMED[x] ?? x
|
||||||
|
if (seen.has(id)) continue // a merged page keeps the earlier of the two slots
|
||||||
|
seen.add(id)
|
||||||
|
out.push(id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRailOrder(ids: string[], save = true) {
|
export function setRailOrder(ids: string[], save = true) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The stock Dashboard modules were written against one Sample object. They now read the
|
// The stock Dashboard modules were written against one Sample object. They now read the
|
||||||
// sensor API like everything else; this file is the seam — it rebuilds that object from
|
// sensor API like everything else; this file is the seam — it rebuilds that object from
|
||||||
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
|
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
|
||||||
import { Sample, Series } from './api'
|
import { Sample } from './api'
|
||||||
import { Sensor } from './sensors'
|
import { Sensor } from './sensors'
|
||||||
|
|
||||||
export type Values = Record<string, number | boolean | string>
|
export type Values = Record<string, number | boolean | string>
|
||||||
@@ -19,7 +19,6 @@ export function mergeValues(parts: unknown[]): Values {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
|
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
|
||||||
export const MONITOR_HISTORY = 'cpu.usage,mem.used,net.*.rx,net.*.tx,disk.*.read,disk.*.write,disk.*.util,temp.*'
|
|
||||||
|
|
||||||
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
|
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
|
||||||
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
|
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
|
||||||
@@ -52,7 +51,7 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
|||||||
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
||||||
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
||||||
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
||||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used') })),
|
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
|
||||||
ups: v['ups.battery'] === undefined ? null : {
|
ups: v['ups.battery'] === undefined ? null : {
|
||||||
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
||||||
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
||||||
@@ -63,18 +62,3 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
|||||||
|
|
||||||
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned
|
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned
|
||||||
// to it and zero-padded on the left where the sensor is younger than the ring.
|
// to it and zero-padded on the left where the sensor is younger than the ring.
|
||||||
export function seriesOf(hist: { id: string; t: number[]; v: number[] }[], cat: Catalog): Series {
|
|
||||||
const by = new Map(hist.map((h) => [h.id, h]))
|
|
||||||
const t = by.get('cpu.usage')?.t ?? []
|
|
||||||
const n = t.length
|
|
||||||
const fit = (id: string) => { const v = (by.get(id)?.v ?? []).slice(-n); return [...new Array(Math.max(0, n - v.length)).fill(0), ...v] as number[] }
|
|
||||||
const out: Series = { t: [...t], cpu: fit('cpu.usage'), mem: fit('mem.used'), net: {}, disk: {}, temp: {} }
|
|
||||||
for (const h of hist) {
|
|
||||||
const l = cat.get(h.id)?.labels
|
|
||||||
const stem = h.id.slice(0, h.id.lastIndexOf('.'))
|
|
||||||
if (h.id.startsWith('net.') && l?.iface) out.net[l.iface] ??= { rx: fit(stem + '.rx'), tx: fit(stem + '.tx') }
|
|
||||||
else if (h.id.startsWith('disk.') && l?.device) out.disk[l.device] ??= { r: fit(stem + '.read'), w: fit(stem + '.write'), u: fit(stem + '.util') }
|
|
||||||
else if (h.id.startsWith('temp.') && l?.chip !== undefined) out.temp[`${l.chip}/${l.label}`] = fit(h.id)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -720,3 +720,16 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
|
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
|
||||||
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
|
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* the Processes tab's drive rows and NIC switcher */
|
||||||
|
.t-disk .t-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 14ch; margin-left: 6px; }
|
||||||
|
.t-disk .t-temp { margin-right: 8px; color: var(--t-blue, var(--accent2)); }
|
||||||
|
.t-disk .t-mounts { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.t-ifs { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.t-ifs .t-if { padding: 0 4px; border-radius: 3px; color: var(--muted); }
|
||||||
|
.t-ifs .t-if:hover { color: var(--fg); background: var(--bg3); }
|
||||||
|
.t-ifs .t-if.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||||
|
|
||||||
|
/* ZFS: the schedule dialog's controls match the rest of the modal forms */
|
||||||
|
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
|
.modal .chips { margin-top: 2px; }
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; ti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A filled bar. Red past 95%, amber past 85%, unless the caller says otherwise.
|
||||||
|
export function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
||||||
|
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
|
||||||
|
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||||||
|
}
|
||||||
|
|
||||||
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
||||||
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user