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 }