363 lines
12 KiB
Go
363 lines
12 KiB
Go
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
|
||
}
|