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:
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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">×</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">×</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">× 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. × on a resolved alert removes it from the log; resolved alerts older than 90 days are removed automatically.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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">×</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}}
|
||||
@@ -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 & send test e-mail</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -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"> </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"> </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"> </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}}
|
||||
@@ -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}}/<name> (parents are created too), mounts it at /{{.Name}}/<name> 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}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user