munin/internal/store/usage.go
2026-10-19 10:13:56 +02:00

380 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}