munin/internal/web/handlers_overview.go
2026-10-19 10:13:56 +02:00

286 lines
9.5 KiB
Go

package web
import (
"context"
"fmt"
"net/http"
"sort"
"time"
"munin/internal/auth"
"munin/internal/stats"
"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 %.
emptySearchesFrom = 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.
emptySearchesPercent = 30
// reviewMax: 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.
reviewMax = 1000
)
// overviewData sind die Daten des Überblicks.
type overviewData struct {
Workspace string // Name
// Groups: was zu tun ist, je Projekt zusammengefasst. Projekte mit
// einer Warnung zuerst, sonst alphabetisch. Projekte ohne Befund fehlen.
Groups []overviewGroup
Projects []overviewProject // alphabetisch
Total store.Inventory
Writes int // im Zeitraum, alle Projekte
Searches int
ChartDays int
WriteChart chart
SearchChart chart
}
// overviewGroup 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 overviewGroup struct {
Project string // Name
Warning bool // mindestens ein Befund ist dringend
Items []overviewHint
}
// overviewHint ist ein Befund, mit einem Link genau dorthin, wo er zu
// erledigen ist.
type overviewHint struct {
Warning bool
Text string
Link string
LinkText string
}
// overviewProject ist eine Zeile der Projekttabelle.
type overviewProject struct {
stats.Project
Entries int
LastChanged time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine
NoHitsPercent int // Anteil der Suchen ohne Treffer in Prozent, -1 = keine Suchen
}
// handleOverview 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) handleOverview(w http.ResponseWriter, r *http.Request) {
ws, sess := s.workspaceAndSession(r)
ctx := r.Context()
now := time.Now()
entry, 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.
snapshot := s.stats.ForWorkspace(ctx, entry, now)
if len(snapshot.Errors) > 0 {
s.serverError(w, "Überblick konnte nicht erhoben werden", fmt.Errorf("%v", snapshot.Errors))
return
}
// Einträge und jüngste Änderung je Projekt, in einer Abfrage für alle.
usage, err := ws.Store.UsageSummaries(ctx, "", now)
if err != nil {
s.serverError(w, "Nutzung konnte nicht gelesen werden", err)
return
}
data := overviewData{
Workspace: entry.Name,
ChartDays: len(snapshot.Days),
}
if len(snapshot.Workspaces) == 1 {
data.Total = snapshot.Workspaces[0].Inventory
}
for _, t := range snapshot.Days {
data.Writes += t.Writes()
data.Searches += t.Searches
}
data.WriteChart = buildChart("Schreibvorgänge je Tag", chartUnit{"Schreibvorgang", "Schreibvorgänge"}, 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"}, snapshot.Days,
chartSeries{value: func(t stats.Tag) int { return t.Searches }, class: "bar-search", name: "Suchen"},
)
// Aktive und ruhige Projekte kommen getrennt aus der Erhebung; hier
// stehen alle in einer Tabelle, alphabetisch.
all := append(append([]stats.Project{}, snapshot.Projects...), snapshot.Idle...)
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
for _, p := range all {
line := overviewProject{Project: p, NoHitsPercent: -1}
u := usage[p.ID]
line.Entries, line.LastChanged = u.Entries, u.LastUpdatedAt
searches, err := ws.Store.SearchStatsFor(ctx, p.ID, now)
if err != nil {
s.serverError(w, "Suchprotokoll konnte nicht gelesen werden", err)
return
}
if searches.Total > 0 {
line.NoHitsPercent = searches.EmptyPercent()
}
data.Projects = append(data.Projects, line)
warnings, hints, err := projectHints(ctx, ws.Store, p, searches, now)
if err != nil {
s.serverError(w, "Hinweise konnten nicht erhoben werden", err)
return
}
if len(warnings)+len(hints) > 0 {
data.Groups = append(data.Groups, overviewGroup{
Project: p.Name,
Warning: len(warnings) > 0,
Items: append(warnings, hints...), // Dringendes zuerst
})
}
}
// Projekte mit Warnung nach oben. SliceStable erhält innerhalb beider
// Gruppen die alphabetische Reihenfolge von oben.
sort.SliceStable(data.Groups, func(i, j int) bool {
return data.Groups[i].Warning && !data.Groups[j].Warning
})
s.render(w, r, "overview.html", http.StatusOK, pageData{
Title: "Überblick",
Section: "ueberblick",
Data: data,
})
}
// projectHints 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 projectHints(ctx context.Context, st *store.Store, p stats.Project, searches store.SearchStats, now time.Time) (warnings, hints []overviewHint, err error) {
keysLink := "/p/" + p.Slug + "/keys"
usage := "/p/" + p.Slug + "/usage"
keys, err := st.ListAPIKeys(ctx, p.ID)
if err != nil {
return nil, nil, err
}
valid, admins, unused := 0, 0, 0
for _, k := range keys {
if !k.Valid(now) {
continue
}
valid++
if k.Role == string(auth.RoleAdmin) {
admins++
}
if k.Unused(now) {
unused++
}
if k.ExpiresSoon(now) {
// Wie bisTag: angezeigt wird der letzte gültige Tag.
warnings = append(warnings, overviewHint{
Warning: 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: keysLink, LinkText: "Zugänge",
})
}
}
switch {
case valid == 0:
warnings = append(warnings, overviewHint{
Warning: true,
Text: "Kein gültiger Key — weder das Team noch ein Agent kommt hinein.",
Link: keysLink, LinkText: "Key anlegen",
})
case admins == 0:
hints = append(hints, overviewHint{
Text: "Kein Key mit der Rolle admin — das Team kann seine Zugänge nicht selbst verwalten und muss jedes Mal dich fragen.",
Link: keysLink, LinkText: "Zugänge",
})
}
if unused > 0 {
hints = append(hints, overviewHint{
Text: fmt.Sprintf("%s seit %d Tagen nicht benutzt. Weiß niemand mehr, wofür, zurückziehen.",
plural(unused, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24)),
Link: keysLink, LinkText: "Zugänge",
})
}
// Ein Projekt ohne Key ist schon oben gewarnt; dass dort nichts
// geschieht, ist dann keine Nachricht.
if p.Total() == 0 && valid > 0 {
hints = append(hints, overviewHint{
Text: fmt.Sprintf("In %d Tagen weder geschrieben noch gesucht. Wird das Projekt noch gebraucht, oder fehlt dem Team die Anbindung?", stats.Days),
Link: usage, LinkText: "Nutzung",
})
}
if searches.Total >= emptySearchesFrom && searches.EmptyPercent() >= emptySearchesPercent {
hints = append(hints, overviewHint{
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.",
searches.EmptyPercent()),
Link: usage, LinkText: "Suchen ohne Treffer",
})
}
check, err := st.CuratedOverrides(ctx, p.ID, reviewMax)
if err != nil {
return nil, nil, err
}
if n := len(check); 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)
}
hints = append(hints, overviewHint{
Text: text, Link: usage, LinkText: "Zur Prüfung",
})
}
stale, err := st.CountStaleEntries(ctx, p.ID, now)
if err != nil {
return nil, nil, err
}
if stale > 0 {
text := "Ein Eintrag ist seit über einem Jahr unverändert. Das Team prüfen lassen, ob er noch stimmt."
if stale > 1 {
text = fmt.Sprintf("%d Einträge sind seit über einem Jahr unverändert. Das Team prüfen lassen, ob sie noch stimmen.", stale)
}
hints = append(hints, overviewHint{
Text: text, Link: "/p/" + p.Slug + "/?veraltet=1", LinkText: "Veraltete Einträge",
})
}
return warnings, hints, nil
}