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
+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}}