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" "strings" "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 percent(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 percent(t.Updated, t.Created+t.Updated) } // percent rechnet part/whole 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 percent(part, whole int) int { if whole == 0 { return 0 } // Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird — // das ist Runden statt Abschneiden. return (part*100 + whole/2) / whole } // 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, now time.Time) (map[string]UsageSummary, error) { result := map[string]UsageSummary{} since := now.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: …". query := `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 (?, ?)` values := []any{ ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, DetailEntryCreated, ActionEntryWrite, ActionEntryDelete, since, ActionEntryWrite, ActionEntryDelete, } if projectID != "" { query += ` AND project_id = ?` values = append(values, projectID) } query += ` GROUP BY project_id, source` rows, err := s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err) } for rows.Next() { var project, source string var z WriteCounts if err := rows.Scan(&project, &source, &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 := result[project] switch source { case SourceWeb: u.Human = u.Human.add(z) case SourceMCP: u.Agent = u.Agent.add(z) } result[project] = 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. query = `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` values = nil if projectID != "" { query += ` AND project_id = ?` values = append(values, projectID) } query += ` GROUP BY project_id` rows, err = s.read.QueryContext(ctx, query, values...) if err != nil { return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err) } defer rows.Close() for rows.Next() { var project string var latest sql.NullString var count, curated, untagged int if err := rows.Scan(&project, &count, &curated, &untagged, &latest); err != nil { return nil, err } u := result[project] u.Entries, u.Curated, u.Untagged = count, curated, untagged if latest.Valid { u.LastUpdatedAt = parseTime(latest.String) } result[project] = u } return result, 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.read.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 list []EntryRef for rows.Next() { var e EntryRef if err := rows.Scan(&e.Slug, &e.Title); err != nil { return nil, err } list = append(list, e) } return list, 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.read.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 list []MissingLink for rows.Next() { var m MissingLink if err := rows.Scan(&m.Slug, &m.Count); err != nil { return nil, err } list = append(list, m) } return list, 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.read.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() } // CuratedOverride ist ein Eintrag, den ein Mensch kuratiert hatte und den // danach ein Agent geändert hat — mit dem jüngsten solchen Vorgang. type CuratedOverride struct { Slug string Title string Actor string // Name des Zugangs, der zuletzt geändert hat Reason string // Begründung des Agenten At time.Time // Zeitpunkt der Änderung } // CuratedOverrides gibt die Einträge zurück, die ein Agent trotz // Kuratierung geändert hat und die seitdem niemand wieder als kuratiert // markiert hat — jüngste zuerst, höchstens limit Stück. // // Das ist die Liste „bitte prüfen": Sobald ein Mensch den Eintrag ansieht und // den Haken neu setzt, verschwindet er von selbst daraus. Eine eigene Tabelle // für offene Prüfungen braucht es dafür nicht. // // Grundlage ist das Protokoll. Nach dessen Aufbewahrungsfrist (90 Tage) fällt // ein nie geprüfter Eintrag aus der Liste — er ist dann aber immer noch als // nicht kuratiert zu erkennen. func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit int) ([]CuratedOverride, error) { // Je Eintrag zählt nur der jüngste Vorgang, samt Zugang und Begründung // genau dieser Zeile. ROW_NUMBER() nummeriert die Vorgänge je Eintrag, // jüngster zuerst; nr = 1 ist dann der jüngste. a.id entscheidet, falls // zwei Vorgänge denselben Zeitstempel haben. // // Früher stand hier MAX(a.ts) mit Spalten ohne Aggregat. Das kann nur // SQLite (es nimmt die übrigen Spalten dann aus der Zeile mit dem // Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide // (SQLite ab 3.25), siehe AUSBAUSTUFEN.md 4.10. rows, err := s.read.QueryContext(ctx, `SELECT entry_slug, title, actor, detail, ts FROM ( SELECT a.entry_slug, e.title, a.actor, a.detail, a.ts, ROW_NUMBER() OVER (PARTITION BY a.entry_slug ORDER BY a.ts DESC, a.id DESC) AS nr FROM audit_events a JOIN entries e ON e.project_id = a.project_id AND e.slug = a.entry_slug WHERE a.project_id = ? AND a.action = ? AND a.detail LIKE ? AND e.curated = 0 AND e.deleted_at IS NULL ) AS jeweils_juengster WHERE nr = 1 ORDER BY ts DESC LIMIT ?`, projectID, ActionEntryWrite, DetailCuratedOverridden+"%", limit, ) if err != nil { return nil, fmt.Errorf("geänderte kuratierte Einträge konnten nicht gelesen werden: %w", err) } defer rows.Close() var list []CuratedOverride for rows.Next() { var c CuratedOverride var detail, at string if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &at); err != nil { return nil, err } c.Reason = strings.TrimSpace(strings.TrimPrefix( strings.TrimPrefix(detail, DetailCuratedOverridden), ":")) c.At = parseTime(at) list = append(list, c) } return list, rows.Err() }