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 }