Shares of other services and RepoISO integration
build / build (push) Successful in 1m43s

- 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>
This commit is contained in:
2026-10-03 15:06:17 +03:00
co-authored by Claude Opus 5.5
parent 661a862f8d
commit 30dbb00dd1
22 changed files with 656 additions and 19 deletions
+35
View File
@@ -0,0 +1,35 @@
package alert
import (
"testing"
"time"
"nasmon/internal/collect"
)
func TestRepoISORules(t *testing.T) {
base := Input{Now: time.Now()}
if ev := Evaluate(base); len(ev) != 0 {
t.Fatalf("integration off must not alert: %v", keys(ev))
}
in := base
in.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Error: "connection refused"}
ev := Evaluate(in)
if !has(ev, "repoiso:api:down") || len(ev) != 1 || ev[0].Level != LevelCrit {
t.Fatalf("down: %v", keys(ev))
}
in.RepoISO = &collect.RepoISO{Reachable: true, WorkerAlive: false, JobsFailed24h: 2, PublicationsError: 1}
ev = Evaluate(in)
for _, k := range []string{"repoiso:worker:stale", "repoiso:jobs:failed", "repoiso:publications:error"} {
if !has(ev, k) {
t.Errorf("missing %s in %v", k, keys(ev))
}
}
in.RepoISO = &collect.RepoISO{Reachable: true, WorkerAlive: true}
if ev = Evaluate(in); len(ev) != 0 {
t.Fatalf("healthy RepoISO alerted: %v", keys(ev))
}
}
+28
View File
@@ -40,6 +40,8 @@ type Input struct {
HasCPU bool
RootFS collect.FSUsage
RootOK bool
// RepoISO is the polled RepoISO health (nil when the integration is off).
RepoISO *collect.RepoISO
// Frozen lists key prefixes whose open alerts must be left untouched
// because no fresh data was available for them in this cycle.
Frozen []string
@@ -194,10 +196,36 @@ func Evaluate(in Input) []Event {
}
}
if r := in.RepoISO; r != nil {
switch {
case !r.Reachable:
add("repoiso:api:down", LevelCrit, "RepoISO is not responding",
fmt.Sprintf("%s/api/integration/status: %s. Check: systemctl status repoiso-api nginx", r.URL, r.Error))
case !r.WorkerAlive:
add("repoiso:worker:stale", LevelWarn, "RepoISO worker is not running",
fmt.Sprintf("Last worker heartbeat: %s. Downloads, mounts and Proxmox exports are not processed. Check: systemctl status repoiso-worker", orNever(r.WorkerHeartbeat)))
}
if r.Reachable && r.JobsFailed24h > 0 {
add("repoiso:jobs:failed", LevelWarn, fmt.Sprintf("RepoISO: %d failed job(s) in 24 h", r.JobsFailed24h),
"See the Jobs page of RepoISO for the errors.")
}
if r.Reachable && r.PublicationsError > 0 {
add("repoiso:publications:error", LevelWarn, fmt.Sprintf("RepoISO: %d publication(s) not mounted", r.PublicationsError),
"See the Publications page of RepoISO; remount or unpublish the image.")
}
}
sort.Slice(ev, func(i, j int) bool { return ev[i].Key < ev[j].Key })
return ev
}
func orNever(s string) string {
if s == "" {
return "never"
}
return s
}
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
+54
View File
@@ -0,0 +1,54 @@
package collect
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// RepoISO is the health summary of a RepoISO portal running on the same host
// (GET <url>/api/integration/status). Reachable=false means the API did not answer.
type RepoISO struct {
URL string `json:"url"`
Reachable bool `json:"reachable"`
Error string `json:"error,omitempty"`
CheckedAt time.Time `json:"checked_at"`
Version string `json:"version"`
WorkerAlive bool `json:"worker_alive"`
WorkerHeartbeat string `json:"worker_heartbeat"`
Images int `json:"images"`
ImagesBytes uint64 `json:"images_bytes"`
ImagesPending int `json:"images_pending"`
ImagesError int `json:"images_error"`
Publications int `json:"publications"`
PublicationsError int `json:"publications_error"`
JobsActive int `json:"jobs_active"`
JobsFailed24h int `json:"jobs_failed_24h"`
Exports int `json:"exports"`
}
var repoisoClient = &http.Client{Timeout: 5 * time.Second}
// FetchRepoISO queries the RepoISO status endpoint; errors are reported in the result.
func FetchRepoISO(base string) RepoISO {
base = strings.TrimRight(base, "/")
r := RepoISO{URL: base, CheckedAt: time.Now()}
resp, err := repoisoClient.Get(base + "/api/integration/status")
if err != nil {
r.Error = err.Error()
return r
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
r.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return r
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
r.Error = "bad response: " + err.Error()
return r
}
r.URL, r.Reachable, r.Error = base, true, ""
return r
}
+29
View File
@@ -0,0 +1,29 @@
package collect
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestFetchRepoISO(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/integration/status" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`{"version":"0.1.3","worker_alive":true,"images":5,"images_bytes":1024,"jobs_failed_24h":1}`))
}))
defer srv.Close()
r := FetchRepoISO(srv.URL + "/")
if !r.Reachable || r.Version != "0.1.3" || !r.WorkerAlive || r.Images != 5 || r.JobsFailed24h != 1 || r.URL != srv.URL {
t.Fatalf("unexpected %+v", r)
}
if r := FetchRepoISO(srv.URL + "/missing"); r.Reachable || r.Error != "HTTP 404" {
t.Fatalf("404 not reported: %+v", r)
}
srv.Close()
if r := FetchRepoISO(srv.URL); r.Reachable || r.Error == "" {
t.Fatalf("closed server reported reachable: %+v", r)
}
}
+21 -8
View File
@@ -40,15 +40,23 @@ type SelfTests struct {
Hour int `json:"hour"` // local hour of day when tests may be started
}
// Integrations links nasmon with other services on the same host.
type Integrations struct {
// RepoISOURL is the RepoISO portal base URL nasmon polls for health
// (e.g. http://127.0.0.1); empty disables the integration.
RepoISOURL string `json:"repoiso_url"`
}
type Settings struct {
SMTP SMTP `json:"smtp"`
AlertsEnabled bool `json:"alerts_enabled"`
NotifyResolved bool `json:"notify_resolved"`
RemindHours int `json:"remind_hours"`
SmartIntervalMin int `json:"smart_interval_min"`
SmartSkipStandby bool `json:"smart_skip_standby"`
Thresholds Thresholds `json:"thresholds"`
SelfTests SelfTests `json:"self_tests"`
SMTP SMTP `json:"smtp"`
AlertsEnabled bool `json:"alerts_enabled"`
NotifyResolved bool `json:"notify_resolved"`
RemindHours int `json:"remind_hours"`
SmartIntervalMin int `json:"smart_interval_min"`
SmartSkipStandby bool `json:"smart_skip_standby"`
Thresholds Thresholds `json:"thresholds"`
SelfTests SelfTests `json:"self_tests"`
Integrations Integrations `json:"integrations"`
}
func Defaults() Settings {
@@ -102,6 +110,11 @@ func (s *Settings) Validate() error {
if s.SelfTests.ShortDays < 0 || s.SelfTests.LongDays < 0 {
return errors.New("self-test periods must be >= 0")
}
u := strings.TrimRight(strings.TrimSpace(s.Integrations.RepoISOURL), "/")
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
return errors.New("RepoISO URL must start with http:// or https://")
}
s.Integrations.RepoISOURL = u
return nil
}
+9 -1
View File
@@ -54,6 +54,7 @@ type Status struct {
NextSmartCheck time.Time `json:"next_smart_check"`
SmartRunning bool `json:"smart_running"`
LastMailErr string `json:"last_mail_err,omitempty"`
RepoISO *collect.RepoISO `json:"repoiso,omitempty"`
}
type Scheduler struct {
@@ -243,6 +244,11 @@ func (s *Scheduler) collectStorage() {
root, _ := collect.ReadFS("/")
alerts, _ := s.st.ActiveAlerts()
services := collect.ReadShareServices()
var repoiso *collect.RepoISO
if url := s.cfg.Get().Integrations.RepoISOURL; url != "" {
r := collect.FetchRepoISO(url)
repoiso = &r
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -264,6 +270,7 @@ func (s *Scheduler) collectStorage() {
s.status.RootFS = root
s.status.Alerts = alerts
s.status.Services = services
s.status.RepoISO = repoiso
}
// buildViews merges lsblk disks with cached SMART data; caller holds s.mu.
@@ -419,7 +426,8 @@ func (s *Scheduler) evaluate() {
Disks: nil, Pools: st.Pools, PoolsOK: st.PoolsErr == "",
CPUTemp: st.CPUTemp, HasCPU: st.HasCPUTemp,
RootFS: st.RootFS, RootOK: st.RootFS.Total > 0,
Prev: map[string]*collect.Smart{},
Prev: map[string]*collect.Smart{},
RepoISO: st.RepoISO,
}
for _, v := range st.Disks {
in.Disks = append(in.Disks, v.Disk)
+23 -8
View File
@@ -29,12 +29,17 @@ type Manager struct {
UID, GID string
SMBConf string
Exports string
Timeout time.Duration
// IncludeDir holds Samba drop-ins of other services, included into smb.conf;
// ExportsDir is scanned for other services' NFS exports (both: "" = none).
IncludeDir string
ExportsDir string
Timeout time.Duration
}
func New(run hostrun.Runner) *Manager {
m := &Manager{Run: run, LookPath: exec.LookPath, UID: DefaultOwnerUID, GID: DefaultOwnerUID,
SMBConf: DefaultSMBConf, Exports: DefaultExports, Timeout: 90 * time.Second}
SMBConf: DefaultSMBConf, Exports: DefaultExports, IncludeDir: DefaultSMBIncludeDir,
ExportsDir: DefaultExportsDir, Timeout: 90 * time.Second}
m.Owner = m.UID
if u, err := user.LookupId(m.UID); err == nil {
m.Owner, m.GID = u.Username, u.Gid
@@ -74,14 +79,19 @@ func (m *Manager) Apply(c Config) error {
func (m *Manager) applySMB(c Config) error {
ctx, cancel := m.ctx()
defer cancel()
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(c, m.Owner)); err != nil {
own := c
if !c.SMB.Enabled {
own.SMB.Shares = nil // other services may keep smbd running; don't serve disabled shares
}
external := countKind(m.External(), "smb")
if err := hostrun.WriteFile(ctx, m.Run, m.SMBConf, RenderSMB(own, m.Owner, IncludeFiles(m.IncludeDir)...)); err != nil {
return fmt.Errorf("write %s: %w", m.SMBConf, err)
}
if out, err := m.Run(ctx, "", "testparm", "-s", "--suppress-prompt", m.SMBConf); err != nil {
_ = hostrun.Restore(ctx, m.Run, m.SMBConf)
return fmt.Errorf("testparm rejected the config, previous file restored: %s", firstLines(out, 4))
}
active := c.SMB.Enabled && len(c.SMB.Shares) > 0
active := len(own.SMB.Shares) > 0 || external > 0
if !active {
_, err := m.Run(ctx, "", "systemctl", "disable", "--now", "smbd", "nmbd")
if err != nil {
@@ -96,17 +106,22 @@ func (m *Manager) applySMB(c Config) error {
if out, err := m.Run(ctx, "", "smbcontrol", "all", "reload-config"); err != nil {
return fmt.Errorf("reload smbd: %s", firstLines(out, 2))
}
log.Printf("shares: SMB config applied, %d shares", len(c.SMB.Shares))
log.Printf("shares: SMB config applied, %d shares, %d from other services", len(own.SMB.Shares), external)
return nil
}
func (m *Manager) applyNFS(c Config) error {
ctx, cancel := m.ctx()
defer cancel()
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(c, m.UID, m.GID)); err != nil {
own := c
if !c.NFS.Enabled {
own.NFS.Exports = nil
}
external := countKind(m.External(), "nfs")
if err := hostrun.WriteFile(ctx, m.Run, m.Exports, RenderExports(own, m.UID, m.GID)); err != nil {
return fmt.Errorf("write %s: %w", m.Exports, err)
}
active := c.NFS.Enabled && len(c.NFS.Exports) > 0
active := len(own.NFS.Exports) > 0 || external > 0
if !active {
if _, err := m.Run(ctx, "", "systemctl", "disable", "--now", "nfs-server"); err != nil {
return fmt.Errorf("stop nfs-server: %w", err)
@@ -122,7 +137,7 @@ func (m *Manager) applyNFS(c Config) error {
_, _ = m.Run(ctx, "", "exportfs", "-ra")
return fmt.Errorf("exportfs rejected the exports, previous file restored: %s", firstLines(out, 4))
}
log.Printf("shares: NFS exports applied, %d exports", len(c.NFS.Exports))
log.Printf("shares: NFS exports applied, %d exports, %d from other services", len(own.NFS.Exports), external)
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package shares
import (
"os"
"path/filepath"
"sort"
"strings"
)
// Other services on the host (e.g. RepoISO) publish their own shares through
// drop-in files. nasmon includes the SMB drop-ins into the smb.conf it
// generates and keeps smbd / nfs-server running while any of them define a
// share, even when nasmon itself has none.
const (
DefaultSMBIncludeDir = "/etc/samba/smb.conf.d"
DefaultExportsDir = "/etc/exports.d"
)
// ExternalShare is a share defined outside nasmon (read-only in the UI).
type ExternalShare struct {
Kind string // smb | nfs
Source string // drop-in file name without extension, e.g. "repoiso"
Name string // SMB share name (empty for NFS)
Path string
Clients string // NFS clients with options, as written in the file
}
// IncludeFiles lists the *.conf drop-ins in dir, sorted. A missing dir yields nil.
func IncludeFiles(dir string) []string {
if dir == "" {
return nil
}
m, _ := filepath.Glob(filepath.Join(dir, "*.conf"))
sort.Strings(m)
return m
}
func sourceName(file string) string {
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}
// ParseSMBDropIn returns the shares ([section] other than [global], [homes],
// [printers]) defined in a Samba config fragment.
func ParseSMBDropIn(text, source string) []ExternalShare {
var out []ExternalShare
var cur *ExternalShare
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" || line[0] == '#' || line[0] == ';' {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
name := strings.TrimSpace(line[1 : len(line)-1])
switch strings.ToLower(name) {
case "global", "homes", "printers":
cur = nil
default:
out = append(out, ExternalShare{Kind: "smb", Source: source, Name: name})
cur = &out[len(out)-1]
}
continue
}
if cur == nil {
continue
}
if k, v, ok := strings.Cut(line, "="); ok && strings.EqualFold(strings.TrimSpace(k), "path") {
cur.Path = strings.TrimSpace(v)
}
}
return out
}
// ParseExportsFile returns the exports in an exports(5) file.
func ParseExportsFile(text, source string) []ExternalShare {
var out []ExternalShare
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" || line[0] == '#' {
continue
}
var path, rest string
if line[0] == '"' {
if end := strings.Index(line[1:], `"`); end >= 0 {
path, rest = line[1:end+1], line[end+2:]
}
} else {
path, rest, _ = strings.Cut(line, " ")
}
if path == "" {
continue
}
out = append(out, ExternalShare{Kind: "nfs", Source: source, Path: path, Clients: strings.TrimSpace(rest)})
}
return out
}
// External reads the SMB drop-ins and the exports.d files of other services.
func (m *Manager) External() []ExternalShare {
var out []ExternalShare
for _, f := range IncludeFiles(m.IncludeDir) {
if b, err := os.ReadFile(f); err == nil {
out = append(out, ParseSMBDropIn(string(b), sourceName(f))...)
}
}
if m.ExportsDir != "" {
files, _ := filepath.Glob(filepath.Join(m.ExportsDir, "*.exports"))
sort.Strings(files)
for _, f := range files {
if filepath.Clean(f) == filepath.Clean(m.Exports) {
continue
}
if b, err := os.ReadFile(f); err == nil {
out = append(out, ParseExportsFile(string(b), sourceName(f))...)
}
}
}
return out
}
func countKind(ext []ExternalShare, kind string) int {
n := 0
for _, e := range ext {
if e.Kind == kind {
n++
}
}
return n
}
+111
View File
@@ -0,0 +1,111 @@
package shares
import (
"os"
"path/filepath"
"strings"
"testing"
)
const repoisoSMB = `# Managed by repoiso — do not edit
[iso-pve]
path = /tank/iso/export/pve
read only = yes
guest ok = yes
[global]
; ignored
`
func writeDropIns(t *testing.T) (smbDir, nfsDir string) {
t.Helper()
root := t.TempDir()
smbDir, nfsDir = filepath.Join(root, "smb.conf.d"), filepath.Join(root, "exports.d")
for _, d := range []string{smbDir, nfsDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
must := func(p, s string) {
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
must(filepath.Join(smbDir, "repoiso.conf"), repoisoSMB)
must(filepath.Join(smbDir, "notes.txt"), "[ignored]\n")
must(filepath.Join(nfsDir, "repoiso.exports"), "# Managed by repoiso\n\"/tank/iso/export/pve\" 10.10.0.0/24(ro,root_squash)\n")
must(filepath.Join(nfsDir, "nasmon.exports"), "\"/data\" *(rw)\n")
return
}
func TestParseDropIns(t *testing.T) {
smb := ParseSMBDropIn(repoisoSMB, "repoiso")
if len(smb) != 1 || smb[0].Name != "iso-pve" || smb[0].Path != "/tank/iso/export/pve" || smb[0].Source != "repoiso" {
t.Fatalf("smb: %+v", smb)
}
nfs := ParseExportsFile("# c\n/plain host(ro)\n\"/with space\" 10.0.0.0/8(ro)\n\n", "x")
if len(nfs) != 2 || nfs[0].Path != "/plain" || nfs[1].Path != "/with space" || nfs[1].Clients != "10.0.0.0/8(ro)" {
t.Fatalf("nfs: %+v", nfs)
}
}
func TestExternalSkipsOwnExports(t *testing.T) {
smbDir, nfsDir := writeDropIns(t)
m := newManager(newFake())
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
ext := m.External()
if countKind(ext, "smb") != 1 || countKind(ext, "nfs") != 1 {
t.Fatalf("external: %+v", ext)
}
if got := IncludeFiles(smbDir); len(got) != 1 || !strings.HasSuffix(got[0], "repoiso.conf") {
t.Fatalf("includes: %v", got)
}
}
func TestRenderSMBIncludes(t *testing.T) {
out := RenderSMB(sample(), "ansible", "/etc/samba/smb.conf.d/repoiso.conf")
if !strings.HasSuffix(strings.TrimSpace(out), "include = /etc/samba/smb.conf.d/repoiso.conf") {
t.Fatalf("include not at the end:\n%s", out)
}
if strings.Contains(RenderSMB(sample(), "ansible"), "include =") {
t.Fatal("include rendered without drop-ins")
}
}
// The bug this guards against: nasmon without own shares disabled smbd and
// nfs-server and killed the shares RepoISO publishes for Proxmox.
func TestApplyKeepsServicesForOtherServices(t *testing.T) {
smbDir, nfsDir := writeDropIns(t)
f := newFake()
m := newManager(f)
m.IncludeDir, m.ExportsDir, m.Exports = smbDir, nfsDir, filepath.Join(nfsDir, "nasmon.exports")
if err := m.Apply(Defaults()); err != nil {
t.Fatal(err)
}
if f.has("systemctl disable") {
t.Fatalf("services disabled although other services share: %v", f.calls)
}
if !f.has("systemctl enable --now smbd nmbd") || !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") {
t.Fatalf("services not kept running: %v", f.calls)
}
wi := f.index("sh -c")
if !strings.Contains(f.stdin[f.calls[wi]], "include = "+filepath.Join(smbDir, "repoiso.conf")) {
t.Errorf("smb.conf lacks the drop-in include")
}
}
func TestApplyDisabledSMBHidesOwnShares(t *testing.T) {
smbDir, _ := writeDropIns(t)
f := newFake()
m := newManager(f)
m.IncludeDir = smbDir
c := sample()
c.SMB.Enabled = false
if err := m.Apply(c); err != nil {
t.Fatal(err)
}
if strings.Contains(f.stdin[f.calls[f.index("sh -c")]], "[documents]") {
t.Errorf("disabled nasmon shares still rendered while smbd stays up for others")
}
}
+9 -2
View File
@@ -26,8 +26,9 @@ func anyGuest(c Config) bool {
// RenderSMB produces the complete smb.conf. owner is the Unix account every
// SMB session acts as (force user), so files end up with the same owner the
// containers use.
func RenderSMB(c Config, owner string) string {
// containers use. includes are drop-in files of other services appended as
// "include =" lines.
func RenderSMB(c Config, owner string, includes ...string) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n\n[global]\n", Header)
// Bad User turns unknown accounts (e.g. the Windows login) into guests.
@@ -98,6 +99,12 @@ func RenderSMB(c Config, owner string) string {
fmt.Fprintf(&b, " %s\n", l)
}
}
if len(includes) > 0 {
b.WriteString("\n# Shares of other services (read-only in nasmon)\n")
for _, f := range includes {
fmt.Fprintf(&b, "include = %s\n", f)
}
}
return b.String()
}
+1
View File
@@ -225,6 +225,7 @@ func newManager(f *fake) *Manager {
m := New(f.run)
m.LookPath = f.lookPath
m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
m.IncludeDir, m.ExportsDir = "", "" // never read the host's drop-ins in tests
return m
}
+1
View File
@@ -465,6 +465,7 @@ func formToSettings(r *http.Request, old config.Settings) config.Settings {
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)
c.Integrations.RepoISOURL = strings.TrimSpace(r.FormValue("repoiso_url"))
return c
}
+87
View File
@@ -0,0 +1,87 @@
package web
import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"nasmon/internal/collect"
"nasmon/internal/shares"
)
func TestBrowserURL(t *testing.T) {
cases := []struct{ cfg, host, want string }{
{"", "nas:8090", ""},
{"http://127.0.0.1", "10.10.0.129:8090", "http://10.10.0.129"},
{"http://localhost:8080/", "nas.lan:8090", "http://nas.lan:8080/"},
{"https://repo.example.com", "10.0.0.1:8090", "https://repo.example.com"},
{"http://127.0.0.1", "[fe80::1]:8090", "http://[fe80::1]"},
}
for _, c := range cases {
if got := browserURL(c.cfg, c.host); got != c.want {
t.Errorf("browserURL(%q, %q) = %q, want %q", c.cfg, c.host, got, c.want)
}
}
}
func TestRepoISOIntegrationUI(t *testing.T) {
srv, sc, _ := newTestServer(t)
// settings: save the URL, menu link appears
rec := postForm(srv, "/settings", url.Values{"smtp_port": {"587"}, "smtp_security": {"starttls"},
"smart_interval": {"30"}, "repoiso_url": {"http://127.0.0.1/"}})
if rec.Code != 200 || srv.cfg.Get().Integrations.RepoISOURL != "http://127.0.0.1" {
t.Fatalf("settings not saved: %d %q", rec.Code, srv.cfg.Get().Integrations.RepoISOURL)
}
rec = postForm(srv, "/settings", url.Values{"smtp_port": {"587"}, "smtp_security": {"starttls"},
"smart_interval": {"30"}, "repoiso_url": {"ftp://x"}})
if !strings.Contains(rec.Body.String(), "RepoISO URL must start") {
t.Errorf("bad URL accepted")
}
st := sc.Status()
st.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Reachable: true, WorkerAlive: true, Version: "0.1.3",
Images: 61, ImagesBytes: 350 << 30, Publications: 2, JobsFailed24h: 1, Exports: 1}
sc.SetStatus(st)
rec = httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Host = "10.10.0.129:8090"
srv.ServeHTTP(rec, req)
body := rec.Body.String()
for _, want := range []string{`href="http://10.10.0.129"`, "RepoISO ↗", `id="repoiso"`, "v0.1.3", "1 failed in 24 h"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard lacks %q", want)
}
}
st.RepoISO = &collect.RepoISO{URL: "http://127.0.0.1", Error: "connection refused"}
sc.SetStatus(st)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if !strings.Contains(rec.Body.String(), "not responding") {
t.Errorf("unreachable RepoISO not shown")
}
}
func TestSharesPageListsExternal(t *testing.T) {
srv, _, _ := newTestServer(t)
f := &shareRunner{}
withShareRunner(srv, f)
dir := t.TempDir()
smbDir, nfsDir := filepath.Join(dir, "smb.conf.d"), filepath.Join(dir, "exports.d")
_ = os.MkdirAll(smbDir, 0o755)
_ = os.MkdirAll(nfsDir, 0o755)
_ = os.WriteFile(filepath.Join(smbDir, "repoiso.conf"), []byte("[iso-pve]\n path = /tank/iso/export/pve\n"), 0o644)
_ = os.WriteFile(filepath.Join(nfsDir, "repoiso.exports"), []byte("\"/tank/iso/export/pve\" 10.10.0.0/24(ro)\n"), 0o644)
srv.shm.IncludeDir, srv.shm.ExportsDir, srv.shm.Exports = smbDir, nfsDir, filepath.Join(nfsDir, shares.DefaultExports)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", "/shares", nil))
body := rec.Body.String()
for _, want := range []string{`id="external"`, "iso-pve", "/tank/iso/export/pve", "10.10.0.0/24(ro)", "RepoISO"} {
if !strings.Contains(body, want) {
t.Errorf("shares page lacks %q", want)
}
}
}
+34
View File
@@ -8,7 +8,9 @@ import (
"io/fs"
"log"
"math"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"sync"
@@ -105,6 +107,7 @@ type pageData struct {
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
@@ -112,6 +115,7 @@ type pageData struct {
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)
}
@@ -288,3 +292,33 @@ func sparkline(vals []float64, max float64, class string) template.HTML {
`<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()
}
+2
View File
@@ -39,6 +39,7 @@ type sharesVM struct {
NForm nfsForm
ApplyErr string // last Apply failure: config saved but not live
Squashes []string
External []shares.ExternalShare // shares of other services (drop-ins), read-only
}
func (s *Server) applyErr() string {
@@ -96,6 +97,7 @@ func (s *Server) sharesView(st sched.Status, form smbForm, nform nfsForm) shares
vm.NForm.Squash = "root"
}
}
vm.External = s.shm.External()
if u, err := s.shm.ListUsers(); err == nil {
vm.Users = u
} else {
+1
View File
@@ -59,6 +59,7 @@ func withShareRunner(srv *Server, f *shareRunner) {
srv.shm = shares.New(f.run)
srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil }
srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000"
srv.shm.IncludeDir, srv.shm.ExportsDir = "", ""
}
func TestSharesSMBFlow(t *testing.T) {
+20
View File
@@ -104,6 +104,26 @@
</table></div>
</section>
{{with .S.RepoISO}}
<section class="card" id="repoiso">
<h2>RepoISO {{if $.RepoISO}}<a class="muted small" href="{{$.RepoISO}}" target="_blank" rel="noopener">open ↗</a>{{end}}
{{if not .Reachable}}<span class="badge crit">not responding</span>
{{else if not .WorkerAlive}}<span class="badge warn">worker stopped</span>
{{else}}<span class="badge ok">running</span>{{end}}
{{if .Version}}<span class="muted small">v{{.Version}}</span>{{end}}</h2>
{{if not .Reachable}}<div class="flash crit">{{.URL}}/api/integration/status: {{.Error}}</div>{{else}}
<div class="tablewrap"><table class="compact">
<tbody>
<tr><td class="muted">Images</td><td class="num">{{.Images}}</td><td>{{bytes .ImagesBytes}}{{if .ImagesPending}} · <span class="muted">{{.ImagesPending}} being checked</span>{{end}}{{if .ImagesError}} · <span class="crit">{{.ImagesError}} with errors</span>{{end}}</td></tr>
<tr><td class="muted">Publications</td><td class="num">{{.Publications}}</td><td>{{if .PublicationsError}}<span class="badge warn">{{.PublicationsError}} not mounted</span>{{else}}<span class="muted">HTTP repositories / file trees</span>{{end}}</td></tr>
<tr><td class="muted">Jobs</td><td class="num">{{.JobsActive}}</td><td>{{if .JobsActive}}active{{else}}<span class="muted">idle</span>{{end}}{{if .JobsFailed24h}} · <span class="badge warn">{{.JobsFailed24h}} failed in 24 h</span>{{end}}</td></tr>
<tr><td class="muted">Proxmox exports</td><td class="num">{{.Exports}}</td><td class="muted">NFS / SMB shares — listed on <a href="/shares#external">Shares</a></td></tr>
<tr><td class="muted">Worker</td><td></td><td class="small">{{if .WorkerHeartbeat}}last heartbeat {{.WorkerHeartbeat}}{{else}}no heartbeat yet{{end}}</td></tr>
</tbody>
</table></div>{{end}}
</section>
{{end}}
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
+1
View File
@@ -16,6 +16,7 @@
<a href="/shares" {{if eq .Page "shares"}}class="on"{{end}}>Shares</a>
<a href="/alerts" {{if eq .Page "alerts"}}class="on"{{end}}>Alerts{{if .Alerts}} <span class="badge crit">{{.Alerts}}</span>{{end}}</a>
<a href="/settings" {{if eq .Page "settings"}}class="on"{{end}}>Settings</a>
{{if .RepoISO}}<a href="{{.RepoISO}}" target="_blank" rel="noopener" title="ISO library, repositories, Proxmox exports">RepoISO ↗</a>{{end}}
</nav>
<span class="refresh" id="refresh-state" title="auto-refresh"></span>
</header>
+11
View File
@@ -67,6 +67,17 @@
<p class="muted small">Tests start only for disks that are awake. A long test on a large HDD takes many hours; disk stays usable.</p>
</section>
<section class="card">
<h2>Integrations</h2>
<div class="form-grid">
<label class="wide">RepoISO URL <input name="repoiso_url" value="{{$c.Integrations.RepoISOURL}}" placeholder="http://127.0.0.1"></label>
</div>
<p class="muted small">RepoISO (ISO library, apt/dnf repositories, Proxmox ISO exports) on this host. nasmon polls
<span class="mono">&lt;url&gt;/api/integration/status</span> every minute, shows a dashboard card, alerts when the portal
or its worker is down, and adds a menu link. Use the local address (<span class="mono">http://127.0.0.1</span>) — the
status endpoint only answers on loopback. Empty disables the integration.</p>
</section>
<div class="actions">
<button type="submit" name="action" value="save">Save</button>
<button type="submit" name="action" value="test" class="secondary">Save &amp; send test e-mail</button>
+21
View File
@@ -140,5 +140,26 @@
</details>
<p class="muted small">Linux: <span class="mono">sudo mount -t nfs {{.Host}}:/nas_pool_1_8_gb/documents /mnt/nas</span>; list exports with <span class="mono">showmount -e {{.Host}}</span>.</p>
</section>
{{if .External}}
<section class="card" id="external">
<h2>Shares of other services <span class="muted small">read-only</span></h2>
<p class="muted small">Defined by other software on this host in <span class="mono">/etc/samba/smb.conf.d/*.conf</span>
(included into smb.conf) and <span class="mono">/etc/exports.d/*.exports</span>. Manage them in that software;
nasmon keeps smbd / nfs-server running while any of them exist.</p>
<div class="tablewrap"><table>
<thead><tr><th>Type</th><th>Source</th><th>Name</th><th>Path</th><th>Clients</th></tr></thead>
<tbody>
{{range .External}}<tr>
<td><span class="badge">{{if eq .Kind "smb"}}SMB{{else}}NFS{{end}}</span></td>
<td>{{if eq .Source "repoiso"}}{{if $.RepoISO}}<a href="{{$.RepoISO}}/proxmox" target="_blank" rel="noopener">RepoISO</a>{{else}}RepoISO{{end}}{{else}}{{.Source}}{{end}}</td>
<td class="mono">{{if .Name}}\\{{$.Data.Host}}\{{.Name}}{{else}}—{{end}}</td>
<td class="mono small">{{.Path}}</td>
<td class="mono small">{{.Clients}}</td>
</tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{end}}
{{end}}