371 lines
13 KiB
Go
371 lines
13 KiB
Go
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 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()
|
||
}
|
||
|
||
// 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) {
|
||
// MAX(a.ts) mit Spalten ohne Aggregat: SQLite nimmt diese dann aus genau
|
||
// der Zeile mit dem Höchstwert — Zugang und Begründung gehören also zum
|
||
// jüngsten Vorgang (wie in EmptySearches).
|
||
rows, err := s.lesen.QueryContext(ctx,
|
||
`SELECT a.entry_slug, e.title, a.actor, a.detail, MAX(a.ts)
|
||
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
|
||
GROUP BY a.entry_slug
|
||
ORDER BY MAX(a.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 liste []CuratedOverride
|
||
for rows.Next() {
|
||
var c CuratedOverride
|
||
var detail, zeitpunkt string
|
||
if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &zeitpunkt); err != nil {
|
||
return nil, err
|
||
}
|
||
c.Reason = strings.TrimSpace(strings.TrimPrefix(
|
||
strings.TrimPrefix(detail, DetailCuratedOverridden), ":"))
|
||
c.At = parseTime(zeitpunkt)
|
||
liste = append(liste, c)
|
||
}
|
||
return liste, rows.Err()
|
||
}
|