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 (nutzung.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/statistik. import ( "context" "database/sql" "fmt" "os" "time" ) // Bestand zählt, was in einem Workspace liegt. type Bestand struct { Projekte int Eintraege int // ohne Papierkorb Fassungen int // ältere Fassungen in entry_versions Papierkorb 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 KeysUngenutzt int KeysLaufenAb int } // Plus zählt zwei Bestände zusammen — für die Summenzeile über alle // Workspaces. func (b Bestand) Plus(o Bestand) Bestand { return Bestand{ Projekte: b.Projekte + o.Projekte, Eintraege: b.Eintraege + o.Eintraege, Fassungen: b.Fassungen + o.Fassungen, Papierkorb: b.Papierkorb + o.Papierkorb, Keys: b.Keys + o.Keys, KeysUngenutzt: b.KeysUngenutzt + o.KeysUngenutzt, KeysLaufenAb: b.KeysLaufenAb + o.KeysLaufenAb, } } // Bestand zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des // Workspace. jetzt entscheidet, welche Keys als ungenutzt oder bald // ablaufend gelten. func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) { var b Bestand if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&b.Projekte); 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.lesen.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.Eintraege, &b.Papierkorb) if err != nil { return b, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err) } if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_versions`).Scan(&b.Fassungen); 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.lesen.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(jetzt) { continue } b.Keys++ if k.Unused(jetzt) { b.KeysUngenutzt++ } if k.ExpiresSoon(jetzt) { b.KeysLaufenAb++ } } return b, rows.Err() } // Belegung ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes. type Belegung struct { // Daten: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des // Schemas samt Indizes. Daten int64 // WAL ist nur bei SQLite gesetzt: die Datei .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 } // Gesamt ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte. func (b Belegung) Gesamt() int64 { return b.Daten + b.WAL } // Belegung misst den Platz des Workspace. Wie, hängt von der Datenbank ab // (siehe dialekt.belegung). func (s *Store) Belegung(ctx context.Context) (Belegung, error) { return s.lesen.d.belegung(ctx, s.lesen) } // belegung (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 (sqliteDialekt) belegung(ctx context.Context, p pool) (Belegung, error) { var b Belegung var seiten, seitengroesse int64 if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&seiten); err != nil { return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err) } if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&seitengroesse); err != nil { return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err) } b.Daten = seiten * seitengroesse // database_list liefert je angehängter Datenbank eine Zeile: Nummer, Name // ("main" ist die eigene) und Pfad. var nummer int var name, pfad string if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&nummer, &name, &pfad); 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(pfad + "-wal"); err == nil { b.WAL = info.Size() } return b, nil } // belegung (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 (speicher_postgres.go). Die Summe ist numeric und // wird nach bigint gewandelt, damit sie sich als int64 lesen lässt. func (postgresDialekt) belegung(ctx context.Context, p pool) (Belegung, error) { var b Belegung 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.Daten) if err != nil { return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err) } return b, nil } // Tagesaktivitaet zählt, was an einem Tag in einem Projekt geschah. type Tagesaktivitaet struct { ProjektID string Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel SchreibenAgent int // über MCP angelegt, geändert oder gelöscht SchreibenMensch int // dasselbe über die Weboberfläche Suchen int // recall-Aufrufe aus dem Suchprotokoll } // Aktivitaet liefert je Projekt und Tag die Schreibvorgänge und Suchen seit // seit. Tage und Projekte ohne jede Aktivität fehlen. // // Geschrieben heißt hier dasselbe wie in der Nutzung (nutzung.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) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivitaet, error) { grenze := seit.UTC().Format(time.RFC3339Nano) // Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in // dieselbe map ein. type schluessel struct{ projekt, tag string } je := map[schluessel]*Tagesaktivitaet{} eintrag := func(projekt, tag string) *Tagesaktivitaet { k := schluessel{projekt, tag} if je[k] == nil { je[k] = &Tagesaktivitaet{ProjektID: projekt, Tag: tag} } return je[k] } rows, err := s.lesen.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`, grenze, ActionEntryWrite, ActionEntryDelete, ) if err != nil { return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err) } for rows.Next() { var projekt, tag, quelle string var anzahl int if err := rows.Scan(&projekt, &tag, &quelle, &anzahl); err != nil { rows.Close() return nil, err } switch quelle { case SourceMCP: eintrag(projekt, tag).SchreibenAgent += anzahl case SourceWeb: eintrag(projekt, tag).SchreibenMensch += anzahl } } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() rows, err = s.lesen.QueryContext(ctx, `SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*) FROM search_log WHERE ts >= ? GROUP BY project_id, SUBSTR(ts, 1, 10)`, grenze, ) if err != nil { return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err) } defer rows.Close() for rows.Next() { var projekt, tag string var anzahl int if err := rows.Scan(&projekt, &tag, &anzahl); err != nil { return nil, err } eintrag(projekt, tag).Suchen += anzahl } if err := rows.Err(); err != nil { return nil, err } liste := make([]Tagesaktivitaet, 0, len(je)) for _, a := range je { liste = append(liste, *a) } return liste, nil } // Schemastand nennt die zuletzt angewandte Migration des Workspace (ist) und // die neueste, die dieses Programm mitbringt (soll), z.B. "007_key_ablauf.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) Schemastand(ctx context.Context) (ist, soll string, err error) { var name sql.NullString if err := s.lesen.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil { return "", "", fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err) } dateien, err := migrationFiles(migrationsFS, "migrations/"+s.lesen.d.migrationsUnterordner()) if err != nil { return "", "", err } if len(dateien) > 0 { soll = dateien[len(dateien)-1] } return name.String, soll, nil }