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 }