munin/internal/web/handlers_ueberblick.go

286 lines
9.9 KiB
Go

package web
import (
"context"
"fmt"
"net/http"
"sort"
"time"
"munin/internal/auth"
"munin/internal/statistik"
"munin/internal/store"
)
// Überblick eines Workspace (AUSBAUSTUFEN.md 5.17).
//
// Das Gegenstück zur Systemseite, aber für den Workspace-Admin: nur sein
// Workspace und nur, was er selbst in die Hand nehmen kann — Zugänge, die
// Pflege der Inhalte, die Frage, ob die Teams Munin benutzen. Verbindungen,
// Platz, Schema und Export bleiben auf der Systemseite; daran kann er nichts
// ändern.
// Schwellen für die Hinweise. Bewusst grob: Sie sollen auf ein Projekt
// aufmerksam machen, nicht jede Schwankung melden.
const (
// Ab so vielen Suchen im Zeitraum ist ein Anteil ohne Treffer
// aussagekräftig — bei drei Suchen wäre eine leere schon 33 %.
leereSuchenAb = 10
// Ab diesem Anteil ohne Treffer (in Prozent) lohnt ein Blick: Dann
// fehlt Wissen, oder Agenten suchen mit anderen Wörtern, als in den
// Einträgen stehen.
leereSuchenProzent = 30
// pruefenHoechstens: So viele Einträge „zur Prüfung“ liest der
// Überblick je Projekt, um sie zu zählen. CuratedOverrides braucht eine
// Obergrenze; mehr als das prüft ohnehin niemand am Stück.
pruefenHoechstens = 1000
)
// ueberblickData sind die Daten des Überblicks.
type ueberblickData struct {
Workspace string // Name
// Gruppen: was zu tun ist, je Projekt zusammengefasst. Projekte mit
// einer Warnung zuerst, sonst alphabetisch. Projekte ohne Befund fehlen.
Gruppen []ueberblickGruppe
Projekte []ueberblickProjekt // alphabetisch
Summe store.Bestand
Schreiben int // im Zeitraum, alle Projekte
Suchen int
TageImDiagramm int
DiagrammSchreiben diagramm
DiagrammSuchen diagramm
}
// ueberblickGruppe sind die Befunde eines Projekts. Je Projekt eine Karte
// statt einer Zeile je Befund: Bei 20 Projekten mit je drei Befunden würde
// eine flache Liste unlesbar lang, und derselbe Projektname stünde dreimal
// untereinander.
type ueberblickGruppe struct {
Projekt string // Name
Warnung bool // mindestens ein Befund ist dringend
Punkte []ueberblickHinweis
}
// ueberblickHinweis ist ein Befund, mit einem Link genau dorthin, wo er zu
// erledigen ist.
type ueberblickHinweis struct {
Warnung bool
Text string
Link string
LinkText string
}
// ueberblickProjekt ist eine Zeile der Projekttabelle.
type ueberblickProjekt struct {
statistik.Projekt
Eintraege int
Zuletzt time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine
OhneTreffer int // Anteil der Suchen ohne Treffer in Prozent, -1 = keine Suchen
}
// handleUeberblick zeigt den Überblick des gewählten Workspace — für
// Workspace-Admins und den Plattform-Admin (der damit sieht, was der
// Workspace-Admin sieht).
func (s *Server) handleUeberblick(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
ctx := r.Context()
jetzt := time.Now()
eintrag, err := s.control.WorkspaceByID(ctx, sess.WorkspaceID)
if err != nil {
s.serverError(w, "Workspace konnte nicht gelesen werden", err)
return
}
// Aktivität je Tag und je Projekt: dieselbe Zählung wie auf der
// Systemseite, nur für diesen einen Workspace.
stand := s.statistik.FuerWorkspace(ctx, eintrag, jetzt)
if len(stand.Fehler) > 0 {
s.serverError(w, "Überblick konnte nicht erhoben werden", fmt.Errorf("%v", stand.Fehler))
return
}
// Einträge und jüngste Änderung je Projekt, in einer Abfrage für alle.
nutzung, err := ws.Store.UsageSummaries(ctx, "", jetzt)
if err != nil {
s.serverError(w, "Nutzung konnte nicht gelesen werden", err)
return
}
daten := ueberblickData{
Workspace: eintrag.Name,
TageImDiagramm: len(stand.Tage),
}
if len(stand.Workspaces) == 1 {
daten.Summe = stand.Workspaces[0].Bestand
}
for _, t := range stand.Tage {
daten.Schreiben += t.Schreiben()
daten.Suchen += t.Suchen
}
daten.DiagrammSchreiben = baueDiagramm("Schreibvorgänge je Tag", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, 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"}, stand.Tage,
diagrammReihe{wert: func(t statistik.Tag) int { return t.Suchen }, klasse: "balken-suche", name: "Suchen"},
)
// Aktive und ruhige Projekte kommen getrennt aus der Erhebung; hier
// stehen alle in einer Tabelle, alphabetisch.
alle := append(append([]statistik.Projekt{}, stand.Projekte...), stand.Ruhig...)
sort.Slice(alle, func(i, j int) bool { return alle[i].Name < alle[j].Name })
for _, p := range alle {
zeile := ueberblickProjekt{Projekt: p, OhneTreffer: -1}
u := nutzung[p.ID]
zeile.Eintraege, zeile.Zuletzt = u.Entries, u.LastUpdatedAt
suchen, err := ws.Store.SearchStatsFor(ctx, p.ID, jetzt)
if err != nil {
s.serverError(w, "Suchprotokoll konnte nicht gelesen werden", err)
return
}
if suchen.Total > 0 {
zeile.OhneTreffer = suchen.EmptyPercent()
}
daten.Projekte = append(daten.Projekte, zeile)
warnungen, hinweise, err := projektHinweise(ctx, ws.Store, p, suchen, jetzt)
if err != nil {
s.serverError(w, "Hinweise konnten nicht erhoben werden", err)
return
}
if len(warnungen)+len(hinweise) > 0 {
daten.Gruppen = append(daten.Gruppen, ueberblickGruppe{
Projekt: p.Name,
Warnung: len(warnungen) > 0,
Punkte: append(warnungen, hinweise...), // Dringendes zuerst
})
}
}
// Projekte mit Warnung nach oben. SliceStable erhält innerhalb beider
// Gruppen die alphabetische Reihenfolge von oben.
sort.SliceStable(daten.Gruppen, func(i, j int) bool {
return daten.Gruppen[i].Warnung && !daten.Gruppen[j].Warnung
})
s.render(w, r, "ueberblick.html", http.StatusOK, pageData{
Title: "Überblick",
Bereich: "ueberblick",
Data: daten,
})
}
// projektHinweise prüft ein Projekt auf das, was ein Workspace-Admin
// erledigen kann. Warnungen sind dringend (ein Zugang endet bald, niemand
// kommt hinein), Hinweise eine Bitte, bei Gelegenheit hinzusehen.
//
// Jede Prüfung nimmt dieselbe Regel wie die Seite, auf die sie verlinkt —
// "ungenutzt" wie unter Zugänge, "veraltet" wie in der Eintragsliste, "zur
// Prüfung" wie auf der Nutzungsseite. Wer dem Link folgt, findet dort genau
// das, was hier gezählt wurde.
func projektHinweise(ctx context.Context, st *store.Store, p statistik.Projekt, suchen store.SearchStats, jetzt time.Time) (warnungen, hinweise []ueberblickHinweis, err error) {
zugaenge := "/p/" + p.Slug + "/keys"
nutzung := "/p/" + p.Slug + "/usage"
keys, err := st.ListAPIKeys(ctx, p.ID)
if err != nil {
return nil, nil, err
}
gueltig, admins, ungenutzt := 0, 0, 0
for _, k := range keys {
if !k.Valid(jetzt) {
continue
}
gueltig++
if k.Role == string(auth.RoleAdmin) {
admins++
}
if k.Unused(jetzt) {
ungenutzt++
}
if k.ExpiresSoon(jetzt) {
// Wie bisTag: angezeigt wird der letzte gültige Tag.
warnungen = append(warnungen, ueberblickHinweis{
Warnung: true,
Text: fmt.Sprintf("Der Key „%s“ gilt nur noch bis %s. Noch gebraucht? Dann jetzt einen neuen anlegen und im Client austauschen.",
k.Name, k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006")),
Link: zugaenge, LinkText: "Zugänge",
})
}
}
switch {
case gueltig == 0:
warnungen = append(warnungen, ueberblickHinweis{
Warnung: true,
Text: "Kein gültiger Key — weder das Team noch ein Agent kommt hinein.",
Link: zugaenge, LinkText: "Key anlegen",
})
case admins == 0:
hinweise = append(hinweise, ueberblickHinweis{
Text: "Kein Key mit der Rolle admin — das Team kann seine Zugänge nicht selbst verwalten und muss jedes Mal dich fragen.",
Link: zugaenge, LinkText: "Zugänge",
})
}
if ungenutzt > 0 {
hinweise = append(hinweise, ueberblickHinweis{
Text: fmt.Sprintf("%s seit %d Tagen nicht benutzt. Weiß niemand mehr, wofür, zurückziehen.",
plural(ungenutzt, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24)),
Link: zugaenge, LinkText: "Zugänge",
})
}
// Ein Projekt ohne Key ist schon oben gewarnt; dass dort nichts
// geschieht, ist dann keine Nachricht.
if p.Summe() == 0 && gueltig > 0 {
hinweise = append(hinweise, ueberblickHinweis{
Text: fmt.Sprintf("In %d Tagen weder geschrieben noch gesucht. Wird das Projekt noch gebraucht, oder fehlt dem Team die Anbindung?", statistik.Tage),
Link: nutzung, LinkText: "Nutzung",
})
}
if suchen.Total >= leereSuchenAb && suchen.EmptyPercent() >= leereSuchenProzent {
hinweise = append(hinweise, ueberblickHinweis{
Text: fmt.Sprintf("%d %% der Suchen fanden nichts. Entweder fehlt Wissen, oder die Agenten suchen mit anderen Wörtern, als in den Einträgen stehen.",
suchen.EmptyPercent()),
Link: nutzung, LinkText: "Suchen ohne Treffer",
})
}
pruefen, err := st.CuratedOverrides(ctx, p.ID, pruefenHoechstens)
if err != nil {
return nil, nil, err
}
if n := len(pruefen); n > 0 {
text := "Ein kuratierter Eintrag wurde von einem Agenten geändert und wartet auf Prüfung durch das Team."
if n > 1 {
text = fmt.Sprintf("%d kuratierte Einträge wurden von Agenten geändert und warten auf Prüfung durch das Team.", n)
}
hinweise = append(hinweise, ueberblickHinweis{
Text: text, Link: nutzung, LinkText: "Zur Prüfung",
})
}
veraltet, err := st.CountStaleEntries(ctx, p.ID, jetzt)
if err != nil {
return nil, nil, err
}
if veraltet > 0 {
text := "Ein Eintrag ist seit über einem Jahr unverändert. Das Team prüfen lassen, ob er noch stimmt."
if veraltet > 1 {
text = fmt.Sprintf("%d Einträge sind seit über einem Jahr unverändert. Das Team prüfen lassen, ob sie noch stimmen.", veraltet)
}
hinweise = append(hinweise, ueberblickHinweis{
Text: text, Link: "/p/" + p.Slug + "/?veraltet=1", LinkText: "Veraltete Einträge",
})
}
return warnungen, hinweise, nil
}