286 lines
9.9 KiB
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
|
|
}
|