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,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))
|
||||
}
|
||||
Reference in New Issue
Block a user