// Package web serves the dashboard, settings form and JSON API. package web import ( "embed" "fmt" "html/template" "io/fs" "log" "math" "net" "net/http" "net/url" "path/filepath" "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 // share path checks; replaced in tests dirExists func(string) bool realPath func(string) (string, error) readMounts func() string 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, realPath: filepath.EvalSymlinks, readMounts: procMounts, 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("POST /zfs/dedup", s.zfsDedup) 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 RepoISO string // browser URL of the RepoISO portal ("" = integration off) } // 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" pd.RepoISO = browserURL(s.cfg.Get().Integrations.RepoISOURL, r.Host) 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(``, 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( ``, class, int(w), int(h), int(h), p, int(w), int(h), p)) } // browserURL turns the configured RepoISO address into one the user's browser // can open: a loopback host is replaced by the host the browser used for nasmon. func browserURL(cfg, reqHost string) string { if cfg == "" { return "" } u, err := url.Parse(cfg) if err != nil || u.Host == "" { return cfg } switch u.Hostname() { case "127.0.0.1", "localhost", "::1": host := reqHost if h, _, err := net.SplitHostPort(reqHost); err == nil { host = h } if host == "" { return cfg } if strings.Contains(host, ":") { host = "[" + host + "]" } if p := u.Port(); p != "" { host += ":" + p } u.Host = host } return u.String() }