Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+469
View File
@@ -0,0 +1,469 @@
package web
import (
"encoding/json"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"nasmon/internal/collect"
"nasmon/internal/config"
"nasmon/internal/sched"
"nasmon/internal/store"
"nasmon/internal/zfsadm"
)
type dashVM struct {
S sched.Status
Th config.Thresholds
CPUSpark template.HTML
MemSpark template.HTML
TempSpark template.HTML
MemPct float64
ARCPct float64
SwapPct float64
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
st := s.sch.Status()
cfg := s.cfg.Get()
samples := s.ring.Since(time.Now().Add(-24*time.Hour), 240)
cpu := make([]float64, len(samples))
mem := make([]float64, len(samples))
tmp := make([]float64, len(samples))
for i, p := range samples {
cpu[i] = p.CPU
if st.Mem.Total > 0 {
mem[i] = float64(p.MemUsed) * 100 / float64(st.Mem.Total)
}
tmp[i] = p.CPUTemp
}
vm := dashVM{
S: st, Th: cfg.Thresholds,
CPUSpark: sparkline(cpu, 100, "cpu"),
MemSpark: sparkline(mem, 100, "mem"),
TempSpark: sparkline(tmp, 100, "temp"),
}
if st.Mem.Total > 0 {
vm.MemPct = float64(st.Mem.Used) * 100 / float64(st.Mem.Total)
vm.ARCPct = float64(st.Mem.ARCSize) * 100 / float64(st.Mem.Total)
}
if st.Mem.SwapTotal > 0 {
vm.SwapPct = float64(st.Mem.SwapUsed) * 100 / float64(st.Mem.SwapTotal)
}
s.render(w, r, "dashboard", pageData{Title: "Dashboard", Data: vm, Alerts: len(st.Alerts), Flash: r.URL.Query().Get("msg")})
}
type diskVM struct {
D sched.DiskView
Th config.Thresholds
History []store.SmartPoint
HistoryRev []store.SmartPoint // newest first, capped
TempSpark template.HTML
Known bool
}
func (s *Server) disk(w http.ResponseWriter, r *http.Request) {
serial := r.PathValue("serial")
st := s.sch.Status()
var vm diskVM
found := false
for _, d := range st.Disks {
if d.Serial == serial {
vm.D, found = d, true
}
}
if !found {
// disk may be gone: show what we know from the last snapshot
if sm, _ := s.st.LastSmart(serial); sm != nil {
vm.D = sched.DiskView{Smart: sm}
vm.D.Serial, vm.D.Model, vm.D.Path = sm.Serial, sm.Model, sm.Device
found = true
}
}
if !found {
http.NotFound(w, r)
return
}
vm.Th = s.cfg.Get().Thresholds
vm.History, _ = s.st.SmartHistory(serial, time.Now().Add(-30*24*time.Hour))
temps := make([]float64, len(vm.History))
for i, p := range vm.History {
temps[i] = float64(p.TempC)
}
vm.TempSpark = sparkline(temps, 0, "temp")
for i := len(vm.History) - 1; i >= 0 && len(vm.HistoryRev) < 100; i-- {
vm.HistoryRev = append(vm.HistoryRev, vm.History[i])
}
if seen, _ := s.st.SeenDisks(); seen != nil {
for _, d := range seen {
if d.Serial == serial {
vm.Known = true
}
}
}
s.render(w, r, "disk", pageData{Title: "Disk " + vm.D.Model, Data: vm, Alerts: len(st.Alerts)})
}
func (s *Server) forgetDisk(w http.ResponseWriter, r *http.Request) {
_ = s.st.ForgetDisk(r.PathValue("serial"))
http.Redirect(w, r, "/?msg=Disk+forgotten", http.StatusSeeOther)
}
// ---- ZFS page + dataset management
// defaultOwner is pre-filled in the create form: uid/gid the Docker containers
// on this NAS run as, so a new dataset is writable for them right away.
const defaultOwner = "1000:1000"
type zfsVM struct {
S sched.Status
Th config.Thresholds
Datasets []datasetRow
Form createForm // last submitted values (re-rendered after an error)
}
type datasetRow struct {
collect.Dataset
CanDestroy bool
DomID string
}
type createForm struct {
Pool, Name, Owner, Compression, Quota string
}
func (s *Server) zfsView(st sched.Status, form createForm) zfsVM {
vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form}
if vm.Form.Owner == "" {
vm.Form.Owner = defaultOwner
}
for _, d := range st.Datasets {
row := datasetRow{Dataset: d, DomID: strings.NewReplacer("/", "-", ".", "_", ":", "_").Replace(d.Name)}
row.CanDestroy = strings.Contains(d.Name, "/") && d.Refer < zfsadm.EmptyRefer
for _, o := range st.Datasets {
if strings.HasPrefix(o.Name, d.Name+"/") {
row.CanDestroy = false
}
}
vm.Datasets = append(vm.Datasets, row)
}
return vm
}
func (s *Server) zfs(w http.ResponseWriter, r *http.Request) {
st := s.sch.Status()
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Flash: r.URL.Query().Get("msg")})
}
func (s *Server) zfsCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f := createForm{
Pool: strings.TrimSpace(r.FormValue("pool")),
Name: strings.Trim(strings.TrimSpace(r.FormValue("name")), "/"),
Owner: strings.TrimSpace(r.FormValue("owner")),
Compression: r.FormValue("compression"),
Quota: strings.ToUpper(strings.TrimSpace(r.FormValue("quota"))),
}
st := s.sch.Status()
fail := func(msg string) {
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, f), Alerts: len(st.Alerts), Error: "Not created: " + msg})
}
if !hasPool(st, f.Pool) {
fail("unknown pool " + f.Pool)
return
}
if err := zfsadm.ValidateRelName(f.Name); err != nil {
fail(err.Error())
return
}
full := f.Pool + "/" + f.Name
for _, d := range st.Datasets {
if d.Name == full {
fail(full + " already exists")
return
}
}
for _, err := range []error{zfsadm.ValidateOwner(f.Owner), zfsadm.ValidateCompression(f.Compression), zfsadm.ValidateSize(f.Quota)} {
if err != nil {
fail(err.Error())
return
}
}
err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Quota: f.Quota, Owner: f.Owner})
s.sch.RefreshStorage()
if err != nil {
log.Printf("zfs create %s: %v", full, err)
fail(err.Error())
return
}
log.Printf("dataset %s created (owner %s, compression %q, quota %q)", full, f.Owner, f.Compression, f.Quota)
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+full+" created")+"#"+url.PathEscape(f.Pool), http.StatusSeeOther)
}
func (s *Server) zfsDestroy(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
st := s.sch.Status()
fail := func(msg string) {
s.render(w, r, "zfs", pageData{Title: "ZFS", Data: s.zfsView(st, createForm{}), Alerts: len(st.Alerts), Error: "Not destroyed: " + msg})
}
if strings.TrimSpace(r.FormValue("confirm")) != name {
fail("type the dataset name to confirm")
return
}
eligible := false
for _, row := range s.zfsView(st, createForm{}).Datasets {
if row.Name == name {
eligible = row.CanDestroy
}
}
if !eligible {
fail(name + " is not an empty dataset")
return
}
err := s.admin.DestroyEmpty(name)
s.sch.RefreshStorage()
if err != nil {
log.Printf("zfs destroy %s: %v", name, err)
fail(err.Error())
return
}
log.Printf("dataset %s destroyed", name)
http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dataset "+name+" destroyed"), http.StatusSeeOther)
}
func hasPool(st sched.Status, name string) bool {
for _, p := range st.Pools {
if p.Name == name {
return true
}
}
return false
}
// ---- alerts
// resolvedWindow is how long resolved alerts stay in the default /alerts view.
const resolvedWindow = 7 * 24 * time.Hour
type alertsVM struct {
List []store.Alert
LastMailErr string
All bool
Hidden int // resolved alerts outside the window (only when !All)
}
func (s *Server) alerts(w http.ResponseWriter, r *http.Request) {
vm := alertsVM{All: r.URL.Query().Get("all") == "1"}
since := time.Time{}
if !vm.All {
since = time.Now().Add(-resolvedWindow)
vm.Hidden, _ = s.st.CountResolvedBefore(since)
}
vm.List, _ = s.st.RecentAlerts(500, since)
st := s.sch.Status()
vm.LastMailErr = st.LastMailErr
s.render(w, r, "alerts", pageData{Title: "Alerts", Data: vm, Alerts: len(st.Alerts), Flash: r.URL.Query().Get("msg")})
}
// alertAction handles ack / unack / delete for one alert and sends the browser
// back where it came from.
func (s *Server) alertAction(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
a, err := s.st.GetAlert(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
var msg string
switch r.PathValue("action") {
case "ack":
if a.ResolvedAt != nil {
http.Error(w, "alert is already resolved", http.StatusConflict)
return
}
err = s.st.AckAlert(id, time.Now())
msg = "Alert acknowledged"
log.Printf("alert acknowledged: %s — %s", a.Key, a.Title)
case "unack":
err = s.st.UnackAlert(id)
msg = "Alert shown again"
case "delete":
if a.ResolvedAt == nil {
http.Error(w, "only resolved alerts can be deleted", http.StatusConflict)
return
}
err = s.st.DeleteAlert(id)
msg = "Alert removed from the log"
default:
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.sch.RefreshAlerts()
if r.Header.Get("X-Requested-With") == "fetch" {
w.WriteHeader(http.StatusNoContent)
return
}
back := r.Header.Get("Referer")
if back == "" || !strings.HasPrefix(back, "/") && !strings.Contains(back, r.Host) {
back = "/alerts"
}
if u, err := url.Parse(back); err == nil {
q := u.Query()
q.Set("msg", msg)
u.RawQuery = q.Encode()
back = u.String()
}
http.Redirect(w, r, back, http.StatusSeeOther)
}
func (s *Server) smartRun(w http.ResponseWriter, r *http.Request) {
s.sch.RunSmartNow()
http.Redirect(w, r, "/?msg=SMART+check+started", http.StatusSeeOther)
}
// ---- settings
type settingsVM struct {
C config.Settings
HasPass bool
ToText string
}
func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
s.render(w, r, "settings", pageData{Title: "Settings", Data: settingsVM{C: c, HasPass: c.SMTP.Pass != "", ToText: strings.Join(c.SMTP.To, "\n")}})
}
func (s *Server) settingsPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
old := s.cfg.Get()
c := formToSettings(r, old)
pd := pageData{Title: "Settings"}
if err := s.cfg.Save(c); err != nil {
pd.Error = "Not saved: " + err.Error()
pd.Data = settingsVM{C: c, HasPass: c.SMTP.Pass != "", ToText: strings.Join(c.SMTP.To, "\n")}
s.render(w, r, "settings", pd)
return
}
s.sch.Reload()
pd.Flash = "Settings saved."
if r.FormValue("action") == "test" {
host := s.sch.Status().Host
body := "This is a test message from nasmon on " + host + ".\nIf you can read this, alert delivery works."
if err := s.send(c.SMTP, strings.TrimSpace(c.SMTP.SubjectPrefix+" test message from "+host), body); err != nil {
pd.Error = "Test mail failed: " + err.Error()
} else {
pd.Flash = "Settings saved, test mail sent to " + strings.Join(c.SMTP.To, ", ") + "."
}
}
saved := s.cfg.Get()
pd.Data = settingsVM{C: saved, HasPass: saved.SMTP.Pass != "", ToText: strings.Join(saved.SMTP.To, "\n")}
s.render(w, r, "settings", pd)
}
func formToSettings(r *http.Request, old config.Settings) config.Settings {
num := func(name string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(r.FormValue(name)))
if err != nil {
return def
}
return n
}
on := func(name string) bool { return r.FormValue(name) != "" }
c := old
c.SMTP.Host = strings.TrimSpace(r.FormValue("smtp_host"))
c.SMTP.Port = num("smtp_port", 587)
c.SMTP.Security = r.FormValue("smtp_security")
c.SMTP.User = strings.TrimSpace(r.FormValue("smtp_user"))
if p := r.FormValue("smtp_pass"); p != "" {
c.SMTP.Pass = p
}
if on("smtp_clear_pass") {
c.SMTP.Pass = ""
}
c.SMTP.From = strings.TrimSpace(r.FormValue("smtp_from"))
c.SMTP.SubjectPrefix = strings.TrimSpace(r.FormValue("smtp_prefix"))
c.SMTP.To = nil
for _, line := range strings.FieldsFunc(r.FormValue("smtp_to"), func(r rune) bool { return r == '\n' || r == ',' || r == ';' || r == ' ' }) {
if line = strings.TrimSpace(line); line != "" {
c.SMTP.To = append(c.SMTP.To, line)
}
}
c.AlertsEnabled = on("alerts_enabled")
c.NotifyResolved = on("notify_resolved")
c.RemindHours = num("remind_hours", old.RemindHours)
c.SmartIntervalMin = num("smart_interval", old.SmartIntervalMin)
c.SmartSkipStandby = on("smart_skip_standby")
c.Thresholds.DiskTempC = num("th_disk_temp", old.Thresholds.DiskTempC)
c.Thresholds.CPUTempC = num("th_cpu_temp", old.Thresholds.CPUTempC)
c.Thresholds.PoolCapacityPct = num("th_pool_cap", old.Thresholds.PoolCapacityPct)
c.Thresholds.ScrubMaxAgeDays = num("th_scrub_days", old.Thresholds.ScrubMaxAgeDays)
c.Thresholds.RootFSFreePct = num("th_rootfs_free", old.Thresholds.RootFSFreePct)
c.Thresholds.NVMeUsedPct = num("th_nvme_used", old.Thresholds.NVMeUsedPct)
c.Thresholds.NVMeSparePct = num("th_nvme_spare", old.Thresholds.NVMeSparePct)
c.SelfTests.Enabled = on("st_enabled")
c.SelfTests.ShortDays = num("st_short_days", old.SelfTests.ShortDays)
c.SelfTests.LongDays = num("st_long_days", old.SelfTests.LongDays)
c.SelfTests.Hour = num("st_hour", old.SelfTests.Hour)
return c
}
// ---- JSON API
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func (s *Server) apiStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.sch.Status())
}
func (s *Server) apiHistory(w http.ResponseWriter, r *http.Request) {
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
if hours <= 0 || hours > 24 {
hours = 24
}
writeJSON(w, s.ring.Since(time.Now().Add(-time.Duration(hours)*time.Hour), 1000))
}
func (s *Server) apiDiskHistory(w http.ResponseWriter, r *http.Request) {
days, _ := strconv.Atoi(r.URL.Query().Get("days"))
if days <= 0 || days > 365 {
days = 30
}
pts, err := s.st.SmartHistory(r.PathValue("serial"), time.Now().Add(-time.Duration(days)*24*time.Hour))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if pts == nil {
pts = []store.SmartPoint{}
}
writeJSON(w, pts)
}
+284
View File
@@ -0,0 +1,284 @@
// Package web serves the dashboard, settings form and JSON API.
package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"math"
"net/http"
"strings"
"sync"
"time"
"nasmon/internal/alert"
"nasmon/internal/config"
"nasmon/internal/history"
"nasmon/internal/hostrun"
"nasmon/internal/sched"
"nasmon/internal/shares"
"nasmon/internal/store"
"nasmon/internal/zfsadm"
)
//go:embed templates/*.html static/*
var assets embed.FS
type Server struct {
cfg *config.Store
st *store.Store
ring *history.Ring
sch *sched.Scheduler
send func(config.SMTP, string, string) error
admin *zfsadm.Admin
shares *shares.Store
shm *shares.Manager
pages map[string]*template.Template
mux *http.ServeMux
dirExists func(string) bool // share path check; replaced in tests
mu sync.Mutex
shareErr string // last failed Apply of the share config
}
func New(cfg *config.Store, st *store.Store, ring *history.Ring, sch *sched.Scheduler, sh *shares.Store) *Server {
run := hostrun.Auto()
s := &Server{cfg: cfg, st: st, ring: ring, sch: sch, send: alert.SendMail,
admin: zfsadm.New(zfsadm.NoInput(run)), shares: sh, shm: shares.New(run), dirExists: statDir, mux: http.NewServeMux()}
s.pages = map[string]*template.Template{}
layout := template.Must(template.New("layout").Funcs(funcs).ParseFS(assets, "templates/layout.html"))
for _, name := range []string{"dashboard", "disk", "zfs", "alerts", "settings", "shares"} {
t := template.Must(template.Must(layout.Clone()).ParseFS(assets, "templates/"+name+".html"))
s.pages[name] = t
}
static, _ := fs.Sub(assets, "static")
// embedded files carry no modtime, so tell browsers to re-fetch them:
// otherwise a stale app.js can survive an upgrade until a hard reload
files := http.StripPrefix("/static/", http.FileServer(http.FS(static)))
s.mux.Handle("GET /static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r)
}))
s.mux.HandleFunc("GET /{$}", s.dashboard)
s.mux.HandleFunc("GET /disks/{serial}", s.disk)
s.mux.HandleFunc("POST /disks/{serial}/forget", s.forgetDisk)
s.mux.HandleFunc("GET /zfs", s.zfs)
s.mux.HandleFunc("POST /zfs/create", s.zfsCreate)
s.mux.HandleFunc("POST /zfs/destroy", s.zfsDestroy)
s.mux.HandleFunc("GET /alerts", s.alerts)
s.mux.HandleFunc("POST /alerts/{id}/{action}", s.alertAction)
s.mux.HandleFunc("GET /shares", s.sharesPage)
s.mux.HandleFunc("POST /shares/apply", s.sharesApply)
s.mux.HandleFunc("POST /shares/smb/settings", s.smbSettings)
s.mux.HandleFunc("POST /shares/smb/save", s.smbSave)
s.mux.HandleFunc("POST /shares/smb/delete", s.smbDelete)
s.mux.HandleFunc("POST /shares/nfs/settings", s.nfsSettings)
s.mux.HandleFunc("POST /shares/nfs/save", s.nfsSave)
s.mux.HandleFunc("POST /shares/nfs/delete", s.nfsDelete)
s.mux.HandleFunc("POST /shares/users/save", s.userSave)
s.mux.HandleFunc("POST /shares/users/delete", s.userDelete)
s.mux.HandleFunc("GET /settings", s.settings)
s.mux.HandleFunc("POST /settings", s.settingsPost)
s.mux.HandleFunc("POST /smart/run", s.smartRun)
s.mux.HandleFunc("GET /api/status", s.apiStatus)
s.mux.HandleFunc("GET /api/history", s.apiHistory)
s.mux.HandleFunc("GET /api/disks/{serial}/history", s.apiDiskHistory)
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
type pageData struct {
Title string
Page string
Partial bool
Alerts int
Data any
Flash string
Error string
}
// render writes a full page, or only the "content" block when ?partial=1 is
// requested by the auto-refresh script.
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, pd pageData) {
pd.Page = name
pd.Partial = r.URL.Query().Get("partial") == "1"
if pd.Alerts == 0 {
pd.Alerts = len(s.sch.Status().Alerts)
}
t := s.pages[name]
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Alerts-Open", fmt.Sprint(pd.Alerts))
block := "layout"
if pd.Partial {
block = "content"
}
if err := t.ExecuteTemplate(w, block, pd); err != nil {
log.Printf("render %s: %v", name, err)
}
}
// ---- template helpers
var funcs = template.FuncMap{
"bytes": humanBytes,
"pct": func(part, total uint64) float64 {
if total == 0 {
return 0
}
return math.Round(float64(part)*1000/float64(total)) / 10
},
"f1": func(f float64) string { return fmt.Sprintf("%.1f", f) },
"f0": func(f float64) string { return fmt.Sprintf("%.0f", f) },
"ago": ago,
"agop": func(t *time.Time) string {
if t == nil {
return "never"
}
return ago(*t)
},
"capClass": func(pct, warn, crit int) string {
switch {
case pct >= crit:
return "crit"
case pct >= warn:
return "warn"
}
return "ok"
},
"dt": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Local().Format("2006-01-02 15:04")
},
"dtp": func(t *time.Time) string {
if t == nil {
return "—"
}
return t.Local().Format("2006-01-02 15:04")
},
"hours": func(h int64) string { return fmt.Sprintf("%d h (%.1f y)", h, float64(h)/8760) },
"dur": func(sec float64) string { return (time.Duration(sec) * time.Second).Truncate(time.Minute).String() },
"levelClass": func(pct float64, warn, crit float64) string {
switch {
case pct >= crit:
return "crit"
case pct >= warn:
return "warn"
}
return "ok"
},
"tempClass": func(v any, warn int) string {
c := toFloat(v)
switch {
case warn > 0 && c >= float64(warn):
return "crit"
case warn > 0 && c >= float64(warn)-8:
return "warn"
}
return "ok"
},
"healthClass": func(h string) string {
switch h {
case "ONLINE":
return "ok"
case "DEGRADED":
return "warn"
}
return "crit"
},
"unix": func(t int64) time.Time { return time.Unix(t, 0) },
"join": strings.Join,
"hasPrefix": strings.HasPrefix,
"contains": func(list []string, x string) bool {
for _, v := range list {
if v == x {
return true
}
}
return false
},
"repeat": strings.Repeat,
"lines": func(s string) []string { return strings.Split(s, "\n") },
"seq": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
},
}
func toFloat(v any) float64 {
switch n := v.(type) {
case int:
return float64(n)
case int64:
return float64(n)
case float64:
return n
}
return 0
}
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
func ago(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
}
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
// sparkline renders values as an inline SVG polyline. max<=0 means autoscale.
func sparkline(vals []float64, max float64, class string) template.HTML {
const w, h = 240.0, 40.0
if len(vals) < 2 {
return template.HTML(fmt.Sprintf(`<svg class="spark %s" viewBox="0 0 %d %d" preserveAspectRatio="none"></svg>`, class, int(w), int(h)))
}
if max <= 0 {
for _, v := range vals {
if v > max {
max = v
}
}
if max == 0 {
max = 1
}
max *= 1.1
}
var pts strings.Builder
for i, v := range vals {
x := w * float64(i) / float64(len(vals)-1)
y := h - h*math.Min(v, max)/max
fmt.Fprintf(&pts, "%.1f,%.1f ", x, y)
}
p := strings.TrimSpace(pts.String())
return template.HTML(fmt.Sprintf(
`<svg class="spark %s" viewBox="0 0 %d %d" preserveAspectRatio="none"><polygon points="0,%d %s %d,%d"/><polyline points="%s"/></svg>`,
class, int(w), int(h), int(h), p, int(w), int(h), p))
}
+372
View File
@@ -0,0 +1,372 @@
package web
import (
"log"
"net/http"
"net/url"
"os"
"sort"
"strings"
"nasmon/internal/collect"
"nasmon/internal/sched"
"nasmon/internal/shares"
)
// ---- /shares: SMB + NFS management
type smbForm struct {
Orig, Name, Path, Comment string
ReadOnly, Guest, Browseable, HideSys bool
Users []string
New bool
}
type nfsForm struct {
Orig, Path, Clients, Squash string
ReadOnly, Async bool
New bool
}
type sharesVM struct {
C shares.Config
Svc collect.ShareServices
Users []string
Mountpoints []string
Owner string
Host string
Form smbForm
NForm nfsForm
ApplyErr string // last Apply failure: config saved but not live
Squashes []string
}
func (s *Server) applyErr() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.shareErr
}
func (s *Server) setApplyErr(e string) {
s.mu.Lock()
s.shareErr = e
s.mu.Unlock()
}
// mountpoints lists dataset mountpoints — the only places shares may point to.
func mountpoints(st sched.Status) []string {
var out []string
for _, d := range st.Datasets {
if strings.HasPrefix(d.Mountpoint, "/") {
out = append(out, d.Mountpoint)
}
}
sort.Strings(out)
return out
}
func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) sharesVM {
c := s.shares.Get()
vm := sharesVM{C: c, Svc: st.Services, Mountpoints: mountpoints(st), Owner: s.shm.Owner, Host: st.Host,
Form: form, NForm: nform, ApplyErr: s.applyErr(), Squashes: []string{"root", "all", "none"}}
if vm.Form.Name == "" && vm.Form.Orig == "" {
vm.Form.New, vm.Form.Guest, vm.Form.Browseable, vm.Form.HideSys = true, true, true, true
}
if vm.NForm.Path == "" && vm.NForm.Orig == "" {
vm.NForm.New = true
if vm.NForm.Squash == "" {
vm.NForm.Squash = "root"
}
}
if u, err := s.shm.ListUsers(); err == nil {
vm.Users = u
} else {
log.Printf("shares: list users: %v", err)
}
return vm
}
func smbToForm(sh shares.SMBShare) smbForm {
return smbForm{Orig: sh.Name, Name: sh.Name, Path: sh.Path, Comment: sh.Comment, ReadOnly: sh.ReadOnly,
Guest: sh.Guest, Browseable: sh.Browseable, HideSys: sh.HideSysFiles, Users: sh.Users}
}
func nfsToForm(e shares.NFSExport) nfsForm {
return nfsForm{Orig: e.Path, Path: e.Path, Clients: strings.Join(e.Clients, ", "), Squash: e.Squash, ReadOnly: e.ReadOnly, Async: e.Async}
}
func (s *Server) sharesPage(w http.ResponseWriter, r *http.Request) {
st := s.sch.Status()
c := s.shares.Get()
q := r.URL.Query()
var form smbForm
var nform nfsForm
if i := c.FindSMB(q.Get("edit")); i >= 0 {
form = smbToForm(c.SMB.Shares[i])
}
if i := c.FindNFS(q.Get("nedit")); i >= 0 {
nform = nfsToForm(c.NFS.Exports[i])
}
if p := q.Get("path"); p != "" { // "share…" link from the ZFS page
if form.Orig == "" {
form.Path = p
form.Name = strings.Trim(p[strings.LastIndex(p, "/")+1:], "/")
}
if nform.Orig == "" {
nform.Path = p
}
}
s.render(w, r, "shares", pageData{Title: "Shares", Data: s.sharesView(st, form, nform), Alerts: len(st.Alerts), Flash: q.Get("msg")})
}
// saveAndApply persists the config and pushes it to the daemons. Returns the
// message for the user and whether everything went live.
func (s *Server) saveAndApply(c shares.Config) error {
if err := s.shares.Save(c); err != nil {
return err
}
if err := s.shm.Apply(c); err != nil {
s.setApplyErr(err.Error())
log.Printf("shares: apply failed: %v", err)
return nil
}
s.setApplyErr("")
s.sch.RefreshStorage()
return nil
}
func (s *Server) sharesFail(w http.ResponseWriter, r *http.Request, msg string, form smbForm, nform nfsForm) {
st := s.sch.Status()
s.render(w, r, "shares", pageData{Title: "Shares", Data: s.sharesView(st, form, nform), Alerts: len(st.Alerts), Error: msg})
}
func (s *Server) sharesDone(w http.ResponseWriter, r *http.Request, msg, anchor string) {
if e := s.applyErr(); e != "" {
msg += " — but applying failed: " + e
}
http.Redirect(w, r, "/shares?msg="+url.QueryEscape(msg)+anchor, http.StatusSeeOther)
}
// pathOK checks that a share path exists and lies inside a ZFS dataset.
func (s *Server) pathOK(st sched.Status, p string) error {
if err := shares.ValidatePath(p); err != nil {
return err
}
if !shares.UnderRoots(p, mountpoints(st)) {
return errPathOutside
}
if !s.dirExists(p) {
return errPathMissing
}
return nil
}
func statDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
type strErr string
func (e strErr) Error() string { return string(e) }
const (
errPathOutside = strErr("path must be inside a ZFS dataset (see the list of mountpoints)")
errPathMissing = strErr("path does not exist or is not a directory")
)
func (s *Server) smbSettings(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
c := s.shares.Get()
c.SMB.Enabled = r.FormValue("enabled") != ""
c.SMB.Workgroup = strings.ToUpper(strings.TrimSpace(r.FormValue("workgroup")))
c.SMB.ServerString = strings.TrimSpace(r.FormValue("server_string"))
if err := shares.ValidateComment(c.SMB.ServerString); err != nil {
s.sharesFail(w, r, "Not saved: server description: "+err.Error(), smbForm{}, nfsForm{})
return
}
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, nfsForm{})
return
}
s.sharesDone(w, r, "SMB settings saved", "#smb")
}
func (s *Server) smbSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f := smbForm{
Orig: strings.TrimSpace(r.FormValue("orig")), Name: strings.TrimSpace(r.FormValue("name")),
Path: strings.TrimSpace(r.FormValue("path")), Comment: strings.TrimSpace(r.FormValue("comment")),
ReadOnly: r.FormValue("read_only") != "", Guest: r.FormValue("guest") != "",
Browseable: r.FormValue("browseable") != "", HideSys: r.FormValue("hide_sys") != "",
Users: r.Form["users"],
}
if len(f.Path) > 1 {
f.Path = strings.TrimRight(f.Path, "/")
}
st := s.sch.Status()
sh := shares.SMBShare{Name: f.Name, Path: f.Path, Comment: f.Comment, ReadOnly: f.ReadOnly, Guest: f.Guest,
Users: f.Users, Browseable: f.Browseable, HideSysFiles: f.HideSys}
if sh.Guest {
sh.Users = nil
}
if err := sh.Validate(); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
if err := s.pathOK(st, sh.Path); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
c := s.shares.Get()
if i := c.FindSMB(f.Orig); f.Orig != "" && i >= 0 {
if sh.Name != f.Orig && c.FindSMB(sh.Name) >= 0 {
s.sharesFail(w, r, "Not saved: share "+sh.Name+" already exists", f, nfsForm{})
return
}
c.SMB.Shares[i] = sh
} else {
if c.FindSMB(sh.Name) >= 0 {
s.sharesFail(w, r, "Not saved: share "+sh.Name+" already exists — edit it instead", f, nfsForm{})
return
}
c.SMB.Shares = append(c.SMB.Shares, sh)
}
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), f, nfsForm{})
return
}
log.Printf("shares: SMB share %s -> %s saved (guest=%v users=%v ro=%v)", sh.Name, sh.Path, sh.Guest, sh.Users, sh.ReadOnly)
s.sharesDone(w, r, "Share "+sh.Name+" saved", "#smb")
}
func (s *Server) smbDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
c := s.shares.Get()
i := c.FindSMB(name)
if i < 0 {
s.sharesFail(w, r, "No such share: "+name, smbForm{}, nfsForm{})
return
}
c.SMB.Shares = append(c.SMB.Shares[:i], c.SMB.Shares[i+1:]...)
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not deleted: "+err.Error(), smbForm{}, nfsForm{})
return
}
log.Printf("shares: SMB share %s deleted", name)
s.sharesDone(w, r, "Share "+name+" deleted", "#smb")
}
func (s *Server) nfsSave(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f := nfsForm{
Orig: strings.TrimSpace(r.FormValue("orig")), Path: strings.TrimSpace(r.FormValue("path")),
Clients: strings.TrimSpace(r.FormValue("clients")), Squash: r.FormValue("squash"),
ReadOnly: r.FormValue("read_only") != "", Async: r.FormValue("async") != "",
}
if len(f.Path) > 1 {
f.Path = strings.TrimRight(f.Path, "/")
}
st := s.sch.Status()
e := shares.NFSExport{Path: f.Path, Clients: shares.SplitList(f.Clients), ReadOnly: f.ReadOnly, Squash: f.Squash, Async: f.Async}
if err := e.Validate(); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
if err := s.pathOK(st, e.Path); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
c := s.shares.Get()
if i := c.FindNFS(f.Orig); f.Orig != "" && i >= 0 {
if e.Path != f.Orig && c.FindNFS(e.Path) >= 0 {
s.sharesFail(w, r, "Not saved: "+e.Path+" is already exported", smbForm{}, f)
return
}
c.NFS.Exports[i] = e
} else {
if c.FindNFS(e.Path) >= 0 {
s.sharesFail(w, r, "Not saved: "+e.Path+" is already exported — edit it instead", smbForm{}, f)
return
}
c.NFS.Exports = append(c.NFS.Exports, e)
}
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, f)
return
}
log.Printf("shares: NFS export %s to %v saved (ro=%v squash=%s)", e.Path, e.Clients, e.ReadOnly, e.Squash)
s.sharesDone(w, r, "Export "+e.Path+" saved", "#nfs")
}
func (s *Server) nfsDelete(w http.ResponseWriter, r *http.Request) {
p := strings.TrimSpace(r.FormValue("path"))
c := s.shares.Get()
i := c.FindNFS(p)
if i < 0 {
s.sharesFail(w, r, "No such export: "+p, smbForm{}, nfsForm{})
return
}
c.NFS.Exports = append(c.NFS.Exports[:i], c.NFS.Exports[i+1:]...)
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not deleted: "+err.Error(), smbForm{}, nfsForm{})
return
}
log.Printf("shares: NFS export %s deleted", p)
s.sharesDone(w, r, "Export "+p+" deleted", "#nfs")
}
func (s *Server) nfsSettings(w http.ResponseWriter, r *http.Request) {
c := s.shares.Get()
c.NFS.Enabled = r.FormValue("enabled") != ""
if err := s.saveAndApply(c); err != nil {
s.sharesFail(w, r, "Not saved: "+err.Error(), smbForm{}, nfsForm{})
return
}
s.sharesDone(w, r, "NFS settings saved", "#nfs")
}
func (s *Server) userSave(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
pass := r.FormValue("password")
if err := s.shm.SetPassword(name, pass); err != nil {
s.sharesFail(w, r, "User not saved: "+err.Error(), smbForm{}, nfsForm{})
return
}
http.Redirect(w, r, "/shares?msg="+url.QueryEscape("Password set for "+name)+"#users", http.StatusSeeOther)
}
func (s *Server) userDelete(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
c := s.shares.Get()
for _, sh := range c.SMB.Shares {
for _, u := range sh.Users {
if u == name {
s.sharesFail(w, r, "User "+name+" is still allowed on share "+sh.Name+" — remove them there first", smbForm{}, nfsForm{})
return
}
}
}
if err := s.shm.DeleteUser(name); err != nil {
s.sharesFail(w, r, "User not deleted: "+err.Error(), smbForm{}, nfsForm{})
return
}
http.Redirect(w, r, "/shares?msg="+url.QueryEscape("User "+name+" deleted")+"#users", http.StatusSeeOther)
}
func (s *Server) sharesApply(w http.ResponseWriter, r *http.Request) {
if err := s.saveAndApply(s.shares.Get()); err != nil {
s.sharesFail(w, r, err.Error(), smbForm{}, nfsForm{})
return
}
s.sharesDone(w, r, "Configuration applied", "")
}
+212
View File
@@ -0,0 +1,212 @@
package web
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"testing"
"nasmon/internal/shares"
)
// shareRunner fakes the host runner for the shares manager.
type shareRunner struct {
calls []string
stdin map[string]string
fail string
users string
}
func (f *shareRunner) run(_ context.Context, stdin, name string, args ...string) (string, error) {
c := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, c)
if stdin != "" {
if f.stdin == nil {
f.stdin = map[string]string{}
}
f.stdin[c] = stdin
}
if f.fail != "" && strings.HasPrefix(c, f.fail) {
return "boom: " + f.fail, errors.New("boom: " + f.fail)
}
if strings.HasPrefix(c, "pdbedit -L") {
return f.users, nil
}
return "", nil
}
func (f *shareRunner) has(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func (f *shareRunner) written(path string) string {
for k, v := range f.stdin {
if strings.Contains(k, path) {
return v
}
}
return ""
}
func withShareRunner(srv *Server, f *shareRunner) {
srv.shm = shares.New(f.run)
srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil }
srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000"
}
func TestSharesSMBFlow(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &shareRunner{users: "alice:1001:\n"}
withShareRunner(srv, f)
// page lists users and mountpoints, prefills from ?path=
body := get(srv, "/shares?path=/tank/data").Body.String()
for _, want := range []string{`value="/tank/data"`, `value="data"`, `value="alice"`, "No SMB shares yet"} {
if !strings.Contains(body, want) {
t.Errorf("shares page missing %q", want)
}
}
// create a guest share (trailing slash is trimmed)
rec := postForm(srv, "/shares/smb/save", url.Values{"name": {"data"}, "path": {"/tank/data/"}, "comment": {"Stuff"}, "guest": {"1"}, "browseable": {"1"}, "hide_sys": {"1"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "Share+data+saved") {
t.Fatalf("create: %d %s %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
c := srv.shares.Get()
if len(c.SMB.Shares) != 1 || c.SMB.Shares[0].Path != "/tank/data" || !c.SMB.Shares[0].Guest {
t.Fatalf("saved config: %+v", c.SMB.Shares)
}
if !f.has("testparm -s") || !f.has("systemctl enable --now smbd nmbd") || !f.has("smbcontrol all reload-config") {
t.Errorf("apply calls: %v", f.calls)
}
conf := f.written(shares.DefaultSMBConf)
for _, want := range []string{"[data]", "path = /tank/data", "guest ok = yes", "force user = ansible", "veto files"} {
if !strings.Contains(conf, want) {
t.Errorf("smb.conf missing %q:\n%s", want, conf)
}
}
if !strings.Contains(get(srv, "/shares").Body.String(), "<b>data</b>") {
t.Errorf("share not listed")
}
// refused: duplicate name, path outside pools, non-guest without users, reserved name, unclean path
for _, form := range []url.Values{
{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}},
{"name": {"etc"}, "path": {"/etc"}, "guest": {"1"}},
{"name": {"priv"}, "path": {"/tank/data"}},
{"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}},
{"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}},
} {
rec = postForm(srv, "/shares/smb/save", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
t.Errorf("form %v accepted: %d", form, rec.Code)
}
}
if len(srv.shares.Get().SMB.Shares) != 1 {
t.Fatalf("invalid share stored")
}
// edit: rename + restrict to alice
rec = postForm(srv, "/shares/smb/save", url.Values{"orig": {"data"}, "name": {"private"}, "path": {"/tank/data"}, "users": {"alice"}, "read_only": {"1"}, "browseable": {"1"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("edit: %d %s", rec.Code, rec.Body.String())
}
c = srv.shares.Get()
if len(c.SMB.Shares) != 1 || c.SMB.Shares[0].Name != "private" || c.SMB.Shares[0].Guest || strings.Join(c.SMB.Shares[0].Users, ",") != "alice" || !c.SMB.Shares[0].ReadOnly {
t.Fatalf("edited share: %+v", c.SMB.Shares)
}
if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") {
t.Errorf("smb.conf not regenerated after edit")
}
// a user still referenced by a share cannot be deleted
rec = postForm(srv, "/shares/users/delete", url.Values{"name": {"alice"}})
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "still allowed on share private") || f.has("smbpasswd -x") {
t.Errorf("user deletion while in use: %d", rec.Code)
}
// add a user: password goes to smbpasswd on stdin
rec = postForm(srv, "/shares/users/save", url.Values{"name": {"bob"}, "password": {"hunter2"}})
if rec.Code != http.StatusSeeOther || f.stdin["smbpasswd -s -a bob"] != "hunter2\nhunter2\n" {
t.Errorf("add user: %d %v", rec.Code, f.stdin)
}
// delete the last share: smbd stopped
rec = postForm(srv, "/shares/smb/delete", url.Values{"name": {"private"}})
if rec.Code != http.StatusSeeOther || len(srv.shares.Get().SMB.Shares) != 0 {
t.Errorf("delete: %d", rec.Code)
}
if !f.has("systemctl disable --now smbd nmbd") {
t.Errorf("smbd not stopped after last share removed: %v", f.calls)
}
}
func TestSharesApplyFailureIsReported(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &shareRunner{fail: "testparm"}
withShareRunner(srv, f)
rec := postForm(srv, "/shares/smb/save", url.Values{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}, "browseable": {"1"}})
loc := rec.Header().Get("Location")
if rec.Code != http.StatusSeeOther || !strings.Contains(loc, "applying+failed") {
t.Fatalf("expected redirect with failure notice: %d %s", rec.Code, loc)
}
if len(srv.shares.Get().SMB.Shares) != 1 {
t.Errorf("config not saved")
}
body := get(srv, "/shares").Body.String()
if !strings.Contains(body, "not applied") || !strings.Contains(body, "/shares/apply") {
t.Errorf("pending state not shown")
}
// fix the runner and re-apply
f.fail = ""
rec = postForm(srv, "/shares/apply", nil)
if rec.Code != http.StatusSeeOther || strings.Contains(rec.Header().Get("Location"), "failed") {
t.Errorf("re-apply: %d %s", rec.Code, rec.Header().Get("Location"))
}
if strings.Contains(get(srv, "/shares").Body.String(), "not applied") {
t.Errorf("pending state still shown")
}
}
func TestSharesNFSFlow(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &shareRunner{}
withShareRunner(srv, f)
rec := postForm(srv, "/shares/nfs/save", url.Values{"path": {"/tank/data"}, "clients": {"192.168.101.0/24, pc.lan"}, "squash": {"all"}, "async": {"1"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
exp := f.written(shares.DefaultExports)
if !strings.Contains(exp, `"/tank/data" 192.168.101.0/24(rw,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(`) {
t.Errorf("exports:\n%s", exp)
}
if !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") {
t.Errorf("nfs apply calls: %v", f.calls)
}
for _, form := range []url.Values{
{"path": {"/tank/data"}, "clients": {"*"}, "squash": {"root"}}, // duplicate
{"path": {"/tank/data2"}, "clients": {""}, "squash": {"root"}}, // no clients
{"path": {"/tank/data2"}, "clients": {"10.0.0.0/33"}, "squash": {"root"}}, // bad cidr
{"path": {"/tank/data2"}, "clients": {"*"}, "squash": {"weird"}}, // bad squash
} {
if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
t.Errorf("form %v accepted", form)
}
}
body := get(srv, "/shares").Body.String()
if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") {
t.Errorf("export not listed")
}
body = get(srv, "/shares?nedit=/tank/data").Body.String()
if !strings.Contains(body, "Edit export /tank/data") || !strings.Contains(body, `value="192.168.101.0/24, pc.lan"`) {
t.Errorf("edit form not prefilled")
}
rec = postForm(srv, "/shares/nfs/delete", url.Values{"path": {"/tank/data"}})
if rec.Code != http.StatusSeeOther || len(srv.shares.Get().NFS.Exports) != 0 || !f.has("systemctl disable --now nfs-server") {
t.Errorf("delete: %d %v", rec.Code, f.calls)
}
}
+122
View File
@@ -0,0 +1,122 @@
:root {
--bg: #f4f6f9; --card: #ffffff; --fg: #1d2330; --muted: #6b7280; --line: #e3e7ee;
--accent: #2d7ff9; --ok: #16a34a; --warn: #d97706; --crit: #dc2626;
--ok-bg: #dcfce7; --warn-bg: #fef3c7; --crit-bg: #fee2e2; --badge-bg: #e5e7eb;
--mem: #2d7ff9; --arc: #8b5cf6;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1419; --card: #171d26; --fg: #e5e9f0; --muted: #8b95a5; --line: #263041;
--ok-bg: #14532d55; --warn-bg: #78350f55; --crit-bg: #7f1d1d55; --badge-bg: #2a3444;
}
}
* { box-sizing: border-box; }
html { color-scheme: light dark; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
header.top { display: flex; align-items: center; gap: 24px; padding: 10px 20px; background: var(--card); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
.brand { font-weight: 700; font-size: 16px; color: var(--fg); letter-spacing: .02em; }
nav { display: flex; gap: 4px; flex: 1; }
nav a { color: var(--muted); padding: 6px 10px; border-radius: 6px; }
nav a.on, nav a:hover { color: var(--fg); background: var(--bg); text-decoration: none; }
.refresh { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); opacity: .6; }
.refresh.stale { background: var(--warn); }
.refresh.paused { background: var(--muted); }
main { max-width: 1280px; margin: 0 auto; padding: 16px 20px 40px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 18px; margin-bottom: 16px; }
.card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 600; display: flex; align-items: baseline; gap: 10px; }
.card h2 .muted { font-weight: 400; font-size: 13px; }
.grid.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-bottom: 16px; }
.grid.metrics .card { margin: 0; }
.metric .big { font-size: 32px; font-weight: 600; line-height: 1.1; margin: 2px 0 4px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.ok { color: var(--ok); } .warn { color: var(--warn); } .crit { color: var(--crit); }
.crit-text { color: var(--crit); } .warn-text { color: var(--warn); }
.kv div { margin: 2px 0; }
.kv .muted { display: inline-block; min-width: 110px; }
.kv.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 20px; }
.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--badge-bg); color: var(--fg); vertical-align: middle; text-transform: uppercase; letter-spacing: .03em; }
.badge.ok { background: var(--ok-bg); color: var(--ok); }
.badge.warn { background: var(--warn-bg); color: var(--warn); }
.badge.crit { background: var(--crit-bg); color: var(--crit); }
.bar { position: relative; height: 10px; background: var(--badge-bg); border-radius: 5px; overflow: hidden; margin: 6px 0; }
.bar.small { display: inline-block; width: 80px; height: 8px; margin: 0; vertical-align: middle; }
.bar .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); }
.bar .fill.mem { background: var(--mem); }
.bar .fill.arc { background: var(--arc); }
.bar .fill.ok { background: var(--ok); } .bar .fill.warn { background: var(--warn); } .bar .fill.crit { background: var(--crit); }
.arc-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--arc); margin-right: 4px; }
svg.spark { display: block; width: 100%; height: 40px; margin-top: 8px; }
svg.spark polyline { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
svg.spark polygon { fill: var(--accent); opacity: .12; }
svg.spark.temp polyline { stroke: var(--warn); } svg.spark.temp polygon { fill: var(--warn); }
svg.spark.mem polyline { stroke: var(--mem); } svg.spark.mem polygon { fill: var(--mem); }
.tablewrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
tbody tr:last-child td { border-bottom: 0; }
table.compact th, table.compact td { padding: 3px 8px; }
tr.row-crit td { background: var(--crit-bg); }
details.sensors { margin-top: 6px; }
details.sensors summary { cursor: pointer; }
.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; background: var(--badge-bg); }
.flash.ok { background: var(--ok-bg); color: var(--ok); }
.flash.crit { background: var(--crit-bg); color: var(--crit); }
.alerts-open { border-color: var(--crit); }
.alert { padding: 8px 28px 8px 0; border-top: 1px solid var(--line); position: relative; }
.alert.acked .alert-title { color: var(--muted); }
.x-form { display: inline; }
.alert .x-form:not(.inline) { position: absolute; top: 6px; right: 0; }
button.x { padding: 0 6px; line-height: 20px; font-size: 18px; background: transparent; border: 0; color: var(--muted); }
button.x:hover { color: var(--crit); }
button.link { padding: 0; border: 0; background: none; color: var(--accent); font-size: inherit; }
button.link:hover { text-decoration: underline; }
.alert:first-of-type { border-top: 0; }
.alert-title { font-weight: 600; }
.alert.resolved .alert-title { color: var(--muted); font-weight: 500; }
.alert-detail { margin: 4px 0; font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; white-space: pre-wrap; color: var(--muted); }
button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
button.secondary { background: transparent; color: var(--accent); }
button:disabled { opacity: .5; cursor: default; }
form.inline { display: inline; margin-top: 8px; }
.actions { display: flex; gap: 10px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 10px 24px; }
.form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); }
.form-grid label.wide { grid-column: 1 / -1; }
.form-grid label.check { flex-direction: row; align-items: center; color: var(--fg); }
.form-grid .unit { display: flex; align-items: center; gap: 6px; color: var(--fg); }
.form-grid .unit input { width: 90px; }
input, select, textarea { font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
input[type=checkbox] { width: auto; margin-right: 6px; }
textarea { resize: vertical; }
/* dataset management (ZFS page) */
details.newds, details.destroy { margin-top: 8px; }
details.newds summary, details.destroy summary { cursor: pointer; }
details.newds summary { color: var(--accent); }
.ds-form { margin-top: 10px; }
.ds-form button { margin-top: 4px; }
.ds-destroy { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
button.danger { border-color: var(--crit); color: var(--crit); }
/* shares page */
.card h3 { margin: 18px 0 8px; font-size: 14px; font-weight: 600; }
.nowrap { white-space: nowrap; }
.form-grid .field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.form-grid .field-name { color: var(--muted); }
.checks { display: flex; flex-wrap: wrap; gap: 6px 16px; }
.checks .check-item { display: inline-flex; align-items: center; gap: 4px; color: var(--fg); font-size: 13px; }
+63
View File
@@ -0,0 +1,63 @@
// Auto-refresh: re-fetch the current page's content block every few seconds
// and swap it in. Rendering stays server-side; this keeps the UI live without
// duplicating templates in JS.
(function () {
var page = document.body.dataset.page;
var interval = { dashboard: 5000, zfs: 15000, alerts: 15000, disk: 30000 }[page];
if (!interval) return;
var main = document.getElementById('content');
var dot = document.getElementById('refresh-state');
var timer = null;
// A refresh replaces the whole content block, which would wipe a form the
// user is typing into: hold off while a form is focused or has been edited.
main.addEventListener('input', function (e) {
var f = e.target.closest('form');
if (f) f.classList.add('dirty');
});
// The ✕ / "show again" forms are single buttons, not something being typed
// into, so they never hold a refresh back.
function editing() {
if (main.querySelector('form.dirty:not(.x-form)')) return true;
var a = document.activeElement;
return !!(a && a.closest && a.closest('#content form:not(.x-form)'));
}
function tick() {
if (document.hidden || editing()) { dot.className = 'refresh paused'; return; }
var open = [].slice.call(main.querySelectorAll('details[open]')).map(function (d) { return d.className; });
var url = new URL(location.href);
url.searchParams.set('partial', '1');
url.searchParams.delete('msg');
var n = null;
fetch(url, { cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error(r.status);
n = r.headers.get('X-Alerts-Open');
return r.text();
}).then(function (html) {
main.innerHTML = html;
open.forEach(function (cls) {
var d = main.querySelector('details.' + cls.split(' ').join('.'));
if (d) d.open = true;
});
var nav = document.querySelector('nav a[href="/alerts"]');
if (nav && n !== null) nav.innerHTML = 'Alerts' + (n !== '0' ? ' <span class="badge crit">' + n + '</span>' : '');
dot.className = 'refresh';
}).catch(function () { dot.className = 'refresh stale'; });
}
// ✕ / "show again" forms: post in place and refresh the content block
document.addEventListener('submit', function (ev) {
var f = ev.target;
if (!f.classList.contains('x-form')) return;
ev.preventDefault();
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
fetch(f.action, { method: 'POST', headers: { 'X-Requested-With': 'fetch' } })
.then(function (r) { if (!r.ok) throw new Error(r.status); tick(); })
.catch(function () { f.submit(); });
});
function start() { if (!timer) timer = setInterval(tick, interval); }
document.addEventListener('visibilitychange', function () { if (!document.hidden) tick(); });
start();
})();
+34
View File
@@ -0,0 +1,34 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .LastMailErr}}<div class="flash crit">Last mail delivery failed: {{.LastMailErr}}</div>{{end}}
<section class="card">
<h2>Alert log <span class="muted small">{{if .All}}everything{{else}}open + resolved in the last 7 days{{end}}</span></h2>
{{if not .List}}<p class="muted">Nothing has happened yet.</p>{{end}}
{{range .List}}
<div class="alert {{if .ResolvedAt}}resolved{{else if .Acked}}acked{{else}}{{.Level}}{{end}}">
{{if .ResolvedAt}}
<form method="post" action="/alerts/{{.ID}}/delete" class="x-form"><button type="submit" class="x" title="Remove from the log">&times;</button></form>
{{else if not .Acked}}
<form method="post" action="/alerts/{{.ID}}/ack" class="x-form"><button type="submit" class="x" title="Acknowledge: hide until it changes or resolves">&times;</button></form>
{{end}}
<div class="alert-title">
{{if .ResolvedAt}}<span class="badge">resolved</span>{{else if .Acked}}<span class="badge">acked</span>{{else}}<span class="badge {{.Level}}">{{.Level}}</span>{{end}}
{{.Title}}
</div>
<pre class="alert-detail">{{.Detail}}</pre>
<div class="muted small">
opened {{dt .OpenedAt}}
{{if .ResolvedAt}} · resolved {{dtp .ResolvedAt}}{{end}}
{{if .Acked}} · acknowledged {{dtp .AckedAt}} · <form method="post" action="/alerts/{{.ID}}/unack" class="inline x-form"><button type="submit" class="link">show again</button></form>{{end}}
{{if .LastNotifiedAt}} · mailed {{dtp .LastNotifiedAt}}{{else if not .ResolvedAt}} · <span class="warn-text">not mailed</span>{{end}}
· <span class="mono">{{.Key}}</span>
</div>
</div>
{{end}}
{{if .All}}<p class="small"><a href="/alerts">Hide resolved alerts older than 7 days</a></p>
{{else if .Hidden}}<p class="small"><a href="/alerts?all=1">Show all ({{.Hidden}} older resolved hidden)</a></p>{{end}}
<p class="muted small">&times; on an open alert acknowledges it: it leaves the dashboard and stops e-mail reminders until the problem changes (higher level, grown counters) or resolves by itself. &times; on a resolved alert removes it from the log; resolved alerts older than 90 days are removed automatically.</p>
</section>
{{end}}
{{end}}
+117
View File
@@ -0,0 +1,117 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{template "alertlist" .S.Alerts}}
<section class="grid metrics">
<div class="card metric">
<h2>CPU <span class="muted">{{.S.CPU.Cores}} cores</span></h2>
<div class="big {{levelClass .S.CPU.UsagePct 70 90}}">{{f0 .S.CPU.UsagePct}}%</div>
<div class="muted">load {{f1 (index .S.CPU.Load 0)}} / {{f1 (index .S.CPU.Load 1)}} / {{f1 (index .S.CPU.Load 2)}}</div>
{{.CPUSpark}}
<div class="muted small">last 24 h</div>
</div>
<div class="card metric">
<h2>Memory <span class="muted">{{bytes .S.Mem.Total}}</span></h2>
<div class="big {{levelClass .MemPct 80 95}}">{{f0 .MemPct}}%</div>
<div class="bar">
<span class="fill mem" style="width:{{f1 .MemPct}}%"></span>
{{if .S.Mem.ARCSize}}<span class="fill arc" style="width:{{f1 .ARCPct}}%"></span>{{end}}
</div>
<div class="muted">used {{bytes .S.Mem.Used}}{{if .S.Mem.ARCSize}} · <span class="arc-dot"></span>ARC {{bytes .S.Mem.ARCSize}} / {{bytes .S.Mem.ARCMax}}{{end}}</div>
{{if .S.Mem.SwapTotal}}<div class="muted">swap {{bytes .S.Mem.SwapUsed}} / {{bytes .S.Mem.SwapTotal}} ({{f0 .SwapPct}}%)</div>{{end}}
{{.MemSpark}}
<div class="muted small">last 24 h</div>
</div>
<div class="card metric">
<h2>Temperature</h2>
{{if .S.HasCPUTemp}}<div class="big {{tempClass .S.CPUTemp .Th.CPUTempC}}">{{f0 .S.CPUTemp}}°C</div><div class="muted">CPU</div>{{else}}<div class="big muted">—</div><div class="muted">no CPU sensor</div>{{end}}
{{.TempSpark}}
<details class="sensors">
<summary class="muted small">{{len .S.Temps}} sensors</summary>
<table class="compact">
{{range .S.Temps}}<tr><td class="muted">{{.Chip}}</td><td>{{.Label}}</td><td class="num">{{f0 .C}}°C</td></tr>{{end}}
</table>
</details>
</div>
<div class="card metric">
<h2>System</h2>
<div class="kv">
<div><span class="muted">host</span> {{.S.Host}}</div>
<div><span class="muted">root fs</span> {{bytes .S.RootFS.Free}} free of {{bytes .S.RootFS.Total}}</div>
<div><span class="muted">SMART check</span> {{ago .S.LastSmartCheck}}{{if .S.SmartRunning}} <span class="badge">running</span>{{else}} · next {{dt .S.NextSmartCheck}}{{end}}</div>
<div><span class="muted">nasmon</span> {{.S.Version}} · up {{dur .S.Uptime}}</div>
{{if .S.LastMailErr}}<div class="crit-text">mail error: {{.S.LastMailErr}}</div>{{end}}
</div>
<form method="post" action="/smart/run" class="inline"><button type="submit" {{if .S.SmartRunning}}disabled{{end}}>Check SMART now</button></form>
</div>
</section>
<section class="card">
<h2>Disks</h2>
{{if .S.DisksErr}}<div class="flash crit">{{.S.DisksErr}}</div>{{end}}
<div class="tablewrap"><table>
<thead><tr><th>Device</th><th>Model</th><th>Serial</th><th class="num">Size</th><th>Bus</th><th class="num">Temp</th><th>Health</th><th class="num">Power-on</th><th>Self-test</th><th>Warnings</th></tr></thead>
<tbody>
{{range .S.Disks}}{{$sm := .Smart}}
<tr>
<td><a href="/disks/{{.Serial}}">{{.Name}}</a></td>
<td>{{.Model}}</td>
<td class="mono small">{{.Serial}}</td>
<td class="num">{{bytes .Size}}</td>
<td class="muted">{{.Tran}}{{if not .Rota}} ssd{{end}}</td>
{{if $sm}}
<td class="num">{{if $sm.HasTemp}}<span class="{{tempClass $sm.TempC $.Data.Th.DiskTempC}}">{{$sm.TempC}}°C</span>{{if .Standby}} <span class="muted small" title="disk in standby, value from last read">zz</span>{{end}}{{else}}—{{end}}</td>
<td>{{if .Error}}<span class="badge warn" title="{{.Error}}">n/a</span>{{else if not $sm.Supported}}<span class="muted">n/a</span>{{else if $sm.Healthy}}<span class="badge ok">PASSED</span>{{else}}<span class="badge crit">FAILED</span>{{end}}</td>
<td class="num">{{if $sm.PowerOnHrs}}{{$sm.PowerOnHrs}} h{{else}}—{{end}}</td>
<td class="small">{{with $sm.LastTest}}{{if .InProgress}}<span class="badge">{{.Remaining}}% left</span>{{else}}<span class="{{if .Passed}}ok{{else}}crit{{end}}">{{.Status}}</span>{{end}}{{else}}—{{end}}</td>
<td class="small">
{{range $sm.Attrs}}{{if and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}<span class="badge crit" title="{{.Name}}">{{.ID}}: {{.Raw}}</span> {{end}}{{if .WhenFailed}}<span class="badge crit">{{.ID}} {{.WhenFailed}}</span> {{end}}{{end}}
{{with $sm.NVMe}}{{if .CriticalWarning}}<span class="badge crit">warn 0x{{printf "%02x" .CriticalWarning}}</span> {{end}}{{if .MediaErrors}}<span class="badge crit">media err {{.MediaErrors}}</span> {{end}}<span class="muted">{{.PercentageUsed}}% used</span>{{end}}
</td>
{{else}}
<td class="num">—</td><td>{{if .Standby}}<span class="muted">standby</span>{{else if .Error}}<span class="badge warn" title="{{.Error}}">n/a</span>{{else}}<span class="muted">pending</span>{{end}}</td><td></td><td></td><td></td>
{{end}}
</tr>
{{else}}<tr><td colspan="10" class="muted">no disks found</td></tr>{{end}}
</tbody>
</table></div>
</section>
<section class="card">
<h2>ZFS pools <a class="muted small" href="/zfs">details</a></h2>
{{if .S.PoolsErr}}<div class="flash crit">{{.S.PoolsErr}}</div>{{end}}
<div class="tablewrap"><table>
<thead><tr><th>Pool</th><th>Health</th><th class="num">Used</th><th class="num">Free</th><th class="num">Size</th><th>Capacity</th><th class="num">Frag</th><th>Last scrub</th><th>Errors</th></tr></thead>
<tbody>
{{range .S.Pools}}
<tr>
<td><a href="/zfs#{{.Name}}">{{.Name}}</a></td>
<td><span class="badge {{healthClass .Health}}">{{.Health}}</span></td>
<td class="num">{{bytes .Alloc}}</td>
<td class="num">{{bytes .Free}}</td>
<td class="num">{{bytes .Size}}</td>
<td><div class="bar small"><span class="fill {{capClass .CapPct 75 90}}" style="width:{{.CapPct}}%"></span></div> <span class="small">{{.CapPct}}%</span></td>
<td class="num">{{.FragPct}}%</td>
<td class="small">{{if .Scrubbing}}<span class="badge">scrubbing</span>{{else if .LastScrub}}{{agop .LastScrub}}{{else}}<span class="muted">never</span>{{end}}</td>
<td class="small">{{if .Errors}}{{.Errors}}{{end}}</td>
</tr>
{{else}}<tr><td colspan="9" class="muted">no pools</td></tr>{{end}}
</tbody>
</table></div>
</section>
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
<thead><tr><th>Dataset</th><th>Mountpoint</th><th class="num">Used</th><th class="num">Referenced</th><th class="num">Available</th></tr></thead>
<tbody>
{{range .S.Datasets}}<tr><td class="mono">{{.Name}}</td><td class="muted mono small">{{.Mountpoint}}</td><td class="num">{{bytes .Used}}</td><td class="num">{{bytes .Refer}}</td><td class="num">{{bytes .Avail}}</td></tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}{{$sm := .D.Smart}}
<section class="card">
<h2>{{.D.Model}} <span class="muted">{{.D.Serial}}</span></h2>
<div class="kv cols">
<div><span class="muted">device</span> {{.D.Path}}</div>
{{if .D.Size}}<div><span class="muted">size</span> {{bytes .D.Size}}</div>{{end}}
<div><span class="muted">bus</span> {{.D.Tran}}{{if .D.Rota}} (rotational){{else}} (ssd){{end}}</div>
{{if $sm}}
<div><span class="muted">protocol</span> {{$sm.Protocol}}</div>
<div><span class="muted">firmware</span> {{$sm.Firmware}}</div>
<div><span class="muted">health</span> {{if not $sm.Supported}}<span class="muted">not supported</span>{{else if $sm.Healthy}}<span class="badge ok">PASSED</span>{{else}}<span class="badge crit">FAILED</span>{{end}}</div>
<div><span class="muted">temperature</span> {{if $sm.HasTemp}}<span class="{{tempClass $sm.TempC $.Data.Th.DiskTempC}}">{{$sm.TempC}}°C</span>{{else}}—{{end}}{{if .D.Standby}} <span class="muted">(standby, last read)</span>{{end}}</div>
<div><span class="muted">power-on</span> {{hours $sm.PowerOnHrs}}</div>
<div><span class="muted">power cycles</span> {{$sm.PowerCycles}}</div>
<div><span class="muted">last SMART read</span> {{dt $sm.CheckedAt}}</div>
{{with $sm.LastTest}}<div><span class="muted">self-test</span> {{if .InProgress}}<span class="badge">in progress, {{.Remaining}}% left</span>{{else}}{{.Type}}: <span class="{{if .Passed}}ok{{else}}crit{{end}}">{{.Status}}</span> <span class="muted">at {{.Hours}} h</span>{{end}}</div>{{end}}
{{if $sm.Error}}<div class="crit-text">{{$sm.Error}}</div>{{end}}
{{end}}
</div>
{{if .Known}}<form method="post" action="/disks/{{.D.Serial}}/forget" class="inline" onsubmit="return confirm('Forget this disk? It will no longer trigger a \'disk disappeared\' alert.')"><button type="submit" class="secondary">Forget disk</button></form>{{end}}
</section>
{{if $sm}}
{{with $sm.NVMe}}
<section class="card">
<h2>NVMe health</h2>
<div class="kv cols">
<div><span class="muted">critical warning</span> {{if .CriticalWarning}}<span class="crit">0x{{printf "%02x" .CriticalWarning}}</span>{{else}}<span class="ok">none</span>{{end}}</div>
<div><span class="muted">percentage used</span> {{.PercentageUsed}}%</div>
<div><span class="muted">available spare</span> {{.AvailableSpare}}% <span class="muted">(threshold {{.SpareThreshold}}%)</span></div>
<div><span class="muted">media errors</span> {{if .MediaErrors}}<span class="crit">{{.MediaErrors}}</span>{{else}}0{{end}}</div>
<div><span class="muted">error log entries</span> {{.ErrLogEntries}}</div>
<div><span class="muted">unsafe shutdowns</span> {{.UnsafeShutdowns}}</div>
</div>
</section>
{{end}}
<section class="card">
<h2>Temperature, last 30 days <span class="muted small">{{len .History}} snapshots</span></h2>
{{.TempSpark}}
</section>
{{if $sm.Attrs}}
<section class="card">
<h2>SMART attributes</h2>
<div class="tablewrap"><table>
<thead><tr><th class="num">ID</th><th>Name</th><th class="num">Value</th><th class="num">Worst</th><th class="num">Thresh</th><th class="num">Raw</th><th>Failed</th></tr></thead>
<tbody>
{{range $sm.Attrs}}
{{$crit := and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}
<tr class="{{if or $crit .WhenFailed}}row-crit{{end}}">
<td class="num">{{.ID}}</td><td class="mono">{{.Name}}</td>
<td class="num">{{.Value}}</td><td class="num">{{.Worst}}</td><td class="num">{{.Thresh}}</td>
<td class="num">{{.RawString}}</td>
<td>{{if .WhenFailed}}<span class="badge crit">{{.WhenFailed}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{if .History}}
<section class="card">
<h2>History</h2>
<div class="tablewrap"><table class="compact">
<thead><tr><th>Time</th><th class="num">Temp</th><th>Health</th><th class="num">Hours</th><th class="num">{{if $sm.NVMe}}Media err{{else}}Realloc (5){{end}}</th><th class="num">Pending (197)</th><th class="num">CRC (199)</th></tr></thead>
<tbody>
{{range .HistoryRev}}<tr><td class="small">{{dt (unix .T)}}</td><td class="num">{{.TempC}}°C</td><td>{{if .Healthy}}<span class="ok">ok</span>{{else}}<span class="crit">FAIL</span>{{end}}</td><td class="num">{{.Hours}}</td><td class="num">{{.Realloc}}</td><td class="num">{{.Pending}}</td><td class="num">{{.CRC}}</td></tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{else}}
<section class="card"><p class="muted">No SMART data yet{{if .D.Standby}} — disk is in standby, waiting for it to wake up{{end}}.</p></section>
{{end}}
{{end}}
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · nasmon</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232d7ff9'/%3E%3C/svg%3E">
</head>
<body data-page="{{.Page}}">
<header class="top">
<a class="brand" href="/">nasmon</a>
<nav>
<a href="/" {{if eq .Page "dashboard"}}class="on"{{end}}>Dashboard</a>
<a href="/zfs" {{if eq .Page "zfs"}}class="on"{{end}}>ZFS</a>
<a href="/shares" {{if eq .Page "shares"}}class="on"{{end}}>Shares</a>
<a href="/alerts" {{if eq .Page "alerts"}}class="on"{{end}}>Alerts{{if .Alerts}} <span class="badge crit">{{.Alerts}}</span>{{end}}</a>
<a href="/settings" {{if eq .Page "settings"}}class="on"{{end}}>Settings</a>
</nav>
<span class="refresh" id="refresh-state" title="auto-refresh"></span>
</header>
<main id="content">{{template "content" .}}</main>
<script src="/static/app.js"></script>
</body>
</html>{{end}}
{{define "flash"}}{{if .Flash}}<div class="flash ok">{{.Flash}}</div>{{end}}{{if .Error}}<div class="flash crit">{{.Error}}</div>{{end}}{{end}}
{{define "alertlist"}}{{if .}}
<section class="card alerts-open">
<h2>Open alerts</h2>
{{range .}}<div class="alert {{.Level}}">
<form method="post" action="/alerts/{{.ID}}/ack" class="x-form"><button type="submit" class="x" title="Acknowledge: hide until it changes or resolves">&times;</button></form>
<div class="alert-title"><span class="badge {{.Level}}">{{.Level}}</span> {{.Title}}</div>
<pre class="alert-detail">{{.Detail}}</pre>
<div class="muted">since {{dt .OpenedAt}}</div>
</div>{{end}}
</section>{{end}}{{end}}
+76
View File
@@ -0,0 +1,76 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}{{$c := .C}}
<form method="post" action="/settings" class="settings">
<section class="card">
<h2>E-mail (SMTP)</h2>
<div class="form-grid">
<label>Server <input name="smtp_host" value="{{$c.SMTP.Host}}" placeholder="smtp.yandex.ru"></label>
<label>Port <input name="smtp_port" type="number" min="1" max="65535" value="{{$c.SMTP.Port}}"></label>
<label>Security
<select name="smtp_security">
<option value="starttls" {{if eq $c.SMTP.Security "starttls"}}selected{{end}}>STARTTLS (587)</option>
<option value="tls" {{if eq $c.SMTP.Security "tls"}}selected{{end}}>TLS / SSL (465)</option>
<option value="none" {{if eq $c.SMTP.Security "none"}}selected{{end}}>none (25)</option>
</select>
</label>
<label>User <input name="smtp_user" value="{{$c.SMTP.User}}" autocomplete="off"></label>
<label>Password <input name="smtp_pass" type="password" autocomplete="new-password" placeholder="{{if .HasPass}}•••••••• (unchanged){{else}}not set{{end}}">
{{if .HasPass}}<span class="small"><input type="checkbox" name="smtp_clear_pass" value="1"> clear stored password</span>{{end}}</label>
<label>From <input name="smtp_from" value="{{$c.SMTP.From}}" placeholder="same as user"></label>
<label class="wide">Recipients <textarea name="smtp_to" rows="2" placeholder="one address per line">{{.ToText}}</textarea></label>
<label>Subject prefix <input name="smtp_prefix" value="{{$c.SMTP.SubjectPrefix}}"></label>
</div>
<p class="muted small">Gmail/Yandex/Mail.ru need an app password, not the account password. Yandex: smtp.yandex.ru 465 TLS; Gmail: smtp.gmail.com 587 STARTTLS.</p>
</section>
<section class="card">
<h2>Alerts</h2>
<div class="form-grid">
<label class="check"><input type="checkbox" name="alerts_enabled" value="1" {{if $c.AlertsEnabled}}checked{{end}}> Send e-mail alerts</label>
<label class="check"><input type="checkbox" name="notify_resolved" value="1" {{if $c.NotifyResolved}}checked{{end}}> Also mail when a problem clears</label>
<label>Remind about open alerts every <span class="unit"><input name="remind_hours" type="number" min="0" value="{{$c.RemindHours}}"> hours (0 = never)</span></label>
</div>
</section>
<section class="card">
<h2>SMART polling</h2>
<div class="form-grid">
<label>Check every <span class="unit"><input name="smart_interval" type="number" min="1" value="{{$c.SmartIntervalMin}}"> minutes</span></label>
<label class="check"><input type="checkbox" name="smart_skip_standby" value="1" {{if $c.SmartSkipStandby}}checked{{end}}> Do not wake disks in standby</label>
</div>
</section>
<section class="card">
<h2>Thresholds</h2>
<div class="form-grid">
<label>Disk temperature <span class="unit"><input name="th_disk_temp" type="number" min="0" value="{{$c.Thresholds.DiskTempC}}"> °C</span></label>
<label>CPU temperature <span class="unit"><input name="th_cpu_temp" type="number" min="0" value="{{$c.Thresholds.CPUTempC}}"> °C</span></label>
<label>Pool capacity <span class="unit"><input name="th_pool_cap" type="number" min="0" max="100" value="{{$c.Thresholds.PoolCapacityPct}}"> %</span></label>
<label>Scrub older than <span class="unit"><input name="th_scrub_days" type="number" min="0" value="{{$c.Thresholds.ScrubMaxAgeDays}}"> days</span></label>
<label>Root filesystem free below <span class="unit"><input name="th_rootfs_free" type="number" min="0" max="100" value="{{$c.Thresholds.RootFSFreePct}}"> %</span></label>
<label>NVMe percentage used <span class="unit"><input name="th_nvme_used" type="number" min="0" max="100" value="{{$c.Thresholds.NVMeUsedPct}}"> %</span></label>
<label>NVMe spare below <span class="unit"><input name="th_nvme_spare" type="number" min="0" max="100" value="{{$c.Thresholds.NVMeSparePct}}"> %</span></label>
</div>
<p class="muted small">0 disables a threshold. SMART health FAILED, reallocated/pending sectors, pool state and vdev errors always alert.</p>
</section>
<section class="card">
<h2>Scheduled self-tests</h2>
<div class="form-grid">
<label class="check"><input type="checkbox" name="st_enabled" value="1" {{if $c.SelfTests.Enabled}}checked{{end}}> Run SMART self-tests automatically</label>
<label>Short test every <span class="unit"><input name="st_short_days" type="number" min="0" value="{{$c.SelfTests.ShortDays}}"> days</span></label>
<label>Long test every <span class="unit"><input name="st_long_days" type="number" min="0" value="{{$c.SelfTests.LongDays}}"> days</span></label>
<label>Start window from <span class="unit"><input name="st_hour" type="number" min="0" max="23" value="{{$c.SelfTests.Hour}}">:00, 3 hours</span></label>
</div>
<p class="muted small">Tests start only for disks that are awake. A long test on a large HDD takes many hours; disk stays usable.</p>
</section>
<div class="actions">
<button type="submit" name="action" value="save">Save</button>
<button type="submit" name="action" value="test" class="secondary">Save &amp; send test e-mail</button>
</div>
</form>
{{end}}
{{end}}
+141
View File
@@ -0,0 +1,141 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .ApplyErr}}<div class="flash crit">Configuration saved but not applied: {{.ApplyErr}}
<form method="post" action="/shares/apply" class="inline"><button type="submit" class="secondary">Apply again</button></form></div>{{end}}
<datalist id="mountpoints">{{range .Mountpoints}}<option value="{{.}}">{{end}}</datalist>
<section class="card" id="smb">
<h2>SMB (Windows / macOS network shares)
{{if not .Svc.SMBInstalled}}<span class="badge warn">not installed</span>
{{else if .Svc.SMBActive}}<span class="badge ok">running</span> <span class="muted small">{{.Svc.SMBSessions}} session(s)</span>
{{else}}<span class="badge">stopped</span>{{end}}
</h2>
{{if not .Svc.SMBInstalled}}<p class="warn-text">Samba is not installed — run <span class="mono">sudo apt install samba wsdd</span> on the NAS (install.sh does it). Shares can be defined now and are applied once Samba is there.</p>{{end}}
<form method="post" action="/shares/smb/settings" class="form-grid">
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.SMB.Enabled}}checked{{end}}> SMB server enabled</label>
<label>Workgroup <input name="workgroup" value="{{.C.SMB.Workgroup}}"></label>
<label>Description <input name="server_string" value="{{.C.SMB.ServerString}}"></label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
</form>
<h3>Shares</h3>
<div class="tablewrap"><table>
<thead><tr><th>Name</th><th>Path</th><th>Access</th><th>Who</th><th>Connect as</th><th></th></tr></thead>
<tbody>
{{range .C.SMB.Shares}}
<tr>
<td><b>{{.Name}}</b>{{if .Comment}} <span class="muted small">{{.Comment}}</span>{{end}}{{if not .Browseable}} <span class="badge">hidden</span>{{end}}</td>
<td class="mono small">{{.Path}}</td>
<td>{{if .ReadOnly}}read-only{{else}}read/write{{end}}</td>
<td>{{if .Guest}}<span class="ok">guest</span> (no password){{else}}{{join .Users ", "}}{{end}}</td>
<td class="mono small">\\{{$.Data.Host}}\{{.Name}}</td>
<td class="small nowrap">
<a href="/shares?edit={{.Name}}#smbform">edit</a> ·
<form method="post" action="/shares/smb/delete" class="inline" onsubmit="return confirm('Delete share {{.Name}}? Files are not touched.')"><input type="hidden" name="name" value="{{.Name}}"><button type="submit" class="link">delete</button></form>
</td>
</tr>
{{else}}<tr><td colspan="6" class="muted">No SMB shares yet.</td></tr>{{end}}
</tbody>
</table></div>
<details class="newds" id="smbform" {{if or (not .Form.New) .Form.Path $.Error}}open{{end}}>
<summary>{{if .Form.New}}New share{{else}}Edit share {{.Form.Orig}}{{end}}</summary>
<form method="post" action="/shares/smb/save" class="ds-form">
<input type="hidden" name="orig" value="{{.Form.Orig}}">
<div class="form-grid">
<label>Share name <input name="name" required value="{{.Form.Name}}" placeholder="documents"></label>
<label>Path <input name="path" required list="mountpoints" value="{{.Form.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
<label>Comment <input name="comment" value="{{.Form.Comment}}"></label>
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .Form.ReadOnly}}checked{{end}}> Read-only</label>
<label class="check"><input type="checkbox" name="guest" value="1" {{if .Form.Guest}}checked{{end}}> Guest access (anyone on the LAN, no password)</label>
<label class="check"><input type="checkbox" name="browseable" value="1" {{if .Form.Browseable}}checked{{end}}> Visible in the share list</label>
<label class="check"><input type="checkbox" name="hide_sys" value="1" {{if .Form.HideSys}}checked{{end}}> Hide ._*, .DS_Store, Thumbs.db, desktop.ini</label>
<div class="wide field">
<span class="field-name">Allowed users (when guest access is off)</span>
<span class="checks">{{range .Users}}<label class="check-item"><input type="checkbox" name="users" value="{{.}}" {{if contains $.Data.Form.Users .}}checked{{end}}> {{.}}</label>{{else}}<span class="muted">no SMB users yet — add one below</span>{{end}}</span>
</div>
</div>
<p class="small muted">Files are read and written as <b>{{.Owner}}</b> regardless of the SMB user, so containers and shares see the same ownership.</p>
<button type="submit">{{if .Form.New}}Create share{{else}}Save share{{end}}</button>
{{if not .Form.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form>
</details>
<h3 id="users">SMB users</h3>
<div class="tablewrap"><table class="compact">
<tbody>
{{range .Users}}<tr><td class="mono">{{.}}</td><td class="small">
<form method="post" action="/shares/users/delete" class="inline" onsubmit="return confirm('Delete SMB user {{.}}?')"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="link">delete</button></form>
</td></tr>{{else}}<tr><td class="muted">No SMB users. Guest shares work without users.</td></tr>{{end}}
</tbody>
</table></div>
<form method="post" action="/shares/users/save" class="form-grid">
<label>User <input name="name" required placeholder="alice" list="smbusers" autocomplete="off"></label>
<datalist id="smbusers">{{range .Users}}<option value="{{.}}">{{end}}</datalist>
<label>Password <input name="password" type="password" required minlength="4" autocomplete="new-password"></label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
</form>
<p class="muted small">Windows: <span class="mono">\\{{.Host}}\share</span> in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → <span class="mono">smb://{{.Host}}/share</span>.
Note: Windows 10/11 Pro/Enterprise refuse <i>guest</i> shares by default (error 0x80070035 «network path not found») — use an SMB user with a password, or allow guest access on the PC (gpedit: Lanman Workstation → Enable insecure guest logons).</p>
</section>
<section class="card" id="nfs">
<h2>NFS (Linux / other NAS)
{{if not .Svc.NFSInstalled}}<span class="badge warn">not installed</span>
{{else if .Svc.NFSActive}}<span class="badge ok">running</span>
{{else}}<span class="badge">stopped</span>{{end}}
</h2>
{{if not .Svc.NFSInstalled}}<p class="warn-text">NFS server is not installed — run <span class="mono">sudo apt install nfs-kernel-server</span> on the NAS (install.sh does it).</p>{{end}}
<form method="post" action="/shares/nfs/settings" class="form-grid">
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.NFS.Enabled}}checked{{end}}> NFS server enabled</label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save NFS settings</button></span></div>
</form>
<div class="tablewrap"><table>
<thead><tr><th>Path</th><th>Clients</th><th>Access</th><th>Squash</th><th>Mount</th><th></th></tr></thead>
<tbody>
{{range .C.NFS.Exports}}
<tr>
<td class="mono small">{{.Path}}</td>
<td class="mono small">{{join .Clients ", "}}</td>
<td>{{if .ReadOnly}}read-only{{else}}read/write{{end}}{{if .Async}} <span class="muted small">async</span>{{end}}</td>
<td>{{.Squash}}</td>
<td class="mono small">{{$.Data.Host}}:{{.Path}}</td>
<td class="small nowrap">
<a href="/shares?nedit={{.Path}}#nfsform">edit</a> ·
<form method="post" action="/shares/nfs/delete" class="inline" onsubmit="return confirm('Remove NFS export {{.Path}}?')"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="link">delete</button></form>
</td>
</tr>
{{else}}<tr><td colspan="6" class="muted">No NFS exports yet.</td></tr>{{end}}
</tbody>
</table></div>
<details class="newds" id="nfsform" {{if or (not .NForm.New) .NForm.Path}}open{{end}}>
<summary>{{if .NForm.New}}New export{{else}}Edit export {{.NForm.Orig}}{{end}}</summary>
<form method="post" action="/shares/nfs/save" class="ds-form">
<input type="hidden" name="orig" value="{{.NForm.Orig}}">
<div class="form-grid">
<label>Path <input name="path" required list="mountpoints" value="{{.NForm.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
<label>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
<label>Squash
<select name="squash">
<option value="root" {{if eq .NForm.Squash "root"}}selected{{end}}>root → {{.Owner}} (root_squash)</option>
<option value="all" {{if eq .NForm.Squash "all"}}selected{{end}}>everyone → {{.Owner}} (all_squash)</option>
<option value="none" {{if eq .NForm.Squash "none"}}selected{{end}}>none (no_root_squash, trusted clients only)</option>
</select>
</label>
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .NForm.ReadOnly}}checked{{end}}> Read-only</label>
<label class="check"><input type="checkbox" name="async" value="1" {{if .NForm.Async}}checked{{end}}> async writes (faster, less safe on power loss)</label>
</div>
<p class="small muted">Nested datasets under the path are exported too (crossmnt). Clients: host names, IPs, networks like 192.168.101.0/24, or * for everyone.</p>
<button type="submit">{{if .NForm.New}}Create export{{else}}Save export{{end}}</button>
{{if not .NForm.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form>
</details>
<p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p>
</section>
{{end}}
{{end}}
+82
View File
@@ -0,0 +1,82 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .S.PoolsErr}}<div class="flash crit">{{.S.PoolsErr}}</div>{{end}}
{{$form := .Form}}
{{range .S.Pools}}
<section class="card" id="{{.Name}}">
<h2>{{.Name}} <span class="badge {{healthClass .Health}}">{{.Health}}</span></h2>
<div class="kv cols">
<div><span class="muted">size</span> {{bytes .Size}}</div>
<div><span class="muted">allocated</span> {{bytes .Alloc}} ({{.CapPct}}%)</div>
<div><span class="muted">free</span> {{bytes .Free}}</div>
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
</div>
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
{{if .Status}}<p class="small warn-text"><span class="muted">status:</span> {{.Status}}</p>{{end}}
{{if .Action}}<p class="small"><span class="muted">action:</span> {{.Action}}</p>{{end}}
<div class="tablewrap"><table class="compact vdevs">
<thead><tr><th>vdev</th><th>State</th><th class="num">Read</th><th class="num">Write</th><th class="num">Cksum</th><th></th></tr></thead>
<tbody>
{{range .Vdevs}}
<tr class="{{if or .Read .Write .Cksum}}row-crit{{end}}">
<td class="mono" style="padding-left:{{.Depth}}.5em">{{.Name}}</td>
<td>{{if .State}}<span class="{{healthClass .State}}">{{.State}}</span>{{end}}</td>
<td class="num">{{.Read}}</td><td class="num">{{.Write}}</td><td class="num">{{.Cksum}}</td>
<td class="muted small">{{.Note}}</td>
</tr>
{{end}}
</tbody>
</table></div>
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
<summary>New dataset in {{.Name}}</summary>
<form method="post" action="/zfs/create" class="ds-form">
<input type="hidden" name="pool" value="{{.Name}}">
<div class="form-grid">
<label>Name <input name="name" required placeholder="media/video" value="{{if eq $form.Pool .Name}}{{$form.Name}}{{end}}"></label>
<label>Owner (uid:gid) <input name="owner" value="{{$form.Owner}}" placeholder="1000:1000"></label>
<label>Compression
<select name="compression">
<option value="" {{if eq $form.Compression ""}}selected{{end}}>inherit</option>
<option value="lz4" {{if eq $form.Compression "lz4"}}selected{{end}}>lz4</option>
<option value="zstd" {{if eq $form.Compression "zstd"}}selected{{end}}>zstd</option>
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
</select>
</label>
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
</div>
<p class="small muted">Creates {{.Name}}/&lt;name&gt; (parents are created too), mounts it at /{{.Name}}/&lt;name&gt; and chowns the mountpoint to the owner.</p>
<button type="submit">Create dataset</button>
</form>
</details>
</section>
{{else}}<section class="card"><p class="muted">No ZFS pools found.</p></section>{{end}}
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
<thead><tr><th>Dataset</th><th>Mountpoint</th><th class="num">Used</th><th class="num">Referenced</th><th class="num">Available</th><th></th></tr></thead>
<tbody>
{{range .Datasets}}<tr>
<td class="mono">{{.Name}}</td><td class="muted mono small">{{.Mountpoint}}</td>
<td class="num">{{bytes .Used}}</td><td class="num">{{bytes .Refer}}</td><td class="num">{{bytes .Avail}}</td>
<td class="small">{{if hasPrefix .Mountpoint "/"}}<a href="/shares?path={{.Mountpoint}}">share…</a> {{end}}{{if .CanDestroy}}
<details class="destroy destroy-{{.DomID}}">
<summary class="muted">destroy…</summary>
<form method="post" action="/zfs/destroy" class="inline ds-destroy">
<input type="hidden" name="name" value="{{.Name}}">
<input name="confirm" placeholder="type {{.Name}} to confirm" size="30" required>
<button type="submit" class="secondary danger">Destroy empty dataset</button>
</form>
</details>
{{end}}</td>
</tr>{{end}}
</tbody>
</table></div>
<p class="small muted">Only empty datasets (no data, no children, no snapshots) can be destroyed here.</p>
</section>
{{end}}
{{end}}
+406
View File
@@ -0,0 +1,406 @@
package web
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"nasmon/internal/alert"
"nasmon/internal/collect"
"nasmon/internal/config"
"nasmon/internal/history"
"nasmon/internal/sched"
"nasmon/internal/shares"
"nasmon/internal/store"
"nasmon/internal/zfsadm"
)
func newTestServer(t *testing.T) (*Server, *sched.Scheduler, *store.Store) {
t.Helper()
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
ring := history.New(100)
for i := 0; i < 50; i++ {
ring.Add(history.Sample{T: time.Now().Add(-time.Duration(50-i) * time.Minute).Unix(), CPU: float64(i), MemUsed: 1 << 30, CPUTemp: 40 + float64(i%10)})
}
am := &alert.Manager{Store: st, Config: cfg}
sc := sched.New(cfg, st, ring, am, "test")
now := time.Now()
old := now.Add(-100 * 24 * time.Hour)
hdd := &collect.Smart{Device: "/dev/sda", Protocol: "ATA", Model: "WDC WD80EFZZ", Serial: "WD-AAA", Firmware: "81.00A81",
Supported: true, Healthy: true, HasTemp: true, TempC: 36, PowerOnHrs: 7541, PowerCycles: 21, CheckedAt: now,
Attrs: []collect.SmartAttr{{ID: 5, Name: "Reallocated_Sector_Ct", Value: 200, Worst: 200, Thresh: 140, Raw: 3, RawString: "3"},
{ID: 194, Name: "Temperature_Celsius", Value: 116, Raw: 36, RawString: "36"}},
LastTest: &collect.SelfTest{Type: "Extended offline", Status: "Completed without error", Passed: true, Hours: 7000}}
nvme := &collect.Smart{Device: "/dev/nvme0n1", Protocol: "NVMe", Model: "Samsung 980", Serial: "S64", Supported: true, Healthy: true,
HasTemp: true, TempC: 41, PowerOnHrs: 1234, CheckedAt: now,
NVMe: &collect.NVMeHealth{AvailableSpare: 100, SpareThreshold: 10, PercentageUsed: 3},
LastTest: &collect.SelfTest{InProgress: true, Remaining: 40, Passed: true}}
_ = st.AddSmart(*hdd)
_ = st.TouchDisk("WD-AAA", "WDC WD80EFZZ", "/dev/sda", now)
a, _ := st.OpenAlert("zfs:tank:health", alert.LevelCrit, "Pool tank is DEGRADED", "State: DEGRADED\nAction: replace", now)
sc.SetStatus(sched.Status{
UpdatedAt: now,
CPU: collect.CPU{UsagePct: 12.5, Cores: 4, Load: [3]float64{0.5, 0.4, 0.3}},
Mem: collect.Memory{Total: 16 << 30, Available: 8 << 30, Used: 8 << 30, ARCSize: 4 << 30, ARCMax: 8 << 30, SwapTotal: 1 << 30, SwapUsed: 100 << 20},
Temps: []collect.Temp{{Chip: "coretemp", Label: "Package id 0", C: 45}},
CPUTemp: 45, HasCPUTemp: true,
Disks: []sched.DiskView{
{Disk: collect.Disk{Name: "sda", Path: "/dev/sda", Model: "WDC WD80EFZZ", Serial: "WD-AAA", Size: 8 << 40, Rota: true, Tran: "sata"}, Smart: hdd},
{Disk: collect.Disk{Name: "nvme0n1", Path: "/dev/nvme0n1", Model: "Samsung 980", Serial: "S64", Size: 500 << 30, Tran: "nvme"}, Smart: nvme},
{Disk: collect.Disk{Name: "sdb", Path: "/dev/sdb", Model: "Sleepy", Serial: "ZZZ", Size: 1 << 40, Rota: true, Tran: "sata"}, Standby: true},
{Disk: collect.Disk{Name: "sdc", Path: "/dev/sdc", Model: "USB", Serial: "U1", Size: 1 << 40, Tran: "usb"}, Error: "open failed"},
},
Pools: []collect.Pool{
{Name: "tank", Health: "DEGRADED", Size: 8 << 40, Alloc: 3 << 40, Free: 5 << 40, CapPct: 37, FragPct: 5, Scan: "scrub repaired 0B", LastScrub: &old,
Status: "One or more devices could not be opened.", Action: "Attach the missing device", Errors: "No known data errors",
Vdevs: []collect.Vdev{{Name: "tank", State: "DEGRADED"}, {Name: "mirror-0", Depth: 1, State: "DEGRADED"}, {Name: "sda", Depth: 2, State: "ONLINE", Cksum: 2}, {Name: "123", Depth: 2, State: "UNAVAIL", Note: "was /dev/sdb1"}}},
{Name: "tmp_pool", Health: "ONLINE", Size: 1 << 40, Alloc: 900 << 30, Free: 124 << 30, CapPct: 88, Scrubbing: true, Scan: "scrub in progress"},
},
Datasets: []collect.Dataset{
{Name: "tank", Used: 3 << 40, Avail: 5 << 40, Refer: 1 << 20, Mountpoint: "/tank"},
{Name: "tank/empty", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/empty"},
{Name: "tank/data", Used: 3 << 40, Avail: 5 << 40, Refer: 3 << 40, Mountpoint: "/tank/data"},
{Name: "tank/parent", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent"},
{Name: "tank/parent/child", Used: 96 << 10, Avail: 5 << 40, Refer: 96 << 10, Mountpoint: "/tank/parent/child"},
},
RootFS: collect.FSUsage{Path: "/", Total: 100 << 30, Free: 40 << 30},
Alerts: []store.Alert{a},
LastSmartCheck: now.Add(-10 * time.Minute), NextSmartCheck: now.Add(20 * time.Minute),
LastMailErr: "auth: 535",
})
shs, err := shares.Load(filepath.Join(dir, "shares.json"))
if err != nil {
t.Fatal(err)
}
srv := New(cfg, st, ring, sc, shs)
srv.dirExists = func(p string) bool { return strings.HasPrefix(p, "/tank") || strings.HasPrefix(p, "/tmp_pool") }
return srv, sc, st
}
func TestPagesRender(t *testing.T) {
srv, _, _ := newTestServer(t)
for _, path := range []string{"/", "/?partial=1", "/zfs", "/alerts", "/settings", "/shares", "/shares?path=/tank/data", "/disks/WD-AAA", "/disks/S64", "/disks/ZZZ", "/disks/U1", "/api/status", "/api/history", "/api/disks/WD-AAA/history", "/static/app.css"} {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
if rec.Code != 200 {
t.Errorf("%s: status %d", path, rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "<no value>") || strings.Contains(body, "{{") {
t.Errorf("%s: template leak in output", path)
}
if len(body) < 100 {
t.Errorf("%s: suspiciously short body: %q", path, body)
}
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/disks/NOPE", nil))
if rec.Code != 404 {
t.Errorf("unknown disk: status %d", rec.Code)
}
}
func TestDashboardContent(t *testing.T) {
srv, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
body := rec.Body.String()
for _, want := range []string{"Pool tank is DEGRADED", "WD-AAA", "PASSED", "standby", "40% left", "DEGRADED", "scrubbing", "mail error", "8.0 TiB", "ARC"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
if rec.Header().Get("X-Alerts-Open") != "1" {
t.Errorf("X-Alerts-Open = %q", rec.Header().Get("X-Alerts-Open"))
}
}
func TestSettingsRoundTrip(t *testing.T) {
srv, _, _ := newTestServer(t)
var sent []string
srv.send = func(c config.SMTP, subject, body string) error {
sent = append(sent, subject+"|"+strings.Join(c.To, ","))
return nil
}
form := url.Values{
"smtp_host": {"smtp.example.com"}, "smtp_port": {"465"}, "smtp_security": {"tls"},
"smtp_user": {"u"}, "smtp_pass": {"secret"}, "smtp_from": {"nas@example.com"},
"smtp_to": {"a@example.com\nb@example.com"}, "smtp_prefix": {"[nas]"},
"alerts_enabled": {"1"}, "remind_hours": {"12"}, "smart_interval": {"15"},
"th_disk_temp": {"48"}, "st_enabled": {"1"}, "st_hour": {"4"},
"action": {"test"},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "test mail sent") {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String()[:200])
}
if len(sent) != 1 || !strings.Contains(sent[0], "a@example.com,b@example.com") {
t.Errorf("sent = %v", sent)
}
c := srv.cfg.Get()
if c.SMTP.Host != "smtp.example.com" || c.SMTP.Port != 465 || c.SMTP.Security != "tls" || c.SMTP.Pass != "secret" {
t.Errorf("smtp = %+v", c.SMTP)
}
if c.RemindHours != 12 || c.SmartIntervalMin != 15 || c.Thresholds.DiskTempC != 48 || !c.SelfTests.Enabled || c.SelfTests.Hour != 4 {
t.Errorf("settings = %+v", c)
}
if c.NotifyResolved || c.SmartSkipStandby {
t.Errorf("unchecked boxes must be false: %+v", c)
}
// password must not be echoed
if strings.Contains(rec.Body.String(), "secret") {
t.Errorf("password leaked into the page")
}
// second save without password keeps the old one
form.Del("smtp_pass")
form.Set("action", "save")
rec = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
if srv.cfg.Get().SMTP.Pass != "secret" {
t.Errorf("empty password field must keep the stored one")
}
// invalid value is rejected
form.Set("smtp_port", "99999")
rec = httptest.NewRecorder()
req = httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Not saved") {
t.Errorf("invalid port accepted")
}
}
func TestSmartRunRedirect(t *testing.T) {
srv, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("POST", "/smart/run", nil))
if rec.Code != http.StatusSeeOther {
t.Errorf("status %d", rec.Code)
}
}
// fakeRunner records host commands and answers "zfs get"/"zfs list" queries.
type fakeRunner struct {
calls []string
fail string // command prefix that fails
}
func (f *fakeRunner) run(_ context.Context, name string, args ...string) (string, error) {
c := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, c)
if f.fail != "" && strings.HasPrefix(c, f.fail) {
return "cannot do that", errors.New("cannot do that")
}
switch {
case strings.HasPrefix(c, "zfs get"):
return "/" + args[len(args)-1], nil
case strings.HasPrefix(c, "zfs list -Hp"):
return args[len(args)-1] + " 98304", nil
}
return "", nil
}
func postForm(srv *Server, path string, form url.Values) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
srv.ServeHTTP(rec, req)
return rec
}
func TestZfsPageControls(t *testing.T) {
srv, _, _ := newTestServer(t)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/zfs?msg=hello", nil))
body := rec.Body.String()
for _, want := range []string{"New dataset in tank", "New dataset in tmp_pool", "hello", `value="1000:1000"`, "type tank/empty to confirm"} {
if !strings.Contains(body, want) {
t.Errorf("zfs page missing %q", want)
}
}
for _, no := range []string{"type tank to confirm", "type tank/data to confirm", "type tank/parent to confirm"} {
if strings.Contains(body, no) {
t.Errorf("zfs page offers destroy for a non-empty/parent/root dataset: %q", no)
}
}
if !strings.Contains(body, "type tank/parent/child to confirm") {
t.Errorf("leaf child should be destroyable")
}
}
func TestZfsCreate(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}
srv.admin = zfsadm.New(f.run)
// rejected input never reaches the runner (checked first: a successful
// create refreshes the status from the real host, which has no ZFS here)
bad := []url.Values{
{"pool": {"nope"}, "name": {"x"}}, // unknown pool
{"pool": {"tank"}, "name": {"../etc"}}, // bad name
{"pool": {"tank"}, "name": {"data"}}, // exists
{"pool": {"tank"}, "name": {"x"}, "quota": {"lots"}}, // bad quota
{"pool": {"tank"}, "name": {"x"}, "owner": {"ansible"}}, // bad owner
{"pool": {"tank"}, "name": {"x"}, "compression": {"gzip"}},
}
for _, form := range bad {
f.calls = nil
rec := postForm(srv, "/zfs/create", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not created") {
t.Errorf("%v: status %d, no error shown", form, rec.Code)
}
if len(f.calls) != 0 {
t.Errorf("%v: commands ran: %v", form, f.calls)
}
if strings.Contains(rec.Body.String(), "<no value>") {
t.Errorf("%v: template leak", form)
}
}
rec := postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"/media/video/"}, "owner": {"1000:1000"}, "compression": {"lz4"}, "quota": {"100g"}})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "created") {
t.Fatalf("status %d location %q body %s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
want := "zfs create -p -o compression=lz4 -o quota=100G tank/media/video"
if len(f.calls) != 3 || f.calls[0] != want || f.calls[2] != "chown 1000:1000 /tank/media/video" {
t.Errorf("calls = %v", f.calls)
}
// zfs failure is shown and the form keeps its values
f = &fakeRunner{fail: "zfs create"}
srv.admin = zfsadm.New(f.run)
rec = postForm(srv, "/zfs/create", url.Values{"pool": {"tank"}, "name": {"x"}, "quota": {"5G"}})
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "cannot do that") || !strings.Contains(rec.Body.String(), `value="5G"`) {
t.Errorf("failure not reported: %d %s", rec.Code, rec.Body.String()[:300])
}
}
func TestZfsDestroy(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &fakeRunner{}
srv.admin = zfsadm.New(f.run)
bad := []url.Values{
{"name": {"tank/empty"}, "confirm": {"tank/emp"}}, // wrong confirmation
{"name": {"tank/data"}, "confirm": {"tank/data"}}, // not empty
{"name": {"tank/parent"}, "confirm": {"tank/parent"}}, // has child
{"name": {"tank"}, "confirm": {"tank"}}, // pool root
{"name": {"tank/none"}, "confirm": {"tank/none"}}, // unknown
}
for _, form := range bad {
f.calls = nil
rec := postForm(srv, "/zfs/destroy", form)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not destroyed") {
t.Errorf("%v: status %d", form, rec.Code)
}
if len(f.calls) != 0 {
t.Errorf("%v: commands ran: %v", form, f.calls)
}
}
rec := postForm(srv, "/zfs/destroy", url.Values{"name": {"tank/empty"}, "confirm": {"tank/empty"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
if len(f.calls) == 0 || f.calls[len(f.calls)-1] != "zfs destroy tank/empty" {
t.Errorf("calls = %v", f.calls)
}
}
func get(srv http.Handler, path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
return rec
}
func post(srv http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
srv.ServeHTTP(rec, req)
return rec
}
func TestAlertAckAndDelete(t *testing.T) {
srv, _, st := newTestServer(t)
open, _ := st.OpenAlerts()
id := open[0].ID
idS := strconv.FormatInt(id, 10)
// dashboard shows the open alert with an ack control
body := get(srv, "/").Body.String()
if !strings.Contains(body, "/alerts/"+idS+"/ack") {
t.Fatalf("dashboard lacks ack form")
}
// ack via fetch: 204, gone from dashboard and badge
rec := post(srv, "/alerts/"+idS+"/ack", map[string]string{"X-Requested-With": "fetch"})
if rec.Code != http.StatusNoContent {
t.Fatalf("ack status %d", rec.Code)
}
rec = get(srv, "/")
if strings.Contains(rec.Body.String(), "Pool tank is DEGRADED") || rec.Header().Get("X-Alerts-Open") != "0" {
t.Errorf("acked alert still on dashboard / badge=%q", rec.Header().Get("X-Alerts-Open"))
}
// /alerts shows it as acked with "show again"
body = get(srv, "/alerts").Body.String()
if !strings.Contains(body, ">acked<") || !strings.Contains(body, "/alerts/"+idS+"/unack") {
t.Errorf("alerts page missing acked state")
}
// delete refused for an open alert
if rec := post(srv, "/alerts/"+idS+"/delete", nil); rec.Code != http.StatusConflict {
t.Errorf("delete of open alert: %d", rec.Code)
}
// unack via a plain form post redirects back to the referer with a message
rec = post(srv, "/alerts/"+idS+"/unack", map[string]string{"Referer": "/alerts?all=1"})
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "all=1") || !strings.Contains(rec.Header().Get("Location"), "msg=") {
t.Errorf("unack redirect: %d %s", rec.Code, rec.Header().Get("Location"))
}
if rec := get(srv, "/"); rec.Header().Get("X-Alerts-Open") != "1" {
t.Errorf("badge after unack = %q", rec.Header().Get("X-Alerts-Open"))
}
// resolved long ago: hidden by default, visible with ?all=1, deletable
oldA, _ := st.OpenAlert("k:old", "warn", "Ancient problem", "d", time.Now().Add(-40*24*time.Hour))
_ = st.ResolveAlert(oldA.ID, time.Now().Add(-39*24*time.Hour))
body = get(srv, "/alerts").Body.String()
if strings.Contains(body, "Ancient problem") || !strings.Contains(body, "Show all (1 older resolved hidden)") {
t.Errorf("old resolved alert not hidden by default")
}
body = get(srv, "/alerts?all=1").Body.String()
if !strings.Contains(body, "Ancient problem") || !strings.Contains(body, "/alerts/"+strconv.FormatInt(oldA.ID, 10)+"/delete") {
t.Errorf("old resolved alert missing from ?all=1")
}
if rec := post(srv, "/alerts/"+strconv.FormatInt(oldA.ID, 10)+"/delete", map[string]string{"X-Requested-With": "fetch"}); rec.Code != http.StatusNoContent {
t.Errorf("delete: %d", rec.Code)
}
if strings.Contains(get(srv, "/alerts?all=1").Body.String(), "Ancient problem") {
t.Errorf("deleted alert still listed")
}
if rec := post(srv, "/alerts/999/ack", nil); rec.Code != 404 {
t.Errorf("unknown alert: %d", rec.Code)
}
}