Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30dbb00dd1 | ||
|
|
661a862f8d |
+12
-48
@@ -1,8 +1,5 @@
|
||||
name: build
|
||||
|
||||
# Every push to main is released: it gets the next patch tag (v0.1.3 -> v0.1.4)
|
||||
# and a Gitea release with the deb/rpm. Pushing a v* tag by hand releases that
|
||||
# exact version instead (use it to bump minor/major).
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
@@ -17,8 +14,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # tags + history for the version and release notes
|
||||
|
||||
- uses: https://gitea.com/actions/setup-go@v5
|
||||
with:
|
||||
@@ -29,27 +24,6 @@ jobs:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
- name: release version
|
||||
id: ver
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --tags --force -q
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
# re-run of an already released commit keeps its tag
|
||||
tag=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)
|
||||
if [ -z "$tag" ]; then
|
||||
last=$(git tag | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)
|
||||
IFS=. read -r major minor patch <<<"${last:-v0.0.0}"
|
||||
tag="${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
fi
|
||||
echo "release $tag"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
|
||||
|
||||
@@ -68,41 +42,31 @@ jobs:
|
||||
dist/*.rpm
|
||||
|
||||
- name: publish release
|
||||
if: steps.ver.outputs.tag != ''
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.api_url }}/repos/${{ github.repository }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
auth="Authorization: token $GITEA_TOKEN"
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
# release notes: commit subjects since the previous release tag
|
||||
prev=$(git tag --merged HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -vx "$TAG" | sort -V | tail -1 || true)
|
||||
git log --no-merges --pretty='- %s' ${prev:+"$prev"..}HEAD > notes.md
|
||||
body=$(python3 -c 'import json,os,sys; print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "target_commitish": os.environ["GITHUB_SHA"], "body": open("notes.md").read()}))')
|
||||
|
||||
# creating the release also creates the tag on this commit
|
||||
release_id=$(curl -sf -X POST "$API/releases" -H "$auth" \
|
||||
-H "Content-Type: application/json" -d "$body" \
|
||||
release_id=$(curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
|
||||
| grep -oP '"id":\s*\K[0-9]+' | head -1) || \
|
||||
release_id=$(curl -sf "$API/releases/tags/$TAG" -H "$auth" \
|
||||
release_id=$(curl -sf "$API/releases/tags/$tag" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "could not create or find a release for tag $TAG" >&2
|
||||
echo "could not create or find a release for tag $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing=$(curl -sf "$API/releases/$release_id/assets" -H "$auth" || true)
|
||||
for f in dist/*.deb dist/*.rpm; do
|
||||
[ -e "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
if grep -q "\"name\":\"$name\"" <<<"$existing"; then
|
||||
echo "$name already attached"
|
||||
continue
|
||||
fi
|
||||
curl -sf -X POST "$API/releases/$release_id/assets?name=$name" \
|
||||
-H "$auth" -F "attachment=@${f}"
|
||||
curl -sf -X POST "$API/releases/$release_id/assets?name=$(basename "$f")" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-F "attachment=@${f}"
|
||||
done
|
||||
echo "released $TAG: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
|
||||
|
||||
@@ -11,6 +11,10 @@ share management from the UI.
|
||||
* Service: `systemctl status nasmon` / `journalctl -u nasmon -f`
|
||||
* JSON API: `/api/status`, `/api/history?hours=24`, `/api/disks/<serial>/history?days=30`
|
||||
|
||||
Integrates with RepoISO on the same host: shares of other services
|
||||
(`/etc/samba/smb.conf.d`, `/etc/exports.d`) are included and kept running,
|
||||
and RepoISO health is shown on the dashboard and alerted on.
|
||||
|
||||
No authentication — designed for a LAN-only NAS (the `/shares` page can
|
||||
create SMB/NFS users and exports, so keep it off the public internet).
|
||||
|
||||
@@ -43,14 +47,6 @@ One-time tool setup:
|
||||
go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
CI (`.gitea/workflows/build.yml`) releases every push to `main`: it tags the
|
||||
commit with the next patch version (v0.1.3 → v0.1.4), builds deb + rpm with
|
||||
that version and publishes a Gitea release with both packages and the commit
|
||||
subjects as notes. To bump minor/major, push a tag yourself (`git tag v0.2.0 &&
|
||||
git push origin v0.2.0`); later pushes to main continue from it.
|
||||
|
||||
## Packaging
|
||||
|
||||
`packaging/nfpm.yaml` builds both formats from one spec:
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ 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"`
|
||||
@@ -49,6 +56,7 @@ type Settings struct {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -420,6 +427,7 @@ func (s *Scheduler) evaluate() {
|
||||
CPUTemp: st.CPUTemp, HasCPU: st.HasCPUTemp,
|
||||
RootFS: st.RootFS, RootOK: st.RootFS.Total > 0,
|
||||
Prev: map[string]*collect.Smart{},
|
||||
RepoISO: st.RepoISO,
|
||||
}
|
||||
for _, v := range st.Disks {
|
||||
in.Disks = append(in.Disks, v.Disk)
|
||||
|
||||
@@ -29,12 +29,17 @@ type Manager struct {
|
||||
UID, GID string
|
||||
SMBConf string
|
||||
Exports string
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"><url>/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 & send test e-mail</button>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -47,3 +47,29 @@ Alerts: the × on an open alert acknowledges it (hidden and not mailed until the
|
||||
problem changes or clears); the × on a resolved alert removes it from the log.
|
||||
Resolved alerts older than 7 days are hidden by default, older than 90 days
|
||||
deleted.
|
||||
|
||||
## Shares of other services
|
||||
|
||||
Other software on the host can publish shares without fighting over the
|
||||
config files nasmon owns:
|
||||
|
||||
* SMB: drop a fragment into /etc/samba/smb.conf.d/<name>.conf. nasmon appends
|
||||
"include = <file>" for every *.conf there to the smb.conf it generates.
|
||||
* NFS: write /etc/exports.d/<name>.exports.
|
||||
|
||||
nasmon lists these shares read-only on /shares and keeps smbd / nfs-server
|
||||
enabled while any of them exist, even with no nasmon shares (previously it
|
||||
disabled the daemons and took such shares down). When SMB is switched off in
|
||||
nasmon, only nasmon's own shares are removed.
|
||||
|
||||
## RepoISO integration
|
||||
|
||||
Settings → Integrations → RepoISO URL (e.g. http://127.0.0.1). nasmon then
|
||||
polls <url>/api/integration/status every minute and
|
||||
|
||||
* shows a RepoISO card on the dashboard (images, publications, jobs, Proxmox
|
||||
exports, worker heartbeat) and a "RepoISO" menu link;
|
||||
* alerts: crit when the portal does not respond, warn when its worker has no
|
||||
heartbeat, when jobs failed in the last 24 h or publications are not mounted.
|
||||
|
||||
The status endpoint answers only on loopback, so use the local address.
|
||||
|
||||
Reference in New Issue
Block a user