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>
285 lines
7.7 KiB
Go
285 lines
7.7 KiB
Go
// 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))
|
|
}
|