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