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 // PoolDedup maps a pool to the dedup property of its root dataset. PoolDedup map[string]string 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, Dedup, Quota string } func (s *Server) zfsView(st sched.Status, form createForm) zfsVM { vm := zfsVM{S: st, Th: s.cfg.Get().Thresholds, Form: form, PoolDedup: map[string]string{}} if vm.Form.Owner == "" { vm.Form.Owner = defaultOwner } for _, d := range st.Datasets { if !strings.Contains(d.Name, "/") { vm.PoolDedup[d.Name] = d.Dedup } 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"), Dedup: r.FormValue("dedup"), 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.ValidateDedup(f.Dedup), zfsadm.ValidateSize(f.Quota)} { if err != nil { fail(err.Error()) return } } err := s.admin.Create(full, zfsadm.CreateOpts{Compression: f.Compression, Dedup: f.Dedup, 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, dedup %q, quota %q)", full, f.Owner, f.Compression, f.Dedup, 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) } // zfsDedup turns dedup on/off on a pool's root dataset (inherited by children). func (s *Server) zfsDedup(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } pool := strings.TrimSpace(r.FormValue("pool")) value := r.FormValue("dedup") 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: "Dedup not changed: " + msg}) } if !hasPool(st, pool) { fail("unknown pool " + pool) return } if value != "on" && value != "off" { fail("dedup must be on or off") return } err := s.admin.SetDedup(pool, value) s.sch.RefreshStorage() if err != nil { log.Printf("zfs set dedup=%s %s: %v", value, pool, err) fail(err.Error()) return } log.Printf("dedup=%s set on pool %s", value, pool) http.Redirect(w, r, "/zfs?msg="+url.QueryEscape("Dedup "+value+" for "+pool)+"#"+url.PathEscape(pool), 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) }