Files
nasmon/internal/web/handlers.go
T
a.gritsenkoandClaude Opus 5.5 42cfb43e2e
build / build (push) Successful in 1m5s
Remove the RepoISO integration; keep generic support for shares of other services
RepoISO now manages the NAS itself, so nasmon no longer polls it (settings field, dashboard card,
menu link and repoiso:* alerts are gone). Drop-in SMB includes, read-only listing and keeping
smbd/nfs-server running for other software's shares stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:05:16 +03:00

507 lines
15 KiB
Go

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)
}