Files
nasmon/internal/web/server.go
T
a.gritsenkoandClaude Opus 5.5 30dbb00dd1
build / build (push) Successful in 1m43s
Shares of other services and RepoISO integration
- include SMB drop-ins from /etc/samba/smb.conf.d into the generated smb.conf
- keep smbd/nfs-server running while other services (/etc/exports.d, drop-ins) share,
  instead of disabling them when nasmon has no shares of its own
- list those shares read-only on /shares
- settings: RepoISO URL; poll its /api/integration/status, dashboard card, menu link,
  alerts for portal down, stale worker, failed jobs, unmounted publications

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:06:17 +03:00

325 lines
8.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"
"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(`<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))
}
// 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()
}