Initial import: nasmon health monitor, split out of home_nas

Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 10:24:34 +03:00
co-authored by Claude Sonnet 5
commit 09159a5f31
61 changed files with 7882 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .LastMailErr}}<div class="flash crit">Last mail delivery failed: {{.LastMailErr}}</div>{{end}}
<section class="card">
<h2>Alert log <span class="muted small">{{if .All}}everything{{else}}open + resolved in the last 7 days{{end}}</span></h2>
{{if not .List}}<p class="muted">Nothing has happened yet.</p>{{end}}
{{range .List}}
<div class="alert {{if .ResolvedAt}}resolved{{else if .Acked}}acked{{else}}{{.Level}}{{end}}">
{{if .ResolvedAt}}
<form method="post" action="/alerts/{{.ID}}/delete" class="x-form"><button type="submit" class="x" title="Remove from the log">&times;</button></form>
{{else if not .Acked}}
<form method="post" action="/alerts/{{.ID}}/ack" class="x-form"><button type="submit" class="x" title="Acknowledge: hide until it changes or resolves">&times;</button></form>
{{end}}
<div class="alert-title">
{{if .ResolvedAt}}<span class="badge">resolved</span>{{else if .Acked}}<span class="badge">acked</span>{{else}}<span class="badge {{.Level}}">{{.Level}}</span>{{end}}
{{.Title}}
</div>
<pre class="alert-detail">{{.Detail}}</pre>
<div class="muted small">
opened {{dt .OpenedAt}}
{{if .ResolvedAt}} · resolved {{dtp .ResolvedAt}}{{end}}
{{if .Acked}} · acknowledged {{dtp .AckedAt}} · <form method="post" action="/alerts/{{.ID}}/unack" class="inline x-form"><button type="submit" class="link">show again</button></form>{{end}}
{{if .LastNotifiedAt}} · mailed {{dtp .LastNotifiedAt}}{{else if not .ResolvedAt}} · <span class="warn-text">not mailed</span>{{end}}
· <span class="mono">{{.Key}}</span>
</div>
</div>
{{end}}
{{if .All}}<p class="small"><a href="/alerts">Hide resolved alerts older than 7 days</a></p>
{{else if .Hidden}}<p class="small"><a href="/alerts?all=1">Show all ({{.Hidden}} older resolved hidden)</a></p>{{end}}
<p class="muted small">&times; on an open alert acknowledges it: it leaves the dashboard and stops e-mail reminders until the problem changes (higher level, grown counters) or resolves by itself. &times; on a resolved alert removes it from the log; resolved alerts older than 90 days are removed automatically.</p>
</section>
{{end}}
{{end}}
+117
View File
@@ -0,0 +1,117 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{template "alertlist" .S.Alerts}}
<section class="grid metrics">
<div class="card metric">
<h2>CPU <span class="muted">{{.S.CPU.Cores}} cores</span></h2>
<div class="big {{levelClass .S.CPU.UsagePct 70 90}}">{{f0 .S.CPU.UsagePct}}%</div>
<div class="muted">load {{f1 (index .S.CPU.Load 0)}} / {{f1 (index .S.CPU.Load 1)}} / {{f1 (index .S.CPU.Load 2)}}</div>
{{.CPUSpark}}
<div class="muted small">last 24 h</div>
</div>
<div class="card metric">
<h2>Memory <span class="muted">{{bytes .S.Mem.Total}}</span></h2>
<div class="big {{levelClass .MemPct 80 95}}">{{f0 .MemPct}}%</div>
<div class="bar">
<span class="fill mem" style="width:{{f1 .MemPct}}%"></span>
{{if .S.Mem.ARCSize}}<span class="fill arc" style="width:{{f1 .ARCPct}}%"></span>{{end}}
</div>
<div class="muted">used {{bytes .S.Mem.Used}}{{if .S.Mem.ARCSize}} · <span class="arc-dot"></span>ARC {{bytes .S.Mem.ARCSize}} / {{bytes .S.Mem.ARCMax}}{{end}}</div>
{{if .S.Mem.SwapTotal}}<div class="muted">swap {{bytes .S.Mem.SwapUsed}} / {{bytes .S.Mem.SwapTotal}} ({{f0 .SwapPct}}%)</div>{{end}}
{{.MemSpark}}
<div class="muted small">last 24 h</div>
</div>
<div class="card metric">
<h2>Temperature</h2>
{{if .S.HasCPUTemp}}<div class="big {{tempClass .S.CPUTemp .Th.CPUTempC}}">{{f0 .S.CPUTemp}}°C</div><div class="muted">CPU</div>{{else}}<div class="big muted">—</div><div class="muted">no CPU sensor</div>{{end}}
{{.TempSpark}}
<details class="sensors">
<summary class="muted small">{{len .S.Temps}} sensors</summary>
<table class="compact">
{{range .S.Temps}}<tr><td class="muted">{{.Chip}}</td><td>{{.Label}}</td><td class="num">{{f0 .C}}°C</td></tr>{{end}}
</table>
</details>
</div>
<div class="card metric">
<h2>System</h2>
<div class="kv">
<div><span class="muted">host</span> {{.S.Host}}</div>
<div><span class="muted">root fs</span> {{bytes .S.RootFS.Free}} free of {{bytes .S.RootFS.Total}}</div>
<div><span class="muted">SMART check</span> {{ago .S.LastSmartCheck}}{{if .S.SmartRunning}} <span class="badge">running</span>{{else}} · next {{dt .S.NextSmartCheck}}{{end}}</div>
<div><span class="muted">nasmon</span> {{.S.Version}} · up {{dur .S.Uptime}}</div>
{{if .S.LastMailErr}}<div class="crit-text">mail error: {{.S.LastMailErr}}</div>{{end}}
</div>
<form method="post" action="/smart/run" class="inline"><button type="submit" {{if .S.SmartRunning}}disabled{{end}}>Check SMART now</button></form>
</div>
</section>
<section class="card">
<h2>Disks</h2>
{{if .S.DisksErr}}<div class="flash crit">{{.S.DisksErr}}</div>{{end}}
<div class="tablewrap"><table>
<thead><tr><th>Device</th><th>Model</th><th>Serial</th><th class="num">Size</th><th>Bus</th><th class="num">Temp</th><th>Health</th><th class="num">Power-on</th><th>Self-test</th><th>Warnings</th></tr></thead>
<tbody>
{{range .S.Disks}}{{$sm := .Smart}}
<tr>
<td><a href="/disks/{{.Serial}}">{{.Name}}</a></td>
<td>{{.Model}}</td>
<td class="mono small">{{.Serial}}</td>
<td class="num">{{bytes .Size}}</td>
<td class="muted">{{.Tran}}{{if not .Rota}} ssd{{end}}</td>
{{if $sm}}
<td class="num">{{if $sm.HasTemp}}<span class="{{tempClass $sm.TempC $.Data.Th.DiskTempC}}">{{$sm.TempC}}°C</span>{{if .Standby}} <span class="muted small" title="disk in standby, value from last read">zz</span>{{end}}{{else}}—{{end}}</td>
<td>{{if .Error}}<span class="badge warn" title="{{.Error}}">n/a</span>{{else if not $sm.Supported}}<span class="muted">n/a</span>{{else if $sm.Healthy}}<span class="badge ok">PASSED</span>{{else}}<span class="badge crit">FAILED</span>{{end}}</td>
<td class="num">{{if $sm.PowerOnHrs}}{{$sm.PowerOnHrs}} h{{else}}—{{end}}</td>
<td class="small">{{with $sm.LastTest}}{{if .InProgress}}<span class="badge">{{.Remaining}}% left</span>{{else}}<span class="{{if .Passed}}ok{{else}}crit{{end}}">{{.Status}}</span>{{end}}{{else}}—{{end}}</td>
<td class="small">
{{range $sm.Attrs}}{{if and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}<span class="badge crit" title="{{.Name}}">{{.ID}}: {{.Raw}}</span> {{end}}{{if .WhenFailed}}<span class="badge crit">{{.ID}} {{.WhenFailed}}</span> {{end}}{{end}}
{{with $sm.NVMe}}{{if .CriticalWarning}}<span class="badge crit">warn 0x{{printf "%02x" .CriticalWarning}}</span> {{end}}{{if .MediaErrors}}<span class="badge crit">media err {{.MediaErrors}}</span> {{end}}<span class="muted">{{.PercentageUsed}}% used</span>{{end}}
</td>
{{else}}
<td class="num">—</td><td>{{if .Standby}}<span class="muted">standby</span>{{else if .Error}}<span class="badge warn" title="{{.Error}}">n/a</span>{{else}}<span class="muted">pending</span>{{end}}</td><td></td><td></td><td></td>
{{end}}
</tr>
{{else}}<tr><td colspan="10" class="muted">no disks found</td></tr>{{end}}
</tbody>
</table></div>
</section>
<section class="card">
<h2>ZFS pools <a class="muted small" href="/zfs">details</a></h2>
{{if .S.PoolsErr}}<div class="flash crit">{{.S.PoolsErr}}</div>{{end}}
<div class="tablewrap"><table>
<thead><tr><th>Pool</th><th>Health</th><th class="num">Used</th><th class="num">Free</th><th class="num">Size</th><th>Capacity</th><th class="num">Frag</th><th>Last scrub</th><th>Errors</th></tr></thead>
<tbody>
{{range .S.Pools}}
<tr>
<td><a href="/zfs#{{.Name}}">{{.Name}}</a></td>
<td><span class="badge {{healthClass .Health}}">{{.Health}}</span></td>
<td class="num">{{bytes .Alloc}}</td>
<td class="num">{{bytes .Free}}</td>
<td class="num">{{bytes .Size}}</td>
<td><div class="bar small"><span class="fill {{capClass .CapPct 75 90}}" style="width:{{.CapPct}}%"></span></div> <span class="small">{{.CapPct}}%</span></td>
<td class="num">{{.FragPct}}%</td>
<td class="small">{{if .Scrubbing}}<span class="badge">scrubbing</span>{{else if .LastScrub}}{{agop .LastScrub}}{{else}}<span class="muted">never</span>{{end}}</td>
<td class="small">{{if .Errors}}{{.Errors}}{{end}}</td>
</tr>
{{else}}<tr><td colspan="9" class="muted">no pools</td></tr>{{end}}
</tbody>
</table></div>
</section>
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
<thead><tr><th>Dataset</th><th>Mountpoint</th><th class="num">Used</th><th class="num">Referenced</th><th class="num">Available</th></tr></thead>
<tbody>
{{range .S.Datasets}}<tr><td class="mono">{{.Name}}</td><td class="muted mono small">{{.Mountpoint}}</td><td class="num">{{bytes .Used}}</td><td class="num">{{bytes .Refer}}</td><td class="num">{{bytes .Avail}}</td></tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{end}}
+80
View File
@@ -0,0 +1,80 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}{{$sm := .D.Smart}}
<section class="card">
<h2>{{.D.Model}} <span class="muted">{{.D.Serial}}</span></h2>
<div class="kv cols">
<div><span class="muted">device</span> {{.D.Path}}</div>
{{if .D.Size}}<div><span class="muted">size</span> {{bytes .D.Size}}</div>{{end}}
<div><span class="muted">bus</span> {{.D.Tran}}{{if .D.Rota}} (rotational){{else}} (ssd){{end}}</div>
{{if $sm}}
<div><span class="muted">protocol</span> {{$sm.Protocol}}</div>
<div><span class="muted">firmware</span> {{$sm.Firmware}}</div>
<div><span class="muted">health</span> {{if not $sm.Supported}}<span class="muted">not supported</span>{{else if $sm.Healthy}}<span class="badge ok">PASSED</span>{{else}}<span class="badge crit">FAILED</span>{{end}}</div>
<div><span class="muted">temperature</span> {{if $sm.HasTemp}}<span class="{{tempClass $sm.TempC $.Data.Th.DiskTempC}}">{{$sm.TempC}}°C</span>{{else}}—{{end}}{{if .D.Standby}} <span class="muted">(standby, last read)</span>{{end}}</div>
<div><span class="muted">power-on</span> {{hours $sm.PowerOnHrs}}</div>
<div><span class="muted">power cycles</span> {{$sm.PowerCycles}}</div>
<div><span class="muted">last SMART read</span> {{dt $sm.CheckedAt}}</div>
{{with $sm.LastTest}}<div><span class="muted">self-test</span> {{if .InProgress}}<span class="badge">in progress, {{.Remaining}}% left</span>{{else}}{{.Type}}: <span class="{{if .Passed}}ok{{else}}crit{{end}}">{{.Status}}</span> <span class="muted">at {{.Hours}} h</span>{{end}}</div>{{end}}
{{if $sm.Error}}<div class="crit-text">{{$sm.Error}}</div>{{end}}
{{end}}
</div>
{{if .Known}}<form method="post" action="/disks/{{.D.Serial}}/forget" class="inline" onsubmit="return confirm('Forget this disk? It will no longer trigger a \'disk disappeared\' alert.')"><button type="submit" class="secondary">Forget disk</button></form>{{end}}
</section>
{{if $sm}}
{{with $sm.NVMe}}
<section class="card">
<h2>NVMe health</h2>
<div class="kv cols">
<div><span class="muted">critical warning</span> {{if .CriticalWarning}}<span class="crit">0x{{printf "%02x" .CriticalWarning}}</span>{{else}}<span class="ok">none</span>{{end}}</div>
<div><span class="muted">percentage used</span> {{.PercentageUsed}}%</div>
<div><span class="muted">available spare</span> {{.AvailableSpare}}% <span class="muted">(threshold {{.SpareThreshold}}%)</span></div>
<div><span class="muted">media errors</span> {{if .MediaErrors}}<span class="crit">{{.MediaErrors}}</span>{{else}}0{{end}}</div>
<div><span class="muted">error log entries</span> {{.ErrLogEntries}}</div>
<div><span class="muted">unsafe shutdowns</span> {{.UnsafeShutdowns}}</div>
</div>
</section>
{{end}}
<section class="card">
<h2>Temperature, last 30 days <span class="muted small">{{len .History}} snapshots</span></h2>
{{.TempSpark}}
</section>
{{if $sm.Attrs}}
<section class="card">
<h2>SMART attributes</h2>
<div class="tablewrap"><table>
<thead><tr><th class="num">ID</th><th>Name</th><th class="num">Value</th><th class="num">Worst</th><th class="num">Thresh</th><th class="num">Raw</th><th>Failed</th></tr></thead>
<tbody>
{{range $sm.Attrs}}
{{$crit := and (or (eq .ID 5) (eq .ID 187) (eq .ID 188) (eq .ID 197) (eq .ID 198)) (gt .Raw 0)}}
<tr class="{{if or $crit .WhenFailed}}row-crit{{end}}">
<td class="num">{{.ID}}</td><td class="mono">{{.Name}}</td>
<td class="num">{{.Value}}</td><td class="num">{{.Worst}}</td><td class="num">{{.Thresh}}</td>
<td class="num">{{.RawString}}</td>
<td>{{if .WhenFailed}}<span class="badge crit">{{.WhenFailed}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{if .History}}
<section class="card">
<h2>History</h2>
<div class="tablewrap"><table class="compact">
<thead><tr><th>Time</th><th class="num">Temp</th><th>Health</th><th class="num">Hours</th><th class="num">{{if $sm.NVMe}}Media err{{else}}Realloc (5){{end}}</th><th class="num">Pending (197)</th><th class="num">CRC (199)</th></tr></thead>
<tbody>
{{range .HistoryRev}}<tr><td class="small">{{dt (unix .T)}}</td><td class="num">{{.TempC}}°C</td><td>{{if .Healthy}}<span class="ok">ok</span>{{else}}<span class="crit">FAIL</span>{{end}}</td><td class="num">{{.Hours}}</td><td class="num">{{.Realloc}}</td><td class="num">{{.Pending}}</td><td class="num">{{.CRC}}</td></tr>{{end}}
</tbody>
</table></div>
</section>
{{end}}
{{else}}
<section class="card"><p class="muted">No SMART data yet{{if .D.Standby}} — disk is in standby, waiting for it to wake up{{end}}.</p></section>
{{end}}
{{end}}
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · nasmon</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232d7ff9'/%3E%3C/svg%3E">
</head>
<body data-page="{{.Page}}">
<header class="top">
<a class="brand" href="/">nasmon</a>
<nav>
<a href="/" {{if eq .Page "dashboard"}}class="on"{{end}}>Dashboard</a>
<a href="/zfs" {{if eq .Page "zfs"}}class="on"{{end}}>ZFS</a>
<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>
</nav>
<span class="refresh" id="refresh-state" title="auto-refresh"></span>
</header>
<main id="content">{{template "content" .}}</main>
<script src="/static/app.js"></script>
</body>
</html>{{end}}
{{define "flash"}}{{if .Flash}}<div class="flash ok">{{.Flash}}</div>{{end}}{{if .Error}}<div class="flash crit">{{.Error}}</div>{{end}}{{end}}
{{define "alertlist"}}{{if .}}
<section class="card alerts-open">
<h2>Open alerts</h2>
{{range .}}<div class="alert {{.Level}}">
<form method="post" action="/alerts/{{.ID}}/ack" class="x-form"><button type="submit" class="x" title="Acknowledge: hide until it changes or resolves">&times;</button></form>
<div class="alert-title"><span class="badge {{.Level}}">{{.Level}}</span> {{.Title}}</div>
<pre class="alert-detail">{{.Detail}}</pre>
<div class="muted">since {{dt .OpenedAt}}</div>
</div>{{end}}
</section>{{end}}{{end}}
+76
View File
@@ -0,0 +1,76 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}{{$c := .C}}
<form method="post" action="/settings" class="settings">
<section class="card">
<h2>E-mail (SMTP)</h2>
<div class="form-grid">
<label>Server <input name="smtp_host" value="{{$c.SMTP.Host}}" placeholder="smtp.yandex.ru"></label>
<label>Port <input name="smtp_port" type="number" min="1" max="65535" value="{{$c.SMTP.Port}}"></label>
<label>Security
<select name="smtp_security">
<option value="starttls" {{if eq $c.SMTP.Security "starttls"}}selected{{end}}>STARTTLS (587)</option>
<option value="tls" {{if eq $c.SMTP.Security "tls"}}selected{{end}}>TLS / SSL (465)</option>
<option value="none" {{if eq $c.SMTP.Security "none"}}selected{{end}}>none (25)</option>
</select>
</label>
<label>User <input name="smtp_user" value="{{$c.SMTP.User}}" autocomplete="off"></label>
<label>Password <input name="smtp_pass" type="password" autocomplete="new-password" placeholder="{{if .HasPass}}•••••••• (unchanged){{else}}not set{{end}}">
{{if .HasPass}}<span class="small"><input type="checkbox" name="smtp_clear_pass" value="1"> clear stored password</span>{{end}}</label>
<label>From <input name="smtp_from" value="{{$c.SMTP.From}}" placeholder="same as user"></label>
<label class="wide">Recipients <textarea name="smtp_to" rows="2" placeholder="one address per line">{{.ToText}}</textarea></label>
<label>Subject prefix <input name="smtp_prefix" value="{{$c.SMTP.SubjectPrefix}}"></label>
</div>
<p class="muted small">Gmail/Yandex/Mail.ru need an app password, not the account password. Yandex: smtp.yandex.ru 465 TLS; Gmail: smtp.gmail.com 587 STARTTLS.</p>
</section>
<section class="card">
<h2>Alerts</h2>
<div class="form-grid">
<label class="check"><input type="checkbox" name="alerts_enabled" value="1" {{if $c.AlertsEnabled}}checked{{end}}> Send e-mail alerts</label>
<label class="check"><input type="checkbox" name="notify_resolved" value="1" {{if $c.NotifyResolved}}checked{{end}}> Also mail when a problem clears</label>
<label>Remind about open alerts every <span class="unit"><input name="remind_hours" type="number" min="0" value="{{$c.RemindHours}}"> hours (0 = never)</span></label>
</div>
</section>
<section class="card">
<h2>SMART polling</h2>
<div class="form-grid">
<label>Check every <span class="unit"><input name="smart_interval" type="number" min="1" value="{{$c.SmartIntervalMin}}"> minutes</span></label>
<label class="check"><input type="checkbox" name="smart_skip_standby" value="1" {{if $c.SmartSkipStandby}}checked{{end}}> Do not wake disks in standby</label>
</div>
</section>
<section class="card">
<h2>Thresholds</h2>
<div class="form-grid">
<label>Disk temperature <span class="unit"><input name="th_disk_temp" type="number" min="0" value="{{$c.Thresholds.DiskTempC}}"> °C</span></label>
<label>CPU temperature <span class="unit"><input name="th_cpu_temp" type="number" min="0" value="{{$c.Thresholds.CPUTempC}}"> °C</span></label>
<label>Pool capacity <span class="unit"><input name="th_pool_cap" type="number" min="0" max="100" value="{{$c.Thresholds.PoolCapacityPct}}"> %</span></label>
<label>Scrub older than <span class="unit"><input name="th_scrub_days" type="number" min="0" value="{{$c.Thresholds.ScrubMaxAgeDays}}"> days</span></label>
<label>Root filesystem free below <span class="unit"><input name="th_rootfs_free" type="number" min="0" max="100" value="{{$c.Thresholds.RootFSFreePct}}"> %</span></label>
<label>NVMe percentage used <span class="unit"><input name="th_nvme_used" type="number" min="0" max="100" value="{{$c.Thresholds.NVMeUsedPct}}"> %</span></label>
<label>NVMe spare below <span class="unit"><input name="th_nvme_spare" type="number" min="0" max="100" value="{{$c.Thresholds.NVMeSparePct}}"> %</span></label>
</div>
<p class="muted small">0 disables a threshold. SMART health FAILED, reallocated/pending sectors, pool state and vdev errors always alert.</p>
</section>
<section class="card">
<h2>Scheduled self-tests</h2>
<div class="form-grid">
<label class="check"><input type="checkbox" name="st_enabled" value="1" {{if $c.SelfTests.Enabled}}checked{{end}}> Run SMART self-tests automatically</label>
<label>Short test every <span class="unit"><input name="st_short_days" type="number" min="0" value="{{$c.SelfTests.ShortDays}}"> days</span></label>
<label>Long test every <span class="unit"><input name="st_long_days" type="number" min="0" value="{{$c.SelfTests.LongDays}}"> days</span></label>
<label>Start window from <span class="unit"><input name="st_hour" type="number" min="0" max="23" value="{{$c.SelfTests.Hour}}">:00, 3 hours</span></label>
</div>
<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>
<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>
</div>
</form>
{{end}}
{{end}}
+141
View File
@@ -0,0 +1,141 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .ApplyErr}}<div class="flash crit">Configuration saved but not applied: {{.ApplyErr}}
<form method="post" action="/shares/apply" class="inline"><button type="submit" class="secondary">Apply again</button></form></div>{{end}}
<datalist id="mountpoints">{{range .Mountpoints}}<option value="{{.}}">{{end}}</datalist>
<section class="card" id="smb">
<h2>SMB (Windows / macOS network shares)
{{if not .Svc.SMBInstalled}}<span class="badge warn">not installed</span>
{{else if .Svc.SMBActive}}<span class="badge ok">running</span> <span class="muted small">{{.Svc.SMBSessions}} session(s)</span>
{{else}}<span class="badge">stopped</span>{{end}}
</h2>
{{if not .Svc.SMBInstalled}}<p class="warn-text">Samba is not installed — run <span class="mono">sudo apt install samba wsdd</span> on the NAS (install.sh does it). Shares can be defined now and are applied once Samba is there.</p>{{end}}
<form method="post" action="/shares/smb/settings" class="form-grid">
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.SMB.Enabled}}checked{{end}}> SMB server enabled</label>
<label>Workgroup <input name="workgroup" value="{{.C.SMB.Workgroup}}"></label>
<label>Description <input name="server_string" value="{{.C.SMB.ServerString}}"></label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save SMB settings</button></span></div>
</form>
<h3>Shares</h3>
<div class="tablewrap"><table>
<thead><tr><th>Name</th><th>Path</th><th>Access</th><th>Who</th><th>Connect as</th><th></th></tr></thead>
<tbody>
{{range .C.SMB.Shares}}
<tr>
<td><b>{{.Name}}</b>{{if .Comment}} <span class="muted small">{{.Comment}}</span>{{end}}{{if not .Browseable}} <span class="badge">hidden</span>{{end}}</td>
<td class="mono small">{{.Path}}</td>
<td>{{if .ReadOnly}}read-only{{else}}read/write{{end}}</td>
<td>{{if .Guest}}<span class="ok">guest</span> (no password){{else}}{{join .Users ", "}}{{end}}</td>
<td class="mono small">\\{{$.Data.Host}}\{{.Name}}</td>
<td class="small nowrap">
<a href="/shares?edit={{.Name}}#smbform">edit</a> ·
<form method="post" action="/shares/smb/delete" class="inline" onsubmit="return confirm('Delete share {{.Name}}? Files are not touched.')"><input type="hidden" name="name" value="{{.Name}}"><button type="submit" class="link">delete</button></form>
</td>
</tr>
{{else}}<tr><td colspan="6" class="muted">No SMB shares yet.</td></tr>{{end}}
</tbody>
</table></div>
<details class="newds" id="smbform" {{if or (not .Form.New) .Form.Path $.Error}}open{{end}}>
<summary>{{if .Form.New}}New share{{else}}Edit share {{.Form.Orig}}{{end}}</summary>
<form method="post" action="/shares/smb/save" class="ds-form">
<input type="hidden" name="orig" value="{{.Form.Orig}}">
<div class="form-grid">
<label>Share name <input name="name" required value="{{.Form.Name}}" placeholder="documents"></label>
<label>Path <input name="path" required list="mountpoints" value="{{.Form.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
<label>Comment <input name="comment" value="{{.Form.Comment}}"></label>
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .Form.ReadOnly}}checked{{end}}> Read-only</label>
<label class="check"><input type="checkbox" name="guest" value="1" {{if .Form.Guest}}checked{{end}}> Guest access (anyone on the LAN, no password)</label>
<label class="check"><input type="checkbox" name="browseable" value="1" {{if .Form.Browseable}}checked{{end}}> Visible in the share list</label>
<label class="check"><input type="checkbox" name="hide_sys" value="1" {{if .Form.HideSys}}checked{{end}}> Hide ._*, .DS_Store, Thumbs.db, desktop.ini</label>
<div class="wide field">
<span class="field-name">Allowed users (when guest access is off)</span>
<span class="checks">{{range .Users}}<label class="check-item"><input type="checkbox" name="users" value="{{.}}" {{if contains $.Data.Form.Users .}}checked{{end}}> {{.}}</label>{{else}}<span class="muted">no SMB users yet — add one below</span>{{end}}</span>
</div>
</div>
<p class="small muted">Files are read and written as <b>{{.Owner}}</b> regardless of the SMB user, so containers and shares see the same ownership.</p>
<button type="submit">{{if .Form.New}}Create share{{else}}Save share{{end}}</button>
{{if not .Form.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form>
</details>
<h3 id="users">SMB users</h3>
<div class="tablewrap"><table class="compact">
<tbody>
{{range .Users}}<tr><td class="mono">{{.}}</td><td class="small">
<form method="post" action="/shares/users/delete" class="inline" onsubmit="return confirm('Delete SMB user {{.}}?')"><input type="hidden" name="name" value="{{.}}"><button type="submit" class="link">delete</button></form>
</td></tr>{{else}}<tr><td class="muted">No SMB users. Guest shares work without users.</td></tr>{{end}}
</tbody>
</table></div>
<form method="post" action="/shares/users/save" class="form-grid">
<label>User <input name="name" required placeholder="alice" list="smbusers" autocomplete="off"></label>
<datalist id="smbusers">{{range .Users}}<option value="{{.}}">{{end}}</datalist>
<label>Password <input name="password" type="password" required minlength="4" autocomplete="new-password"></label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Add user / set password</button></span></div>
</form>
<p class="muted small">Windows: <span class="mono">\\{{.Host}}\share</span> in Explorer, or map a network drive. macOS: Finder → Go → Connect to Server → <span class="mono">smb://{{.Host}}/share</span>.
Note: Windows 10/11 Pro/Enterprise refuse <i>guest</i> shares by default (error 0x80070035 «network path not found») — use an SMB user with a password, or allow guest access on the PC (gpedit: Lanman Workstation → Enable insecure guest logons).</p>
</section>
<section class="card" id="nfs">
<h2>NFS (Linux / other NAS)
{{if not .Svc.NFSInstalled}}<span class="badge warn">not installed</span>
{{else if .Svc.NFSActive}}<span class="badge ok">running</span>
{{else}}<span class="badge">stopped</span>{{end}}
</h2>
{{if not .Svc.NFSInstalled}}<p class="warn-text">NFS server is not installed — run <span class="mono">sudo apt install nfs-kernel-server</span> on the NAS (install.sh does it).</p>{{end}}
<form method="post" action="/shares/nfs/settings" class="form-grid">
<label class="check"><input type="checkbox" name="enabled" value="1" {{if .C.NFS.Enabled}}checked{{end}}> NFS server enabled</label>
<div class="field"><span class="field-name">&nbsp;</span><span><button type="submit" class="secondary">Save NFS settings</button></span></div>
</form>
<div class="tablewrap"><table>
<thead><tr><th>Path</th><th>Clients</th><th>Access</th><th>Squash</th><th>Mount</th><th></th></tr></thead>
<tbody>
{{range .C.NFS.Exports}}
<tr>
<td class="mono small">{{.Path}}</td>
<td class="mono small">{{join .Clients ", "}}</td>
<td>{{if .ReadOnly}}read-only{{else}}read/write{{end}}{{if .Async}} <span class="muted small">async</span>{{end}}</td>
<td>{{.Squash}}</td>
<td class="mono small">{{$.Data.Host}}:{{.Path}}</td>
<td class="small nowrap">
<a href="/shares?nedit={{.Path}}#nfsform">edit</a> ·
<form method="post" action="/shares/nfs/delete" class="inline" onsubmit="return confirm('Remove NFS export {{.Path}}?')"><input type="hidden" name="path" value="{{.Path}}"><button type="submit" class="link">delete</button></form>
</td>
</tr>
{{else}}<tr><td colspan="6" class="muted">No NFS exports yet.</td></tr>{{end}}
</tbody>
</table></div>
<details class="newds" id="nfsform" {{if or (not .NForm.New) .NForm.Path}}open{{end}}>
<summary>{{if .NForm.New}}New export{{else}}Edit export {{.NForm.Orig}}{{end}}</summary>
<form method="post" action="/shares/nfs/save" class="ds-form">
<input type="hidden" name="orig" value="{{.NForm.Orig}}">
<div class="form-grid">
<label>Path <input name="path" required list="mountpoints" value="{{.NForm.Path}}" placeholder="/nas_pool_1_8_gb/documents"></label>
<label>Clients <input name="clients" required value="{{.NForm.Clients}}" placeholder="192.168.101.0/24, pc.lan, *"></label>
<label>Squash
<select name="squash">
<option value="root" {{if eq .NForm.Squash "root"}}selected{{end}}>root → {{.Owner}} (root_squash)</option>
<option value="all" {{if eq .NForm.Squash "all"}}selected{{end}}>everyone → {{.Owner}} (all_squash)</option>
<option value="none" {{if eq .NForm.Squash "none"}}selected{{end}}>none (no_root_squash, trusted clients only)</option>
</select>
</label>
<label class="check"><input type="checkbox" name="read_only" value="1" {{if .NForm.ReadOnly}}checked{{end}}> Read-only</label>
<label class="check"><input type="checkbox" name="async" value="1" {{if .NForm.Async}}checked{{end}}> async writes (faster, less safe on power loss)</label>
</div>
<p class="small muted">Nested datasets under the path are exported too (crossmnt). Clients: host names, IPs, networks like 192.168.101.0/24, or * for everyone.</p>
<button type="submit">{{if .NForm.New}}Create export{{else}}Save export{{end}}</button>
{{if not .NForm.New}}<a href="/shares" class="small">cancel</a>{{end}}
</form>
</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>
{{end}}
{{end}}
+82
View File
@@ -0,0 +1,82 @@
{{define "content"}}
{{template "flash" .}}
{{with .Data}}
{{if .S.PoolsErr}}<div class="flash crit">{{.S.PoolsErr}}</div>{{end}}
{{$form := .Form}}
{{range .S.Pools}}
<section class="card" id="{{.Name}}">
<h2>{{.Name}} <span class="badge {{healthClass .Health}}">{{.Health}}</span></h2>
<div class="kv cols">
<div><span class="muted">size</span> {{bytes .Size}}</div>
<div><span class="muted">allocated</span> {{bytes .Alloc}} ({{.CapPct}}%)</div>
<div><span class="muted">free</span> {{bytes .Free}}</div>
<div><span class="muted">fragmentation</span> {{.FragPct}}%</div>
<div><span class="muted">last scrub</span> {{if .LastScrub}}{{dtp .LastScrub}} ({{agop .LastScrub}}){{else}}never{{end}}</div>
<div><span class="muted">errors</span> {{if .Errors}}{{.Errors}}{{else}}—{{end}}</div>
</div>
<div class="bar"><span class="fill {{capClass .CapPct $.Data.Th.PoolCapacityPct 95}}" style="width:{{.CapPct}}%"></span></div>
{{if .Scan}}<p class="small"><span class="muted">scan:</span> {{.Scan}}</p>{{end}}
{{if .Status}}<p class="small warn-text"><span class="muted">status:</span> {{.Status}}</p>{{end}}
{{if .Action}}<p class="small"><span class="muted">action:</span> {{.Action}}</p>{{end}}
<div class="tablewrap"><table class="compact vdevs">
<thead><tr><th>vdev</th><th>State</th><th class="num">Read</th><th class="num">Write</th><th class="num">Cksum</th><th></th></tr></thead>
<tbody>
{{range .Vdevs}}
<tr class="{{if or .Read .Write .Cksum}}row-crit{{end}}">
<td class="mono" style="padding-left:{{.Depth}}.5em">{{.Name}}</td>
<td>{{if .State}}<span class="{{healthClass .State}}">{{.State}}</span>{{end}}</td>
<td class="num">{{.Read}}</td><td class="num">{{.Write}}</td><td class="num">{{.Cksum}}</td>
<td class="muted small">{{.Note}}</td>
</tr>
{{end}}
</tbody>
</table></div>
<details class="newds newds-{{.Name}}" {{if eq $form.Pool .Name}}open{{end}}>
<summary>New dataset in {{.Name}}</summary>
<form method="post" action="/zfs/create" class="ds-form">
<input type="hidden" name="pool" value="{{.Name}}">
<div class="form-grid">
<label>Name <input name="name" required placeholder="media/video" value="{{if eq $form.Pool .Name}}{{$form.Name}}{{end}}"></label>
<label>Owner (uid:gid) <input name="owner" value="{{$form.Owner}}" placeholder="1000:1000"></label>
<label>Compression
<select name="compression">
<option value="" {{if eq $form.Compression ""}}selected{{end}}>inherit</option>
<option value="lz4" {{if eq $form.Compression "lz4"}}selected{{end}}>lz4</option>
<option value="zstd" {{if eq $form.Compression "zstd"}}selected{{end}}>zstd</option>
<option value="off" {{if eq $form.Compression "off"}}selected{{end}}>off</option>
</select>
</label>
<label>Quota <input name="quota" placeholder="none, or 100G / 1.5T" value="{{if eq $form.Pool .Name}}{{$form.Quota}}{{end}}"></label>
</div>
<p class="small muted">Creates {{.Name}}/&lt;name&gt; (parents are created too), mounts it at /{{.Name}}/&lt;name&gt; and chowns the mountpoint to the owner.</p>
<button type="submit">Create dataset</button>
</form>
</details>
</section>
{{else}}<section class="card"><p class="muted">No ZFS pools found.</p></section>{{end}}
<section class="card">
<h2>Datasets</h2>
<div class="tablewrap"><table>
<thead><tr><th>Dataset</th><th>Mountpoint</th><th class="num">Used</th><th class="num">Referenced</th><th class="num">Available</th><th></th></tr></thead>
<tbody>
{{range .Datasets}}<tr>
<td class="mono">{{.Name}}</td><td class="muted mono small">{{.Mountpoint}}</td>
<td class="num">{{bytes .Used}}</td><td class="num">{{bytes .Refer}}</td><td class="num">{{bytes .Avail}}</td>
<td class="small">{{if hasPrefix .Mountpoint "/"}}<a href="/shares?path={{.Mountpoint}}">share…</a> {{end}}{{if .CanDestroy}}
<details class="destroy destroy-{{.DomID}}">
<summary class="muted">destroy…</summary>
<form method="post" action="/zfs/destroy" class="inline ds-destroy">
<input type="hidden" name="name" value="{{.Name}}">
<input name="confirm" placeholder="type {{.Name}} to confirm" size="30" required>
<button type="submit" class="secondary danger">Destroy empty dataset</button>
</form>
</details>
{{end}}</td>
</tr>{{end}}
</tbody>
</table></div>
<p class="small muted">Only empty datasets (no data, no children, no snapshots) can be destroyed here.</p>
</section>
{{end}}
{{end}}