mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-24 19:32:06 +00:00
bb99771f20
Fixes regressions from the previous audit-fixes commit (SSH host-key TOFU race/silent-swallow, webhook durability, guest-switch exec race, dashboard save dirty-flag misattribution) plus new findings across both frontend and backend: AI Assistant streaming into the wrong conversation on switch, NodeSystemPanel forms getting clobbered by background refetch, stale ClusterPage vnet/connection selection, NaN-producing numeric form fields, PBS oversized-response parity with the PVE client, gauge/sparkline gradient ID collisions, PBS GC progress surviving tab switches, digest fleet-average skew from fully-offline connections, unbounded console/SSH sessions, and Postgres "?" rebinding corrupting literal "?" in string literals. Also fixes three UI bugs reported directly: the dashboard meter glow being clipped on one side, the "Available widgets" sticky label showing scrolled content through its padding gap, and ClusterActivityWidget's generic error message hiding the real upstream failure reason. Separately: fixes the Release workflow's docker job hanging for GitHub's 360-minute hard cap — the web/go build stages were running under QEMU emulation for the linux/arm64 target instead of natively, which is known to hang Node/npm outright. Pinned both stages to --platform=$BUILDPLATFORM (Go cross-compiles without needing to execute target-arch code) and added a 30-minute job timeout so a real hang fails fast instead of burning hours.
348 lines
11 KiB
Go
348 lines
11 KiB
Go
// Package digest builds the periodic fleet health summary (uptime, backup
|
|
// success rate, active alerts, capacity) and delivers it through the
|
|
// existing notify.Notifier — it doesn't add a new delivery mechanism, only a
|
|
// new message.
|
|
//
|
|
// This package deliberately does NOT import internal/api: internal/api's
|
|
// settings handlers (internal/api/digest_settings.go) import this package to
|
|
// drive the HTTP layer, so the reverse import would be a cycle. Where that
|
|
// means duplicating a shape internal/api/overview.go already has (the
|
|
// per-connection fleet rollup), it's duplicated here in a smaller form
|
|
// rather than shared.
|
|
package digest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"ferrum/internal/connections"
|
|
"ferrum/internal/pbs"
|
|
"ferrum/internal/pve"
|
|
"ferrum/internal/store"
|
|
)
|
|
|
|
// fanoutLimit bounds how many connections are polled concurrently while
|
|
// building a digest, mirroring internal/api's fleetFanoutLimit.
|
|
const fanoutLimit = 8
|
|
|
|
// backupLookback is how far back "backup success rate" looks for vzdump
|
|
// tasks, regardless of how often the digest itself is sent — a digest fired
|
|
// daily still reports the trailing week, since a single day's backup count
|
|
// is too small a sample to mean much.
|
|
const backupLookback = 7 * 24 * time.Hour
|
|
|
|
// maxTasksScanned bounds how many recent cluster tasks are inspected per
|
|
// connection when tallying backups — /cluster/tasks has no server-side time
|
|
// filter (and takes no query parameters at all: pve-manager registers the
|
|
// endpoint with an empty property list, and pmxcfs only broadcasts the
|
|
// newest ~32 KiB of tasks per node anyway), so this is applied client-side
|
|
// after the fetch. It's set high enough to keep everything the endpoint can
|
|
// return, leaving the backupLookback window below as the real filter.
|
|
const maxTasksScanned = 5000
|
|
|
|
// ConnectionSummary is one connection's rollup within a FleetSummary.
|
|
type ConnectionSummary struct {
|
|
Name string
|
|
Type string // "pve" | "pbs" — a pbs row reports Datastores, not nodes/guests
|
|
Error string // non-empty when Reachable is false
|
|
Reachable bool
|
|
|
|
// PBS only: how many backup datastores the server answered with.
|
|
Datastores int
|
|
|
|
Nodes, OnlineNodes int
|
|
Guests, RunningGuests int
|
|
CPUPct, MemPct, StoragePct float64
|
|
BackupTotal, BackupOK int // vzdump tasks observed in backupLookback
|
|
}
|
|
|
|
// FleetSummary is the pure data model behind the digest email/webhook —
|
|
// built once per send, then rendered to text by Render.
|
|
type FleetSummary struct {
|
|
GeneratedAt time.Time
|
|
|
|
Connections []ConnectionSummary
|
|
TotalConnections int
|
|
ReachableConnections int
|
|
|
|
TotalNodes, OnlineNodes int
|
|
TotalGuests, RunningGuests int
|
|
AvgCPUPct, AvgMemPct, AvgStoragePct float64
|
|
|
|
AlertsCritical, AlertsWarning int
|
|
|
|
BackupTotal, BackupOK int
|
|
}
|
|
|
|
// Build queries every configured connection (like internal/api/overview.go's
|
|
// fleetOverviewHandler) plus the active-alert counts, and assembles a
|
|
// FleetSummary. One unreachable connection doesn't abort the whole digest —
|
|
// it's reported inline via ConnectionSummary.Error, same as the overview
|
|
// page.
|
|
func Build(ctx context.Context, db *store.DB, conns *connections.Resolver) (FleetSummary, error) {
|
|
summary := FleetSummary{GeneratedAt: time.Now().UTC()}
|
|
|
|
infos, err := conns.List(ctx)
|
|
if err != nil {
|
|
return summary, fmt.Errorf("listing connections: %w", err)
|
|
}
|
|
summary.TotalConnections = len(infos)
|
|
|
|
crit, warn, err := activeAlertCounts(ctx, db)
|
|
if err != nil {
|
|
// Best-effort: a digest missing alert counts is still useful; don't
|
|
// fail the whole send over it.
|
|
slog.Warn("digest: loading active alert counts failed", "error", err)
|
|
}
|
|
summary.AlertsCritical, summary.AlertsWarning = crit, warn
|
|
|
|
results := make([]ConnectionSummary, len(infos))
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, fanoutLimit)
|
|
for i, info := range infos {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int, info connections.Info) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
results[i] = buildConnectionSummary(ctx, conns, info)
|
|
}(i, info)
|
|
}
|
|
wg.Wait()
|
|
|
|
var cpuSum, memSum, stoSum float64
|
|
var cpuN, memN, stoN int
|
|
for _, cs := range results {
|
|
summary.Connections = append(summary.Connections, cs)
|
|
if !cs.Reachable {
|
|
continue
|
|
}
|
|
summary.ReachableConnections++
|
|
summary.TotalNodes += cs.Nodes
|
|
summary.OnlineNodes += cs.OnlineNodes
|
|
summary.TotalGuests += cs.Guests
|
|
summary.RunningGuests += cs.RunningGuests
|
|
summary.BackupTotal += cs.BackupTotal
|
|
summary.BackupOK += cs.BackupOK
|
|
if cs.OnlineNodes > 0 {
|
|
cpuSum += cs.CPUPct
|
|
memSum += cs.MemPct
|
|
cpuN++
|
|
memN++
|
|
}
|
|
if cs.StoragePct > 0 {
|
|
stoSum += cs.StoragePct
|
|
stoN++
|
|
}
|
|
}
|
|
if cpuN > 0 {
|
|
summary.AvgCPUPct = cpuSum / float64(cpuN)
|
|
}
|
|
if memN > 0 {
|
|
summary.AvgMemPct = memSum / float64(memN)
|
|
}
|
|
if stoN > 0 {
|
|
summary.AvgStoragePct = stoSum / float64(stoN)
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
func buildConnectionSummary(ctx context.Context, conns *connections.Resolver, info connections.Info) ConnectionSummary {
|
|
cs := ConnectionSummary{Name: info.Name, Type: info.Type}
|
|
|
|
if info.Type == "pbs" {
|
|
// PBS remotes don't serve /cluster/resources or the vzdump task list
|
|
// — driven through the PVE path below they'd all be reported
|
|
// UNREACHABLE. Probe with the cheapest authenticated call the PBS
|
|
// client offers instead so a healthy server shows as reachable (its
|
|
// usage numbers come from the PBS-specific pages, not this digest).
|
|
var stores []pbs.Datastore
|
|
err := conns.RetryOnUnauthorized(info.ID, func() error {
|
|
client, cerr := conns.PBSClientFor(ctx, info.ID)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
stores, cerr = client.ListDatastores(ctx)
|
|
return cerr
|
|
})
|
|
if err != nil {
|
|
cs.Error = err.Error()
|
|
return cs
|
|
}
|
|
cs.Reachable = true
|
|
cs.Datastores = len(stores)
|
|
return cs
|
|
}
|
|
|
|
// Retry once on a 401: ticket-auth clients are cached for ticketTTL, and
|
|
// a ticket that expired between two digest sends would otherwise label
|
|
// the whole connection UNREACHABLE even though a fresh login works.
|
|
var resources []pve.ClusterResource
|
|
err := conns.RetryOnUnauthorized(info.ID, func() error {
|
|
client, cerr := conns.ClientFor(ctx, info.ID)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
resources, cerr = client.ClusterResources(ctx)
|
|
return cerr
|
|
})
|
|
if err != nil {
|
|
cs.Error = err.Error()
|
|
return cs
|
|
}
|
|
cs.Reachable = true
|
|
|
|
var cpuCores int
|
|
var usedCore float64
|
|
var memTotal, memUsed int64
|
|
for _, res := range resources {
|
|
switch res.Type {
|
|
case "node":
|
|
cs.Nodes++
|
|
if res.Status == "online" {
|
|
cs.OnlineNodes++
|
|
cpuCores += res.MaxCPU
|
|
usedCore += res.CPU * float64(res.MaxCPU)
|
|
memTotal += res.MaxMem
|
|
memUsed += res.Mem
|
|
}
|
|
case "qemu", "lxc":
|
|
if res.Template == 1 {
|
|
continue
|
|
}
|
|
cs.Guests++
|
|
if res.Status == "running" {
|
|
cs.RunningGuests++
|
|
}
|
|
}
|
|
}
|
|
if cpuCores > 0 {
|
|
cs.CPUPct = usedCore / float64(cpuCores) * 100
|
|
}
|
|
if memTotal > 0 {
|
|
cs.MemPct = float64(memUsed) / float64(memTotal) * 100
|
|
}
|
|
cs.StoragePct = storagePct(resources)
|
|
|
|
var tasks []pve.Task
|
|
err = conns.RetryOnUnauthorized(info.ID, func() error {
|
|
client, cerr := conns.ClientFor(ctx, info.ID)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
tasks, cerr = client.ClusterTasks(ctx, maxTasksScanned)
|
|
return cerr
|
|
})
|
|
if err != nil {
|
|
// Backup stats are a bonus, not required for the rest of the
|
|
// summary to be useful.
|
|
slog.Warn("digest: fetching cluster tasks failed", "connection", info.Name, "error", err)
|
|
return cs
|
|
}
|
|
cutoff := time.Now().Add(-backupLookback).Unix()
|
|
for _, t := range tasks {
|
|
if t.Type != "vzdump" || t.StartTime < cutoff {
|
|
continue
|
|
}
|
|
t.Normalize()
|
|
if t.Status == "running" {
|
|
continue // not finished yet, don't count either way
|
|
}
|
|
cs.BackupTotal++
|
|
if t.Status == "OK" {
|
|
cs.BackupOK++
|
|
}
|
|
}
|
|
return cs
|
|
}
|
|
|
|
// storagePct gives a simple used/total percentage across every storage
|
|
// resource reported >0 capacity, without internal/api's cross-node
|
|
// dedup pass (dedupeSharedStorage) — a digest's storage figure is a rough
|
|
// capacity signal, not a billing-grade total, so a shared NFS mount counted
|
|
// once per mounting node just skews slightly high rather than needing that
|
|
// extra bookkeeping here.
|
|
func storagePct(resources []pve.ClusterResource) float64 {
|
|
var total, used int64
|
|
for _, res := range resources {
|
|
if res.Type != "storage" || res.MaxDisk <= 0 {
|
|
continue
|
|
}
|
|
total += res.MaxDisk
|
|
used += res.Disk
|
|
}
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
return float64(used) / float64(total) * 100
|
|
}
|
|
|
|
func activeAlertCounts(ctx context.Context, db *store.DB) (critical, warning int, err error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT severity, COUNT(*) FROM alert_instances WHERE status = 'active' GROUP BY severity`)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var severity string
|
|
var n int
|
|
if err := rows.Scan(&severity, &n); err != nil {
|
|
return critical, warning, err
|
|
}
|
|
if severity == "critical" {
|
|
critical += n
|
|
} else {
|
|
warning += n
|
|
}
|
|
}
|
|
return critical, warning, rows.Err()
|
|
}
|
|
|
|
// Render turns the summary into an email subject + plain-text body. HTML
|
|
// isn't produced: notify.Notifier.Notify sends a plain-text SMTP body (see
|
|
// internal/notify/notify.go's buildMessage), so an HTML part would never
|
|
// actually be used by the one delivery path this reuses.
|
|
func (s FleetSummary) Render() (subject, plainBody string) {
|
|
subject = fmt.Sprintf("Ferrum fleet digest — %s", s.GeneratedAt.Format("2006-01-02"))
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Ferrum Fleet Health Digest\nGenerated %s\n\n", s.GeneratedAt.Format(time.RFC3339))
|
|
|
|
fmt.Fprintf(&b, "Connections: %d/%d reachable\n", s.ReachableConnections, s.TotalConnections)
|
|
fmt.Fprintf(&b, "Nodes: %d/%d online\n", s.OnlineNodes, s.TotalNodes)
|
|
fmt.Fprintf(&b, "Guests: %d/%d running\n", s.RunningGuests, s.TotalGuests)
|
|
fmt.Fprintf(&b, "Capacity: CPU %.1f%% Memory %.1f%% Storage %.1f%% (fleet average)\n", s.AvgCPUPct, s.AvgMemPct, s.AvgStoragePct)
|
|
if s.BackupTotal > 0 {
|
|
fmt.Fprintf(&b, "Backups (7d): %d/%d succeeded (%.0f%%)\n", s.BackupOK, s.BackupTotal, float64(s.BackupOK)/float64(s.BackupTotal)*100)
|
|
} else {
|
|
b.WriteString("Backups (7d): no vzdump tasks observed\n")
|
|
}
|
|
fmt.Fprintf(&b, "Active alerts: %d critical, %d warning\n", s.AlertsCritical, s.AlertsWarning)
|
|
|
|
b.WriteString("\nPer-connection detail:\n")
|
|
for _, c := range s.Connections {
|
|
if !c.Reachable {
|
|
fmt.Fprintf(&b, " - %s: UNREACHABLE (%s)\n", c.Name, c.Error)
|
|
continue
|
|
}
|
|
if c.Type == "pbs" {
|
|
fmt.Fprintf(&b, " - %s: PBS backup server reachable (%d datastores)\n", c.Name, c.Datastores)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " - %s: %d/%d nodes online, %d/%d guests running, CPU %.0f%%, Mem %.0f%%, Storage %.0f%%",
|
|
c.Name, c.OnlineNodes, c.Nodes, c.RunningGuests, c.Guests, c.CPUPct, c.MemPct, c.StoragePct)
|
|
if c.BackupTotal > 0 {
|
|
fmt.Fprintf(&b, ", backups %d/%d ok", c.BackupOK, c.BackupTotal)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return subject, b.String()
|
|
}
|