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