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

293 lines
10 KiB
Go

package store
// Kennzahlen eines ganzen Workspace für die Systemseite des Plattform-Admins
// (AUSBAUSTUFEN.md 5.16): Bestand, Platz auf der Platte, Aktivität je Tag und
// Stand des Schemas.
//
// Anders als die Nutzung (usage.go) fragt hier niemand nach einem einzelnen
// Projekt. Die Zahlen gelten für die ganze Workspace-Datei bzw. das ganze
// Schema; zusammengezählt über alle Workspaces werden sie in
// internal/stats.
import (
"context"
"database/sql"
"fmt"
"os"
"time"
)
// Inventory zählt, was in einem Workspace liegt.
type Inventory struct {
Projects int
Entries int // ohne Papierkorb
Versions int // ältere Fassungen in entry_versions
Trash int
// Keys: nur gültige (weder zurückgezogen noch abgelaufen). Ungenutzt und
// LaufenAb sind Teilmengen davon, nach denselben Regeln wie auf der Seite
// "Zugänge" (APIKey.Unused, APIKey.ExpiresSoon).
Keys int
KeysUnused int
KeysExpiring int
}
// Plus zählt zwei Bestände zusammen — für die Summenzeile über alle
// Workspaces.
func (b Inventory) Plus(o Inventory) Inventory {
return Inventory{
Projects: b.Projects + o.Projects,
Entries: b.Entries + o.Entries,
Versions: b.Versions + o.Versions,
Trash: b.Trash + o.Trash,
Keys: b.Keys + o.Keys,
KeysUnused: b.KeysUnused + o.KeysUnused,
KeysExpiring: b.KeysExpiring + o.KeysExpiring,
}
}
// Inventory zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des
// Workspace. now entscheidet, welche Keys als ungenutzt oder bald
// ablaufend gelten.
func (s *Store) Inventory(ctx context.Context, now time.Time) (Inventory, error) {
var b Inventory
if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&b.Projects); err != nil {
return b, fmt.Errorf("Projekte konnten nicht gezählt werden: %w", err)
}
// Eine Abfrage für beide Zahlen. COALESCE, weil SUM über eine leere
// Tabelle NULL ergibt, nicht 0.
err := s.read.QueryRowContext(ctx,
`SELECT COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END), 0)
FROM entries`,
).Scan(&b.Entries, &b.Trash)
if err != nil {
return b, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
}
if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_versions`).Scan(&b.Versions); err != nil {
return b, fmt.Errorf("Fassungen konnten nicht gezählt werden: %w", err)
}
// Die Keys werden in Go gezählt statt in SQL: Was "ungenutzt" und "läuft
// bald ab" heißt, steht in models.go — einmal, für die Seite "Zugänge" und
// für diese hier. Eine zweite Fassung der Regel in SQL könnte unbemerkt
// davon abweichen. Zurückgezogene Keys fallen schon in der Abfrage weg.
rows, err := s.read.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE revoked_at = ''`)
if err != nil {
return b, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
k, err := scanKey(rows.Scan)
if err != nil {
return b, err
}
if !k.Valid(now) {
continue
}
b.Keys++
if k.Unused(now) {
b.KeysUnused++
}
if k.ExpiresSoon(now) {
b.KeysExpiring++
}
}
return b, rows.Err()
}
// DiskUsage ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes.
type DiskUsage struct {
// Data: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des
// Schemas samt Indizes.
Data int64
// WAL ist nur bei SQLite gesetzt: die Datei <id>.db-wal. In ihr sammelt
// SQLite Änderungen, bevor es sie in die Datenbank zurückschreibt
// (Checkpoint). Wächst sie immer weiter, kommt der Checkpoint nicht durch
// — meist, weil eine Leseverbindung nie endet. PostgreSQL führt sein WAL
// für den ganzen Server, nicht je Schema; dort bleibt der Wert 0.
WAL int64
}
// Total ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte.
func (b DiskUsage) Total() int64 { return b.Data + b.WAL }
// DiskUsage misst den Platz des Workspace. Wie, hängt von der Datenbank ab
// (siehe dialect.diskUsage).
func (s *Store) DiskUsage(ctx context.Context) (DiskUsage, error) {
return s.read.d.diskUsage(ctx, s.read)
}
// diskUsage (SQLite): Seitenzahl mal Seitengröße ist die Größe der Datei,
// ohne sie im Dateisystem suchen zu müssen. Die WAL-Datei kennt SQLite selbst
// nicht als Zahl; ihr Pfad ergibt sich aber aus dem der Datenbank, und den
// nennt PRAGMA database_list.
func (sqliteDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) {
var b DiskUsage
var pages, pageSize int64
if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pages); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err)
}
if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err)
}
b.Data = pages * pageSize
// database_list liefert je angehängter Datenbank eine Zeile: Nummer, Name
// ("main" ist die eigene) und Pfad.
var number int
var name, filePath string
if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&number, &name, &filePath); err != nil {
return b, fmt.Errorf("Pfad der Datenbank unbekannt: %w", err)
}
// Ohne WAL-Datei (gerade alles zurückgeschrieben) bleibt WAL 0 — das ist
// kein Fehler.
if info, err := os.Stat(filePath + "-wal"); err == nil {
b.WAL = info.Size()
}
return b, nil
}
// diskUsage (PostgreSQL): die Tabellen des eigenen Schemas zusammengezählt.
// pg_total_relation_size rechnet Indizes und ausgelagerte große Werte (TOAST)
// mit ein; deshalb nur über die Tabellen (relkind 'r') summieren, sonst
// zählten die Indizes doppelt.
//
// current_schema() ist das Schema des Workspace, weil jeder Pool seinen
// search_path darauf setzt (backend_postgres.go). Die Summe ist numeric und
// wird nach bigint gewandelt, damit sie sich als int64 lesen lässt.
func (postgresDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) {
var b DiskUsage
err := p.QueryRowContext(ctx,
`SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)::bigint
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relkind = 'r'`,
).Scan(&b.Data)
if err != nil {
return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err)
}
return b, nil
}
// DailyActivity zählt, was an einem Tag in einem Projekt geschah.
type DailyActivity struct {
ProjectID string
Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel
AgentWrites int // über MCP angelegt, geändert oder gelöscht
HumanWrites int // dasselbe über die Weboberfläche
Searches int // recall-Aufrufe aus dem Suchprotokoll
}
// Activity liefert je Projekt und Tag die Schreibvorgänge und Suchen seit
// since. Tage und Projekte ohne jede Aktivität fehlen.
//
// Geschrieben heißt hier dasselbe wie in der Nutzung (usage.go): angelegt,
// geändert, in den Papierkorb gelegt. Gelesen wird nur gezählt, was im
// Suchprotokoll steht, also recall. Ein get taucht nirgends auf — es gibt
// dafür kein Protokoll, und eines nur für diese Zahl wäre zu viel.
//
// Der Tag sind die ersten zehn Zeichen des Zeitstempels (RFC3339 in UTC).
// SUBSTR gibt es in beiden Datenbanken, und gruppiert wird nach genau
// diesem Ausdruck, nicht nach einer Spaltennummer.
func (s *Store) Activity(ctx context.Context, since time.Time) ([]DailyActivity, error) {
limit := since.UTC().Format(time.RFC3339Nano)
// Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in
// dieselbe map ein.
type activityKey struct{ project, tag string }
byKey := map[activityKey]*DailyActivity{}
entry := func(project, tag string) *DailyActivity {
k := activityKey{project, tag}
if byKey[k] == nil {
byKey[k] = &DailyActivity{ProjectID: project, Tag: tag}
}
return byKey[k]
}
rows, err := s.read.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), source, COUNT(*)
FROM audit_events
WHERE ts >= ? AND action IN (?, ?)
GROUP BY project_id, SUBSTR(ts, 1, 10), source`,
limit, ActionEntryWrite, ActionEntryDelete,
)
if err != nil {
return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err)
}
for rows.Next() {
var project, tag, source string
var count int
if err := rows.Scan(&project, &tag, &source, &count); err != nil {
rows.Close()
return nil, err
}
switch source {
case SourceMCP:
entry(project, tag).AgentWrites += count
case SourceWeb:
entry(project, tag).HumanWrites += count
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
rows, err = s.read.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*)
FROM search_log
WHERE ts >= ?
GROUP BY project_id, SUBSTR(ts, 1, 10)`,
limit,
)
if err != nil {
return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var project, tag string
var count int
if err := rows.Scan(&project, &tag, &count); err != nil {
return nil, err
}
entry(project, tag).Searches += count
}
if err := rows.Err(); err != nil {
return nil, err
}
list := make([]DailyActivity, 0, len(byKey))
for _, a := range byKey {
list = append(list, *a)
}
return list, nil
}
// SchemaVersion nennt die zuletzt angewandte Migration des Workspace (actual) und
// die neueste, die dieses Programm mitbringt (expected), z.B. "007_key_expiry.sql".
//
// Beim Öffnen eines Workspace laufen fehlende Migrationen ohnehin durch
// (runMigrations). Weichen beide trotzdem voneinander ab, hat eine neuere
// Fassung von Munin die Datenbank schon weitergebracht als dieses Programm —
// etwa nach dem Zurückrollen auf eine ältere Fassung.
func (s *Store) SchemaVersion(ctx context.Context) (actual, expected string, err error) {
var name sql.NullString
if err := s.read.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil {
return "", "", fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
files, err := migrationFiles(migrationsFS, "migrations/"+s.read.d.migrationsSubdir())
if err != nil {
return "", "", err
}
if len(files) > 0 {
expected = files[len(files)-1]
}
return name.String, expected, nil
}