munin/internal/store/nutzung.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

314 lines
11 KiB
Go
Raw 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"
"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()
}