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>
470 lines
14 KiB
Go
470 lines
14 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
|
|
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)
|
|
}
|