package store // Nutzungsübersicht je Projekt (AUSBAUSTUFEN.md 4.6). // // Die Frage dahinter: Benutzen die Teams Munin wirklich — und benutzen ihre // Agenten es so, wie es gedacht ist? Ein gutes Zeichen ist, wenn der Anteil // der Aktualisierungen gegenüber Neuanlagen wächst: Dann finden Agenten // vorhandenes Wissen und schreiben es fort, statt Dubletten daneben zu legen. // // Alle Zahlen kommen aus Daten, die ohnehin da sind — dem Protokoll // (audit_events) und den Einträgen. Es gibt keine eigene Zähltabelle, die // mitgepflegt werden müsste und auseinanderlaufen könnte. import ( "context" "database/sql" "fmt" "time" ) // UsageWindow ist der Zeitraum, über den die Schreibvorgänge gezählt werden. // Kürzer als die Aufbewahrungsfrist des Protokolls (90 Tage), sonst wären die // ältesten Tage schon teilweise weggeräumt und die Zahlen zu niedrig. const UsageWindow = 30 * 24 * time.Hour // UsageWindowDays ist derselbe Zeitraum in Tagen, für Texte in der Oberfläche. const UsageWindowDays = int(UsageWindow / (24 * time.Hour)) // WriteCounts zählt Schreibvorgänge eines Zeitraums nach Art. type WriteCounts struct { Created int // neu angelegt Updated int // bestehenden Eintrag geändert (auch Wiederherstellen, Sammelbearbeitung) Deleted int // in den Papierkorb gelegt // Unknown sind Schreibvorgänge ohne Angabe, ob angelegt oder aktualisiert. // Das Protokoll unterscheidet das erst seit 2026-09-24; ältere Zeilen // landen hier. Nach 30 Tagen ist dieser Wert von selbst immer 0. Unknown int } // Writes ist die Zahl aller Schreibvorgänge ohne Löschungen. func (w WriteCounts) Writes() int { return w.Created + w.Updated + w.Unknown } // add zählt zwei Werte zusammen — für die Summe aus Menschen und Agenten. func (w WriteCounts) add(o WriteCounts) WriteCounts { return WriteCounts{ Created: w.Created + o.Created, Updated: w.Updated + o.Updated, Deleted: w.Deleted + o.Deleted, Unknown: w.Unknown + o.Unknown, } } // UsageSummary sind die Kennzahlen eines Projekts. type UsageSummary struct { // Schreibvorgänge im Zeitraum UsageWindow, getrennt nach Weg: Die // Weboberfläche benutzen Menschen, MCP benutzen Agenten. (Ein Mensch, der // selbst über MCP schreibt, zählt als Agent — aus Sicht von Munin ist er // in dem Moment auch einer.) Human WriteCounts Agent WriteCounts // Bestand, ohne Papierkorb. Entries int Curated int Untagged int LastUpdatedAt time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine Einträge } // Total ist die Summe aus Menschen und Agenten. func (u UsageSummary) Total() WriteCounts { return u.Human.add(u.Agent) } // CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100). func (u UsageSummary) CuratedPercent() int { return prozent(u.Curated, u.Entries) } // UpdatePercent ist der Anteil der Aktualisierungen an allen Schreibvorgängen // mit bekannter Art, in Prozent. -1 heißt: Es gab keine solchen // Schreibvorgänge, ein Anteil ergibt dann keinen Sinn. func (u UsageSummary) UpdatePercent() int { t := u.Total() if t.Created+t.Updated == 0 { return -1 } return prozent(t.Updated, t.Created+t.Updated) } // prozent rechnet teil/ganz in ganze Prozent um, kaufmännisch gerundet. // Ganzzahlig statt mit Kommazahlen: "37 %" reicht für diese Übersicht, und so // gibt es keine Rundungsfragen in der Anzeige. func prozent(teil, ganz int) int { if ganz == 0 { return 0 } // Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird — // das ist Runden statt Abschneiden. return (teil*100 + ganz/2) / ganz } // UsageSummaries liefert die Kennzahlen je Projekt, als map von Projekt-ID // auf Werte. projectID schränkt auf ein Projekt ein; leer heißt: alle Projekte // dieser Workspace-Datei (für die Admin-Übersicht, in zwei Abfragen statt // zwei je Projekt). // // Projekte ganz ohne Einträge und ohne Schreibvorgänge fehlen in der map. Der // Aufrufer bekommt für sie beim Nachschlagen den Nullwert — also überall 0, // und genau das stimmt ja auch. func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time.Time) (map[string]UsageSummary, error) { ergebnis := map[string]UsageSummary{} seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) // ── 1. Schreibvorgänge aus dem Protokoll ──────────────────────────────── // // Eine Abfrage für alle vier Zahlen: CASE ... THEN 1 ELSE 0 macht aus jeder // Zeile eine 1 oder 0 je Spalte, SUM zählt sie. Das Feld detail sagt, ob // angelegt oder aktualisiert wurde (siehe DetailEntryCreated in audit.go). // Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung: // "aktualisiert", "wiederhergestellt: …", "Sammelbearbeitung: …". abfrage := `SELECT project_id, source, SUM(CASE WHEN action = ? AND detail = ? THEN 1 ELSE 0 END), SUM(CASE WHEN action = ? AND detail <> ? AND detail <> '' THEN 1 ELSE 0 END), SUM(CASE WHEN action = ? AND detail = '' THEN 1 ELSE 0 END), SUM(CASE WHEN action = ? THEN 1 ELSE 0 END) FROM audit_events WHERE ts >= ? AND action IN (?, ?)` werte := []any{ ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, ActionEntryDelete, seit, ActionEntryWrite, ActionEntryDelete, } if projectID != "" { abfrage += ` AND project_id = ?` werte = append(werte, projectID) } abfrage += ` GROUP BY project_id, source` rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) if err != nil { return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err) } for rows.Next() { var projekt, quelle string var z WriteCounts if err := rows.Scan(&projekt, &quelle, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil { rows.Close() return nil, err } // Werte aus einer map sind in Go Kopien: erst herausholen, ändern, // dann wieder hineinschreiben. u := ergebnis[projekt] switch quelle { case SourceWeb: u.Human = u.Human.add(z) case SourceMCP: u.Agent = u.Agent.add(z) } ergebnis[projekt] = u } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() // ── 2. Bestand aus den Einträgen ──────────────────────────────────────── // // NOT EXISTS fragt je Eintrag, ob es in entry_tags gar keine Zeile für ihn // gibt. Das läuft über den Primärschlüssel (entry_id, tag) und ist damit // ein kurzer Blick in den Index je Eintrag. abfrage = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0), SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) THEN 1 ELSE 0 END), MAX(updated_at) FROM entries e WHERE e.deleted_at IS NULL` werte = nil if projectID != "" { abfrage += ` AND project_id = ?` werte = append(werte, projectID) } abfrage += ` GROUP BY project_id` rows, err = s.lesen.QueryContext(ctx, abfrage, werte...) if err != nil { return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err) } defer rows.Close() for rows.Next() { var projekt string var zuletzt sql.NullString var anzahl, kuratiert, ohneSchlagwort int if err := rows.Scan(&projekt, &anzahl, &kuratiert, &ohneSchlagwort, &zuletzt); err != nil { return nil, err } u := ergebnis[projekt] u.Entries, u.Curated, u.Untagged = anzahl, kuratiert, ohneSchlagwort if zuletzt.Valid { u.LastUpdatedAt = parseTime(zuletzt.String) } ergebnis[projekt] = u } return ergebnis, rows.Err() } // EntryRef ist ein knapper Verweis auf einen Eintrag: Bezeichner und Titel. type EntryRef struct { Slug string Title string } // UntaggedEntries gibt Einträge ohne Schlagwort zurück, zuletzt geänderte // zuerst, höchstens limit Stück. func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int) ([]EntryRef, error) { rows, err := s.lesen.QueryContext(ctx, `SELECT slug, title FROM entries e WHERE project_id = ? AND e.deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) ORDER BY updated_at DESC LIMIT ?`, projectID, limit, ) if err != nil { return nil, fmt.Errorf("Einträge ohne Schlagwort konnten nicht gelesen werden: %w", err) } defer rows.Close() var liste []EntryRef for rows.Next() { var e EntryRef if err := rows.Scan(&e.Slug, &e.Title); err != nil { return nil, err } liste = append(liste, e) } return liste, rows.Err() } // MissingLink ist ein Bezeichner, auf den verwiesen wird, zu dem es aber // keinen Eintrag gibt — mit der Zahl der Einträge, die darauf verweisen. type MissingLink struct { Slug string Count int } // MissingLinks gibt alle [[…]]-Verweise ins Leere zurück, meistgenannte zuerst. // // Ein Verweis ins Leere ist nicht unbedingt ein Fehler: Oft steht er für // Wissen, das jemand für wichtig hielt, aber noch niemand aufgeschrieben hat. // Die meistgenannten sind deshalb gute Kandidaten für den nächsten Eintrag. // // Verweise AUS gelöschten Einträgen zählen nicht; Verweise AUF gelöschte // Einträge dagegen schon — für alle Leser ist das Ziel ja weg. func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLink, error) { rows, err := s.lesen.QueryContext(ctx, `SELECT l.to_slug, COUNT(*) AS anzahl FROM entry_links l JOIN entries e ON e.id = l.from_entry_id WHERE l.project_id = ? AND e.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM entries z WHERE z.project_id = l.project_id AND z.slug = l.to_slug AND z.deleted_at IS NULL) GROUP BY l.to_slug ORDER BY anzahl DESC, l.to_slug`, projectID, ) if err != nil { return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err) } defer rows.Close() var liste []MissingLink for rows.Next() { var m MissingLink if err := rows.Scan(&m.Slug, &m.Count); err != nil { return nil, err } liste = append(liste, m) } return liste, rows.Err() } // RecentlyUsedKeys gibt die zuletzt benutzten, nicht zurückgezogenen Keys // eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen — // die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7). func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit int) ([]APIKey, error) { rows, err := s.lesen.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? AND revoked_at = '' AND last_used_at <> '' ORDER BY last_used_at DESC LIMIT ?`, projectID, limit, ) if err != nil { return nil, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err) } defer rows.Close() var keys []APIKey for rows.Next() { k, err := scanKey(rows.Scan) if err != nil { return nil, err } keys = append(keys, k) } return keys, rows.Err() }