munin/internal/web/handlers_system.go
2026-10-19 10:13:56 +02:00

363 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"context"
"fmt"
"net/http"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
"munin/internal/export"
"munin/internal/stats"
"munin/internal/store"
)
// systemData sind die Daten der Systemseite (AUSBAUSTUFEN.md 5.12, 5.16).
type systemData struct {
Backend string // store.Backend.Description, ohne Passwort
Ready bool // Kontroll-Datenbank antwortet
ReadyText string // Grund, falls nicht — nur für den Plattform-Admin
// Hints: Was zu tun ist, die Warnungen zuerst. Leer heißt: alles in
// Ordnung.
Hints []systemHint
Capacity store.Capacity
// Verbindungen: Kontroll-Datenbank und jeder geöffnete Workspace.
Control store.PoolStats
Workspaces []systemWorkspace
OpenConns int // Summe der offenen Verbindungen aller Pools
WaitCount int64
WorkspaceCount int // in der Kontroll-Datenbank, geöffnet oder nicht
// Snapshot: Bestand, Belegung und Aktivität aller Workspaces, höchstens
// stats.MinInterval alt.
Snapshot stats.Snapshot
WriteChart chart
SearchChart chart
ChartDays int
TopProjects []stats.Project // die aktivsten, höchstens topProjects
MoreActive int // aktive Projekte, die nicht mehr in die Liste passen
ExportConfigured bool
Export export.Run
Version string
GoVersion string
StartedAt time.Time
Uptime string
}
// systemWorkspace ist ein geöffneter Workspace auf der Systemseite.
type systemWorkspace struct {
ID string
Name string
Location string
PoolStats store.PoolStats
}
// systemHint ist eine Zeile im Kasten "Zustand" oben auf der Seite.
type systemHint struct {
Warning bool // true: sollte behoben werden; false: zur Kenntnis
Text string
}
const (
// walLimit: Ab dieser Größe der WAL-Datei warnt die Systemseite.
// SQLite schreibt die WAL-Datei normalerweise ab etwa 4 MB in die
// Datenbank zurück (1000 Seiten); ist sie das Sechzehnfache, kommt das
// Zurückschreiben offenbar nicht durch.
walLimit = 64 << 20
// capacityWarning: Ab diesem Anteil der höchstens möglichen Workspaces
// (in Prozent) warnt die Seite — bevor Munin neue ablehnt.
capacityWarning = 80
// exportHeadroom: So lange darf ein Export über sein Intervall hinaus
// ausbleiben, bevor er als überfällig gilt. Ein großer Lauf braucht
// selbst etwas Zeit.
exportHeadroom = 2 * time.Hour
// topProjects: so viele Projekte zeigt die Liste "am aktivsten".
topProjects = 10
)
// handleSystem zeigt dem Plattform-Admin, worauf Munin läuft, ob etwas zu tun
// ist und wie Munin genutzt wird.
//
// Gedacht vor allem für PostgreSQL: Dort teilt sich Munin eine feste Zahl an
// Verbindungen (max_connections). Die Seite zeigt, wie viele gerade offen
// sind, wie viele Workspaces die Grenze erlaubt und ob Anfragen auf
// Verbindungen warten mussten — bevor neue Workspaces abgelehnt werden.
//
// Seit AUSBAUSTUFEN.md 5.16 kommen dazu: ein Kasten mit allem, was zu tun ist,
// die Aktivität der letzten 30 Tage und Bestand und Platz je Workspace.
//
// Nur lesend und nur für den Plattform-Admin: Host und Datenbankname sind
// Betriebsinterna (das Passwort steht nirgends).
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
now := time.Now()
backend := s.workspaces.Backend()
data := systemData{
Backend: backend.Description(),
Control: s.control.PoolStats(),
ExportConfigured: s.exporter != nil,
Version: muninVersion(),
GoVersion: runtime.Version(),
StartedAt: s.startedAt,
Uptime: uptime(now.Sub(s.startedAt)),
}
pingCtx, cancel := context.WithTimeout(ctx, readyTimeout)
if err := s.control.Ping(pingCtx); err != nil {
data.ReadyText = err.Error()
} else {
data.Ready = true
}
cancel()
if k, err := backend.Capacity(ctx); err == nil {
data.Capacity = k
}
names := map[string]string{}
if all, err := s.control.ListWorkspaces(ctx); err == nil {
data.WorkspaceCount = len(all)
for _, ws := range all {
names[ws.ID] = ws.Name
}
}
// Die Kennzahlen aller Workspaces — nur, wenn die Datenbank antwortet.
// Sonst liefe jede Abfrage einzeln in ihr Zeitlimit.
if data.Ready {
data.Snapshot = s.stats.Current(ctx, now)
}
data.ChartDays = len(data.Snapshot.Days)
data.WriteChart = buildChart("Schreibvorgänge je Tag", chartUnit{"Schreibvorgang", "Schreibvorgänge"}, data.Snapshot.Days,
chartSeries{value: func(t stats.Tag) int { return t.AgentWrites }, class: "bar-agent", name: "Agenten"},
chartSeries{value: func(t stats.Tag) int { return t.HumanWrites }, class: "bar-human", name: "Menschen"},
)
data.SearchChart = buildChart("Suchen je Tag", chartUnit{"Suche", "Suchen"}, data.Snapshot.Days,
chartSeries{value: func(t stats.Tag) int { return t.Searches }, class: "bar-search", name: "Suchen"},
)
data.TopProjects = data.Snapshot.Projects
if len(data.TopProjects) > topProjects {
data.MoreActive = len(data.TopProjects) - topProjects
data.TopProjects = data.TopProjects[:topProjects]
}
data.OpenConns = data.Control.OpenConns()
data.WaitCount = data.Control.WaitCount()
for _, opened := range s.workspaces.OpenWorkspaces() {
v := opened.Store.PoolStats()
data.Workspaces = append(data.Workspaces, systemWorkspace{
ID: opened.ID, Name: names[opened.ID], Location: s.workspaces.Location(opened.ID), PoolStats: v,
})
data.OpenConns += v.OpenConns()
data.WaitCount += v.WaitCount()
}
if s.exporter != nil {
data.Export = s.exporter.LastRun()
}
data.Hints = s.systemHints(ctx, now, data, backend.Hints())
s.render(w, r, "system.html", http.StatusOK, pageData{
Title: "System",
Section: "system",
Data: data,
})
}
// systemHints stellt zusammen, was der Plattform-Admin wissen sollte: erst
// die Warnungen (sollte behoben werden), dann die Hinweise (zur Kenntnis).
//
// Fast alles davon prüft Munin auch an anderer Stelle — beim Start, auf der
// Seite "Zugänge", im Log des Exports. Hier steht es an einem Ort, damit ein
// Blick genügt.
func (s *Server) systemHints(ctx context.Context, now time.Time, d systemData, backendHints []string) []systemHint {
var warnings, hints []systemHint
warning := func(format string, args ...any) {
warnings = append(warnings, systemHint{Warning: true, Text: fmt.Sprintf(format, args...)})
}
hint := func(format string, args ...any) {
hints = append(hints, systemHint{Text: fmt.Sprintf(format, args...)})
}
// ── Warnungen ────────────────────────────────────────────────────────────
if k := d.Capacity; k.Limited && k.MaxWorkspaces() > 0 && d.WorkspaceCount*100 >= k.MaxWorkspaces()*capacityWarning {
warning("%d von höchstens %d Workspaces sind angelegt. Vor weiteren beim Datenbank-Admin max_connections erhöhen lassen (README, „Mit PostgreSQL“).",
d.WorkspaceCount, k.MaxWorkspaces())
}
for _, h := range backendHints {
warning("%s", h)
}
for _, f := range d.Snapshot.Errors {
warning("Workspace ließ sich nicht auswerten — %s", f)
}
for _, ws := range d.Snapshot.Workspaces {
if ws.DiskUsage.WAL > walLimit {
warning("Die WAL-Datei von „%s“ ist %s groß. SQLite schreibt sie offenbar nicht mehr in die Datenbank zurück — meist hält eine Leseverbindung zu lange offen. Ein Neustart behebt es; tritt es wieder auf, ins Log schauen.",
ws.Name, byteSize(ws.DiskUsage.WAL))
}
if ws.SchemaMismatch(d.Snapshot.SchemaVersion) {
warning("„%s“ steht auf Schema %s, dieses Programm kennt höchstens %s. Läuft hier eine ältere Fassung von Munin als zuletzt?",
ws.Name, ws.Schema, d.Snapshot.SchemaVersion)
}
}
if d.ExportConfigured {
l := d.Export
switch {
case l.Err != "":
warning("Der letzte Export (%s) war unvollständig: %s", l.End.Local().Format("02.01. 15:04"), l.Err)
case l.End.IsZero() && now.Sub(s.startedAt) > time.Hour:
warning("Seit dem Start vor %s ist kein Export fertig geworden. Läuft er noch, oder hängt er? Siehe Log.", uptime(now.Sub(s.startedAt)))
case !l.End.IsZero() && now.Sub(l.End) > export.Interval+exportHeadroom:
warning("Der letzte Export ist von %s — er läuft sonst täglich. Siehe Log.", l.End.Local().Format("02.01. 15:04"))
}
}
// ── Hinweise ─────────────────────────────────────────────────────────────
// Die Differenz zur vorigen Erhebung; bei der ersten seit dem Start. Ein
// gelöschter Workspace nimmt seine Zahlen mit, deshalb nie unter null.
if since := max(0, s.stats.WaitCount()-d.Snapshot.PrevWaitCount); since > 0 && !d.Snapshot.CollectedAt.IsZero() {
from := "dem Start"
if !d.Snapshot.PrevCollectedAt.IsZero() {
from = d.Snapshot.PrevCollectedAt.Local().Format("15:04")
}
hint("Seit %s mussten Anfragen %d× auf eine freie Datenbankverbindung warten. Ab und zu ist normal; kommt es bei jedem Blick hierher vor, sind die Pools zu klein.",
from, since)
}
if n, where := countPerWorkspace(d.Snapshot.Workspaces, func(b store.Inventory) int { return b.KeysExpiring }); n > 0 {
hint("%s in den nächsten %d Tagen ab (%s). Noch gebraucht? Dann rechtzeitig einen neuen anlegen.",
plural(n, "Key läuft", "Keys laufen"), int(store.ExpiresSoonWithin.Hours()/24), where)
}
if n, where := countPerWorkspace(d.Snapshot.Workspaces, func(b store.Inventory) int { return b.KeysUnused }); n > 0 {
hint("%s seit %d Tagen nicht benutzt (%s). Zu finden unter „Zugänge“ der Projekte, markiert mit „ungenutzt“.",
plural(n, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24), where)
}
if admins, err := s.control.ListActiveWorkspaceAdmins(ctx); err == nil {
unused := 0
for _, a := range admins {
if a.Unused(now) {
unused++
}
}
if unused > 0 {
hint("%s seit %d Tagen nicht benutzt — unter „Workspaces“ prüfen und gegebenenfalls zurückziehen.",
plural(unused, "Workspace-Admin-Zugang ist", "Workspace-Admin-Zugänge sind"), int(store.UnusedAfter.Hours()/24))
}
}
return append(warnings, hints...)
}
// countPerWorkspace zählt einen Wert über alle Workspaces und nennt dazu, wo
// er vorkommt: "Intern 2, Vertrieb 1".
func countPerWorkspace(list []stats.Workspace, value func(store.Inventory) int) (int, string) {
total := 0
var where []string
for _, ws := range list {
if n := value(ws.Inventory); n > 0 {
total += n
where = append(where, fmt.Sprintf("%s %d", ws.Name, n))
}
}
return total, strings.Join(where, ", ")
}
// byteSize zeigt eine Zahl von Bytes lesbar an, mit Komma wie im Deutschen:
// "812 KB", "1,4 GB". Gerechnet wird in Schritten von 1024 — so zählen auch
// Dateisysteme und PostgreSQL (pg_size_pretty).
func byteSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
value := float64(bytes)
for _, unit := range []string{"KB", "MB", "GB", "TB"} {
value /= 1024
if value < 1024 || unit == "TB" {
// Eine Nachkommastelle nur bei kleinen Werten — "812,3 KB" ist
// genauer, als jemand wissen will.
text := fmt.Sprintf("%.0f %s", value, unit)
if value < 10 {
text = strings.Replace(fmt.Sprintf("%.1f %s", value, unit), ".", ",", 1)
}
return text
}
}
return "" // nicht erreichbar: die Schleife endet spätestens bei TB
}
// muninVersion liest die Fassung aus den Angaben, die Go beim Bauen ins
// Programm schreibt: die Git-Fassung (gekürzt), ihr Datum und ob es
// ungespeicherte Änderungen gab. Bei "go run" fehlt das meist.
func muninVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unbekannt"
}
var version, ts string
changed := false
for _, e := range info.Settings {
switch e.Key {
case "vcs.revision":
version = e.Value
if len(version) > 12 {
version = version[:12]
}
case "vcs.time":
ts = e.Value
case "vcs.modified":
changed = e.Value == "true"
}
}
if version == "" {
return "Entwicklungsstand (ohne Git-Angabe gebaut)"
}
if ts != "" {
version += " vom " + ts
}
if changed {
version += ", mit nicht eingecheckten Änderungen"
}
return version
}
// uptime schreibt eine Dauer grob und lesbar: "3 Tage, 4 Std." statt
// "76h12m3.4s".
func uptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
switch {
case days > 0:
return plural(days, "Tag", "Tage") + ", " + plural(hours, "Std.", "Std.")
case hours > 0:
return plural(hours, "Std.", "Std.") + ", " + plural(minutes, "Min.", "Min.")
default:
return plural(minutes, "Min.", "Min.")
}
}
func plural(n int, one, more string) string {
if n == 1 {
return "1 " + one
}
return strconv.Itoa(n) + " " + more
}