293 lines
10 KiB
Go
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 (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 <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
|
|
}
|
|
|
|
// 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
|
|
}
|