mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
P5b: notifier transport registry (webhook/slack/discord/telegram/ gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules (failsBeforeDown, latencyWarn, certDays, mute). P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip collectors, crt.sh subdomain discovery with daily sweep. P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login rate limit, public-route bypass) and multiple boards (boards table, sections.board_id, /api/boards CRUD, slug routing). Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
231 lines
6.0 KiB
Go
231 lines
6.0 KiB
Go
package main
|
|
|
|
// Optional collectors: SMART (smartctl), ZFS (zpool), GPU (nvidia-smi or
|
|
// /sys/class/drm). Every collector is exec-or-skip — missing tools or
|
|
// permissions produce nil, never errors, and each exec carries a timeout so a
|
|
// wedged tool can't stall reporting.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// runOut executes a command with a timeout; empty output on any failure.
|
|
func runOut(timeout time.Duration, name string, args ...string) []byte {
|
|
if _, err := exec.LookPath(name); err != nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, name, args...).Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- SMART ----------
|
|
|
|
type smartctlScan struct {
|
|
Devices []struct {
|
|
Name string `json:"name"`
|
|
} `json:"devices"`
|
|
}
|
|
|
|
type smartctlAll struct {
|
|
ModelName string `json:"model_name"`
|
|
SmartStatus *struct {
|
|
Passed bool `json:"passed"`
|
|
} `json:"smart_status"`
|
|
Temperature *struct {
|
|
Current float64 `json:"current"`
|
|
} `json:"temperature"`
|
|
}
|
|
|
|
// readSMART scans devices via smartctl -j --scan, then reads each drive.
|
|
// Needs smartmontools installed and usually root or sudo rights for /dev.
|
|
func readSMART() []smartDisk {
|
|
scan := runOut(5*time.Second, "smartctl", "-j", "--scan")
|
|
if scan == nil {
|
|
return nil
|
|
}
|
|
var sc smartctlScan
|
|
if json.Unmarshal(scan, &sc) != nil || len(sc.Devices) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]smartDisk, 0, len(sc.Devices))
|
|
for i, d := range sc.Devices {
|
|
if i >= 8 { // bound the fan-out
|
|
break
|
|
}
|
|
b := runOut(4*time.Second, "smartctl", "-j", "-a", d.Name)
|
|
if b == nil {
|
|
continue
|
|
}
|
|
var a smartctlAll
|
|
if json.Unmarshal(b, &a) != nil {
|
|
continue
|
|
}
|
|
sd := smartDisk{Device: d.Name, Model: a.ModelName}
|
|
if a.SmartStatus != nil {
|
|
p := a.SmartStatus.Passed
|
|
sd.Passed = &p
|
|
}
|
|
if a.Temperature != nil {
|
|
sd.TempC = a.Temperature.Current
|
|
}
|
|
out = append(out, sd)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- ZFS ----------
|
|
|
|
// readZFS prefers `zpool list -j` (OpenZFS 2.3+), falls back to the stable
|
|
// `-H -p` text format. Parse failures degrade to nil.
|
|
func readZFS() []zfsPool {
|
|
if b := runOut(4*time.Second, "zpool", "list", "-j", "--json-int",
|
|
"-o", "name,health,size,free"); b != nil {
|
|
if pools := parseZpoolJSON(b); pools != nil {
|
|
return pools
|
|
}
|
|
}
|
|
return parseZpoolText(runOut(4*time.Second,
|
|
"zpool", "list", "-H", "-p", "-o", "name,health,size,free"))
|
|
}
|
|
|
|
// parseZpoolJSON reads {"pools":{"name":{"properties":{...}}}}.
|
|
func parseZpoolJSON(b []byte) []zfsPool {
|
|
var doc struct {
|
|
Pools map[string]struct {
|
|
Properties map[string]struct {
|
|
Value any `json:"value"`
|
|
} `json:"properties"`
|
|
} `json:"pools"`
|
|
}
|
|
if json.Unmarshal(b, &doc) != nil || len(doc.Pools) == 0 {
|
|
return nil
|
|
}
|
|
var out []zfsPool
|
|
num := func(v any) float64 {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case string:
|
|
f, _ := strconv.ParseFloat(n, 64)
|
|
return f
|
|
}
|
|
return 0
|
|
}
|
|
for name, p := range doc.Pools {
|
|
z := zfsPool{Name: name}
|
|
if v, ok := p.Properties["health"]; ok {
|
|
if s, ok := v.Value.(string); ok {
|
|
z.Health = s
|
|
}
|
|
}
|
|
if v, ok := p.Properties["size"]; ok {
|
|
z.Size = num(v.Value)
|
|
}
|
|
if v, ok := p.Properties["free"]; ok {
|
|
z.Free = num(v.Value)
|
|
}
|
|
out = append(out, z)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseZpoolText reads `zpool list -H -p` rows: name\thealth\tsize\tfree.
|
|
func parseZpoolText(b []byte) []zfsPool {
|
|
var out []zfsPool
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) < 4 {
|
|
continue
|
|
}
|
|
size, _ := strconv.ParseFloat(f[2], 64)
|
|
free, _ := strconv.ParseFloat(f[3], 64)
|
|
out = append(out, zfsPool{Name: f[0], Health: f[1], Size: size, Free: free})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------- GPU ----------
|
|
|
|
// readGPU prefers nvidia-smi (CSV query); otherwise walks /sys/class/drm for
|
|
// AMD/Intel cards exposing gpu_busy_percent and hwmon temps.
|
|
func readGPU() []gpuStat {
|
|
if _, err := exec.LookPath("nvidia-smi"); err == nil {
|
|
return readNvidiaGPU()
|
|
}
|
|
return readGPUSysfs()
|
|
}
|
|
|
|
func readNvidiaGPU() []gpuStat {
|
|
return parseNvidiaCSV(runOut(4*time.Second, "nvidia-smi",
|
|
"--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total",
|
|
"--format=csv,noheader,nounits"))
|
|
}
|
|
|
|
// parseNvidiaCSV reads rows: name, tempC, util%, memUsed MiB, memTotal MiB.
|
|
func parseNvidiaCSV(b []byte) []gpuStat {
|
|
var out []gpuStat
|
|
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
|
|
f := strings.Split(line, ",")
|
|
if len(f) < 5 {
|
|
continue
|
|
}
|
|
g := gpuStat{Name: strings.TrimSpace(f[0])}
|
|
g.TempC, _ = strconv.ParseFloat(strings.TrimSpace(f[1]), 64)
|
|
g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(f[2]), 64)
|
|
g.MemUsed, _ = strconv.ParseFloat(strings.TrimSpace(f[3]), 64)
|
|
g.MemTotal, _ = strconv.ParseFloat(strings.TrimSpace(f[4]), 64)
|
|
g.MemUsed *= 1 << 20
|
|
g.MemTotal *= 1 << 20
|
|
out = append(out, g)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// readGPUSysfs pulls util/temp for each card from DRM sysfs.
|
|
func readGPUSysfs() []gpuStat {
|
|
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
|
|
var out []gpuStat
|
|
for _, card := range cards {
|
|
dev := card + "/device"
|
|
g := gpuStat{Name: filepath.Base(card)}
|
|
if b, err := os.ReadFile(dev + "/gpu_busy_percent"); err == nil {
|
|
g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
|
|
}
|
|
// hwmon: first temp1_input under device/hwmon/hwmonN.
|
|
if hws, err := filepath.Glob(dev + "/hwmon/hwmon*"); err == nil {
|
|
for _, hw := range hws {
|
|
if b, err := os.ReadFile(hw + "/temp1_input"); err == nil {
|
|
if milli, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64); err == nil {
|
|
g.TempC = milli / 1000
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Driver name via uevent DRIVER= for a friendlier label.
|
|
if b, err := os.ReadFile(dev + "/uevent"); err == nil {
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if v, ok := strings.CutPrefix(line, "DRIVER="); ok {
|
|
g.Name += " (" + v + ")"
|
|
}
|
|
}
|
|
}
|
|
if g.UtilPct > 0 || g.TempC > 0 {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out
|
|
}
|