package web import ( "context" "fmt" "net/http" "runtime" "runtime/debug" "strconv" "strings" "time" "munin/internal/export" "munin/internal/statistik" "munin/internal/store" ) // systemData sind die Daten der Systemseite (AUSBAUSTUFEN.md 5.12, 5.16). type systemData struct { Speicher string // store.Speicher.Beschreibung, ohne Passwort Bereit bool // Kontroll-Datenbank antwortet BereitText string // Grund, falls nicht — nur für den Plattform-Admin // Hinweise: Was zu tun ist, die Warnungen zuerst. Leer heißt: alles in // Ordnung. Hinweise []systemHinweis Kapazitaet store.Kapazitaet // Verbindungen: Kontroll-Datenbank und jeder geöffnete Workspace. Kontrolle store.Verbindungen Workspaces []systemWorkspace Offen int // Summe der offenen Verbindungen aller Pools Gewartet int64 AnzahlWorkspaces int // in der Kontroll-Datenbank, geöffnet oder nicht // Stand: Bestand, Belegung und Aktivität aller Workspaces, höchstens // statistik.MindestAbstand alt. Stand statistik.Stand DiagrammSchreiben diagramm DiagrammSuchen diagramm TageImDiagramm int TopProjekte []statistik.Projekt // die aktivsten, höchstens topProjekte WeitereAktive int // aktive Projekte, die nicht mehr in die Liste passen ExportEingerichtet bool Export export.Lauf Fassung string GoFassung string Gestartet time.Time Laufzeit string } // systemWorkspace ist ein geöffneter Workspace auf der Systemseite. type systemWorkspace struct { ID string Name string Ort string Verbindungen store.Verbindungen } // systemHinweis ist eine Zeile im Kasten "Zustand" oben auf der Seite. type systemHinweis struct { Warnung bool // true: sollte behoben werden; false: zur Kenntnis Text string } const ( // walGrenze: 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. walGrenze = 64 << 20 // kapazitaetWarnung: Ab diesem Anteil der höchstens möglichen Workspaces // (in Prozent) warnt die Seite — bevor Munin neue ablehnt. kapazitaetWarnung = 80 // exportSpielraum: So lange darf ein Export über sein Intervall hinaus // ausbleiben, bevor er als überfällig gilt. Ein großer Lauf braucht // selbst etwas Zeit. exportSpielraum = 2 * time.Hour // topProjekte: so viele Projekte zeigt die Liste "am aktivsten". topProjekte = 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() jetzt := time.Now() speicher := s.workspaces.Speicher() daten := systemData{ Speicher: speicher.Beschreibung(), Kontrolle: s.control.Verbindungen(), ExportEingerichtet: s.exporter != nil, Fassung: munFassung(), GoFassung: runtime.Version(), Gestartet: s.gestartet, Laufzeit: laufzeit(jetzt.Sub(s.gestartet)), } pingCtx, cancel := context.WithTimeout(ctx, bereitZeitlimit) if err := s.control.Ping(pingCtx); err != nil { daten.BereitText = err.Error() } else { daten.Bereit = true } cancel() if k, err := speicher.Kapazitaet(ctx); err == nil { daten.Kapazitaet = k } namen := map[string]string{} if alle, err := s.control.ListWorkspaces(ctx); err == nil { daten.AnzahlWorkspaces = len(alle) for _, ws := range alle { namen[ws.ID] = ws.Name } } // Die Kennzahlen aller Workspaces — nur, wenn die Datenbank antwortet. // Sonst liefe jede Abfrage einzeln in ihr Zeitlimit. if daten.Bereit { daten.Stand = s.statistik.Aktuell(ctx, jetzt) } daten.TageImDiagramm = len(daten.Stand.Tage) daten.DiagrammSchreiben = baueDiagramm("Schreibvorgänge je Tag", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, daten.Stand.Tage, diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "balken-agent", name: "Agenten"}, diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "balken-mensch", name: "Menschen"}, ) daten.DiagrammSuchen = baueDiagramm("Suchen je Tag", diagrammEinheit{"Suche", "Suchen"}, daten.Stand.Tage, diagrammReihe{wert: func(t statistik.Tag) int { return t.Suchen }, klasse: "balken-suche", name: "Suchen"}, ) daten.TopProjekte = daten.Stand.Projekte if len(daten.TopProjekte) > topProjekte { daten.WeitereAktive = len(daten.TopProjekte) - topProjekte daten.TopProjekte = daten.TopProjekte[:topProjekte] } daten.Offen = daten.Kontrolle.Offen() daten.Gewartet = daten.Kontrolle.Gewartet() for _, offen := range s.workspaces.Offen() { v := offen.Store.Verbindungen() daten.Workspaces = append(daten.Workspaces, systemWorkspace{ ID: offen.ID, Name: namen[offen.ID], Ort: s.workspaces.Ort(offen.ID), Verbindungen: v, }) daten.Offen += v.Offen() daten.Gewartet += v.Gewartet() } if s.exporter != nil { daten.Export = s.exporter.LetzterLauf() } daten.Hinweise = s.systemHinweise(ctx, jetzt, daten, speicher.Hinweise()) s.render(w, r, "system.html", http.StatusOK, pageData{ Title: "System", Bereich: "system", Data: daten, }) } // systemHinweise 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) systemHinweise(ctx context.Context, jetzt time.Time, d systemData, speicherHinweise []string) []systemHinweis { var warnungen, hinweise []systemHinweis warnung := func(format string, args ...any) { warnungen = append(warnungen, systemHinweis{Warnung: true, Text: fmt.Sprintf(format, args...)}) } hinweis := func(format string, args ...any) { hinweise = append(hinweise, systemHinweis{Text: fmt.Sprintf(format, args...)}) } // ── Warnungen ──────────────────────────────────────────────────────────── if k := d.Kapazitaet; k.Begrenzt && k.MaxWorkspaces() > 0 && d.AnzahlWorkspaces*100 >= k.MaxWorkspaces()*kapazitaetWarnung { warnung("%d von höchstens %d Workspaces sind angelegt. Vor weiteren beim Datenbank-Admin max_connections erhöhen lassen (README, „Mit PostgreSQL“).", d.AnzahlWorkspaces, k.MaxWorkspaces()) } for _, h := range speicherHinweise { warnung("%s", h) } for _, f := range d.Stand.Fehler { warnung("Workspace ließ sich nicht auswerten — %s", f) } for _, ws := range d.Stand.Workspaces { if ws.Belegung.WAL > walGrenze { warnung("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, groesse(ws.Belegung.WAL)) } if ws.SchemaAbweichend(d.Stand.Schemastand) { warnung("„%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.Stand.Schemastand) } } if d.ExportEingerichtet { l := d.Export switch { case l.Fehler != "": warnung("Der letzte Export (%s) war unvollständig: %s", l.Ende.Local().Format("02.01. 15:04"), l.Fehler) case l.Ende.IsZero() && jetzt.Sub(s.gestartet) > time.Hour: warnung("Seit dem Start vor %s ist kein Export fertig geworden. Läuft er noch, oder hängt er? Siehe Log.", laufzeit(jetzt.Sub(s.gestartet))) case !l.Ende.IsZero() && jetzt.Sub(l.Ende) > export.Intervall+exportSpielraum: warnung("Der letzte Export ist von %s — er läuft sonst täglich. Siehe Log.", l.Ende.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 seit := max(0, s.statistik.Gewartet()-d.Stand.GewartetVorher); seit > 0 && !d.Stand.Erhoben.IsZero() { ab := "dem Start" if !d.Stand.VorherErhoben.IsZero() { ab = d.Stand.VorherErhoben.Local().Format("15:04") } hinweis("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.", ab, seit) } if n, wo := zaehleJeWorkspace(d.Stand.Workspaces, func(b store.Bestand) int { return b.KeysLaufenAb }); n > 0 { hinweis("%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), wo) } if n, wo := zaehleJeWorkspace(d.Stand.Workspaces, func(b store.Bestand) int { return b.KeysUngenutzt }); n > 0 { hinweis("%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), wo) } if admins, err := s.control.ListActiveWorkspaceAdmins(ctx); err == nil { ungenutzt := 0 for _, a := range admins { if a.Unused(jetzt) { ungenutzt++ } } if ungenutzt > 0 { hinweis("%s seit %d Tagen nicht benutzt — unter „Workspaces“ prüfen und gegebenenfalls zurückziehen.", plural(ungenutzt, "Workspace-Admin-Zugang ist", "Workspace-Admin-Zugänge sind"), int(store.UnusedAfter.Hours()/24)) } } return append(warnungen, hinweise...) } // zaehleJeWorkspace zählt einen Wert über alle Workspaces und nennt dazu, wo // er vorkommt: "Intern 2, Vertrieb 1". func zaehleJeWorkspace(liste []statistik.Workspace, wert func(store.Bestand) int) (int, string) { summe := 0 var wo []string for _, ws := range liste { if n := wert(ws.Bestand); n > 0 { summe += n wo = append(wo, fmt.Sprintf("%s %d", ws.Name, n)) } } return summe, strings.Join(wo, ", ") } // groesse 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 groesse(bytes int64) string { if bytes < 1024 { return fmt.Sprintf("%d B", bytes) } wert := float64(bytes) for _, einheit := range []string{"KB", "MB", "GB", "TB"} { wert /= 1024 if wert < 1024 || einheit == "TB" { // Eine Nachkommastelle nur bei kleinen Werten — "812,3 KB" ist // genauer, als jemand wissen will. text := fmt.Sprintf("%.0f %s", wert, einheit) if wert < 10 { text = strings.Replace(fmt.Sprintf("%.1f %s", wert, einheit), ".", ",", 1) } return text } } return "" // nicht erreichbar: die Schleife endet spätestens bei TB } // munFassung 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 munFassung() string { info, ok := debug.ReadBuildInfo() if !ok { return "unbekannt" } var fassung, zeit string geaendert := false for _, e := range info.Settings { switch e.Key { case "vcs.revision": fassung = e.Value if len(fassung) > 12 { fassung = fassung[:12] } case "vcs.time": zeit = e.Value case "vcs.modified": geaendert = e.Value == "true" } } if fassung == "" { return "Entwicklungsstand (ohne Git-Angabe gebaut)" } if zeit != "" { fassung += " vom " + zeit } if geaendert { fassung += ", mit nicht eingecheckten Änderungen" } return fassung } // laufzeit schreibt eine Dauer grob und lesbar: "3 Tage, 4 Std." statt // "76h12m3.4s". func laufzeit(d time.Duration) string { tage := int(d.Hours()) / 24 stunden := int(d.Hours()) % 24 minuten := int(d.Minutes()) % 60 switch { case tage > 0: return plural(tage, "Tag", "Tage") + ", " + plural(stunden, "Std.", "Std.") case stunden > 0: return plural(stunden, "Std.", "Std.") + ", " + plural(minuten, "Min.", "Min.") default: return plural(minuten, "Min.", "Min.") } } func plural(n int, eins, mehr string) string { if n == 1 { return "1 " + eins } return strconv.Itoa(n) + " " + mehr }