From 1ce9be1e2e3004404faac63902f40e35222c9176 Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Fri, 16 Oct 2026 22:36:24 +0200 Subject: [PATCH] Erweiterung um Postgres und Systemstats, Bugfixes und Erweiterung UI --- .env.example | 58 +- AGENTEN-GUIDE.md | 5 +- AUSBAUSTUFEN.md | 813 +++++++++++++++++- BETRIEB.md | 255 +++++- Dockerfile | 2 +- README.md | 182 +++- REPOMAP.md | 132 ++- docker-compose.dev.yml | 47 + go.mod | 6 + go.sum | 24 + internal/dubletten/dubletten.go | 202 +++++ internal/dubletten/dubletten_test.go | 48 ++ internal/export/export.go | 37 + internal/hilfe/hilfe.go | 55 +- internal/hilfe/hilfe_test.go | 114 ++- internal/hilfe/seiten/begriffe.md | 4 +- internal/hilfe/seiten/mit-agenten-arbeiten.md | 9 +- internal/hilfe/seiten/plattform-admin.md | 75 +- internal/hilfe/seiten/weboberflaeche.md | 41 +- internal/hilfe/seiten/workspace-admin.md | 103 +++ internal/hilfe/seiten/zugaenge.md | 16 + internal/importer/postgres_test.go | 107 +++ internal/mcpserver/dubletten.go | 118 --- internal/mcpserver/mcpserver_test.go | 56 +- internal/mcpserver/server.go | 8 +- internal/mcpserver/tools.go | 15 +- internal/search/varianten.go | 42 +- internal/search/varianten_test.go | 27 + internal/statistik/statistik.go | 290 +++++++ internal/statistik/statistik_test.go | 124 +++ internal/store/audit.go | 96 ++- internal/store/control.go | 65 +- internal/store/control_test.go | 7 +- internal/store/dialekt.go | 118 +++ internal/store/dialekt_test.go | 127 +++ internal/store/entries.go | 37 +- internal/store/keys.go | 46 +- internal/store/links.go | 189 +++- .../store/migrations/postgres/001_init.sql | 136 +++ .../002_projekt_in_nebentabellen.sql | 0 .../003_schreibbudget_je_projekt.sql | 0 .../{ => postgres}/004_aenderungshistorie.sql | 0 .../{ => postgres}/005_papierkorb.sql | 0 .../migrations/postgres/006_suchprotokoll.sql | 46 + .../migrations/postgres/007_key_ablauf.sql | 13 + .../migrations/{ => sqlite}/001_init.sql | 3 + .../sqlite/002_projekt_in_nebentabellen.sql | 47 + .../sqlite/003_schreibbudget_je_projekt.sql | 26 + .../sqlite/004_aenderungshistorie.sql | 43 + .../migrations/sqlite/005_papierkorb.sql | 41 + .../{ => sqlite}/006_suchprotokoll.sql | 0 .../migrations/sqlite/007_key_ablauf.sql | 13 + .../migrations_control/postgres/001_init.sql | 36 + .../{ => postgres}/002_workspace_admins.sql | 0 .../{ => sqlite}/001_init.sql | 2 + .../sqlite/002_workspace_admins.sql | 27 + internal/store/models.go | 34 +- internal/store/nutzung.go | 29 +- internal/store/nutzung_test.go | 46 + internal/store/papierkorb.go | 8 +- internal/store/postgres_test.go | 418 +++++++++ internal/store/speicher.go | 79 ++ internal/store/speicher_postgres.go | 406 +++++++++ internal/store/speicher_sqlite.go | 81 ++ internal/store/store.go | 113 ++- internal/store/store_test.go | 169 +++- internal/store/suchprotokoll.go | 25 +- internal/store/systemstand.go | 293 +++++++ internal/store/systemstand_test.go | 189 ++++ internal/store/verbindung.go | 90 ++ internal/web/diagramm.go | 191 ++++ internal/web/diagramm_test.go | 82 ++ internal/web/handlers_admin.go | 131 ++- internal/web/handlers_bereitschaft.go | 68 ++ internal/web/handlers_entries.go | 106 ++- internal/web/handlers_hilfe.go | 5 +- internal/web/handlers_nutzung.go | 8 + internal/web/handlers_suche.go | 109 +++ internal/web/handlers_system.go | 363 ++++++++ internal/web/handlers_ueberblick.go | 286 ++++++ internal/web/handlers_workspaces.go | 5 + internal/web/middleware.go | 16 +- internal/web/oberflaeche_test.go | 388 +++++++++ internal/web/server.go | 53 +- internal/web/web_test.go | 48 +- internal/web/workspace_admin_test.go | 79 ++ internal/workspace/workspace.go | 70 +- internal/workspace/workspace_test.go | 6 +- last_test.go | 2 +- main.go | 104 ++- main_test.go | 2 +- scripts/backup.sh | 187 +++- scripts/postgres-dev-init.sql | 7 + scripts/sicherung-pruefen.sh | 188 ++++ static/aehnlich.js | 96 +++ static/style.css | 220 ++++- static/tastenkuerzel.js | 61 ++ static/ungespeichert.js | 45 + templates/audit.html | 43 +- templates/edit.html | 20 +- templates/entries.html | 21 +- templates/entry.html | 18 +- templates/import.html | 2 +- templates/keys.html | 27 +- templates/nutzung.html | 6 + templates/partials.html | 60 +- templates/suche.html | 51 ++ templates/system.html | 265 ++++++ templates/ueberblick.html | 124 +++ templates/workspaces.html | 6 +- 110 files changed, 9185 insertions(+), 497 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 internal/dubletten/dubletten.go create mode 100644 internal/dubletten/dubletten_test.go create mode 100644 internal/hilfe/seiten/workspace-admin.md create mode 100644 internal/importer/postgres_test.go delete mode 100644 internal/mcpserver/dubletten.go create mode 100644 internal/statistik/statistik.go create mode 100644 internal/statistik/statistik_test.go create mode 100644 internal/store/dialekt.go create mode 100644 internal/store/dialekt_test.go create mode 100644 internal/store/migrations/postgres/001_init.sql rename internal/store/migrations/{ => postgres}/002_projekt_in_nebentabellen.sql (100%) rename internal/store/migrations/{ => postgres}/003_schreibbudget_je_projekt.sql (100%) rename internal/store/migrations/{ => postgres}/004_aenderungshistorie.sql (100%) rename internal/store/migrations/{ => postgres}/005_papierkorb.sql (100%) create mode 100644 internal/store/migrations/postgres/006_suchprotokoll.sql create mode 100644 internal/store/migrations/postgres/007_key_ablauf.sql rename internal/store/migrations/{ => sqlite}/001_init.sql (97%) create mode 100644 internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql create mode 100644 internal/store/migrations/sqlite/003_schreibbudget_je_projekt.sql create mode 100644 internal/store/migrations/sqlite/004_aenderungshistorie.sql create mode 100644 internal/store/migrations/sqlite/005_papierkorb.sql rename internal/store/migrations/{ => sqlite}/006_suchprotokoll.sql (100%) create mode 100644 internal/store/migrations/sqlite/007_key_ablauf.sql create mode 100644 internal/store/migrations_control/postgres/001_init.sql rename internal/store/migrations_control/{ => postgres}/002_workspace_admins.sql (100%) rename internal/store/migrations_control/{ => sqlite}/001_init.sql (96%) create mode 100644 internal/store/migrations_control/sqlite/002_workspace_admins.sql create mode 100644 internal/store/postgres_test.go create mode 100644 internal/store/speicher.go create mode 100644 internal/store/speicher_postgres.go create mode 100644 internal/store/speicher_sqlite.go create mode 100644 internal/store/systemstand.go create mode 100644 internal/store/systemstand_test.go create mode 100644 internal/store/verbindung.go create mode 100644 internal/web/diagramm.go create mode 100644 internal/web/diagramm_test.go create mode 100644 internal/web/handlers_bereitschaft.go create mode 100644 internal/web/handlers_suche.go create mode 100644 internal/web/handlers_system.go create mode 100644 internal/web/handlers_ueberblick.go create mode 100644 internal/web/oberflaeche_test.go create mode 100644 scripts/postgres-dev-init.sql create mode 100755 scripts/sicherung-pruefen.sh create mode 100644 static/aehnlich.js create mode 100644 static/tastenkuerzel.js create mode 100644 static/ungespeichert.js create mode 100644 templates/suche.html create mode 100644 templates/system.html create mode 100644 templates/ueberblick.html diff --git a/.env.example b/.env.example index 4d917b4..e3e68c2 100644 --- a/.env.example +++ b/.env.example @@ -16,13 +16,30 @@ SESSION_SECRET= # openssl rand -hex 24 ADMIN_TOKEN= -# ── Server ──────────────────────────────────────────────────────────────────── +# ── Datenbank ───────────────────────────────────────────────────────────────── -# Datenverzeichnis: enthält die zentrale Kontroll-Datenbank (control.db) und -# den Ordner workspaces/ mit einer SQLite-Datei je Workspace (Abteilung). -# Wird beim Start angelegt. +# Wo die Daten liegen: "sqlite" (Standard) oder "postgres". +# Siehe AUSBAUSTUFEN.md 4.10. +# DB_DRIVER=sqlite + +# Nur bei DB_DRIVER=sqlite: Datenverzeichnis mit der zentralen +# Kontroll-Datenbank (control.db) und dem Ordner workspaces/ mit einer +# SQLite-Datei je Workspace (Abteilung). Wird beim Start angelegt. DATA_DIR=data +# Nur bei DB_DRIVER=postgres: Verbindung zur Datenbank. Munin legt darin das +# Schema munin_control und je Workspace ein Schema munin_ws_ an. +# Für die lokale Entwicklung siehe docker-compose.dev.yml. +# DATABASE_URL=postgres://munin:munin@localhost:5432/munin?sslmode=disable +# +# Im Betrieb: das Passwort nicht in die Adresse schreiben, sondern als +# PGPASSWORD setzen, und TLS verlangen (sslmode=verify-full). Welche Rechte +# der Datenbank-User braucht, steht in BETRIEB.md. +# DATABASE_URL=postgres://munin@db.intern:5432/munin?sslmode=verify-full +# PGPASSWORD= + +# ── Server ──────────────────────────────────────────────────────────────────── + # Port, auf dem der Server lauscht. PORT=8080 @@ -80,3 +97,36 @@ PORT=8080 # Ein Lauf ohne inhaltliche Änderungen fasst keine Datei an und erzeugt damit # auch keinen Git-Diff. # EXPORT_DIR=data/export + +# ── Sicherung (nur für die Skripte) ─────────────────────────────────────────── + +# Diese Werte liest nicht der Server, sondern scripts/backup.sh und +# scripts/sicherung-pruefen.sh — und zwar aus der Umgebung, nicht aus dieser +# Datei. Im Cronjob also mitgeben oder vorher die .env einlesen. Die Skripte +# nutzen außerdem DB_DRIVER, DATA_DIR und DATABASE_URL von oben. +# Einzelheiten stehen im Kopf der Skripte und in BETRIEB.md. + +# Wohin die Sicherungen geschrieben werden. Standard: backups +# BACKUP_DIR=/srv/backups + +# Sicherungen, die älter als so viele Tage sind, werden gelöscht. Ohne Angabe +# bleibt jede liegen. +# RETENTION_DAYS=14 + +# Nur PostgreSQL: eine leere Datenbank, in die die Probe der Sicherung +# zurückspielt. Niemals die Produktivdatenbank eintragen. +# PROBE_DATABASE_URL=postgres://munin@db.intern:5432/munin_probe + +# Nur PostgreSQL: andere Aufrufe für die Werkzeuge, z.B. aus dem Container, +# wenn sie auf dem Sicherungsrechner fehlen. Den Namen des Containers anpassen +# (die lokale Entwicklungsumgebung heißt go-wiki-postgres-1). +# PSQL="docker exec -i munin-postgres psql" +# PG_DUMP="docker exec -i munin-postgres pg_dump" +# PG_RESTORE="docker exec -i munin-postgres pg_restore" + +# Für die Probe: das Munin-Programm, mit dem sie die Sicherung öffnet. Ohne +# Angabe baut das Skript es selbst, dafür braucht es Go. +# MUNIN_BIN=/srv/munin/munin + +# Port, auf dem Munin während der Probe kurz läuft. Standard: 18080 +# PROBE_PORT=18080 diff --git a/AGENTEN-GUIDE.md b/AGENTEN-GUIDE.md index 79ef5cb..70610f1 100644 --- a/AGENTEN-GUIDE.md +++ b/AGENTEN-GUIDE.md @@ -192,7 +192,10 @@ schreibe zurück — mit expected_version aus dem get. Als letzte Hürde prüft `remember` beim Anlegen, ob es schon einen Eintrag mit ähnlichem Titel gibt, und fragt dann nach (`judgment_required` mit -`candidates`). Erst `force: true` legt trotzdem an. +`candidates`). Jeder Kandidat nennt die gemeinsamen Wörter (`matched_words`); +steht dort nur ein allgemeines Wort, ist es meist ein anderes Thema. Erst +`force: true` legt trotzdem an. Es übergeht nur diese Rückfrage — kuratierte +Einträge, `expected_version` und Schreibbudget gelten weiter. --- diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index bce787e..210e335 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -210,6 +210,16 @@ grob 0,5–2 GB Speicher (einmal, nicht je Workspace) und in Go entweder cgo — dann ist der statische Build weg — oder einen zweiten Dienst. Der Zusatznutzen gegenüber Synonymen vom Client ist klein. +**pgvector: geprüft und nicht nötig (2026-10-16).** Mit PostgreSQL (4.10) liegt +pgvector nahe. Es löst aber nur den kleineren Teil: Vektoren speichern und +vergleichen. Das Einbettungsmodell, an dem diese Stufe scheitert, bleibt +nötig. Außerdem gibt es pgvector nur in PostgreSQL, die Suche verhielte sich +dann je nach Datenbank anders. Und bei dieser Größe braucht es keinen Index: +10.000 Einträge × 1.024 Werte sind rund 40 MB, ein Vergleich mit allen dauert +in Go wenige Millisekunden. **Falls semantische Suche doch kommt:** Vektoren +als `BLOB`/`bytea` in einer Tabelle (gleich in beiden Datenbanken), Suche im +Speicher wie beim BM25-Index. pgvector erst bei Millionen von Vektoren. + ### 2.2a Ausschnitt an der Fundstelle — ✅ umgesetzt (2026-10-14) `entry.Snippet` suchte die ganze Anfrage wörtlich. Bei Agenten-Fragen („wie @@ -335,7 +345,8 @@ Erstbefüllung aus vorhandener Dokumentation. ### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14) -**Umsetzung:** `internal/mcpserver/dubletten.go`. Bei `remember` mit neuem +**Umsetzung:** `internal/dubletten` (bis 2026-10-16 `internal/mcpserver/dubletten.go`; +seitdem eigenes Paket, weil auch die Weboberfläche es nutzt, siehe 5.7). Bei `remember` mit neuem Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich), sondern der Anteil gemeinsamer Titelwörter (`search.Ueberdeckung`, in beide @@ -384,6 +395,46 @@ Braucht keine Einbettungen — der BM25-Index liegt bereits vor. das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster ist gut und ließe sich mit unserem Index eins zu eins übernehmen. +**Nachgebessert 2026-10-16: gemeinsames Präfix im Titel.** Im Betrieb mit +Titeln nach dem Muster „Projekt – Thema“ wurde fast jeder neue Eintrag +gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein +gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein +Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn +**beide** Titel dasselbe haben (`ohneGemeinsamesPraefix` in +`internal/dubletten`). Verschiedene Präfixe bleiben im +Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne +Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter +gleichem Präfix fällt weiter auf. Tests: `TestOhneGemeinsamesPraefix`, +`TestRememberGemeinsamesPraefixKeineDublette`. + +Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen +Projekt mit zwei Einträgen drückt das auch „Deployment-Prozess“ gegen +„Deployment-Ablauf“ unter die Schwelle, also genau den Fall, um den es geht. +Titel ohne Trenner („Munin Architektur“) erfasst die Regel nicht; das bleibt +ein Fehlalarm, den `force` auflöst. + +**Zweite Nachbesserung 2026-10-16: Suchbegriffe nur wörtlich, gemeinsame +Wörter in der Antwort.** Beim Dokumentieren in Munin selbst kamen zwei weitere +Fehlalarme mit je 50 %: + +1. „Munin – Betrieb mit PostgreSQL“ gegen einen Statuseintrag mit dem + Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von + „postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt + jetzt nur wörtlich, Titelwörter weiter mit allen Varianten + (`search.UeberdeckungMitSuchbegriffen`). Der Kernfall „Release-Ablauf“ + gegen den Suchbegriff `release` bleibt erkannt. +2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit + PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet — + strukturell ist das derselbe Fall wie „Deployment-Ablauf“ gegen + „Deployment-Prozess“, den die Prüfung finden soll. Wortvergleiche können + die beiden nicht trennen, nur Bedeutung könnte es. Dafür nennt jeder + Kandidat jetzt die gemeinsamen Wörter (`matched_words`). Steht dort nur ein + allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den + Kandidaten erst zu laden. + +Tests: `TestUeberdeckungMitSuchbegriffen`, `TestRememberSuchbegriffNurWoertlich`, +`matched_words` in `TestRememberMeldetDublette`. + ### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14) **Problem:** `remember` überschrieb kuratierte Einträge ohne Rückfrage; der @@ -645,10 +696,20 @@ kein Betriebsdatum. Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`), den die Anmelde-Middleware befüllt. -### 4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt +### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell) -`.github/workflows/test.yml` prüft bei jedem Push und jedem Pull Request: -Formatierung, `go vet`, `go test -race` und einen Bau mit `CGO_ENABLED=0`. +Es gibt keine automatische Pipeline (kein GitHub, keine CI). Die Prüfungen +werden vor jedem Commit von Hand aufgerufen: + +``` +gofmt -l . # muss leer bleiben +go vet ./... +go test -race ./... +CGO_ENABLED=0 go build -o /dev/null . +``` + +*Korrektur 2026-10-16:* Hier stand früher, `.github/workflows/test.yml` führe +diese Prüfungen automatisch aus. Diese Datei hat es im Repository nie gegeben. `-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl, @@ -785,11 +846,29 @@ und am selben Tag behoben): Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden. -### 4.5 Kennzahlen +### 4.5 Kennzahlen — geplant, sobald es ein zentrales Monitoring gibt Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht. +*Stand 2026-10-16:* Ein zentrales Monitoring ist angekündigt, aber noch nicht +da. Bis dahin beantwortet die Systemseite (5.12, 5.16) die Fragen „Ist etwas +zu tun?“ und „Wird Munin genutzt?“. Was erst mit dem Monitoring kommt: + +- **Laufzeitwerte, die heute niemand zählt:** MCP-Aufrufe je Werkzeug, + Fehlerquote, Dauer (Median, langsamste 5 %), Treffer auf das Schreibbudget je + Projekt, gesperrte IPs, Go-Laufzeit (Speicher, Goroutinen, GC), + Wartezeiten der Verbindungspools als Verlauf. +- **Weg:** entweder `/metrics` im Prometheus-Textformat (von Hand + geschrieben, ohne Bibliothek machbar) oder OpenTelemetry, je nachdem, was das + Monitoring erwartet. OpenTelemetry brächte eine Bibliothek mit sich und ist + erst zu wählen, wenn klar ist, wohin die Daten gehen. +- **Bewusst nicht in Munin:** eine eigene Ablage für Messwerte über Monate mit + eigenen Diagrammen. Verläufe zeichnet das Monitoring; die Systemseite bleibt + der schnelle Blick. +- **Schutz:** `/metrics` nicht öffentlich — nur aus dem internen Netz oder + mit eigenem Token, wie `/live` und `/ready` ohne Inhalte der Projekte. + ### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der @@ -845,7 +924,7 @@ unterscheiden. „keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt. -### 4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt +### 4.7 Ungenutzte Zugänge sichtbar machen — ✅ umgesetzt (Stufe 2: 2026-10-16) `last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben, @@ -871,7 +950,28 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. - **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine Grenze von 90 Tagen spielt das keine Rolle. - **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`. -- **Offen:** Stufe 2 (Ablaufdatum). + +**✅ Stufe 2 umgesetzt 2026-10-16: Ablaufdatum.** + +- **Migration 007** (`007_key_ablauf.sql`, in beiden Ordnern gleich): + `api_keys.expires_at TEXT NOT NULL DEFAULT ''`. Leer = läuft nie ab, wie bei + `revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst + keine Daten an. +- **Gilt nicht mehr ab dem Ablauf**, an beiden Stellen, an denen geprüft wird, + ob ein Key gilt: `ResolveKeyHash` (jede MCP-Anfrage, jede Anmeldung; + Bedingung `expires_at = '' OR expires_at > jetzt`) und `sessionFromSubject` + (jede Seite: `APIKey.Valid`). Eine laufende Browser-Sitzung endet also bei + der nächsten Anfrage nach dem Ablauf. +- **Oberfläche:** Feld *Gültig bis* beim Anlegen (optional, gilt bis + einschließlich des Tages). Spalte *Gültig bis*, Status *abgelaufen*, Marke + *läuft bald ab* zwei Wochen vorher (`ExpiresSoonWithin`) und ein Hinweis über + der Tabelle. Ein Datum in der Vergangenheit wird abgelehnt. Das Protokoll + vermerkt das Ablaufdatum bei `key_created`. +- **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr. +- **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf + derselbe Weg in der Kontroll-Datenbank. +- **Tests:** `TestKeyAblauf` (Store, auch gegen PostgreSQL), + `TestKeyMitAblaufdatum` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung). ### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30) @@ -1076,6 +1176,446 @@ Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute. +### 4.10 PostgreSQL als zweiter Speicherort — in Arbeit (begonnen 2026-10-16) + +PostgreSQL soll **neben** SQLite nutzbar sein, umschaltbar über die +Konfiguration. SQLite bleibt der Standard. + +**Stand 2026-10-16:** Schritte 1, 2, 3 und 5 sind umgesetzt (siehe +*Reihenfolge und Aufwand* am Ende). Alle Tests laufen gegen beide Datenbanken, +Sicherung und Wiederherstellung sind durchgespielt. PostgreSQL ist damit +produktiv nutzbar, mit einer Schreibverbindung je Workspace. Offen ist +Schritt 4 (mehrere Schreiber). + +**Grenze: Auch mit PostgreSQL läuft genau ein Munin-Prozess.** PostgreSQL +ändert den Speicherort, nicht die Architektur. Einiges lebt nur im +Arbeitsspeicher des Prozesses: + +- **Suchindex** (`internal/search`): Ein zweiter Prozess sähe nicht, was der + erste geschrieben hat, und fände veraltete Stände, ohne Fehlermeldung. +- **Schreibbudget und Rate-Limiter** (`internal/auth/ratelimit.go`): Jeder + Prozess zählte für sich, die Grenzen gälten doppelt. + +Die Anmeldung wäre kein Problem (signierte Cookies, kein Sitzungsspeicher). +Mehrere Prozesse wären eine eigene, deutlich größere Ausbaustufe. + +**Ausgangslage, was schon passt:** + +- **SQL gibt es nur in `internal/store`.** Handler, MCP-Tools und Templates + arbeiten mit Go-Typen. Genau für diesen Fall ist das Package gebaut (siehe + Kopfkommentar in `store.go`). +- **Die Migrationen folgen portablen Regeln** (Kopf von + `migrations/001_init.sql`): IDs als TEXT aus Go, Zeitstempel als + RFC3339-Text, Wahrheitswerte als 0/1. Kein `AUTOINCREMENT`, kein + `INSERT OR REPLACE`, kein `datetime()`. +- **Die Volltextsuche ist eigener BM25-Code in Go** (`internal/search`), nicht + FTS5 (siehe 1.1). Sie hängt also nicht an der Datenbank. +- `RETURNING` und `ALTER TABLE … ADD COLUMN` können beide Datenbanken. + `isUniqueViolation` erkennt die Fehlermeldungen beider (`models.go`). + +**Was sich ändern muss:** + +| # | Stelle | Änderung | Aufwand | +|---|---|---|---| +| 1 | Konfiguration (`main.go`) | `DB_DRIVER=sqlite\|postgres` (Standard `sqlite`) und `DATABASE_URL` | gering | +| 2 | Treiber | `github.com/jackc/pgx/v5/stdlib`. Ist reines Go, das statische Alpine-Image bleibt möglich | gering | +| 3 | Platzhalter | PostgreSQL erwartet `$1, $2 …` statt `?`, betroffen sind rund 100 Abfragen. Lösung: `Rebind` im Dialekt (siehe unten), kein Umschreiben von Hand | gering bis mittel | +| 4 | `openPool` (`store.go`) | `_pragma`-Parameter, `_txlock=immediate` und die einzelne Schreibverbindung gelten nur für SQLite. PostgreSQL braucht einen eigenen Öffnungsweg | gering | +| 5 | Workspace-Modell (`internal/workspace`) | Ein Schema je Workspace statt einer Datei, siehe unten | mittel | +| 6 | Migrationen | Eigenes Verzeichnis je Datenbank, siehe unten. Konkreter Unterschied heute: `search_log.id INTEGER PRIMARY KEY` (Migration 006) zählt nur in SQLite von selbst hoch. PostgreSQL braucht `BIGINT GENERATED ALWAYS AS IDENTITY` | gering | +| 7 | Gleichzeitiges Schreiben | Stellen absichern, die heute nur durch die eine Schreibverbindung sicher sind, siehe unten | mittel | +| 8 | Tests | Die Store-Tests zusätzlich gegen PostgreSQL laufen lassen | mittel | +| 9 | Betrieb | `scripts/backup.sh` und `BETRIEB.md` setzen auf `sqlite3`. Für PostgreSQL braucht es `pg_dump` und angepasste Abschnitte | gering | + +#### Entscheidung: ein Store mit Dialekt, keine zwei Stores + +Naheliegend wären zwei vollständige Umsetzungen hinter einem Interface +(`SQLiteStore`, `PostgresStore`). Das hieße, rund 100 Abfragen doppelt zu +pflegen und jede Methode zweimal zu testen. + +Stattdessen bleibt es bei **einem** `Store`. Umgesetzt in drei Teilen: + +| Teil | Datei | Aufgabe | +|---|---|---| +| `Speicher` | `speicher.go`, `speicher_sqlite.go`, `speicher_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. | +| `dialekt` | `dialekt.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. | +| `pool`, `transaktion` | `verbindung.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. | + +```go +type dialekt interface { + rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert) + migrationsUnterordner() string // "sqlite" bzw. "postgres" +} +``` + +Braucht eine einzelne Abfrage später wirklich zwei Fassungen, bekommt der +Dialekt dafür gezielt eine eigene Methode. Der Rest bleibt gemeinsam. +`isUniqueViolation` erkennt die Meldungen beider Datenbanken schon und bleibt +deshalb, wo sie ist. + +**Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides` +(`nutzung.go`) und `EmptySearches` (`suchprotokoll.go`) nutzten `MAX()` mit +Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide +nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab +3.25). Neuer Test: `TestCuratedOverridesJuengsterVorgang`. + +#### Entscheidung: bewusst schlichtes SQL + +In PostgreSQL entstehen dieselben Tabellen wie in SQLite, keine JSONB-Spalten, +keine Arrays, keine PostgreSQL-Volltextsuche, keine Erweiterungen (pgvector: +siehe 2.2). Gründe: + +- Die Daten haben eine feste Form. Schlagworte und Verweise liegen schon in + eigenen Tabellen, es gibt nichts Frei-Geformtes. +- Jedes PostgreSQL-Feature in einer Abfrage braucht eine zweite Fassung für + SQLite. Damit finge das Auseinanderlaufen an. +- Der Schema-Vergleichstest setzt gleiche Strukturen voraus. +- Die Suche liegt in Go (1.1). Ein zweiter Suchweg in der Datenbank wäre + dieselbe Abwägung wie bei FTS5. +- Ein Umzug bleibt reines Kopieren, in beide Richtungen. + +Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text, +`BOOLEAN` statt 0/1, `TEXT[]` für die kommagetrennten Felder +`entry_versions.tags` und `search_log.synonyms`. + +#### Entscheidung: Migrationen je Datenbank getrennt + +``` +internal/store/migrations/sqlite/001_init.sql … 006_suchprotokoll.sql +internal/store/migrations/postgres/001_init.sql … 006_suchprotokoll.sql +internal/store/migrations_control/sqlite/… +internal/store/migrations_control/postgres/… +``` + +- **Warum:** Jede Datenbank bekommt ihr passendes SQL, ohne die andere + einzuschränken. Laufen die beiden später auseinander, ist das sichtbar und + gewollt statt in Sonderfällen versteckt. +- **Preis:** Jede Schemaänderung wird zweimal geschrieben. +- **Absicherung, zwei Tests:** + - Beide Verzeichnisse enthalten dieselben Dateinamen. So bleibt keine + Migration auf einer Seite liegen. Läuft ohne PostgreSQL. + - Nach allen Migrationen haben beide Datenbanken dasselbe Schema: Tabellen, + Spalten, NOT NULL, Standardwerte, Indizes (SQLite über `pragma_table_info`, + PostgreSQL über `information_schema`). Erst dieser Test merkt, wenn eine + Spalte auf einer Seite anders angelegt wurde. Braucht PostgreSQL, siehe + *Tests* unten. +- **Für den Anfang:** Die PostgreSQL-Fassungen bleiben so nah wie möglich an + SQLite (weiter TEXT-Zeitstempel, 0/1). Dann bleiben die Go-Typen gleich, und + der Umzug von Bestandsdaten ist ein reines Kopieren. Echte PostgreSQL-Typen + (`TIMESTAMPTZ`, `BOOLEAN`) erst in einem späteren Schritt. + +#### Entscheidung: ein Schema je Workspace + +Heute hat jeder Workspace eine eigene Datei, dazu kommt `control.db` (4.4). In +PostgreSQL entspricht dem am ehesten **ein Schema je Workspace** plus ein +Schema für die Kontroll-Datenbank. Jeder Workspace bekommt einen eigenen Pool +mit `search_path=` in der Verbindungs-URL. Abfragen und Migrationen +bleiben damit unverändert, die Abschottung ist dieselbe wie heute. +`Manager.Delete` wird zu `DROP SCHEMA … CASCADE`. + +**Verworfen: eine gemeinsame Datenbank, gefiltert über `workspace_id`.** Die +Spalte steht zwar an jeder Zeile, aber die Abfragen filtern nicht durchgängig +danach, weil eine Datei bisher immer nur einen Workspace enthält. Jede +vergessene Bedingung wäre ein Datenleck zwischen Abteilungen. + +#### Entscheidung: Schemanamen aus der Workspace-ID + +| Schema | Inhalt | +|---|---| +| `munin_control` | Kontroll-Datenbank (Workspaces, Key-Routing, Workspace-Admins) | +| `munin_ws_` | ein Workspace, z.B. `munin_ws_mue9bh3x_1c0e1120` | + +- **Präfix `munin_`:** Alles von Munin ist in der Datenbank auf einen Blick + erkennbar (`\dn munin_*`), auch wenn dort andere Anwendungen liegen. +- **ID statt Name:** Workspaces lassen sich umbenennen + (`ControlStore.UpdateWorkspace`). Ein Schema mit dem Namen müsste dann + mitumbenannt werden, während offene Pools noch auf den alten `search_path` + zeigen. Dazu kommt: Namen sind freier Text mit Umlauten und Leerzeichen, + sind nicht zwingend eindeutig, und PostgreSQL begrenzt Namen auf 63 Byte. + Derselbe Grund, aus dem der Export die ID als Ordnernamen nimmt (4.4). +- **Umwandlung:** Die ID enthält `-`, das in PostgreSQL überall Anführungszeichen + erzwingen würde. Deshalb `-` → `_`, an genau einer Stelle (eine Funktion im + Dialekt), mit Test. +- **Zuordnung in der Datenbank:** Den lesbaren Namen trägt ein Kommentar am + Schema, `COMMENT ON SCHEMA munin_ws_… IS ''`. Er wird beim + Anlegen gesetzt und bei `UpdateWorkspace` nachgezogen. `\dn+` zeigt ihn + neben dem Schemanamen an. + +#### Entscheidung: ein Datenbankbenutzer (2026-10-16) + +Munin nutzt **einen** Benutzer für alles: Schemas anlegen und löschen, +Migrationen, Lesen und Schreiben. Er braucht `CONNECT, CREATE` auf der +Datenbank (oder ist ihr Eigentümer); alles Weitere ergibt sich daraus, dass +ihm gehört, was er anlegt. Übersicht je Vorgang: `BETRIEB.md`, „Rechte des +Datenbankbenutzers“. + +**Geprüft und verworfen: getrennte Benutzer** für Verwaltung (`munin_admin`: +Schemas, Migrationen, Eigentümer) und Betrieb (`munin_app`: nur `SELECT`, +`INSERT`, `UPDATE`, `DELETE`). Technisch ginge es mit einer zweiten +`DATABASE_ADMIN_URL`, Migrationen über die Verwaltungsverbindung, `GRANT …` +und `ALTER DEFAULT PRIVILEGES` je Schema (etwa 1 Tag). Der Gewinn ist aber +klein: + +- Workspaces werden zur Laufzeit über die Oberfläche angelegt, Migrationen + laufen beim ersten Öffnen. Der laufende Prozess bräuchte also **beide** + Zugänge; wer ihn übernimmt, hat beide. +- Geschützt wäre nur der normale Abfrageweg gegen versehentliche DDL. Dort + nutzt Munin durchgehend Platzhalter. +- Den Verwaltungsbenutzer ganz aus dem Prozess zu halten, ginge nur mit + Workspaces per Befehl beim Deployment oder mit `SECURITY DEFINER`-Funktionen + in der Datenbank (PostgreSQL-eigen, kein Gegenstück in SQLite). + +**Wenn es doch gebraucht wird** (etwa weil der Datenbankbetrieb getrennte +Benutzer verlangt): obiger Weg. Löschen kann dann nur `munin_admin`, denn +`DROP SCHEMA` verlangt das Eigentum (erprobt). Beim Umstieg überträgt +`REASSIGN OWNED BY munin_app TO munin_admin;` (als Superuser) das Eigentum an +allen bestehenden Schemas und Tabellen auf einmal (erprobt). + +#### Entscheidung: Verbindungen fest begrenzt + +PostgreSQL erlaubt standardmäßig nur 100 Verbindungen (`max_connections`). +Heute hat jeder Pool 8 Lese- und 1 Schreibverbindung. Bei 40 Workspaces wären +das über 360 Verbindungen. PostgreSQL würde neue Verbindungen ablehnen, und +Anfragen schlügen mit Fehlern fehl. + +**Warum kleine Pools reichen:** Eine Verbindung ist nur für die Dauer einer +Abfrage oder Transaktion belegt, also Millisekunden (`get` ≈ 1 ms, `recall` +≈ 5 ms, siehe `LASTTEST.md`). Während ein Agent bzw. das LLM nachdenkt, hält +Munin keine Verbindung. Länger belegt ist eine Verbindung nur beim Aufbau +eines Suchindex oder bei Export und Import, und das ist eine einzelne +Leseverbindung. + +**Regeln:** + +1. **Feste, kleine Pools unter PostgreSQL:** je Workspace 2 Lese- und + 2 Schreibverbindungen, `munin_control` 2. Bis die schreibenden Methoden + abgesichert sind (Schritt 4), bleibt es bei 1 Schreibverbindung. Unter + SQLite bleibt alles wie heute. +2. **Leerlauf gibt Verbindungen zurück:** `SetConnMaxIdleTime` (z.B. 2 + Minuten). Ein ruhiger Workspace hält dann gar keine Verbindung. +3. **In Go warten statt in PostgreSQL scheitern:** Ist ein Pool voll, wartet + `database/sql` auf eine freie Verbindung, statt einen Fehler zu liefern. + Begrenzt wird das Warten durch den Kontext der Anfrage. Solange die Summe + aller Pools unter `max_connections` bleibt, kann PostgreSQL also nie + „too many connections“ melden. Schlimmstenfalls wird es langsamer. +4. **Rechnung, die immer aufgehen muss:** + `Workspaces × 4 + 2 ≤ max_connections − Reserve (10)`. + Bei 40 Workspaces sind das 162, also `max_connections = 200`. +5. **Prüfung durch Munin, statt sich darauf zu verlassen:** Beim Start liest + Munin `SHOW max_connections` und rechnet nach. Geht die Rechnung nicht + auf, startet Munin nicht und nennt die nötige Zahl. Das Anlegen eines + Workspaces, der die Rechnung sprengen würde, wird mit derselben Meldung + abgelehnt. So fällt die Grenze beim Einrichten auf und nicht unter Last. + +#### Unterschiede im Verhalten + +- **Sortierung:** `ORDER BY slug` bzw. `ORDER BY tag` (z.B. `entries.go`) + sortiert SQLite Byte für Byte. PostgreSQL sortiert nach Spracheinstellung, + und bei `en_US`/`de_DE` werden Bindestriche zunächst übergangen (`a-c` vor + `ab`). Deshalb die Datenbank mit `--locale=C` anlegen, dann verhält sie sich + wie SQLite. Die Startprüfung (siehe oben) kann das gleich mit prüfen. +- **`LIKE`** unterscheidet in PostgreSQL Groß- und Kleinschreibung, in SQLite + (bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `nutzung.go` + vergleichen feste, kleingeschriebene Texte. Bei neuen Abfragen beachten. +- **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn, + SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar). +- **Ausführungspläne:** Manche Abfragen sind auf SQLite zugeschnitten, z.B. + verzichtet `entries.go` (verwandte Einträge) bewusst auf `ORDER BY`, weil + SQLite sonst schlecht plant. In Schritt 3 die häufigen Wege (`recall`, + `get`, `remember`, verwandte Einträge) mit `EXPLAIN` prüfen. Ob das Tempo + stimmt, zeigt der Lasttest. +- **Start ohne Datenbank:** Eine SQLite-Datei ist immer da, PostgreSQL ist es + vielleicht noch nicht (Container fährt hoch). Beim Start einige Male mit + Pause erneut versuchen, dann mit klarer Meldung abbrechen. + +#### Mehrere Schreibverbindungen + +**Was die eine Schreibverbindung heute kostet** (`LASTTEST.md`, ein Projekt, +30 % Schreibanteil): + +| Agenten | Schreibvorgänge/s | Wartezeit Schreiben p50 / p95 | Fehler | +|---|---|---|---| +| 32 | ≈ 1.370 | 13 / 42 ms | 0 | +| 128 | ≈ 1.430 | 68 / 190 ms | 0 | + +Der Durchsatz bleibt gleich, die Schreiber stellen sich an, und die +Wartezeit wächst mit der Zahl der Agenten. Gemessen am Schreibbudget (1.000 +je *Stunde*) ist das reichlich. Zu beachten: Seit dem Suchprotokoll (2.4) +schreibt auch jeder `recall` und steht damit in derselben Schlange. + +- **Unter SQLite bleibt es bei einer Schreibverbindung.** SQLite lässt + ohnehin nur einen Schreiber gleichzeitig zu. Mehr Verbindungen führen nur zu + „database is locked“ (296 von 320 Schreibvorgängen in einer früheren + Messung, siehe `store.go`). +- **Unter PostgreSQL sind mehrere Schreibverbindungen nötig.** Dort geht jede + Transaktion über das Netzwerk und dauert länger. Eine einzige Verbindung wäre + langsamer als heute mit SQLite. + +**Voraussetzung:** Manche Abläufe sind heute nur sicher, weil alle +Schreibvorgänge eines Workspaces nacheinander laufen. Bekanntes Beispiel: +`PutEntry` (`entries.go`) liest `version`, prüft `ExpectedVersion`, sichert die +alte Fassung und schreibt `version + 1`. Laufen zwei Aufrufe gleichzeitig, +lesen unter PostgreSQL (READ COMMITTED) beide denselben Stand: + +- **Bestehender Eintrag:** Beide schreiben dieselbe neue Version, und eine + Änderung geht verloren. +- **Neuer Eintrag:** Beide legen an, und der zweite scheitert an + `UNIQUE (project_id, slug)`, statt zu aktualisieren. + +**Abhilfe, in dieser Rangfolge:** + +1. **`INSERT … ON CONFLICT … DO UPDATE`**, wo es passt. Das können beide + Datenbanken (SQLite ab 3.24), der Code bleibt also gemeinsam. +2. **Gelesene Zeile sperren**, wo erst geprüft werden muss (etwa + `ExpectedVersion`). Achtung: `SELECT … FOR UPDATE` gibt es **nur in + PostgreSQL**, SQLite meldet einen Syntaxfehler. Deshalb über eine + Dialekt-Methode, die bei PostgreSQL `" FOR UPDATE"` liefert und bei SQLite + `""`. SQLite braucht die Sperre nicht, dort schreibt ohnehin nur eine + Verbindung. + +Vor dem Freischalten mehrerer Schreiber alle schreibenden Methoden in +`internal/store` auf dieses Muster durchsehen. Danach gehört die Regel in den +Kopfkommentar von `store.go`: *Schreibende Methoden sperren, was sie lesen und +danach ändern, oder nutzen `ON CONFLICT`.* Neuer Code kann sonst unter SQLite +korrekt sein und unter PostgreSQL still Änderungen verlieren. Normale Tests +finden das kaum, weil der Fehler nur bei echter Gleichzeitigkeit auftritt. Den Nachweis liefert der +Lasttest gegen PostgreSQL mit vielen Schreibern: Er prüft bereits, dass kein +Schreibvorgang verloren geht. Bis dahin kann auch PostgreSQL mit einer +Schreibverbindung je Workspace laufen. Das Verhalten ist dann dasselbe wie +heute. + +#### Entwicklung mit Docker + +`docker-compose.dev.yml` startet PostgreSQL 18 für die Entwicklung: Sortierung +`C`, `max_connections=200`, Port nur auf localhost. Beim allerersten Start +legt `scripts/postgres-dev-init.sql` zusätzlich die Datenbank `munin_test` für +die Tests an. + +``` +docker compose -f docker-compose.dev.yml up -d +DB_DRIVER=postgres DATABASE_URL='postgres://munin:munin@localhost:5432/munin?sslmode=disable' go run . +``` + +**Tests:** Die Store-Tests laufen zusätzlich gegen PostgreSQL, wenn +`MUNIN_TEST_PG` gesetzt ist (Datenbank `munin_test`, siehe +`internal/store/postgres_test.go`). Ohne die Variable werden sie übersprungen, und +`go test ./...` braucht weiterhin kein Docker. Jeder Test bekommt ein eigenes, +zufällig benanntes Schema und räumt es am Ende ab, damit parallele Tests sich +nicht stören. + +**Manueller Ablauf.** Tests werden von Hand aufgerufen, eine automatische +Prüfung gibt es nicht. Der Gleichstand beider Datenbanken hängt also an +diesem Ablauf: + +| Wann | Was | +|---|---| +| Bei jeder Änderung | `go test ./...` (nur SQLite) | +| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRundreisePostgres`) gegen PostgreSQL | +| Nach Änderungen am Schreibweg | Lasttest (Auslöser wie in `LASTTEST.md`). Läuft vorerst nur gegen SQLite; die PostgreSQL-Fassung kommt mit Schritt 4 | +| Vor einer Auslieferung | einmal alles gegen beide Datenbanken | + +#### Reihenfolge und Aufwand + +Schätzung, nicht erprobt: + +| Schritt | Inhalt | Aufwand | Stand | +|---|---|---|---| +| 1 | Docker-Compose, Konfiguration, Treiber, Dialekt mit `Rebind`, Migrationen aufteilen, Start mit Wiederholung | 1–2 Tage | ✅ 2026-10-16 | +| 2 | Workspace-Manager auf Schema je Workspace (`munin_ws_`, Kommentar mit Namen), `munin_control`, feste Poolgrößen mit Startprüfung | 1½ Tage | ✅ 2026-10-16 | +| 3 | Store-Tests gegen PostgreSQL, Dialektfehler beheben, `EXPLAIN` der häufigen Wege | 1½–2½ Tage | ✅ 2026-10-16 | +| 4 | Schreibende Methoden absichern, mehrere Schreiber, Lasttest gegen PostgreSQL | 1–2 Tage | offen | +| 5 | Backup-Skript, `BETRIEB.md` | ½ Tag | ✅ 2026-10-16 | +| optional | Umzugswerkzeug SQLite → PostgreSQL für Bestandsdaten | 1 Tag | offen | + +Bis Schritt 3 ist PostgreSQL mit einem Schreiber voll nutzbar. Schritt 4 ist +der eigentliche Gewinn, aber auch die heikelste Stelle. + +**Ergebnisse Schritt 3 (2026-10-16):** + +- Die ganze Store-Testsuite lief ohne einen Fehler gegen PostgreSQL. Gefunden + hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach + gelöschtem Workspace). +- Export und Import laufen unverändert, `TestRundreisePostgres` belegt es. +- Abfragepläne mit 5.000 Einträgen, Schlagworten, Verweisen und Protokoll + (PostgreSQL 18 im Container, MacBook): + + | Abfrage | Plan | Zeit | + |---|---|---| + | `get` (Eintrag nach Bezeichner) | Index `entries_project_id_slug_key` | 0,03 ms | + | Liste, neueste 50 | Index `idx_entries_project_updated` rückwärts | 0,03 ms | + | Rückverweise, verwandte Einträge | `idx_entry_links_projekt`, Primärschlüssel | 0,01 ms | + | Protokoll je Projekt | Index `idx_audit_ts` | 0,04 ms | + | Liste nach Schlagwort | `idx_entry_tags_projekt`, dann Hash Join | 1,0 ms | + | Schlagwortmenü | ganze Tabelle `entry_tags` | 1,3 ms | + | Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms | + | Suchindex aufbauen | ganze Tabelle `entries` (braucht alle Zeilen) | 0,4 ms | + + Alles, was je Anfrage läuft, geht über Indizes. Wo die ganze Tabelle + gelesen wird, braucht die Abfrage ohnehin (fast) alle Zeilen. Kein Index + fehlt, keine Abfrage musste geändert werden. Schreiben: 2,1 ms je + `PutEntry` samt Protokoll und Suchprotokoll. + +**Ergebnisse Schritt 5 (2026-10-16):** + +- `scripts/backup.sh` sichert mit `DB_DRIVER=postgres` jedes Schema einzeln + per `pg_dump` (`control.dump`, `workspaces/munin_ws_.dump`) und prüft + jede Datei gleich mit `pg_restore --list`. `PSQL`, `PG_DUMP`, `PG_RESTORE` + lassen sich auf einen Container umstellen. +- **Bewusst kein einzelner Dump aller Schemas:** Daraus lässt sich ein + einzelner Workspace nicht zurückholen. `pg_restore --schema=…` legt das + Schema selbst nicht an, alle Tabellen scheitern dann (ausprobiert). +- Durchgespielt: Sicherung, einzelnen Workspace über einen bestehenden + zurückspielen (altes Schema als `munin_vorher_ws_…` zur Seite), komplette + Sicherung in eine neue Datenbank und Munin darauf starten. Die + Identitätsspalte von `search_log` zählt nach dem Zurückspielen richtig + weiter. +- Munin läuft mit einem Benutzer, der nur `CONNECT, CREATE` auf der Datenbank + hat (sie muss ihm nicht gehören), ebenso als Eigentümer der Datenbank. In + beiden Fällen ohne + Superuser-Rechte (ausprobiert). +- `BETRIEB.md` hat für jeden Ablauf die PostgreSQL-Fassung, dazu einen neuen + Abschnitt *PostgreSQL ist weg*. README *Mit PostgreSQL* nennt die + Anforderungen an die Produktiv-Datenbank. +- **Nicht erprobt:** wie sich Munin verhält, wenn PostgreSQL im laufenden + Betrieb neu startet. Erwartet: Anfragen scheitern während des Ausfalls, + danach baut `database/sql` neue Verbindungen auf. Beim nächsten Neustart des + Entwicklungs-Containers mit laufendem Munin nachsehen. + +### 4.11 Bereitschaft prüfen und Probe der Sicherung — ✅ umgesetzt (2026-10-16) + +Zwei Lücken, die mit PostgreSQL sichtbar wurden: Die Datenbank ist dort ein +eigener Dienst, der wegfallen kann, und die Probe der Sicherung bestand aus +vielen Handgriffen. + +**`/live` und `/ready`** (`internal/web/handlers_bereitschaft.go`): + +| Pfad | Prüft | Daran hängt | +|---|---|---| +| `/live` | nur, ob der Prozess antwortet | Neustart des Containers (`HEALTHCHECK` im Dockerfile). Fehlt nur die Datenbank, hülfe ein Neustart nicht | +| `/ready` | `SELECT 1` auf der Kontroll-Datenbank, Zeitlimit 2 s; sonst `503` | Load-Balancer, Monitoring | + +- `/healthz` (früherer Name) bleibt als Alias für `/live`, damit vorhandene + Healthchecks und Monitore nicht ins Leere laufen. +- Die Antwort verrät nichts (`ok` bzw. `nicht bereit`), der Grund steht im + Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet + nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log. +- Alle drei Pfade erscheinen nicht im Zugriffsprotokoll. +- Test: `TestLiveUndReady` (Kontroll-Datenbank geschlossen → `/ready` 503, + `/live` weiter 200, keine Einzelheiten in der Antwort). + +**`scripts/sicherung-pruefen.sh`**: spielt eine Sicherung zurück (SQLite mit +`PRAGMA integrity_check`, PostgreSQL in eine leere Probe-Datenbank), startet +Munin darauf mit eigenem Port und eigenen Geheimnissen, wartet auf `/ready`, +vergleicht die Zahl der Workspaces und räumt auf. Ist die Probe-Datenbank +nicht leer, bricht es ab, statt etwas zu überschreiben. + +Ausprobiert: SQLite und PostgreSQL bestanden; eine absichtlich beschädigte +SQLite-Datei fällt mit klarer Meldung durch; die echte Datenbank als +`PROBE_DATABASE_URL` wird abgelehnt und bleibt unberührt. Unterwegs gefunden: +`tr … `. +- `export.Exporter` merkt sich den letzten Lauf über alle Workspaces + (`MerkeLauf`, `LetzterLauf`); `export.Intervall` ersetzt die Konstante in + `main.go`. + +**Nicht umgesetzt** (siehe 4.5): Laufzeitwerte wie Aufrufe je Werkzeug und +Dauern — sie brauchen eine neue Zählung und gehören in das künftige +Monitoring. Ebenso nicht: die Sicherung. Sie läuft per Cron außerhalb von +Munin; dafür gibt es das Log des Cronjobs und die Probe (4.11). Freier +Platz auf der Platte fehlt ebenfalls: Er braucht Systemaufrufe je +Betriebssystem und ist ein klassischer Wert für das Monitoring. + +Tests: `TestBestand`, `TestBelegung`, `TestAktivitaet`, `TestSchemastand` +(auch gegen PostgreSQL), `TestErhebung`, `TestMindestAbstand`, +`TestDiagramm`, `TestRunde125`, `TestGroesse`, `TestSystemseiteZustand`. +Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage. + +### 5.17 Überblick für Workspace-Admins — ✅ umgesetzt (2026-10-16) + +`/admin/overview`, für Workspace-Admins und den Plattform-Admin (für den +gewählten Workspace; in der Seitenleiste „Überblick (Workspace)“). Das +Gegenstück zur Systemseite, beschränkt auf das, was ein Workspace-Admin selbst +erledigen kann. Technik (Verbindungen, Platz, Schema, Export) bleibt auf der +Systemseite. + +- **Kennzahlen:** Projekte, Einträge, gültige Keys, Schreibvorgänge und + Suchen der letzten 30 Tage. +- **Was zu tun ist**, je Projekt eine Karte, jeder Punkt mit Link genau + dorthin, wo er zu erledigen ist. Projekte mit Dringendem zuerst. + *Dringend:* ein Key läuft in 14 Tagen ab (mit Datum); kein gültiger Key im + Projekt. *Bei Gelegenheit:* kein Key mit Rolle `admin` (das Team muss für + jeden Zugang den Workspace-Admin fragen); Keys 90 Tage ungenutzt; 30 Tage + weder geschrieben noch gesucht (nicht bei Projekten ohne Key — dort steht + schon die Warnung); ab 30 % Suchen ohne Treffer bei mindestens 10 Suchen; + kuratierte Einträge zur Prüfung; Einträge über ein Jahr unverändert. + Jede Prüfung nimmt dieselbe Regel wie die verlinkte Seite. +- **Projekte:** Tabelle mit Einträgen, Schreibvorgängen (Agenten/Menschen), + Suchen, Anteil ohne Treffer, letzter Änderung; der Name führt zur Nutzung. +- **Aktivität:** dieselben Diagramme wie auf der Systemseite, nur für diesen + Workspace. + +Umsetzung: `internal/web/handlers_ueberblick.go`; +`statistik.Sammler.FuerWorkspace` (ohne Zwischenspeicher — nach einem neuen +Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt +in `partials.html`. Die Startseite des Workspace-Admins bleibt die +Projektliste. Test: `TestWorkspaceAdminUeberblick` (auch: Warnungen vor +Hinweisen, keine Technik auf der Seite, ein Projektzugang kommt nicht dran). + +### 5.6 Verweise in Code ignorieren — ✅ umgesetzt (2026-10-16) + +Ein Eintrag, der erklärt, wie man verweist, schreibt dafür ein Beispiel in +Code. Früher wurde auch das als Verweis gezählt: Es erschien unter „Verweise +ins Leere“, und beim Anzeigen stand mitten im Code ein Markdown-Link statt des +Beispiels. Aufgefallen beim Dokumentieren in Munin selbst (`munin-ueberblick` +verwies auf einen Eintrag „bezeichner“). + +- `ExtractLinks` und `ReplaceLinks` (`internal/store/links.go`) übergehen + Codeblöcke (```` ``` ```` bzw. `~~~`, mit Info-Zeichenkette, auch bis zum + Textende offen) und Code im Fließtext zwischen gleich langen + Backtick-Folgen. +- Eingerückte Codeblöcke (vier Leerzeichen) bewusst nicht: von eingerückten + Listenzeilen nicht sicher zu unterscheiden, und ein übersehener echter + Verweis wäre schlimmer als ein überflüssiger. +- Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie + das nächste Mal geschrieben werden. +- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLaesstCodeStehen`. + ### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14) In der Sammelleiste der Eintragsliste steht neben *Anwenden* der Knopf @@ -1304,6 +2089,20 @@ Als Nächstes: 9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak (siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es gebraucht wird. +10. **PostgreSQL als zweiter Speicherort** (4.10) — produktiv nutzbar seit + 2026-10-16 (Schritte 1, 2, 3, 5). Offen: mehrere Schreiber (Schritt 4), + erst wenn die Wartezeiten beim Schreiben es verlangen. Den genauen Stand + führt 4.10. +11. ~~**`/live` und `/ready`, Probe der Sicherung** (4.11), **Verweise in Code** + (5.6), **Dublettenprüfung nachgeschärft** (3.3)~~ — erledigt 2026-10-16. +12. ~~**Weboberfläche:** ähnliche Einträge beim Anlegen (5.7), Rückfrage bei + ungespeicherten Änderungen (5.8), verwandte Einträge (5.9), veraltete + Einträge (5.10), Protokoll filtern (5.11), Systemseite (5.12), Suche über + alle Projekte (5.13), Tastenkürzel (5.14), **Ablaufdatum für Keys** (4.7 + Stufe 2)~~ — erledigt 2026-10-16. +13. ~~**Systemseite: Zustand, Aktivität, Bestand** (5.16) und **Überblick für + Workspace-Admins** (5.17)~~ — erledigt 2026-10-16. Als Nächstes dort: `/metrics` bzw. OpenTelemetry (4.5), + sobald das zentrale Monitoring steht. Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es diff --git a/BETRIEB.md b/BETRIEB.md index 603f0d2..60a127b 100644 --- a/BETRIEB.md +++ b/BETRIEB.md @@ -8,6 +8,14 @@ Die Befehle gehen vom Docker-Aufbau aus dem README aus: Container `munin`, Volume unter `/data`. Ohne Docker gilt dasselbe mit dem eigenen `DATA_DIR` statt `/data`, und die Ausgabe des Prozesses statt `docker logs`. +Munin läuft entweder mit **SQLite** (Standard, eine Datei je Workspace) oder +mit **PostgreSQL** (`DB_DRIVER=postgres`, ein Schema je Workspace). Wo sich +die Schritte unterscheiden, steht beides da. Welches gilt, zeigt die Zeile +`Speicher:` beim Start. + +Für die PostgreSQL-Befehle: `$DATABASE_URL` ist dieselbe Adresse wie beim +Server, das Passwort kommt aus `PGPASSWORD` oder `~/.pgpass`. + --- ## Auf einen Blick @@ -21,12 +29,25 @@ statt `/data`, und die Ausgabe des Prozesses statt `docker logs`. | Sicherungen | `BACKUP_DIR//` von `scripts/backup.sh`, siehe README „Datensicherung“ | | Markdown-Export | `EXPORT_DIR`, falls gesetzt. Lesbar, aber **keine** vollständige Sicherung (keine Keys, keine Historie) | | Geheimnisse | `SESSION_SECRET`, `ADMIN_TOKEN` als Umgebungsvariablen, siehe README „Konfiguration“ | -| Lebenszeichen | `GET /healthz` antwortet `ok` | +| Lebenszeichen | `GET /live` antwortet `ok`, solange der Prozess läuft (`/healthz` ist der alte Name) | +| Bereitschaft | `GET /ready` antwortet `ok`, wenn die Datenbank antwortet, sonst `503 nicht bereit`. Der Grund steht im Log (`Bereitschaft: …`) | + +Mit PostgreSQL: + +| Was | Wo | +|---|---| +| Kontroll-Datenbank | Schema `munin_control` in der Datenbank aus `DATABASE_URL` | +| Ein Workspace | Schema `munin_ws_` (Bindestrich der ID als `_`). Der Name steht als Kommentar daran und unter *Workspaces* in der Weboberfläche | +| Sicherungen | `BACKUP_DIR//control.dump` und `…/workspaces/munin_ws_.dump` | Welcher Workspace hinter einer ID steckt: ```sh +# SQLite sqlite3 /data/control.db "SELECT id, name FROM workspaces;" + +# PostgreSQL: Schema und Name +psql "$DATABASE_URL" -c "SELECT nspname, obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname LIKE 'munin%' ORDER BY 1;" ``` --- @@ -34,7 +55,8 @@ sqlite3 /data/control.db "SELECT id, name FROM workspaces;" ## Läuft er? ```sh -curl -s https:///healthz # erwartet: ok +curl -s https:///live # Prozess läuft? erwartet: ok +curl -s https:///ready # Datenbank erreichbar? erwartet: ok docker logs --tail 50 munin ``` @@ -43,19 +65,37 @@ gescheitert: ``` Munin läuft auf http://localhost:8080 (3 Workspaces) -Workspaces: /data/workspaces (Kontrolle: /data/control.db) +Speicher: SQLite — Workspaces: /data/workspaces, Kontrolle: /data/control.db Schreibbudget: 1000 Aufrufe je Projekt und Stunde (Vorgabe) ``` -`/healthz` prüft nur, ob der Prozess antwortet, nicht die Datenbanken. Ein -`ok` heißt also nicht, dass jeder Workspace lesbar ist. Siehe dazu -[Datenbank beschädigt](#datenbank-beschädigt). +Mit PostgreSQL lautet die zweite Zeile etwa +`Speicher: PostgreSQL — db.intern:5432/munin (Schemas munin_control, munin_ws_…)`. + +`/live` prüft nur, ob der Prozess antwortet. `/ready` prüft zusätzlich die +Kontroll-Datenbank (unter PostgreSQL also auch, ob der Datenbankserver +erreichbar ist), aber nicht jede einzelne Workspace-Datei. Siehe dazu +[Datenbank beschädigt](#datenbank-beschädigt) und +[PostgreSQL ist weg](#postgresql-ist-weg). + +Mehr Einzelheiten zeigt dem Plattform-Admin die Seite **System** +(`/admin/system`). Ganz oben steht der Kasten **Zustand** mit allem, was zu +tun ist: Verbindungen knapp, WAL-Datei zu groß, Export fehlgeschlagen oder +überfällig, Sortierung der PostgreSQL-Datenbank falsch, Keys laufen ab oder +sind ungenutzt. Darunter die Aktivität der letzten 30 Tage, Bestand und Platz +je Workspace, Datenbank, Bereitschaft, offene Verbindungen je Workspace gegen +das Budget (mit PostgreSQL), wie oft auf Verbindungen gewartet wurde, Fassung, +Schema-Stand und Laufzeit. + +Der Container-Healthcheck im Dockerfile hängt an `/live`: Fehlt nur die +Datenbank, soll der Container nicht neu starten, das hülfe nicht. Ein +Load-Balancer oder Monitoring gehört an `/ready`. --- ## Server startet nicht -**Man sieht:** Der Container beendet sich sofort, `/healthz` antwortet nicht. +**Man sieht:** Der Container beendet sich sofort, `/live` antwortet nicht. `docker logs munin` zeigt die Ursache in der letzten Zeile: @@ -68,6 +108,17 @@ Schreibbudget: 1000 Aufrufe je Projekt und Stunde (Vorgabe) | `address already in use` | Ein anderer Prozess belegt den Port | anderen Prozess beenden oder `PORT` ändern | | `database disk image is malformed` | beschädigte Datenbank | [Datenbank beschädigt](#datenbank-beschädigt) | +Mit PostgreSQL kommen diese hinzu: + +| Meldung | Ursache | Abhilfe | +|---|---|---| +| `DB_DRIVER=postgres, aber DATABASE_URL ist nicht gesetzt` | Umgebungsvariable fehlt | setzen, siehe README „Mit PostgreSQL“ | +| `PostgreSQL ist noch nicht erreichbar (Versuch …)` und am Ende `PostgreSQL ist nicht erreichbar` | Datenbank läuft nicht, falscher Host/Port, Firewall, falsches Passwort | Die Meldung nennt den Grund. Mit `psql "$DATABASE_URL" -c 'SELECT 1'` vom selben Rechner gegenprüfen | +| `zu wenige PostgreSQL-Verbindungen: … max_connections auf mindestens N erhöhen` | `max_connections` reicht nicht für alle Workspaces | Beim Datenbank-Admin `max_connections` auf mindestens N setzen lassen und PostgreSQL neu starten. Munin danach neu starten | +| `Schema … konnte nicht angelegt werden: permission denied` | Der Benutzer aus `DATABASE_URL` darf keine Schemas anlegen | `GRANT CONNECT, CREATE ON DATABASE munin TO munin;` (oder ihn zum Eigentümer machen), siehe [Rechte des Datenbankbenutzers](#rechte-des-datenbankbenutzers) | +| `Schema … konnte nicht gelöscht werden: must be owner of schema` | Das Schema gehört einem anderen Benutzer, etwa nach einem Zurückspielen als anderer Benutzer | Eigentum übertragen: `ALTER SCHEMA munin_ws_ OWNER TO munin;` (für alles auf einmal `REASSIGN OWNED BY TO munin;`, als Superuser) | +| `Warnung: Die Datenbank sortiert nach "…" statt "C"` | Datenbank ohne `LOCALE 'C'` angelegt | Läuft trotzdem, Listen sind nur anders sortiert. Beheben lässt es sich nur durch eine neue Datenbank mit `LOCALE 'C'` und Zurückspielen einer Sicherung | + --- ## Platte voll @@ -87,6 +138,72 @@ vollständig zurückgerollt, halbe Einträge gibt es nicht. Sobald wieder Platz ist, schreibt Munin ohne Neustart weiter. +Mit PostgreSQL betrifft das die Platte des Datenbankservers. PostgreSQL rollt +einen Schreibvorgang, der nicht mehr passt, ebenso vollständig zurück. Platz +schaffen ist dort Sache des Datenbank-Admins; auf Munins Seite wachsen dann +nur noch Sicherungen und Export. + +--- + +## Rechte des Datenbankbenutzers + +Munin arbeitet mit **einem** Datenbankbenutzer (`DATABASE_URL`) für alles. Er +braucht genau zwei Rechte auf der Datenbank, oder er ist ihr Eigentümer: + +```sql +GRANT CONNECT, CREATE ON DATABASE munin TO munin; +``` + +| Vorgang | Was in PostgreSQL passiert | Nötiges Recht | +|---|---|---| +| Verbinden | Anmeldung an der Datenbank | `CONNECT` auf der Datenbank | +| Erster Start, Workspace anlegen | `CREATE SCHEMA munin_control` bzw. `munin_ws_` | `CREATE` auf der Datenbank | +| Workspace öffnen (Migrationen) | `CREATE TABLE`, `CREATE INDEX` im Schema | Eigentum am Schema — hat er, weil er es angelegt hat | +| Normaler Betrieb | `SELECT`, `INSERT`, `UPDATE`, `DELETE` | Eigentum an den Tabellen — ebenso | +| Workspace umbenennen | `COMMENT ON SCHEMA …` | Eigentum am Schema | +| Workspace löschen | `DROP SCHEMA munin_ws_ CASCADE` | **nur** Eigentum am Schema (oder Superuser). Ein eigenes Löschrecht gibt es in PostgreSQL nicht; auch alle Rechte an allen Tabellen reichen nicht | +| Verbindungsbudget prüfen | `SHOW max_connections` | keins | + +Nicht nötig: Superuser, `CREATEDB`, `CREATEROLE`, Rechte am Schema `public`. + +**Erprobt am 2026-10-16:** Munin mit `CONNECT, CREATE` auf einer Datenbank, die +einem anderen Benutzer gehört — Start, Workspace anlegen, `/ready` ok, die +Schemas gehören danach Munins Benutzer. Ein Benutzer mit allen Tabellenrechten, +aber ohne Eigentum, kann das Schema nicht löschen (`must be owner of schema`). + +**Sicherung und Wiederherstellung:** `pg_dump` braucht nur Lesezugriff; +`pg_restore` und das Umbenennen eines Schemas (`ALTER SCHEMA … RENAME`) laufen +am besten als derselbe Benutzer wie Munin. Dann gehört das zurückgespielte +Schema wieder ihm, und er kann es später auch löschen. Wurde als ein anderer +Benutzer zurückgespielt: `ALTER SCHEMA munin_ws_ OWNER TO munin;` und +`ALTER TABLE … OWNER TO munin;`, einfacher als Superuser +`REASSIGN OWNED BY TO munin;`. + +**Bewusst kein zweiter Benutzer** für die Verwaltung: siehe AUSBAUSTUFEN.md +4.10, „Entscheidung: ein Datenbankbenutzer“. + +--- + +## PostgreSQL ist weg + +**Man sieht:** `/live` antwortet `ok`, `/ready` aber `503 nicht bereit`. Seiten +und MCP-Aufrufe enden mit Fehlern, im Log steht `Bereitschaft: Datenbank +antwortet nicht …` (einmal, beim Wechsel) und Meldungen wie `connection refused` +oder `the database system is shutting down`. + +1. Läuft die Datenbank? `pg_isready -d "$DATABASE_URL"` oder + `psql "$DATABASE_URL" -c 'SELECT 1'`. +2. Ist sie wieder da, sollten Anfragen von selbst wieder gehen: Go verwirft + abgebrochene Verbindungen und baut neue auf. Im Log steht dann + `Bereitschaft: Datenbank antwortet wieder`, und `/ready` meldet `ok`. + **Noch nicht erprobt.** Gehen sie nach einer Minute nicht, Munin neu + starten (`docker restart munin`). + Dabei geht nichts verloren, der Suchindex baut sich beim ersten Zugriff neu + auf. + +Schreibvorgänge, die während des Ausfalls scheiterten, haben nichts +geschrieben. Agenten bekommen einen Fehler und versuchen es erneut. + --- ## Ein Key ist in falsche Hände geraten @@ -98,9 +215,10 @@ gepostet, oder der Laptop ist weg. betroffenen Key **Zurückziehen**. Das wirkt sofort, für MCP-Aufrufe und für Sitzungen in der Weboberfläche. Jede Anfrage prüft den Key neu in der Datenbank. -2. Unter **Protokoll** in der Spalte *Wer* nach dem Namen des Keys suchen (ein - Filter fehlt bisher, die Suche des Browsers hilft): Was wurde damit getan, - von welchen Adressen? Unbekannte Adressen deuten auf Missbrauch hin. +2. Unter **Protokoll** oben bei *Wer* den Namen des Keys wählen, bei Bedarf + den Zeitraum (*von*, *bis*) eingrenzen: Was wurde damit getan, von welchen + Adressen? Unbekannte Adressen deuten auf Missbrauch hin. Die gefilterte + Ansicht hat eine eigene Adresse und lässt sich weitergeben. 3. Hat der Key geschrieben, was er nicht sollte: Einträge über ihre **Historie** auf eine frühere Fassung zurücksetzen, Gelöschtes aus dem **Papierkorb** holen. @@ -257,7 +375,7 @@ chown 10001:10001 /data/workspaces/.db # 7. Starten und prüfen docker start munin -curl -s https:///healthz +curl -s https:///ready ``` Danach in der Weboberfläche den Workspace öffnen und **zurückgezogene Keys @@ -269,6 +387,62 @@ Wie oben, aber mit `control.db` und **allen** Dateien unter `workspaces/`, und mit allen `-wal`/`-shm`-Dateien. Zusätzlich zu den Keys gelten dann auch Workspace-Admins wieder, die nach der Sicherung zurückgezogen wurden. +### Einen Workspace zurückholen (PostgreSQL) + +Am 2026-10-16 durchgespielt (Sicherung, Zurückspielen über einen bestehenden +Workspace, Start auf einer zurückgespielten Datenbank). + +Das alte Schema wird nicht gelöscht, sondern umbenannt. Der neue Name beginnt +nicht mit `munin_ws_`, damit weder Munin noch `scripts/backup.sh` ihn für +einen Workspace halten. + +```sh +# 1. Welches Schema? (Schema ↔ Name) +psql "$DATABASE_URL" -c "SELECT nspname, obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname LIKE 'munin_ws_%';" + +# 2. Sicherung prüfen: Lässt sie sich lesen? +pg_restore --list //workspaces/munin_ws_.dump > /dev/null && echo lesbar + +# 3. Server stoppen +docker stop munin + +# 4. Das jetzige Schema zur Seite legen, nicht löschen +psql "$DATABASE_URL" -c 'ALTER SCHEMA munin_ws_ RENAME TO munin_vorher_ws_;' + +# 5. Zurückspielen. Legt das Schema samt Namenskommentar wieder an. +pg_restore --dbname="$DATABASE_URL" --no-owner //workspaces/munin_ws_.dump + +# 6. Starten und prüfen +docker start munin +curl -s https:///ready +``` + +Danach wie oben **zurückgezogene Keys erneut zurückziehen**. Läuft alles, kann +das alte Schema weg: `DROP SCHEMA munin_vorher_ws_ CASCADE;` + +Fehlen `pg_restore` oder `psql` auf dem Rechner oder sind sie älter als der +Server, gehen dieselben Befehle über einen PostgreSQL-Container, z.B. +`docker exec -i pg_restore … < datei.dump`. + +### Alles zurückholen (PostgreSQL) + +Am sichersten in eine **neue, leere Datenbank** und dann `DATABASE_URL` +darauf umstellen. Die alte bleibt so unberührt, bis klar ist, dass alles +stimmt: + +```sql +CREATE DATABASE munin_neu OWNER munin TEMPLATE template0 LOCALE 'C' ENCODING 'UTF8'; +``` + +```sh +for f in //control.dump //workspaces/*.dump; do + pg_restore --dbname="postgres://munin@:5432/munin_neu" --no-owner "$f" +done +``` + +Dann `DATABASE_URL` auf `munin_neu` ändern und Munin neu starten. Auch hier +gelten danach zurückgezogene Keys und Workspace-Admins wieder. + ### Ein gelöschter Workspace Beim Löschen verschwinden die Datei **und** sein Eintrag in der @@ -279,6 +453,12 @@ Workspace-Admins, die Zuordnung neuer Keys. Das ist eine bewusste Entscheidung. Meist ist der Weg über einen getrennten zweiten Server mit Export und Import der bessere. +Mit PostgreSQL gilt dasselbe: Schema **und** `munin_control` müssten zurück. +Der bessere Weg: die ganze Sicherung in eine eigene Datenbank zurückspielen +(siehe oben), einen zweiten Munin mit `DATABASE_URL` darauf und anderem `PORT` +starten, dort den Workspace als ZIP herunterladen und im echten Server in +einen neuen Workspace importieren. + --- ## Datenbank beschädigt @@ -300,6 +480,9 @@ man schneller und sicherer am Ziel. Häufigste Ursache ist eine Datei, die im laufenden Betrieb mit `cp` statt mit `scripts/backup.sh` kopiert und dann zurückgespielt wurde. +Mit PostgreSQL ist eine beschädigte Datenbank Sache des Datenbank-Admins. +Betrifft es nur einen Workspace, lässt er sich wie oben einzeln zurückholen. + --- ## Eine neue Fassung einspielen @@ -310,7 +493,7 @@ danach nicht garantiert. Deshalb: 1. `scripts/backup.sh` von Hand laufen lassen. 2. Neues Abbild bauen, Container ersetzen. -3. Log auf die Startzeilen prüfen, `/healthz`, einmal in der Weboberfläche +3. Log auf die Startzeilen prüfen, `/ready`, einmal in der Weboberfläche suchen und ein MCP-`recall` absetzen. 4. Geht etwas schief: altes Abbild starten **und** die Sicherung aus Schritt 1 zurückspielen. @@ -325,12 +508,38 @@ verbinden sich danach von selbst neu. | Wann | Was | |---|---| | wöchentlich | Läuft die Sicherung? Ein Blick ins Log des Cronjobs und auf den neuesten Ordner in `BACKUP_DIR`. | -| monatlich | Marke *ungenutzt* bei Keys und Workspace-Admins (Übersicht unter *Workspaces*). Vergessene Zugänge zurückziehen. | -| vierteljährlich | **Probe der Sicherung:** die neueste Sicherung in ein leeres Verzeichnis kopieren, `PRAGMA integrity_check` für jede Datei, einen zweiten Server mit `DATA_DIR` darauf und anderem `PORT` starten, einmal anmelden und suchen. Eine Sicherung, die nie zurückgespielt wurde, ist nur eine Hoffnung. | +| wöchentlich | Ein Blick auf *System* → *Zustand*. Steht dort „Alles in Ordnung“, ist nichts zu tun. | +| monatlich | Marke *ungenutzt* bei Keys und Workspace-Admins (Übersicht unter *Workspaces*, Summe unter *System* → *Zustand*). Vergessene Zugänge zurückziehen. Keys mit *läuft bald ab*: noch gebraucht? Dann rechtzeitig ersetzen. Unter *System* → *Aktivität*: Projekte ganz ohne Aktivität mit dem Team klären. | +| vierteljährlich | **Probe der Sicherung:** `scripts/sicherung-pruefen.sh /` (siehe unten). Endet es mit `PROBE BESTANDEN`, ist die Sicherung zurückspielbar. Eine Sicherung, die nie zurückgespielt wurde, ist nur eine Hoffnung. | | vor jedem Update | Sicherung von Hand, siehe oben | -Die Probe der Sicherung ohne Docker, auf einem beliebigen Rechner mit Go und -sqlite3: +### Probe der Sicherung + +Ein Befehl, für beide Datenbanken. Das Skript spielt die Sicherung zurück +(SQLite: in ein Arbeitsverzeichnis samt `PRAGMA integrity_check`; PostgreSQL: +in eine leere Probe-Datenbank), startet Munin darauf mit eigenem Port und +eigenen Geheimnissen, wartet auf `/ready`, vergleicht die Zahl der Workspaces +mit der Sicherung und räumt auf. Der echte Server wird nicht berührt. + +```sh +# SQLite (braucht sqlite3 und Go, oder MUNIN_BIN=/pfad/zu/munin) +scripts/sicherung-pruefen.sh / + +# PostgreSQL: einmalig eine leere Probe-Datenbank anlegen … +psql "$DATABASE_URL" -c "CREATE DATABASE munin_probe OWNER munin TEMPLATE template0 LOCALE 'C' ENCODING 'UTF8';" +# … dann bei jeder Probe +DB_DRIVER=postgres PROBE_DATABASE_URL="postgres://munin@:5432/munin_probe" \ + scripts/sicherung-pruefen.sh / +``` + +Ist die Probe-Datenbank nicht leer, bricht das Skript ab, statt etwas zu +überschreiben. Das schützt davor, versehentlich die echte Datenbank anzugeben. +Am 2026-10-16 ausprobiert: SQLite und PostgreSQL bestanden, eine absichtlich +beschädigte SQLite-Sicherung durchgefallen, die echte Datenbank als Probe +abgelehnt. + +Von Hand, falls das Skript nicht zur Hand ist (SQLite, auf einem beliebigen +Rechner mit Go und sqlite3): ```sh probe=$(mktemp -d) @@ -343,5 +552,19 @@ DATA_DIR="$probe" PORT=18080 SESSION_SECRET=$(openssl rand -hex 32) \ # im Browser: http://localhost:18080, Anmeldung mit probe-probe-probe-1 ``` +Mit PostgreSQL, in eine eigene Probe-Datenbank auf demselben Server: + +```sh +psql "$DATABASE_URL" -c "CREATE DATABASE munin_probe OWNER munin TEMPLATE template0 LOCALE 'C' ENCODING 'UTF8';" +probe_url="postgres://munin@:5432/munin_probe" +for f in //control.dump //workspaces/*.dump; do + pg_restore --dbname="$probe_url" --no-owner "$f" +done +DB_DRIVER=postgres DATABASE_URL="$probe_url" PORT=18080 SESSION_SECRET=$(openssl rand -hex 32) \ + ADMIN_TOKEN=probe-probe-probe-1 go run . +# danach aufräumen: +psql "$DATABASE_URL" -c "DROP DATABASE munin_probe;" +``` + Die Probe darf mit eigenen Geheimnissen laufen. Sie braucht weder die echten noch erreicht sie den echten Server. diff --git a/Dockerfile b/Dockerfile index d64a7ea..69939ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,6 @@ USER munin VOLUME ["/data"] HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --quiet --tries=1 --spider http://127.0.0.1:8080/healthz || exit 1 + CMD wget --quiet --tries=1 --spider http://127.0.0.1:8080/live || exit 1 CMD ["./munin"] diff --git a/README.md b/README.md index 60e469a..fd4c7cc 100644 --- a/README.md +++ b/README.md @@ -339,8 +339,9 @@ und entscheidet: Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein -Mensch ihn wieder kuratiert. Einzelheiten: `internal/mcpserver/dubletten.go` -und `kuratiert.go`. +Mensch ihn wieder kuratiert. Einzelheiten: `internal/dubletten` und +`internal/mcpserver/kuratiert.go`. Jeder Kandidat nennt die gemeinsamen Wörter +(`matched_words`). ### Gleichzeitiges Schreiben @@ -368,21 +369,25 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh | `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen | | `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) | | `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen | -| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace | -| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen | -| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) | -| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen | +| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace, filterbar nach Vorgang, Zugang, Projekt und Zeitraum | +| `/admin/search` | Suche in allen Projekten des gewählten Workspace, nach Projekt gruppiert (Plattform- und Workspace-Admin) | +| `/admin/overview` | Überblick des Workspace: was zu tun ist (je Projekt mit Link), Projekte, Aktivität der letzten 30 Tage (Workspace- und Plattform-Admin) | +| `/admin/system` | Zustand (was zu tun ist), Aktivität der letzten 30 Tage, Bestand und Platz je Workspace, Speicher, Bereitschaft, offene Datenbankverbindungen gegen das Budget, Fassung und Laufzeit (nur Plattform-Admin) | +| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, veraltete zeigen (`?veraltet=1`), für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen | +| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor), mit Hinweis auf ähnliche vorhandene Einträge | +| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen und verwandten Einträgen über einen Zwischeneintrag | | `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau | | `/p/{projekt}/e/{bezeichner}/versions` | Alle Fassungen eines Eintrags | | `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen | | `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph | -| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) | +| `/p/{projekt}/keys` | Zugänge verwalten, optional mit Ablaufdatum (Rolle `admin`) | | `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) | | `/p/{projekt}/usage` | Nutzung: geänderte kuratierte Einträge zur Prüfung, Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Suchen der Agenten samt Anfragen ohne Treffer, Bestand, Verweise ins Leere, aktive Zugänge | -| `/p/{projekt}/audit` | Protokoll des Projekts | +| `/p/{projekt}/audit` | Protokoll des Projekts, filterbar nach Vorgang, Zugang und Zeitraum | | `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) | -| `/hilfe` | Eingebaute Onboarding-Hilfe für alle Rollen: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Die Seite für Plattform-Admins sieht nur der Plattform-Admin. | -| `/healthz` | Healthcheck, ohne Anmeldung | +| `/hilfe` | Eingebaute Onboarding-Hilfe, oben rechts in der Kopfleiste: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Dazu je eine Seite für Workspace-Admins (sehen Workspace- und Plattform-Admin) und für Plattform-Admins (nur dieser). | +| `/live` | Läuft der Prozess? Ohne Anmeldung, ohne Datenbank. Für den Neustart des Containers (`/healthz` ist der alte Name und geht weiter) | +| `/ready` | Kann er Anfragen bedienen? Prüft die Datenbank, sonst `503`. Ohne Anmeldung. Für Load-Balancer und Monitoring | ### Funktionen beim Bearbeiten und Lesen @@ -409,9 +414,21 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh - **Schlagworte für viele Einträge:** Mit Schreibrecht hat die Eintragsliste Auswahlkästchen. Für die ausgewählten Einträge lassen sich Schlagworte auf einmal hinzufügen oder entfernen. +- **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene + Einträge mit ähnlichem Titel und die gemeinsamen Wörter — dieselbe Prüfung + wie bei `remember` über MCP (`internal/dubletten`), hier nur als Hinweis. +- **Rückfrage beim Verlassen:** Wer im Formular etwas geändert hat und die + Seite verlässt, ohne zu speichern, bekommt eine Rückfrage des Browsers. +- **Auch verwandt:** Unter einem Eintrag stehen neben den Rückverweisen die + Einträge, die über einen Zwischeneintrag verknüpft sind — wie `related`. +- **Veraltet:** Einträge, die seit über einem Jahr niemand geändert hat, tragen + eine Marke; ein Filter neben der Suche zeigt genau diese, die Nutzungsseite + nennt ihre Zahl. +- **Tastenkürzel:** `/` Suche, `n` neuer Eintrag, `e` bearbeiten, + `Strg+Enter` speichern. -Vorschau, Autovervollständigung und Graph brauchen JavaScript. Alles andere -funktioniert auch ohne. +Vorschau, Autovervollständigung, „Gibt es schon?“, Rückfrage, Tastenkürzel und +Graph brauchen JavaScript. Alles andere funktioniert auch ohne. ### Rollen @@ -429,7 +446,9 @@ funktioniert auch ohne. |---|---|---| | `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. | | `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. | -| `DATA_DIR` | `data` | Datenverzeichnis: Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace | +| `DB_DRIVER` | `sqlite` | Wo die Daten liegen: `sqlite` oder `postgres` (siehe AUSBAUSTUFEN.md 4.10) | +| `DATA_DIR` | `data` | Nur SQLite: Datenverzeichnis mit Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace | +| `DATABASE_URL` | — | Nur PostgreSQL, dann **Pflicht**: z.B. `postgres://munin:geheim@host:5432/munin`. Schemas `munin_control` und je Workspace `munin_ws_`. Lokal: `docker-compose.dev.yml` | | `PORT` | `8080` | Port des Servers | | `COOKIE_SECURE` | automatisch | `Secure`-Flag der Cookies. Ohne Angabe: an, außer bei localhost. | | `ENABLE_HSTS` | `false` | HSTS-Header senden. Nur bei gültigem Zertifikat. | @@ -539,6 +558,74 @@ docker run -d --name munin \ Die Geheimnisse sollten aus einem Passwortspeicher kommen, nicht bei jedem Start neu erzeugt werden — ein neues `SESSION_SECRET` macht alle Sitzungen ungültig. +### Mit PostgreSQL + +Statt der SQLite-Dateien kann Munin eine PostgreSQL-Datenbank nutzen +(Hintergrund: [AUSBAUSTUFEN.md 4.10](AUSBAUSTUFEN.md)). Darin legt es selbst +an, was es braucht: das Schema `munin_control` und je Workspace ein Schema +`munin_ws_`. Die Tabellen sind dieselben wie unter SQLite. + +**Datenbank einrichten** (einmalig, als Datenbank-Admin). Am einfachsten +gehört Munin die Datenbank: + +```sql +CREATE ROLE munin LOGIN PASSWORD '…'; +CREATE DATABASE munin OWNER munin TEMPLATE template0 LOCALE 'C' ENCODING 'UTF8'; +``` + +Soll die Datenbank einem anderen Benutzer gehören (etwa dem des +Datenbankbetriebs), reichen diese Mindestrechte: + +```sql +CREATE ROLE munin LOGIN PASSWORD '…' NOSUPERUSER NOCREATEDB NOCREATEROLE; +GRANT CONNECT, CREATE ON DATABASE munin TO munin; +``` + +Mehr braucht Munin nicht: keine Rechte am Schema `public`, kein Superuser, +kein `CREATEDB`/`CREATEROLE`. Beide Wege sind erprobt (2026-10-16). Wofür +welches Recht gebraucht wird, steht in +[`BETRIEB.md`](BETRIEB.md#rechte-des-datenbankbenutzers). + +| Anforderung | Warum | +|---|---| +| PostgreSQL 13 oder neuer, erprobt mit 18 | Fensterfunktionen und Identitätsspalten; pgx unterstützt 13+ | +| Munin ist Eigentümer der Datenbank **oder** hat `CONNECT, CREATE` darauf; kein Superuser | Er legt je Workspace ein Schema an. Was er anlegt, gehört ihm; deshalb darf er darin Tabellen anlegen und das Schema beim Löschen des Workspace wieder entfernen | +| Sortierung `C` (`LOCALE 'C'`) | Sortiert wie SQLite, Byte für Byte. Sonst stünde z.B. `a-c` vor `ab`. Munin warnt beim Start, wenn es anders ist | +| `max_connections` ≥ Workspaces × 3 + 14 | Je Workspace bis zu 3 Verbindungen, 4 fest, 10 Reserve. Bei 40 Workspaces also 134, `200` ist eine gute Zahl. Reicht es nicht, startet Munin nicht und nennt die nötige Zahl; ebenso wird ein neuer Workspace abgelehnt | +| Genau **ein** Munin-Prozess | Suchindex und Schreibbudget liegen im Arbeitsspeicher. Ein zweiter Prozess fände veraltete Stände | + +**Starten:** + +```bash +docker run -d --name munin \ + -p 8080:8080 \ + -v munin-daten:/data \ + -e DB_DRIVER=postgres \ + -e DATABASE_URL='postgres://munin@db.intern:5432/munin?sslmode=verify-full&sslrootcert=/certs/ca.pem' \ + -e PGPASSWORD="…" \ + -e SESSION_SECRET="…" \ + -e ADMIN_TOKEN="…" \ + munin +``` + +- **Passwort** nicht in `DATABASE_URL` schreiben, sondern über `PGPASSWORD` + oder eine `.pgpass`-Datei (beides liest Munin). Steht es doch in der URL, + müssen Sonderzeichen URL-kodiert sein (`@` → `%40`). +- **Verschlüsselung:** im Netz mindestens `sslmode=require`, besser + `verify-full` mit dem Zertifikat der eigenen CA (`sslrootcert`). + `sslmode=disable` nur, wenn die Datenbank auf demselben Rechner läuft. +- **Das Volume `/data`** braucht es nur noch für `EXPORT_DIR`, falls gesetzt. +- **Ist die Datenbank beim Start noch nicht bereit**, versucht Munin es bis zu + 15-mal im Abstand von 2 Sekunden. Fällt sie im laufenden Betrieb weg, + schlagen Anfragen fehl, solange sie fehlt; abgebrochene Verbindungen + verwirft Go (`database/sql`) und baut danach neue auf. Ein Neustart von + Munin sollte also nicht nötig sein — erprobt ist das aber noch nicht, siehe + [`BETRIEB.md`](BETRIEB.md#postgresql-ist-weg). +- **Sicherung:** `scripts/backup.sh` mit `DB_DRIVER=postgres`, siehe unten. + +Für die Entwicklung startet `docker-compose.dev.yml` eine passend +eingestellte Datenbank. + ### Hinter einem Reverse-Proxy ```bash @@ -553,7 +640,7 @@ gleich alle aussperren. ### Datensicherung -**Datenbank.** Jeder Workspace (Abteilung) liegt in einer eigenen Datei unter +**Datenbank (SQLite).** Jeder Workspace (Abteilung) liegt in einer eigenen Datei unter `DATA_DIR/workspaces/`, dazu kommt die zentrale Kontroll-Datenbank `DATA_DIR/control.db`. Weil SQLite im WAL-Modus läuft, sollte im laufenden Betrieb nicht einfach kopiert werden — `scripts/backup.sh` sichert beides @@ -577,6 +664,32 @@ Server wieder starten. Die genauen Schritte und was dabei zu beachten ist (Hilfsdateien `-wal`/`-shm`, wieder gültige Keys) stehen in [`BETRIEB.md`](BETRIEB.md#aus-einer-sicherung-wiederherstellen). +**Datenbank (PostgreSQL).** Dasselbe Skript sichert mit `pg_dump` jedes Schema +in eine eigene Datei: `control.dump` und `workspaces/munin_ws_.dump`. Jede +Datei wird nach dem Schreiben auf Lesbarkeit geprüft. Ein einzelner Workspace +lässt sich so gezielt zurückspielen. + +```bash +DB_DRIVER=postgres DATABASE_URL='postgres://munin@db.intern:5432/munin' \ + BACKUP_DIR=/pfad/sicherungen scripts/backup.sh +``` + +Gebraucht werden `psql`, `pg_dump` und `pg_restore` in mindestens der Version +des Servers. Fehlen sie, lassen sie sich aus einem PostgreSQL-Container leihen +(`PSQL`, `PG_DUMP`, `PG_RESTORE`, siehe Kopf von `scripts/backup.sh`). + +**Probe der Sicherung.** `scripts/sicherung-pruefen.sh` spielt eine Sicherung +zurück, startet Munin darauf (eigener Port, eigene Geheimnisse), wartet auf +`/ready`, vergleicht die Zahl der Workspaces und räumt auf. Für PostgreSQL +braucht es eine leere Probe-Datenbank (`PROBE_DATABASE_URL`), die einmal +angelegt wird; ist sie nicht leer, bricht das Skript ab. + +```bash +scripts/sicherung-pruefen.sh /pfad/sicherungen/ +DB_DRIVER=postgres PROBE_DATABASE_URL='postgres://munin@db.intern:5432/munin_probe' \ + scripts/sicherung-pruefen.sh /pfad/sicherungen/ +``` + **Markdown-Export.** Ist `EXPORT_DIR` gesetzt, schreibt der Server den Bestand aller Workspaces täglich als Markdown-Dateien dorthin: ein Ordner je Workspace, darin ein Ordner je Projekt, eine Datei je Eintrag und eine `_index.md` als @@ -701,26 +814,39 @@ kennen `store`, aber `store` kennt keinen von beiden. ### Datenbank -SQLite über `modernc.org/sqlite` — ein in Go übersetzter Nachbau, kein C. Nur -deshalb lässt sich das Programm als statisches Binary ohne Laufzeitumgebung -ausliefern. +SQLite über `modernc.org/sqlite` — ein in Go übersetzter Nachbau, kein C — oder +PostgreSQL über `pgx`, ebenfalls reines Go. Nur deshalb lässt sich das Programm +als statisches Binary ohne Laufzeitumgebung ausliefern. -Das SQL vermeidet bewusst alles, was nur SQLite kann (kein `AUTOINCREMENT`, keine -Datumsfunktionen, keine `INSERT OR REPLACE`), damit ein Umstieg auf PostgreSQL -offenbleibt. Die Regeln stehen als Kommentar in -`internal/store/migrations/001_init.sql`. +Welche Datenbank, entscheidet `DB_DRIVER`. Alle Abfragen sind für beide +dieselben: Sie werden mit `?` geschrieben und laufen durch einen kleinen +Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialekt.go`, +`verbindung.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden +kann. Die Regeln stehen als Kommentar in +`internal/store/migrations/sqlite/001_init.sql`. -Es gibt zwei Schemata mit je eigenem Migrationsordner: +Es gibt zwei Schemata mit je eigenem Migrationsordner, darin je Datenbank ein +Unterordner: | Ordner | Gilt für | |---|---| -| `internal/store/migrations/` | jede Workspace-Datei (Projekte, Einträge, Keys, Protokoll) | -| `internal/store/migrations_control/` | die Kontroll-Datenbank `control.db` (Workspaces, Zuordnung Key → Workspace) | +| `internal/store/migrations/{sqlite,postgres}/` | jeden Workspace (Projekte, Einträge, Keys, Protokoll) | +| `internal/store/migrations_control/{sqlite,postgres}/` | die Kontroll-Datenbank (Workspaces, Zuordnung Key → Workspace) | -**Schema ändern:** im passenden Ordner eine neue Datei mit der nächsten Nummer -anlegen, für Workspace-Dateien derzeit `005_….sql`. Sie wird automatisch -angewandt und vermerkt: für die Kontroll-Datenbank beim Start, für eine -Workspace-Datei beim ersten Öffnen. Bestehende Migrationen werden nie verändert. +**Schema ändern:** eine neue Datei mit der nächsten Nummer anlegen, derzeit +`008_….sql` für Workspaces, **in beiden Unterordnern** unter demselben Namen. +Sie wird automatisch angewandt und vermerkt: für die Kontroll-Datenbank beim +Start, für einen Workspace beim ersten Öffnen. Bestehende Migrationen werden +nie verändert. + +Zwei Tests passen auf: `TestMigrationsordnerGleich` (beide Ordner haben +dieselben Dateien, läuft immer) und `TestSchemaGleich` (beide ergeben dasselbe +Schema, braucht PostgreSQL): + +```bash +docker compose -f docker-compose.dev.yml up -d +MUNIN_TEST_PG='postgres://munin:munin@localhost:5432/munin_test?sslmode=disable' go test ./... +``` --- diff --git a/REPOMAP.md b/REPOMAP.md index ee62bf2..08f1c0e 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -56,6 +56,20 @@ munin/ │ │ │ der Treiber modernc.org/sqlite ist: Er ist reines │ │ │ Go und erlaubt damit ein statisches Binary. │ │ │ +│ │ ├── speicher.go ← Wo liegen die Daten? Interface Speicher, von +│ │ │ main.go anhand von DB_DRIVER gewählt +│ │ │ (AUSBAUSTUFEN.md 4.10). +│ │ ├── speicher_sqlite.go ← Eine Datei je Workspace plus control.db (Standard). +│ │ ├── speicher_postgres.go← Ein Schema je Workspace (munin_ws_) plus +│ │ │ munin_control. Feste Poolgrößen, Prüfung gegen +│ │ │ max_connections, Wiederholung beim Start, +│ │ │ Workspace-Name als Schema-Kommentar. +│ │ ├── dialekt.go ← Was sich zwischen SQLite und PostgreSQL +│ │ │ unterscheidet: Platzhalter (? → $1) und +│ │ │ Migrationsordner. Bewusst klein. +│ │ ├── verbindung.go ← pool/transaktion: Jede Abfrage läuft hier durch +│ │ │ den Dialekt — vorbei kommt keine. +│ │ │ │ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl): │ │ │ Workspaces anlegen, umbenennen, löschen, dazu die │ │ │ Zuordnung Key-Prüfsumme → Workspace @@ -78,7 +92,8 @@ munin/ │ │ ├── keys.go ← API-Keys. ResolveKeyHash ist der heißeste Pfad im │ │ │ Programm (jeder MCP-Request) und deshalb ein │ │ │ indizierter Lookup über die Prüfsumme, keine -│ │ │ Schleife. Zurückgezogene Keys gelten als nicht +│ │ │ Schleife. Zurückgezogene und abgelaufene Keys +│ │ │ (expires_at, migrations/007) gelten als nicht │ │ │ vorhanden, der Datensatz bleibt fürs Protokoll. │ │ │ │ │ ├── entries.go ← Einträge. PutEntry legt an ODER aktualisiert — @@ -107,6 +122,10 @@ munin/ │ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys. │ │ │ Keine eigene Zähltabelle. │ │ │ +│ │ ├── systemstand.go ← Kennzahlen eines ganzen Workspace für die +│ │ │ Systemseite: Bestand, Belegung (Dialekt- +│ │ │ Methode belegung), Aktivität je Tag, +│ │ │ Schemastand. │ │ ├── suchprotokoll.go ← Was Agenten mit recall suchen (search_log): │ │ │ LogSearch, SearchStatsFor, EmptySearches, │ │ │ PruneSearchLog. Eigene Tabelle statt Protokoll, @@ -132,13 +151,19 @@ munin/ │ │ │ Plattenplatzes — das Protokoll enthält Adressen │ │ │ und Zugangsnamen. EntryWriteDetail vermerkt bei │ │ │ Schreibvorgängen "angelegt" oder "aktualisiert". +│ │ │ AuditFilter filtert nach Vorgang, Zugang und +│ │ │ Zeitraum; AuditActions benennt die Vorgänge. │ │ │ -│ │ ├── migrations_control/ +│ │ ├── migrations_control/ ← je Datenbank ein Unterordner: sqlite/, postgres/ │ │ │ └── 001_init.sql ← Schema der Kontroll-Datenbank: workspaces und │ │ │ key_index (Key-Prüfsumme → Workspace). Bewusst │ │ │ dünn — Inhalte liegen nie hier. │ │ │ -│ │ └── migrations/ ← Schema jeder Workspace-Datei +│ │ └── migrations/ ← Schema jedes Workspace. Je Datenbank ein +│ │ Unterordner (sqlite/, postgres/) mit denselben +│ │ Dateinamen; eine neue Migration gehört in beide. +│ │ Abweichungen stehen in der Datei (z.B. 006: +│ │ Identitätsspalte in PostgreSQL). │ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln │ │ │ für portables SQL, an die sich alle weiteren │ │ │ Migrationen halten sollen. @@ -158,18 +183,31 @@ munin/ │ │ ├── 004_aenderungshistorie.sql │ │ ← Tabelle entry_versions für abgelöste Fassungen. │ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE). -│ │ └── 005_papierkorb.sql +│ │ ├── 005_papierkorb.sql │ │ ← deleted_at/deleted_by an entries und ein │ │ Teilindex nur über den Papierkorb. Löschen setzt │ │ nur noch die Markierung; CASCADE greift erst │ │ beim endgültigen Löschen. +│ │ ├── 006_suchprotokoll.sql +│ │ ← Tabelle search_log (recall-Anfragen, 30 Tage). +│ │ └── 007_key_ablauf.sql +│ │ ← expires_at an api_keys. Leer = läuft nie ab; +│ │ abgelaufen gilt wie zurückgezogen. +│ │ +│ ├── statistik/ ── Kennzahlen aller Workspaces ─────────────────── +│ │ └── statistik.go ← Sammler für die Systemseite: Bestand, Platz, +│ │ Schema-Stand und Aktivität der letzten 30 Tage +│ │ über alle Workspaces. Rechnet beim Aufruf der +│ │ Seite, höchstens alle 5 Minuten neu. │ │ │ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ────── -│ │ └── workspace.go ← Manager öffnet Workspace-Dateien bei Bedarf und -│ │ hält sie offen, jeweils mit eigenem Suchindex. -│ │ Datenbank und Index gehören zusammen, damit kein -│ │ Index versehentlich an der falschen Datei hängt. -│ │ Delete schließt eine Datei und entfernt sie. +│ │ └── workspace.go ← Manager öffnet Workspaces über den +│ │ store.Speicher bei Bedarf und hält sie offen, +│ │ jeweils mit eigenem Suchindex. Datenbank und +│ │ Index gehören zusammen, damit kein Index +│ │ versehentlich an der falschen Datei hängt. +│ │ Delete schließt einen Workspace und entfernt +│ │ seine Datei bzw. sein Schema. │ │ │ ├── auth/ ── Wer ist das? ──────────────────────────────────── │ │ ├── doc.go ← Erklärt die beiden Wege hinein (API-Key im Header, @@ -284,13 +322,16 @@ munin/ │ │ Aufbau nicht den ganzen Workspace aufhält. │ │ │ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ─────────────────── -│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel) und +│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel, +│ │ │ Zielgruppe: alle / Admins / Plattform-Admin) und │ │ │ das Rendern mit Überschrift-Ankern. Erklärt, │ │ │ warum die Hilfe im Binary steckt statt als │ │ │ Einträge in der Datenbank: Sie muss zur │ │ │ laufenden Fassung des Programms passen. │ │ └── seiten/*.md ← Die Inhalte, je Seite eine Datei. {{SERVER}} und │ │ {{PROJEKT}} werden beim Anzeigen ersetzt. +│ │ Tests: jede Seite verlinkt nur, was ihr Leser +│ │ sehen darf, und jeder #Abschnitt-Link trifft. │ │ │ ├── web/ ── HTTP: Browser und API ────────────────────────── │ │ ├── context.go ← Session-Typ (wer greift zu, was darf er) und die @@ -329,6 +370,26 @@ munin/ │ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage), │ │ │ für jede Rolle. Die Admin-Projektliste nutzt │ │ │ dieselben Zahlen (projectListItems). +│ │ ├── handlers_system.go ← /admin/system (Plattform-Admin): Kasten +│ │ │ "Zustand" (systemHinweise: Warnungen und +│ │ │ Hinweise), Aktivität, Bestand, Speicher, +│ │ │ Bereitschaft, offene Verbindungen je Workspace +│ │ │ gegen das Budget, Fassung aus den Build-Infos. +│ │ ├── handlers_ueberblick.go +│ │ │ ← /admin/overview: Überblick des Workspace für +│ │ │ Workspace-Admins — Befunde je Projekt mit Link +│ │ │ (projektHinweise), Projekttabelle, Aktivität. +│ │ ├── diagramm.go ← Balkendiagramme der Systemseite als SVG, vom +│ │ │ Server ausgerechnet (kein JavaScript, CSP-fest). +│ │ ├── handlers_suche.go ← /admin/search: Suche in allen Projekten des +│ │ │ Workspace, nach Projekt gruppiert (Plattform- +│ │ │ und Workspace-Admin; Keys bleiben bei einem +│ │ │ Projekt). +│ │ ├── handlers_bereitschaft.go +│ │ │ ← /live (Prozess läuft, für den Container-Neustart) +│ │ │ und /ready (Datenbank antwortet, sonst 503; für +│ │ │ Load-Balancer). /healthz ist Alias für /live. +│ │ │ Ins Log kommt nur der Wechsel der Bereitschaft. │ │ ├── handlers_download.go │ │ │ ← Download als ZIP: ein Projekt (/p/{projekt}/ │ │ │ export.zip, wer darf: Session.CanDownloadProject) @@ -364,6 +425,15 @@ munin/ │ │ tatsächlichen Werte in den Feldern, und ein │ │ Speichern ohne Änderung überschreibt nichts. │ │ +│ ├── dubletten/ ── Gibt es schon? ────────────────────────────────── +│ │ └── dubletten.go ← Vor dem Anlegen: Gibt es einen Eintrag mit +│ │ ähnlichem Titel? (Pruefe) Gemeinsames Präfix +│ │ zählt nicht, Suchbegriffe nur wörtlich, die +│ │ gemeinsamen Wörter kommen mit. Genutzt von +│ │ remember (MCP: candidates statt schreiben, +│ │ force legt doch an) und vom Formular "Neuer +│ │ Eintrag" (nur Hinweis). +│ │ │ └── mcpserver/ ── Die Hauptschnittstelle ──────────────────────── │ ├── server.go ← Handler und Transport (Streamable HTTP), ein │ │ MCP-Server je Projekt. Enthält den Text der @@ -375,10 +445,6 @@ munin/ │ │ override_curated und Begründung; die Änderung │ │ landet auf der Nutzungsseite zur Prüfung. │ │ -│ ├── dubletten.go ← Vor dem Anlegen: Gibt es einen Eintrag mit -│ │ ähnlichem Titel? Dann nichts schreiben, sondern -│ │ candidates zurückgeben (force legt doch an). -│ │ │ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related, │ remember, forget. Alte Einträge tragen in den │ Antworten "stale": true. Jedes ist eine Closure @@ -407,15 +473,33 @@ munin/ │ │ Knoten auseinander, Namen nur, wo Platz ist. │ │ Texte nur über textContent, nie innerHTML. │ ├── tagfilter.js ← Filterfeld auf der Seite "Alle Schlagworte". +│ ├── aehnlich.js ← "Gibt es schon?" unter dem Titel eines neuen +│ │ Eintrags, fragt nach einer Tipp-Pause. +│ ├── ungespeichert.js ← Rückfrage beim Verlassen eines Formulars mit +│ │ ungespeicherten Änderungen (data-ungespeichert). +│ ├── tastenkuerzel.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter +│ │ speichern. Ziel steht als data-kuerzel im +│ │ Template, die Rechte also dort. │ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne │ Hintergrund). Die Anmeldeseite wählt je nach │ Farbschema die passende. │ ├── scripts/ -│ └── backup.sh ← Sichert control.db und jede Workspace-Datei -│ einzeln über sqlite3 .backup in einen -│ zeitgestempelten Ordner; RETENTION_DAYS räumt -│ alte Sicherungen auf. +│ ├── backup.sh ← Sichert Kontroll-Datenbank und jeden Workspace +│ │ einzeln in einen zeitgestempelten Ordner: +│ │ SQLite über sqlite3 .backup, PostgreSQL +│ │ (DB_DRIVER=postgres) über pg_dump je Schema, +│ │ mit Lesbarkeitsprüfung. RETENTION_DAYS räumt +│ │ alte Sicherungen auf. +│ ├── sicherung-pruefen.sh ← Probe einer Sicherung (SQLite oder PostgreSQL): +│ │ zurückspielen, Munin darauf starten, /ready +│ │ und Zahl der Workspaces prüfen, aufräumen. +│ │ Lehnt eine nicht leere Probe-Datenbank ab. +│ └── postgres-dev-init.sql ← Legt im Entwicklungs-Container die Datenbank +│ munin_test für die Tests an. +│ +├── docker-compose.dev.yml ← PostgreSQL für die Entwicklung (Sortierung C, +│ max_connections 200, nur localhost). │ └── data/ ← Daten (nicht im Repository, als Volume gemountet) ├── control.db ← Kontroll-Datenbank: Workspaces, Key-Zuordnung @@ -441,10 +525,16 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`. | Datei | Schwerpunkt | |---|---| | `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For | +| `internal/store/systemstand_test.go` | Bestand samt Key-Regeln, Belegung wächst mit dem Inhalt, Aktivität je Tag und Weg mit Zeitgrenze, Schemastand (auch gegen PostgreSQL) | +| `internal/statistik/statistik_test.go` | Summen über zwei Workspaces, alle 30 Tage gefüllt, aktive und ruhige Projekte, Zwischenspeicher für 5 Minuten | +| `internal/web/diagramm_test.go` | Balken gestapelt von der Nulllinie, Skala 1-2-5, kleine Werte sichtbar, Tooltip, Bytes lesbar | | `internal/store/nutzung_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge | | `internal/store/papierkorb_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist | | `internal/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll, Änderungshistorie | | `internal/store/control_test.go` | Kontroll-Datenbank: Standard-Workspace bleibt stabil, Auflistung, Zuordnung Key → Workspace | +| `internal/store/dialekt_test.go` | Platzhalter-Umwandlung (Text, Kommentare, Bezeichner), **beide Migrationsordner gleich**, Schemanamen, Textkonstanten | +| `internal/store/postgres_test.go` | Nur mit `MUNIN_TEST_PG`: **Schema-Vergleich SQLite ↔ PostgreSQL**, Schema je Workspace samt Kommentar, Abschottung, Verbindungsbudget. Schaltet außerdem `testStore`/`testControl` auf PostgreSQL um | +| `internal/importer/postgres_test.go` | Nur mit `MUNIN_TEST_PG`: Export als ZIP aus einem PostgreSQL-Workspace und Import in einen zweiten, Einträge danach gleich | | `internal/workspace/workspace_test.go` | Manager hält Dateien offen, trennt Workspaces, schließt sauber | | `internal/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung | | `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe | @@ -459,7 +549,9 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`. | `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe | | `internal/web/download_test.go` | ZIP-Download: Inhalt und Protokolleintrag, nur Admins, kein fremdes Projekt, Workspace-ZIP für Plattform- und Workspace-Admin, sichere Dateinamen | | `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route | -| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse | +| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse, `/live` und `/ready` (503 ohne Datenbank, ohne Einzelheiten) | +| `internal/web/oberflaeche_test.go` | Veraltete Einträge (Marke, Filter, Nutzungsseite), „Auch verwandt“, „Gibt es schon?“ (auch: nicht mit Leserecht, nicht bei vorhandenem Bezeichner), Formular-Attribute und Kürzel je Rolle, Protokollfilter, Systemseite (nur Plattform-Admin), **Ablaufdatum für Keys** (Ende der Sitzung), Suche über alle Projekte (nicht für Projektzugänge) | +| `internal/dubletten/dubletten_test.go` | Wann ein gemeinsames Titel-Präfix aus dem Vergleich fällt | | `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung (gleicher Bezeichner und ähnlicher Titel), Synonyme, Suchprotokoll, Konflikte, Rollen, **Projektabschottung** | | `internal/store/suchprotokoll_test.go` | Kennzahlen und Anfragen ohne Treffer, Zeitfenster, Aufräumen, verschwindet mit dem Projekt | | `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML | @@ -492,7 +584,7 @@ KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "c → web/middleware.go (APIAuth) wie oben → mcpserver/tools.go (remember) → requireWrite() Rolle read wird hier abgewiesen - → dubletten.go (pruefeDubletten) neuer Bezeichner, ähnlicher Titel? + → dubletten (Pruefe) neuer Bezeichner, ähnlicher Titel? → candidates zurück, nichts schreiben → kuratiert.go (istKuratiert) kuratiert und kein override_curated? → judgment_required, nichts schreiben diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..2cff9ef --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,47 @@ +# PostgreSQL für die lokale Entwicklung — siehe AUSBAUSTUFEN.md 4.10. +# +# Nur für die Entwicklung gedacht: feste, schwache Zugangsdaten, Port offen +# auf localhost. Für den Betrieb ungeeignet. +# +# docker compose -f docker-compose.dev.yml up -d # starten +# docker compose -f docker-compose.dev.yml down # stoppen (Daten bleiben) +# docker compose -f docker-compose.dev.yml down -v # stoppen und Daten löschen +# +# Munin damit starten: +# DB_DRIVER=postgres \ +# DATABASE_URL='postgres://munin:munin@localhost:5432/munin?sslmode=disable' \ +# go run . +# +# Tests gegen PostgreSQL (eigene Datenbank munin_test). Store, Export und +# Import laufen dann gegen PostgreSQL, alles andere wie immer gegen SQLite: +# MUNIN_TEST_PG='postgres://munin:munin@localhost:5432/munin_test?sslmode=disable' \ +# go test ./... + +services: + postgres: + image: postgres:18 + # Munin braucht je Workspace bis zu 3 Verbindungen (siehe + # internal/store/speicher_postgres.go). 200 reichen für rund 60 Workspaces. + command: postgres -c max_connections=200 + environment: + POSTGRES_USER: munin + POSTGRES_PASSWORD: munin + POSTGRES_DB: munin + # Sortierung "C": Text wird Byte für Byte sortiert, genau wie in SQLite. + # Ohne das stünde z.B. "a-c" vor "ab". Wirkt nur beim allerersten Start + # (leeres Volume). + POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8" + ports: + - "127.0.0.1:5432:5432" + volumes: + - munin-pgdata:/var/lib/postgresql + # Legt beim allerersten Start die Datenbank munin_test für die Tests an. + - ./scripts/postgres-dev-init.sql:/docker-entrypoint-initdb.d/munin-init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U munin -d munin"] + interval: 2s + timeout: 3s + retries: 15 + +volumes: + munin-pgdata: diff --git a/go.mod b/go.mod index c876f7f..8b9d10a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module munin go 1.26.4 require ( + github.com/jackc/pgx/v5 v5.11.0 github.com/joho/godotenv v1.5.1 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/yuin/goldmark v1.8.2 @@ -13,6 +14,9 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -20,7 +24,9 @@ require ( github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.29.0 // indirect modernc.org/libc v1.75.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/go.sum b/go.sum index e1568ed..34f99da 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -12,6 +15,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -20,12 +31,19 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= @@ -38,8 +56,14 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= diff --git a/internal/dubletten/dubletten.go b/internal/dubletten/dubletten.go new file mode 100644 index 0000000..21b32a1 --- /dev/null +++ b/internal/dubletten/dubletten.go @@ -0,0 +1,202 @@ +// Package dubletten erkennt, ob ein neuer Eintrag einem vorhandenen ähnelt +// (AUSBAUSTUFEN.md 3.3). +// +// Eigenes Paket, weil zwei Wege es brauchen: "remember" über MCP (fragt +// zurück, statt anzulegen) und das Formular "Neuer Eintrag" der Weboberfläche +// (zeigt beim Tippen „Gibt es schon?"). Beide sollen dieselbe Prüfung haben — +// zwei getrennte würden mit der Zeit unterschiedlich urteilen. +package dubletten + +// Dubletten über Ähnlichkeit erkennen. +// +// Der Bezeichner schützt nur vor Dubletten unter GLEICHEM Namen. Wählt ein +// Agent für ein vorhandenes Thema einen neuen Bezeichner („deployment-ablauf" +// statt „deployment-prozess"), stünde dieselbe Erkenntnis zweimal da — und +// die beiden Fassungen laufen mit der Zeit auseinander. +// +// Deshalb prüft remember vor dem Anlegen, ob es schon einen Eintrag mit +// ähnlichem Titel gibt. Wenn ja, legt es NICHT an, sondern gibt die +// Kandidaten zurück und lässt den Agenten entscheiden. +// +// # Warum zurückgeben statt selbst entscheiden? +// +// Ein Agent, der einen Hinweis mit Kandidaten bekommt, korrigiert sich: Er +// lädt den passenden Eintrag und schreibt ihn fort. Einer, dessen Aufruf +// ohne Erklärung abgelehnt wird, versucht es mit einem noch schlechteren +// Bezeichner. Und manchmal ist ein ähnlicher Titel eben doch ein anderes +// Thema — das weiß der Agent besser als jede Schwelle. Dafür gibt es force. + +import ( + "context" + "errors" + "math" + "regexp" + "sort" + "strings" + + "munin/internal/entry" + "munin/internal/search" + "munin/internal/store" + "munin/internal/workspace" +) + +const ( + // Schwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem + // ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung). + // + // 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf" + // und „Deployment-Prozess" erreichen das gerade — genau der Fall, um den + // es geht. Dafür meldet die Prüfung auch „Server-Zugang" bei + // „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen + // zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer + // mehr. + Schwelle = 0.5 + + // suchtiefe: So viele Suchtreffer werden genauer verglichen. + // Die Suche findet Kandidaten, die Überdeckung entscheidet. + suchtiefe = 10 + + // maxKandidaten: So viele Kandidaten gehen höchstens zurück. + maxKandidaten = 5 +) + +// Kandidat ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt. +type Kandidat struct { + Slug string + Title string + Overlap int // Übereinstimmung in Prozent + + // MatchedWords sind die Wörter, auf denen die Übereinstimmung beruht — + // in der Form, die die Suche erzeugt (klein, Umlaute gefaltet). + MatchedWords []string +} + +// Pruefe sucht vorhandene Einträge, die einem neu anzulegenden ähneln. Ist +// slug schon vergeben, wird aktualisiert statt angelegt — dann gibt es nichts +// zu prüfen, und das Ergebnis ist leer. Ein leerer slug (das Formular, bevor +// jemand einen Bezeichner eingetragen hat) überspringt diese Prüfung. +// +// Verglichen wird der Titel samt der Zeile „Auch gesucht als: …", in beide +// Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen +// „Deployment-Prozess", wenn dort „release" als Suchbegriff steht. Ein +// Suchbegriff zählt dabei nur wörtlich, ein Titelwort auch mit Varianten +// (siehe search.UeberdeckungMitSuchbegriffen). +func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project, + slug, titel, inhalt string) ([]Kandidat, error) { + + if strings.TrimSpace(titel) == "" { + return nil, nil + } + if slug != "" { + _, err := ws.Store.EntryBySlug(ctx, project.ID, slug) + if err == nil { + return nil, nil // vorhanden: Aktualisierung, keine Neuanlage + } + if !errors.Is(err, store.ErrNotFound) { + return nil, err + } + } + + idx, err := ws.Search.For(ctx, project.ID) + if err != nil { + return nil, err + } + + neuerName := titel + " " + entry.AuchGesuchtAls(inhalt) + + var kandidaten []Kandidat + for _, t := range idx.Search(neuerName, suchtiefe) { + e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) + if errors.Is(err, store.ErrNotFound) { + continue // Index kennt einen Eintrag, den es nicht mehr gibt + } + if err != nil { + return nil, err + } + + // Ein gemeinsames Präfix wie „Munin – " sagt nichts über das Thema, + // siehe ohneGemeinsamesPraefix. + themaNeu, themaVorhanden := ohneGemeinsamesPraefix(titel, e.Title) + + // In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist + // vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur + // Hälfte. Beides ist ein Hinweis auf dasselbe Thema. + anteil, gemeinsam := search.UeberdeckungMitSuchbegriffen( + themaNeu, themaVorhanden, entry.AuchGesuchtAls(e.Content)) + rueckwaerts, gemeinsamRueckwaerts := search.UeberdeckungMitSuchbegriffen( + themaVorhanden, themaNeu, entry.AuchGesuchtAls(inhalt)) + if rueckwaerts > anteil { + anteil, gemeinsam = rueckwaerts, gemeinsamRueckwaerts + } + if anteil < Schwelle { + continue + } + + kandidaten = append(kandidaten, Kandidat{ + Slug: e.Slug, + Title: e.Title, + Overlap: int(math.Round(anteil * 100)), + MatchedWords: gemeinsam, + }) + } + + // Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge + // der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall. + sort.SliceStable(kandidaten, func(i, j int) bool { + return kandidaten[i].Overlap > kandidaten[j].Overlap + }) + if len(kandidaten) > maxKandidaten { + kandidaten = kandidaten[:maxKandidaten] + } + return kandidaten, nil +} + +// titelTrenner trennt ein Präfix vom eigentlichen Thema eines Titels: +// „Munin – Architektur", „Munin — Architektur", „Munin - Architektur", +// „Munin: Architektur", „Munin | Architektur". +// +// Ein Bindestrich zählt nur mit Leerzeichen auf beiden Seiten. Ohne +// Leerzeichen ist er Teil eines Worts — „Deployment-Ablauf" hat kein Präfix. +var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`) + +// ohneGemeinsamesPraefix entfernt ein Präfix, das beide Titel gemeinsam +// haben, und gibt die beiden Themen zurück. +// +// # Warum? +// +// Viele Teams benennen Einträge nach dem Muster „Projekt – Thema". Bei +// kurzen Titeln entscheidet dann das gemeinsame Präfix allein: „Munin – +// Architektur" und „Munin – Überblick" teilen ein Wort von zweien, also 50 % +// — genau die Schwelle. Gemeldet wurde so jeder neue Eintrag des Projekts. +// Das Präfix ist aber bei allen gleich und sagt deshalb nichts darüber, ob +// es um dasselbe Thema geht. +// +// Entfernt wird es nur, wenn BEIDE Titel dasselbe Präfix haben. Haben sie +// verschiedene („Munin – Deployment" und „Hugin – Deployment") oder nur einer +// eines, bleibt der volle Titel im Vergleich — dann kann das Präfix etwas +// unterscheiden. Bleibt nach dem Präfix nichts übrig, ebenfalls. +func ohneGemeinsamesPraefix(neu, vorhanden string) (string, string) { + praefixNeu, themaNeu, okNeu := titelTeilen(neu) + praefixVorhanden, themaVorhanden, okVorhanden := titelTeilen(vorhanden) + if !okNeu || !okVorhanden || praefixNeu != praefixVorhanden { + return neu, vorhanden + } + return themaNeu, themaVorhanden +} + +// titelTeilen trennt einen Titel am ersten Trenner in Präfix und Thema. Das +// Präfix kommt vereinheitlicht zurück (klein, einfache Leerzeichen), damit +// „Munin" und „munin" als dasselbe gelten. ok ist false, wenn es keinen +// Trenner gibt oder eine Seite leer ist. +func titelTeilen(titel string) (praefix, thema string, ok bool) { + pos := titelTrenner.FindStringIndex(titel) + if pos == nil { + return "", titel, false + } + praefix = strings.Join(strings.Fields(strings.ToLower(titel[:pos[0]])), " ") + thema = strings.TrimSpace(titel[pos[1]:]) + if praefix == "" || thema == "" { + return "", titel, false + } + return praefix, thema, true +} diff --git a/internal/dubletten/dubletten_test.go b/internal/dubletten/dubletten_test.go new file mode 100644 index 0000000..96835d2 --- /dev/null +++ b/internal/dubletten/dubletten_test.go @@ -0,0 +1,48 @@ +package dubletten + +import "testing" + +// TestOhneGemeinsamesPraefix prüft, wann das Präfix eines Titels aus dem +// Dublettenvergleich herausfällt — nur wenn beide Titel dasselbe haben. +func TestOhneGemeinsamesPraefix(t *testing.T) { + faelle := []struct { + name string + neu, vorhanden string + themaNeu, themaVorhand string + }{ + {"gleiches Präfix mit Halbgeviertstrich", + "Munin – Architektur", "Munin – Überblick", "Architektur", "Überblick"}, + {"Bindestrich mit Leerzeichen", + "Munin - Architektur", "Munin - Überblick", "Architektur", "Überblick"}, + {"Doppelpunkt", + "Munin: Architektur", "Munin: Überblick", "Architektur", "Überblick"}, + {"senkrechter Strich", + "Munin | Architektur", "Munin | Überblick", "Architektur", "Überblick"}, + {"Groß-/Kleinschreibung und Leerzeichen im Präfix egal", + "Munin Wiki – Architektur", "munin wiki – Überblick", "Architektur", "Überblick"}, + {"verschiedene Trenner, gleiches Präfix", + "Munin – Architektur", "Munin: Überblick", "Architektur", "Überblick"}, + {"nur am ersten Trenner geteilt", + "Munin – Architektur – Speicher", "Munin – Überblick", "Architektur – Speicher", "Überblick"}, + + // Hier muss der volle Titel im Vergleich bleiben. + {"verschiedene Präfixe", + "Munin – Deployment", "Hugin – Deployment", "Munin – Deployment", "Hugin – Deployment"}, + {"nur einer hat ein Präfix", + "Munin – Deployment", "Deployment-Prozess", "Munin – Deployment", "Deployment-Prozess"}, + {"Bindestrich ohne Leerzeichen ist kein Trenner", + "Deployment-Ablauf", "Deployment-Prozess", "Deployment-Ablauf", "Deployment-Prozess"}, + {"nichts nach dem Trenner", + "Munin – ", "Munin – Überblick", "Munin – ", "Munin – Überblick"}, + } + + for _, f := range faelle { + t.Run(f.name, func(t *testing.T) { + neu, vorhanden := ohneGemeinsamesPraefix(f.neu, f.vorhanden) + if neu != f.themaNeu || vorhanden != f.themaVorhand { + t.Errorf("ohneGemeinsamesPraefix(%q, %q) = (%q, %q), erwartet (%q, %q)", + f.neu, f.vorhanden, neu, vorhanden, f.themaNeu, f.themaVorhand) + } + }) + } +} diff --git a/internal/export/export.go b/internal/export/export.go index e0bba03..fecac36 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -32,6 +32,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "munin/internal/store" @@ -62,6 +63,42 @@ import ( // sein. type Exporter struct { root string + + // letzter ist der zuletzt abgeschlossene Lauf über alle Workspaces — + // für die Systemseite. Der Mutex schützt ihn, weil der tägliche Lauf in + // einer eigenen Goroutine schreibt, während die Seite liest. + mu sync.Mutex + letzter Lauf +} + +// Intervall bestimmt, wie oft der Markdown-Export automatisch läuft (siehe +// main.go). Die Systemseite meldet einen Export als überfällig, wenn der +// letzte Lauf deutlich länger zurückliegt. +const Intervall = 24 * time.Hour + +// Lauf beschreibt einen Export über alle Workspaces. +type Lauf struct { + Ende time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig + Dauer time.Duration + Stats Stats + Fehler string // leer = vollständig +} + +// MerkeLauf hält das Ergebnis eines Laufs über alle Workspaces fest. Den +// Knopf "Exportieren" für einen einzelnen Workspace merkt sich der Exporter +// nicht: Er sagt nichts darüber, ob die übrigen gesichert sind. +func (e *Exporter) MerkeLauf(l Lauf) { + e.mu.Lock() + defer e.mu.Unlock() + e.letzter = l +} + +// LetzterLauf liefert den zuletzt gemerkten Lauf (Nullwert, wenn noch keiner +// fertig ist). +func (e *Exporter) LetzterLauf() Lauf { + e.mu.Lock() + defer e.mu.Unlock() + return e.letzter } // New erstellt einen Exporter, der unter root schreibt. diff --git a/internal/hilfe/hilfe.go b/internal/hilfe/hilfe.go index 97190b2..2d38748 100644 --- a/internal/hilfe/hilfe.go +++ b/internal/hilfe/hilfe.go @@ -30,11 +30,42 @@ type Seite struct { Titel string // Überschrift und Name in der Navigation Kurz string // ein Satz für die Übersicht - // NurAdmin blendet die Seite für alle aus, die nicht als Plattform-Admin - // angemeldet sind: Sie steht dann weder in der Navigation, noch lässt sie - // sich direkt aufrufen. Geheim ist darin nichts — aber für ein Team mit - // Projekt-Key beschreibt sie nur Knöpfe, die es gar nicht sieht. - NurAdmin bool + // Fuer legt fest, wer die Seite sieht. Wer nicht dazugehört, findet sie + // weder in der Navigation, noch lässt sie sich direkt aufrufen (404). + // Geheim ist darin nichts — aber sie beschreibt Knöpfe, die andere gar + // nicht sehen. + Fuer Zielgruppe +} + +// Zielgruppe einer Hilfeseite. +type Zielgruppe int + +const ( + // FuerAlle: jeder, der angemeldet ist. + FuerAlle Zielgruppe = iota + // FuerAdmins: Workspace-Admins und der Plattform-Admin. + FuerAdmins + // FuerPlattformAdmin: nur der Plattform-Admin. + FuerPlattformAdmin +) + +// Leser beschreibt, wer die Hilfe gerade liest — so viel, wie für die +// Auswahl der Seiten nötig ist. +type Leser struct { + PlattformAdmin bool + WorkspaceAdmin bool +} + +// DarfLesen sagt, ob l die Seite sehen darf. +func (s Seite) DarfLesen(l Leser) bool { + switch s.Fuer { + case FuerPlattformAdmin: + return l.PlattformAdmin + case FuerAdmins: + return l.PlattformAdmin || l.WorkspaceAdmin + default: + return true + } } // seiten legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer @@ -47,7 +78,8 @@ var seiten = []Seite{ {Slug: "mit-agenten-arbeiten", Titel: "Mit Agenten arbeiten", Kurz: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."}, {Slug: "weboberflaeche", Titel: "Weboberfläche", Kurz: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."}, {Slug: "zugaenge", Titel: "Zugänge und Sicherheit", Kurz: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."}, - {Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces und Projekte anlegen, Schreibbudget, Export.", NurAdmin: true}, + {Slug: "workspace-admin", Titel: "Für Workspace-Admins", Kurz: "Projekte und Zugänge einer Abteilung verwalten, Protokoll, Suche über alle Projekte.", Fuer: FuerAdmins}, + {Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces, Workspace-Admins, Schreibbudget, System, Sicherung.", Fuer: FuerPlattformAdmin}, } // Alle gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die @@ -61,15 +93,14 @@ func Alle() []Seite { return kopie } -// Sichtbar gibt die Seiten zurück, die jemand sehen darf: alle für einen -// Plattform-Admin, sonst alle außer den NurAdmin-Seiten. -func Sichtbar(istAdmin bool) []Seite { +// Sichtbar gibt die Seiten zurück, die l sehen darf, in der Reihenfolge der +// Navigation. +func Sichtbar(l Leser) []Seite { var ergebnis []Seite for _, s := range seiten { - if s.NurAdmin && !istAdmin { - continue + if s.DarfLesen(l) { + ergebnis = append(ergebnis, s) } - ergebnis = append(ergebnis, s) } return ergebnis } diff --git a/internal/hilfe/hilfe_test.go b/internal/hilfe/hilfe_test.go index 97d60aa..fce26c3 100644 --- a/internal/hilfe/hilfe_test.go +++ b/internal/hilfe/hilfe_test.go @@ -80,30 +80,108 @@ func TestAlleGibtKopie(t *testing.T) { } } -func TestSichtbarBlendetAdminSeitenAus(t *testing.T) { - for _, s := range Sichtbar(false) { - if s.NurAdmin { - t.Errorf("Seite %q ist nur für Admins, wird aber allen gezeigt", s.Slug) +// Die drei Lesergruppen, für die Tests. +var ( + projektLeser = Leser{} + workspaceAdmin = Leser{WorkspaceAdmin: true} + plattformAdmin = Leser{PlattformAdmin: true} +) + +func TestSichtbarJeLesergruppe(t *testing.T) { + enthaelt := func(seiten []Seite, slug string) bool { + for _, s := range seiten { + if s.Slug == slug { + return true + } + } + return false + } + + for _, f := range []struct { + name string + leser Leser + workspaceSeite, plattSei bool + }{ + {"Projektzugang", projektLeser, false, false}, + {"Workspace-Admin", workspaceAdmin, true, false}, + {"Plattform-Admin", plattformAdmin, true, true}, + } { + sichtbar := Sichtbar(f.leser) + if enthaelt(sichtbar, "workspace-admin") != f.workspaceSeite { + t.Errorf("%s: Seite für Workspace-Admins sichtbar = %v", f.name, !f.workspaceSeite) + } + if enthaelt(sichtbar, "plattform-admin") != f.plattSei { + t.Errorf("%s: Seite für Plattform-Admins sichtbar = %v", f.name, !f.plattSei) + } + if !enthaelt(sichtbar, "start") { + t.Errorf("%s sieht die Startseite nicht", f.name) } } - if len(Sichtbar(true)) != len(Alle()) { - t.Error("ein Admin sieht nicht alle Seiten") + if len(Sichtbar(plattformAdmin)) != len(Alle()) { + t.Error("der Plattform-Admin sieht nicht alle Seiten") } } -// TestAllgemeineSeitenVerlinkenKeineAdminSeiten: Ein Link auf eine Admin-Seite -// liefe für alle anderen ins Leere (404). -func TestAllgemeineSeitenVerlinkenKeineAdminSeiten(t *testing.T) { - link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`) +// TestSeitenVerlinkenNurSichtbares: Ein Link auf eine Seite, die der Leser +// nicht sehen darf, liefe für ihn ins Leere (404). Geprüft für jede +// Lesergruppe: Was sie sieht, verlinkt nur auf das, was sie auch sieht. +func TestSeitenVerlinkenNurSichtbares(t *testing.T) { + link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)[#)]`) - for _, s := range Sichtbar(false) { - md, err := s.Markdown() - if err != nil { - t.Fatal(err) - } - for _, treffer := range link.FindAllStringSubmatch(md, -1) { - if ziel, ok := Finde(treffer[1]); ok && ziel.NurAdmin { - t.Errorf("Seite %q verlinkt auf die Admin-Seite %q", s.Slug, ziel.Slug) + for _, leser := range []Leser{projektLeser, workspaceAdmin} { + for _, s := range Sichtbar(leser) { + md, err := s.Markdown() + if err != nil { + t.Fatal(err) + } + for _, treffer := range link.FindAllStringSubmatch(md, -1) { + if ziel, ok := Finde(treffer[1]); ok && !ziel.DarfLesen(leser) { + t.Errorf("Seite %q verlinkt auf %q, die dieser Leser nicht sieht (%+v)", s.Slug, ziel.Slug, leser) + } + } + } + } +} + +// TestAnkerInLinksGibtEs: Jeder Link auf einen Abschnitt einer Hilfeseite — +// (/hilfe/seite#abschnitt) oder innerhalb der Seite (#abschnitt) — zeigt auf +// eine Überschrift, die es dort gibt. Sonst landet man oben auf der Seite +// statt beim gemeinten Abschnitt, und niemand merkt es. +// +// Die Hilfe-Links der Templates prüft TestHilfelinksZeigenAufVorhandeneAbschnitte +// in internal/web. +func TestAnkerInLinksGibtEs(t *testing.T) { + idMuster := regexp.MustCompile(`.db` im Datenverzeichnis), mit PostgreSQL das Schema +(`munin_ws_`). Damit findest du einen Workspace in der Datenbank wieder, +etwa für eine Sicherung oder in `psql`. + +**Löschen** entfernt die Daten des Workspace (Datei bzw. Schema) mit allen Projekten, Einträgen und Keys. Zur Bestätigung muss der Name eingetippt werden. Rückgängig machen lässt sich das nur aus einer Sicherung. @@ -36,7 +41,9 @@ machen lässt sich das nur aus einer Sicherung. Ein Workspace-Admin legt im eigenen Workspace Projekte an, bearbeitet und löscht sie und verwaltet deren Zugänge, nur über die Weboberfläche. Was er genau darf und was nicht, steht unter -[Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin). +[Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin). Für ihn gibt es +eine eigene Hilfeseite, [Für Workspace-Admins](/hilfe/workspace-admin) — gut +zum Weitergeben zusammen mit dem Token. **Anlegen:** *Workspaces* → *Bearbeiten* → Abschnitt *Workspace-Admins*. Den Namen der Person eintragen. Das Token (beginnt mit `wa_`) wird genau einmal @@ -71,13 +78,71 @@ gerade vor durchdrehenden Agenten schützen soll. ## Protokoll über alle Projekte *Protokoll (alle)* zeigt die Vorgänge aller Projekte des gewählten Workspace, -auch das Anlegen und Löschen von Projekten. +auch das Anlegen und Löschen von Projekten. Gefiltert wird wie im Protokoll +eines Projekts (siehe [Zugänge und Sicherheit](/hilfe/zugaenge#protokoll)), +zusätzlich nach Projekt. + +## Suche in allen Projekten + +*Suche (alle Projekte)* in der Seitenleiste durchsucht alle Projekte des +gewählten Workspace auf einmal, für die Frage „In welchem Projekt stand das +nochmal?“. Die Treffer stehen nach Projekt gruppiert, je Projekt die besten +fünf, das passendste Projekt oben. Über *Weitere Treffer* geht es zur Suche im +jeweiligen Projekt. + +Das können der Plattform-Admin und Workspace-Admins, denn beide sehen ohnehin +alle Projekte. Am Zugriff der Keys ändert sich nichts: Ein Key und ein +Agent sehen weiter genau ein Projekt. + +## System + +*System* (nur Plattform-Admin) zeigt, ob etwas zu tun ist, wie Munin genutzt +wird und worauf es läuft. Von oben nach unten: + +- **Zustand:** alles, was Aufmerksamkeit braucht. *Warnungen* sollten behoben + werden: die Verbindungen werden knapp (ab 80 % der höchstens möglichen + Workspaces), PostgreSQL sortiert nicht nach `C`, die WAL-Datei eines + Workspace wächst über 64 MB, der letzte Export ist fehlgeschlagen oder + überfällig. *Hinweise* sind zur Kenntnis: Keys, die bald ablaufen oder seit + 90 Tagen niemand benutzt hat (je Workspace genannt), oder Anfragen, die seit + dem letzten Blick auf eine Datenbankverbindung warten mussten. Steht hier + „Alles in Ordnung“, ist nichts zu tun. +- **Aktivität der letzten 30 Tage:** Schreibvorgänge und Suchen je Tag über + alle Workspaces. Fährt man mit der Maus über einen Tag, erscheinen die + genauen Zahlen; *Als Tabelle* zeigt alle. Darunter die aktivsten Projekte + und die Projekte ganz ohne Aktivität. Wird so ein Projekt noch gebraucht, + oder fehlt dem Team die Anbindung? +- **Bestand je Workspace:** Projekte, Einträge, ältere Fassungen, Papierkorb, + gültige Keys und der belegte Platz in der Datenbank. +- **Speicher und Verbindungen:** SQLite oder PostgreSQL samt Ort (nie das + Passwort), und ob die Datenbank antwortet, wie bei `/ready`. Dazu, wie viele + Verbindungen gerade offen sind, für die Kontroll-Datenbank und jeden + geöffneten Workspace. Mit PostgreSQL steht daneben das Budget: + `max_connections` und die Zahl der Workspaces, die es höchstens erlaubt. + Darüber startet Munin nicht und lehnt neue Workspaces ab. *Gewartet* zählt, + wie oft eine Anfrage auf eine freie Verbindung warten musste. Ab und zu ist + normal; wächst die Zahl stetig, sind die Pools zu klein. +- **Programm:** Fassung (aus Git, falls beim Bauen vorhanden), Go-Fassung, + Startzeit, Schema-Stand und der letzte Markdown-Export. + +Aktivität und Bestand zählt Munin höchstens alle 5 Minuten neu; daneben steht +der Stand. Verbindungen und Zustand der Datenbank sind bei jedem Neuladen +aktuell. + +Was die Seite nicht zeigt: ob die Sicherung gelaufen ist. Die läuft außerhalb +von Munin, siehe unten. + +Für den gewählten Workspace gibt es außerdem *Überblick (Workspace)*: genau +die Seite, die ein Workspace-Admin sieht — was je Projekt zu tun ist, +Projekte und Aktivität, ohne Technik. Einzelheiten unter +[Für Workspace-Admins](/hilfe/workspace-admin#ueberblick). ## Sicherung und Export Die eigentliche Sicherung läuft außerhalb der Oberfläche über -`scripts/backup.sh`. Das Skript sichert jede Workspace-Datei einzeln, sodass -sich auch eine einzelne Abteilung wiederherstellen lässt. +`scripts/backup.sh`. Das Skript sichert jeden Workspace einzeln (die Datei +bzw. mit PostgreSQL das Schema), sodass sich auch eine einzelne Abteilung +wiederherstellen lässt. Ist auf dem Server ein Exportverzeichnis eingerichtet, schreibt Munin einmal täglich alle Einträge aller Workspaces als Markdown-Dateien dorthin, als diff --git a/internal/hilfe/seiten/weboberflaeche.md b/internal/hilfe/seiten/weboberflaeche.md index 648b643..2cc75b2 100644 --- a/internal/hilfe/seiten/weboberflaeche.md +++ b/internal/hilfe/seiten/weboberflaeche.md @@ -22,7 +22,18 @@ zurückgezogen, endet auch die Sitzung im Browser sofort. *Alle Schlagworte…* zu einer Seite mit der vollständigen Liste, nach Häufigkeit oder alphabetisch sortiert und mit einem Filterfeld. - **Verweisen folgen:** Unter jedem Eintrag steht, welche anderen Einträge auf - ihn verweisen. + ihn verweisen, und unter *Auch verwandt* die Einträge, die über einen + Zwischeneintrag mit ihm verknüpft sind (siehe [unten](#verwandte-eintraege)). +- **Veraltete Einträge:** Einträge, die seit über einem Jahr niemand geändert + hat, tragen die Marke *veraltet*. Neben dem Suchfeld steht dann ein Link wie + *3 veraltet*, der die Liste auf genau diese Einträge filtert. Die Zahl steht + auch auf der Nutzungsseite. Ein veralteter Eintrag kann noch stimmen, sollte + aber geprüft werden: Stimmt er noch, einmal speichern (oder kuratieren), dann + zählt er wieder als aktuell; sonst korrigieren oder löschen. +- **Tastenkürzel:** `/` springt ins Suchfeld, `n` legt einen neuen Eintrag an, + `e` bearbeitet den angezeigten Eintrag, `Strg+Enter` (Mac: `Cmd+Enter`) + speichert im Formular. Die Buchstaben gelten nur, wenn gerade kein + Eingabefeld aktiv ist, und nur, wo die Rolle das darf. - **Graph:** Unter *Graph* in der Seitenleiste zeigt Munin, wie die Einträge über Verweise zusammenhängen. Gestrichelte Knoten sind Verweisziele, zu denen es noch keinen Eintrag gibt. Mausrad zoomt, Ziehen verschiebt, ein Klick @@ -48,6 +59,21 @@ Mit der Rolle `write` oder `admin` gibt es über der Liste den Knopf **Bevor du einen neuen Eintrag anlegst**, such kurz, ob es das Thema schon gibt. Ergänzen ist fast immer besser als ein zweiter Eintrag. +### Gibt es schon? + +Beim Tippen des Titels eines neuen Eintrags zeigt Munin darunter vorhandene +Einträge mit ähnlichem Titel, samt der Wörter, die sie gemeinsam haben. Das +ist dieselbe Prüfung, mit der Munin bei Agenten nachfragt, bevor sie einen +zweiten Eintrag zum selben Thema anlegen. Hier ist es nur ein Hinweis: +Passt einer, öffne ihn (der Link geht in einem neuen Tab auf) und schreib dort +weiter. Ist es ein anderes Thema, einfach speichern. + +### Ungespeicherte Änderungen + +Wer im Formular etwas geändert hat und die Seite verlässt, ohne zu speichern, +etwa durch einen Klick in der Seitenleiste, bekommt eine Rückfrage des +Browsers. Den Wortlaut legt der Browser fest. + ### Markdown im Inhalt Unterstützt werden die üblichen Auszeichnungen: @@ -106,6 +132,19 @@ fremde Änderung zu überschreiben. Dein Text bleibt im Formular erhalten. Öffn den Eintrag in einem zweiten Tab, übernimm dort deine Änderungen in den aktuellen Stand und speichere dann dort. +## Verwandte Einträge + +Unter einem Eintrag stehen zwei Listen: + +- **Verweist hierher:** Einträge, die mit `[[…]]` direkt auf diesen verweisen. +- **Auch verwandt:** Einträge, die über einen **Zwischeneintrag** verknüpft + sind, mit dem Hinweis *über …*. Verweist „Deployment“ auf „Pipeline“ und + „Pipeline“ auf „Runner“, steht unter „Deployment“: *Runner, über pipeline*. + +So findet man Zusammenhänge, die kein Suchwort gemeinsam haben, aber von +jemandem bewusst verknüpft wurden. Agenten bekommen dasselbe über das Werkzeug +`related`. + ## Verlauf und Vergleich In jedem Eintrag führt **Fassung n, Verlauf ansehen** zu allen früheren diff --git a/internal/hilfe/seiten/workspace-admin.md b/internal/hilfe/seiten/workspace-admin.md new file mode 100644 index 0000000..7a498d1 --- /dev/null +++ b/internal/hilfe/seiten/workspace-admin.md @@ -0,0 +1,103 @@ +Als Workspace-Admin kümmerst du dich um die Projekte einer ganzen Abteilung: +Projekte anlegen, Zugänge vergeben und im Blick behalten, ob Munin genutzt +wird. Einträge schreiben die Teams selbst, über ihre eigenen Keys. + +## Anmelden + +Du meldest dich mit deinem Token an (beginnt mit `wa_`), an derselben Stelle +wie mit einem Key. Danach landest du unter *Projekte → Verwalten…* deines +Workspace. Das Token bekommst du vom Plattform-Admin; es wird nur einmal +angezeigt. Geht es verloren oder in falsche Hände, lass es zurückziehen und dir +ein neues geben. + +Über MCP kannst du dich mit dem Token nicht verbinden. Willst du selbst mit +einem Agenten mitarbeiten, leg dir in dem Projekt einen eigenen Key an. + +## Was du darfst + +| Darfst du | Darfst du nicht | +|---|---| +| Projekte deines Workspace anlegen, umbenennen und löschen | Einträge schreiben, löschen oder importieren | +| Zugänge aller Projekte dort anlegen und zurückziehen | das Schreibbudget eines Projekts ändern | +| alle Projekte dort lesen, samt Protokoll und Nutzung | andere Workspaces sehen | +| in allen Projekten auf einmal suchen | auf die Platte des Servers exportieren | +| einzelne Projekte oder den ganzen Workspace als ZIP herunterladen | Workspace-Admins anlegen | + +Warum keine Einträge: Im Protokoll soll eindeutig stehen, welcher Zugang etwas +geschrieben hat. Mehr dazu unter +[Zugänge und Sicherheit](/hilfe/zugaenge#workspace-admin). + +## Ein neues Projekt einrichten + +1. *Projekte → Verwalten…* → **Neues Projekt**: Bezeichner (dauerhaft, z. B. + `plattform`), Name und eine kurze Beschreibung. Die Beschreibung steht über + der Eintragsliste und hilft dem Team, das Projekt richtig zu nutzen. +2. Ins Projekt wechseln → **Zugänge** → je Mensch und je Agent einen Key + anlegen (Rollen siehe [Zugänge und Sicherheit](/hilfe/zugaenge#welche-rolle-fuer-wen)). + Mindestens ein Key mit der Rolle `admin`, damit das Team seine Zugänge + künftig selbst verwalten kann. +3. Die Keys sicher weitergeben. Jeder wird genau einmal angezeigt, zusammen mit + fertigen Konfigurationsblöcken für Claude Code, Cline und OpenCode. +4. Dem Team den Einstieg zeigen: [Agent anbinden](/hilfe/mcp-einrichten) und + [Mit Agenten arbeiten](/hilfe/mit-agenten-arbeiten). + +**Zugänge auf Zeit:** Für Praktika, befristete Projekte oder Tests beim +Anlegen *Gültig bis* setzen. Danach gilt der Key wie zurückgezogen, ohne dass +jemand daran denken muss. + +## Überblick + +*Überblick* in der Seitenleiste ist deine Startseite für die Pflege: eine +Seite für den ganzen Workspace. + +- **Was zu tun ist:** je Projekt, mit einem Link genau dorthin, wo es zu + erledigen ist. *Rot* ist dringend: Ein Key läuft in den nächsten 14 Tagen + ab, oder ein Projekt hat gar keinen gültigen Key. *Grün* ist eine Bitte, bei + Gelegenheit hinzusehen: + - Kein Key mit der Rolle `admin`: Das Team muss für jeden Zugang dich fragen. + - Keys seit 90 Tagen nicht benutzt. + - Ein Projekt, in dem 30 Tage lang niemand geschrieben oder gesucht hat. + - Viele Suchen ohne Treffer (ab 30 %, wenn es mindestens 10 Suchen gab). + - Kuratierte Einträge, die ein Agent geändert hat und die auf Prüfung warten. + - Einträge, die seit über einem Jahr unverändert sind. + + Ein Hinweis verschwindet von selbst, sobald es erledigt ist. +- **Projekte:** alle in einer Tabelle — Einträge, Schreibvorgänge von Agenten + und Menschen, Suchen, Anteil ohne Treffer, letzte Änderung. Der Name führt + zur *Nutzung* des Projekts mit allen Einzelheiten. +- **Aktivität der letzten 30 Tage:** Schreibvorgänge und Suchen je Tag für den + ganzen Workspace. Mit der Maus über einem Tag erscheinen die genauen Zahlen. + +Technische Werte (Datenbank, Verbindungen, Platz) stehen hier nicht. Die +betreut der Plattform-Admin. + +## Weitere Seiten zum Nachsehen + +| Wo | Was du siehst | +|---|---| +| *Projekte → Verwalten…* | alle Projekte mit Eintragszahl und Schreibvorgängen der letzten 30 Tage — „keine“ heißt: Das Team nutzt Munin nicht | +| **Nutzung** (im Projekt) | ob Agenten vorhandenes Wissen fortschreiben statt Neues daneben zu legen, Suchen ohne Treffer, kuratierte Einträge zur Prüfung, veraltete Einträge | +| **Protokoll (alle)** | Vorgänge aller Projekte, filterbar nach Vorgang, Zugang, Projekt und Zeitraum | +| **Suche (alle Projekte)** | Einträge aus allen Projekten auf einmal, nach Projekt gruppiert — „In welchem Projekt stand das nochmal?“ | + +**Suche in allen Projekten:** Die Treffer stehen je Projekt, die besten fünf, +das passendste Projekt oben. *Weitere Treffer* führt zur Suche im jeweiligen +Projekt. Nur du und der Plattform-Admin können so suchen; ein Key und ein Agent +sehen weiter genau ein Projekt. + +## Regelmäßig + +| Wann | Was | +|---|---| +| wöchentlich | **Überblick** öffnen. Rote Zeilen gleich erledigen; steht dort „Alles in Ordnung“, ist nichts zu tun. | +| monatlich | Die grünen Zeilen im **Überblick** durchgehen: ungenutzte Keys zurückziehen, wenn niemand mehr weiß, wofür sie da sind; Einträge *zur Prüfung* und viele Suchen ohne Treffer mit dem Team besprechen; ruhige Projekte klären. | +| vierteljährlich | Einträge mit *veraltet* (über ein Jahr unverändert): Das Team prüfen lassen, ob sie noch stimmen. Der Link im **Überblick** führt zur gefilterten Liste. | +| bei Bedarf | Ein Key ist in falsche Hände geraten: sofort **Zurückziehen**, dann im **Protokoll** unter *Wer* den Namen wählen und nachsehen, was damit geschah. | + +## Ein Projekt löschen + +*Projekte → Verwalten…* → *Bearbeiten* → **Löschen**. Zur Bestätigung muss der +Bezeichner eingetippt werden. Alle Einträge, Fassungen und Zugänge des Projekts +verschwinden; rückgängig machen lässt sich das nur aus einer Sicherung, die +der Plattform-Admin einspielt. Vorher lohnt sich *Als ZIP herunterladen*: Das +Archiv passt direkt in den Import eines neuen Projekts. diff --git a/internal/hilfe/seiten/zugaenge.md b/internal/hilfe/seiten/zugaenge.md index 11c31ab..3407ff4 100644 --- a/internal/hilfe/seiten/zugaenge.md +++ b/internal/hilfe/seiten/zugaenge.md @@ -7,6 +7,15 @@ Wer im Projekt die Rolle `admin` hat, sieht in der Seitenleiste den Punkt Key benutzt, zum Beispiel `anna-cline` oder `ci-pipeline`. Er erscheint als Autor an jedem Eintrag und im Protokoll. +**Gültig bis (optional):** Ein Key kann ein Ablaufdatum bekommen. Er gilt bis +einschließlich dieses Tages und danach wie ein zurückgezogener, auch laufende +Browser-Sitzungen enden. Sinnvoll für Zugänge auf Zeit: ein Praktikum, ein +Projekt mit Enddatum, ein Test. Leer gelassen läuft der Key nie ab. In der +Liste steht das Datum in der Spalte *Gültig bis*. Zwei Wochen vorher trägt der +Key die Marke *läuft bald ab*, und über der Tabelle steht ein Hinweis: Wird er +weiter gebraucht, einen neuen anlegen und in den Clients austauschen, bevor +der alte nicht mehr gilt. + Der Key wird **genau einmal** angezeigt, zusammen mit fertigen Konfigurationsblöcken für Claude Code, Cline und OpenCode. Munin speichert nur eine Prüfsumme. Geht der Key verloren, lässt er sich nicht wiederherstellen. @@ -91,6 +100,13 @@ wer sich angemeldet hat (auch Fehlversuche) und wann Zugänge angelegt oder zurückgezogen wurden. Einträge werden nach 90 Tagen gelöscht, weil sie Namen und Adressen enthalten. +Über der Tabelle lässt sich filtern: nach **Vorgang** (z. B. *in den Papierkorb +gelegt*), nach **Wer** (dem Namen des Zugangs) und nach Zeitraum (**von**, +**bis**, jeweils ganze Tage). Das beantwortet die Frage, die man bei einem +Vorfall stellt: „Was hat dieser Zugang gestern gemacht?“ Die gefilterte Ansicht +hat eine eigene Adresse und lässt sich weitergeben. Angezeigt werden höchstens +die jüngsten 200 Vorgänge. + Was Agenten suchen, steht getrennt im Suchprotokoll und wird nach 30 Tagen gelöscht. Die häufigsten Anfragen ohne Treffer zeigt die Nutzungsseite allen mit Zugang zum Projekt, auch mit Leserecht. Eine Anfrage kann verraten, woran diff --git a/internal/importer/postgres_test.go b/internal/importer/postgres_test.go new file mode 100644 index 0000000..1625178 --- /dev/null +++ b/internal/importer/postgres_test.go @@ -0,0 +1,107 @@ +package importer + +import ( + "bytes" + "context" + "os" + "testing" + + "munin/internal/export" + "munin/internal/store" +) + +// TestRundreisePostgres prüft Export und Import gegen PostgreSQL: Ein Projekt +// wird als ZIP exportiert (derselbe Weg wie „als ZIP herunterladen“) und in +// einen zweiten, leeren Workspace importiert. Danach müssen dieselben Einträge +// mit demselben Inhalt dastehen. +// +// Läuft nur mit MUNIN_TEST_PG (siehe internal/store/postgres_test.go). Export +// und Import lesen und schreiben ausschließlich über den Store, sind also +// nicht an SQLite gebunden — dieser Test belegt das. +func TestRundreisePostgres(t *testing.T) { + url := os.Getenv("MUNIN_TEST_PG") + if url == "" { + t.Skip("MUNIN_TEST_PG ist nicht gesetzt — PostgreSQL-Test übersprungen") + } + ctx := context.Background() + + sp, err := store.NewPostgres(ctx, url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { sp.Close() }) + + // Zwei frische Workspace-Schemas, nach dem Test wieder weg. + oeffne := func() *store.Store { + id := store.NewID() + s, err := sp.OpenWorkspace(id) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + s.Close() + sp.DeleteWorkspace(id) + }) + return s + } + quelle, ziel := oeffne(), oeffne() + + // Quelle: ein Projekt mit Schlagworten, Verweis, Umlauten und einem + // kuratierten Eintrag. + projekt, err := quelle.CreateProject(ctx, "ws", "plattform", "Plattform", "") + if err != nil { + t.Fatal(err) + } + for _, e := range []store.EntryWrite{ + {Slug: "deployment", Title: "Deployment-Prozess", Content: "Über die Pipeline, siehe [[vpn]].", + Tags: []string{"ops"}, Source: store.SourceHuman, Curated: true}, + {Slug: "vpn", Title: "VPN-Zugang", Content: "Zugang über vpn.intern — Änderungen bei der IT.", + Tags: []string{"ops", "zugang"}, Source: store.SourceAgent}, + } { + e.ProjectID = projekt.ID + if _, err := quelle.PutEntry(ctx, e); err != nil { + t.Fatal(err) + } + } + + // Export als ZIP und wieder auspacken — wie Download und Upload. + dateien, err := export.ProjektDateien(ctx, quelle, projekt) + if err != nil { + t.Fatal(err) + } + var zip bytes.Buffer + if err := export.SchreibeZIP(&zip, dateien); err != nil { + t.Fatal(err) + } + gelesen, err := LeseZip(zip.Bytes()) + if err != nil { + t.Fatal(err) + } + + zielProjekt, err := ziel.CreateProject(ctx, "ws", "plattform", "Plattform", "") + if err != nil { + t.Fatal(err) + } + bericht, err := Importiere(ctx, ziel, zielProjekt.ID, gelesen, Optionen{Modus: ModusUeberspringen}) + if err != nil { + t.Fatal(err) + } + if bericht.Neu != 2 || bericht.Fehler != 0 { + t.Fatalf("Import: %d neu, %d Fehler, erwartet 2 neu und 0 Fehler: %+v", bericht.Neu, bericht.Fehler, bericht.Zeilen) + } + + for _, slug := range []string{"deployment", "vpn"} { + vorher, err := quelle.EntryBySlug(ctx, projekt.ID, slug) + if err != nil { + t.Fatal(err) + } + nachher, err := ziel.EntryBySlug(ctx, zielProjekt.ID, slug) + if err != nil { + t.Fatalf("%s fehlt nach dem Import: %v", slug, err) + } + if nachher.Title != vorher.Title || nachher.Content != vorher.Content || + nachher.Curated != vorher.Curated || len(nachher.Tags) != len(vorher.Tags) { + t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, vorher, nachher) + } + } +} diff --git a/internal/mcpserver/dubletten.go b/internal/mcpserver/dubletten.go deleted file mode 100644 index 93acd73..0000000 --- a/internal/mcpserver/dubletten.go +++ /dev/null @@ -1,118 +0,0 @@ -package mcpserver - -// Dubletten über Ähnlichkeit erkennen (AUSBAUSTUFEN.md 3.3). -// -// Der Bezeichner schützt nur vor Dubletten unter GLEICHEM Namen. Wählt ein -// Agent für ein vorhandenes Thema einen neuen Bezeichner („deployment-ablauf" -// statt „deployment-prozess"), stünde dieselbe Erkenntnis zweimal da — und -// die beiden Fassungen laufen mit der Zeit auseinander. -// -// Deshalb prüft remember vor dem Anlegen, ob es schon einen Eintrag mit -// ähnlichem Titel gibt. Wenn ja, legt es NICHT an, sondern gibt die -// Kandidaten zurück und lässt den Agenten entscheiden. -// -// # Warum zurückgeben statt selbst entscheiden? -// -// Ein Agent, der einen Hinweis mit Kandidaten bekommt, korrigiert sich: Er -// lädt den passenden Eintrag und schreibt ihn fort. Einer, dessen Aufruf -// ohne Erklärung abgelehnt wird, versucht es mit einem noch schlechteren -// Bezeichner. Und manchmal ist ein ähnlicher Titel eben doch ein anderes -// Thema — das weiß der Agent besser als jede Schwelle. Dafür gibt es force. - -import ( - "context" - "errors" - "math" - "sort" - - "munin/internal/entry" - "munin/internal/search" - "munin/internal/store" - "munin/internal/workspace" -) - -const ( - // dublettenSchwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem - // ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung). - // - // 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf" - // und „Deployment-Prozess" erreichen das gerade — genau der Fall, um den - // es geht. Dafür meldet die Prüfung auch „Server-Zugang" bei - // „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen - // zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer - // mehr. - dublettenSchwelle = 0.5 - - // dublettenSuchtiefe: So viele Suchtreffer werden genauer verglichen. - // Die Suche findet Kandidaten, die Überdeckung entscheidet. - dublettenSuchtiefe = 10 - - // dublettenMaxKandidaten: So viele Kandidaten gehen höchstens zurück. - dublettenMaxKandidaten = 5 -) - -// pruefeDubletten sucht vorhandene Einträge, die einem neu anzulegenden -// ähneln. Ist slug schon vergeben, wird aktualisiert statt angelegt — dann -// gibt es nichts zu prüfen, und das Ergebnis ist leer. -// -// Verglichen wird der Titel samt der Zeile „Auch gesucht als: …", in beide -// Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen -// „Deployment-Prozess", wenn dort „release" als Suchbegriff steht. -func pruefeDubletten(ctx context.Context, ws *workspace.Workspace, project store.Project, - slug, titel, inhalt string) ([]dublettenKandidat, error) { - - _, err := ws.Store.EntryBySlug(ctx, project.ID, slug) - if err == nil { - return nil, nil // vorhanden: Aktualisierung, keine Neuanlage - } - if !errors.Is(err, store.ErrNotFound) { - return nil, err - } - - idx, err := ws.Search.For(ctx, project.ID) - if err != nil { - return nil, err - } - - neuerName := titel + " " + entry.AuchGesuchtAls(inhalt) - - var kandidaten []dublettenKandidat - for _, t := range idx.Search(neuerName, dublettenSuchtiefe) { - e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID) - if errors.Is(err, store.ErrNotFound) { - continue // Index kennt einen Eintrag, den es nicht mehr gibt - } - if err != nil { - return nil, err - } - - vorhandenerName := e.Title + " " + entry.AuchGesuchtAls(e.Content) - - // In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist - // vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur - // Hälfte. Beides ist ein Hinweis auf dasselbe Thema. - anteil := max( - search.Ueberdeckung(titel, vorhandenerName), - search.Ueberdeckung(e.Title, neuerName), - ) - if anteil < dublettenSchwelle { - continue - } - - kandidaten = append(kandidaten, dublettenKandidat{ - Slug: e.Slug, - Title: e.Title, - Overlap: int(math.Round(anteil * 100)), - }) - } - - // Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge - // der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall. - sort.SliceStable(kandidaten, func(i, j int) bool { - return kandidaten[i].Overlap > kandidaten[j].Overlap - }) - if len(kandidaten) > dublettenMaxKandidaten { - kandidaten = kandidaten[:dublettenMaxKandidaten] - } - return kandidaten, nil -} diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go index 36fbd67..368aaf0 100644 --- a/internal/mcpserver/mcpserver_test.go +++ b/internal/mcpserver/mcpserver_test.go @@ -873,7 +873,7 @@ func TestRememberMeldetDublette(t *testing.T) { t.Fatalf("die Dublettenmeldung soll kein Fehler sein: %s", text(ergebnis)) } antwort := text(ergebnis) - for _, erwartet := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force"} { + for _, erwartet := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force", `"matched_words":["deployment"]`} { if !strings.Contains(antwort, erwartet) { t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort) } @@ -946,6 +946,60 @@ func TestRememberDubletteUeberSuchbegriffe(t *testing.T) { } } +// TestRememberGemeinsamesPraefixKeineDublette prüft den Fall aus dem Betrieb +// (2026-10-16): Titel nach dem Muster „Projekt – Thema" lösten allein wegen +// des gleichen Präfixes eine Dublettenmeldung aus. „Munin – Architektur" +// wurde nicht angelegt, Kandidat war „Munin – Überblick" mit 50 %. +// +// Eine echte Dublette unter demselben Präfix muss aber weiter auffallen. +func TestRememberGemeinsamesPraefixKeineDublette(t *testing.T) { + h, ws, projekt := aufbau(t) + + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "munin-ueberblick", "title": "Munin – Überblick", "content": "Was Munin ist.", + }) + for _, neu := range []map[string]any{ + {"slug": "munin-architektur", "title": "Munin – Architektur", "content": "Aufbau."}, + {"slug": "munin-ausbaustand", "title": "Munin – Ausbaustand", "content": "Was fertig ist."}, + } { + ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", neu) + if !strings.Contains(text(ergebnis), `"created":true`) { + t.Errorf("%s wurde nicht angelegt: %s", neu["slug"], text(ergebnis)) + } + } + + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "munin-deployment-prozess", "title": "Munin – Deployment-Prozess", "content": "Über die Pipeline.", + }) + ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "munin-deployment-ablauf", "title": "Munin – Deployment-Ablauf", "content": "Auch über die Pipeline.", + }) + if !strings.Contains(text(ergebnis), `"judgment_required":true`) || + !strings.Contains(text(ergebnis), `"munin-deployment-prozess"`) { + t.Errorf("die Dublette unter gleichem Präfix wurde nicht erkannt: %s", text(ergebnis)) + } +} + +// TestRememberSuchbegriffNurWoertlich prüft den zweiten Fall aus dem Betrieb +// (2026-10-16): Ein Statuseintrag mit dem Suchbegriff „postgres-migration" +// machte „Munin – Betrieb mit PostgreSQL" zur vermeintlichen Dublette, weil +// „postgres" der Wortanfang von „postgresql" ist. Suchbegriffe zählen jetzt +// nur wörtlich. +func TestRememberSuchbegriffNurWoertlich(t *testing.T) { + h, ws, projekt := aufbau(t) + + ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "munin-ausbaustand", "title": "Munin – Ausbaustand und nächste Schritte", + "content": "Stand der Dinge.\n\nAuch gesucht als: roadmap, status, postgres-migration", + }) + ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{ + "slug": "munin-postgres-betrieb", "title": "Munin – Betrieb mit PostgreSQL", "content": "Sichern und so.", + }) + if !strings.Contains(text(ergebnis), `"created":true`) { + t.Errorf("wurde wegen eines Suchbegriffs als Dublette gemeldet: %s", text(ergebnis)) + } +} + // ── Kuratierte Einträge ────────────────────────────────────────────────────── // legeKuratiertAn legt einen von einem Menschen geprüften Eintrag an. diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index d42a6fc..2a203ea 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -230,11 +230,17 @@ type rememberInput struct { } // dublettenKandidat ist ein vorhandener Eintrag, der einem neu anzulegenden -// ähnelt (siehe pruefeDubletten). +// ähnelt (siehe internal/dubletten) — so, wie MCP ihn ausliefert. type dublettenKandidat struct { Slug string `json:"slug"` Title string `json:"title"` Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"` + + // MatchedWords sind die Wörter, auf denen die Übereinstimmung beruht. + // Steht dort nur ein allgemeines Wort, das viele Einträge teilen, ist es + // meist kein Thema für einen gemeinsamen Eintrag — der Agent sieht das + // sofort, statt den Kandidaten erst laden zu müssen. + MatchedWords []string `json:"matched_words,omitempty" jsonschema:"Gemeinsame Wörter, auf denen die Übereinstimmung beruht"` } type rememberOutput struct { diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index 390b6da..a3b8060 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -11,6 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "munin/internal/auth" + "munin/internal/dubletten" "munin/internal/entry" "munin/internal/store" "munin/internal/web" @@ -465,21 +466,29 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) { // Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner? // Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe - // dubletten.go). Kein Fehler: Das Modell soll die Kandidaten als + // internal/dubletten). Kein Fehler: Das Modell soll die Kandidaten als // Daten lesen und entscheiden, nicht einen Fehlschlag behandeln. if !in.Force { - kandidaten, err := pruefeDubletten(ctx, ws, project, slug, titel, in.Content) + gefunden, err := dubletten.Pruefe(ctx, ws, project, slug, titel, in.Content) if err != nil { return nil, rememberOutput{}, err } + kandidaten := make([]dublettenKandidat, 0, len(gefunden)) + for _, k := range gefunden { + kandidaten = append(kandidaten, dublettenKandidat{ + Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, MatchedWords: k.MatchedWords, + }) + } if len(kandidaten) > 0 { return nil, rememberOutput{ Slug: slug, JudgmentRequired: true, Candidates: kandidaten, Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " + + "\"matched_words\" nennt die gemeinsamen Wörter. " + "Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " + - "mit DESSEN slug erneut \"remember\" aufrufen. Ist es wirklich ein anderes Thema, " + + "mit DESSEN slug erneut \"remember\" aufrufen. Ist es wirklich ein anderes Thema " + + "(etwa weil nur ein allgemeines Wort übereinstimmt), " + "denselben Aufruf mit \"force\": true wiederholen.", }, nil } diff --git a/internal/search/varianten.go b/internal/search/varianten.go index 5587677..337bc47 100644 --- a/internal/search/varianten.go +++ b/internal/search/varianten.go @@ -333,16 +333,42 @@ func abs(n int) int { // müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist // dagegen in jedem Projekt gleich zu lesen. func Ueberdeckung(anfrage, text string) float64 { + anteil, _ := UeberdeckungMitSuchbegriffen(anfrage, text, "") + return anteil +} + +// UeberdeckungMitSuchbegriffen ist Ueberdeckung für einen vorhandenen +// Eintrag, der aus zwei Teilen besteht: seinem Titel und seinen Suchbegriffen +// (der Zeile „Auch gesucht als: …"). Zurück kommen der Anteil und die Wörter +// aus anfrage, die gefunden wurden — in der Form, die tokenize erzeugt. +// +// # Warum zwei Teile? +// +// Im Titel gelten alle Varianten wie bei Ueberdeckung. Ein Suchbegriff zählt +// dagegen nur, wenn er wörtlich passt. Suchbegriffe sind ausdrücklich +// gewählte, ganze Wörter; mit allen Varianten erfassen sie zu viel. Im +// Betrieb (2026-10-16) machte der Suchbegriff „postgres-migration" eines +// Statuseintrags jeden neuen Eintrag mit „PostgreSQL" im Titel zur +// vermeintlichen Dublette — „postgres" ist der Wortanfang von „postgresql". +// +// „Release-Ablauf" vs. Suchbegriff „release" → zählt (wörtlich) +// „Betrieb mit PostgreSQL" vs. Suchbegriff „postgres" → zählt nicht +func UeberdeckungMitSuchbegriffen(anfrage, titel, suchbegriffe string) (float64, []string) { woerter := ohneStoppwoerter(tokenize(anfrage)) if len(woerter) == 0 { - return 0 + return 0, nil + } + imTitel := tokenize(titel) + wortgenau := make(map[string]bool) + for _, w := range tokenize(suchbegriffe) { + wortgenau[w] = true } - vorhanden := tokenize(text) // Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht // zu zwei Dritteln „Server". gesehen := make(map[string]bool, len(woerter)) - gesamt, gefunden := 0, 0 + gesamt := 0 + var gefunden []string for _, w := range woerter { if gesehen[w] { continue @@ -350,14 +376,18 @@ func Ueberdeckung(anfrage, text string) float64 { gesehen[w] = true gesamt++ - for _, v := range vorhanden { + if wortgenau[w] { + gefunden = append(gefunden, w) + continue + } + for _, v := range imTitel { if passtZu(w, v) { - gefunden++ + gefunden = append(gefunden, w) break } } } - return float64(gefunden) / float64(gesamt) + return float64(len(gefunden)) / float64(gesamt), gefunden } // passtZu prüft, ob zwei einzelne Wörter als dasselbe gelten. diff --git a/internal/search/varianten_test.go b/internal/search/varianten_test.go index 1a259a3..5b23cfd 100644 --- a/internal/search/varianten_test.go +++ b/internal/search/varianten_test.go @@ -2,6 +2,7 @@ package search import ( "slices" + "strings" "testing" ) @@ -252,6 +253,32 @@ func TestErlaubteTippfehler(t *testing.T) { // ── Überdeckung (Dubletten) ────────────────────────────────────────────────── +// TestUeberdeckungMitSuchbegriffen: Im Titel zählen alle Varianten, ein +// Suchbegriff nur wörtlich. Dazu kommen die gefundenen Wörter zurück. +func TestUeberdeckungMitSuchbegriffen(t *testing.T) { + faelle := []struct { + name string + anfrage, titel, suchbegriffe string + anteil float64 + gemeinsam string + }{ + {"Suchbegriff wörtlich", "Release-Ablauf", "Deployment-Prozess", "release, rollout", 0.5, "release"}, + {"Suchbegriff nur als Wortanfang zählt nicht", "Betrieb mit PostgreSQL", "Ausbaustand", "roadmap, postgres-migration", 0, ""}, + {"im Titel zählt der Wortanfang weiter", "Betrieb mit PostgreSQL", "Postgres-Einrichtung", "", 0.5, "postgresql"}, + {"Endung im Titel", "Zertifikate erneuern", "Zertifikat erneuern", "", 1, "zertifikate erneuern"}, + {"Umlaute im Suchbegriff", "Übergabe", "Anderes", "uebergabe, übergabe", 1, "ubergabe"}, + {"nichts", "VPN-Zugang", "Drucker", "", 0, ""}, + } + for _, f := range faelle { + t.Run(f.name, func(t *testing.T) { + anteil, gemeinsam := UeberdeckungMitSuchbegriffen(f.anfrage, f.titel, f.suchbegriffe) + if anteil != f.anteil || strings.Join(gemeinsam, " ") != f.gemeinsam { + t.Errorf("= %v %q, erwartet %v %q", anteil, gemeinsam, f.anteil, f.gemeinsam) + } + }) + } +} + func TestUeberdeckung(t *testing.T) { faelle := []struct { anfrage, text string diff --git a/internal/statistik/statistik.go b/internal/statistik/statistik.go new file mode 100644 index 0000000..dd96966 --- /dev/null +++ b/internal/statistik/statistik.go @@ -0,0 +1,290 @@ +// Package statistik zählt die Kennzahlen aller Workspaces für die Systemseite +// zusammen (AUSBAUSTUFEN.md 5.16): Bestand, Platz, Stand des Schemas und die +// Aktivität der letzten 30 Tage. +// +// # Warum nicht bei jedem Seitenaufruf? +// +// Für die Zahlen fragt Munin jeden Workspace einzeln ab — bei 40 Workspaces +// rund 300 Abfragen. Das ist kein Problem, wenn es selten geschieht, aber +// unnötig bei jedem Neuladen der Seite. Der Sammler rechnet deshalb höchstens +// alle MindestAbstand neu und gibt dazwischen das gemerkte Ergebnis zurück. +// Die Seite nennt den Stand ("Stand 14:05"). +// +// Gerechnet wird beim Aufruf der Seite, nicht in einer eigenen Goroutine: Wer +// die Seite nie öffnet, kostet nichts, und es gibt keinen Hintergrundlauf, +// der beim Beenden aufgeräumt werden müsste. +package statistik + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "munin/internal/store" + "munin/internal/workspace" +) + +// Tage ist der Zeitraum der Aktivität. Derselbe wie in der Nutzung eines +// Projekts (store.UsageWindow) — und nicht länger, weil das Suchprotokoll +// nach 30 Tagen aufgeräumt wird. Ältere Tage stünden sonst ohne Suchen da. +const Tage = store.UsageWindowDays + +// MindestAbstand ist die Zeit, die ein Ergebnis gilt, bevor neu gerechnet +// wird. +const MindestAbstand = 5 * time.Minute + +// Stand ist das Ergebnis einer Erhebung. +type Stand struct { + Erhoben time.Time + Dauer time.Duration // wie lange die Erhebung brauchte + + Workspaces []Workspace // nach Name sortiert + Summe store.Bestand + Belegung store.Belegung // aller Workspaces zusammen + + // Schemastand: die neueste Migration, die dieses Programm kennt. Ein + // Workspace, der davon abweicht, steht mit seinem Stand in Workspace.Schema. + Schemastand string + + Tage []Tag // genau Tage Einträge, der älteste zuerst, heute zuletzt + Projekte []Projekt // mit Aktivität, die aktivsten zuerst + Ruhig []Projekt // ohne jede Aktivität im Zeitraum, nach Workspace und Name + + // Gewartet zählt, wie oft Anfragen seit dem Start auf eine freie + // Datenbankverbindung warten mussten (alle Pools zusammen), zum Zeitpunkt + // der Erhebung. GewartetVorher und VorherErhoben sind dieselbe Zahl bei + // der vorigen Erhebung (0 und Nullzeitpunkt bei der ersten). Die + // Differenz zeigt, ob gerade gewartet wird — die Summe seit dem Start + // sagt das nach Tagen Laufzeit nicht mehr. + Gewartet int64 + GewartetVorher int64 + VorherErhoben time.Time + + // Fehler sind Workspaces, die sich nicht lesen ließen. Ihre Zahlen fehlen + // in den Summen; die übrigen sind trotzdem da. + Fehler []string +} + +// Workspace sind die Kennzahlen eines Workspace. +type Workspace struct { + ID, Name string + Ort string // Datei oder Schema (store.Speicher.Ort) + Bestand store.Bestand + Belegung store.Belegung + Schema string // zuletzt angewandte Migration +} + +// SchemaAbweichend sagt, ob der Workspace auf einem anderen Stand ist als das +// Programm. +func (w Workspace) SchemaAbweichend(soll string) bool { return w.Schema != soll } + +// Tag ist die Aktivität eines Tages über alle Workspaces. +type Tag struct { + Datum time.Time // 0 Uhr UTC + SchreibenAgent int + SchreibenMensch int + Suchen int +} + +// Schreiben sind alle Schreibvorgänge des Tages. +func (t Tag) Schreiben() int { return t.SchreibenAgent + t.SchreibenMensch } + +// Projekt ist die Aktivität eines Projekts im Zeitraum. +type Projekt struct { + Workspace string // Name + ID string + Slug string + Name string + + SchreibenAgent int + SchreibenMensch int + Suchen int +} + +// Schreiben sind alle Schreibvorgänge des Projekts im Zeitraum. +func (p Projekt) Schreiben() int { return p.SchreibenAgent + p.SchreibenMensch } + +// Summe ist alles zusammen — danach wird sortiert. +func (p Projekt) Summe() int { return p.Schreiben() + p.Suchen } + +// Sammler erhebt den Stand und merkt ihn sich für MindestAbstand. +type Sammler struct { + control *store.ControlStore + workspaces *workspace.Manager + + // mu schützt stand und sorgt zugleich dafür, dass bei zwei + // gleichzeitigen Aufrufen nur einer rechnet; der zweite wartet und + // bekommt dasselbe Ergebnis. + mu sync.Mutex + stand Stand +} + +// NewSammler legt einen Sammler an. +func NewSammler(control *store.ControlStore, workspaces *workspace.Manager) *Sammler { + return &Sammler{control: control, workspaces: workspaces} +} + +// Aktuell liefert einen Stand, der höchstens MindestAbstand alt ist, und +// erhebt dafür nötigenfalls neu. +func (s *Sammler) Aktuell(ctx context.Context, jetzt time.Time) Stand { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.stand.Erhoben.IsZero() && jetzt.Sub(s.stand.Erhoben) < MindestAbstand { + return s.stand + } + neu := s.erhebe(ctx, jetzt) + neu.GewartetVorher, neu.VorherErhoben = s.stand.Gewartet, s.stand.Erhoben + s.stand = neu + return s.stand +} + +// Gewartet zählt die Wartevorgänge aller gerade offenen Pools zusammen: der +// Kontroll-Datenbank und jedes geöffneten Workspace. +func (s *Sammler) Gewartet() int64 { + summe := s.control.Verbindungen().Gewartet() + for _, w := range s.workspaces.Offen() { + summe += w.Store.Verbindungen().Gewartet() + } + return summe +} + +// FuerWorkspace erhebt den Stand eines einzelnen Workspace — für den +// Überblick des Workspace-Admins (AUSBAUSTUFEN.md 5.17). Ohne Zwischenspeicher: +// Ein Workspace sind nur eine Handvoll Abfragen, und der Admin soll nach +// einer Änderung (Key angelegt) sofort den neuen Stand sehen. +func (s *Sammler) FuerWorkspace(ctx context.Context, w store.Workspace, jetzt time.Time) Stand { + return s.erhebeListe(ctx, jetzt, []store.Workspace{w}) +} + +// erhebe fragt jeden Workspace ab und zählt zusammen. +// +// Dafür werden alle Workspaces geöffnet, auch lange nicht benutzte. Das tun +// das tägliche Aufräumen und der Export (main.go) ohnehin schon beim Start; +// zusätzliche Verbindungen kostet die Erhebung also nicht. +func (s *Sammler) erhebe(ctx context.Context, jetzt time.Time) Stand { + alle, err := s.control.ListWorkspaces(ctx) + if err != nil { + return Stand{Erhoben: jetzt, Fehler: []string{fmt.Sprintf("Workspaces konnten nicht gelesen werden: %v", err)}} + } + return s.erhebeListe(ctx, jetzt, alle) +} + +// erhebeListe zählt die gegebenen Workspaces zusammen. +func (s *Sammler) erhebeListe(ctx context.Context, jetzt time.Time, alle []store.Workspace) Stand { + beginn := time.Now() + stand := Stand{Erhoben: jetzt} + + // Die Tage stehen von vornherein fest, auch die ohne jede Aktivität — + // sonst hätte das Diagramm Lücken, die man nicht sieht. + heute := time.Date(jetzt.UTC().Year(), jetzt.UTC().Month(), jetzt.UTC().Day(), 0, 0, 0, 0, time.UTC) + erster := heute.AddDate(0, 0, -(Tage - 1)) + tagNr := map[string]int{} + for i := 0; i < Tage; i++ { + d := erster.AddDate(0, 0, i) + stand.Tage = append(stand.Tage, Tag{Datum: d}) + tagNr[d.Format("2006-01-02")] = i + } + + for _, w := range alle { + ws, projekte, err := s.erheheWorkspace(ctx, w, jetzt, erster, &stand, tagNr) + if err != nil { + stand.Fehler = append(stand.Fehler, fmt.Sprintf("%s: %v", w.Name, err)) + continue + } + stand.Workspaces = append(stand.Workspaces, ws) + stand.Summe = stand.Summe.Plus(ws.Bestand) + stand.Belegung.Daten += ws.Belegung.Daten + stand.Belegung.WAL += ws.Belegung.WAL + + for _, p := range projekte { + if p.Summe() > 0 { + stand.Projekte = append(stand.Projekte, p) + } else { + stand.Ruhig = append(stand.Ruhig, p) + } + } + } + + sort.Slice(stand.Workspaces, func(i, j int) bool { return stand.Workspaces[i].Name < stand.Workspaces[j].Name }) + // Die aktivsten zuerst; bei Gleichstand alphabetisch, damit die + // Reihenfolge beim Neuladen nicht springt. + sort.Slice(stand.Projekte, func(i, j int) bool { + a, b := stand.Projekte[i], stand.Projekte[j] + if a.Summe() != b.Summe() { + return a.Summe() > b.Summe() + } + return a.Workspace+a.Name < b.Workspace+b.Name + }) + sort.Slice(stand.Ruhig, func(i, j int) bool { + a, b := stand.Ruhig[i], stand.Ruhig[j] + return a.Workspace+a.Name < b.Workspace+b.Name + }) + + stand.Gewartet = s.Gewartet() + stand.Dauer = time.Since(beginn) + return stand +} + +// erheheWorkspace sammelt die Zahlen eines Workspace. Die Aktivität je Tag +// zählt sie direkt in stand.Tage ein; die je Projekt gibt sie zurück. +func (s *Sammler) erheheWorkspace(ctx context.Context, w store.Workspace, jetzt, seit time.Time, + stand *Stand, tagNr map[string]int) (Workspace, []Projekt, error) { + + ergebnis := Workspace{ID: w.ID, Name: w.Name, Ort: s.workspaces.Ort(w.ID)} + + offen, err := s.workspaces.Get(w.ID) + if err != nil { + return ergebnis, nil, err + } + st := offen.Store + + if ergebnis.Bestand, err = st.Bestand(ctx, jetzt); err != nil { + return ergebnis, nil, err + } + if ergebnis.Belegung, err = st.Belegung(ctx); err != nil { + return ergebnis, nil, err + } + ist, soll, err := st.Schemastand(ctx) + if err != nil { + return ergebnis, nil, err + } + ergebnis.Schema = ist + stand.Schemastand = soll // für alle Workspaces derselbe: er kommt aus dem Programm + + // Jedes Projekt bekommt einen Eintrag, auch ohne Aktivität — gerade die + // ruhigen sind interessant. + liste, err := st.ListProjects(ctx, w.ID) + if err != nil { + return ergebnis, nil, err + } + projekte := make([]Projekt, len(liste)) + nachID := map[string]*Projekt{} + for i, p := range liste { + projekte[i] = Projekt{Workspace: w.Name, ID: p.ID, Slug: p.Slug, Name: p.Name} + nachID[p.ID] = &projekte[i] + } + + aktivitaet, err := st.Aktivitaet(ctx, seit) + if err != nil { + return ergebnis, nil, err + } + for _, a := range aktivitaet { + if i, ok := tagNr[a.Tag]; ok { + stand.Tage[i].SchreibenAgent += a.SchreibenAgent + stand.Tage[i].SchreibenMensch += a.SchreibenMensch + stand.Tage[i].Suchen += a.Suchen + } + // Zeilen eines inzwischen gelöschten Projekts zählen für den Tag, + // haben aber kein Projekt mehr, dem sie gehören. + if p := nachID[a.ProjektID]; p != nil { + p.SchreibenAgent += a.SchreibenAgent + p.SchreibenMensch += a.SchreibenMensch + p.Suchen += a.Suchen + } + } + + return ergebnis, projekte, nil +} diff --git a/internal/statistik/statistik_test.go b/internal/statistik/statistik_test.go new file mode 100644 index 0000000..15fca73 --- /dev/null +++ b/internal/statistik/statistik_test.go @@ -0,0 +1,124 @@ +package statistik + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "munin/internal/store" + "munin/internal/workspace" +) + +// testUmgebung legt eine Kontroll-Datenbank mit zwei Workspaces an, je einem +// Projekt darin. +func testUmgebung(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) { + t.Helper() + ctx := context.Background() + + ordner := t.TempDir() + wsOrdner := filepath.Join(ordner, "workspaces") + if err := os.MkdirAll(wsOrdner, 0750); err != nil { + t.Fatal(err) + } + speicher := store.NewSQLite(filepath.Join(ordner, "control.db"), wsOrdner) + control, err := speicher.OpenControl() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { control.Close() }) + manager := workspace.NewManager(speicher) + t.Cleanup(func() { manager.Close() }) + + var liste []store.Workspace + for _, name := range []string{"Vertrieb", "Entwicklung"} { + ws, err := control.CreateWorkspace(ctx, name) + if err != nil { + t.Fatal(err) + } + offen, err := manager.Get(ws.ID) + if err != nil { + t.Fatal(err) + } + if _, err := offen.Store.CreateProject(ctx, ws.ID, "allgemein", "Allgemein "+name, ""); err != nil { + t.Fatal(err) + } + liste = append(liste, ws) + } + return control, manager, liste +} + +// TestErhebung zählt über alle Workspaces zusammen, füllt jeden Tag des +// Zeitraums und trennt aktive von ruhigen Projekten. +func TestErhebung(t *testing.T) { + control, manager, liste := testUmgebung(t) + ctx := context.Background() + + // Im Workspace "Vertrieb" schreibt ein Agent einen Eintrag. + offen, _ := manager.Get(liste[0].ID) + projekte, _ := offen.Store.ListProjects(ctx, liste[0].ID) + if _, err := offen.Store.PutEntry(ctx, store.EntryWrite{ + ProjectID: projekte[0].ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, + }); err != nil { + t.Fatal(err) + } + offen.Store.LogAudit(ctx, store.AuditEvent{ + ProjectID: projekte[0].ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP, + }) + + stand := NewSammler(control, manager).Aktuell(ctx, time.Now()) + + if len(stand.Fehler) > 0 { + t.Fatalf("Fehler: %v", stand.Fehler) + } + if len(stand.Workspaces) != 2 || stand.Workspaces[0].Name != "Entwicklung" { + t.Errorf("Workspaces %+v, erwartet zwei, nach Name sortiert", stand.Workspaces) + } + if stand.Summe.Projekte != 2 || stand.Summe.Eintraege != 1 { + t.Errorf("Summe %+v, erwartet 2 Projekte und 1 Eintrag", stand.Summe) + } + if stand.Belegung.Daten <= 0 { + t.Error("keine Belegung gezählt") + } + if stand.Schemastand == "" { + t.Error("Schemastand fehlt") + } + + if len(stand.Tage) != Tage { + t.Fatalf("%d Tage, erwartet %d", len(stand.Tage), Tage) + } + heute := stand.Tage[len(stand.Tage)-1] + if heute.SchreibenAgent != 1 || heute.Datum.Format("2006-01-02") != time.Now().UTC().Format("2006-01-02") { + t.Errorf("letzter Tag %+v, erwartet heute mit einem Schreibvorgang", heute) + } + + if len(stand.Projekte) != 1 || stand.Projekte[0].Workspace != "Vertrieb" { + t.Errorf("aktive Projekte %+v, erwartet nur das im Vertrieb", stand.Projekte) + } + if len(stand.Ruhig) != 1 || stand.Ruhig[0].Workspace != "Entwicklung" { + t.Errorf("ruhige Projekte %+v, erwartet nur das in der Entwicklung", stand.Ruhig) + } +} + +// TestMindestAbstand: Innerhalb von MindestAbstand kommt der gemerkte Stand +// zurück, danach ein neuer — der sich die Wartezahl des vorigen merkt. +func TestMindestAbstand(t *testing.T) { + control, manager, _ := testUmgebung(t) + ctx := context.Background() + s := NewSammler(control, manager) + + jetzt := time.Now() + erster := s.Aktuell(ctx, jetzt) + if zweiter := s.Aktuell(ctx, jetzt.Add(time.Minute)); !zweiter.Erhoben.Equal(erster.Erhoben) { + t.Error("nach einer Minute neu gezählt, erwartet den gemerkten Stand") + } + + dritter := s.Aktuell(ctx, jetzt.Add(MindestAbstand+time.Second)) + if dritter.Erhoben.Equal(erster.Erhoben) { + t.Fatal("nach MindestAbstand nicht neu gezählt") + } + if !dritter.VorherErhoben.Equal(erster.Erhoben) { + t.Errorf("VorherErhoben = %v, erwartet den ersten Stand %v", dritter.VorherErhoben, erster.Erhoben) + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index c83c502..1c38301 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "strings" + "time" ) // Aktionen, die im Protokoll auftauchen. Als Konstanten, damit Auswertungen @@ -138,10 +139,38 @@ func (s *Store) LogAudit(ctx context.Context, e AuditEvent) { } } -// AuditFilter schränkt ListAudit ein. +// AuditFilter schränkt ListAudit ein. Leere Felder schränken nicht ein. type AuditFilter struct { ProjectID string // leer = alle Projekte Limit int + + // Filter der Protokollseite (AUSBAUSTUFEN.md 5.11): die Fragen, die man bei + // einem Vorfall stellt — „Was hat dieser Zugang gestern gemacht?" + Action string // ein Vorgang, z.B. ActionEntryDelete + Actor string // Name des Zugangs, genau so, wie er im Protokoll steht + Since time.Time // ab diesem Zeitpunkt (einschließlich) + Until time.Time // vor diesem Zeitpunkt (ausschließlich) +} + +// AuditActions nennt alle Vorgänge mit einer kurzen Beschreibung — für die +// Auswahlliste der Protokollseite. In der Reihenfolge, in der sie dort +// erscheinen sollen. +var AuditActions = []struct{ Action, Label string }{ + {ActionEntryWrite, "Eintrag geschrieben"}, + {ActionEntryDelete, "in den Papierkorb gelegt"}, + {ActionEntryRestore, "aus dem Papierkorb geholt"}, + {ActionEntryPurge, "endgültig gelöscht"}, + {ActionEntryImport, "Import"}, + {ActionExportDownload, "als ZIP heruntergeladen"}, + {ActionLoginSuccess, "Anmeldung"}, + {ActionAuthFailure, "Anmeldung fehlgeschlagen"}, + {ActionProjectCreated, "Projekt angelegt"}, + {ActionProjectUpdated, "Projekt geändert"}, + {ActionProjectDeleted, "Projekt gelöscht"}, + {ActionKeyCreated, "Zugang angelegt"}, + {ActionKeyRevoked, "Zugang zurückgezogen"}, + {ActionWorkspaceAdminCreated, "Workspace-Admin angelegt"}, + {ActionWorkspaceAdminRevoked, "Workspace-Admin zurückgezogen"}, } const defaultAuditLimit = 200 @@ -153,13 +182,35 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err limit = defaultAuditLimit } + // Die Bedingungen werden gesammelt und mit AND verbunden. Werte gehen + // immer als Platzhalter in die Abfrage, nie als Text. + var bedingungen []string + werte := []any{} + if f.ProjectID != "" { + bedingungen = append(bedingungen, `project_id = ?`) + werte = append(werte, f.ProjectID) + } + if f.Action != "" { + bedingungen = append(bedingungen, `action = ?`) + werte = append(werte, f.Action) + } + if f.Actor != "" { + bedingungen = append(bedingungen, `actor = ?`) + werte = append(werte, f.Actor) + } + if !f.Since.IsZero() { + bedingungen = append(bedingungen, `ts >= ?`) + werte = append(werte, formatTime(f.Since)) + } + if !f.Until.IsZero() { + bedingungen = append(bedingungen, `ts < ?`) + werte = append(werte, formatTime(f.Until)) + } + abfrage := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail FROM audit_events` - werte := []any{} - - if f.ProjectID != "" { - abfrage += ` WHERE project_id = ?` - werte = append(werte, f.ProjectID) + if len(bedingungen) > 0 { + abfrage += ` WHERE ` + strings.Join(bedingungen, ` AND `) } abfrage += ` ORDER BY ts DESC LIMIT ?` @@ -185,6 +236,39 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err return ereignisse, rows.Err() } +// AuditActors nennt alle Zugangsnamen, die im Protokoll vorkommen, sortiert — +// für die Auswahlliste der Protokollseite. Mit projectID nur die eines +// Projekts. Höchstens auditActorLimit, damit die Liste bedienbar bleibt. +func (s *Store) AuditActors(ctx context.Context, projectID string) ([]string, error) { + abfrage := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''` + werte := []any{} + if projectID != "" { + abfrage += ` AND project_id = ?` + werte = append(werte, projectID) + } + abfrage += ` ORDER BY actor LIMIT ?` + werte = append(werte, auditActorLimit) + + rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) + if err != nil { + return nil, fmt.Errorf("Zugänge im Protokoll konnten nicht gelesen werden: %w", err) + } + defer rows.Close() + + var namen []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + return nil, err + } + namen = append(namen, n) + } + return namen, rows.Err() +} + +// auditActorLimit begrenzt die Auswahlliste der Zugänge. +const auditActorLimit = 500 + // PruneAudit löscht Protokolleinträge, die älter als die angegebene Grenze sind. // // # Warum das nötig ist diff --git a/internal/store/control.go b/internal/store/control.go index 35e29bb..5d36e00 100644 --- a/internal/store/control.go +++ b/internal/store/control.go @@ -9,9 +9,10 @@ import ( ) // controlMigrationsFS enthält das Schema der zentralen Kontroll-Datenbank -// (siehe migrations_control/001_init.sql und AUSBAUSTUFEN.md 4.4). +// (siehe migrations_control/sqlite/001_init.sql und AUSBAUSTUFEN.md 4.4), je +// Datenbank in einem eigenen Unterordner wie bei migrationsFS. // -//go:embed migrations_control/*.sql +//go:embed migrations_control/sqlite/*.sql migrations_control/postgres/*.sql var controlMigrationsFS embed.FS // ControlStore verwaltet die zentrale Kontroll-Datenbank: die Liste der @@ -22,20 +23,42 @@ var controlMigrationsFS embed.FS // eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine // Workspace-Abfrage gegen die falsche Datei läuft. type ControlStore struct { - lesen *sql.DB - schreiben *sql.DB + lesen pool + schreiben pool + + // haken sind die zusätzlichen Schritte, die der Speicher beim Anlegen und + // Umbenennen eines Workspace braucht. Bei SQLite gibt es keine (nil): + // Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei + // PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema + // samt lesbarem Namen angelegt (siehe speicher_postgres.go). + haken workspaceHaken } -// OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den -// aktuellen Stand. +// workspaceHaken wird vom Speicher gesetzt, siehe ControlStore.haken. +type workspaceHaken interface { + // vorDemAnlegen prüft, ob ein weiterer Workspace möglich ist. anzahl ist + // die Zahl der Workspaces MIT dem neuen. + vorDemAnlegen(ctx context.Context, anzahl int) error + + // nachDemBenennen läuft nach dem Anlegen und nach jedem Umbenennen. + nachDemBenennen(ctx context.Context, workspaceID, name string) error +} + +// OpenControl öffnet die Kontroll-Datenbank als SQLite-Datei und bringt ihr +// Schema auf den aktuellen Stand. Für PostgreSQL siehe Speicher.OpenControl. func OpenControl(path string) (*ControlStore, error) { - lesen, schreiben, err := openPool(path, controlMigrationsFS, "migrations_control") + lesen, schreiben, err := openSQLite(path, controlMigrationsFS, "migrations_control") if err != nil { return nil, err } return &ControlStore{lesen: lesen, schreiben: schreiben}, nil } +// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen). +func (c *ControlStore) Verbindungen() Verbindungen { + return Verbindungen{Lesen: c.lesen.db.Stats(), Schreiben: c.schreiben.db.Stats()} +} + // Close schließt beide Verbindungspools. func (c *ControlStore) Close() error { fehlerLesen := c.lesen.Close() @@ -45,6 +68,14 @@ func (c *ControlStore) Close() error { return fehlerLesen } +// Ping prüft mit einer echten Abfrage, ob die Kontroll-Datenbank antwortet. +// Für /ready (siehe internal/web/server.go). Bei SQLite ist das die Datei, +// bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin. +func (c *ControlStore) Ping(ctx context.Context) error { + var eins int + return c.lesen.QueryRowContext(ctx, `SELECT 1`).Scan(&eins) +} + // ── Workspaces ─────────────────────────────────────────────────────────────── // EnsureDefaultWorkspace legt beim allerersten Start eines Deployments den @@ -76,6 +107,16 @@ func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string) // CreateWorkspace legt einen neuen Workspace an. func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) { + if c.haken != nil { + var bisher int + if err := c.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&bisher); err != nil { + return Workspace{}, fmt.Errorf("Workspaces konnten nicht gezählt werden: %w", err) + } + if err := c.haken.vorDemAnlegen(ctx, bisher+1); err != nil { + return Workspace{}, err + } + } + w := Workspace{ID: NewID(), Name: name} jetzt := NowString() @@ -86,6 +127,12 @@ func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Worksp return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err) } + if c.haken != nil { + if err := c.haken.nachDemBenennen(ctx, w.ID, w.Name); err != nil { + return Workspace{}, err + } + } + w.CreatedAt = parseTime(jetzt) return w, nil } @@ -148,6 +195,10 @@ func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) err if betroffen == 0 { return ErrNotFound } + + if c.haken != nil { + return c.haken.nachDemBenennen(ctx, id, name) + } return nil } diff --git a/internal/store/control_test.go b/internal/store/control_test.go index 5ecd141..5538c21 100644 --- a/internal/store/control_test.go +++ b/internal/store/control_test.go @@ -6,9 +6,14 @@ import ( "testing" ) -// testControl öffnet eine frische Kontroll-Datenbank in einem Testverzeichnis. +// testControl öffnet eine frische Kontroll-Datenbank in einem Testverzeichnis +// — oder mit MUNIN_TEST_PG in einem eigenen PostgreSQL-Schema (siehe +// postgres_test.go). func testControl(t *testing.T) *ControlStore { t.Helper() + if pgTestAktiv() { + return testPostgresControl(t) + } c, err := OpenControl(filepath.Join(t.TempDir(), "control.db")) if err != nil { diff --git a/internal/store/dialekt.go b/internal/store/dialekt.go new file mode 100644 index 0000000..f7fee66 --- /dev/null +++ b/internal/store/dialekt.go @@ -0,0 +1,118 @@ +package store + +import ( + "context" + "strconv" + "strings" +) + +// dialekt beschreibt, worin sich SQLite und PostgreSQL unterscheiden. +// +// # Warum so klein? +// +// Alle Abfragen im Store sind für beide Datenbanken dieselben (siehe +// AUSBAUSTUFEN.md 4.10, „ein Store mit Dialekt, keine zwei Stores“). Hier steht +// nur, was sich wirklich unterscheidet. Jede Methode, die hier dazukommt, muss +// für beide Datenbanken umgesetzt werden — fehlt eine, baut das Programm nicht. +// Der Compiler passt also auf, dass keine Seite vergessen wird. +type dialekt interface { + // rebind wandelt die Platzhalter einer Abfrage in die Schreibweise der + // Datenbank um. Geschrieben werden alle Abfragen mit "?" (SQLite); + // PostgreSQL erwartet "$1, $2, …". + rebind(abfrage string) string + + // migrationsUnterordner nennt den Ordner innerhalb von migrations/ bzw. + // migrations_control/, aus dem die Migrationen dieser Datenbank kommen. + migrationsUnterordner() string + + // belegung misst, wie viel Platz ein Workspace belegt: bei SQLite die + // Datei samt WAL, bei PostgreSQL die Tabellen des Schemas. Die + // Umsetzungen stehen in systemstand.go. + belegung(ctx context.Context, p pool) (Belegung, error) +} + +// ── SQLite ─────────────────────────────────────────────────────────────────── + +type sqliteDialekt struct{} + +// rebind ist bei SQLite nichts zu tun: Die Abfragen sind bereits in der +// SQLite-Schreibweise geschrieben. +func (sqliteDialekt) rebind(abfrage string) string { return abfrage } + +func (sqliteDialekt) migrationsUnterordner() string { return "sqlite" } + +// ── PostgreSQL ─────────────────────────────────────────────────────────────── + +type postgresDialekt struct{} + +func (postgresDialekt) rebind(abfrage string) string { return platzhalterNummerieren(abfrage) } + +func (postgresDialekt) migrationsUnterordner() string { return "postgres" } + +// platzhalterNummerieren ersetzt jedes "?" durch "$1", "$2", … in der +// Reihenfolge des Auftretens. +// +// Ein "?" innerhalb eines Textes in Hochkommas ('Wie geht das?'), eines +// Bezeichners in Anführungszeichen oder eines Kommentars ist kein Platzhalter +// und bleibt stehen. Deshalb geht die Funktion Zeichen für Zeichen durch und +// merkt sich, wo sie gerade steht, statt blind zu ersetzen. +// +// Ein verdoppeltes Hochkomma (so schreibt SQL ein Hochkomma innerhalb eines +// Textes) braucht keine Sonderbehandlung: Das erste beendet den Text, das +// zweite beginnt sofort einen neuen — das Ergebnis ist dasselbe. +func platzhalterNummerieren(abfrage string) string { + // Schneller Weg: Die meisten Abfragen sind kurz, aber ohne "?" gibt es + // gar nichts zu tun. + if !strings.Contains(abfrage, "?") { + return abfrage + } + + var b strings.Builder + b.Grow(len(abfrage) + 16) + + nummer := 0 + for i := 0; i < len(abfrage); i++ { + c := abfrage[i] + switch { + case c == '\'' || c == '"': + // Text bzw. Bezeichner: bis zum passenden Gegenstück unverändert + // übernehmen. + ende := strings.IndexByte(abfrage[i+1:], c) + if ende < 0 { + b.WriteString(abfrage[i:]) + return b.String() + } + b.WriteString(abfrage[i : i+1+ende+1]) + i += ende + 1 + + case c == '-' && i+1 < len(abfrage) && abfrage[i+1] == '-': + // Kommentar bis zum Zeilenende. + ende := strings.IndexByte(abfrage[i:], '\n') + if ende < 0 { + b.WriteString(abfrage[i:]) + return b.String() + } + b.WriteString(abfrage[i : i+ende]) + i += ende - 1 + + case c == '/' && i+1 < len(abfrage) && abfrage[i+1] == '*': + // Blockkommentar bis "*/". + ende := strings.Index(abfrage[i+2:], "*/") + if ende < 0 { + b.WriteString(abfrage[i:]) + return b.String() + } + b.WriteString(abfrage[i : i+2+ende+2]) + i += 2 + ende + 1 + + case c == '?': + nummer++ + b.WriteByte('$') + b.WriteString(strconv.Itoa(nummer)) + + default: + b.WriteByte(c) + } + } + return b.String() +} diff --git a/internal/store/dialekt_test.go b/internal/store/dialekt_test.go new file mode 100644 index 0000000..107dea0 --- /dev/null +++ b/internal/store/dialekt_test.go @@ -0,0 +1,127 @@ +package store + +import ( + "io/fs" + "slices" + "testing" +) + +// Diese Tests laufen immer, auch ohne PostgreSQL. Die Tests gegen eine echte +// PostgreSQL-Datenbank stehen in postgres_test.go. + +func TestPlatzhalterNummerieren(t *testing.T) { + faelle := []struct { + name, ein, aus string + }{ + {"ohne Platzhalter", `SELECT 1`, `SELECT 1`}, + {"der Reihe nach", `SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`}, + {"IN-Liste", `WHERE id IN (?, ?, ?) LIMIT ?`, `WHERE id IN ($1, $2, $3) LIMIT $4`}, + {"Text bleibt", `WHERE a = 'Wie geht das?' AND b = ?`, `WHERE a = 'Wie geht das?' AND b = $1`}, + {"verdoppeltes Hochkomma", `WHERE a = 'It''s ?' AND b = ?`, `WHERE a = 'It''s ?' AND b = $1`}, + {"Bezeichner bleibt", `SELECT "was?" FROM t WHERE a = ?`, `SELECT "was?" FROM t WHERE a = $1`}, + {"Zeilenkommentar", "SELECT a -- warum?\nFROM t WHERE b = ?", "SELECT a -- warum?\nFROM t WHERE b = $1"}, + {"Blockkommentar", `SELECT /* wer? */ a FROM t WHERE b = ?`, `SELECT /* wer? */ a FROM t WHERE b = $1`}, + {"offener Text am Ende", `WHERE a = ? AND b = 'kaputt?`, `WHERE a = $1 AND b = 'kaputt?`}, + {"Kommentar am Ende ohne Zeilenumbruch", `WHERE a = ? -- fertig?`, `WHERE a = $1 -- fertig?`}, + {"zweistellig", `(?,?,?,?,?,?,?,?,?,?,?)`, `($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`}, + } + for _, f := range faelle { + t.Run(f.name, func(t *testing.T) { + if got := platzhalterNummerieren(f.ein); got != f.aus { + t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.ein, got, f.aus) + } + }) + } +} + +func TestSQLiteRebindLaesstAbfrageUnveraendert(t *testing.T) { + abfrage := `SELECT a FROM t WHERE x = ?` + if got := (sqliteDialekt{}).rebind(abfrage); got != abfrage { + t.Errorf("SQLite hat die Abfrage verändert: %s", got) + } +} + +// TestMigrationsordnerGleich prüft, dass sqlite/ und postgres/ dieselben +// Migrationen enthalten. Eine Migration, die nur in einem Ordner liegt, würde +// die beiden Datenbanken unbemerkt auseinanderlaufen lassen — siehe +// AUSBAUSTUFEN.md 4.10. +// +// Ob die Migrationen auch dasselbe SCHEMA ergeben, prüft TestSchemaGleich +// (braucht PostgreSQL). +func TestMigrationsordnerGleich(t *testing.T) { + for _, quelle := range []struct { + fsys fs.FS + dir string + }{ + {migrationsFS, "migrations"}, + {controlMigrationsFS, "migrations_control"}, + } { + dateien := func(unterordner string) []string { + eintraege, err := fs.ReadDir(quelle.fsys, quelle.dir+"/"+unterordner) + if err != nil { + t.Fatalf("%s/%s: %v", quelle.dir, unterordner, err) + } + var namen []string + for _, e := range eintraege { + namen = append(namen, e.Name()) + } + return namen + } + + sqlite := dateien((sqliteDialekt{}).migrationsUnterordner()) + postgres := dateien((postgresDialekt{}).migrationsUnterordner()) + if len(sqlite) == 0 { + t.Errorf("%s: keine Migrationen gefunden", quelle.dir) + } + if !slices.Equal(sqlite, postgres) { + t.Errorf("%s: Ordner unterscheiden sich\nsqlite: %v\npostgres: %v", quelle.dir, sqlite, postgres) + } + } +} + +func TestWorkspaceSchema(t *testing.T) { + got, err := WorkspaceSchema("mue9bh3x-1c0e1120") + if err != nil { + t.Fatal(err) + } + if got != "munin_ws_mue9bh3x_1c0e1120" { + t.Errorf("Schema = %q", got) + } + + // Eine echte ID muss immer passen. + if _, err := WorkspaceSchema(NewID()); err != nil { + t.Errorf("NewID() ergibt keinen gültigen Schemanamen: %v", err) + } + + // Alles, was nicht wie eine ID aussieht, darf nie in einen Schemanamen. + for _, kaputt := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} { + if _, err := WorkspaceSchema(kaputt); err == nil { + t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", kaputt) + } + } + + // PostgreSQL würde längere Namen stillschweigend kürzen. + lang := "" + for len(lang) < 60 { + lang += "abcdefghij" + } + if _, err := WorkspaceSchema(lang); err == nil { + t.Error("zu lange ID wurde nicht abgelehnt") + } +} + +func TestTextLiteral(t *testing.T) { + faelle := map[string]string{ + "Vertrieb": `E'Vertrieb'`, + "O'Brien": `E'O''Brien'`, + `C:\temp`: `E'C:\\temp'`, + `\'; DROP x; --`: `E'\\''; DROP x; --'`, + "mit\x00Null": `E'mitNull'`, + "Ärger & Öl": `E'Ärger & Öl'`, + } + for ein, soll := range faelle { + if got := textLiteral(ein); got != soll { + t.Errorf("textLiteral(%q) = %s, erwartet %s", ein, got, soll) + } + } +} diff --git a/internal/store/entries.go b/internal/store/entries.go index 1be0903..e6eaef3 100644 --- a/internal/store/entries.go +++ b/internal/store/entries.go @@ -127,7 +127,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { var ergebnis Entry - err := s.tx(ctx, func(tx *sql.Tx) error { + err := s.tx(ctx, func(tx *transaktion) error { // Bestehenden Eintrag suchen, um zwischen Anlegen und Ändern zu unterscheiden. // // Bewusst OHNE nichtImPapierkorb: Ein Eintrag im Papierkorb belegt seinen @@ -261,7 +261,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) { // Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung // vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben, // oder keine von beiden. -func snapshotEntryVersion(ctx context.Context, tx *sql.Tx, projectID, entryID string) error { +func snapshotEntryVersion(ctx context.Context, tx *transaktion, projectID, entryID string) error { var version int var title, content, source, author string var curated int @@ -369,7 +369,7 @@ func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, versio // replaceLinks sind die einzigen Schreibwege in diese Tabellen, und beide laufen // in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht // auseinanderlaufen. -func replaceTags(ctx context.Context, tx *sql.Tx, projectID, entryID string, tags []string) error { +func replaceTags(ctx context.Context, tx *transaktion, projectID, entryID string, tags []string) error { if _, err := tx.ExecContext(ctx, `DELETE FROM entry_tags WHERE entry_id = ?`, entryID); err != nil { return fmt.Errorf("Schlagworte konnten nicht ersetzt werden: %w", err) } @@ -384,7 +384,7 @@ func replaceTags(ctx context.Context, tx *sql.Tx, projectID, entryID string, tag } // replaceLinks ersetzt die ausgehenden Verweise eines Eintrags. -func replaceLinks(ctx context.Context, tx *sql.Tx, projectID, entryID string, links []string) error { +func replaceLinks(ctx context.Context, tx *transaktion, projectID, entryID string, links []string) error { if _, err := tx.ExecContext(ctx, `DELETE FROM entry_links WHERE from_entry_id = ?`, entryID); err != nil { return fmt.Errorf("Verweise konnten nicht ersetzt werden: %w", err) } @@ -426,6 +426,11 @@ type EntryFilter struct { Limit int // 0 = Standardwert Offset int Curated bool // true = nur kuratierte Einträge + + // ChangedBefore: nur Einträge, die zuletzt vor diesem Zeitpunkt geändert + // wurden. Leer (Nullwert) = ohne diese Einschränkung. Für den Filter + // „veraltet" der Weboberfläche: StaleCutoff(jetzt). + ChangedBefore time.Time } const defaultEntryLimit = 50 @@ -452,6 +457,13 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter if f.Curated { abfrage += ` AND curated = 1` } + if !f.ChangedBefore.IsZero() { + // Textvergleich auf RFC3339-Zeitstempeln, wie überall im Store (siehe + // timeFormat): Das sortiert richtig und braucht keine Datumsfunktion + // der Datenbank. + abfrage += ` AND updated_at < ?` + werte = append(werte, formatTime(f.ChangedBefore)) + } abfrage += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?` werte = append(werte, limit, f.Offset) @@ -518,6 +530,21 @@ func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) return n, nil } +// CountStaleEntries zählt die Einträge eines Projekts, die seit StaleAfter +// nicht geändert wurden (siehe Entry.Stale). Für die Weboberfläche: Liste und +// Nutzungsseite nennen die Zahl, damit ein Team gezielt aufräumen kann. +func (s *Store) CountStaleEntries(ctx context.Context, projectID string, jetzt time.Time) (int, error) { + var n int + err := s.lesen.QueryRowContext(ctx, + `SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+nichtImPapierkorb, + projectID, formatTime(StaleCutoff(jetzt)), + ).Scan(&n) + if err != nil { + return 0, fmt.Errorf("veraltete Einträge konnten nicht gezählt werden: %w", err) + } + return n, nil +} + // ── Schlagworte ────────────────────────────────────────────────────────────── // ListTags gibt alle Schlagworte eines Projekts mit ihrer Häufigkeit zurück, @@ -637,7 +664,7 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, var geaendert []Entry jetzt := NowString() - err := s.tx(ctx, func(tx *sql.Tx) error { + err := s.tx(ctx, func(tx *transaktion) error { for _, id := range entryIDs { var vorhandenID string err := tx.QueryRowContext(ctx, diff --git a/internal/store/keys.go b/internal/store/keys.go index 9bf4213..e080f03 100644 --- a/internal/store/keys.go +++ b/internal/store/keys.go @@ -20,30 +20,40 @@ type ResolvedKey struct { } // keyColumns steht in jeder Key-Abfrage. -const keyColumns = `id, project_id, name, hash, role, created_at, last_used_at, revoked_at` +const keyColumns = `id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at` // scanKey liest eine Key-Zeile. func scanKey(scan func(...any) error) (APIKey, error) { var k APIKey - var createdAt, lastUsedAt, revokedAt string + var createdAt, lastUsedAt, revokedAt, expiresAt string if err := scan(&k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role, - &createdAt, &lastUsedAt, &revokedAt); err != nil { + &createdAt, &lastUsedAt, &revokedAt, &expiresAt); err != nil { return APIKey{}, err } k.CreatedAt = parseTime(createdAt) k.LastUsedAt = parseTime(lastUsedAt) k.RevokedAt = parseTime(revokedAt) + k.ExpiresAt = parseTime(expiresAt) return k, nil } -// CreateAPIKey legt einen Key an. Übergeben wird der Hash, nie der Klartext — -// den kennt nur der Aufrufer, und zwar genau einmal. +// CreateAPIKey legt einen Key ohne Ablaufdatum an. Übergeben wird der Hash, +// nie der Klartext — den kennt nur der Aufrufer, und zwar genau einmal. func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role string) (APIKey, error) { + return s.CreateAPIKeyWithExpiry(ctx, projectID, name, hash, role, time.Time{}) +} + +// CreateAPIKeyWithExpiry legt einen Key an, der ab ablauf nicht mehr gilt +// (AUSBAUSTUFEN.md 4.7, Stufe 2). Der Nullzeitpunkt heißt: läuft nie ab. +func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, hash, role string, ablauf time.Time) (APIKey, error) { if strings.TrimSpace(name) == "" { return APIKey{}, fmt.Errorf("der Key braucht einen Namen — daran erkennt man später, wer oder was ihn benutzt") } + if !ablauf.IsZero() && !ablauf.After(time.Now()) { + return APIKey{}, fmt.Errorf("das Ablaufdatum liegt in der Vergangenheit — so wäre der Key nie gültig") + } k := APIKey{ ID: NewID(), @@ -51,13 +61,18 @@ func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role st Name: strings.TrimSpace(name), Hash: hash, Role: role, + ExpiresAt: ablauf, } jetzt := NowString() + ablaufText := "" + if !ablauf.IsZero() { + ablaufText = formatTime(ablauf) + } _, err := s.schreiben.ExecContext(ctx, - `INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at) - VALUES (?, ?, ?, ?, ?, ?, '', '')`, - k.ID, k.ProjectID, k.Name, k.Hash, k.Role, jetzt, + `INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, '', '', ?)`, + k.ID, k.ProjectID, k.Name, k.Hash, k.Role, jetzt, ablaufText, ) if err != nil { return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err) @@ -73,24 +88,24 @@ func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role st // MCP-Request. Deshalb ein einziger indizierter Lookup über die UNIQUE-Spalte // "hash" statt einer Schleife über alle Keys. // -// Zurückgezogene Keys gelten als nicht vorhanden: der Aufrufer erfährt nicht, ob -// es den Key nie gab oder ob er gesperrt wurde. +// Zurückgezogene und abgelaufene Keys gelten als nicht vorhanden: der +// Aufrufer erfährt nicht, ob es den Key nie gab oder ob er gesperrt wurde. func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, error) { row := s.lesen.QueryRowContext(ctx, - `SELECT k.id, k.project_id, k.name, k.hash, k.role, k.created_at, k.last_used_at, k.revoked_at, + `SELECT k.id, k.project_id, k.name, k.hash, k.role, k.created_at, k.last_used_at, k.revoked_at, k.expires_at, p.id, p.workspace_id, p.slug, p.name, p.description, p.created_at FROM api_keys k JOIN projects p ON p.id = k.project_id - WHERE k.hash = ? AND k.revoked_at = ''`, - hash, + WHERE k.hash = ? AND k.revoked_at = '' AND (k.expires_at = '' OR k.expires_at > ?)`, + hash, NowString(), ) var k APIKey var p Project - var kCreated, kLastUsed, kRevoked, pCreated string + var kCreated, kLastUsed, kRevoked, kExpires, pCreated string err := row.Scan( - &k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role, &kCreated, &kLastUsed, &kRevoked, + &k.ID, &k.ProjectID, &k.Name, &k.Hash, &k.Role, &kCreated, &kLastUsed, &kRevoked, &kExpires, &p.ID, &p.WorkspaceID, &p.Slug, &p.Name, &p.Description, &pCreated, ) if errors.Is(err, sql.ErrNoRows) { @@ -103,6 +118,7 @@ func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, e k.CreatedAt = parseTime(kCreated) k.LastUsedAt = parseTime(kLastUsed) k.RevokedAt = parseTime(kRevoked) + k.ExpiresAt = parseTime(kExpires) p.CreatedAt = parseTime(pCreated) return ResolvedKey{Key: k, Project: p}, nil diff --git a/internal/store/links.go b/internal/store/links.go index 7f629e6..422cbf4 100644 --- a/internal/store/links.go +++ b/internal/store/links.go @@ -21,8 +21,10 @@ var linkPattern = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9_-]{0,63})\]\]`) // ExtractLinks liest alle [[slug]]-Verweise aus einem Text. // Das Ergebnis ist sortiert und ohne Dubletten — ein zweiter Verweis auf dasselbe // Ziel ist keine zusätzliche Information. +// +// Was in Code steht, zählt nicht (siehe verweiseAusserhalbVonCode). func ExtractLinks(content string) []string { - treffer := linkPattern.FindAllStringSubmatch(content, -1) + treffer := verweiseAusserhalbVonCode(content) if len(treffer) == 0 { return nil } @@ -31,7 +33,7 @@ func ExtractLinks(content string) []string { var ziele []string for _, t := range treffer { - ziel := t[1] + ziel := content[t[2]:t[3]] if gesehen[ziel] { continue } @@ -46,10 +48,183 @@ func ExtractLinks(content string) []string { // ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn. // // Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die -// Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext. +// Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext. Was in Code +// steht, bleibt unverändert (siehe verweiseAusserhalbVonCode). func ReplaceLinks(content string, fn func(slug string) string) string { - return linkPattern.ReplaceAllStringFunc(content, func(treffer string) string { - slug := strings.TrimSuffix(strings.TrimPrefix(treffer, "[["), "]]") - return fn(slug) - }) + treffer := verweiseAusserhalbVonCode(content) + if len(treffer) == 0 { + return content + } + + var b strings.Builder + b.Grow(len(content)) + bisher := 0 + for _, t := range treffer { + b.WriteString(content[bisher:t[0]]) + b.WriteString(fn(content[t[2]:t[3]])) + bisher = t[1] + } + b.WriteString(content[bisher:]) + return b.String() +} + +// verweiseAusserhalbVonCode findet alle [[slug]]-Verweise, die NICHT in Code +// stehen. Jeder Treffer ist wie bei regexp.FindAllStringSubmatchIndex +// aufgebaut: [Anfang, Ende, Anfang des Slugs, Ende des Slugs]. +// +// # Warum? +// +// Technische Einträge erklären oft, wie man verweist, und schreiben dafür +// ein Beispiel in Code: `[[bezeichner]]`. Früher wurde auch das als Verweis +// gezählt. Er erschien dann unter „Verweise ins Leere", und beim Anzeigen +// stand mitten im Code ein Markdown-Link statt des Beispiels. +// +// Als Code gilt, was Markdown als Code darstellt: Codeblöcke zwischen ``` +// oder ~~~ und Code im Fließtext zwischen Backticks. +func verweiseAusserhalbVonCode(content string) [][]int { + alle := linkPattern.FindAllStringSubmatchIndex(content, -1) + if len(alle) == 0 { + return nil + } + code := codeBereiche(content) + if len(code) == 0 { + return alle + } + + var draussen [][]int + for _, t := range alle { + if !inBereich(t[0], code) { + draussen = append(draussen, t) + } + } + return draussen +} + +// inBereich sagt, ob die Stelle pos in einem der Bereiche liegt. +func inBereich(pos int, bereiche [][2]int) bool { + for _, b := range bereiche { + if pos >= b[0] && pos < b[1] { + return true + } + } + return false +} + +// codeBereiche liefert die Stellen eines Markdown-Texts, die Code sind, als +// [Anfang, Ende) in Bytes. +// +// Das ist bewusst kein vollständiger Markdown-Parser, sondern deckt die zwei +// Formen ab, in denen Code in Einträgen tatsächlich vorkommt: +// +// - Codeblöcke: Eine Zeile beginnt (nach höchstens drei Leerzeichen) mit +// mindestens drei ``` oder ~~~. Der Block endet an einer Zeile mit +// mindestens ebenso vielen gleichen Zeichen, sonst am Textende — so +// behandelt es auch Markdown. +// - Code im Fließtext: Eine Folge von Backticks bis zur nächsten Folge mit +// genau derselben Länge. Fehlt das Gegenstück, ist es kein Code. +// +// Eingerückte Codeblöcke (vier Leerzeichen) fehlen absichtlich: Sie sind von +// eingerückten Listenzeilen nicht sicher zu unterscheiden, und ein +// übersehener Verweis wäre schlimmer als ein überflüssiger. +func codeBereiche(content string) [][2]int { + var bereiche [][2]int + + // ── 1. Codeblöcke, Zeile für Zeile ─────────────────────────────────── + var fliesstext [][2]int // was außerhalb von Codeblöcken liegt + blockAnfang := -1 + var zaun string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks + zeilenAnfang := 0 + textAnfang := 0 + for zeilenAnfang < len(content) { + zeilenEnde := strings.IndexByte(content[zeilenAnfang:], '\n') + if zeilenEnde < 0 { + zeilenEnde = len(content) + } else { + zeilenEnde += zeilenAnfang + 1 // samt Zeilenumbruch + } + zeile := content[zeilenAnfang:zeilenEnde] + + if blockAnfang < 0 { + if z := codeZaun(zeile); z != "" { + fliesstext = append(fliesstext, [2]int{textAnfang, zeilenAnfang}) + blockAnfang, zaun = zeilenAnfang, z + } + } else if z := codeZaun(zeile); z != "" && z[0] == zaun[0] && len(z) >= len(zaun) && + strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(zeile), z[:1])) == "" { + // Schließender Zaun: nur Zaunzeichen, nichts dahinter. + bereiche = append(bereiche, [2]int{blockAnfang, zeilenEnde}) + blockAnfang = -1 + textAnfang = zeilenEnde + } + zeilenAnfang = zeilenEnde + } + if blockAnfang >= 0 { + bereiche = append(bereiche, [2]int{blockAnfang, len(content)}) + } else { + fliesstext = append(fliesstext, [2]int{textAnfang, len(content)}) + } + + // ── 2. Code im Fließtext, nur außerhalb der Codeblöcke ─────────────── + for _, f := range fliesstext { + text := content[f[0]:f[1]] + for i := 0; i < len(text); { + if text[i] != '`' { + i++ + continue + } + laenge := laufLaenge(text, i, '`') + ende := schliessendeBackticks(text, i+laenge, laenge) + if ende < 0 { + i += laenge // kein Gegenstück: kein Code + continue + } + bereiche = append(bereiche, [2]int{f[0] + i, f[0] + ende + laenge}) + i = ende + laenge + } + } + return bereiche +} + +// codeZaun liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock +// öffnet oder schließt, sonst "". +func codeZaun(zeile string) string { + eingerueckt := len(zeile) - len(strings.TrimLeft(zeile, " ")) + if eingerueckt > 3 { + return "" + } + rest := zeile[eingerueckt:] + if rest == "" || (rest[0] != '`' && rest[0] != '~') { + return "" + } + n := laufLaenge(rest, 0, rest[0]) + if n < 3 { + return "" + } + return rest[:n] +} + +// laufLaenge zählt, wie oft das Zeichen c ab Stelle i hintereinander steht. +func laufLaenge(text string, i int, c byte) int { + n := 0 + for i+n < len(text) && text[i+n] == c { + n++ + } + return n +} + +// schliessendeBackticks sucht ab Stelle ab eine Folge von genau laenge +// Backticks und gibt ihre Stelle zurück, sonst -1. +func schliessendeBackticks(text string, ab, laenge int) int { + for i := ab; i < len(text); { + if text[i] != '`' { + i++ + continue + } + n := laufLaenge(text, i, '`') + if n == laenge { + return i + } + i += n + } + return -1 } diff --git a/internal/store/migrations/postgres/001_init.sql b/internal/store/migrations/postgres/001_init.sql new file mode 100644 index 0000000..bf062dc --- /dev/null +++ b/internal/store/migrations/postgres/001_init.sql @@ -0,0 +1,136 @@ +-- PostgreSQL-Fassung. Gegenstück: ../sqlite/001_init.sql. +-- +-- Die Migrationen liegen je Datenbank in einem eigenen Ordner (siehe +-- AUSBAUSTUFEN.md 4.10). Beide Ordner enthalten dieselben Dateinamen, und +-- nach allen Migrationen müssen beide Datenbanken dasselbe Schema haben — das +-- prüfen TestMigrationsordnerGleich und TestSchemaGleich. Eine neue Migration +-- gehört also immer in BEIDE Ordner. +-- +-- Abweichungen von der SQLite-Fassung sind in der jeweiligen Datei vermerkt. + +-- Erste Fassung des Schemas. +-- +-- Konventionen, die für alle Migrationen gelten: +-- +-- * IDs sind TEXT und werden in Go erzeugt (siehe ids.go), nicht von der +-- Datenbank. Damit gibt es keine Sequenzen, die bei einem Umzug nach +-- PostgreSQL nachgezogen werden müssten. +-- * Zeitstempel sind TEXT im Format RFC3339 in UTC. SQLite kennt keinen +-- echten Datumstyp, und Textvergleiche sortieren bei diesem Format korrekt. +-- * Wahrheitswerte sind INTEGER 0/1 — funktioniert in SQLite wie in PostgreSQL. +-- * Kein SQLite-spezifisches SQL (kein AUTOINCREMENT, kein INSERT OR REPLACE, +-- keine datetime()-Funktionen), damit der Umstieg offen bleibt. + +-- Diese Datei enthält genau einen Workspace (eine Abteilung). Die +-- Workspace-Verwaltung selbst (Tabelle "workspaces") lebt in einer eigenen, +-- zentralen Kontroll-Datenbank — siehe internal/store/migrations_control und +-- AUSBAUSTUFEN.md 4.4. "workspace_id" steht hier trotzdem an jeder Zeile, damit +-- der Bezug beim Lesen einer einzelnen Datei erhalten bleibt, auch wenn er +-- innerhalb der Datei immer denselben Wert hat. Es gibt bewusst keinen +-- Fremdschlüssel auf eine "workspaces"-Tabelle — die existiert in dieser Datei +-- nicht. + +-- Ein Projekt ist die Einheit der Abschottung: eigene Einträge, eigene Keys, +-- eigener MCP-Server. Ein Agent, der an Projekt A arbeitet, sieht Projekt B nicht. +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE (workspace_id, slug) +); + +-- API-Keys gehören zu genau einem Projekt. +-- +-- Gespeichert wird nur der SHA-256-Hash (siehe auth.HashKey) — der Klartext wird +-- genau einmal beim Anlegen angezeigt und danach nie wieder. Der Hash ist UNIQUE +-- und damit indiziert: die Auflösung eines Keys ist ein Lookup, keine Schleife. +CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + hash TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT '', + revoked_at TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_api_keys_project ON api_keys (project_id, created_at); + +-- Ein Eintrag ist die einzige Wissenseinheit — egal ob ein Mensch ihn über die +-- Weboberfläche geschrieben hat oder ein Agent über MCP. Der Unterschied steckt +-- in "source" und "curated", nicht in einer zweiten Tabelle. +-- +-- "slug" ist der stabile Bezeichner innerhalb eines Projekts. Er ist zugleich der +-- Schlüssel für die Dublettenvermeidung: schreibt ein Agent zweimal unter +-- demselben Slug, wird aktualisiert statt ein zweiter Eintrag angelegt. +-- +-- "version" zählt bei jeder Änderung hoch und dient dem optimistischen Sperren: +-- ein Schreiber kann angeben, welchen Stand er gelesen hat, und bekommt einen +-- Konflikt statt eines stillen Überschreibens. +CREATE TABLE entries ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + source TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + curated INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (project_id, slug) +); + +-- Für die Übersichtsliste "zuletzt geändert zuerst". +CREATE INDEX idx_entries_project_updated ON entries (project_id, updated_at); + +-- Schlagworte in einer eigenen Tabelle statt als kommagetrennte Spalte: +-- so lässt sich ohne LIKE-Gebastel nach ihnen filtern, und es gibt keine +-- doppelte Wahrheit über denselben Wert. +CREATE TABLE entry_tags ( + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (entry_id, tag) +); + +CREATE INDEX idx_entry_tags_tag ON entry_tags (tag); + +-- Verweise zwischen Einträgen, geparst aus [[slug]] im Inhalt. +-- +-- Das ersetzt die frühere Ordnerhierarchie: Struktur entsteht durch Verlinkung, +-- nicht durch einen Baum. Gespeichert wird der Ziel-Slug als Text, nicht als +-- Fremdschlüssel — ein Verweis darf auch auf einen noch nicht existierenden +-- Eintrag zeigen (und bleibt gültig, wenn dieser später angelegt wird). +CREATE TABLE entry_links ( + from_entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + to_slug TEXT NOT NULL, + PRIMARY KEY (from_entry_id, to_slug) +); + +CREATE INDEX idx_entry_links_to ON entry_links (to_slug); + +-- Nachvollziehbarkeitsprotokoll. +-- +-- Bewusst mit api_key_id statt nur einem Namen: kommt später eine +-- Nutzerverwaltung dazu, lässt sich eine user_id danebenstellen und die +-- Historie bleibt auswertbar. +CREATE TABLE audit_events ( + id TEXT PRIMARY KEY, + ts TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + api_key_id TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL, + action TEXT NOT NULL, + entry_slug TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_audit_ts ON audit_events (ts); +CREATE INDEX idx_audit_project ON audit_events (project_id, ts); diff --git a/internal/store/migrations/002_projekt_in_nebentabellen.sql b/internal/store/migrations/postgres/002_projekt_in_nebentabellen.sql similarity index 100% rename from internal/store/migrations/002_projekt_in_nebentabellen.sql rename to internal/store/migrations/postgres/002_projekt_in_nebentabellen.sql diff --git a/internal/store/migrations/003_schreibbudget_je_projekt.sql b/internal/store/migrations/postgres/003_schreibbudget_je_projekt.sql similarity index 100% rename from internal/store/migrations/003_schreibbudget_je_projekt.sql rename to internal/store/migrations/postgres/003_schreibbudget_je_projekt.sql diff --git a/internal/store/migrations/004_aenderungshistorie.sql b/internal/store/migrations/postgres/004_aenderungshistorie.sql similarity index 100% rename from internal/store/migrations/004_aenderungshistorie.sql rename to internal/store/migrations/postgres/004_aenderungshistorie.sql diff --git a/internal/store/migrations/005_papierkorb.sql b/internal/store/migrations/postgres/005_papierkorb.sql similarity index 100% rename from internal/store/migrations/005_papierkorb.sql rename to internal/store/migrations/postgres/005_papierkorb.sql diff --git a/internal/store/migrations/postgres/006_suchprotokoll.sql b/internal/store/migrations/postgres/006_suchprotokoll.sql new file mode 100644 index 0000000..8825f00 --- /dev/null +++ b/internal/store/migrations/postgres/006_suchprotokoll.sql @@ -0,0 +1,46 @@ +-- Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden. +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- Ohne diese Zahlen lässt sich nicht beurteilen, ob die Suche gut genug ist. +-- Eine Suche ohne Treffer ist das deutlichste Zeichen: Entweder gibt es das +-- Wissen nicht — dann ist die Anfrage ein Hinweis, was fehlt — oder es steht +-- unter anderen Wörtern da. Beides will man sehen (AUSBAUSTUFEN.md 3.1). +-- +-- ── Warum nicht im Zugriffsprotokoll (audit_events)? ───────────────────────── +-- +-- Gesucht wird viel öfter als geschrieben. Im Zugriffsprotokoll würden die +-- Suchen die Schreibvorgänge zudecken, um die es dort geht. Außerdem enthält +-- eine Anfrage Inhalt („wie kommt man an das Passwort für …"), und Inhalt hat +-- eine kürzere Aufbewahrungsfrist verdient als die 90 Tage des Protokolls: +-- Die tägliche Aufräumaufgabe löscht Zeilen nach 30 Tagen +-- (store.SearchLogRetention). +-- +-- ── Aufbau ─────────────────────────────────────────────────────────────────── +-- +-- Eine Zeile je recall-Aufruf. Bewusst ohne IP-Adresse und ohne Namen: Für +-- die Auswertung zählt, WAS gesucht wurde, nicht wer. api_key_id bleibt, damit +-- sich ein einzelner Agent mit auffälligem Verhalten eingrenzen lässt. +-- +-- Die ID ist hier eine fortlaufende Zahl (INTEGER PRIMARY KEY) statt eines +-- Zufallswerts wie in den anderen Tabellen: Niemand verweist auf eine +-- einzelne Zeile, und eine Zahl ist beim Einfügen am billigsten. +-- +-- PostgreSQL-Fassung: "INTEGER PRIMARY KEY" zählt nur in SQLite von selbst +-- hoch. PostgreSQL braucht dafür ausdrücklich eine Identitätsspalte. BIGINT, +-- weil die Zahl nie zurückgesetzt wird, auch wenn alte Zeilen gelöscht werden. +-- +-- Anders als das Zugriffsprotokoll hängt die Tabelle per Fremdschlüssel am +-- Projekt: Wird es gelöscht, verschwinden auch seine Anfragen. Sie sind Inhalt +-- des Projekts, kein Nachweis darüber, wer was getan hat. +CREATE TABLE search_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + ts TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + api_key_id TEXT NOT NULL DEFAULT '', + query TEXT NOT NULL, + synonyms TEXT NOT NULL DEFAULT '', -- mit Komma getrennt, leer = keine + results INTEGER NOT NULL -- Zahl der ausgegebenen Treffer +); + +CREATE INDEX idx_search_log_project ON search_log (project_id, ts); diff --git a/internal/store/migrations/postgres/007_key_ablauf.sql b/internal/store/migrations/postgres/007_key_ablauf.sql new file mode 100644 index 0000000..dce2199 --- /dev/null +++ b/internal/store/migrations/postgres/007_key_ablauf.sql @@ -0,0 +1,13 @@ +-- Ablaufdatum für API-Keys (AUSBAUSTUFEN.md 4.7, Stufe 2). +-- +-- Bei vielen Teams sammeln sich Keys an, die niemand mehr braucht. Die Marke +-- "ungenutzt" (Stufe 1) zeigt sie nur an. Ein Ablaufdatum schließt einen +-- Zugang von selbst: Ab dem Zeitpunkt gilt der Key wie ein zurückgezogener. +-- +-- Leerer Text = läuft nie ab — wie bei revoked_at und last_used_at. Damit +-- behalten alle vorhandenen Keys ihr bisheriges Verhalten, und die Migration +-- muss keine Daten anfassen. Gespeichert wird wie überall RFC3339 in UTC; der +-- Textvergleich mit dem aktuellen Zeitpunkt sortiert dabei richtig. +-- +-- In beiden Datenbanken gleich (ADD COLUMN mit Standardwert können beide). +ALTER TABLE api_keys ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/sqlite/001_init.sql similarity index 97% rename from internal/store/migrations/001_init.sql rename to internal/store/migrations/sqlite/001_init.sql index f3eed0d..b5ede1d 100644 --- a/internal/store/migrations/001_init.sql +++ b/internal/store/migrations/sqlite/001_init.sql @@ -1,3 +1,6 @@ +-- SQLite-Fassung. Gegenstück: ../postgres/001_init.sql. Eine neue Migration +-- gehört immer in BEIDE Ordner (siehe dort und AUSBAUSTUFEN.md 4.10). + -- Erste Fassung des Schemas. -- -- Konventionen, die für alle Migrationen gelten: diff --git a/internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql b/internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql new file mode 100644 index 0000000..3724896 --- /dev/null +++ b/internal/store/migrations/sqlite/002_projekt_in_nebentabellen.sql @@ -0,0 +1,47 @@ +-- Die Projektzugehörigkeit auch in den Nebentabellen führen. +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- Bisher stand project_id nur an "entries". Wer die Schlagworte eines Projekts +-- zählen wollte, musste deshalb über entries verbinden: +-- +-- SELECT t.tag, COUNT(*) FROM entry_tags t +-- JOIN entries e ON e.id = t.entry_id +-- WHERE e.project_id = ? GROUP BY t.tag +-- +-- SQLite läuft dafür durch ALLE Einträge des Projekts und schlägt zu jedem die +-- Schlagworte nach — der Aufwand wächst also mit der Zahl der Einträge, obwohl +-- am Ende nur ein paar Dutzend Schlagworte herauskommen. Gemessen bei 5.000 +-- Einträgen: 6,7 ms. Seit die Schlagworte in der Seitenleiste stehen, läuft +-- diese Abfrage bei JEDEM Seitenaufruf. +-- +-- Dasselbe bei den Rückverweisen: Dort kippte zusätzlich die Reihenfolge der +-- Verknüpfung, sobald ein ORDER BY dazukam — statt vom kleinen Verweis-Index +-- auszugehen, lief SQLite wieder durch alle Einträge (3,1 ms statt 9 µs). +-- +-- ── Der Handel ─────────────────────────────────────────────────────────────── +-- +-- Eine Spalte doppelt zu führen ist eine Redundanz, und Redundanz kann +-- auseinanderlaufen. Hier ist das vertretbar: Beide Tabellen werden +-- ausschließlich in replaceTags/replaceLinks geschrieben, innerhalb derselben +-- Transaktion wie der Eintrag selbst. Es gibt keinen zweiten Schreibweg. + +-- ALTER TABLE ... ADD COLUMN können SQLite und PostgreSQL gleichermaßen. +-- NOT NULL braucht dabei einen Vorgabewert, sonst wüssten bestehende Zeilen +-- nicht, was hineingehört. +ALTER TABLE entry_tags ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; +UPDATE entry_tags SET project_id = (SELECT e.project_id FROM entries e WHERE e.id = entry_tags.entry_id); + +ALTER TABLE entry_links ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; +UPDATE entry_links SET project_id = (SELECT e.project_id FROM entries e WHERE e.id = entry_links.from_entry_id); + +-- Die alten Indizes werden nicht mehr gebraucht: Jede Abfrage kennt jetzt das +-- Projekt und kann den zusammengesetzten Index nutzen. +DROP INDEX idx_entry_tags_tag; +DROP INDEX idx_entry_links_to; + +-- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index +-- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort +-- in einem Projekt" ab. +CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag); +CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug); diff --git a/internal/store/migrations/sqlite/003_schreibbudget_je_projekt.sql b/internal/store/migrations/sqlite/003_schreibbudget_je_projekt.sql new file mode 100644 index 0000000..97f94a1 --- /dev/null +++ b/internal/store/migrations/sqlite/003_schreibbudget_je_projekt.sql @@ -0,0 +1,26 @@ +-- Ein eigenes Schreibbudget je Projekt. +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- Ein gültiger API-Key umgeht die IP-Sperre bewusst (siehe APIAuth in +-- internal/web/middleware.go). Damit hat ein Agent, der in einer Schleife hängt, +-- überhaupt keine Grenze mehr — er schreibt, bis die Platte voll ist. +-- +-- Die Obergrenze hängt am Projekt und nicht am Key: Ein Limit je Key ließe sich +-- umgehen, indem man einen zweiten Key anlegt, und das darf jeder Projekt-Admin +-- selbst. +-- +-- ── Warum die Vorgabe 0 und nicht die eigentliche Zahl? ────────────────────── +-- +-- 0 heißt hier nicht "darf nicht schreiben", sondern "keine eigene Angabe — +-- es gilt der Standard aus WRITE_LIMIT_HOURLY". Damit muss diese Migration +-- keine Daten anfassen: Alle bestehenden Projekte bekommen automatisch den +-- Standardwert, und der lässt sich später in der Umgebung ändern, ohne jede +-- Zeile nachzuziehen. +-- +-- Die eigentliche Zahl steht bewusst NICHT als Vorgabewert im Schema. Sonst +-- gäbe es zwei Orte, an denen ein Standard festgelegt ist, und irgendwann +-- widersprechen sie sich. + +-- ALTER TABLE ... ADD COLUMN können SQLite und PostgreSQL gleichermaßen. +ALTER TABLE projects ADD COLUMN write_limit_hourly INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/migrations/sqlite/004_aenderungshistorie.sql b/internal/store/migrations/sqlite/004_aenderungshistorie.sql new file mode 100644 index 0000000..bf01241 --- /dev/null +++ b/internal/store/migrations/sqlite/004_aenderungshistorie.sql @@ -0,0 +1,43 @@ +-- Änderungshistorie: die überschriebene Fassung eines Eintrags geht nicht +-- mehr verloren. +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- entries.version zählt bei jeder Änderung hoch, aber der alte Inhalt wird +-- nirgends aufgehoben. Überschreibt ein Agent etwas Richtiges mit etwas +-- Falschem — oder ein Mensch verwechselt sich beim Bearbeiten —, ist das +-- Richtige weg, ohne Wiederherstellungsweg. +-- +-- ── Aufbau ─────────────────────────────────────────────────────────────────── +-- +-- entries bleibt die aktuelle Fassung. entry_versions hält die SUPERSEDED +-- Fassungen: bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht +-- beim erstmaligen Anlegen), wird der bisherige Stand hier abgelegt, bevor er +-- überschrieben wird. version trägt dabei die Nummer, die die Fassung VOR der +-- Änderung hatte — Fassung 1 ist also das, was durch das Schreiben zu Fassung 2 +-- wurde. +-- +-- Schlagworte stehen hier bewusst als kommagetrennter Text statt in einer +-- eigenen Tabelle wie bei entries/entry_tags: Eine historische Fassung wird nie +-- gefiltert oder durchsucht, sie wird nur ganz gelesen — eine Nebentabelle +-- brächte dafür keinen Nutzen, nur eine weitere Stelle, die beim Schreiben +-- mitgepflegt werden müsste. +CREATE TABLE entry_versions ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + project_id TEXT NOT NULL, + version INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL, + curated INTEGER NOT NULL DEFAULT 0, + -- Zeitpunkt, zu dem diese Fassung abgelöst wurde — nicht, wann sie + -- entstand (das wüssten wir hier nicht mehr, ohne die Kette rückwärts + -- aufzurollen; für die Historie reicht "abgelöst am"). + created_at TEXT NOT NULL, + UNIQUE (entry_id, version) +); + +CREATE INDEX idx_entry_versions_entry ON entry_versions (entry_id, version); diff --git a/internal/store/migrations/sqlite/005_papierkorb.sql b/internal/store/migrations/sqlite/005_papierkorb.sql new file mode 100644 index 0000000..fbe3239 --- /dev/null +++ b/internal/store/migrations/sqlite/005_papierkorb.sql @@ -0,0 +1,41 @@ +-- Papierkorb: Löschen ist nicht mehr sofort endgültig (AUSBAUSTUFEN.md 3.9). +-- +-- ── Warum? ─────────────────────────────────────────────────────────────────── +-- +-- Seit der Änderungshistorie (004) lässt sich jedes Überschreiben reparieren. +-- Löschen nicht: DELETE entfernte den Eintrag samt aller Fassungen (ON DELETE +-- CASCADE). Damit war "forget" der schlimmste Fall, den ein fehlgeleiteter +-- Agent anrichten konnte. +-- +-- ── Aufbau ─────────────────────────────────────────────────────────────────── +-- +-- Löschen setzt jetzt nur noch deleted_at (Zeitpunkt) und deleted_by (Name des +-- Zugangs). Die Zeile bleibt mit Schlagworten, Verweisen und Verlauf stehen. +-- Endgültig entfernt wird sie erst, wenn ein Mensch den Papierkorb leert oder +-- die tägliche Aufgabe sie nach 30 Tagen abräumt — dann greift das bekannte +-- ON DELETE CASCADE. +-- +-- deleted_at ist NULL für jeden normalen Eintrag. NULL statt eines leeren +-- Textes, weil "nicht gelöscht" kein Zeitpunkt ist — und weil sich +-- "deleted_at IS NULL" in jeder Abfrage eindeutig liest. +-- +-- Jede lesende Abfrage auf entries braucht ab jetzt diese Bedingung. In Go +-- steht sie als Konstante (store.nichtImPapierkorb), damit sie sich leicht +-- finden lässt; ein Test prüft jeden lesenden Weg. +-- +-- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt +-- (UNIQUE (project_id, slug) gilt weiter). Wer denselben Bezeichner neu +-- schreibt, holt den Eintrag zurück und überschreibt ihn — der Stand aus dem +-- Papierkorb wandert dabei in den Verlauf (siehe PutEntry). +ALTER TABLE entries ADD COLUMN deleted_at TEXT; +ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT ''; + +-- Ein Teilindex nur über die Einträge im Papierkorb. Er bleibt winzig, denn +-- dort liegt fast nie etwas, und bedient die drei Fragen, die nur den +-- Papierkorb betreffen: "was liegt im Papierkorb dieses Projekts" (Seite), +-- "was ist älter als die Frist" (tägliches Aufräumen) und "welche +-- Schlagworte gehören zu gelöschten Einträgen" (ListTags). +-- +-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg +-- dorthin bleibt offen. +CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/internal/store/migrations/006_suchprotokoll.sql b/internal/store/migrations/sqlite/006_suchprotokoll.sql similarity index 100% rename from internal/store/migrations/006_suchprotokoll.sql rename to internal/store/migrations/sqlite/006_suchprotokoll.sql diff --git a/internal/store/migrations/sqlite/007_key_ablauf.sql b/internal/store/migrations/sqlite/007_key_ablauf.sql new file mode 100644 index 0000000..dce2199 --- /dev/null +++ b/internal/store/migrations/sqlite/007_key_ablauf.sql @@ -0,0 +1,13 @@ +-- Ablaufdatum für API-Keys (AUSBAUSTUFEN.md 4.7, Stufe 2). +-- +-- Bei vielen Teams sammeln sich Keys an, die niemand mehr braucht. Die Marke +-- "ungenutzt" (Stufe 1) zeigt sie nur an. Ein Ablaufdatum schließt einen +-- Zugang von selbst: Ab dem Zeitpunkt gilt der Key wie ein zurückgezogener. +-- +-- Leerer Text = läuft nie ab — wie bei revoked_at und last_used_at. Damit +-- behalten alle vorhandenen Keys ihr bisheriges Verhalten, und die Migration +-- muss keine Daten anfassen. Gespeichert wird wie überall RFC3339 in UTC; der +-- Textvergleich mit dem aktuellen Zeitpunkt sortiert dabei richtig. +-- +-- In beiden Datenbanken gleich (ADD COLUMN mit Standardwert können beide). +ALTER TABLE api_keys ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/migrations_control/postgres/001_init.sql b/internal/store/migrations_control/postgres/001_init.sql new file mode 100644 index 0000000..bcaa0dd --- /dev/null +++ b/internal/store/migrations_control/postgres/001_init.sql @@ -0,0 +1,36 @@ +-- PostgreSQL-Fassung. Gegenstück: ../sqlite/001_init.sql. Hinweise zu den +-- getrennten Ordnern siehe migrations/postgres/001_init.sql. + +-- Schema der zentralen Kontroll-Datenbank. +-- +-- Diese Datei ist bewusst von den Workspace-Dateien getrennt (siehe +-- internal/store/migrations) und enthält nur, was gebraucht wird, BEVOR der +-- Workspace bekannt ist: die Liste der Workspaces selbst, und den Weg von +-- einem Key-Hash zu seinem Workspace. +-- +-- Alles andere (Projekte, Einträge, Keys im Volltext, Protokoll) lebt in der +-- jeweiligen Workspace-Datei — siehe AUSBAUSTUFEN.md 4.4. + +-- Ein Workspace ist eine Abteilung. Jede hat ihre eigene Datenbankdatei; diese +-- Tabelle ist nur das Verzeichnis darüber. +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- key_index bildet Key-Hash auf Workspace ab — der einzige Grund, warum diese +-- Datenbank vor dem Öffnen einer Workspace-Datei befragt werden muss. +-- +-- Die Zeile hier ist ein Index, keine Wahrheit über den Key selbst: Name, +-- Rolle, Projekt und der Zeitpunkt der letzten Nutzung stehen ausschließlich +-- in der Workspace-Datei. Wird ein Key dort angelegt oder zurückgezogen, muss +-- diese Tabelle im selben Zug nachgezogen werden (siehe ControlStore.IndexKey +-- und ControlStore.RemoveKeyIndex). +CREATE TABLE key_index ( + hash TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_key_index_workspace ON key_index (workspace_id); diff --git a/internal/store/migrations_control/002_workspace_admins.sql b/internal/store/migrations_control/postgres/002_workspace_admins.sql similarity index 100% rename from internal/store/migrations_control/002_workspace_admins.sql rename to internal/store/migrations_control/postgres/002_workspace_admins.sql diff --git a/internal/store/migrations_control/001_init.sql b/internal/store/migrations_control/sqlite/001_init.sql similarity index 96% rename from internal/store/migrations_control/001_init.sql rename to internal/store/migrations_control/sqlite/001_init.sql index 8d89d82..8c61ffc 100644 --- a/internal/store/migrations_control/001_init.sql +++ b/internal/store/migrations_control/sqlite/001_init.sql @@ -1,3 +1,5 @@ +-- SQLite-Fassung. Gegenstück: ../postgres/001_init.sql. + -- Schema der zentralen Kontroll-Datenbank. -- -- Diese Datei ist bewusst von den Workspace-Dateien getrennt (siehe diff --git a/internal/store/migrations_control/sqlite/002_workspace_admins.sql b/internal/store/migrations_control/sqlite/002_workspace_admins.sql new file mode 100644 index 0000000..36c2acb --- /dev/null +++ b/internal/store/migrations_control/sqlite/002_workspace_admins.sql @@ -0,0 +1,27 @@ +-- Workspace-Admins (AUSBAUSTUFEN.md 4.8). +-- +-- Ein Workspace-Admin verwaltet in genau einem Workspace Projekte und deren +-- Keys — nur über die Weboberfläche, nie über MCP. Er ist damit das Gegenstück +-- zum Plattform-Admin (ADMIN_TOKEN), aber ohne Zugriff auf andere Workspaces, +-- das Schreibbudget oder den Export. +-- +-- Die Tabelle liegt in der Kontroll-Datenbank und nicht in der Workspace-Datei: +-- Beim Anmelden ist der Workspace noch unbekannt, und genau diese Frage — zu +-- welchem Workspace gehört dieses Token? — beantwortet die Kontroll-Datenbank. +-- +-- Wie bei den API-Keys steht hier nur der Hash, nie der Klartext. Ein +-- zurückgezogener Zugang bleibt als Zeile erhalten (revoked_at gesetzt), damit +-- das Protokoll weiterhin sagen kann, wer etwas getan hat. +-- +-- Leerer Text statt NULL für "noch nie benutzt" und "aktiv" — wie bei api_keys. +CREATE TABLE workspace_admins ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_used_at TEXT NOT NULL DEFAULT '', + revoked_at TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_workspace_admins_workspace ON workspace_admins (workspace_id); diff --git a/internal/store/models.go b/internal/store/models.go index 2d53c8a..9cbe579 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -59,6 +59,7 @@ type APIKey struct { CreatedAt time.Time LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt RevokedAt time.Time // Nullzeitpunkt = aktiv + ExpiresAt time.Time // Nullzeitpunkt = läuft nie ab (migrations/007) } // Revoked sagt, ob der Key zurückgezogen wurde. @@ -66,6 +67,28 @@ func (k APIKey) Revoked() bool { return !k.RevokedAt.IsZero() } +// Expired sagt, ob der Key abgelaufen ist. Ein abgelaufener Key gilt wie ein +// zurückgezogener (siehe ResolveKeyHash und sessionFromSubject). +func (k APIKey) Expired(jetzt time.Time) bool { + return !k.ExpiresAt.IsZero() && !jetzt.Before(k.ExpiresAt) +} + +// Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen. +func (k APIKey) Valid(jetzt time.Time) bool { + return !k.Revoked() && !k.Expired(jetzt) +} + +// ExpiresSoonWithin ist die Zeitspanne vor dem Ablauf, in der die Oberfläche +// einen Key als „läuft bald ab" markiert — Zeit genug, einen neuen anzulegen +// und in den Clients auszutauschen. +const ExpiresSoonWithin = 14 * 24 * time.Hour + +// ExpiresSoon sagt, ob ein gültiger Key innerhalb von ExpiresSoonWithin +// abläuft. +func (k APIKey) ExpiresSoon(jetzt time.Time) bool { + return k.Valid(jetzt) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(jetzt) <= ExpiresSoonWithin +} + // UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als // „ungenutzt“ gilt (siehe AUSBAUSTUFEN.md 4.7). const UnusedAfter = 90 * 24 * time.Hour @@ -82,8 +105,10 @@ const UnusedAfter = 90 * 24 * time.Hour // // jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen // Zeitpunkt vorgeben können. +// +// Abgelaufene Keys sind ebenso wenig „ungenutzt" — auch sie öffnen nichts mehr. func (k APIKey) Unused(jetzt time.Time) bool { - return ungenutzt(k.Revoked(), k.LastUsedAt, k.CreatedAt, jetzt) + return ungenutzt(!k.Valid(jetzt), k.LastUsedAt, k.CreatedAt, jetzt) } // ungenutzt ist die gemeinsame Regel hinter APIKey.Unused und @@ -151,6 +176,13 @@ func StaleSince(geaendert, jetzt time.Time) bool { return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter } +// StaleCutoff ist der Zeitpunkt, vor dem ein Eintrag zuletzt geändert worden +// sein muss, um als veraltet zu gelten — dieselbe Grenze wie StaleSince, nur +// als Wert für eine Abfrage. +func StaleCutoff(jetzt time.Time) time.Time { + return jetzt.Add(-StaleAfter) +} + // TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite // braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt. type TrashedEntry struct { diff --git a/internal/store/nutzung.go b/internal/store/nutzung.go index d1863eb..4f394bf 100644 --- a/internal/store/nutzung.go +++ b/internal/store/nutzung.go @@ -336,17 +336,26 @@ type CuratedOverride struct { // 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). + // Je Eintrag zählt nur der jüngste Vorgang, samt Zugang und Begründung + // genau dieser Zeile. ROW_NUMBER() nummeriert die Vorgänge je Eintrag, + // jüngster zuerst; nr = 1 ist dann der jüngste. a.id entscheidet, falls + // zwei Vorgänge denselben Zeitstempel haben. + // + // Früher stand hier MAX(a.ts) mit Spalten ohne Aggregat. Das kann nur + // SQLite (es nimmt die übrigen Spalten dann aus der Zeile mit dem + // Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide + // (SQLite ab 3.25), siehe AUSBAUSTUFEN.md 4.10. 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 + `SELECT entry_slug, title, actor, detail, ts FROM ( + SELECT a.entry_slug, e.title, a.actor, a.detail, a.ts, + ROW_NUMBER() OVER (PARTITION BY a.entry_slug ORDER BY a.ts DESC, a.id DESC) AS nr + 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 + ) AS jeweils_juengster + WHERE nr = 1 + ORDER BY ts DESC LIMIT ?`, projectID, ActionEntryWrite, DetailCuratedOverridden+"%", limit, ) diff --git a/internal/store/nutzung_test.go b/internal/store/nutzung_test.go index 8955cc0..83a9503 100644 --- a/internal/store/nutzung_test.go +++ b/internal/store/nutzung_test.go @@ -169,6 +169,52 @@ func TestCuratedOverrideDetailKuerzt(t *testing.T) { } } +// TestCuratedOverridesJuengsterVorgang prüft, dass bei mehreren Änderungen an +// einem Eintrag Zugang und Begründung der JÜNGSTEN Änderung erscheinen — und +// der Eintrag nur einmal. +// +// Früher hing das an einer Eigenheit von SQLite (MAX() mit Spalten ohne +// Aggregat), die PostgreSQL nicht kennt. Die Zeitstempel sind hier fest +// gesetzt, damit die Reihenfolge eindeutig ist. +func TestCuratedOverridesJuengsterVorgang(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "vpn", Title: "VPN", Content: "x", Source: SourceAgent}); err != nil { + t.Fatal(err) + } + + jetzt := time.Now().UTC() + for _, v := range []struct { + alter time.Duration + zugang, grund string + }{ + {2 * time.Hour, "agent-alt", "alter Grund"}, + {1 * time.Minute, "agent-neu", "neuer Grund"}, + {1 * time.Hour, "agent-mitte", "mittlerer Grund"}, + } { + if _, err := s.schreiben.ExecContext(ctx, + `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) + VALUES (?, ?, ?, '', ?, ?, 'vpn', ?, '', ?)`, + NewID(), jetzt.Add(-v.alter).Format(time.RFC3339Nano), p.ID, v.zugang, + ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.grund)); err != nil { + t.Fatal(err) + } + } + + liste, err := s.CuratedOverrides(ctx, p.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(liste) != 1 { + t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(liste), liste) + } + if liste[0].Actor != "agent-neu" || liste[0].Reason != "neuer Grund" { + t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", liste[0]) + } +} + // TestCuratedOverridesOhneGeloeschte prüft, dass Einträge im Papierkorb nicht // zur Prüfung erscheinen — es gibt dort nichts mehr zu prüfen. func TestCuratedOverridesOhneGeloeschte(t *testing.T) { diff --git a/internal/store/papierkorb.go b/internal/store/papierkorb.go index 695b969..f6e1fa9 100644 --- a/internal/store/papierkorb.go +++ b/internal/store/papierkorb.go @@ -75,7 +75,7 @@ func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryI var geloescht []string jetzt := NowString() - err := s.tx(ctx, func(tx *sql.Tx) error { + err := s.tx(ctx, func(tx *transaktion) error { for _, id := range entryIDs { // RETURNING liefert die Spalte der geänderten Zeile gleich mit — // so braucht es keine zweite Abfrage, um den Bezeichner zu holen. @@ -148,7 +148,7 @@ func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ( func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) { var zurueck []Entry - err := s.tx(ctx, func(tx *sql.Tx) error { + err := s.tx(ctx, func(tx *transaktion) error { for _, slug := range slugs { res, err := tx.ExecContext(ctx, `UPDATE entries SET deleted_at = NULL, deleted_by = '' @@ -190,7 +190,7 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs [] // wenn jemand seinen Bezeichner ins Formular schmuggelt. func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) { geloescht := 0 - err := s.tx(ctx, func(tx *sql.Tx) error { + err := s.tx(ctx, func(tx *transaktion) error { for _, slug := range slugs { res, err := tx.ExecContext(ctx, `DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`, @@ -219,7 +219,7 @@ func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []st func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) { entfernt := map[string][]string{} - err := s.tx(ctx, func(tx *sql.Tx) error { + err := s.tx(ctx, func(tx *transaktion) error { // Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg. rows, err := tx.QueryContext(ctx, `SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls) diff --git a/internal/store/postgres_test.go b/internal/store/postgres_test.go new file mode 100644 index 0000000..c8df0a2 --- /dev/null +++ b/internal/store/postgres_test.go @@ -0,0 +1,418 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "sync" + "testing" +) + +// ── PostgreSQL in den Tests ────────────────────────────────────────────────── +// +// Ist MUNIN_TEST_PG gesetzt, laufen testStore und testControl — und damit +// fast alle Store-Tests — gegen PostgreSQL statt gegen SQLite. Ohne die +// Variable wird nur SQLite getestet, und `go test ./...` braucht kein Docker. +// +// MUNIN_TEST_PG='postgres://munin:munin@localhost:5432/munin_test?sslmode=disable' \ +// go test ./... +// +// Jeder Test bekommt ein eigenes, frisch angelegtes Schema und löscht es am +// Ende wieder. Die Tests stören sich also nicht gegenseitig und hinterlassen +// nichts. Trotzdem gehört MUNIN_TEST_PG auf eine eigene Datenbank +// (munin_test, legt docker-compose.dev.yml an), nicht auf die, mit der man +// Munin lokal ausprobiert. + +const pgTestVariable = "MUNIN_TEST_PG" + +// pgTestAktiv sagt, ob die Tests gegen PostgreSQL laufen sollen. +func pgTestAktiv() bool { return os.Getenv(pgTestVariable) != "" } + +var ( + pgTestOnce sync.Once + pgTestSpeicher *PostgresSpeicher + pgTestFehler error +) + +// testPostgres liefert den gemeinsamen Speicher aller PostgreSQL-Tests dieses +// Laufs, oder überspringt den Test, wenn MUNIN_TEST_PG nicht gesetzt ist. +// +// Ein gemeinsamer Speicher statt einem je Test: Er hält eine +// Verwaltungsverbindung offen, und die braucht es nur einmal. +func testPostgres(t *testing.T) *PostgresSpeicher { + t.Helper() + if !pgTestAktiv() { + t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable) + } + pgTestOnce.Do(func() { + pgTestSpeicher, pgTestFehler = NewPostgres(context.Background(), os.Getenv(pgTestVariable)) + }) + if pgTestFehler != nil { + t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestFehler) + } + return pgTestSpeicher +} + +// testPostgresStore öffnet einen Workspace in einem frischen Schema und löscht +// es nach dem Test. +func testPostgresStore(t *testing.T) *Store { + t.Helper() + sp := testPostgres(t) + + id := NewID() + s, err := sp.OpenWorkspace(id) + if err != nil { + t.Fatalf("Workspace-Schema konnte nicht angelegt werden: %v", err) + } + t.Cleanup(func() { + s.Close() + if err := sp.DeleteWorkspace(id); err != nil { + t.Errorf("Aufräumen: %v", err) + } + }) + return s +} + +// testPostgresControl öffnet eine Kontroll-Datenbank in einem eigenen Schema +// (statt munin_control) und räumt danach auf — auch die Schemas der +// Workspaces, die der Test darüber angelegt hat. +func testPostgresControl(t *testing.T) *ControlStore { + t.Helper() + sp := testPostgres(t) + + // Eigener Speicher mit eigenem Kontroll-Schema, aber derselben + // Verwaltungsverbindung. + schema := "munin_test_control_" + strings.ReplaceAll(NewID(), "-", "_") + eigener := *sp + eigener.controlSchema = schema + ctx := context.Background() + if err := eigener.legeSchemaAn(ctx, schema); err != nil { + t.Fatal(err) + } + + c, err := eigener.OpenControl() + if err != nil { + t.Fatalf("Kontroll-Schema konnte nicht angelegt werden: %v", err) + } + + // Jeden angelegten Workspace mitschreiben. Die Liste aus der + // Kontroll-Datenbank reicht zum Aufräumen nicht: Ein Test, der dort einen + // Workspace löscht, ließe sonst dessen Schema liegen. + merker := &angelegteWorkspaces{PostgresSpeicher: &eigener} + c.haken = merker + + t.Cleanup(func() { + for _, id := range merker.ids { + if err := sp.DeleteWorkspace(id); err != nil { + t.Errorf("Aufräumen: %v", err) + } + } + c.Close() + if _, err := sp.admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil { + t.Errorf("Aufräumen: %v", err) + } + }) + return c +} + +// angelegteWorkspaces reicht die Haken an den Speicher weiter und merkt sich +// dabei jeden Workspace, für den ein Schema angelegt wurde. +type angelegteWorkspaces struct { + *PostgresSpeicher + mu sync.Mutex + ids []string +} + +func (a *angelegteWorkspaces) nachDemBenennen(ctx context.Context, workspaceID, name string) error { + a.mu.Lock() + if !slices.Contains(a.ids, workspaceID) { + a.ids = append(a.ids, workspaceID) + } + a.mu.Unlock() + return a.PostgresSpeicher.nachDemBenennen(ctx, workspaceID, name) +} + +// ── Tests, die nur mit PostgreSQL laufen ───────────────────────────────────── + +// TestSchemaGleich prüft, dass die Migrationen beider Ordner dasselbe Schema +// ergeben: dieselben Tabellen, Spalten, NOT NULL, Standardwerte und Indizes. +// +// TestMigrationsordnerGleich stellt nur sicher, dass beide Ordner dieselben +// Dateien haben. Erst dieser Test merkt, wenn eine Spalte auf einer Seite +// anders angelegt wurde — siehe AUSBAUSTUFEN.md 4.10. +func TestSchemaGleich(t *testing.T) { + ctx := context.Background() + + t.Run("Workspace", func(t *testing.T) { + sqlite, err := Open(filepath.Join(t.TempDir(), "vergleich.db")) + if err != nil { + t.Fatal(err) + } + defer sqlite.Close() + pg := testPostgresStore(t) + + vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen)) + }) + + t.Run("Kontrolle", func(t *testing.T) { + sqlite, err := OpenControl(filepath.Join(t.TempDir(), "vergleich.db")) + if err != nil { + t.Fatal(err) + } + defer sqlite.Close() + pg := testPostgresControl(t) + + vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen)) + }) +} + +// TestPostgresSchemaJeWorkspace prüft den Weg eines Workspace durch +// PostgreSQL: Anlegen erzeugt ein Schema mit dem Namen als Kommentar, +// Umbenennen zieht den Kommentar nach, Löschen entfernt das Schema. +func TestPostgresSchemaJeWorkspace(t *testing.T) { + sp := testPostgres(t) + c := testPostgresControl(t) + ctx := context.Background() + + kommentar := func(schema string) (string, bool) { + var text sql.NullString + err := sp.admin.QueryRowContext(ctx, + `SELECT obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname = $1`, schema, + ).Scan(&text) + if errors.Is(err, sql.ErrNoRows) { + return "", false + } + if err != nil { + t.Fatal(err) + } + return text.String, true + } + + w, err := c.CreateWorkspace(ctx, "Vertrieb & Co's") + if err != nil { + t.Fatal(err) + } + schema, _ := WorkspaceSchema(w.ID) + if ort := sp.Ort(w.ID); ort != schema { + t.Errorf("Ort = %q, erwartet das Schema %q", ort, schema) + } + + if text, da := kommentar(schema); !da || text != "Vertrieb & Co's" { + t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", da, text) + } + + if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil { + t.Fatal(err) + } + if text, _ := kommentar(schema); text != "Vertrieb Nord" { + t.Errorf("nach dem Umbenennen: Kommentar=%q", text) + } + + // Der Workspace lässt sich öffnen und benutzen. + s, err := sp.OpenWorkspace(w.ID) + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateProject(ctx, w.ID, "p", "P", ""); err != nil { + t.Fatal(err) + } + s.Close() + + if err := sp.DeleteWorkspace(w.ID); err != nil { + t.Fatal(err) + } + if _, da := kommentar(schema); da { + t.Error("Schema ist nach DeleteWorkspace noch da") + } +} + +// TestPostgresWorkspacesGetrennt: Was in einem Workspace-Schema steht, ist im +// anderen nicht zu sehen — dieselbe Abschottung wie mit getrennten Dateien. +func TestPostgresWorkspacesGetrennt(t *testing.T) { + a := testPostgresStore(t) + b := testPostgresStore(t) + ctx := context.Background() + + if _, err := a.CreateProject(ctx, "ws-a", "nur-in-a", "Nur in A", ""); err != nil { + t.Fatal(err) + } + if _, err := b.ProjectBySlug(ctx, "ws-a", "nur-in-a"); !errors.Is(err, ErrNotFound) { + t.Errorf("Projekt aus Workspace A über Workspace B: err=%v, erwartet ErrNotFound", err) + } +} + +// TestPostgresKapazitaet: Zu viele Workspaces für max_connections werden mit +// einer Meldung abgelehnt, die die nötige Zahl nennt. +func TestPostgresKapazitaet(t *testing.T) { + sp := testPostgres(t) + ctx := context.Background() + + if err := sp.PruefeKapazitaet(ctx, 1); err != nil { + t.Errorf("ein Workspace sollte immer passen: %v", err) + } + err := sp.PruefeKapazitaet(ctx, 100000) + if err == nil || !strings.Contains(err.Error(), "max_connections auf mindestens") { + t.Errorf("Fehler = %v, erwartet eine Meldung mit der nötigen Zahl", err) + } +} + +// ── Schema auslesen und vergleichen ────────────────────────────────────────── + +// schemaBild ist das ausgelesene Schema einer Datenbank, so aufbereitet, dass +// SQLite und PostgreSQL vergleichbar werden. Jede Spalte wird zu einem Text +// wie "text NOT NULL DEFAULT 0". +type schemaBild struct { + spalten map[string]string // "tabelle.spalte" → Beschreibung + indizes []string // eigene Indizes (idx_…), sortiert +} + +// typKlasse fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite) +// und integer/bigint (PostgreSQL) sind für Munin dasselbe: eine Ganzzahl. +func typKlasse(typ string) string { + switch strings.ToLower(typ) { + case "integer", "bigint", "int", "int8", "int4": + return "ganzzahl" + case "text": + return "text" + } + return "?" + typ +} + +// standardwert gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte +// den Typ an (leerer Text samt "::text"), SQLite nicht. +var textTypAngabe = regexp.MustCompile(`::[a-z ]+$`) + +func standardwert(wert sql.NullString) string { + if !wert.Valid { + return "" + } + return " DEFAULT " + textTypAngabe.ReplaceAllString(wert.String, "") +} + +func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaBild { + t.Helper() + bild := schemaBild{spalten: map[string]string{}} + + tabellen := abfrageListe(ctx, t, p, + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) + for _, tabelle := range tabellen { + rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, tabelle) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var name, typ string + var nichtNull, pk int + var standard sql.NullString + if err := rows.Scan(&name, &typ, &nichtNull, &standard, &pk); err != nil { + t.Fatal(err) + } + // Ein Primärschlüssel ist in PostgreSQL immer NOT NULL, in SQLite + // steht es nur dabei, wenn es ausdrücklich geschrieben wurde. + bild.spalten[tabelle+"."+name] = spaltenText(typ, nichtNull == 1 || pk > 0, standard) + } + rows.Close() + } + + // Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen + // für PRIMARY KEY und UNIQUE nicht. + bild.indizes = abfrageListe(ctx, t, p, + `SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name`) + return bild +} + +func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaBild { + t.Helper() + bild := schemaBild{spalten: map[string]string{}} + + rows, err := p.QueryContext(ctx, + `SELECT table_name, column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = current_schema()`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var tabelle, name, typ, nullbar string + var standard sql.NullString + if err := rows.Scan(&tabelle, &name, &typ, &nullbar, &standard); err != nil { + t.Fatal(err) + } + bild.spalten[tabelle+"."+name] = spaltenText(typ, nullbar == "NO", standard) + } + rows.Close() + + // Die Indizes hinter PRIMARY KEY und UNIQUE benennt PostgreSQL selbst + // (…_pkey, …_key). Verglichen werden nur die eigenen, die mit idx_ beginnen + // — so heißen sie in den Migrationen. + bild.indizes = abfrageListe(ctx, t, p, + `SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE 'idx\_%' ORDER BY indexname`) + return bild +} + +func spaltenText(typ string, nichtNull bool, standard sql.NullString) string { + text := typKlasse(typ) + if nichtNull { + text += " NOT NULL" + } + return text + standardwert(standard) +} + +func abfrageListe(ctx context.Context, t *testing.T, p pool, abfrage string) []string { + t.Helper() + rows, err := p.QueryContext(ctx, abfrage) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var liste []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatal(err) + } + liste = append(liste, s) + } + return liste +} + +func vergleicheSchema(t *testing.T, sqlite, postgres schemaBild) { + t.Helper() + + alle := map[string]bool{} + for k := range sqlite.spalten { + alle[k] = true + } + for k := range postgres.spalten { + alle[k] = true + } + var namen []string + for k := range alle { + namen = append(namen, k) + } + sort.Strings(namen) + + for _, name := range namen { + s, inSQLite := sqlite.spalten[name] + p, inPostgres := postgres.spalten[name] + switch { + case !inSQLite: + t.Errorf("%s gibt es nur in PostgreSQL (%s)", name, p) + case !inPostgres: + t.Errorf("%s gibt es nur in SQLite (%s)", name, s) + case s != p: + t.Errorf("%s unterscheidet sich:\n sqlite: %s\n postgres: %s", name, s, p) + } + } + + if fmt.Sprint(sqlite.indizes) != fmt.Sprint(postgres.indizes) { + t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indizes, postgres.indizes) + } +} diff --git a/internal/store/speicher.go b/internal/store/speicher.go new file mode 100644 index 0000000..05c2248 --- /dev/null +++ b/internal/store/speicher.go @@ -0,0 +1,79 @@ +package store + +import "context" + +// Speicher entscheidet, WO die Daten liegen: in SQLite-Dateien oder in einer +// PostgreSQL-Datenbank (AUSBAUSTUFEN.md 4.10). +// +// main.go wählt beim Start anhand von DB_DRIVER genau einen Speicher aus und +// reicht ihn weiter. Alles danach — ControlStore, Store, Suchindex, Handler — +// weiß nicht und muss nicht wissen, welcher es ist. +// +// # Wie die beiden Speicher die Daten aufteilen +// +// SQLite PostgreSQL +// Kontroll-Datenbank /control.db Schema munin_control +// je Workspace workspaces/.db Schema munin_ws_ +// +// In beiden Fällen bekommt jeder Workspace seinen eigenen, abgeschotteten +// Bereich mit denselben Tabellen. +// Kapazitaet beschreibt, wie viele Datenbankverbindungen Munin belegen darf. +// Die Rechnung dahinter steht bei PruefeKapazitaet (speicher_postgres.go). +type Kapazitaet struct { + Begrenzt bool // false: keine gemeinsame Grenze (SQLite) + Erlaubt int // max_connections des Servers + Reserve int // bleibt frei für psql, pg_dump, Superuser + JeWorkspace int // so viele belegt ein geöffneter Workspace höchstens + Fest int // Kontroll-Datenbank und Verwaltungsverbindung +} + +// MaxWorkspaces ist die Zahl der Workspaces, die mit dieser Grenze höchstens +// gleichzeitig geöffnet sein können. +func (k Kapazitaet) MaxWorkspaces() int { + if !k.Begrenzt || k.JeWorkspace == 0 { + return 0 + } + return (k.Erlaubt - k.Reserve - k.Fest) / k.JeWorkspace +} + +type Speicher interface { + // OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den + // aktuellen Stand. + OpenControl() (*ControlStore, error) + + // OpenWorkspace öffnet den Bereich eines Workspace. Gibt es ihn noch + // nicht, wird er angelegt. + OpenWorkspace(workspaceID string) (*Store, error) + + // DeleteWorkspace entfernt den Bereich eines Workspace unwiderruflich. + // Der Store dazu muss vorher geschlossen sein (siehe workspace.Manager). + DeleteWorkspace(workspaceID string) error + + // PruefeKapazitaet meldet einen Fehler, wenn anzahl Workspaces mehr + // Verbindungen bräuchten, als die Datenbank erlaubt. Bei SQLite gibt es + // diese Grenze nicht. + PruefeKapazitaet(ctx context.Context, anzahl int) error + + // Kapazitaet beschreibt die Grenze der Datenbankverbindungen — für die + // Systemseite. Bei SQLite gibt es keine (Begrenzt = false). + Kapazitaet(ctx context.Context) (Kapazitaet, error) + + // Ort nennt, wo die Daten eines Workspace liegen: den Schemanamen + // (PostgreSQL) bzw. die Datei (SQLite). Für die Workspace-Übersicht des + // Plattform-Admins, damit sich ein Workspace in der Datenbank wiederfinden + // lässt. + Ort(workspaceID string) string + + // Hinweise nennt Auffälligkeiten des Speichers, die den Betrieb nicht + // verhindern, aber behoben werden sollten — für die Systemseite. Leer, + // wenn alles in Ordnung ist. + Hinweise() []string + + // Beschreibung sagt für das Startprotokoll, wo die Daten liegen — ohne + // Passwort. + Beschreibung() string + + // Close gibt alles frei, was der Speicher selbst offen hält (nicht die + // geöffneten Stores — die schließt ihr jeweiliger Besitzer). + Close() error +} diff --git a/internal/store/speicher_postgres.go b/internal/store/speicher_postgres.go new file mode 100644 index 0000000..c374b18 --- /dev/null +++ b/internal/store/speicher_postgres.go @@ -0,0 +1,406 @@ +package store + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" +) + +// ── Verbindungen ───────────────────────────────────────────────────────────── +// +// PostgreSQL erlaubt nur eine feste Zahl gleichzeitiger Verbindungen +// (max_connections, Standard 100). Würde Munin mehr öffnen, lehnte PostgreSQL +// neue Verbindungen ab und Anfragen schlügen fehl. Deshalb gelten hier feste, +// kleine Pools, und Munin rechnet vor dem Start und vor jedem neuen Workspace +// nach, ob sie reichen (PruefeKapazitaet). Hintergrund und Rechnung: +// AUSBAUSTUFEN.md 4.10, „Verbindungen fest begrenzt“. +// +// Kleine Pools reichen, weil eine Verbindung nur für die Dauer einer Abfrage +// belegt ist — Millisekunden. Während ein Agent nachdenkt, hält Munin keine. +// Ist ein Pool voll, wartet die Anfrage in Go auf eine freie Verbindung (bis +// zum Ende ihres Kontexts), statt in PostgreSQL zu scheitern. +const ( + // pgLeseverbindungen je Workspace (und für die Kontroll-Datenbank). + pgLeseverbindungen = 2 + + // pgSchreibverbindungen je Workspace. Vorerst 1, also dasselbe Verhalten + // wie unter SQLite: Schreibvorgänge eines Workspace laufen nacheinander. + // Mehr erst, wenn die schreibenden Methoden gegen gleichzeitiges Schreiben + // abgesichert sind (AUSBAUSTUFEN.md 4.10, Schritt 4) — sonst kann z.B. + // PutEntry unter PostgreSQL Änderungen verlieren. + pgSchreibverbindungen = 1 + + // pgVerbindungenJeWorkspace ist, was ein geöffneter Workspace höchstens + // belegt. + pgVerbindungenJeWorkspace = pgLeseverbindungen + pgSchreibverbindungen + + // pgFesteVerbindungen: die Kontroll-Datenbank (wie ein Workspace) plus + // eine Verwaltungsverbindung zum Anlegen und Löschen von Schemas. + pgFesteVerbindungen = pgVerbindungenJeWorkspace + 1 + + // pgReserve bleibt frei — für psql, Backups (pg_dump) und die + // Verbindungen, die PostgreSQL selbst für Superuser zurückhält. + pgReserve = 10 + + // pgLeerlauf: Nach so langer Ruhe schließt ein Pool seine Verbindungen. + // Ein Workspace, den gerade niemand benutzt, belegt dann keine. + pgLeerlauf = 2 * time.Minute + + // Beim Start ist PostgreSQL vielleicht noch nicht bereit (der Container + // fährt gerade hoch). Munin versucht es so oft, mit dieser Pause dazwischen. + pgStartVersuche = 15 + pgStartPause = 2 * time.Second +) + +// ── Schemanamen ────────────────────────────────────────────────────────────── + +// Alles von Munin liegt in Schemas mit dem Präfix "munin_" — auf einen Blick +// erkennbar (\dn munin_*), auch wenn in derselben Datenbank andere +// Anwendungen liegen. Begründung: AUSBAUSTUFEN.md 4.10, „Schemanamen aus der +// Workspace-ID“. +const ( + ControlSchema = "munin_control" + workspaceSchemaPraefix = "munin_ws_" +) + +// erlaubteWorkspaceID passt auf IDs, wie NewID sie erzeugt (Kleinbuchstaben, +// Ziffern, Bindestrich). Alles andere wird abgelehnt, bevor es in einen +// Schemanamen wandert. +var erlaubteWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`) + +// WorkspaceSchema liefert den Schemanamen eines Workspace, z.B. +// "munin_ws_mue9bh3x_1c0e1120". +// +// Die ID statt des Namens, weil sich Namen ändern können und freier Text sind. +// "-" wird zu "_", weil ein Bindestrich in PostgreSQL überall Anführungszeichen +// erzwingen würde. Den lesbaren Namen trägt ein Kommentar am Schema (siehe +// nachDemBenennen). +func WorkspaceSchema(workspaceID string) (string, error) { + if !erlaubteWorkspaceID.MatchString(workspaceID) { + return "", fmt.Errorf("Workspace-ID %q ist als Schemaname nicht geeignet", workspaceID) + } + name := workspaceSchemaPraefix + strings.ReplaceAll(workspaceID, "-", "_") + + // PostgreSQL kürzt längere Namen stillschweigend auf 63 Byte. Zwei lange + // IDs könnten dann im selben Schema landen — lieber gleich ablehnen. + if len(name) > 63 { + return "", fmt.Errorf("Workspace-ID %q ergibt einen zu langen Schemanamen", workspaceID) + } + return name, nil +} + +// ── Speicher ───────────────────────────────────────────────────────────────── + +// PostgresSpeicher legt die Kontroll-Datenbank und jeden Workspace als eigenes +// Schema in einer PostgreSQL-Datenbank ab. +// +// Jedes Schema bekommt eigene Pools, deren Verbindungen mit +// search_path= starten. Dadurch laufen dieselben Abfragen wie unter +// SQLite unverändert — "SELECT … FROM entries" trifft automatisch die Tabelle +// im richtigen Schema, und ein Workspace kann die Tabellen eines anderen gar +// nicht erst sehen. +type PostgresSpeicher struct { + basis *pgx.ConnConfig // aus DATABASE_URL; je Schema kopiert und ergänzt + admin *sql.DB // eine Verbindung ohne search_path, für CREATE/DROP SCHEMA + + // controlSchema ist im Betrieb immer ControlSchema. Nur Tests setzen ein + // eigenes, damit sie die Kontroll-Datenbank eines Entwicklers nicht + // anfassen (siehe postgres_test.go). + controlSchema string + + // hinweise sammelt beim Start, was nicht zum Abbruch reicht, aber auf die + // Systemseite gehört (siehe Hinweise). + hinweise []string +} + +// NewPostgres verbindet sich mit der Datenbank aus url, wartet nötigenfalls, +// bis sie bereit ist, und legt das Schema der Kontroll-Datenbank an. +func NewPostgres(ctx context.Context, url string) (*PostgresSpeicher, error) { + basis, err := pgx.ParseConfig(url) + if err != nil { + // pgx ersetzt das Passwort in der Fehlermeldung durch "xxxxx". + return nil, fmt.Errorf("DATABASE_URL ist ungültig: %w", err) + } + + admin := stdlib.OpenDB(*basis) + admin.SetMaxOpenConns(1) + admin.SetConnMaxIdleTime(pgLeerlauf) + + p := &PostgresSpeicher{basis: basis, admin: admin, controlSchema: ControlSchema} + + if err := p.warteAufDatenbank(ctx); err != nil { + admin.Close() + return nil, err + } + + p.pruefeSortierung(ctx) + + if err := p.legeSchemaAn(ctx, p.controlSchema); err != nil { + admin.Close() + return nil, err + } + return p, nil +} + +// warteAufDatenbank versucht mehrmals, die Datenbank zu erreichen. +// +// Mit SQLite ist die Datei immer da. PostgreSQL läuft dagegen als eigener +// Dienst und ist nach einem gemeinsamen Start (docker compose up) oft ein paar +// Sekunden später bereit als Munin. Ohne Wiederholung bräche Munin dann sofort +// ab. +func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error { + var letzterFehler error + for versuch := 1; versuch <= pgStartVersuche; versuch++ { + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + letzterFehler = p.admin.PingContext(pingCtx) + cancel() + if letzterFehler == nil { + return nil + } + + if versuch < pgStartVersuche { + log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", versuch, pgStartVersuche, letzterFehler) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pgStartPause): + } + } + } + return fmt.Errorf("PostgreSQL ist nicht erreichbar (%s): %w", p.Beschreibung(), letzterFehler) +} + +// pruefeSortierung warnt, wenn die Datenbank nicht mit der Sortierung "C" +// angelegt wurde. +// +// SQLite sortiert Text Byte für Byte. PostgreSQL sortiert nach der +// Spracheinstellung der Datenbank, und bei en_US/de_DE werden Bindestriche +// zunächst übergangen — "a-c" stünde dann vor "ab". Listen wären anders +// sortiert als unter SQLite. Kein Grund, den Start zu verweigern, aber einer +// für einen deutlichen Hinweis. +func (p *PostgresSpeicher) pruefeSortierung(ctx context.Context) { + var sortierung string + err := p.admin.QueryRowContext(ctx, + `SELECT datcollate FROM pg_database WHERE datname = current_database()`, + ).Scan(&sortierung) + if err != nil { + log.Printf("Hinweis: Sortierung der Datenbank konnte nicht geprüft werden: %v", err) + return + } + if sortierung != "C" && sortierung != "POSIX" { + hinweis := fmt.Sprintf("Die Datenbank sortiert nach %q statt \"C\". Listen sind dann anders sortiert als unter SQLite. "+ + "Abhilfe: Datenbank mit --locale=C anlegen (siehe AUSBAUSTUFEN.md 4.10).", sortierung) + log.Printf("Warnung: %s", hinweis) + p.hinweise = append(p.hinweise, hinweis) + } +} + +// Hinweise nennt, was beim Start auffiel, etwa eine falsche Sortierung. Die +// Liste entsteht einmal in NewPostgres: Die Sortierung einer Datenbank lässt +// sich nachträglich nicht ändern, ein erneutes Nachfragen brächte nichts. +func (p *PostgresSpeicher) Hinweise() []string { return p.hinweise } + +// OpenControl öffnet die Kontroll-Datenbank im Schema munin_control. +// +// Der ControlStore bekommt den Speicher als Haken mit: Er prüft vor jedem neuen +// Workspace die Verbindungen und legt danach dessen Schema samt lesbarem Namen +// an (siehe vorDemAnlegen, nachDemBenennen). +func (p *PostgresSpeicher) OpenControl() (*ControlStore, error) { + lesen, schreiben, err := p.oeffnePools(p.controlSchema, controlMigrationsFS, "migrations_control") + if err != nil { + return nil, err + } + return &ControlStore{lesen: lesen, schreiben: schreiben, haken: p}, nil +} + +// OpenWorkspace öffnet das Schema eines Workspace und legt es an, falls es +// noch fehlt. +func (p *PostgresSpeicher) OpenWorkspace(workspaceID string) (*Store, error) { + schema, err := WorkspaceSchema(workspaceID) + if err != nil { + return nil, err + } + if err := p.legeSchemaAn(context.Background(), schema); err != nil { + return nil, err + } + + lesen, schreiben, err := p.oeffnePools(schema, migrationsFS, "migrations") + if err != nil { + return nil, err + } + return &Store{lesen: lesen, schreiben: schreiben}, nil +} + +// DeleteWorkspace löscht das Schema eines Workspace samt aller Tabellen und +// Daten darin. +func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error { + schema, err := WorkspaceSchema(workspaceID) + if err != nil { + return err + } + if _, err := p.admin.ExecContext(context.Background(), + `DROP SCHEMA IF EXISTS `+pgx.Identifier{schema}.Sanitize()+` CASCADE`, + ); err != nil { + return fmt.Errorf("Schema %s konnte nicht gelöscht werden: %w", schema, err) + } + return nil +} + +// Kapazitaet liest max_connections und nennt die Größen, mit denen +// PruefeKapazitaet rechnet. +func (p *PostgresSpeicher) Kapazitaet(ctx context.Context) (Kapazitaet, error) { + erlaubt, err := p.maxConnections(ctx) + if err != nil { + return Kapazitaet{}, err + } + return Kapazitaet{ + Begrenzt: true, Erlaubt: erlaubt, Reserve: pgReserve, + JeWorkspace: pgVerbindungenJeWorkspace, Fest: pgFesteVerbindungen, + }, nil +} + +// maxConnections liest max_connections des Servers. +func (p *PostgresSpeicher) maxConnections(ctx context.Context) (int, error) { + var text string + if err := p.admin.QueryRowContext(ctx, `SHOW max_connections`).Scan(&text); err != nil { + return 0, fmt.Errorf("max_connections konnte nicht gelesen werden: %w", err) + } + erlaubt, err := strconv.Atoi(text) + if err != nil { + return 0, fmt.Errorf("max_connections hat einen unerwarteten Wert %q", text) + } + return erlaubt, nil +} + +// PruefeKapazitaet rechnet nach, ob anzahl Workspaces mit ihren Pools unter +// max_connections bleiben (abzüglich Reserve). +// +// max_connections wird jedes Mal frisch gelesen: Wer es nach einer Meldung +// erhöht und PostgreSQL neu startet, soll Munin dafür nicht auch neu starten +// müssen. +func (p *PostgresSpeicher) PruefeKapazitaet(ctx context.Context, anzahl int) error { + erlaubt, err := p.maxConnections(ctx) + if err != nil { + return err + } + + bedarf := anzahl*pgVerbindungenJeWorkspace + pgFesteVerbindungen + if bedarf > erlaubt-pgReserve { + return fmt.Errorf("zu wenige PostgreSQL-Verbindungen: %d Workspaces brauchen bis zu %d Verbindungen "+ + "(%d je Workspace + %d fest), max_connections ist %d (davon %d Reserve). "+ + "max_connections auf mindestens %d erhöhen", + anzahl, bedarf, pgVerbindungenJeWorkspace, pgFesteVerbindungen, erlaubt, pgReserve, bedarf+pgReserve) + } + return nil +} + +// Ort nennt das Schema des Workspace, z.B. "munin_ws_mue9bh3x_1c0e1120". +// Eine ID, die keinen gültigen Schemanamen ergibt, kann es hier nicht geben +// (OpenWorkspace hätte sie abgelehnt); falls doch, steht der Grund da. +func (p *PostgresSpeicher) Ort(workspaceID string) string { + schema, err := WorkspaceSchema(workspaceID) + if err != nil { + return err.Error() + } + return schema +} + +// Beschreibung nennt Server und Datenbank, nie das Passwort. +func (p *PostgresSpeicher) Beschreibung() string { + return fmt.Sprintf("PostgreSQL — %s:%d/%s (Schemas %s, %s…)", + p.basis.Host, p.basis.Port, p.basis.Database, ControlSchema, workspaceSchemaPraefix) +} + +// Close schließt die Verwaltungsverbindung. +func (p *PostgresSpeicher) Close() error { return p.admin.Close() } + +// ── Haken für den ControlStore ─────────────────────────────────────────────── + +func (p *PostgresSpeicher) vorDemAnlegen(ctx context.Context, anzahl int) error { + return p.PruefeKapazitaet(ctx, anzahl) +} + +// nachDemBenennen legt das Schema des Workspace an (falls noch nicht da) und +// schreibt den lesbaren Namen als Kommentar daran. In psql zeigt \dn+ ihn +// neben dem Schemanamen. +func (p *PostgresSpeicher) nachDemBenennen(ctx context.Context, workspaceID, name string) error { + schema, err := WorkspaceSchema(workspaceID) + if err != nil { + return err + } + if err := p.legeSchemaAn(ctx, schema); err != nil { + return err + } + + // COMMENT ON nimmt keine Platzhalter ($1) an, der Name muss also als + // Text in die Anweisung. textLiteral sorgt dafür, dass er dort nichts + // anderes sein kann als Text. + if _, err := p.admin.ExecContext(ctx, + `COMMENT ON SCHEMA `+pgx.Identifier{schema}.Sanitize()+` IS `+textLiteral(name), + ); err != nil { + return fmt.Errorf("Name konnte nicht am Schema %s vermerkt werden: %w", schema, err) + } + return nil +} + +// ── Hilfsfunktionen ────────────────────────────────────────────────────────── + +func (p *PostgresSpeicher) legeSchemaAn(ctx context.Context, schema string) error { + if _, err := p.admin.ExecContext(ctx, + `CREATE SCHEMA IF NOT EXISTS `+pgx.Identifier{schema}.Sanitize(), + ); err != nil { + return fmt.Errorf("Schema %s konnte nicht angelegt werden: %w", schema, err) + } + return nil +} + +// oeffnePools öffnet Lese- und Schreibpool für ein Schema und bringt dessen +// Tabellen auf den aktuellen Stand — das Gegenstück zu openSQLite. +func (p *PostgresSpeicher) oeffnePools(schema string, fsys embed.FS, migrationsDir string) (lesen, schreiben pool, err error) { + d := postgresDialekt{} + + // Jede Verbindung dieser Pools startet mit search_path=. Kopie, + // damit die Grundeinstellung für die anderen Schemas unverändert bleibt. + cfg := p.basis.Copy() + cfg.RuntimeParams["search_path"] = schema + + oeffne := func(maxVerbindungen int) *sql.DB { + db := stdlib.OpenDB(*cfg) + db.SetMaxOpenConns(maxVerbindungen) + db.SetMaxIdleConns(maxVerbindungen) + db.SetConnMaxIdleTime(pgLeerlauf) + return db + } + + schreibDB := oeffne(pgSchreibverbindungen) + schreiben = pool{db: schreibDB, d: d} + if err := runMigrations(schreiben, fsys, migrationsDir); err != nil { + schreibDB.Close() + return pool{}, pool{}, fmt.Errorf("Schema %s: %w", schema, err) + } + + lesen = pool{db: oeffne(pgLeseverbindungen), d: d} + return lesen, schreiben, nil +} + +// textLiteral schreibt s als PostgreSQL-Textkonstante, z.B. E'Vertrieb & Co'. +// +// Die Form E'…' wertet Backslashes immer gleich aus, unabhängig von der +// Einstellung standard_conforming_strings. Darin werden Backslash und +// Hochkomma verdoppelt; ein Nullbyte kann PostgreSQL in Text ohnehin nicht +// speichern und wird entfernt. +func textLiteral(s string) string { + s = strings.ReplaceAll(s, "\x00", "") + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `'`, `''`) + return "E'" + s + "'" +} diff --git a/internal/store/speicher_sqlite.go b/internal/store/speicher_sqlite.go new file mode 100644 index 0000000..41f93bc --- /dev/null +++ b/internal/store/speicher_sqlite.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" +) + +// SQLiteSpeicher legt die Kontroll-Datenbank und jeden Workspace als eigene +// SQLite-Datei ab. Das ist der Standard und der bisherige Aufbau +// (AUSBAUSTUFEN.md 4.4). +type SQLiteSpeicher struct { + controlPfad string // z.B. "/control.db" + workspaceOrdner string // z.B. "/workspaces", muss existieren +} + +// NewSQLite legt einen SQLite-Speicher an. Der Workspace-Ordner muss bereits +// existieren — das übernimmt main.go beim Start. +// +// Wer nur Workspaces braucht (etwa ein Test des workspace.Manager), kann +// controlPfad leer lassen; OpenControl meldet dann einen Fehler. +func NewSQLite(controlPfad, workspaceOrdner string) *SQLiteSpeicher { + return &SQLiteSpeicher{controlPfad: controlPfad, workspaceOrdner: workspaceOrdner} +} + +func (s *SQLiteSpeicher) OpenControl() (*ControlStore, error) { + if s.controlPfad == "" { + return nil, errors.New("SQLite-Speicher ohne Pfad zur Kontroll-Datenbank") + } + return OpenControl(s.controlPfad) +} + +func (s *SQLiteSpeicher) OpenWorkspace(workspaceID string) (*Store, error) { + return Open(s.workspaceDatei(workspaceID)) +} + +// DeleteWorkspace entfernt die Workspace-Datei unwiederbringlich von der +// Platte — samt WAL/SHM-Nebendateien, die SQLite im laufenden Betrieb +// danebenlegt. +func (s *SQLiteSpeicher) DeleteWorkspace(workspaceID string) error { + pfad := s.workspaceDatei(workspaceID) + for _, endung := range []string{"", "-wal", "-shm"} { + if err := os.Remove(pfad + endung); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("Workspace-Datei konnte nicht gelöscht werden: %w", err) + } + } + return nil +} + +// PruefeKapazitaet: Jede SQLite-Datei hat ihre eigenen Verbindungen, eine +// gemeinsame Obergrenze gibt es nicht. +func (s *SQLiteSpeicher) PruefeKapazitaet(context.Context, int) error { return nil } + +// Kapazitaet: SQLite kennt keine gemeinsame Grenze. +func (s *SQLiteSpeicher) Kapazitaet(context.Context) (Kapazitaet, error) { + return Kapazitaet{}, nil +} + +// Ort nennt die Datei relativ zum Datenverzeichnis, z.B. +// "workspaces/mue9bh3x-1c0e1120.db" — der volle Pfad stünde schon im +// Startprotokoll und wäre in der Tabelle nur lang. +func (s *SQLiteSpeicher) Ort(workspaceID string) string { + return filepath.Join(filepath.Base(s.workspaceOrdner), workspaceID+".db") +} + +func (s *SQLiteSpeicher) Beschreibung() string { + return fmt.Sprintf("SQLite — Workspaces: %s, Kontrolle: %s", s.workspaceOrdner, s.controlPfad) +} + +// Hinweise: Beim SQLite-Speicher gibt es beim Start nichts zu prüfen. Eine +// zu große WAL-Datei zeigt die Systemseite je Workspace (Store.Belegung). +func (s *SQLiteSpeicher) Hinweise() []string { return nil } + +// Close: Der SQLite-Speicher hält selbst nichts offen. +func (s *SQLiteSpeicher) Close() error { return nil } + +func (s *SQLiteSpeicher) workspaceDatei(workspaceID string) string { + return filepath.Join(s.workspaceOrdner, workspaceID+".db") +} diff --git a/internal/store/store.go b/internal/store/store.go index 38e648f..4a61a98 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -10,7 +10,13 @@ // # Portabilität // // Das SQL hier ist bewusst schlicht gehalten und vermeidet alles, was nur SQLite -// kann. Die Regeln stehen als Kommentar in migrations/001_init.sql. +// kann. Die Regeln stehen als Kommentar in migrations/sqlite/001_init.sql. +// +// Seit AUSBAUSTUFEN.md 4.10 läuft derselbe Store wahlweise auf SQLite oder +// PostgreSQL. Was sich unterscheidet, steht im dialekt (dialekt.go); welche +// Datenbank benutzt wird, entscheidet der Speicher (speicher.go). Alle +// Abfragen laufen über pool (verbindung.go), damit keine am Dialekt +// vorbeikommt. // // # Warum modernc.org/sqlite? // @@ -35,15 +41,18 @@ import ( _ "modernc.org/sqlite" // meldet sich als Treiber "sqlite" an ) -// migrationsFS enthält die .sql-Dateien, die das Schema einer Workspace-Datei +// migrationsFS enthält die .sql-Dateien, die das Schema eines Workspace // aufbauen (Projekte, Einträge, Keys, Protokoll — siehe internal/store/migrations). // Durch das Einbetten wandern sie mit ins Binary — beim Start ist kein // zusätzlicher Ordner nötig. // +// Je Datenbank gibt es einen eigenen Unterordner (sqlite/, postgres/) mit +// denselben Dateinamen — siehe AUSBAUSTUFEN.md 4.10. +// // Das Schema der zentralen Kontroll-Datenbank (Workspaces, Key-Routing) liegt // getrennt in migrations_control — siehe control.go. // -//go:embed migrations/*.sql +//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql var migrationsFS embed.FS // Store hält die Datenbankverbindungen und stellt alle Abfragen bereit. @@ -66,9 +75,12 @@ var migrationsFS embed.FS // Deshalb getrennt: viele Verbindungen zum Lesen, genau eine zum Schreiben. Die // Schreiber warten damit in Go aufeinander statt in SQLite zu kollidieren, und // die Leser sind davon nicht betroffen. +// +// Unter PostgreSQL gilt dieselbe Aufteilung, nur mit anderen Größen (siehe +// speicher_postgres.go). type Store struct { - lesen *sql.DB // mehrere Verbindungen — nur lesende Abfragen - schreiben *sql.DB // genau eine Verbindung — alles, was verändert + lesen pool // mehrere Verbindungen — nur lesende Abfragen + schreiben pool // genau eine Verbindung — alles, was verändert } // Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und @@ -76,19 +88,27 @@ type Store struct { // // Jede Workspace-Datei bekommt dieselbe Behandlung — unabhängig davon, ob es // sich um die einzige Abteilung handelt oder eine von vierzig. +// +// Open ist der Weg für SQLite. Welche Datenbank der Server benutzt, entscheidet +// der Speicher (speicher.go); Tests und Werkzeuge, die einfach eine Datei +// brauchen, rufen Open direkt auf. func Open(path string) (*Store, error) { - lesen, schreiben, err := openPool(path, migrationsFS, "migrations") + lesen, schreiben, err := openSQLite(path, migrationsFS, "migrations") if err != nil { return nil, err } return &Store{lesen: lesen, schreiben: schreiben}, nil } -// openPool öffnet eine Datenbankdatei mit getrenntem Lese- und Schreibpool und +// openSQLite öffnet eine SQLite-Datei mit getrenntem Lese- und Schreibpool und // bringt ihr Schema auf den aktuellen Stand. Verwendet von Open // (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen // exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen. -func openPool(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben *sql.DB, err error) { +// +// Das Gegenstück für PostgreSQL ist PostgresSpeicher.oeffnePools. +func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben pool, err error) { + d := sqliteDialekt{} + // Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter // gleichzeitigen Zugriffen unangenehm wird: // @@ -116,34 +136,36 @@ func openPool(path string, migrationsFS embed.FS, migrationsDir string) (lesen, // weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion // abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen // Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md). - schreiben, err = sql.Open("sqlite", dsn+"&_txlock=immediate") + schreibDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate") if err != nil { - return nil, nil, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) + return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err) } - schreiben.SetMaxOpenConns(1) + schreibDB.SetMaxOpenConns(1) + schreiben = pool{db: schreibDB, d: d} - if err := schreiben.Ping(); err != nil { - schreiben.Close() - return nil, nil, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) + if err := schreibDB.Ping(); err != nil { + schreibDB.Close() + return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err) } if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil { - schreiben.Close() - return nil, nil, err + schreibDB.Close() + return pool{}, pool{}, err } // Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits. - lesen, err = sql.Open("sqlite", dsn) + leseDB, err := sql.Open("sqlite", dsn) if err != nil { - schreiben.Close() - return nil, nil, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) + schreibDB.Close() + return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err) } - lesen.SetMaxOpenConns(maxLeseverbindungen) + leseDB.SetMaxOpenConns(maxLeseverbindungen) + lesen = pool{db: leseDB, d: d} - if err := lesen.Ping(); err != nil { - schreiben.Close() - lesen.Close() - return nil, nil, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) + if err := leseDB.Ping(); err != nil { + schreibDB.Close() + leseDB.Close() + return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht lesbar: %w", err) } return lesen, schreiben, nil @@ -156,6 +178,11 @@ func openPool(path string, migrationsFS embed.FS, migrationsDir string) (lesen, // Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher. const maxLeseverbindungen = 8 +// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen). +func (s *Store) Verbindungen() Verbindungen { + return Verbindungen{Lesen: s.lesen.db.Stats(), Schreiben: s.schreiben.db.Stats()} +} + // Close schließt beide Verbindungspools. func (s *Store) Close() error { fehlerLesen := s.lesen.Close() @@ -165,8 +192,8 @@ func (s *Store) Close() error { return fehlerLesen } -// runMigrations wendet alle noch nicht ausgeführten Migrationen aus fsys/dir -// über die gegebene Schreibverbindung an. +// runMigrations wendet alle noch nicht ausgeführten Migrationen aus +// fsys/dir/ über die gegebene Schreibverbindung an. // // # Wie das funktioniert // @@ -181,9 +208,17 @@ func (s *Store) Close() error { // // Jede Datenbankdatei (jede Workspace-Datei ebenso wie die Kontroll-Datenbank) // führt ihre eigene schema_migrations-Tabelle — sie kennt nur die Migrationen -// aus dem für sie zuständigen Verzeichnis. -func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error { - _, err := schreiben.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( +// aus dem für sie zuständigen Verzeichnis. Unter PostgreSQL gilt dasselbe je +// Schema. +// +// In schema_migrations steht nur der Dateiname ("001_init.sql"), nicht der +// Ordner. Deshalb hat das Aufteilen in sqlite/ und postgres/ bestehende +// Datenbanken nicht berührt. +func runMigrations(schreiben pool, fsys embed.FS, dir string) error { + ctx := context.Background() + dir = dir + "/" + schreiben.d.migrationsUnterordner() + + _, err := schreiben.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, applied_at TEXT NOT NULL )`) @@ -220,8 +255,8 @@ func runMigrations(schreiben *sql.DB, fsys embed.FS, dir string) error { } // appliedMigrations liest die Namen der bereits ausgeführten Migrationen. -func appliedMigrations(schreiben *sql.DB) (map[string]bool, error) { - rows, err := schreiben.Query(`SELECT name FROM schema_migrations`) +func appliedMigrations(schreiben pool) (map[string]bool, error) { + rows, err := schreiben.QueryContext(context.Background(), `SELECT name FROM schema_migrations`) if err != nil { return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err) } @@ -259,8 +294,14 @@ func migrationFiles(fsys embed.FS, dir string) ([]string, error) { } // applyMigration führt eine einzelne Migration in einer Transaktion aus. -func applyMigration(schreiben *sql.DB, name, sqlText string) error { - tx, err := schreiben.Begin() +// +// Der Text der Migration geht ausnahmsweise am rebind vorbei (direkt über +// *sql.Tx): Er enthält keine Platzhalter, aber in den Kommentaren stehen +// Fragezeichen, und die gehören dort hin. Ohne Parameter schickt auch der +// PostgreSQL-Treiber den Text unverändert und erlaubt dabei mehrere +// Anweisungen hintereinander. +func applyMigration(schreiben pool, name, sqlText string) error { + tx, err := schreiben.db.Begin() if err != nil { return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err) } @@ -273,7 +314,7 @@ func applyMigration(schreiben *sql.DB, name, sqlText string) error { } if _, err := tx.Exec( - `INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, + schreiben.d.rebind(`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`), name, NowString(), ); err != nil { return fmt.Errorf("Migration %s konnte nicht vermerkt werden: %w", name, err) @@ -286,8 +327,8 @@ func applyMigration(schreiben *sql.DB, name, sqlText string) error { // // Damit steht die Fehlerbehandlung einmal hier statt in jeder schreibenden // Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird. -func (s *Store) tx(ctx context.Context, fn func(*sql.Tx) error) error { - tx, err := s.schreiben.BeginTx(ctx, nil) +func (s *Store) tx(ctx context.Context, fn func(*transaktion) error) error { + tx, err := s.schreiben.beginTx(ctx) if err != nil { return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ee2773b..eaca692 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -17,8 +17,14 @@ import ( // Bewusst eine echte Datei statt ":memory:" — so wird auch geprüft, dass // Migrationen, WAL-Modus und Fremdschlüssel wirklich funktionieren. t.TempDir // räumt hinterher auf. +// +// Mit MUNIN_TEST_PG läuft der Test stattdessen gegen PostgreSQL, in einem +// eigenen Schema (siehe postgres_test.go). func testStore(t *testing.T) *Store { t.Helper() + if pgTestAktiv() { + return testPostgresStore(t) + } s, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { @@ -103,8 +109,8 @@ func TestMigrationBestandsdaten(t *testing.T) { // project_id muss in beiden Nebentabellen nachgetragen worden sein. var leereTags, leereLinks int - s2.lesen.QueryRow(`SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags) - s2.lesen.QueryRow(`SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks) + s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags) + s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks) if leereTags != 0 || leereLinks != 0 { t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks) } @@ -700,6 +706,17 @@ func TestExtractLinks(t *testing.T) { "[[mit leerzeichen]] auch nicht": nil, "[[a-b_c1]] ist erlaubt": {"a-b_c1"}, "einfache [klammern] sind kein Verweis": nil, + + // In Code zählt nichts (siehe verweiseAusserhalbVonCode). + "Verweis: `[[beispiel]]`, echt: [[echt]]": {"echt"}, + "doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"}, + "```\n[[im-block]]\n```\n[[danach]]": {"danach"}, + "~~~go\n[[tilde]]\n~~~\n": nil, + " ```\n[[eingerueckter-zaun]]\n ```": nil, + "````\n```\n[[innen]]\n````\n[[aussen]]": {"aussen"}, + "```\n[[offen-bis-zum-ende]]": nil, + "ein ` allein, dann [[zaehlt]]": {"zaehlt"}, + " [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"}, } for input, erwartet := range cases { @@ -717,6 +734,17 @@ func TestExtractLinks(t *testing.T) { } } +// TestReplaceLinksLaesstCodeStehen prüft das Gegenstück beim Anzeigen: Ein +// Verweis in Code bleibt Text, ein echter wird ersetzt. +func TestReplaceLinksLaesstCodeStehen(t *testing.T) { + ein := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n" + soll := "So verweist man: `[[bezeichner]]`. Siehe .\n```\n[[auch-nicht]]\n```\n" + got := ReplaceLinks(ein, func(slug string) string { return "<" + slug + ">" }) + if got != soll { + t.Errorf("\nist: %q\nsoll: %q", got, soll) + } +} + // TestBacklinks prüft die Gegenrichtung der Verweise — sie ersetzt die frühere // Ordnerhierarchie als Struktur zwischen Einträgen. func TestBacklinks(t *testing.T) { @@ -1105,3 +1133,140 @@ func TestLinkGraphIsProjectScoped(t *testing.T) { t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten) } } + +// TestVeralteteEintraegeFilternUndZaehlen: ListEntries mit ChangedBefore und +// CountStaleEntries finden genau die Einträge, die seit über einem Jahr +// unverändert sind — und keine im Papierkorb. +func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + jetzt := time.Now() + + alt := jetzt.Add(-400 * 24 * time.Hour) + for _, w := range []EntryWrite{ + {Slug: "alt", Title: "Alt", CreatedAt: alt, UpdatedAt: alt}, + {Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: alt, UpdatedAt: alt}, + {Slug: "frisch", Title: "Frisch"}, + } { + w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman + if _, err := s.PutEntry(ctx, w); err != nil { + t.Fatal(err) + } + } + if err := s.MoveEntryToTrash(ctx, p.ID, "alt-geloescht", "test"); err != nil { + t.Fatal(err) + } + + liste, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(jetzt)}) + if err != nil { + t.Fatal(err) + } + if len(liste) != 1 || liste[0].Slug != "alt" { + t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(liste)) + } + + anzahl, err := s.CountStaleEntries(ctx, p.ID, jetzt) + if err != nil || anzahl != 1 { + t.Errorf("CountStaleEntries = %d, %v; erwartet 1", anzahl, err) + } +} + +// TestAuditFilter: ListAudit schränkt nach Vorgang, Zugang und Zeitraum ein, +// AuditActors nennt die Zugänge. +func TestAuditFilter(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + jetzt := time.Now().UTC() + for _, z := range []struct { + alter time.Duration + wer, vorgang string + }{ + {48 * time.Hour, "anna", ActionEntryWrite}, + {24 * time.Hour, "bernd", ActionEntryDelete}, + {1 * time.Hour, "anna", ActionEntryDelete}, + } { + if _, err := s.schreiben.ExecContext(ctx, + `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) + VALUES (?, ?, ?, '', ?, ?, '', 'web', '', '')`, + NewID(), jetzt.Add(-z.alter).Format(time.RFC3339Nano), p.ID, z.wer, z.vorgang); err != nil { + t.Fatal(err) + } + } + + zaehle := func(f AuditFilter) int { + t.Helper() + f.ProjectID = p.ID + liste, err := s.ListAudit(ctx, f) + if err != nil { + t.Fatal(err) + } + return len(liste) + } + if n := zaehle(AuditFilter{Action: ActionEntryDelete}); n != 2 { + t.Errorf("Vorgang: %d, erwartet 2", n) + } + if n := zaehle(AuditFilter{Actor: "anna"}); n != 2 { + t.Errorf("Zugang: %d, erwartet 2", n) + } + if n := zaehle(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 { + t.Errorf("Zugang und Vorgang: %d, erwartet 1", n) + } + if n := zaehle(AuditFilter{Since: jetzt.Add(-30 * time.Hour), Until: jetzt.Add(-2 * time.Hour)}); n != 1 { + t.Errorf("Zeitraum: %d, erwartet 1 (bernd)", n) + } + + zugaenge, err := s.AuditActors(ctx, p.ID) + if err != nil || strings.Join(zugaenge, ",") != "anna,bernd" { + t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", zugaenge, err) + } +} + +// TestKeyAblauf: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt +// sich nicht mehr auflösen. Ein Ablauf in der Vergangenheit wird gar nicht +// erst angenommen. +func TestKeyAblauf(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + jetzt := time.Now() + + if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", jetzt.Add(-time.Hour)); err == nil { + t.Error("Key mit Ablauf in der Vergangenheit wurde angelegt") + } + + k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", jetzt.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil { + t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err) + } + gelesen, _ := s.APIKeyByID(ctx, k.ID) + if gelesen.ExpiresAt.IsZero() || !gelesen.ExpiresSoon(jetzt) || gelesen.Expired(jetzt) { + t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", gelesen) + } + + // Ablauf in die Vergangenheit setzen — wie ein Key, dessen Tag vorbei ist. + if _, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`, + jetzt.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil { + t.Fatal(err) + } + if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) { + t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err) + } + gelesen, _ = s.APIKeyByID(ctx, k.ID) + if gelesen.Valid(jetzt) || gelesen.Unused(jetzt.Add(200*24*time.Hour)) { + t.Error("abgelaufener Key gilt als gültig oder als ungenutzt") + } + + // Ohne Ablauf: wie bisher. + if _, err := s.CreateAPIKey(ctx, p.ID, "unbegrenzt", "h2", "read"); err != nil { + t.Fatal(err) + } + if _, err := s.ResolveKeyHash(ctx, "h2"); err != nil { + t.Errorf("Key ohne Ablauf: %v", err) + } +} diff --git a/internal/store/suchprotokoll.go b/internal/store/suchprotokoll.go index 4a6fc71..5f117ea 100644 --- a/internal/store/suchprotokoll.go +++ b/internal/store/suchprotokoll.go @@ -109,15 +109,24 @@ type EmptySearch struct { func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.Time, limit int) ([]EmptySearch, error) { seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano) - // MAX(ts) ist der jüngste Zeitpunkt. Die Synonyme der jüngsten Zeile holt - // eine Eigenheit von SQLite: Steht MAX() in der Abfrage, kommen die übrigen - // Spalten ohne Aggregat aus genau der Zeile mit dem Höchstwert. + // Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am + // Rand) eine Zeile: die jüngste, mit ihren Synonymen. COUNT(*) OVER zählt + // alle Male, die die Anfrage gestellt wurde; ROW_NUMBER() nummeriert sie, + // jüngste zuerst. id entscheidet bei gleichem Zeitstempel. + // + // Früher stand hier MAX(ts) mit Spalten ohne Aggregat — eine Eigenheit von + // SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe + // CuratedOverrides und AUSBAUSTUFEN.md 4.10). rows, err := s.lesen.QueryContext(ctx, - `SELECT query, synonyms, COUNT(*) AS anzahl, MAX(ts) - FROM search_log - WHERE project_id = ? AND ts >= ? AND results = 0 - GROUP BY lower(trim(query)) - ORDER BY anzahl DESC, MAX(ts) DESC + `SELECT query, synonyms, anzahl, ts FROM ( + SELECT query, synonyms, ts, + COUNT(*) OVER (PARTITION BY lower(trim(query))) AS anzahl, + ROW_NUMBER() OVER (PARTITION BY lower(trim(query)) ORDER BY ts DESC, id DESC) AS nr + FROM search_log + WHERE project_id = ? AND ts >= ? AND results = 0 + ) AS je_anfrage + WHERE nr = 1 + ORDER BY anzahl DESC, ts DESC LIMIT ?`, projectID, seit, limit, ) diff --git a/internal/store/systemstand.go b/internal/store/systemstand.go new file mode 100644 index 0000000..449ce1a --- /dev/null +++ b/internal/store/systemstand.go @@ -0,0 +1,293 @@ +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 +} diff --git a/internal/store/systemstand_test.go b/internal/store/systemstand_test.go new file mode 100644 index 0000000..ab2aa0e --- /dev/null +++ b/internal/store/systemstand_test.go @@ -0,0 +1,189 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// Tests der Kennzahlen für die Systemseite (systemstand.go). Laufen mit +// MUNIN_TEST_PG auch gegen PostgreSQL (siehe testStore). + +// TestBestand zählt Projekte, Einträge, Papierkorb, Fassungen und Keys — +// Keys nach denselben Regeln wie die Seite "Zugänge". +func TestBestand(t *testing.T) { + s := testStore(t) + ctx := context.Background() + jetzt := time.Now() + + leer, err := s.Bestand(ctx, jetzt) + if err != nil { + t.Fatal(err) + } + if leer != (Bestand{}) { + t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", leer) + } + + p := testProject(t, s) + for _, slug := range []string{"a", "b", "c"} { + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Content: "x", Source: SourceHuman}); err != nil { + t.Fatal(err) + } + } + // Eine Änderung legt eine ältere Fassung ab. + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "a", Content: "neu", Source: SourceHuman}); err != nil { + t.Fatal(err) + } + if err := s.MoveEntryToTrash(ctx, p.ID, "c", "test"); err != nil { + t.Fatal(err) + } + + // Keys: einer frisch, einer läuft bald ab, einer zurückgezogen, einer + // abgelaufen. Gültig sind die ersten beiden. + if _, err := s.CreateAPIKey(ctx, p.ID, "frisch", "h1", "write"); err != nil { + t.Fatal(err) + } + if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "bald", "h2", "read", jetzt.Add(3*24*time.Hour)); err != nil { + t.Fatal(err) + } + weg, err := s.CreateAPIKey(ctx, p.ID, "weg", "h3", "read") + if err != nil { + t.Fatal(err) + } + if err := s.RevokeAPIKey(ctx, weg.ID); err != nil { + t.Fatal(err) + } + if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "abgelaufen", "h4", "read", jetzt.Add(time.Hour)); err != nil { + t.Fatal(err) + } + + // Zwei Stunden später: "abgelaufen" ist es dann. + b, err := s.Bestand(ctx, jetzt.Add(2*time.Hour)) + if err != nil { + t.Fatal(err) + } + erwartet := Bestand{Projekte: 1, Eintraege: 2, Fassungen: 1, Papierkorb: 1, Keys: 2, KeysLaufenAb: 1} + if b != erwartet { + t.Errorf("Bestand = %+v\nerwartet %+v", b, erwartet) + } + + // 100 Tage später ist "frisch" nie benutzt worden und damit ungenutzt; + // "bald" ist bis dahin abgelaufen und zählt gar nicht mehr. + b, err = s.Bestand(ctx, jetzt.Add(100*24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if b.Keys != 1 || b.KeysUngenutzt != 1 || b.KeysLaufenAb != 0 { + t.Errorf("nach 100 Tagen: Keys %d, ungenutzt %d, laufen ab %d — erwartet 1, 1, 0", b.Keys, b.KeysUngenutzt, b.KeysLaufenAb) + } +} + +// TestBelegung: Ein Workspace mit Inhalt belegt Platz. Wie viel genau, hängt +// von der Datenbank ab — geprüft wird nur, dass die Zahl plausibel ist und +// mit dem Inhalt wächst. +func TestBelegung(t *testing.T) { + s := testStore(t) + ctx := context.Background() + + vorher, err := s.Belegung(ctx) + if err != nil { + t.Fatal(err) + } + if vorher.Daten <= 0 { + t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", vorher.Daten) + } + + p := testProject(t, s) + inhalt := make([]byte, 64<<10) + for i := range inhalt { + inhalt[i] = 'a' + byte(i%26) + } + for _, slug := range []string{"gross-1", "gross-2", "gross-3", "gross-4"} { + if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Content: string(inhalt), Source: SourceHuman}); err != nil { + t.Fatal(err) + } + } + + nachher, err := s.Belegung(ctx) + if err != nil { + t.Fatal(err) + } + // Bei SQLite landet das Geschriebene zunächst in der WAL-Datei, bei + // PostgreSQL in den Tabellen. Zusammen muss es mehr geworden sein. + if nachher.Gesamt() <= vorher.Gesamt() { + t.Errorf("Belegung vorher %d, nachher %d — sollte gewachsen sein", vorher.Gesamt(), nachher.Gesamt()) + } +} + +// TestAktivitaet zählt Schreibvorgänge je Projekt, Tag und Weg sowie Suchen, +// und lässt alles vor der Grenze weg. +func TestAktivitaet(t *testing.T) { + s := testStore(t) + ctx := context.Background() + p := testProject(t, s) + + // Mit festen Zeitstempeln direkt eintragen — LogAudit und LogSearch + // nehmen immer "jetzt". + audit := func(ts, aktion, quelle string) { + t.Helper() + _, err := s.schreiben.ExecContext(ctx, + `INSERT INTO audit_events (id, ts, project_id, actor, action, source) VALUES (?, ?, ?, 'test', ?, ?)`, + NewID(), ts, p.ID, aktion, quelle) + if err != nil { + t.Fatal(err) + } + } + suche := func(ts string) { + t.Helper() + _, err := s.schreiben.ExecContext(ctx, + `INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'frage', 1)`, ts, p.ID) + if err != nil { + t.Fatal(err) + } + } + + audit("2026-10-01T08:00:00Z", ActionEntryWrite, SourceMCP) + audit("2026-10-01T09:00:00Z", ActionEntryWrite, SourceMCP) + audit("2026-10-01T10:00:00Z", ActionEntryDelete, SourceWeb) + audit("2026-10-01T11:00:00Z", ActionLoginSuccess, SourceWeb) // kein Schreibvorgang + audit("2026-10-02T08:00:00Z", ActionEntryWrite, SourceWeb) + audit("2026-09-20T08:00:00Z", ActionEntryWrite, SourceMCP) // vor der Grenze + suche("2026-10-02T12:00:00Z") + suche("2026-10-02T13:00:00Z") + suche("2026-09-20T13:00:00Z") // vor der Grenze + + liste, err := s.Aktivitaet(ctx, time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + + je := map[string]Tagesaktivitaet{} + for _, a := range liste { + if a.ProjektID != p.ID { + t.Errorf("fremdes Projekt %q", a.ProjektID) + } + je[a.Tag] = a + } + if len(je) != 2 { + t.Fatalf("%d Tage, erwartet 2: %+v", len(je), liste) + } + if a := je["2026-10-01"]; a.SchreibenAgent != 2 || a.SchreibenMensch != 1 || a.Suchen != 0 { + t.Errorf("1.10.: %+v, erwartet 2 Agent, 1 Mensch, 0 Suchen", a) + } + if a := je["2026-10-02"]; a.SchreibenAgent != 0 || a.SchreibenMensch != 1 || a.Suchen != 2 { + t.Errorf("2.10.: %+v, erwartet 0 Agent, 1 Mensch, 2 Suchen", a) + } +} + +// TestSchemastand: Nach dem Öffnen steht der Workspace auf der neuesten +// Migration, die das Programm mitbringt. +func TestSchemastand(t *testing.T) { + s := testStore(t) + ist, soll, err := s.Schemastand(context.Background()) + if err != nil { + t.Fatal(err) + } + if soll == "" || ist != soll { + t.Errorf("Schemastand ist %q, soll %q", ist, soll) + } +} diff --git a/internal/store/verbindung.go b/internal/store/verbindung.go new file mode 100644 index 0000000..6bf7eac --- /dev/null +++ b/internal/store/verbindung.go @@ -0,0 +1,90 @@ +package store + +import ( + "context" + "database/sql" +) + +// pool ist ein Verbindungspool samt dem Dialekt seiner Datenbank. +// +// # Warum nicht direkt *sql.DB? +// +// Jede Abfrage muss durch dialekt.rebind, bevor sie an die Datenbank geht — +// sonst bekommt PostgreSQL ein "?", mit dem es nichts anfangen kann. Würde der +// Store *sql.DB direkt benutzen, wäre das bei jeder der rund 100 Abfragen eine +// Stelle, an der man es vergessen kann. +// +// pool bietet deshalb nur die Methoden an, die der Store braucht, und jede +// davon wandelt um. An *sql.DB selbst kommt außerhalb dieser Datei niemand +// heran — eine Abfrage am Dialekt vorbei lässt sich gar nicht erst schreiben. +type pool struct { + db *sql.DB + d dialekt +} + +func (p pool) ExecContext(ctx context.Context, abfrage string, args ...any) (sql.Result, error) { + return p.db.ExecContext(ctx, p.d.rebind(abfrage), args...) +} + +func (p pool) QueryContext(ctx context.Context, abfrage string, args ...any) (*sql.Rows, error) { + return p.db.QueryContext(ctx, p.d.rebind(abfrage), args...) +} + +func (p pool) QueryRowContext(ctx context.Context, abfrage string, args ...any) *sql.Row { + return p.db.QueryRowContext(ctx, p.d.rebind(abfrage), args...) +} + +// beginTx startet eine Transaktion, deren Abfragen ebenfalls umgewandelt +// werden. +func (p pool) beginTx(ctx context.Context) (*transaktion, error) { + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + return &transaktion{tx: tx, d: p.d}, nil +} + +func (p pool) Close() error { return p.db.Close() } + +// Verbindungen sind die Zahlen eines Lese- und eines Schreibpools, wie +// database/sql sie führt — für die Systemseite (AUSBAUSTUFEN.md 5.12). +type Verbindungen struct { + Lesen, Schreiben sql.DBStats +} + +// Offen zählt die offenen Verbindungen beider Pools zusammen. Genau so viele +// belegt Munin gerade beim Datenbankserver. +func (v Verbindungen) Offen() int { return v.Lesen.OpenConnections + v.Schreiben.OpenConnections } + +// InBenutzung zählt die Verbindungen, die gerade eine Abfrage ausführen. +func (v Verbindungen) InBenutzung() int { return v.Lesen.InUse + v.Schreiben.InUse } + +// Gewartet zählt, wie oft eine Anfrage auf eine freie Verbindung warten +// musste, seit der Pool geöffnet wurde. Wächst die Zahl stetig, sind die +// Pools zu klein für die Last. +func (v Verbindungen) Gewartet() int64 { return v.Lesen.WaitCount + v.Schreiben.WaitCount } + +// transaktion ist das Gegenstück zu pool für *sql.Tx: dieselben Methoden, +// dieselbe Umwandlung. +type transaktion struct { + tx *sql.Tx + d dialekt +} + +func (t *transaktion) ExecContext(ctx context.Context, abfrage string, args ...any) (sql.Result, error) { + return t.tx.ExecContext(ctx, t.d.rebind(abfrage), args...) +} + +func (t *transaktion) QueryContext(ctx context.Context, abfrage string, args ...any) (*sql.Rows, error) { + return t.tx.QueryContext(ctx, t.d.rebind(abfrage), args...) +} + +func (t *transaktion) QueryRowContext(ctx context.Context, abfrage string, args ...any) *sql.Row { + return t.tx.QueryRowContext(ctx, t.d.rebind(abfrage), args...) +} + +func (t *transaktion) Commit() error { return t.tx.Commit() } + +// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann es +// bedenkenlos per defer stehen. +func (t *transaktion) Rollback() error { return t.tx.Rollback() } diff --git a/internal/web/diagramm.go b/internal/web/diagramm.go new file mode 100644 index 0000000..fd3aaee --- /dev/null +++ b/internal/web/diagramm.go @@ -0,0 +1,191 @@ +package web + +import ( + "fmt" + + "munin/internal/statistik" +) + +// Balkendiagramme für die Systemseite (AUSBAUSTUFEN.md 5.16). +// +// # Warum selbst gezeichnet? +// +// Eine Diagramm-Bibliothek wäre JavaScript, das erst geladen werden und unter +// der Content-Security-Policy laufen muss — für 30 Balken. Stattdessen rechnet +// der Server die Rechtecke aus, und das Template schreibt sie als SVG direkt +// in die Seite. Der Browser zeichnet SVG wie HTML, ohne ein Skript. +// +// Die Farben kommen aus style.css über Klassen (keine style-Attribute — die +// verbietet die CSP), damit das dunkle Farbschema von selbst passt. Fährt man +// mit der Maus über einen Tag, zeigt der Browser dessen an. +// +// Alle Maße sind ganze Zahlen im Koordinatensystem der viewBox. Das SVG +// skaliert mit der Breite der Seite; die Zahlen sind also keine Pixel. + +// Maße des Diagramms in viewBox-Einheiten. +const ( + diagrammLinks = 36 // Platz für die Zahlen der y-Achse + diagrammOben = 8 // damit die oberste Zahl nicht abgeschnitten wird + diagrammFlaeche = 120 // Höhe der Balkenfläche + diagrammUnten = 20 // Platz für die Daten der x-Achse + diagrammSpalte = 20 // Breite je Tag + diagrammBalken = 14 // Breite des Balkens darin, der Rest ist Abstand +) + +// diagramm ist ein fertig ausgerechnetes Balkendiagramm. +type diagramm struct { + Titel string // für Screenreader (aria-label) + Breite, Hoehe int // viewBox + Links, Rechts int // x der Balkenfläche: Anfang und Ende + Grund int // y der Nulllinie + Leer bool // im ganzen Zeitraum nichts — die Seite sagt das statt leerer Achsen + Gitter []diagrammLinie + Balken []diagrammBalkenTag + Achse []diagrammBeschriftung +} + +// diagrammLinie ist eine waagerechte Hilfslinie mit ihrem Wert. +type diagrammLinie struct { + Y int + Text string +} + +// diagrammBalkenTag ist ein Tag: eine unsichtbare Trefferfläche über die +// ganze Höhe (damit der Tooltip auch bei kleinen Balken erscheint) und die +// gestapelten Teile. +type diagrammBalkenTag struct { + X, Breite int // Trefferfläche + Titel string + Teile []diagrammTeil +} + +// diagrammTeil ist ein Rechteck eines gestapelten Balkens. +type diagrammTeil struct { + X, Y, Breite, Hoehe int + Klasse string +} + +// diagrammBeschriftung ist ein Datum unter der x-Achse. +type diagrammBeschriftung struct { + X int + Text string +} + +// diagrammReihe ist eine Teilmenge eines Balkens: welcher Wert, in welcher +// Farbe, wie heißt er im Tooltip. +type diagrammReihe struct { + wert func(statistik.Tag) int + klasse string + name string +} + +// wochentage für den Tooltip, mit Sonntag = 0 wie time.Weekday. +var wochentage = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"} + +// diagrammEinheit ist das Wort hinter der Summe im Tooltip, in Einzahl und +// Mehrzahl: "1 Schreibvorgang", "12 Schreibvorgänge". +type diagrammEinheit struct{ eins, mehr string } + +// baueDiagramm rechnet ein gestapeltes Balkendiagramm aus: je Tag ein Balken, +// darin je Reihe ein Teil, von unten nach oben in der Reihenfolge der Reihen. +func baueDiagramm(titel string, einheit diagrammEinheit, tage []statistik.Tag, reihen ...diagrammReihe) diagramm { + d := diagramm{ + Titel: titel, + Links: diagrammLinks, + Rechts: diagrammLinks + len(tage)*diagrammSpalte, + Grund: diagrammOben + diagrammFlaeche, + } + d.Breite = d.Rechts + 4 + d.Hoehe = d.Grund + diagrammUnten + + summe := func(t statistik.Tag) int { + n := 0 + for _, r := range reihen { + n += r.wert(t) + } + return n + } + + hoechster := 0 + for _, t := range tage { + hoechster = max(hoechster, summe(t)) + } + d.Leer = hoechster == 0 + skala := runde125(hoechster) + + // Hilfslinien bei der Hälfte und ganz oben. Die Hälfte nur, wenn sie + // eine ganze Zahl ist — "2,5 Suchen" wäre seltsam. + d.Gitter = append(d.Gitter, diagrammLinie{Y: d.Grund, Text: "0"}) + if skala%2 == 0 { + d.Gitter = append(d.Gitter, diagrammLinie{Y: d.Grund - diagrammFlaeche/2, Text: fmt.Sprint(skala / 2)}) + } + d.Gitter = append(d.Gitter, diagrammLinie{Y: diagrammOben, Text: fmt.Sprint(skala)}) + + for i, t := range tage { + spalte := diagrammLinks + i*diagrammSpalte + tag := diagrammBalkenTag{X: spalte, Breite: diagrammSpalte} + + // Tooltip: "Mo 13.10.: 12 Schreibvorgänge (Agenten 9, Menschen 3)". + // Der Name vor der Zahl erspart die Frage nach Einzahl oder Mehrzahl. + tag.Titel = fmt.Sprintf("%s %s: %s", wochentage[t.Datum.Weekday()], t.Datum.Format("02.01."), + plural(summe(t), einheit.eins, einheit.mehr)) + if len(reihen) > 1 && summe(t) > 0 { + tag.Titel += " (" + for j, r := range reihen { + if j > 0 { + tag.Titel += ", " + } + tag.Titel += fmt.Sprintf("%s %d", r.name, r.wert(t)) + } + tag.Titel += ")" + } + + // Die Teile stapeln: y wächst in SVG nach unten, also von der + // Nulllinie aus nach oben abziehen. + oben := d.Grund + for _, r := range reihen { + h := hoeheVon(r.wert(t), skala) + if h == 0 { + continue + } + oben -= h + tag.Teile = append(tag.Teile, diagrammTeil{ + X: spalte + (diagrammSpalte-diagrammBalken)/2, Y: oben, + Breite: diagrammBalken, Hoehe: h, Klasse: r.klasse, + }) + } + d.Balken = append(d.Balken, tag) + + // Ein Datum je Woche, vom heutigen Tag rückwärts gezählt — so steht + // heute immer unten rechts. + if (len(tage)-1-i)%7 == 0 { + d.Achse = append(d.Achse, diagrammBeschriftung{X: spalte + diagrammSpalte/2, Text: t.Datum.Format("02.01.")}) + } + } + return d +} + +// hoeheVon rechnet einen Wert in die Höhe seines Balkens um. Ein Wert über +// null bekommt mindestens eine Einheit, sonst verschwände ein einzelner +// Schreibvorgang neben einem Tag mit 500. +func hoeheVon(wert, skala int) int { + if wert <= 0 { + return 0 + } + return max(1, wert*diagrammFlaeche/skala) +} + +// runde125 rundet auf die nächste "glatte" Zahl auf: 1, 2, 5, 10, 20, 50, … +// So endet die y-Achse bei einem Wert, den man gut lesen kann, statt bei 37. +func runde125(n int) int { + if n <= 1 { + return 1 + } + for stufe := 1; ; stufe *= 10 { + for _, f := range []int{1, 2, 5} { + if f*stufe >= n { + return f * stufe + } + } + } +} diff --git a/internal/web/diagramm_test.go b/internal/web/diagramm_test.go new file mode 100644 index 0000000..cb5224f --- /dev/null +++ b/internal/web/diagramm_test.go @@ -0,0 +1,82 @@ +package web + +import ( + "testing" + "time" + + "munin/internal/statistik" +) + +func TestRunde125(t *testing.T) { + for _, fall := range []struct{ ein, aus int }{ + {0, 1}, {1, 1}, {2, 2}, {3, 5}, {5, 5}, {6, 10}, {11, 20}, {37, 50}, {51, 100}, {999, 1000}, {1001, 2000}, + } { + if got := runde125(fall.ein); got != fall.aus { + t.Errorf("runde125(%d) = %d, erwartet %d", fall.ein, got, fall.aus) + } + } +} + +// TestDiagramm: Die Balken stapeln sich von der Nulllinie nach oben, der +// höchste reicht bis zur Skala, ein kleiner Wert bleibt sichtbar, und unter +// dem letzten Tag steht ein Datum. +func TestDiagramm(t *testing.T) { + tag0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + tage := make([]statistik.Tag, 8) + for i := range tage { + tage[i].Datum = tag0.AddDate(0, 0, i) + } + tage[3].SchreibenAgent, tage[3].SchreibenMensch = 6, 4 // Summe 10 = Skala + tage[5].SchreibenAgent = 1 + + d := baueDiagramm("Test", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, tage, + diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "a", name: "Agenten"}, + diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "m", name: "Menschen"}, + ) + + if d.Leer || len(d.Balken) != len(tage) { + t.Fatalf("Leer=%v, %d Balken", d.Leer, len(d.Balken)) + } + voll := d.Balken[3].Teile + if len(voll) != 2 { + t.Fatalf("Tag 3: %d Teile, erwartet 2", len(voll)) + } + if voll[0].Y+voll[0].Hoehe != d.Grund { + t.Error("unterster Teil steht nicht auf der Nulllinie") + } + if voll[1].Y+voll[1].Hoehe != voll[0].Y { + t.Error("zweiter Teil liegt nicht auf dem ersten") + } + if voll[1].Y != diagrammOben { + t.Errorf("höchster Balken endet bei y=%d, erwartet %d (die Skala)", voll[1].Y, diagrammOben) + } + if klein := d.Balken[5].Teile; len(klein) != 1 || klein[0].Hoehe < 1 { + t.Errorf("ein einzelner Schreibvorgang ist unsichtbar: %+v", klein) + } + if len(d.Balken[0].Teile) != 0 { + t.Error("ein Tag ohne Aktivität hat Balken") + } + if got := d.Balken[3].Titel; got != "So 04.10.: 10 Schreibvorgänge (Agenten 6, Menschen 4)" { + t.Errorf("Tooltip %q", got) + } + if letzte := d.Achse[len(d.Achse)-1]; letzte.Text != "08.10." { + t.Errorf("letzte Beschriftung %q, erwartet den letzten Tag", letzte.Text) + } + + if leer := baueDiagramm("Leer", diagrammEinheit{"Suche", "Suchen"}, tage[:2]); !leer.Leer { + t.Error("Diagramm ohne Werte ist nicht als leer markiert") + } +} + +func TestGroesse(t *testing.T) { + for _, fall := range []struct { + ein int64 + aus string + }{ + {0, "0 B"}, {1023, "1023 B"}, {1536, "1,5 KB"}, {812 << 10, "812 KB"}, {3 << 30, "3,0 GB"}, + } { + if got := groesse(fall.ein); got != fall.aus { + t.Errorf("groesse(%d) = %q, erwartet %q", fall.ein, got, fall.aus) + } + } +} diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index 81611a1..6354cdf 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -355,6 +355,14 @@ type keyListData struct { // Nacharbeit kopierbar sein soll. ServerURL string + // BaldAbgelaufen zählt gültige Keys, die innerhalb von + // store.ExpiresSoonWithin ablaufen — für den Hinweis über der Tabelle. + BaldAbgelaufen int + + // Heute ist das frühestmögliche Ablaufdatum im Formular (morgen wäre + // strenger, aber "heute" heißt: bis heute Abend). + Heute string + // Jetzt ist der Bezugszeitpunkt für APIKey.Unused im Template. Einmal pro // Seitenaufruf festgehalten, damit alle Zeilen mit demselben Wert rechnen. Jetzt time.Time @@ -382,13 +390,25 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { return } + // Ablaufdatum (optional, AUSBAUSTUFEN.md 4.7 Stufe 2): Der Key gilt bis + // einschließlich des gewählten Tages, also bis zum Beginn des nächsten. + var ablauf time.Time + if text := strings.TrimSpace(r.FormValue("expires")); text != "" { + tag, err := time.ParseInLocation(auditDatum, text, time.Local) + if err != nil { + s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", "Das Ablaufdatum ist kein gültiges Datum.") + return + } + ablauf = tag.AddDate(0, 0, 1) + } + klartext, err := auth.GenerateKey() if err != nil { s.serverError(w, "Key konnte nicht erzeugt werden", err) return } - erzeugt, err := ws.Store.CreateAPIKey(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle)) + erzeugt, err := ws.Store.CreateAPIKeyWithExpiry(r.Context(), project.ID, name, auth.HashKey(klartext), string(rolle), ablauf) if err != nil { s.renderKeyList(w, r, http.StatusBadRequest, keyListData{}, "", err.Error()) return @@ -407,7 +427,7 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: erzeugt.ID, Actor: sess.ActorName(), Action: store.ActionKeyCreated, Source: store.SourceWeb, IP: auth.ClientIP(r), - Detail: erzeugt.Name + " (" + string(rolle) + ")", + Detail: keyDetail(erzeugt), }) s.renderKeyList(w, r, http.StatusOK, @@ -415,6 +435,16 @@ func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { "Key angelegt. Er wird nur jetzt angezeigt — bitte gleich übernehmen.", "") } +// keyDetail beschreibt einen neuen Key fürs Protokoll: Name, Rolle und — +// falls gesetzt — der letzte gültige Tag. +func keyDetail(k store.APIKey) string { + text := k.Name + " (" + k.Role + if !k.ExpiresAt.IsZero() { + text += ", gültig bis " + k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006") + } + return text + ")" +} + // handleKeyRevoke zieht einen Key zurück. func (s *Server) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) @@ -461,10 +491,14 @@ func (s *Server) renderKeyList(w http.ResponseWriter, r *http.Request, status in daten.Keys = keys daten.ServerURL = serverURL(r) daten.Jetzt = time.Now() + daten.Heute = daten.Jetzt.Format(auditDatum) for _, k := range keys { if k.Unused(daten.Jetzt) { daten.Ungenutzt++ } + if k.ExpiresSoon(daten.Jetzt) { + daten.BaldAbgelaufen++ + } } s.render(w, r, "keys.html", status, pageData{ @@ -484,6 +518,74 @@ type auditData struct { ProjectNames map[string]string KeyNames map[string]string Global bool + + // Filter (AUSBAUSTUFEN.md 5.11): die gewählten Werte, so wie sie wieder + // ins Formular gehören, und die Auswahl für die Listen. + Filter auditFormular + Aktionen []auditAktion + Zugaenge []string + Gefiltert bool // mindestens ein Filter ist gesetzt +} + +// auditFormular sind die Filterwerte der Protokollseite, als Text wie im +// Formular — Datumsangaben im Format JJJJ-MM-TT. +type auditFormular struct { + Vorgang string + Wer string + Projekt string // nur im Protokoll über alle Projekte + Von string + Bis string +} + +// auditAktion ist ein Vorgang samt verständlichem Namen für die Auswahlliste. +type auditAktion struct{ Action, Label string } + +// auditDatum ist das Format der Datumsfelder im Filter (input type=date). +const auditDatum = "2006-01-02" + +// leseAuditFilter übernimmt die Filter aus der Adresse in filter. +// +// Unbekannte oder kaputte Werte werden ignoriert statt gemeldet: Wer an der +// Adresse herumbastelt, bekommt das ungefilterte Protokoll, keine +// Fehlerseite. "bis" schließt den ganzen Tag ein — wer „bis 16.10." wählt, +// meint den 16. mit. +func leseAuditFilter(r *http.Request, filter *store.AuditFilter, global bool) auditFormular { + q := r.URL.Query() + f := auditFormular{ + Vorgang: strings.TrimSpace(q.Get("vorgang")), + Wer: strings.TrimSpace(q.Get("wer")), + Von: strings.TrimSpace(q.Get("von")), + Bis: strings.TrimSpace(q.Get("bis")), + } + + for _, a := range store.AuditActions { + if a.Action == f.Vorgang { + filter.Action = f.Vorgang + } + } + if filter.Action == "" { + f.Vorgang = "" + } + filter.Actor = f.Wer + + if global { + f.Projekt = strings.TrimSpace(q.Get("projekt")) + filter.ProjectID = f.Projekt + } + + // Die Tage gelten in der Zeitzone des Servers — derselben, in der die + // Tabelle die Zeitpunkte anzeigt. + if t, err := time.ParseInLocation(auditDatum, f.Von, time.Local); err == nil { + filter.Since = t + } else { + f.Von = "" + } + if t, err := time.ParseInLocation(auditDatum, f.Bis, time.Local); err == nil { + filter.Until = t.AddDate(0, 0, 1) + } else { + f.Bis = "" + } + return f } // handleProjectAudit zeigt das Protokoll eines Projekts. @@ -503,6 +605,17 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor ws, sess := s.workspaceAndSession(r) ctx := r.Context() + // Die Zugänge für die Auswahl: im Projektprotokoll die dieses Projekts, + // sonst alle — vor dem Übernehmen der Filter, damit die Liste nicht vom + // gewählten Projekt abhängt. + zugaenge, err := ws.Store.AuditActors(ctx, filter.ProjectID) + if err != nil { + s.serverError(w, "Protokoll konnte nicht geladen werden", err) + return + } + + formular := leseAuditFilter(r, &filter, global) + ereignisse, err := ws.Store.ListAudit(ctx, filter) if err != nil { s.serverError(w, "Protokoll konnte nicht geladen werden", err) @@ -536,10 +649,24 @@ func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, filter stor ProjectNames: projektNamen, KeyNames: keyNamen, Global: global, + + Filter: formular, + Aktionen: auditAktionen(), + Zugaenge: zugaenge, + Gefiltert: formular != auditFormular{}, }, }) } +// auditAktionen übersetzt store.AuditActions in den Typ der Seite. +func auditAktionen() []auditAktion { + liste := make([]auditAktion, 0, len(store.AuditActions)) + for _, a := range store.AuditActions { + liste = append(liste, auditAktion{Action: a.Action, Label: a.Label}) + } + return liste +} + // ── Export ─────────────────────────────────────────────────────────────────── // handleExport schreibt alle Projekte als Markdown-Dateien auf die Platte. diff --git a/internal/web/handlers_bereitschaft.go b/internal/web/handlers_bereitschaft.go new file mode 100644 index 0000000..1c08078 --- /dev/null +++ b/internal/web/handlers_bereitschaft.go @@ -0,0 +1,68 @@ +package web + +import ( + "context" + "log" + "net/http" + "sync/atomic" + "time" +) + +// bereitZeitlimit begrenzt, wie lange /ready auf die Datenbank wartet. Ein +// Load-Balancer fragt typischerweise alle paar Sekunden mit eigenem +// Zeitlimit; eine Antwort nach 30 Sekunden hilft ihm nicht. +const bereitZeitlimit = 2 * time.Second + +// handleLive antwortet, solange der Prozess läuft — ohne Datenbank, ohne +// Anmeldung. Siehe Routes in server.go, warum das getrennt von /ready ist. +func handleLive(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte("ok\n")) +} + +// handleReady prüft, ob die Kontroll-Datenbank antwortet: 200 "ok" oder 503 +// "nicht bereit". +// +// Geprüft wird die Kontroll-Datenbank, weil ohne sie keine einzige Anfrage +// durchgeht (jede Key-Prüfung und jede Sitzung braucht sie). Unter +// PostgreSQL liegen die Workspaces auf demselben Server; ist er erreichbar, +// sind sie es auch. +// +// Der Grund steht nur im Log, nicht in der Antwort: /ready ist ohne +// Anmeldung erreichbar, und eine Fehlermeldung der Datenbank verrät Host, +// Port oder Benutzer. +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), bereitZeitlimit) + defer cancel() + + err := s.control.Ping(ctx) + meldeBereitschaft(err) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("nicht bereit\n")) + return + } + w.Write([]byte("ok\n")) +} + +// bereitschaft merkt sich das letzte Ergebnis von /ready: 0 = noch nie +// geprüft, 1 = bereit, 2 = nicht bereit. +var bereitschaft atomic.Int32 + +// meldeBereitschaft schreibt nur einen WECHSEL ins Log. /ready wird alle paar +// Sekunden abgefragt; bei einem Ausfall stünde sonst dieselbe Zeile hundertfach +// da, und die Meldung, wann es wieder ging, ginge darin unter. +func meldeBereitschaft(err error) { + if err != nil { + if bereitschaft.Swap(2) != 2 { + log.Printf("Bereitschaft: Datenbank antwortet nicht — /ready meldet 503: %v", err) + } + return + } + if bereitschaft.Swap(1) == 2 { + log.Printf("Bereitschaft: Datenbank antwortet wieder — /ready meldet ok") + } +} diff --git a/internal/web/handlers_entries.go b/internal/web/handlers_entries.go index 801daec..cc0b567 100644 --- a/internal/web/handlers_entries.go +++ b/internal/web/handlers_entries.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "munin/internal/dubletten" "munin/internal/entry" "munin/internal/store" ) @@ -25,6 +26,12 @@ type entryListData struct { Tag string Total int + // Veraltet: Die Liste zeigt nur Einträge, die seit über einem Jahr + // unverändert sind (?veraltet=1). VeraltetAnzahl nennt, wie viele das im + // ganzen Projekt sind — für den Filterlink, auch wenn er nicht aktiv ist. + Veraltet bool + VeraltetAnzahl int + // RetentionDays: so lange liegen gelöschte Einträge im Papierkorb — für // die Rückfrage beim Sammellöschen, damit dort dieselbe Zahl steht, nach // der auch aufgeräumt wird. @@ -35,6 +42,10 @@ type entryListData struct { type entryListItem struct { Entry store.Entry Snippet template.HTML // bei einer Suche: Textstelle mit hervorgehobenem Treffer + + // Stale: seit über einem Jahr unverändert (store.Entry.Stale). Die Liste + // zeigt dann dieselbe Marke wie die Einzelansicht. + Stale bool } // handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder @@ -46,19 +57,32 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) tag := strings.TrimSpace(r.URL.Query().Get("tag")) + veraltet := r.URL.Query().Get("veraltet") != "" + jetzt := time.Now() - daten := entryListData{Query: query, Tag: tag, RetentionDays: store.TrashRetentionDays} + daten := entryListData{Query: query, Tag: tag, Veraltet: veraltet, RetentionDays: store.TrashRetentionDays} var err error if query != "" { daten.Entries, err = s.searchEntries(r, project, query) } else { - daten.Entries, err = s.listEntries(r, project, tag) + filter := store.EntryFilter{Tag: tag, Limit: 100} + if veraltet { + filter.ChangedBefore = store.StaleCutoff(jetzt) + } + daten.Entries, err = s.listEntries(r, project, filter) } if err != nil { s.serverError(w, "Einträge konnten nicht geladen werden", err) return } + for i := range daten.Entries { + daten.Entries[i].Stale = daten.Entries[i].Entry.Stale(jetzt) + } + if daten.VeraltetAnzahl, err = ws.Store.CountStaleEntries(ctx, project.ID, jetzt); err != nil { + s.serverError(w, "Einträge konnten nicht gezählt werden", err) + return + } if daten.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil { s.serverError(w, "Schlagworte konnten nicht geladen werden", err) @@ -111,9 +135,9 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str } // listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst. -func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) { +func (s *Server) listEntries(r *http.Request, project store.Project, filter store.EntryFilter) ([]entryListItem, error) { ws, _ := WorkspaceFrom(r) - eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100}) + eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, filter) if err != nil { return nil, err } @@ -134,6 +158,12 @@ type entryViewData struct { HTML template.HTML Backlinks []store.Backlink + // Verwandt sind Einträge über einen Zwischeneintrag (zweite Reihe im + // Verweisgraphen, store.RelatedEntries). Die erste Reihe steht schon im + // Text (eigene Verweise) bzw. unter Backlinks — doppelt wäre Rauschen. + // Dasselbe liefert MCP "related" den Agenten. + Verwandt []store.RelatedEntry + // Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die // Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso // vorsichtig lesen wie ein Agent, dem MCP dasselbe sagt. @@ -169,17 +199,35 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) { return } + nachbarn, err := ws.Store.RelatedEntries(r.Context(), project.ID, slug, 2, verwandtLimit) + if err != nil { + s.serverError(w, "Verwandte Einträge konnten nicht geladen werden", err) + return + } + var verwandt []store.RelatedEntry + for _, n := range nachbarn { + if n.Distance == 2 { + verwandt = append(verwandt, n) + } + } + s.render(w, r, "entry.html", http.StatusOK, pageData{ Title: e.Title, Bereich: "eintraege", Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung Data: entryViewData{ Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks, - Stale: e.Stale(time.Now()), + Verwandt: verwandt, + Stale: e.Stale(time.Now()), }, }) } +// verwandtLimit begrenzt die Nachbarn, die die Eintragsansicht lädt (beide +// Reihen zusammen). Mehr als eine Handvoll in der zweiten Reihe hilft beim +// Lesen nicht, sondern lenkt ab; wer mehr will, hat den Graph. +const verwandtLimit = 30 + // entryEditData sind die Daten des Bearbeitungsformulars. type entryEditData struct { Entry store.Entry @@ -284,6 +332,54 @@ func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) { } } +// aehnlichItem ist ein vorhandener Eintrag, der dem gerade angelegten ähnelt +// — für den Hinweis „Gibt es schon?" im Formular (static/aehnlich.js). +type aehnlichItem struct { + Slug string `json:"slug"` + Title string `json:"title"` + Overlap int `json:"overlap"` + Gemeinsam []string `json:"gemeinsam"` +} + +// handleEntrySimilar liefert vorhandene Einträge, die einem neuen Titel +// ähneln — dieselbe Prüfung, mit der "remember" über MCP bei Agenten +// nachfragt (internal/dubletten). +// +// Anders als bei MCP ist das nur ein Hinweis: Das Formular zeigt die +// Kandidaten unter dem Titelfeld an, speichern lässt sich trotzdem. Ein +// Mensch, der die Liste sieht, entscheidet selbst; eine Sperre wäre hier nur +// lästig. +func (s *Server) handleEntrySimilar(w http.ResponseWriter, r *http.Request) { + ws, _ := WorkspaceFrom(r) + project, _ := ProjectFrom(r) + titel := strings.TrimSpace(r.URL.Query().Get("title")) + slug := strings.TrimSpace(r.URL.Query().Get("slug")) + + // Nicht nil, damit die Antwort immer ein JSON-Array ist. + antwort := []aehnlichItem{} + + if titel != "" { + if slug == "" { + slug = store.Slugify(titel) + } + kandidaten, err := dubletten.Pruefe(r.Context(), ws, project, slug, titel, "") + if err != nil { + s.serverError(w, "Ähnliche Einträge konnten nicht gesucht werden", err) + return + } + for _, k := range kandidaten { + antwort = append(antwort, aehnlichItem{ + Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, Gemeinsam: k.MatchedWords, + }) + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(antwort); err != nil { + log.Printf("Ähnliche Einträge konnten nicht gesendet werden: %v", err) + } +} + // handleEntryPreview rendert eingereichten Markdown-Text als HTML — für die // Vorschau im Bearbeitungsformular (siehe static/preview.js). // diff --git a/internal/web/handlers_hilfe.go b/internal/web/handlers_hilfe.go index 17b2eeb..d26061c 100644 --- a/internal/web/handlers_hilfe.go +++ b/internal/web/handlers_hilfe.go @@ -35,8 +35,9 @@ func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { // Eine Admin-Seite antwortet für alle anderen mit 404, genau wie eine // Seite, die es nicht gibt — sie steht für sie ja auch nirgends verlinkt. + leser := hilfe.Leser{PlattformAdmin: sess.IsPlatformAdmin, WorkspaceAdmin: sess.IsWorkspaceAdmin} seite, ok := hilfe.Finde(slug) - if !ok || (seite.NurAdmin && !sess.IsPlatformAdmin) { + if !ok || !seite.DarfLesen(leser) { http.NotFound(w, r) return } @@ -71,7 +72,7 @@ func (s *Server) handleHilfe(w http.ResponseWriter, r *http.Request) { Bereich: "hilfe", Data: hilfeData{ Seite: seite, - Seiten: hilfe.Sichtbar(sess.IsPlatformAdmin), + Seiten: hilfe.Sichtbar(leser), HTML: template.HTML(inhalt), }, } diff --git a/internal/web/handlers_nutzung.go b/internal/web/handlers_nutzung.go index 3b22d94..e707870 100644 --- a/internal/web/handlers_nutzung.go +++ b/internal/web/handlers_nutzung.go @@ -26,6 +26,7 @@ type usageData struct { Summary store.UsageSummary WindowDays int Untagged []store.EntryRef + Stale int // seit über einem Jahr unverändert (store.CountStaleEntries) Missing []store.MissingLink MissingMore int // wie viele Verweise ins Leere über die Liste hinaus Keys []store.APIKey @@ -86,7 +87,14 @@ func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) { return } + veraltet, err := ws.Store.CountStaleEntries(ctx, project.ID, time.Now()) + if err != nil { + s.serverError(w, "veraltete Einträge konnten nicht gezählt werden", err) + return + } + daten := usageData{ + Stale: veraltet, // Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map — // dann liefert alle[project.ID] den Nullwert, also überall 0. Summary: alle[project.ID], diff --git a/internal/web/handlers_suche.go b/internal/web/handlers_suche.go new file mode 100644 index 0000000..0c0d709 --- /dev/null +++ b/internal/web/handlers_suche.go @@ -0,0 +1,109 @@ +package web + +import ( + "errors" + "html/template" + "net/http" + "sort" + "strings" + + "munin/internal/entry" + "munin/internal/store" +) + +// Wie viele Treffer die Suche über alle Projekte je Projekt zeigt. Genug, um +// das richtige Projekt zu erkennen; wer mehr will, sucht dort weiter. +const workspaceSucheJeProjekt = 5 + +// workspaceSucheData sind die Daten der Suche über alle Projekte. +type workspaceSucheData struct { + Query string + Gruppen []sucheGruppe + Treffer int // Summe über alle Gruppen + Projekte int // so viele Projekte wurden durchsucht +} + +// sucheGruppe sind die Treffer eines Projekts. +type sucheGruppe struct { + Project store.Project + Items []entryListItem + Mehr bool // es gibt mehr als workspaceSucheJeProjekt Treffer + besten float64 +} + +// handleWorkspaceSearch sucht in allen Projekten des gewählten Workspace +// (AUSBAUSTUFEN.md 5.13) — für Workspace- und Plattform-Admins, die ohnehin +// alle Projekte sehen. Beantwortet „In welchem Projekt stand das nochmal?". +// +// Bewusst nur in der Weboberfläche und nur für diese Rollen: Am Zugriff der +// API-Keys ändert sich nichts, ein Key sieht weiter genau ein Projekt (siehe +// AUSBAUSTUFEN.md 4.9). +// +// Die Treffer kommen nach Projekt gruppiert. Eine gemeinsame Rangfolge über +// alle Projekte gäbe es nicht ehrlich: Jedes Projekt hat seinen eigenen +// BM25-Index, und deren Werte sind nicht vergleichbar. Die Projekte stehen +// aber nach ihrem besten Treffer sortiert — das passendste oben. +func (s *Server) handleWorkspaceSearch(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) + ctx := r.Context() + query := strings.TrimSpace(r.URL.Query().Get("q")) + + daten := workspaceSucheData{Query: query} + + if query != "" { + projekte, err := ws.Store.ListProjects(ctx, sess.WorkspaceID) + if err != nil { + s.serverError(w, "Projekte konnten nicht geladen werden", err) + return + } + daten.Projekte = len(projekte) + + for _, p := range projekte { + idx, err := ws.Search.For(ctx, p.ID) + if err != nil { + s.serverError(w, "Suchindex konnte nicht geladen werden", err) + return + } + // Einen mehr holen als gezeigt, um "weitere Treffer" anbieten zu + // können. + treffer := idx.SearchFuzzy(query, workspaceSucheJeProjekt+1) + if len(treffer) == 0 { + continue + } + + gruppe := sucheGruppe{Project: p, besten: treffer[0].Score} + for i, t := range treffer { + if i == workspaceSucheJeProjekt { + gruppe.Mehr = true + break + } + e, err := ws.Store.EntryBySlug(ctx, p.ID, t.DocID) + if errors.Is(err, store.ErrNotFound) { + continue // Index kennt einen Eintrag, den es nicht mehr gibt + } + if err != nil { + s.serverError(w, "Eintrag konnte nicht geladen werden", err) + return + } + gruppe.Items = append(gruppe.Items, entryListItem{ + Entry: e, + Snippet: template.HTML(entry.HighlightSnippet(e.Content, query)), + }) + } + if len(gruppe.Items) > 0 { + daten.Gruppen = append(daten.Gruppen, gruppe) + daten.Treffer += len(gruppe.Items) + } + } + + sort.SliceStable(daten.Gruppen, func(i, j int) bool { + return daten.Gruppen[i].besten > daten.Gruppen[j].besten + }) + } + + s.render(w, r, "suche.html", http.StatusOK, pageData{ + Title: "Suche in allen Projekten", + Bereich: "suche", + Data: daten, + }) +} diff --git a/internal/web/handlers_system.go b/internal/web/handlers_system.go new file mode 100644 index 0000000..23be865 --- /dev/null +++ b/internal/web/handlers_system.go @@ -0,0 +1,363 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "runtime" + "runtime/debug" + "strconv" + "strings" + "time" + + "munin/internal/export" + "munin/internal/statistik" + "munin/internal/store" +) + +// systemData sind die Daten der Systemseite (AUSBAUSTUFEN.md 5.12, 5.16). +type systemData struct { + Speicher string // store.Speicher.Beschreibung, ohne Passwort + Bereit bool // Kontroll-Datenbank antwortet + BereitText string // Grund, falls nicht — nur für den Plattform-Admin + + // Hinweise: Was zu tun ist, die Warnungen zuerst. Leer heißt: alles in + // Ordnung. + Hinweise []systemHinweis + + Kapazitaet store.Kapazitaet + + // Verbindungen: Kontroll-Datenbank und jeder geöffnete Workspace. + Kontrolle store.Verbindungen + Workspaces []systemWorkspace + Offen int // Summe der offenen Verbindungen aller Pools + Gewartet int64 + + AnzahlWorkspaces int // in der Kontroll-Datenbank, geöffnet oder nicht + + // Stand: Bestand, Belegung und Aktivität aller Workspaces, höchstens + // statistik.MindestAbstand alt. + Stand statistik.Stand + DiagrammSchreiben diagramm + DiagrammSuchen diagramm + TageImDiagramm int + TopProjekte []statistik.Projekt // die aktivsten, höchstens topProjekte + WeitereAktive int // aktive Projekte, die nicht mehr in die Liste passen + ExportEingerichtet bool + Export export.Lauf + + Fassung string + GoFassung string + Gestartet time.Time + Laufzeit string +} + +// systemWorkspace ist ein geöffneter Workspace auf der Systemseite. +type systemWorkspace struct { + ID string + Name string + Ort string + Verbindungen store.Verbindungen +} + +// systemHinweis ist eine Zeile im Kasten "Zustand" oben auf der Seite. +type systemHinweis struct { + Warnung bool // true: sollte behoben werden; false: zur Kenntnis + Text string +} + +const ( + // walGrenze: Ab dieser Größe der WAL-Datei warnt die Systemseite. + // SQLite schreibt die WAL-Datei normalerweise ab etwa 4 MB in die + // Datenbank zurück (1000 Seiten); ist sie das Sechzehnfache, kommt das + // Zurückschreiben offenbar nicht durch. + walGrenze = 64 << 20 + + // kapazitaetWarnung: Ab diesem Anteil der höchstens möglichen Workspaces + // (in Prozent) warnt die Seite — bevor Munin neue ablehnt. + kapazitaetWarnung = 80 + + // exportSpielraum: So lange darf ein Export über sein Intervall hinaus + // ausbleiben, bevor er als überfällig gilt. Ein großer Lauf braucht + // selbst etwas Zeit. + exportSpielraum = 2 * time.Hour + + // topProjekte: so viele Projekte zeigt die Liste "am aktivsten". + topProjekte = 10 +) + +// handleSystem zeigt dem Plattform-Admin, worauf Munin läuft, ob etwas zu tun +// ist und wie Munin genutzt wird. +// +// Gedacht vor allem für PostgreSQL: Dort teilt sich Munin eine feste Zahl an +// Verbindungen (max_connections). Die Seite zeigt, wie viele gerade offen +// sind, wie viele Workspaces die Grenze erlaubt und ob Anfragen auf +// Verbindungen warten mussten — bevor neue Workspaces abgelehnt werden. +// +// Seit AUSBAUSTUFEN.md 5.16 kommen dazu: ein Kasten mit allem, was zu tun ist, +// die Aktivität der letzten 30 Tage und Bestand und Platz je Workspace. +// +// Nur lesend und nur für den Plattform-Admin: Host und Datenbankname sind +// Betriebsinterna (das Passwort steht nirgends). +func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + jetzt := time.Now() + speicher := s.workspaces.Speicher() + + daten := systemData{ + Speicher: speicher.Beschreibung(), + Kontrolle: s.control.Verbindungen(), + ExportEingerichtet: s.exporter != nil, + Fassung: munFassung(), + GoFassung: runtime.Version(), + Gestartet: s.gestartet, + Laufzeit: laufzeit(jetzt.Sub(s.gestartet)), + } + + pingCtx, cancel := context.WithTimeout(ctx, bereitZeitlimit) + if err := s.control.Ping(pingCtx); err != nil { + daten.BereitText = err.Error() + } else { + daten.Bereit = true + } + cancel() + + if k, err := speicher.Kapazitaet(ctx); err == nil { + daten.Kapazitaet = k + } + + namen := map[string]string{} + if alle, err := s.control.ListWorkspaces(ctx); err == nil { + daten.AnzahlWorkspaces = len(alle) + for _, ws := range alle { + namen[ws.ID] = ws.Name + } + } + + // Die Kennzahlen aller Workspaces — nur, wenn die Datenbank antwortet. + // Sonst liefe jede Abfrage einzeln in ihr Zeitlimit. + if daten.Bereit { + daten.Stand = s.statistik.Aktuell(ctx, jetzt) + } + daten.TageImDiagramm = len(daten.Stand.Tage) + daten.DiagrammSchreiben = baueDiagramm("Schreibvorgänge je Tag", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, daten.Stand.Tage, + diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "balken-agent", name: "Agenten"}, + diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "balken-mensch", name: "Menschen"}, + ) + daten.DiagrammSuchen = baueDiagramm("Suchen je Tag", diagrammEinheit{"Suche", "Suchen"}, daten.Stand.Tage, + diagrammReihe{wert: func(t statistik.Tag) int { return t.Suchen }, klasse: "balken-suche", name: "Suchen"}, + ) + daten.TopProjekte = daten.Stand.Projekte + if len(daten.TopProjekte) > topProjekte { + daten.WeitereAktive = len(daten.TopProjekte) - topProjekte + daten.TopProjekte = daten.TopProjekte[:topProjekte] + } + + daten.Offen = daten.Kontrolle.Offen() + daten.Gewartet = daten.Kontrolle.Gewartet() + for _, offen := range s.workspaces.Offen() { + v := offen.Store.Verbindungen() + daten.Workspaces = append(daten.Workspaces, systemWorkspace{ + ID: offen.ID, Name: namen[offen.ID], Ort: s.workspaces.Ort(offen.ID), Verbindungen: v, + }) + daten.Offen += v.Offen() + daten.Gewartet += v.Gewartet() + } + + if s.exporter != nil { + daten.Export = s.exporter.LetzterLauf() + } + daten.Hinweise = s.systemHinweise(ctx, jetzt, daten, speicher.Hinweise()) + + s.render(w, r, "system.html", http.StatusOK, pageData{ + Title: "System", + Bereich: "system", + Data: daten, + }) +} + +// systemHinweise stellt zusammen, was der Plattform-Admin wissen sollte: erst +// die Warnungen (sollte behoben werden), dann die Hinweise (zur Kenntnis). +// +// Fast alles davon prüft Munin auch an anderer Stelle — beim Start, auf der +// Seite "Zugänge", im Log des Exports. Hier steht es an einem Ort, damit ein +// Blick genügt. +func (s *Server) systemHinweise(ctx context.Context, jetzt time.Time, d systemData, speicherHinweise []string) []systemHinweis { + var warnungen, hinweise []systemHinweis + warnung := func(format string, args ...any) { + warnungen = append(warnungen, systemHinweis{Warnung: true, Text: fmt.Sprintf(format, args...)}) + } + hinweis := func(format string, args ...any) { + hinweise = append(hinweise, systemHinweis{Text: fmt.Sprintf(format, args...)}) + } + + // ── Warnungen ──────────────────────────────────────────────────────────── + + if k := d.Kapazitaet; k.Begrenzt && k.MaxWorkspaces() > 0 && d.AnzahlWorkspaces*100 >= k.MaxWorkspaces()*kapazitaetWarnung { + warnung("%d von höchstens %d Workspaces sind angelegt. Vor weiteren beim Datenbank-Admin max_connections erhöhen lassen (README, „Mit PostgreSQL“).", + d.AnzahlWorkspaces, k.MaxWorkspaces()) + } + + for _, h := range speicherHinweise { + warnung("%s", h) + } + + for _, f := range d.Stand.Fehler { + warnung("Workspace ließ sich nicht auswerten — %s", f) + } + + for _, ws := range d.Stand.Workspaces { + if ws.Belegung.WAL > walGrenze { + warnung("Die WAL-Datei von „%s“ ist %s groß. SQLite schreibt sie offenbar nicht mehr in die Datenbank zurück — meist hält eine Leseverbindung zu lange offen. Ein Neustart behebt es; tritt es wieder auf, ins Log schauen.", + ws.Name, groesse(ws.Belegung.WAL)) + } + if ws.SchemaAbweichend(d.Stand.Schemastand) { + warnung("„%s“ steht auf Schema %s, dieses Programm kennt höchstens %s. Läuft hier eine ältere Fassung von Munin als zuletzt?", + ws.Name, ws.Schema, d.Stand.Schemastand) + } + } + + if d.ExportEingerichtet { + l := d.Export + switch { + case l.Fehler != "": + warnung("Der letzte Export (%s) war unvollständig: %s", l.Ende.Local().Format("02.01. 15:04"), l.Fehler) + case l.Ende.IsZero() && jetzt.Sub(s.gestartet) > time.Hour: + warnung("Seit dem Start vor %s ist kein Export fertig geworden. Läuft er noch, oder hängt er? Siehe Log.", laufzeit(jetzt.Sub(s.gestartet))) + case !l.Ende.IsZero() && jetzt.Sub(l.Ende) > export.Intervall+exportSpielraum: + warnung("Der letzte Export ist von %s — er läuft sonst täglich. Siehe Log.", l.Ende.Local().Format("02.01. 15:04")) + } + } + + // ── Hinweise ───────────────────────────────────────────────────────────── + + // Die Differenz zur vorigen Erhebung; bei der ersten seit dem Start. Ein + // gelöschter Workspace nimmt seine Zahlen mit, deshalb nie unter null. + if seit := max(0, s.statistik.Gewartet()-d.Stand.GewartetVorher); seit > 0 && !d.Stand.Erhoben.IsZero() { + ab := "dem Start" + if !d.Stand.VorherErhoben.IsZero() { + ab = d.Stand.VorherErhoben.Local().Format("15:04") + } + hinweis("Seit %s mussten Anfragen %d× auf eine freie Datenbankverbindung warten. Ab und zu ist normal; kommt es bei jedem Blick hierher vor, sind die Pools zu klein.", + ab, seit) + } + + if n, wo := zaehleJeWorkspace(d.Stand.Workspaces, func(b store.Bestand) int { return b.KeysLaufenAb }); n > 0 { + hinweis("%s in den nächsten %d Tagen ab (%s). Noch gebraucht? Dann rechtzeitig einen neuen anlegen.", + plural(n, "Key läuft", "Keys laufen"), int(store.ExpiresSoonWithin.Hours()/24), wo) + } + if n, wo := zaehleJeWorkspace(d.Stand.Workspaces, func(b store.Bestand) int { return b.KeysUngenutzt }); n > 0 { + hinweis("%s seit %d Tagen nicht benutzt (%s). Zu finden unter „Zugänge“ der Projekte, markiert mit „ungenutzt“.", + plural(n, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24), wo) + } + + if admins, err := s.control.ListActiveWorkspaceAdmins(ctx); err == nil { + ungenutzt := 0 + for _, a := range admins { + if a.Unused(jetzt) { + ungenutzt++ + } + } + if ungenutzt > 0 { + hinweis("%s seit %d Tagen nicht benutzt — unter „Workspaces“ prüfen und gegebenenfalls zurückziehen.", + plural(ungenutzt, "Workspace-Admin-Zugang ist", "Workspace-Admin-Zugänge sind"), int(store.UnusedAfter.Hours()/24)) + } + } + + return append(warnungen, hinweise...) +} + +// zaehleJeWorkspace zählt einen Wert über alle Workspaces und nennt dazu, wo +// er vorkommt: "Intern 2, Vertrieb 1". +func zaehleJeWorkspace(liste []statistik.Workspace, wert func(store.Bestand) int) (int, string) { + summe := 0 + var wo []string + for _, ws := range liste { + if n := wert(ws.Bestand); n > 0 { + summe += n + wo = append(wo, fmt.Sprintf("%s %d", ws.Name, n)) + } + } + return summe, strings.Join(wo, ", ") +} + +// groesse zeigt eine Zahl von Bytes lesbar an, mit Komma wie im Deutschen: +// "812 KB", "1,4 GB". Gerechnet wird in Schritten von 1024 — so zählen auch +// Dateisysteme und PostgreSQL (pg_size_pretty). +func groesse(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + wert := float64(bytes) + for _, einheit := range []string{"KB", "MB", "GB", "TB"} { + wert /= 1024 + if wert < 1024 || einheit == "TB" { + // Eine Nachkommastelle nur bei kleinen Werten — "812,3 KB" ist + // genauer, als jemand wissen will. + text := fmt.Sprintf("%.0f %s", wert, einheit) + if wert < 10 { + text = strings.Replace(fmt.Sprintf("%.1f %s", wert, einheit), ".", ",", 1) + } + return text + } + } + return "" // nicht erreichbar: die Schleife endet spätestens bei TB +} + +// munFassung liest die Fassung aus den Angaben, die Go beim Bauen ins +// Programm schreibt: die Git-Fassung (gekürzt), ihr Datum und ob es +// ungespeicherte Änderungen gab. Bei "go run" fehlt das meist. +func munFassung() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "unbekannt" + } + var fassung, zeit string + geaendert := false + for _, e := range info.Settings { + switch e.Key { + case "vcs.revision": + fassung = e.Value + if len(fassung) > 12 { + fassung = fassung[:12] + } + case "vcs.time": + zeit = e.Value + case "vcs.modified": + geaendert = e.Value == "true" + } + } + if fassung == "" { + return "Entwicklungsstand (ohne Git-Angabe gebaut)" + } + if zeit != "" { + fassung += " vom " + zeit + } + if geaendert { + fassung += ", mit nicht eingecheckten Änderungen" + } + return fassung +} + +// laufzeit schreibt eine Dauer grob und lesbar: "3 Tage, 4 Std." statt +// "76h12m3.4s". +func laufzeit(d time.Duration) string { + tage := int(d.Hours()) / 24 + stunden := int(d.Hours()) % 24 + minuten := int(d.Minutes()) % 60 + switch { + case tage > 0: + return plural(tage, "Tag", "Tage") + ", " + plural(stunden, "Std.", "Std.") + case stunden > 0: + return plural(stunden, "Std.", "Std.") + ", " + plural(minuten, "Min.", "Min.") + default: + return plural(minuten, "Min.", "Min.") + } +} + +func plural(n int, eins, mehr string) string { + if n == 1 { + return "1 " + eins + } + return strconv.Itoa(n) + " " + mehr +} diff --git a/internal/web/handlers_ueberblick.go b/internal/web/handlers_ueberblick.go new file mode 100644 index 0000000..c26663d --- /dev/null +++ b/internal/web/handlers_ueberblick.go @@ -0,0 +1,286 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "sort" + "time" + + "munin/internal/auth" + "munin/internal/statistik" + "munin/internal/store" +) + +// Überblick eines Workspace (AUSBAUSTUFEN.md 5.17). +// +// Das Gegenstück zur Systemseite, aber für den Workspace-Admin: nur sein +// Workspace und nur, was er selbst in die Hand nehmen kann — Zugänge, die +// Pflege der Inhalte, die Frage, ob die Teams Munin benutzen. Verbindungen, +// Platz, Schema und Export bleiben auf der Systemseite; daran kann er nichts +// ändern. + +// Schwellen für die Hinweise. Bewusst grob: Sie sollen auf ein Projekt +// aufmerksam machen, nicht jede Schwankung melden. +const ( + // Ab so vielen Suchen im Zeitraum ist ein Anteil ohne Treffer + // aussagekräftig — bei drei Suchen wäre eine leere schon 33 %. + leereSuchenAb = 10 + + // Ab diesem Anteil ohne Treffer (in Prozent) lohnt ein Blick: Dann + // fehlt Wissen, oder Agenten suchen mit anderen Wörtern, als in den + // Einträgen stehen. + leereSuchenProzent = 30 + + // pruefenHoechstens: So viele Einträge „zur Prüfung“ liest der + // Überblick je Projekt, um sie zu zählen. CuratedOverrides braucht eine + // Obergrenze; mehr als das prüft ohnehin niemand am Stück. + pruefenHoechstens = 1000 +) + +// ueberblickData sind die Daten des Überblicks. +type ueberblickData struct { + Workspace string // Name + + // Gruppen: was zu tun ist, je Projekt zusammengefasst. Projekte mit + // einer Warnung zuerst, sonst alphabetisch. Projekte ohne Befund fehlen. + Gruppen []ueberblickGruppe + + Projekte []ueberblickProjekt // alphabetisch + Summe store.Bestand + Schreiben int // im Zeitraum, alle Projekte + Suchen int + TageImDiagramm int + + DiagrammSchreiben diagramm + DiagrammSuchen diagramm +} + +// ueberblickGruppe sind die Befunde eines Projekts. Je Projekt eine Karte +// statt einer Zeile je Befund: Bei 20 Projekten mit je drei Befunden würde +// eine flache Liste unlesbar lang, und derselbe Projektname stünde dreimal +// untereinander. +type ueberblickGruppe struct { + Projekt string // Name + Warnung bool // mindestens ein Befund ist dringend + Punkte []ueberblickHinweis +} + +// ueberblickHinweis ist ein Befund, mit einem Link genau dorthin, wo er zu +// erledigen ist. +type ueberblickHinweis struct { + Warnung bool + Text string + Link string + LinkText string +} + +// ueberblickProjekt ist eine Zeile der Projekttabelle. +type ueberblickProjekt struct { + statistik.Projekt + Eintraege int + Zuletzt time.Time // jüngste Änderung eines Eintrags; Nullzeitpunkt = keine + OhneTreffer int // Anteil der Suchen ohne Treffer in Prozent, -1 = keine Suchen +} + +// handleUeberblick zeigt den Überblick des gewählten Workspace — für +// Workspace-Admins und den Plattform-Admin (der damit sieht, was der +// Workspace-Admin sieht). +func (s *Server) handleUeberblick(w http.ResponseWriter, r *http.Request) { + ws, sess := s.workspaceAndSession(r) + ctx := r.Context() + jetzt := time.Now() + + eintrag, err := s.control.WorkspaceByID(ctx, sess.WorkspaceID) + if err != nil { + s.serverError(w, "Workspace konnte nicht gelesen werden", err) + return + } + + // Aktivität je Tag und je Projekt: dieselbe Zählung wie auf der + // Systemseite, nur für diesen einen Workspace. + stand := s.statistik.FuerWorkspace(ctx, eintrag, jetzt) + if len(stand.Fehler) > 0 { + s.serverError(w, "Überblick konnte nicht erhoben werden", fmt.Errorf("%v", stand.Fehler)) + return + } + + // Einträge und jüngste Änderung je Projekt, in einer Abfrage für alle. + nutzung, err := ws.Store.UsageSummaries(ctx, "", jetzt) + if err != nil { + s.serverError(w, "Nutzung konnte nicht gelesen werden", err) + return + } + + daten := ueberblickData{ + Workspace: eintrag.Name, + TageImDiagramm: len(stand.Tage), + } + if len(stand.Workspaces) == 1 { + daten.Summe = stand.Workspaces[0].Bestand + } + for _, t := range stand.Tage { + daten.Schreiben += t.Schreiben() + daten.Suchen += t.Suchen + } + daten.DiagrammSchreiben = baueDiagramm("Schreibvorgänge je Tag", diagrammEinheit{"Schreibvorgang", "Schreibvorgänge"}, stand.Tage, + diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenAgent }, klasse: "balken-agent", name: "Agenten"}, + diagrammReihe{wert: func(t statistik.Tag) int { return t.SchreibenMensch }, klasse: "balken-mensch", name: "Menschen"}, + ) + daten.DiagrammSuchen = baueDiagramm("Suchen je Tag", diagrammEinheit{"Suche", "Suchen"}, stand.Tage, + diagrammReihe{wert: func(t statistik.Tag) int { return t.Suchen }, klasse: "balken-suche", name: "Suchen"}, + ) + + // Aktive und ruhige Projekte kommen getrennt aus der Erhebung; hier + // stehen alle in einer Tabelle, alphabetisch. + alle := append(append([]statistik.Projekt{}, stand.Projekte...), stand.Ruhig...) + sort.Slice(alle, func(i, j int) bool { return alle[i].Name < alle[j].Name }) + + for _, p := range alle { + zeile := ueberblickProjekt{Projekt: p, OhneTreffer: -1} + u := nutzung[p.ID] + zeile.Eintraege, zeile.Zuletzt = u.Entries, u.LastUpdatedAt + + suchen, err := ws.Store.SearchStatsFor(ctx, p.ID, jetzt) + if err != nil { + s.serverError(w, "Suchprotokoll konnte nicht gelesen werden", err) + return + } + if suchen.Total > 0 { + zeile.OhneTreffer = suchen.EmptyPercent() + } + daten.Projekte = append(daten.Projekte, zeile) + + warnungen, hinweise, err := projektHinweise(ctx, ws.Store, p, suchen, jetzt) + if err != nil { + s.serverError(w, "Hinweise konnten nicht erhoben werden", err) + return + } + if len(warnungen)+len(hinweise) > 0 { + daten.Gruppen = append(daten.Gruppen, ueberblickGruppe{ + Projekt: p.Name, + Warnung: len(warnungen) > 0, + Punkte: append(warnungen, hinweise...), // Dringendes zuerst + }) + } + } + // Projekte mit Warnung nach oben. SliceStable erhält innerhalb beider + // Gruppen die alphabetische Reihenfolge von oben. + sort.SliceStable(daten.Gruppen, func(i, j int) bool { + return daten.Gruppen[i].Warnung && !daten.Gruppen[j].Warnung + }) + + s.render(w, r, "ueberblick.html", http.StatusOK, pageData{ + Title: "Überblick", + Bereich: "ueberblick", + Data: daten, + }) +} + +// projektHinweise prüft ein Projekt auf das, was ein Workspace-Admin +// erledigen kann. Warnungen sind dringend (ein Zugang endet bald, niemand +// kommt hinein), Hinweise eine Bitte, bei Gelegenheit hinzusehen. +// +// Jede Prüfung nimmt dieselbe Regel wie die Seite, auf die sie verlinkt — +// "ungenutzt" wie unter Zugänge, "veraltet" wie in der Eintragsliste, "zur +// Prüfung" wie auf der Nutzungsseite. Wer dem Link folgt, findet dort genau +// das, was hier gezählt wurde. +func projektHinweise(ctx context.Context, st *store.Store, p statistik.Projekt, suchen store.SearchStats, jetzt time.Time) (warnungen, hinweise []ueberblickHinweis, err error) { + zugaenge := "/p/" + p.Slug + "/keys" + nutzung := "/p/" + p.Slug + "/usage" + + keys, err := st.ListAPIKeys(ctx, p.ID) + if err != nil { + return nil, nil, err + } + gueltig, admins, ungenutzt := 0, 0, 0 + for _, k := range keys { + if !k.Valid(jetzt) { + continue + } + gueltig++ + if k.Role == string(auth.RoleAdmin) { + admins++ + } + if k.Unused(jetzt) { + ungenutzt++ + } + if k.ExpiresSoon(jetzt) { + // Wie bisTag: angezeigt wird der letzte gültige Tag. + warnungen = append(warnungen, ueberblickHinweis{ + Warnung: true, + Text: fmt.Sprintf("Der Key „%s“ gilt nur noch bis %s. Noch gebraucht? Dann jetzt einen neuen anlegen und im Client austauschen.", + k.Name, k.ExpiresAt.Add(-time.Second).Local().Format("02.01.2006")), + Link: zugaenge, LinkText: "Zugänge", + }) + } + } + + switch { + case gueltig == 0: + warnungen = append(warnungen, ueberblickHinweis{ + Warnung: true, + Text: "Kein gültiger Key — weder das Team noch ein Agent kommt hinein.", + Link: zugaenge, LinkText: "Key anlegen", + }) + case admins == 0: + hinweise = append(hinweise, ueberblickHinweis{ + Text: "Kein Key mit der Rolle admin — das Team kann seine Zugänge nicht selbst verwalten und muss jedes Mal dich fragen.", + Link: zugaenge, LinkText: "Zugänge", + }) + } + if ungenutzt > 0 { + hinweise = append(hinweise, ueberblickHinweis{ + Text: fmt.Sprintf("%s seit %d Tagen nicht benutzt. Weiß niemand mehr, wofür, zurückziehen.", + plural(ungenutzt, "Key ist", "Keys sind"), int(store.UnusedAfter.Hours()/24)), + Link: zugaenge, LinkText: "Zugänge", + }) + } + + // Ein Projekt ohne Key ist schon oben gewarnt; dass dort nichts + // geschieht, ist dann keine Nachricht. + if p.Summe() == 0 && gueltig > 0 { + hinweise = append(hinweise, ueberblickHinweis{ + Text: fmt.Sprintf("In %d Tagen weder geschrieben noch gesucht. Wird das Projekt noch gebraucht, oder fehlt dem Team die Anbindung?", statistik.Tage), + Link: nutzung, LinkText: "Nutzung", + }) + } + + if suchen.Total >= leereSuchenAb && suchen.EmptyPercent() >= leereSuchenProzent { + hinweise = append(hinweise, ueberblickHinweis{ + Text: fmt.Sprintf("%d %% der Suchen fanden nichts. Entweder fehlt Wissen, oder die Agenten suchen mit anderen Wörtern, als in den Einträgen stehen.", + suchen.EmptyPercent()), + Link: nutzung, LinkText: "Suchen ohne Treffer", + }) + } + + pruefen, err := st.CuratedOverrides(ctx, p.ID, pruefenHoechstens) + if err != nil { + return nil, nil, err + } + if n := len(pruefen); n > 0 { + text := "Ein kuratierter Eintrag wurde von einem Agenten geändert und wartet auf Prüfung durch das Team." + if n > 1 { + text = fmt.Sprintf("%d kuratierte Einträge wurden von Agenten geändert und warten auf Prüfung durch das Team.", n) + } + hinweise = append(hinweise, ueberblickHinweis{ + Text: text, Link: nutzung, LinkText: "Zur Prüfung", + }) + } + + veraltet, err := st.CountStaleEntries(ctx, p.ID, jetzt) + if err != nil { + return nil, nil, err + } + if veraltet > 0 { + text := "Ein Eintrag ist seit über einem Jahr unverändert. Das Team prüfen lassen, ob er noch stimmt." + if veraltet > 1 { + text = fmt.Sprintf("%d Einträge sind seit über einem Jahr unverändert. Das Team prüfen lassen, ob sie noch stimmen.", veraltet) + } + hinweise = append(hinweise, ueberblickHinweis{ + Text: text, Link: "/p/" + p.Slug + "/?veraltet=1", LinkText: "Veraltete Einträge", + }) + } + + return warnungen, hinweise, nil +} diff --git a/internal/web/handlers_workspaces.go b/internal/web/handlers_workspaces.go index 2fcfb7c..5f68bc3 100644 --- a/internal/web/handlers_workspaces.go +++ b/internal/web/handlers_workspaces.go @@ -26,6 +26,10 @@ type workspaceItem struct { CreatedAt string Projects int + // Ort ist das PostgreSQL-Schema bzw. die SQLite-Datei des Workspace — + // damit der Plattform-Admin ihn in der Datenbank wiederfindet. + Ort string + // Admins zählt die aktiven Workspace-Admins, AdminsUngenutzt die davon, // die seit über 90 Tagen niemand benutzt hat (store.WorkspaceAdmin.Unused). Admins int @@ -74,6 +78,7 @@ func (s *Server) renderWorkspaceList(w http.ResponseWriter, r *http.Request, sta for i, ws := range workspaces { items[len(workspaces)-1-i] = workspaceItem{ ID: ws.ID, Name: ws.Name, CreatedAt: ws.CreatedAt.Format("2006-01-02"), + Ort: s.workspaces.Ort(ws.ID), Projects: s.projectCount(r, ws.ID), Admins: aktiv[ws.ID], AdminsUngenutzt: ungenutzt[ws.ID], diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 14cca20..b058dcc 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -136,8 +136,10 @@ func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, b return Session{}, false } + // Zurückgezogen oder abgelaufen: Die Sitzung endet bei der nächsten + // Anfrage, nicht erst beim Ablauf des Cookies. resolved, err := ws.Store.APIKeyByID(r.Context(), keyID) - if err != nil || resolved.Revoked() { + if err != nil || !resolved.Valid(time.Now()) { return Session{}, false } @@ -628,11 +630,12 @@ func (w *statusSchreiber) Flush() { // // - Die Query-Zeichenkette. Dort stehen Suchbegriffe, und die sind Inhalt, // kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde. -// - Statische Dateien und der Healthcheck. Beides erzeugt nur Rauschen; der -// Healthcheck läuft alle 30 Sekunden. +// - Statische Dateien und die Prüfungen /live, /ready (und /healthz). Das +// erzeugt nur Rauschen; die Prüfungen laufen alle paar Sekunden. Ein +// Ausfall der Datenbank steht trotzdem im Log, siehe meldeBereitschaft. func LogRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" { + if strings.HasPrefix(r.URL.Path, "/static/") || istPruefpfad(r.URL.Path) { next.ServeHTTP(w, r) return } @@ -662,3 +665,8 @@ func LogRequests(next http.Handler) http.Handler { time.Since(start).Round(time.Microsecond), wer) }) } + +// istPruefpfad erkennt die Prüfungen für Container und Load-Balancer. +func istPruefpfad(pfad string) bool { + return pfad == "/live" || pfad == "/ready" || pfad == "/healthz" +} diff --git a/internal/web/oberflaeche_test.go b/internal/web/oberflaeche_test.go new file mode 100644 index 0000000..e483257 --- /dev/null +++ b/internal/web/oberflaeche_test.go @@ -0,0 +1,388 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "munin/internal/auth" + "munin/internal/store" +) + +// Tests zu den Ergänzungen der Weboberfläche vom 2026-10-16 (AUSBAUSTUFEN.md +// 4.7 Stufe 2 und 5.7–5.14): veraltete Einträge, „Auch verwandt", „Gibt es +// schon?", ungespeicherte Änderungen, Tastenkürzel, Protokollfilter, +// Systemseite, Ablaufdatum für Keys und Suche über alle Projekte. + +// legeAn schreibt einen Eintrag direkt in den Store. +func legeAn(t *testing.T, db *store.Store, projekt store.Project, w store.EntryWrite) { + t.Helper() + w.ProjectID = projekt.ID + if w.Source == "" { + w.Source = store.SourceHuman + } + if _, err := db.PutEntry(context.Background(), w); err != nil { + t.Fatal(err) + } +} + +// TestVeralteteEintraege: Die Liste markiert Einträge, die seit über einem +// Jahr unverändert sind, nennt ihre Zahl als Filterlink und filtert auf +// Wunsch. Die Nutzungsseite nennt die Zahl ebenfalls. +func TestVeralteteEintraege(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) + + vor2Jahren := time.Now().Add(-2 * 365 * 24 * time.Hour) + legeAn(t, db, projekt, store.EntryWrite{Slug: "alt", Title: "Uralter Eintrag", Content: "x", + CreatedAt: vor2Jahren, UpdatedAt: vor2Jahren}) + legeAn(t, db, projekt, store.EntryWrite{Slug: "neu", Title: "Frischer Eintrag", Content: "y"}) + + liste := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/").Body.String() + if !strings.Contains(liste, ">1 veraltet</a>") { + t.Error("Filterlink „1 veraltet“ fehlt in der Liste") + } + if strings.Count(liste, `class="marke warnung"`) != 1 { + t.Error("genau ein Eintrag sollte als veraltet markiert sein") + } + + gefiltert := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/?veraltet=1").Body.String() + if !strings.Contains(gefiltert, "Uralter Eintrag") || strings.Contains(gefiltert, "Frischer Eintrag") { + t.Error("Filter veraltet zeigt nicht genau den alten Eintrag") + } + + nutzung := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/usage").Body.String() + if !strings.Contains(nutzung, `href="/p/`+projekt.Slug+`/?veraltet=1">1</a>`) { + t.Error("Nutzungsseite nennt die Zahl der veralteten Einträge nicht") + } +} + +// TestAuchVerwandt: Die Eintragsansicht zeigt die zweite Reihe im +// Verweisgraphen samt Zwischeneintrag — a verweist auf b, b auf c, also ist +// c über b mit a verwandt. +func TestAuchVerwandt(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead)) + + legeAn(t, db, projekt, store.EntryWrite{Slug: "a", Title: "Eintrag A", Content: "siehe [[b]]"}) + legeAn(t, db, projekt, store.EntryWrite{Slug: "b", Title: "Eintrag B", Content: "siehe [[c]]"}) + legeAn(t, db, projekt, store.EntryWrite{Slug: "c", Title: "Eintrag C", Content: "Ende"}) + + seite := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/e/a").Body.String() + _, kasten, gefunden := strings.Cut(seite, "Auch verwandt") + if !gefunden { + t.Fatal("Kasten „Auch verwandt“ fehlt") + } + if !strings.Contains(kasten, "Eintrag C") || !strings.Contains(kasten, `über <a href="/p/`+projekt.Slug+`/e/b">b</a>`) { + t.Errorf("C über B fehlt im Kasten") + } + + // Ohne zweite Reihe kein Kasten: c verweist auf nichts, b auf c, a auf b + // — von b aus sind a und c direkte Nachbarn. + if strings.Contains(seiteHolen(h, cookie, "/p/"+projekt.Slug+"/e/b").Body.String(), "Auch verwandt") { + t.Error("Kasten erscheint, obwohl es keine zweite Reihe gibt") + } +} + +// TestAehnlicheEintraege: Das Formular fragt beim Tippen nach ähnlichen +// Einträgen — dieselbe Prüfung wie "remember" über MCP. +func TestAehnlicheEintraege(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + schreiben := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) + lesen := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead)) + + legeAn(t, db, projekt, store.EntryWrite{Slug: "deployment-prozess", Title: "Deployment-Prozess", Content: "Pipeline"}) + + frage := func(cookie *http.Cookie, titel, slug string) (int, []aehnlichItem) { + w := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/entries/similar?title="+titel+"&slug="+slug) + var antwort []aehnlichItem + if w.Code == http.StatusOK { + if err := json.Unmarshal(w.Body.Bytes(), &antwort); err != nil { + t.Fatalf("keine JSON-Liste: %v (%s)", err, w.Body.String()) + } + } + return w.Code, antwort + } + + code, antwort := frage(schreiben, "Deployment-Ablauf", "") + if code != http.StatusOK || len(antwort) != 1 || antwort[0].Slug != "deployment-prozess" { + t.Fatalf("Antwort = %d %+v, erwartet deployment-prozess", code, antwort) + } + if strings.Join(antwort[0].Gemeinsam, ",") != "deployment" { + t.Errorf("gemeinsame Wörter = %v, erwartet deployment", antwort[0].Gemeinsam) + } + + if _, antwort := frage(schreiben, "VPN-Zugang", ""); len(antwort) != 0 { + t.Errorf("unähnlicher Titel liefert Kandidaten: %+v", antwort) + } + // Der Bezeichner gibt es schon: Das wäre eine Aktualisierung, kein Hinweis. + if _, antwort := frage(schreiben, "Deployment-Ablauf", "deployment-prozess"); len(antwort) != 0 { + t.Errorf("bei vorhandenem Bezeichner kommen Kandidaten: %+v", antwort) + } + // Leserechte reichen nicht — das Formular gibt es für sie auch nicht. + if code, _ := frage(lesen, "Deployment-Ablauf", ""); code == http.StatusOK { + t.Error("Lesezugang bekommt ähnliche Einträge") + } +} + +// TestFormularUndKuerzel: Das Bearbeitungsformular trägt die Attribute für +// Rückfrage, Speichern per Tastatur und „Gibt es schon?"; die Kürzel n und e +// erscheinen nur, wo geschrieben werden darf. +func TestFormularUndKuerzel(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + schreiben := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) + lesen := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead)) + legeAn(t, db, projekt, store.EntryWrite{Slug: "x", Title: "X", Content: "x"}) + + neu := seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/new").Body.String() + for _, attr := range []string{"data-ungespeichert", "data-kuerzel-speichern", "data-aehnlich-url"} { + if !strings.Contains(neu, attr) { + t.Errorf("%s fehlt im Formular für neue Einträge", attr) + } + } + // Beim Bearbeiten gibt es nichts „schon": kein Hinweis auf ähnliche. + if strings.Contains(seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/e/x/edit").Body.String(), "data-aehnlich-url") { + t.Error("„Gibt es schon?“ erscheint beim Bearbeiten") + } + + if !strings.Contains(seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/").Body.String(), `data-kuerzel="n"`) { + t.Error("Kürzel n fehlt mit Schreibrecht") + } + if strings.Contains(seiteHolen(h, lesen, "/p/"+projekt.Slug+"/").Body.String(), `data-kuerzel="n"`) { + t.Error("Kürzel n erscheint ohne Schreibrecht") + } + if strings.Contains(seiteHolen(h, lesen, "/p/"+projekt.Slug+"/e/x").Body.String(), `data-kuerzel="e"`) { + t.Error("Kürzel e erscheint ohne Schreibrecht") + } + + for _, datei := range []string{"ungespeichert.js", "tastenkuerzel.js", "aehnlich.js"} { + if w := seiteHolen(h, lesen, "/static/"+datei); w.Code != http.StatusOK { + t.Errorf("/static/%s = %d", datei, w.Code) + } + } +} + +// TestProtokollFiltern: Die Protokollseite filtert nach Vorgang und Zugang +// und behält die Auswahl im Formular. +func TestProtokollFiltern(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite)) + ctx := context.Background() + + db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-a", Action: store.ActionEntryWrite, + EntrySlug: "geschrieben-von-a", Source: store.SourceMCP}) + db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-b", Action: store.ActionEntryDelete, + EntrySlug: "geloescht-von-b", Source: store.SourceMCP}) + + seite := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/audit?vorgang=entry_delete&wer=agent-b").Body.String() + if !strings.Contains(seite, "geloescht-von-b") || strings.Contains(seite, "geschrieben-von-a") { + t.Error("Filter zeigt nicht genau den Löschvorgang von agent-b") + } + if !strings.Contains(seite, `<option value="agent-b" selected>`) || + !strings.Contains(seite, `<option value="entry_delete" selected>`) { + t.Error("Auswahl bleibt im Formular nicht erhalten") + } + + // Unsinn in der Adresse: ungefiltert statt Fehlerseite. + w := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/audit?vorgang=gibts-nicht&von=gestern") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "geschrieben-von-a") { + t.Errorf("kaputter Filter: %d, erwartet das ungefilterte Protokoll", w.Code) + } +} + +// TestSystemseite: nur für den Plattform-Admin; zeigt Speicher, Bereitschaft +// und die geöffneten Workspaces mit ihrem Speicherort. +func TestSystemseite(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + admin := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + w := seiteHolen(h, admin, "/admin/system") + if w.Code != http.StatusOK { + t.Fatalf("/admin/system = %d", w.Code) + } + seite := w.Body.String() + for _, erwartet := range []string{"SQLite", "bereit", "keins — jede SQLite-Datei", projekt.WorkspaceID + ".db", "Kontroll-Datenbank"} { + if !strings.Contains(seite, erwartet) { + t.Errorf("%q fehlt auf der Systemseite", erwartet) + } + } + + // Ein Projektzugang kommt nicht dran. + key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin)) + if code := seiteHolen(h, key, "/admin/system").Code; code == http.StatusOK { + t.Error("Projekt-Admin sieht die Systemseite") + } +} + +// TestSystemseiteZustand: Zustand, Aktivität und Bestand (AUSBAUSTUFEN.md +// 5.16). Ein bald ablaufender Key erscheint als Hinweis, ein geschriebener +// Eintrag im Diagramm und in der Bestandstabelle. +func TestSystemseiteZustand(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + if _, err := db.CreateAPIKeyWithExpiry(ctx, projekt.ID, "praktikum", "hash-praktikum", string(auth.RoleRead), + time.Now().Add(5*24*time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := db.PutEntry(ctx, store.EntryWrite{ + ProjectID: projekt.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceHuman, + }); err != nil { + t.Fatal(err) + } + db.LogAudit(ctx, store.AuditEvent{ + ProjectID: projekt.ID, Actor: "test", Action: store.ActionEntryWrite, Source: store.SourceWeb, + }) + + admin := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + seite := seiteHolen(h, admin, "/admin/system").Body.String() + + for _, erwartet := range []string{ + "Zustand", + "1 Key läuft in den nächsten 14 Tagen ab", + "Aktivität der letzten 30 Tage", + `<svg class="diagramm"`, + "1 Schreibvorgang (Agenten 0, Menschen 1)", // Tooltip des heutigen Tages + "Bestand je Workspace", + "Schema", + } { + if !strings.Contains(seite, erwartet) { + t.Errorf("%q fehlt auf der Systemseite", erwartet) + } + } + // Ohne Suchen bleibt das zweite Diagramm leer und sagt das. + if !strings.Contains(seite, "Keine im ganzen Zeitraum") { + t.Error("leeres Suchdiagramm ohne Hinweis") + } +} + +// TestKeyMitAblaufdatum: Ein Key lässt sich mit "gültig bis" anlegen, die +// Liste zeigt das Datum und markiert ihn als bald ablaufend. Läuft er ab, +// endet auch eine bestehende Sitzung. +func TestKeyMitAblaufdatum(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + admin := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalter", auth.RoleAdmin)) + + // Ein Datum in der Vergangenheit wird abgelehnt. (Zuerst, weil + // formularSenden das CSRF-Token mit dem jüngsten Key des Projekts bildet + // — das muss noch der angemeldete sein.) + gestern := time.Now().AddDate(0, 0, -2).Format("2006-01-02") + w := formularSenden(t, h, db, projekt, admin, "/p/"+projekt.Slug+"/keys/create", url.Values{ + "name": {"zu-spaet"}, "role": {"read"}, "expires": {gestern}, + }) + if w.Code != http.StatusBadRequest { + t.Errorf("Ablauf in der Vergangenheit: %d, erwartet 400", w.Code) + } + + morgen := time.Now().AddDate(0, 0, 1) + w = formularSenden(t, h, db, projekt, admin, "/p/"+projekt.Slug+"/keys/create", url.Values{ + "name": {"praktikum"}, "role": {"read"}, "expires": {morgen.Format("2006-01-02")}, + }) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Key angelegt") { + t.Fatalf("Anlegen: %d", w.Code) + } + liste := seiteHolen(h, admin, "/p/"+projekt.Slug+"/keys").Body.String() + if !strings.Contains(liste, morgen.Format("02.01.2006")) || !strings.Contains(liste, "läuft bald ab") { + t.Error("Liste zeigt das Ablaufdatum oder „läuft bald ab“ nicht") + } + + // Sitzung eines Keys, der gleich abläuft: danach kein Zugriff mehr. + klartext, _ := auth.GenerateKey() + kurz, err := db.CreateAPIKeyWithExpiry(context.Background(), projekt.ID, "kurz", auth.HashKey(klartext), "read", + time.Now().Add(1500*time.Millisecond)) + if err != nil { + t.Fatal(err) + } + if err := srv.control.IndexKey(context.Background(), kurz.Hash, projekt.WorkspaceID); err != nil { + t.Fatal(err) + } + sitzung := anmelden(t, h, klartext) + if code := seiteHolen(h, sitzung, "/p/"+projekt.Slug+"/").Code; code != http.StatusOK { + t.Fatalf("vor dem Ablauf: %d", code) + } + time.Sleep(2 * time.Second) + if code := seiteHolen(h, sitzung, "/p/"+projekt.Slug+"/").Code; code == http.StatusOK { + t.Error("Sitzung gilt nach dem Ablauf des Keys weiter") + } +} + +// TestSucheInAllenProjekten: Workspace- und Plattform-Admin finden Einträge +// aus allen Projekten des Workspace, nach Projekt gruppiert. Ein +// Projektzugang kommt nicht dran — er sieht weiter nur sein Projekt. +func TestSucheInAllenProjekten(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "vertrieb", "Vertrieb", "") + if err != nil { + t.Fatal(err) + } + legeAn(t, db, projekt, store.EntryWrite{Slug: "vpn", Title: "VPN-Zugang Technik", Content: "Zugang über das Firmen-VPN."}) + legeAn(t, db, zweites, store.EntryWrite{Slug: "vpn-vertrieb", Title: "VPN unterwegs", Content: "VPN für Außendienst."}) + legeAn(t, db, zweites, store.EntryWrite{Slug: "preise", Title: "Preisliste", Content: "Rabatte."}) + + pruefe := func(name string, seite string) { + t.Helper() + for _, erwartet := range []string{"VPN-Zugang Technik", "VPN unterwegs", ">Testprojekt</a>", ">Vertrieb</a>"} { + if !strings.Contains(seite, erwartet) { + t.Errorf("%s: %q fehlt", name, erwartet) + } + } + if strings.Contains(seite, "Preisliste") { + t.Errorf("%s: unpassender Eintrag „Preisliste“ in den Treffern", name) + } + } + + plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + pruefe("Plattform-Admin", seiteHolen(h, plattform, "/admin/search?q=vpn").Body.String()) + + wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + pruefe("Workspace-Admin", wsAdmin.get(h, "/admin/search?q=vpn").Body.String()) + + key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin)) + if w := seiteHolen(h, key, "/admin/search?q=vpn"); w.Code == http.StatusOK && strings.Contains(w.Body.String(), "VPN unterwegs") { + t.Error("Projektzugang findet Einträge aus einem anderen Projekt") + } +} + +// TestHilfeJeRolle: Die Seite für Workspace-Admins sehen Workspace- und +// Plattform-Admin, die für Plattform-Admins nur dieser. Ein Projektzugang +// sieht keine von beiden — auch nicht über die Adresse. +func TestHilfeJeRolle(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + + key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin)) + wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID) + + for _, f := range []struct { + name string + hole func(string) int + workspace, plattSei int + }{ + {"Projektzugang", func(p string) int { return seiteHolen(h, key, p).Code }, http.StatusNotFound, http.StatusNotFound}, + {"Workspace-Admin", func(p string) int { return wsAdmin.get(h, p).Code }, http.StatusOK, http.StatusNotFound}, + {"Plattform-Admin", func(p string) int { return seiteHolen(h, plattform, p).Code }, http.StatusOK, http.StatusOK}, + } { + if code := f.hole("/hilfe/workspace-admin"); code != f.workspace { + t.Errorf("%s: /hilfe/workspace-admin = %d, erwartet %d", f.name, code, f.workspace) + } + if code := f.hole("/hilfe/plattform-admin"); code != f.plattSei { + t.Errorf("%s: /hilfe/plattform-admin = %d, erwartet %d", f.name, code, f.plattSei) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 193f796..85bca2e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -6,9 +6,11 @@ import ( "io/fs" "log" "net/http" + "time" "munin/internal/auth" "munin/internal/export" + "munin/internal/statistik" "munin/internal/store" "munin/internal/workspace" ) @@ -40,6 +42,14 @@ type Server struct { // herum und der Vergleich läuft über dieselbe Funktion wie bei den API-Keys. adminTokenHash string + // gestartet ist der Zeitpunkt, zu dem der Server gebaut wurde — für die + // Laufzeit auf der Systemseite. + gestartet time.Time + + // statistik zählt die Kennzahlen aller Workspaces für die Systemseite + // und merkt sie sich ein paar Minuten (siehe internal/statistik). + statistik *statistik.Sammler + // adminFingerprint steckt in jeder Sitzung des Plattform-Admins und wird // bei jedem Request verglichen. Ändert sich das ADMIN_TOKEN, passt er // nicht mehr, und alle Admin-Sitzungen enden (siehe auth.PlatformSubject). @@ -80,6 +90,8 @@ func NewServer(cfg Config) (*Server, error) { exporter: cfg.Exporter, writeBudget: cfg.WriteBudget, adminTokenHash: adminTokenHash, + gestartet: time.Now(), + statistik: statistik.NewSammler(cfg.Control, cfg.Workspaces), adminFingerprint: cfg.Signer.Fingerprint(adminTokenHash), }, nil @@ -94,12 +106,22 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { // Statische Dateien und Healthcheck brauchen keine Anmeldung. mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) - // Der Healthcheck gibt bewusst nichts über den Zustand der Projekte preis — - // eine Statusseite wäre eine unnötige Auskunftsstelle nach außen. - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write([]byte("ok\n")) - }) + // Zwei Prüfungen für Container-Plattformen, Load-Balancer und Monitoring. + // Beide geben bewusst nichts über den Zustand der Projekte preis — eine + // Statusseite wäre eine unnötige Auskunftsstelle nach außen. + // + // /live Läuft der Prozess? Antwortet immer, solange er läuft. Daran + // hängt der Neustart des Containers — er soll NICHT neu starten, + // nur weil die Datenbank kurz weg ist; das hülfe nicht. + // /ready Kann er Anfragen bedienen? Prüft die Datenbank. Daran hängt, + // ob ein Load-Balancer Anfragen schickt und ob das Monitoring + // Alarm schlägt. + // + // /healthz ist der frühere Name von /live und bleibt, damit vorhandene + // Healthchecks und Monitore nicht ins Leere laufen. + mux.HandleFunc("GET /live", handleLive) + mux.HandleFunc("GET /healthz", handleLive) + mux.HandleFunc("GET /ready", s.handleReady) mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("POST /login", s.handleLoginSubmit) @@ -135,6 +157,9 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { // /admin/workspaces, sonst wäre er mit "create"/"select"/"delete" mehrdeutig. mux.Handle("GET /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditForm))) mux.Handle("POST /admin/workspaces/{id}/edit", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceEditSave))) + + // Systemseite: Speicher, Verbindungen, Fassung — nur der Plattform-Admin. + mux.Handle("GET /admin/system", geschuetzt(s.requirePlatformAdminFunc(s.handleSystem))) // Workspace-Admins eines Workspace anlegen und zurückziehen — nur der // Plattform-Admin, siehe handlers_workspace_admins.go. mux.Handle("POST /admin/workspaces/{id}/admins/create", geschuetzt(s.requirePlatformAdminFunc(s.handleWorkspaceAdminCreate))) @@ -162,6 +187,13 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { mux.Handle("GET /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditForm)) mux.Handle("POST /admin/projects/{slug}/edit", projektVerwaltung(s.handleProjectEditSave)) mux.Handle("GET /admin/audit", projektVerwaltung(s.handleGlobalAudit)) + // Suche über alle Projekte des Workspace — wer alle Projekte verwalten + // darf, darf auch in allen suchen (AUSBAUSTUFEN.md 5.13). + mux.Handle("GET /admin/search", projektVerwaltung(s.handleWorkspaceSearch)) + // Überblick des Workspace: was zu tun ist, Aktivität, Projekte + // (AUSBAUSTUFEN.md 5.17). Das Gegenstück zur Systemseite für den + // Workspace-Admin. + mux.Handle("GET /admin/overview", projektVerwaltung(s.handleUeberblick)) mux.Handle("POST /admin/export", imWorkspace(s.handleExport)) // Download als ZIP: unabhängig von EXPORT_DIR, für alle, die Projekte // verwalten — also auch den Workspace-Admin (siehe handlers_download.go). @@ -172,6 +204,7 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) { mux.Handle("GET /p/{project}/new", mitSchreibrecht(s.handleEntryNew)) mux.Handle("POST /p/{project}/preview", mitSchreibrecht(s.handleEntryPreview)) mux.Handle("GET /p/{project}/entries/suggest", mitSchreibrecht(s.handleEntrySuggest)) + mux.Handle("GET /p/{project}/entries/similar", mitSchreibrecht(s.handleEntrySimilar)) mux.Handle("GET /p/{project}/e/{slug}", imProjekt(s.handleEntryView)) mux.Handle("GET /p/{project}/e/{slug}/versions", imProjekt(s.handleEntryVersions)) mux.Handle("GET /p/{project}/e/{slug}/versions/{version}", imProjekt(s.handleEntryDiff)) @@ -394,6 +427,14 @@ func templateFuncs() template.FuncMap { "datum": func(t interface{ Format(string) string }) string { return t.Format("02.01.2006 15:04") }, + // bisTag zeigt ein Ablaufdatum als letzten gültigen Tag: Ein Key, der + // "bis einschließlich 31.12." gilt, läuft am 1.1. um 0 Uhr ab — + // angezeigt wird der 31.12. + "bisTag": func(ablauf time.Time) string { + return ablauf.Add(-time.Second).Local().Format("02.01.2006") + }, + // groesse zeigt eine Zahl von Bytes lesbar an: "812 KB", "1,4 GB". + "groesse": groesse, // join fügt Schlagworte zu einer Zeile zusammen. "join": func(teile []string, trenner string) string { out := "" diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 4d01c70..447d6ac 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -43,7 +43,7 @@ func testServer(t *testing.T) (*Server, *store.Store, store.Project) { if err := os.MkdirAll(workspacesDir, 0750); err != nil { t.Fatal(err) } - workspaces := workspace.NewManager(workspacesDir) + workspaces := workspace.NewManager(store.NewSQLite("", workspacesDir)) t.Cleanup(func() { workspaces.Close() }) ctx := context.Background() @@ -505,7 +505,8 @@ func TestWorkspaceCreateSelectsItAndRedirects(t *testing.T) { } // TestWorkspaceListShowsProjectCount prüft, dass die Übersicht die -// tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum. +// tatsächliche Projektzahl je Workspace zeigt, nicht nur Name und Datum — +// und unter dem Namen den Speicherort (Datei bzw. Schema). func TestWorkspaceListShowsProjectCount(t *testing.T) { srv, db, projekt := testServer(t) h := testHandler(t, srv) @@ -527,6 +528,12 @@ func TestWorkspaceListShowsProjectCount(t *testing.T) { if !strings.Contains(w.Body.String(), "<td class=\"zahl\">2</td>") { t.Error("Projektzahl \"2\" steht nicht in der Übersicht") } + + // Unter dem Namen steht, wo die Daten liegen — hier die SQLite-Datei. + ort := "workspaces/" + projekt.WorkspaceID + ".db" + if !strings.Contains(w.Body.String(), ort) { + t.Errorf("Speicherort %q steht nicht in der Übersicht", ort) + } } // TestWorkspaceRename prüft das Umbenennen über /admin/workspaces/{id}/edit. @@ -1720,7 +1727,7 @@ func TestLogRequestsSchweigtBeiRauschen(t *testing.T) { defer log.SetOutput(os.Stderr) h := LogRequests(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - for _, pfad := range []string{"/healthz", "/static/style.css"} { + for _, pfad := range []string{"/live", "/ready", "/healthz", "/static/style.css"} { h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, pfad, nil)) } @@ -2661,3 +2668,38 @@ func TestAdminProjektlisteZeigtNutzung(t *testing.T) { t.Error("der Schreibvorgang erscheint nicht in der Projektliste") } } + +// TestLiveUndReady prüft die beiden Prüfungen für Container und +// Load-Balancer: Beide antworten ohne Anmeldung. /ready meldet 503, sobald die +// Datenbank nicht antwortet, /live bleibt dabei ok — sonst würde der +// Container neu gestartet, was gegen eine fehlende Datenbank nicht hilft. +func TestLiveUndReady(t *testing.T) { + srv, _, _ := testServer(t) + h := testHandler(t, srv) + + abfragen := func(pfad string) (int, string) { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, pfad, nil)) + return w.Code, w.Body.String() + } + + for _, pfad := range []string{"/live", "/healthz", "/ready"} { + if code, body := abfragen(pfad); code != http.StatusOK || body != "ok\n" { + t.Errorf("%s = %d %q, erwartet 200 ok", pfad, code, body) + } + } + + // Datenbank weg: Die Kontroll-Datenbank wird geschlossen. + srv.control.Close() + + code, body := abfragen("/ready") + if code != http.StatusServiceUnavailable { + t.Errorf("/ready ohne Datenbank = %d, erwartet 503", code) + } + if body != "nicht bereit\n" { + t.Errorf("/ready verrät mehr als nötig: %q", body) + } + if code, _ := abfragen("/live"); code != http.StatusOK { + t.Errorf("/live ohne Datenbank = %d, erwartet weiter 200", code) + } +} diff --git a/internal/web/workspace_admin_test.go b/internal/web/workspace_admin_test.go index 0f5ca5c..3fb4f4f 100644 --- a/internal/web/workspace_admin_test.go +++ b/internal/web/workspace_admin_test.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "testing" + "time" "munin/internal/auth" "munin/internal/store" @@ -476,3 +477,81 @@ func TestWorkspaceAdminRevokeRejectsForeignID(t *testing.T) { t.Error("der Zugang aus Workspace B wurde zurückgezogen") } } + +// TestWorkspaceAdminUeberblick: Der Überblick (AUSBAUSTUFEN.md 5.17) nennt je +// Projekt, was zu tun ist — mit Link dorthin —, zeigt die Projekttabelle und +// die Aktivität. Ein Projektzugang kommt nicht dran. +func TestWorkspaceAdminUeberblick(t *testing.T) { + srv, db, projekt := testServer(t) + h := testHandler(t, srv) + ctx := context.Background() + + // Projekt 1: ein admin-Key, der in fünf Tagen abläuft, und ein Eintrag. + if _, err := db.CreateAPIKeyWithExpiry(ctx, projekt.ID, "praktikum", "hash-praktikum", string(auth.RoleAdmin), + time.Now().Add(5*24*time.Hour)); err != nil { + t.Fatal(err) + } + if _, err := db.PutEntry(ctx, store.EntryWrite{ + ProjectID: projekt.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent, + }); err != nil { + t.Fatal(err) + } + db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP}) + + // Projekt 2: nur ein Lese-Key, nie benutzt. + ruhig, err := db.CreateProject(ctx, projekt.WorkspaceID, "ruhig", "Ruhiges Projekt", "") + if err != nil { + t.Fatal(err) + } + if _, err := db.CreateAPIKey(ctx, ruhig.ID, "leser", "hash-leser", string(auth.RoleRead)); err != nil { + t.Fatal(err) + } + + // Projekt 3: gar kein Key. + if _, err := db.CreateProject(ctx, projekt.WorkspaceID, "leer", "Leeres Projekt", ""); err != nil { + t.Fatal(err) + } + + admin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID) + w := admin.get(h, "/admin/overview") + if w.Code != http.StatusOK { + t.Fatalf("/admin/overview = %d", w.Code) + } + seite := w.Body.String() + + for _, erwartet := range []string{ + "Der Key „praktikum“ gilt nur noch bis", + "/p/" + projekt.Slug + "/keys", + "Kein gültiger Key", + "Kein Key mit der Rolle admin", + "In 30 Tagen weder geschrieben noch gesucht", + "/p/ruhig/usage", + `<span class="zustand-art">Ruhiges Projekt</span>`, // Name über den Befunden + `<svg class="diagramm"`, + } { + if !strings.Contains(seite, erwartet) { + t.Errorf("%q fehlt im Überblick", erwartet) + } + } + // Das Projekt ohne Key ist schon rot gewarnt; „ohne Aktivität“ wäre + // dort nur Wiederholung. Genau einmal heißt: nur für "ruhig". + if n := strings.Count(seite, "weder geschrieben noch gesucht"); n != 1 { + t.Errorf("„ohne Aktivität“ %d-mal, erwartet einmal (nur das Projekt mit Key)", n) + } + // Warnungen stehen vor Hinweisen. + if strings.Index(seite, "gilt nur noch bis") > strings.Index(seite, "Kein Key mit der Rolle admin") { + t.Error("Warnung steht hinter einem Hinweis") + } + // Technisches gehört auf die Systemseite, nicht hierher. + for _, fehlt := range []string{"max_connections", "WAL", "Kontroll-Datenbank"} { + if strings.Contains(seite, fehlt) { + t.Errorf("%q steht im Überblick des Workspace-Admins", fehlt) + } + } + + // Ein Projektzugang kommt nicht dran, auch nicht mit Rolle admin. + key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "projekt-admin", auth.RoleAdmin)) + if code := seiteHolen(h, key, "/admin/overview").Code; code == http.StatusOK { + t.Error("Projekt-Admin sieht den Überblick des Workspace") + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 01d4377..76c1c7c 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -1,16 +1,17 @@ -// Package workspace verwaltet die offenen Workspace-Dateien eines laufenden +// Package workspace verwaltet die offenen Workspaces eines laufenden // Prozesses. // -// Jede Abteilung hat ihre eigene SQLite-Datei (siehe AUSBAUSTUFEN.md 4.4). Ein -// Manager öffnet sie bei Bedarf und hält sie offen, statt bei jedem Request -// neu zu öffnen — das Öffnen (inklusive Migrationsprüfung) kostet spürbar mehr -// als ein Map-Zugriff. +// Jede Abteilung hat ihren eigenen, abgeschotteten Bereich (siehe +// AUSBAUSTUFEN.md 4.4): eine SQLite-Datei oder ein PostgreSQL-Schema — welches +// von beiden, entscheidet der store.Speicher (AUSBAUSTUFEN.md 4.10). Ein +// Manager öffnet die Bereiche bei Bedarf und hält sie offen, statt bei jedem +// Request neu zu öffnen — das Öffnen (inklusive Migrationsprüfung) kostet +// spürbar mehr als ein Map-Zugriff. package workspace import ( "fmt" - "os" - "path/filepath" + "sort" "sync" "munin/internal/search" @@ -33,16 +34,15 @@ type Workspace struct { // Verdrängung selten gebrauchter Dateien würde erst bei einer deutlich // höheren Zahl nötig, siehe AUSBAUSTUFEN.md 1.2. type Manager struct { - dir string // Ordner mit einer Datei je Workspace, z.B. "<DATA_DIR>/workspaces" + speicher store.Speicher // SQLite-Dateien oder PostgreSQL-Schemas mu sync.Mutex open map[string]*Workspace } -// NewManager legt einen Manager an. dir muss bereits existieren — das -// übernimmt main.go beim Start. -func NewManager(dir string) *Manager { - return &Manager{dir: dir, open: make(map[string]*Workspace)} +// NewManager legt einen Manager an, der seine Workspaces über speicher öffnet. +func NewManager(speicher store.Speicher) *Manager { + return &Manager{speicher: speicher, open: make(map[string]*Workspace)} } // Get liefert den Workspace zu einer ID und öffnet ihn beim ersten Zugriff. @@ -54,7 +54,7 @@ func (m *Manager) Get(workspaceID string) (*Workspace, error) { return w, nil } - s, err := store.Open(filepath.Join(m.dir, workspaceID+".db")) + s, err := m.speicher.OpenWorkspace(workspaceID) if err != nil { return nil, fmt.Errorf("Workspace %s konnte nicht geöffnet werden: %w", workspaceID, err) } @@ -64,14 +64,14 @@ func (m *Manager) Get(workspaceID string) (*Workspace, error) { return w, nil } -// Delete schließt die Workspace-Datei, falls offen, und entfernt sie -// unwiederbringlich von der Platte — samt WAL/SHM-Nebendateien, die SQLite im -// laufenden Betrieb danebenlegt. +// Delete schließt den Workspace, falls offen, und entfernt seinen Bereich +// unwiederbringlich — die Datei samt Nebendateien bzw. das Schema samt +// Tabellen (siehe store.Speicher.DeleteWorkspace). // -// Ruft nur die Datei ab — die Kontroll-Datenbank (workspaces, key_index) muss -// getrennt bereinigt werden, siehe ControlStore.DeleteWorkspace. Reihenfolge -// ist wichtig: Erst dort löschen, dann hier — sonst verweist die -// Kontroll-Datenbank kurzzeitig auf eine Datei, die es nicht mehr gibt. +// Räumt nur den Bereich ab — die Kontroll-Datenbank (workspaces, key_index) +// muss getrennt bereinigt werden, siehe ControlStore.DeleteWorkspace. +// Reihenfolge ist wichtig: Erst dort löschen, dann hier — sonst verweist die +// Kontroll-Datenbank kurzzeitig auf einen Bereich, den es nicht mehr gibt. func (m *Manager) Delete(workspaceID string) error { m.mu.Lock() if w, ok := m.open[workspaceID]; ok { @@ -80,13 +80,31 @@ func (m *Manager) Delete(workspaceID string) error { } m.mu.Unlock() - pfad := filepath.Join(m.dir, workspaceID+".db") - for _, endung := range []string{"", "-wal", "-shm"} { - if err := os.Remove(pfad + endung); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("Workspace-Datei konnte nicht gelöscht werden: %w", err) - } + return m.speicher.DeleteWorkspace(workspaceID) +} + +// Speicher gibt den Speicher zurück, über den der Manager öffnet — für die +// Systemseite (Beschreibung, Kapazität). +func (m *Manager) Speicher() store.Speicher { return m.speicher } + +// Offen liefert die gerade geöffneten Workspaces, nach ID sortiert. Eine +// Momentaufnahme: Währenddessen kann ein weiterer geöffnet werden. +func (m *Manager) Offen() []*Workspace { + m.mu.Lock() + defer m.mu.Unlock() + + liste := make([]*Workspace, 0, len(m.open)) + for _, w := range m.open { + liste = append(liste, w) } - return nil + sort.Slice(liste, func(i, j int) bool { return liste[i].ID < liste[j].ID }) + return liste +} + +// Ort nennt, wo die Daten eines Workspace liegen — Schema oder Datei (siehe +// store.Speicher.Ort). Öffnet dafür nichts. +func (m *Manager) Ort(workspaceID string) string { + return m.speicher.Ort(workspaceID) } // Close schließt alle offenen Workspace-Dateien. diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 367a32d..1a1cbdf 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -8,7 +8,7 @@ import ( ) func TestManagerGetCachesAndOpens(t *testing.T) { - m := NewManager(t.TempDir()) + m := NewManager(store.NewSQLite("", t.TempDir())) t.Cleanup(func() { m.Close() }) id := store.NewID() @@ -39,7 +39,7 @@ func TestManagerGetCachesAndOpens(t *testing.T) { } func TestManagerGetOpensSeparateFilesPerWorkspace(t *testing.T) { - m := NewManager(t.TempDir()) + m := NewManager(store.NewSQLite("", t.TempDir())) t.Cleanup(func() { m.Close() }) a, err := m.Get(store.NewID()) @@ -64,7 +64,7 @@ func TestManagerGetOpensSeparateFilesPerWorkspace(t *testing.T) { } func TestManagerCloseClosesAllOpened(t *testing.T) { - m := NewManager(t.TempDir()) + m := NewManager(store.NewSQLite("", t.TempDir())) w, err := m.Get(store.NewID()) if err != nil { diff --git a/last_test.go b/last_test.go index afe9aa1..2d28fb3 100644 --- a/last_test.go +++ b/last_test.go @@ -98,7 +98,7 @@ func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) { t.Fatal(err) } t.Cleanup(func() { control.Close() }) - workspaces := workspace.NewManager(dir) + workspaces := workspace.NewManager(store.NewSQLite("", dir)) t.Cleanup(func() { workspaces.Close() }) // Dieselbe Verdrahtung wie im Betrieb. Schreibbudget 0 = unbegrenzt — es diff --git a/main.go b/main.go index f1e2035..31f51d0 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "os/signal" "path/filepath" "strconv" + "strings" "syscall" "time" @@ -102,9 +103,6 @@ const ( writeLimitWindow = time.Hour ) -// exportInterval bestimmt, wie oft der Markdown-Export automatisch läuft. -const exportInterval = 24 * time.Hour - // auditRetention bestimmt, wie lange Protokolleinträge aufbewahrt werden. // // Das Protokoll enthält IP-Adressen und Zugangsnamen, aus denen sich Personen @@ -127,37 +125,52 @@ func main() { cfg := readConfig() + ctx := context.Background() + // ── Datenbank ──────────────────────────────────────────────────────────── // // Aufbau: eine zentrale Kontroll-Datenbank (Workspaces, Key-Routing) und je - // Workspace eine eigene Datei mit Projekten, Einträgen und Keys. Eine - // Abteilung kann so strukturell nie auf die Datei einer anderen zugreifen — + // Workspace ein eigener Bereich mit Projekten, Einträgen und Keys. Eine + // Abteilung kann so strukturell nie auf die Daten einer anderen zugreifen — // siehe AUSBAUSTUFEN.md 4.4. - workspacesDir := filepath.Join(cfg.dataDir, "workspaces") - if err := os.MkdirAll(workspacesDir, 0750); err != nil { - log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err) + // + // Wo diese Bereiche liegen — SQLite-Dateien oder PostgreSQL-Schemas — + // entscheidet DB_DRIVER (AUSBAUSTUFEN.md 4.10). Ab hier weiß davon nur + // noch der Speicher. + speicher, err := oeffneSpeicher(ctx, cfg) + if err != nil { + log.Fatal(err) } + defer speicher.Close() - control, err := store.OpenControl(filepath.Join(cfg.dataDir, "control.db")) + control, err := speicher.OpenControl() if err != nil { log.Fatal(err) } defer control.Close() - // workspaces öffnet und cached die Workspace-Dateien bei Bedarf — jede - // Abteilung bekommt so ihre eigene Datenbank samt Suchindex, siehe + // workspaces öffnet und cached die Workspaces bei Bedarf — jede Abteilung + // bekommt so ihre eigene Datenbank samt Suchindex, siehe // internal/workspace. - workspaces := workspace.NewManager(workspacesDir) + workspaces := workspace.NewManager(speicher) defer workspaces.Close() - ctx := context.Background() - // Beim allerersten Start gibt es noch keinen Workspace — dann wird einer // angelegt, damit der Server ohne Zwischenschritt benutzbar ist. if _, err := control.EnsureDefaultWorkspace(ctx, "Intern"); err != nil { log.Fatal(err) } + // Reichen die Datenbankverbindungen für alle Workspaces? Unter PostgreSQL + // lieber jetzt mit klarer Meldung nicht starten als später unter Last mit + // abgelehnten Verbindungen scheitern. Unter SQLite gibt es diese Grenze + // nicht. + if alle, err := control.ListWorkspaces(ctx); err != nil { + log.Fatal(err) + } else if err := speicher.PruefeKapazitaet(ctx, len(alle)); err != nil { + log.Fatal(err) + } + // Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist. var exporter *export.Exporter if cfg.exportDir != "" { @@ -215,7 +228,7 @@ func main() { go func() { alle, _ := control.ListWorkspaces(ctx) log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(alle)) - log.Printf("Workspaces: %s (Kontrolle: %s)", workspacesDir, filepath.Join(cfg.dataDir, "control.db")) + log.Printf("Speicher: %s", speicher.Beschreibung()) if !cfg.enableHSTS { log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.") } @@ -306,8 +319,18 @@ func baueHandler(control *store.ControlStore, workspaces *workspace.Manager, exp // config sammelt alle Einstellungen aus der Umgebung. type config struct { - // dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner - // workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md 4.4. + // dbDriver wählt den Speicher: "sqlite" (Standard) oder "postgres" — + // siehe AUSBAUSTUFEN.md 4.10. + dbDriver string + + // databaseURL ist die Verbindung zu PostgreSQL, z.B. + // postgres://munin:geheim@localhost:5432/munin?sslmode=disable. + // Nur bei dbDriver "postgres". + databaseURL string + + // dataDir enthält bei SQLite die Kontroll-Datenbank (control.db) und den + // Ordner workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md + // 4.4. Bei PostgreSQL wird er nicht gebraucht. dataDir string port string adminToken string @@ -328,6 +351,8 @@ type config struct { // jedes Projekt. Ein erratbarer Wert wäre eine Hintertür. func readConfig() config { cfg := config{ + dbDriver: strings.ToLower(envOr("DB_DRIVER", dbDriverSQLite)), + databaseURL: os.Getenv("DATABASE_URL"), dataDir: envOr("DATA_DIR", "data"), port: envOr("PORT", "8080"), adminToken: os.Getenv("ADMIN_TOKEN"), @@ -339,6 +364,16 @@ func readConfig() config { writeLimitHourly: envInt("WRITE_LIMIT_HOURLY", defaultWriteLimitHourly), } + switch cfg.dbDriver { + case dbDriverSQLite: + case dbDriverPostgres: + if cfg.databaseURL == "" { + log.Fatal("DB_DRIVER=postgres, aber DATABASE_URL ist nicht gesetzt. Beispiel: postgres://munin:geheim@localhost:5432/munin?sslmode=disable") + } + default: + log.Fatalf("DB_DRIVER=%q ist unbekannt — erlaubt sind %q (Standard) und %q.", cfg.dbDriver, dbDriverSQLite, dbDriverPostgres) + } + if cfg.sessionSecret == "" { log.Fatal("SESSION_SECRET ist nicht gesetzt — signiert Sitzungen und CSRF-Tokens. Erzeugen mit: openssl rand -hex 32") } @@ -364,6 +399,29 @@ func readConfig() config { return cfg } +// Die Werte für DB_DRIVER. +const ( + dbDriverSQLite = "sqlite" + dbDriverPostgres = "postgres" +) + +// oeffneSpeicher wählt anhand von DB_DRIVER, wo die Daten liegen. +// +// Bei SQLite wird der Ordner für die Workspace-Dateien angelegt, falls er +// fehlt. Bei PostgreSQL wartet NewPostgres nötigenfalls, bis die Datenbank +// erreichbar ist, und legt das Schema der Kontroll-Datenbank an. +func oeffneSpeicher(ctx context.Context, cfg config) (store.Speicher, error) { + if cfg.dbDriver == dbDriverPostgres { + return store.NewPostgres(ctx, cfg.databaseURL) + } + + workspacesDir := filepath.Join(cfg.dataDir, "workspaces") + if err := os.MkdirAll(workspacesDir, 0750); err != nil { + return nil, fmt.Errorf("Datenverzeichnis konnte nicht angelegt werden: %w", err) + } + return store.NewSQLite(filepath.Join(cfg.dataDir, "control.db"), workspacesDir), nil +} + // fuerJedenWorkspace ruft fn für jeden Workspace mit dessen geöffneter Datei // auf. // @@ -415,7 +473,17 @@ func exportiereAlle(ctx context.Context, e *export.Exporter, control *store.Cont // durchläuft, einen aktuellen Stand hinterlässt. func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) { lauf := func() { + beginn := time.Now() stats, err := exportiereAlle(context.Background(), e, control, workspaces) + + // Für die Systemseite: Dort steht, wann der letzte Lauf endete und ob + // er vollständig war. + ergebnis := export.Lauf{Ende: time.Now(), Dauer: time.Since(beginn), Stats: stats} + if err != nil { + ergebnis.Fehler = err.Error() + } + e.MerkeLauf(ergebnis) + if err != nil { log.Printf("Export fehlgeschlagen: %v", err) } @@ -426,7 +494,7 @@ func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces lauf() - ticker := time.NewTicker(exportInterval) + ticker := time.NewTicker(export.Intervall) defer ticker.Stop() for range ticker.C { lauf() diff --git a/main_test.go b/main_test.go index 1d68e45..df9b913 100644 --- a/main_test.go +++ b/main_test.go @@ -26,7 +26,7 @@ func aufbauZweiWorkspaces(t *testing.T) (*store.ControlStore, *workspace.Manager } t.Cleanup(func() { control.Close() }) - workspaces := workspace.NewManager(dir) + workspaces := workspace.NewManager(store.NewSQLite("", dir)) t.Cleanup(func() { workspaces.Close() }) var alle []store.Workspace diff --git a/scripts/backup.sh b/scripts/backup.sh index 073aa8b..aa6490e 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,23 +1,55 @@ #!/usr/bin/env bash # -# Sichert die zentrale Kontroll-Datenbank und jede Workspace-Datei einzeln. +# Sichert die zentrale Kontroll-Datenbank und jeden Workspace einzeln — mit +# SQLite (Standard) oder PostgreSQL, je nach DB_DRIVER wie beim Server. +# +# Jede Sicherung landet in einem eigenen, zeitgestempelten Ordner. Da jeder +# Workspace für sich gesichert wird, lässt sich später auch nur eine einzelne +# Abteilung gezielt wiederherstellen, ohne die übrigen anzufassen. +# +# SQLite PostgreSQL +# <stempel>/control.db <stempel>/control.dump +# <stempel>/workspaces/<id>.db <stempel>/workspaces/munin_ws_<id>.dump +# +# ── SQLite ─────────────────────────────────────────────────────────────────── # # sqlite3 .backup ist dafür gebaut, auch während der Server im WAL-Modus # schreibt (siehe AUSBAUSTUFEN.md 4.4 und README.md "Datensicherung") — eine # einfache Dateikopie wäre hier riskant. # -# Jede Sicherung landet in einem eigenen, zeitgestempelten Ordner. Da jeder -# Workspace seine eigene Datei ist, lässt sich später auch nur eine einzelne -# Abteilung gezielt wiederherstellen, ohne die übrigen anzufassen. -# -# ── Aufruf ─────────────────────────────────────────────────────────────────── -# # scripts/backup.sh [Datenverzeichnis] [Sicherungsverzeichnis] # # Beide Pfade lassen sich auch über die Umgebungsvariablen DATA_DIR und # BACKUP_DIR setzen (Vorgaben: "data" und "backups") — praktisch für einen # Cronjob, der ohnehin feste Pfade mitgibt. # +# ── PostgreSQL ─────────────────────────────────────────────────────────────── +# +# DB_DRIVER=postgres DATABASE_URL=postgres://… BACKUP_DIR=… scripts/backup.sh +# +# pg_dump sichert jedes Schema (munin_control, munin_ws_…) in eine eigene +# Datei im custom-Format, im laufenden Betrieb und ohne den Server zu +# bremsen. Jede Datei enthält das Schema selbst samt dem Workspace-Namen als +# Kommentar, lässt sich also mit pg_restore ohne Vorarbeit zurückspielen +# (BETRIEB.md, "Aus einer Sicherung wiederherstellen"). +# +# Nicht verwenden: einen einzigen Dump aller Schemas. Aus dem lässt sich ein +# einzelner Workspace nicht zurückholen — pg_restore --schema legt das Schema +# selbst nicht an (am 2026-10-16 ausprobiert). +# +# Gebraucht werden psql, pg_dump und pg_restore in mindestens der Hauptversion +# des Servers (pg_dump verweigert sonst die Arbeit). Fehlen sie auf dem +# Sicherungsrechner, lassen sie sich aus dem PostgreSQL-Container leihen: +# +# PSQL="docker exec -i munin-postgres psql" \ +# PG_DUMP="docker exec -i munin-postgres pg_dump" \ +# PG_RESTORE="docker exec -i munin-postgres pg_restore" \ +# DB_DRIVER=postgres DATABASE_URL=postgres://munin@localhost:5432/munin scripts/backup.sh +# +# Das Passwort besser nicht in DATABASE_URL schreiben — die Adresse steht als +# Aufrufparameter in der Prozessliste. Stattdessen ~/.pgpass oder PGPASSWORD +# (bei docker exec mit -e PGPASSWORD weiterreichen). +# # ── Beispiel für Cron ──────────────────────────────────────────────────────── # # Täglich um 3 Uhr, mit 14 Tagen Aufbewahrung: @@ -30,52 +62,129 @@ # # ── Wiederherstellen ───────────────────────────────────────────────────────── # -# Server stoppen, die gewünschte .db-Datei aus einem Sicherungsordner an ihren -# Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen -# einzelnen Workspace reicht dessen Datei unter workspaces/ — die -# Kontroll-Datenbank bleibt davon unberührt. -# -# Vorher die -wal- und -shm-Dateien der alten Fassung entfernen, sonst -# übernimmt SQLite deren Inhalt in die zurückgespielte Datei. Die genauen -# Schritte stehen in BETRIEB.md, Abschnitt "Aus einer Sicherung wiederherstellen". +# Die genauen Schritte für beide Datenbanken stehen in BETRIEB.md, Abschnitt +# "Aus einer Sicherung wiederherstellen". Kurz für SQLite: Server stoppen, +# -wal/-shm der alten Fassung entfernen, .db-Datei zurückkopieren, starten. set -euo pipefail +treiber="$(echo "${DB_DRIVER:-sqlite}" | tr '[:upper:]' '[:lower:]')" datenverzeichnis="${1:-${DATA_DIR:-data}}" sicherungsverzeichnis="${2:-${BACKUP_DIR:-backups}}" stempel="$(date +%Y-%m-%dT%H-%M-%S)" ziel="$sicherungsverzeichnis/$stempel" -if ! command -v sqlite3 >/dev/null 2>&1; then - echo "sqlite3 ist nicht installiert — ohne dieses Werkzeug keine sichere Sicherung im laufenden Betrieb." >&2 - exit 1 -fi +# ── SQLite ─────────────────────────────────────────────────────────────────── -if [ ! -d "$datenverzeichnis" ]; then - echo "Datenverzeichnis $datenverzeichnis existiert nicht." >&2 - exit 1 -fi +sichere_sqlite() { + if ! command -v sqlite3 >/dev/null 2>&1; then + echo "sqlite3 ist nicht installiert — ohne dieses Werkzeug keine sichere Sicherung im laufenden Betrieb." >&2 + exit 1 + fi -mkdir -p "$ziel/workspaces" + if [ ! -d "$datenverzeichnis" ]; then + echo "Datenverzeichnis $datenverzeichnis existiert nicht." >&2 + exit 1 + fi -sichern() { - sqlite3 "$1" ".backup '$2'" + mkdir -p "$ziel/workspaces" + + sichern() { + sqlite3 "$1" ".backup '$2'" + } + + if [ -f "$datenverzeichnis/control.db" ]; then + sichern "$datenverzeichnis/control.db" "$ziel/control.db" + echo "control.db gesichert" + else + echo "Warnung: $datenverzeichnis/control.db nicht gefunden — übersprungen." >&2 + fi + + anzahl=0 + for datei in "$datenverzeichnis"/workspaces/*.db; do + [ -e "$datei" ] || continue # kein Workspace vorhanden (glob lief leer) + sichern "$datei" "$ziel/workspaces/$(basename "$datei")" + anzahl=$((anzahl + 1)) + done + echo "$anzahl Workspace-Datei(en) gesichert nach $ziel" } -if [ -f "$datenverzeichnis/control.db" ]; then - sichern "$datenverzeichnis/control.db" "$ziel/control.db" - echo "control.db gesichert" -else - echo "Warnung: $datenverzeichnis/control.db nicht gefunden — übersprungen." >&2 -fi +# ── PostgreSQL ─────────────────────────────────────────────────────────────── -anzahl=0 -for datei in "$datenverzeichnis"/workspaces/*.db; do - [ -e "$datei" ] || continue # kein Workspace vorhanden (glob lief leer) - sichern "$datei" "$ziel/workspaces/$(basename "$datei")" - anzahl=$((anzahl + 1)) -done -echo "$anzahl Workspace-Datei(en) gesichert nach $ziel" +sichere_postgres() { + if [ -z "${DATABASE_URL:-}" ]; then + echo "DB_DRIVER=postgres, aber DATABASE_URL ist nicht gesetzt." >&2 + exit 1 + fi + + # Die Befehle dürfen aus mehreren Wörtern bestehen ("docker exec -i … pg_dump"), + # deshalb als Liste eingelesen. + read -r -a psql <<<"${PSQL:-psql}" + read -r -a pg_dump <<<"${PG_DUMP:-pg_dump}" + read -r -a pg_restore <<<"${PG_RESTORE:-pg_restore}" + + for befehl in "${psql[0]}" "${pg_dump[0]}" "${pg_restore[0]}"; do + if ! command -v "$befehl" >/dev/null 2>&1; then + echo "$befehl ist nicht installiert. Siehe Kopf dieses Skripts: PSQL, PG_DUMP und PG_RESTORE lassen sich auf einen Container umstellen." >&2 + exit 1 + fi + done + + # Alle Schemas von Munin: die Kontroll-Datenbank und je Workspace eines. + # Der Unterstrich muss im LIKE maskiert werden, sonst steht er für ein + # beliebiges Zeichen. + schemas="$("${psql[@]}" --dbname="$DATABASE_URL" --no-align --tuples-only --command \ + "SELECT nspname FROM pg_namespace WHERE nspname = 'munin_control' OR nspname LIKE 'munin\_ws\_%' ORDER BY nspname")" + + if ! grep -qx 'munin_control' <<<"$schemas"; then + echo "Schema munin_control nicht gefunden — ist DATABASE_URL die Datenbank von Munin?" >&2 + exit 1 + fi + + mkdir -p "$ziel/workspaces" + + # Bewusst "for" statt "while read": Mit "docker exec -i" liest pg_dump die + # Standardeingabe — in einer while-read-Schleife also den Rest der Liste, + # und nach dem ersten Schema wäre Schluss. Schemanamen enthalten keine + # Leerzeichen, das Aufteilen an Zeilenumbrüchen ist deshalb sicher. + anzahl=0 + for schema in $schemas; do + if [ "$schema" = "munin_control" ]; then + datei="$ziel/control.dump" + else + datei="$ziel/workspaces/$schema.dump" + fi + + # Ausgabe über die Standardausgabe statt --file, damit es auch mit + # "docker exec" funktioniert: Die Datei entsteht dann auf dem Rechner, + # der das Skript ausführt, nicht im Container. + "${pg_dump[@]}" --dbname="$DATABASE_URL" --format=custom --schema="$schema" </dev/null >"$datei" + + # Gleich prüfen, ob sich die Datei lesen lässt. Eine Sicherung, die + # beim Zurückspielen scheitert, fällt sonst erst im Ernstfall auf. + if ! "${pg_restore[@]}" --list <"$datei" >/dev/null; then + echo "Sicherung von $schema ist nicht lesbar: $datei" >&2 + exit 1 + fi + + if [ "$schema" = "munin_control" ]; then + echo "munin_control gesichert" + else + anzahl=$((anzahl + 1)) + fi + done + + echo "$anzahl Workspace-Schema(s) gesichert nach $ziel" +} + +case "$treiber" in + sqlite) sichere_sqlite ;; + postgres) sichere_postgres ;; + *) + echo "DB_DRIVER=$treiber ist unbekannt — erlaubt sind sqlite und postgres." >&2 + exit 1 + ;; +esac # Alte Sicherungen aufräumen, falls eine Aufbewahrungsfrist gesetzt ist. if [ -n "${RETENTION_DAYS:-}" ]; then diff --git a/scripts/postgres-dev-init.sql b/scripts/postgres-dev-init.sql new file mode 100644 index 0000000..b08ed11 --- /dev/null +++ b/scripts/postgres-dev-init.sql @@ -0,0 +1,7 @@ +-- Läuft einmal beim allerersten Start des Entwicklungs-Containers +-- (docker-compose.dev.yml), wenn das Datenvolume noch leer ist. +-- +-- Legt eine eigene Datenbank für die Tests an. So berühren die Tests nie die +-- Datenbank "munin", mit der man Munin lokal ausprobiert. Sie übernimmt die +-- Sortierung "C" aus der Grundeinstellung des Containers. +CREATE DATABASE munin_test OWNER munin; diff --git a/scripts/sicherung-pruefen.sh b/scripts/sicherung-pruefen.sh new file mode 100755 index 0000000..2ba806a --- /dev/null +++ b/scripts/sicherung-pruefen.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# +# Probe einer Sicherung: zurückspielen, Munin darauf starten, prüfen, +# aufräumen. Eine Sicherung, die nie zurückgespielt wurde, ist nur eine +# Hoffnung (BETRIEB.md, "Regelmäßig"). +# +# Geprüft wird: +# 1. Jede Datei der Sicherung lässt sich lesen bzw. zurückspielen. +# 2. Munin startet auf dem zurückgespielten Stand und meldet /ready. +# 3. Munin sieht so viele Workspaces, wie die Sicherung enthält. +# +# Der echte Server wird dabei nicht berührt: Die Probe läuft in einem +# eigenen Verzeichnis (SQLite) bzw. einer eigenen Datenbank (PostgreSQL), mit +# eigenen Geheimnissen und auf einem eigenen Port. +# +# ── Aufruf ─────────────────────────────────────────────────────────────────── +# +# scripts/sicherung-pruefen.sh <Sicherungsordner> +# +# Der Ordner ist einer der zeitgestempelten Ordner von scripts/backup.sh. Ohne +# Angabe wird der neueste unter BACKUP_DIR (Vorgabe "backups") genommen. +# +# SQLite (Standard): braucht sqlite3. +# +# PostgreSQL: DB_DRIVER=postgres und PROBE_DATABASE_URL — eine LEERE +# Datenbank nur für die Probe, die einmal angelegt wird und dann bleibt: +# +# CREATE DATABASE munin_probe OWNER munin TEMPLATE template0 LOCALE 'C' ENCODING 'UTF8'; +# +# Das Skript spielt dort hinein und löscht danach alle Schemas munin_* wieder +# daraus. Enthält die Datenbank vorher schon welche, bricht es ab, statt sie +# zu überschreiben. psql und pg_restore lassen sich wie bei backup.sh über +# PSQL und PG_RESTORE auf einen Container umstellen. +# +# Munin selbst: MUNIN_BIN zeigt auf das Programm. Ohne Angabe wird es aus dem +# Repository gebaut (braucht Go). PROBE_PORT wählt den Port (Vorgabe 18080). +# +# Endet mit 0, wenn die Probe bestanden ist, sonst mit 1. + +set -euo pipefail + +treiber="$(echo "${DB_DRIVER:-sqlite}" | tr '[:upper:]' '[:lower:]')" +port="${PROBE_PORT:-18080}" +repo="$(cd "$(dirname "$0")/.." && pwd)" + +fehler() { + echo "PROBE NICHT BESTANDEN: $*" >&2 + exit 1 +} + +# ── Welche Sicherung? ──────────────────────────────────────────────────────── + +sicherung="${1:-}" +if [ -z "$sicherung" ]; then + sicherung="$(ls -1d "${BACKUP_DIR:-backups}"/*/ 2>/dev/null | sort | tail -n 1 || true)" + [ -n "$sicherung" ] || fehler "keine Sicherung unter ${BACKUP_DIR:-backups} gefunden" +fi +sicherung="${sicherung%/}" +[ -d "$sicherung" ] || fehler "$sicherung ist kein Verzeichnis" +echo "Sicherung: $sicherung" + +# ── Arbeitsverzeichnis und Aufräumen ───────────────────────────────────────── + +arbeit="$(mktemp -d)" +munin_pid="" + +aufraeumen() { + if [ -n "$munin_pid" ]; then + kill "$munin_pid" 2>/dev/null || true + wait "$munin_pid" 2>/dev/null || true + fi + if [ "$treiber" = "postgres" ] && [ -n "${probe_bereit:-}" ]; then + schemas_loeschen || echo "Warnung: Schemas in der Probe-Datenbank konnten nicht gelöscht werden." >&2 + fi + rm -rf "$arbeit" +} +trap aufraeumen EXIT + +# ── Munin besorgen ─────────────────────────────────────────────────────────── + +munin="${MUNIN_BIN:-}" +if [ -z "$munin" ]; then + command -v go >/dev/null 2>&1 || fehler "MUNIN_BIN ist nicht gesetzt und Go ist nicht installiert" + echo "Baue Munin …" + (cd "$repo" && CGO_ENABLED=0 go build -o "$arbeit/munin" .) + munin="$arbeit/munin" +fi +[ -x "$munin" ] || fehler "$munin ist nicht ausführbar" + +# ── Zurückspielen ──────────────────────────────────────────────────────────── + +if [ "$treiber" = "sqlite" ]; then + command -v sqlite3 >/dev/null 2>&1 || fehler "sqlite3 ist nicht installiert" + [ -f "$sicherung/control.db" ] || fehler "control.db fehlt in der Sicherung" + + mkdir -p "$arbeit/daten/workspaces" + cp "$sicherung/control.db" "$arbeit/daten/" + erwartet=0 + for datei in "$sicherung"/workspaces/*.db; do + [ -e "$datei" ] || continue + cp "$datei" "$arbeit/daten/workspaces/" + erwartet=$((erwartet + 1)) + done + + for datei in "$arbeit/daten/control.db" "$arbeit"/daten/workspaces/*.db; do + [ -e "$datei" ] || continue + # Bei schwerem Schaden bricht sqlite3 selbst ab; die Meldung zählt dann + # als Ergebnis, statt dass set -e das Skript wortlos beendet. + ergebnis="$(sqlite3 "$datei" 'PRAGMA integrity_check;' 2>&1)" || true + [ "$ergebnis" = "ok" ] || fehler "$(basename "$datei"): $ergebnis" + done + echo "Alle Dateien unbeschädigt (control.db und $erwartet Workspace(s))" + + umgebung=(DB_DRIVER=sqlite "DATA_DIR=$arbeit/daten") + +elif [ "$treiber" = "postgres" ]; then + [ -n "${PROBE_DATABASE_URL:-}" ] || fehler "PROBE_DATABASE_URL ist nicht gesetzt (eine leere Datenbank nur für die Probe, siehe Kopf des Skripts)" + [ -f "$sicherung/control.dump" ] || fehler "control.dump fehlt in der Sicherung" + + read -r -a psql <<<"${PSQL:-psql}" + read -r -a pg_restore <<<"${PG_RESTORE:-pg_restore}" + + schemas_der_probe() { + "${psql[@]}" --dbname="$PROBE_DATABASE_URL" --no-align --tuples-only --command \ + "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'munin\_%' ORDER BY nspname" </dev/null + } + schemas_loeschen() { + for schema in $(schemas_der_probe); do + # client_min_messages: ohne die NOTICE-Zeile je gelöschter Tabelle. + "${psql[@]}" --dbname="$PROBE_DATABASE_URL" --quiet \ + --command "SET client_min_messages TO warning; DROP SCHEMA \"$schema\" CASCADE" </dev/null + done + } + + vorher="$(schemas_der_probe)" || fehler "Probe-Datenbank nicht erreichbar" + [ -z "$vorher" ] || fehler "Die Probe-Datenbank ist nicht leer (enthält $(echo $vorher)). Ist PROBE_DATABASE_URL wirklich die Probe-Datenbank und nicht die echte?" + probe_bereit=1 + + erwartet=0 + for datei in "$sicherung/control.dump" "$sicherung"/workspaces/*.dump; do + [ -e "$datei" ] || continue + "${pg_restore[@]}" --dbname="$PROBE_DATABASE_URL" --no-owner --exit-on-error <"$datei" || + fehler "$(basename "$datei") ließ sich nicht zurückspielen" + [ "$datei" = "$sicherung/control.dump" ] || erwartet=$((erwartet + 1)) + done + echo "Alles zurückgespielt (control.dump und $erwartet Workspace(s))" + + umgebung=(DB_DRIVER=postgres "DATABASE_URL=$PROBE_DATABASE_URL") +else + fehler "DB_DRIVER=$treiber ist unbekannt — erlaubt sind sqlite und postgres" +fi + +# ── Munin starten und prüfen ───────────────────────────────────────────────── +# +# Eigene Geheimnisse: Die Probe braucht die echten nicht, und mit ihnen +# ließe sich nichts am echten Server anfangen. + +# od liest genau 32 Bytes. (Nicht "tr … </dev/urandom | head": Dort bricht tr +# beim Schließen der Pipe ab, und mit pipefail endete das ganze Skript.) +geheimnis="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')" +env "${umgebung[@]}" PORT="$port" SESSION_SECRET="$geheimnis" ADMIN_TOKEN="probe-${geheimnis:0:24}" \ + EXPORT_DIR= "$munin" >"$arbeit/munin.log" 2>&1 & +munin_pid=$! + +bereit="" +for _ in $(seq 1 60); do + if ! kill -0 "$munin_pid" 2>/dev/null; then + cat "$arbeit/munin.log" >&2 + fehler "Munin hat sich beim Start beendet (Log oben)" + fi + if curl -fsS "http://127.0.0.1:$port/ready" >/dev/null 2>&1; then + bereit=1 + break + fi + sleep 0.5 +done +if [ -z "$bereit" ]; then + cat "$arbeit/munin.log" >&2 + fehler "Munin meldet nach 30 Sekunden nicht /ready (Log oben)" +fi + +# Munin nennt beim Start die Zahl der Workspaces: "Munin läuft auf … (N Workspaces)". +gesehen="$(sed -n 's/.*Munin läuft auf .* (\([0-9]*\) Workspaces).*/\1/p' "$arbeit/munin.log" | head -n 1)" +[ "$gesehen" = "$erwartet" ] || + fehler "Munin sieht ${gesehen:-?} Workspace(s), die Sicherung enthält $erwartet" + +echo "Munin startet, meldet /ready und sieht alle $erwartet Workspace(s)" +echo "PROBE BESTANDEN" diff --git a/static/aehnlich.js b/static/aehnlich.js new file mode 100644 index 0000000..44e3a27 --- /dev/null +++ b/static/aehnlich.js @@ -0,0 +1,96 @@ +// „Gibt es schon?" — zeigt beim Anlegen eines Eintrags vorhandene Einträge mit +// ähnlichem Titel an (AUSBAUSTUFEN.md 5.7). +// +// Agenten bekommen bei "remember" eine Rückfrage, wenn es schon einen +// ähnlichen Eintrag gibt. Menschen im Formular bekommen denselben Hinweis, +// nur ohne Sperre: Die Liste erscheint unter dem Titel, speichern lässt sich +// trotzdem. Geprüft wird auf dem Server mit derselben Logik wie bei MCP +// (internal/dubletten), damit beide Wege gleich urteilen. +// +// Gefragt wird erst, wenn eine Weile nicht getippt wurde (wartezeit), nicht +// bei jedem Buchstaben. Antworten, die zu spät für den aktuellen Titel +// kommen, werden verworfen. +(function () { + "use strict"; + + var titel = document.querySelector("input[data-aehnlich-url]"); + var kasten = document.querySelector("[data-aehnlich-liste]"); + if (!titel || !kasten) { + return; + } + var liste = kasten.querySelector("ul"); + var bezeichner = document.querySelector("input[name=slug]"); + var url = titel.getAttribute("data-aehnlich-url"); + // Die Adresse eines Eintrags: aus der Such-URL ".../entries/similar" wird + // ".../e/<slug>". + var eintragsBasis = url.replace(/entries\/similar$/, "e/"); + + var wartezeit = 400; // Millisekunden ohne Tippen, bevor gefragt wird + var zeitgeber = null; + var letzteFrage = ""; + + function zeige(kandidaten) { + liste.textContent = ""; + kandidaten.forEach(function (k) { + var li = document.createElement("li"); + var a = document.createElement("a"); + a.href = eintragsBasis + encodeURIComponent(k.slug); + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = k.title; + li.appendChild(a); + + // Die gemeinsamen Wörter zeigen, warum der Eintrag hier steht. + // Ist es nur ein allgemeines Wort, sieht man sofort: anderes Thema. + if (k.gemeinsam && k.gemeinsam.length) { + var grund = document.createElement("span"); + grund.className = "hilfe"; + grund.textContent = " — gemeinsam: " + k.gemeinsam.join(", "); + li.appendChild(grund); + } + liste.appendChild(li); + }); + kasten.hidden = kandidaten.length === 0; + } + + function frage() { + var t = titel.value.trim(); + var s = bezeichner ? bezeichner.value.trim() : ""; + var schluessel = t + "\u0000" + s; + if (schluessel === letzteFrage) { + return; + } + letzteFrage = schluessel; + + if (t.length < 3) { + zeige([]); + return; + } + + var adresse = url + "?title=" + encodeURIComponent(t) + "&slug=" + encodeURIComponent(s); + fetch(adresse, { credentials: "same-origin", headers: { "Accept": "application/json" } }) + .then(function (antwort) { return antwort.ok ? antwort.json() : []; }) + .then(function (kandidaten) { + // Inzwischen weitergetippt? Dann gehört die Antwort zu einem + // alten Titel. + if (schluessel === letzteFrage) { + zeige(kandidaten || []); + } + }) + .catch(function () { /* nur ein Hinweis — ohne ihn geht es auch */ }); + } + + function spaeterFragen() { + clearTimeout(zeitgeber); + zeitgeber = setTimeout(frage, wartezeit); + } + + titel.addEventListener("input", spaeterFragen); + if (bezeichner) { + bezeichner.addEventListener("input", spaeterFragen); + } + // Vorbelegter Titel (z.B. nach einem Fehler zurück im Formular). + if (titel.value.trim() !== "") { + frage(); + } +})(); diff --git a/static/style.css b/static/style.css index 137d8c3..1ec02c0 100644 --- a/static/style.css +++ b/static/style.css @@ -67,7 +67,7 @@ /* Höhe der Bedienelemente. Wichtig ist nicht der Wert, sondern dass Knopf * und Eingabefeld denselben verwenden — sonst stehen sie in einer Zeile * nebeneinander unterschiedlich hoch, und das fällt sofort auf. */ - --hoehe-bedienelement: 2.25rem; + --hoehe-bedienelement: 2.5rem; /* Knöpfe, Eingabefelder, Auswahllisten: alle gleich hoch */ --hoehe-kopfleiste: 3.5rem; --radius: 6px; @@ -156,6 +156,15 @@ pre code { background: none; border: none; padding: 0; } font-weight: normal; } +/* Speicherort eines Workspace (Schema oder Datei) als blasse zweite Zeile in + * der Workspace-Übersicht. Festbreitenschrift, weil es ein Bezeichner zum + * Abtippen ist; markierbar, damit er sich kopieren lässt. */ +.speicherort { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: var(--t-klein); + user-select: all; +} + /* ── 3. Gerüst ───────────────────────────────────────────────────────────── */ /* Schmale Kopfleiste über die ganze Breite. Sie trägt nur Marke und Abmelden — @@ -459,6 +468,16 @@ button.warnung:hover { background: var(--warn-flach); filter: none; } } .wie-link:hover { filter: none; text-decoration: underline; } +/* Rechte Seite der Kopfleiste: Hilfe und Abmelden, im selben leisen Ton. + * Doppelte Klasse, weil .reihe weiter unten sonst den Abstand überschreibt. */ +.reihe.kopfleiste-aktionen { gap: var(--s5); } +.kopfleiste-link { + font-size: var(--t-meta); + color: var(--text-leise); +} +.kopfleiste-link:hover { color: var(--text); } +.kopfleiste-link.aktiv { color: var(--text); font-weight: 500; } + .reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; } form.reihe { display: inline-flex; } /* In rechtsbündigen Tabellenspalten (z. B. Verlauf) sollen die Aktionen @@ -468,15 +487,22 @@ form.reihe { display: inline-flex; } /* Formulare. * * Jedes Feld ist ein .feld mit Beschriftung, optionalem Hinweis und Bedienelement. - * Die Breite richtet sich nach dem erwarteten Inhalt statt immer 100 % zu sein: - * Ein Feld für einen Bezeichner, das über die halbe Seite läuft, sieht aus wie - * ein Fehler. */ -.formular { display: flex; flex-direction: column; gap: var(--s4); } + * Die Breite des BEDIENELEMENTS richtet sich nach dem erwarteten Inhalt statt + * immer 100 % zu sein: Ein Feld für einen Bezeichner, das über die halbe Seite + * läuft, sieht aus wie ein Fehler. Beschriftung und Hinweis dürfen breiter + * sein (bis zur Lesebreite) — sonst bricht ein Hinweis in eine schmale Spalte + * um und wirkt gedrückt (so war es bis 2026-10-16). */ +.formular { display: flex; flex-direction: column; gap: var(--s5); } .feld { display: flex; flex-direction: column; gap: var(--s1); } .feld > label { font-weight: 500; font-size: var(--t-meta); } -.feld > .hilfe { margin-bottom: var(--s1); } +.feld > .hilfe { + max-width: var(--breite-lesen); + line-height: 1.5; + margin-bottom: var(--s1); +} -input[type=text], input[type=search], input[type=password], select, textarea { +input[type=text], input[type=search], input[type=password], input[type=number], +input[type=date], input[type=email], input[type=url], select, textarea { width: 100%; padding: var(--s2) var(--s3); min-height: var(--hoehe-bedienelement); @@ -487,7 +513,26 @@ input[type=text], input[type=search], input[type=password], select, textarea { font: inherit; font-size: var(--t-meta); } +/* Einzeilige Bedienelemente bekommen eine feste Höhe statt nur einer + * Mindesthöhe. Sonst sind Textfeld, Auswahlliste und Datumsfeld je nach + * Browser unterschiedlich hoch (beobachtet: 40, 36 und 42 Pixel), und in + * einer Zeile wie der Filterleiste stehen ihre Beschriftungen versetzt. */ +input:is([type=text], [type=search], [type=password], [type=number], [type=date], [type=email], [type=url]), +select { + height: var(--hoehe-bedienelement); + padding-block: 0; +} input::placeholder { color: var(--text-leise); opacity: 0.7; } +/* Dateiauswahl: kein Textfeld, aber derselbe Rahmen, damit sie im Formular + * nicht wie ein Fremdkörper wirkt. */ +input[type=file] { + width: 100%; + padding: var(--s2); + border: 1px dashed var(--rahmen-stark); + border-radius: var(--radius); + background: var(--flaeche); + font-size: var(--t-meta); +} input:focus, select:focus, textarea:focus { border-color: var(--akzent); outline: 2px solid var(--akzent-flach); @@ -572,10 +617,11 @@ textarea { .autovervollstaendigung li.aktiv { background: var(--akzent-flach); } .autovervollstaendigung li.aktiv span { color: var(--akzent-text); } -/* Feldbreiten nach Inhalt. */ -.feld.kurz { max-width: 18rem; } -.feld.mittel { max-width: 28rem; } -.feld.lang { max-width: 40rem; } +/* Breiten der Bedienelemente nach Inhalt — nur das Eingabefeld wird + * begrenzt, nicht Beschriftung und Hinweis (siehe oben). */ +.feld.kurz > :is(input, select) { max-width: 18rem; } +.feld.mittel > :is(input, select) { max-width: 28rem; } +.feld.lang > :is(input, select) { max-width: 40rem; } /* Ankreuzfeld: Kästchen und Text nebeneinander, Text nicht fett. */ .ankreuz { @@ -587,12 +633,15 @@ textarea { * optisch auf der ersten Textzeile liegt statt darüber. */ .ankreuz input { width: auto; min-height: 0; margin-top: 0.3rem; } .ankreuz label { font-weight: normal; } +/* Der Hinweis steht unter der Beschriftung, nicht in derselben Zeile. */ +.ankreuz label .hilfe { display: block; margin-top: var(--s1); max-width: var(--breite-lesen); } .formular-aktionen { display: flex; + flex-wrap: wrap; gap: var(--s2); align-items: center; - padding-top: var(--s2); + padding-top: var(--s1); } /* Tabellen. */ @@ -656,6 +705,137 @@ table.tabelle { margin-bottom: var(--s4); } .werkzeugleiste input[type=search] { flex: 1; } +/* Hinweis "Gibt es schon?" unter dem Titel eines neuen Eintrags. */ +.aehnlich-hinweis { + margin-top: var(--s2); + padding: var(--s2) var(--s3); + border-left: 3px solid var(--akzent); + background: var(--flaeche-leis); + border-radius: var(--radius); +} +.aehnlich-hinweis ul { margin: var(--s1) 0 0; padding-left: var(--s4); } +.aehnlich-hinweis li { font-size: var(--t-meta); } + +/* Eigenschaftsliste (Begriff: Wert), z.B. auf der Systemseite. */ +.eigenschaften { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--s2) var(--s4); + margin: var(--s3) 0 0; +} +.eigenschaften dt { color: var(--text-leise); } +.eigenschaften dd { margin: 0; } +@media (max-width: 60rem) { + .eigenschaften { grid-template-columns: 1fr; gap: var(--s1); } + .eigenschaften dd { margin-bottom: var(--s2); } +} + +/* Systemseite: Kasten "Zustand". Warnungen rot, Hinweise grün — dieselben + * Farben wie .meldung, aber als Liste, damit mehrere untereinander passen. */ +.zustand { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s2); } +.zustand > li { + display: flex; + gap: var(--s3); + align-items: baseline; + padding: var(--s3) var(--s4); + border: 1px solid var(--rahmen); + border-left-width: 3px; + border-radius: var(--radius); + font-size: var(--t-meta); + line-height: 1.5; +} +.zustand > li.zustand-warnung { background: var(--warn-flach); border-left-color: var(--warn); } +.zustand > li.zustand-hinweis { background: var(--akzent-flach); border-left-color: var(--akzent); } +.zustand-art { font-weight: 600; flex: none; min-width: 5rem; max-width: 12rem; overflow-wrap: anywhere; } +.zustand-warnung .zustand-art { color: var(--warn); } +/* Überblick: mehrere Befunde eines Projekts in einer Karte. */ +.zustand-punkte { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s1); flex: 1; min-width: 0; } +.zustand-punkte .dringend { font-weight: 600; } +.weiter-link { white-space: nowrap; font-weight: 400; } +.zustand-hinweis .zustand-art { color: var(--akzent-text); } + +/* Systemseite: Balkendiagramme (internal/web/diagramm.go). Untereinander + * und höchstens so breit wie Fließtext: Das SVG skaliert samt Schrift mit + * seiner Breite — nebeneinander wären die Beschriftungen zu klein, über die + * ganze Seite die Balken unnötig flach. */ +.diagramme { + display: grid; + gap: var(--s4); + max-width: var(--breite-lesen); + margin-bottom: var(--s2); +} +.diagramm-rahmen { + margin: 0; + background: var(--flaeche); + border: 1px solid var(--rahmen); + border-radius: var(--radius-gross); + padding: var(--s4); + min-width: 0; +} +.diagramm-rahmen figcaption { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s1) var(--s4); + margin-bottom: var(--s3); + font-size: var(--t-meta); +} +.legende { display: inline-flex; align-items: center; gap: var(--s1); color: var(--text-leise); } +.legende-farbe { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 2px; } +.diagramm { display: block; width: 100%; height: auto; } +.diagramm-gitter { stroke: var(--rahmen); stroke-width: 1; } +.diagramm-text { fill: var(--text-leise); font-size: 11px; font-variant-numeric: tabular-nums; } +/* Unsichtbar, aber da: Über ihr erscheint der Tooltip eines Tages. */ +.diagramm-treffer { fill: transparent; } +.diagramm-treffer:hover { fill: var(--flaeche-leis); } +.diagramm-leer { margin: var(--s5) 0; text-align: center; } +/* Die Farben gelten für die Balken (SVG: fill) und die Legende (HTML: + * background). Menschen in derselben Farbe wie Agenten, nur heller — beides + * ist "Schreiben". */ +rect.balken-agent { fill: var(--akzent); } +rect.balken-mensch { fill: var(--akzent); fill-opacity: 0.4; } +rect.balken-suche { fill: var(--text-leise); } +.legende-farbe.balken-agent { background: var(--akzent); } +.legende-farbe.balken-mensch { background: var(--akzent); opacity: 0.4; } +.legende-farbe.balken-suche { background: var(--text-leise); } + +/* Aufklappbarer Zusatz unter einer Tabelle oder einem Diagramm. */ +.aufklapper { margin-top: var(--s3); } +.aufklapper > summary { cursor: pointer; color: var(--akzent-text); font-size: var(--t-meta); } +.aufklapper[open] > summary { margin-bottom: var(--s3); } +.abschnitt h3 { margin: var(--s5) 0 var(--s2); } +.ruhig-liste { margin: var(--s2) 0 0; padding-left: var(--s5); columns: 2 16rem; font-size: var(--t-meta); } + +/* Warnender Text in einer Kennzahl. */ +.warn-text { color: var(--warn); } + +/* Filter über einer Tabelle, z.B. im Protokoll. Bricht auf schmalen + * Bildschirmen um. */ +.filterleiste { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--s2) var(--s3); + margin-bottom: var(--s4); +} +.filterleiste label { + display: flex; + flex-direction: column; + gap: var(--s1); + font-size: var(--t-meta); + color: var(--text-leise); +} +.filterleiste a { align-self: center; font-size: var(--t-meta); } +/* In der Filterleiste nehmen die Felder ihre natürliche Breite, nicht 100 % + * des Etiketts — sonst schrumpfen sie auf die Breite der Beschriftung. */ +.filterleiste :is(input, select) { width: auto; min-width: 9rem; } + +/* Filterlink neben der Suche, z.B. "3 veraltet". */ +.werkzeugleiste .filter-link { + align-self: center; + white-space: nowrap; + font-size: var(--t-klein); +} /* Eintragsliste. */ @@ -1002,7 +1182,14 @@ table.tabelle { gap: 0; padding-inline: var(--s4); } - .kopfleiste-innen { padding-inline: var(--s4); } + .kopfleiste-innen { padding-inline: var(--s4); gap: var(--s3); } + /* Kopfleiste: Auf schmalen Bildschirmen reicht "Hilfe", und ein langer + * Workspace-Name wird gekürzt statt die Leiste zu sprengen. */ + .nur-breit { display: none; } + .reihe.kopfleiste-aktionen { gap: var(--s4); flex-wrap: nowrap; } + .kopfleiste-innen > .reihe:first-child { min-width: 0; flex-wrap: nowrap; } + .arbeitsbereich { min-width: 0; } + .arbeitsbereich-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .seitenleiste { position: static; @@ -1025,9 +1212,13 @@ table.tabelle { } .nav-titel { margin-bottom: 0; padding-inline: 0; white-space: nowrap; } + /* min-width: 0, damit die Zeile in sich scrollt, statt die ganze Seite + * zu verbreitern — ein Flex-Element gibt sonst nicht unter die Breite + * seines Inhalts nach. */ .nav-liste { display: flex; gap: var(--s1); + min-width: 0; overflow-x: auto; scrollbar-width: none; /* die Leiste scrollt, ohne einen Balken zu zeigen */ } @@ -1045,7 +1236,8 @@ table.tabelle { .karte { padding: var(--s4); } .tabelle th, .tabelle td { padding: var(--s2) var(--s3); } - .feld.kurz, .feld.mittel, .feld.lang { max-width: none; } + .feld.kurz > :is(input, select), .feld.mittel > :is(input, select), + .feld.lang > :is(input, select) { max-width: none; } textarea { min-height: 14rem; } .diff-koepfe { grid-template-columns: 1fr; } } diff --git a/static/tastenkuerzel.js b/static/tastenkuerzel.js new file mode 100644 index 0000000..e3f60f4 --- /dev/null +++ b/static/tastenkuerzel.js @@ -0,0 +1,61 @@ +// Tastenkürzel für Leute, die viel pflegen. +// +// / Suchfeld der Seite +// n neuer Eintrag +// e Eintrag bearbeiten +// Strg+Enter Formular speichern (Cmd+Enter auf dem Mac) +// +// Welches Ziel eine Taste hat, steht in der Seite selbst: Ein Link oder Knopf +// mit data-kuerzel="n" wird bei "n" ausgelöst. Gibt es keinen — weil die +// Rolle nicht schreiben darf oder die Seite keinen Eintrag zeigt —, passiert +// nichts. So bleiben die Rechte an genau einer Stelle, im Template. +// +// Einzelne Buchstaben gelten nur, wenn gerade kein Eingabefeld den Fokus hat. +// Sonst ließe sich kein "n" mehr tippen. Mit Strg/Alt/Cmd zusammen bleiben sie +// ebenfalls dem Browser überlassen (Strg+N = neues Fenster). +(function () { + "use strict"; + + function imEingabefeld(element) { + if (!element) { + return false; + } + var tag = element.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || element.isContentEditable; + } + + document.addEventListener("keydown", function (event) { + // Strg+Enter / Cmd+Enter: das Formular abschicken, in dem man gerade + // schreibt — nur bei Formularen, die das wollen (data-kuerzel-speichern). + if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { + var form = event.target.closest && event.target.closest("form[data-kuerzel-speichern]"); + if (form) { + event.preventDefault(); + // requestSubmit löst wie ein Klick die Prüfung der Felder und + // das submit-Ereignis aus (wichtig für ungespeichert.js). + form.requestSubmit(); + } + return; + } + + if (event.ctrlKey || event.metaKey || event.altKey || imEingabefeld(event.target)) { + return; + } + + if (event.key === "/") { + var suche = document.querySelector("input[type=search]"); + if (suche) { + event.preventDefault(); + suche.focus(); + suche.select(); + } + return; + } + + var ziel = document.querySelector('[data-kuerzel="' + event.key + '"]'); + if (ziel) { + event.preventDefault(); + ziel.click(); + } + }); +})(); diff --git a/static/ungespeichert.js b/static/ungespeichert.js new file mode 100644 index 0000000..eefd7b9 --- /dev/null +++ b/static/ungespeichert.js @@ -0,0 +1,45 @@ +// Warnt, bevor ungespeicherte Änderungen verloren gehen. +// +// Gilt für Formulare mit dem Attribut data-ungespeichert (das +// Bearbeitungsformular eines Eintrags). Hat jemand dort etwas geändert und +// verlässt die Seite, ohne zu speichern — ein Klick in der Seitenleiste, Tab +// schließen, zurück —, fragt der Browser nach. Den Text der Rückfrage legt der +// Browser selbst fest; eigene Texte erlauben moderne Browser nicht mehr. +// +// Als "geändert" gilt nur, was sich wirklich vom Ausgangsstand unterscheidet: +// Wer etwas tippt und wieder löscht, wird nicht gefragt. Abgeschickt wird +// ohne Rückfrage, und "Abbrechen" ist eine bewusste Entscheidung, die +// trotzdem nachfragt — sie ist nur ein Link. +(function () { + "use strict"; + + // stand fasst alle Felder eines Formulars zu einer Zeichenkette zusammen. + function stand(form) { + var teile = []; + new FormData(form).forEach(function (wert, name) { + if (name !== "csrf_token") { + teile.push(name + "=" + wert); + } + }); + return teile.join("\u0000"); + } + + document.querySelectorAll("form[data-ungespeichert]").forEach(function (form) { + var ausgang = stand(form); + var wirdGespeichert = false; + + form.addEventListener("submit", function () { + wirdGespeichert = true; + }); + + window.addEventListener("beforeunload", function (event) { + if (wirdGespeichert || stand(form) === ausgang) { + return; + } + // Beides nötig: preventDefault für aktuelle Browser, returnValue + // für ältere. + event.preventDefault(); + event.returnValue = ""; + }); + }); +})(); diff --git a/templates/audit.html b/templates/audit.html index b969ae5..3f7f58b 100644 --- a/templates/audit.html +++ b/templates/audit.html @@ -6,13 +6,49 @@ gibt es einen eigenen Abschnitt auf seiner Hilfeseite. */}} <h1>Protokoll {{if .Data.Global}}{{template "hilfelink" "plattform-admin#protokoll-ueber-alle-projekte"}}{{else}}{{template "hilfelink" "zugaenge#protokoll"}}{{end}}</h1> <span class="hilfe"> - Die jüngsten {{len .Data.Events}} Vorgänge, neueste zuerst. + Die jüngsten {{len .Data.Events}} Vorgänge{{if .Data.Gefiltert}} zum Filter{{end}}, neueste zuerst (höchstens 200). {{if .Data.Global}}Über alle Projekte hinweg.{{end}} Einträge werden nach 90 Tagen automatisch entfernt. </span> </div> </div> +{{/* Filter: Vorgang, Zugang, Zeitraum (und im Protokoll über alle + Projekte das Projekt). Ein GET-Formular, damit sich eine gefilterte + Ansicht als Adresse weitergeben lässt. */}} +<form method="GET" class="filterleiste"> + <label>Vorgang + <select name="vorgang"> + <option value="">alle</option> + {{range .Data.Aktionen}} + <option value="{{.Action}}" {{if eq .Action $.Data.Filter.Vorgang}}selected{{end}}>{{.Label}}</option> + {{end}} + </select> + </label> + <label>Wer + <select name="wer"> + <option value="">alle</option> + {{range .Data.Zugaenge}} + <option value="{{.}}" {{if eq . $.Data.Filter.Wer}}selected{{end}}>{{.}}</option> + {{end}} + </select> + </label> + {{if .Data.Global}} + <label>Projekt + <select name="projekt"> + <option value="">alle</option> + {{range $id, $name := .Data.ProjectNames}} + <option value="{{$id}}" {{if eq $id $.Data.Filter.Projekt}}selected{{end}}>{{$name}}</option> + {{end}} + </select> + </label> + {{end}} + <label>von <input type="date" name="von" value="{{.Data.Filter.Von}}"></label> + <label>bis <input type="date" name="bis" value="{{.Data.Filter.Bis}}"></label> + <button type="submit" class="zweitrangig">Filtern</button> + {{if .Data.Gefiltert}}<a href="?">Filter aufheben</a>{{end}} +</form> + {{if .Data.Events}} <div class="tabelle-huelle"> <table class="tabelle"> @@ -47,7 +83,12 @@ </div> {{else}} <div class="leer"> + {{if .Data.Gefiltert}} + <p>Nichts gefunden für diesen Filter.</p> + <p><a href="?">Filter aufheben</a></p> + {{else}} <p>Noch nichts protokolliert.</p> + {{end}} </div> {{end}} diff --git a/templates/edit.html b/templates/edit.html index 15d3429..85a7ec1 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -8,9 +8,13 @@ </div> <div class="karte"> + {{/* data-ungespeichert: Rückfrage beim Verlassen mit ungespeicherten + Änderungen (static/ungespeichert.js). data-kuerzel-speichern: + Strg+Enter speichert (static/tastenkuerzel.js). */}} <form method="POST" class="formular" action="{{if .Data.IsNew}}/p/{{.Project.Slug}}/save{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/save{{end}}" - data-preview-url="/p/{{.Project.Slug}}/preview"> + data-preview-url="/p/{{.Project.Slug}}/preview" + data-ungespeichert data-kuerzel-speichern> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> {{/* Die Fassung wandert unverändert durchs Formular und zurück. Hat jemand @@ -20,7 +24,19 @@ <div class="feld lang"> <label for="title">Titel</label> + {{if .Data.IsNew}} + {{/* Beim Tippen: Gibt es schon einen ähnlichen Eintrag? Dieselbe + Prüfung wie bei "remember" über MCP, hier nur als Hinweis + (static/aehnlich.js). */}} + <input type="text" id="title" name="title" value="{{.Data.Entry.Title}}" required autofocus + data-aehnlich-url="/p/{{.Project.Slug}}/entries/similar" autocomplete="off"> + <div class="aehnlich-hinweis" data-aehnlich-liste hidden aria-live="polite"> + <span class="hilfe">Gibt es schon? Lieber den vorhandenen Eintrag fortschreiben, statt einen zweiten daneben zu legen:</span> + <ul></ul> + </div> + {{else}} <input type="text" id="title" name="title" value="{{.Data.Entry.Title}}" required autofocus> + {{end}} </div> {{if .Data.IsNew}} @@ -72,7 +88,7 @@ </div> <div class="formular-aktionen"> - <button type="submit">Speichern</button> + <button type="submit" title="Speichern (Strg+Enter)" aria-keyshortcuts="Control+Enter Meta+Enter">Speichern</button> <a class="knopf zweitrangig" href="{{if .Data.IsNew}}/p/{{.Project.Slug}}/{{else}}/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}{{end}}">Abbrechen</a> </div> diff --git a/templates/entries.html b/templates/entries.html index 1711f55..7dab42e 100644 --- a/templates/entries.html +++ b/templates/entries.html @@ -16,15 +16,23 @@ <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/export.zip" download>Als ZIP herunterladen</a> {{end}} {{if .Session.CanWrite}} - <a class="knopf" href="/p/{{.Project.Slug}}/new">Neuer Eintrag</a> + <a class="knopf" href="/p/{{.Project.Slug}}/new" + data-kuerzel="n" aria-keyshortcuts="n" title="Neuer Eintrag (Taste n)">Neuer Eintrag</a> {{end}} </div> {{end}} </div> <form method="GET" action="/p/{{.Project.Slug}}/" class="werkzeugleiste" role="search"> - <input type="search" name="q" value="{{.Data.Query}}" placeholder="Im Projekt suchen…" autocomplete="off"> + <input type="search" name="q" value="{{.Data.Query}}" placeholder="Im Projekt suchen… ( / )" autocomplete="off" + aria-keyshortcuts="/"> <button type="submit" class="zweitrangig">Suchen</button> + {{/* Filter "veraltet": nur sichtbar, wenn es welche gibt — ein Link auf + eine leere Liste wäre nur Rauschen. */}} + {{if and .Data.VeraltetAnzahl (not .Data.Veraltet)}} + <a class="filter-link" href="/p/{{.Project.Slug}}/?veraltet=1" + title="Seit über einem Jahr unverändert. Prüfen, ob das noch stimmt.">{{.Data.VeraltetAnzahl}} veraltet</a> + {{end}} </form> {{if .Data.Query}} @@ -33,6 +41,11 @@ {{else if .Data.Tag}} <p class="hilfe">{{len .Data.Entries}} Einträge mit „{{.Data.Tag}}“ · <a href="/p/{{.Project.Slug}}/">Filter aufheben</a></p> +{{else if .Data.Veraltet}} +<p class="hilfe">{{len .Data.Entries}} Einträge seit über einem Jahr unverändert, älteste zuletzt. + Jeweils prüfen: stimmt das noch? Dann einmal speichern (oder kuratieren), sonst + korrigieren oder löschen. · + <a href="/p/{{.Project.Slug}}/">Filter aufheben</a></p> {{end}} {{if .Data.Entries}} @@ -59,6 +72,7 @@ <div class="meta"> {{if .Entry.Curated}}<span class="marke betont">kuratiert</span>{{end}} + {{if .Stale}}<span class="marke warnung" title="Seit über einem Jahr unverändert. Vor Verwendung prüfen, ob das noch stimmt.">veraltet</span>{{end}} <span class="marke">{{if eq .Entry.Source "agent"}}Agent{{else}}Mensch{{end}}</span> {{range .Entry.Tags}}<a class="marke" href="/p/{{$slug}}/?tag={{.}}">{{.}}</a>{{end}} <span>{{datum .Entry.UpdatedAt}}</span> @@ -94,6 +108,9 @@ {{else if .Data.Tag}} <p>Keine Einträge mit dem Schlagwort „{{.Data.Tag}}“.</p> <p><a href="/p/{{.Project.Slug}}/">Alle Einträge anzeigen</a></p> + {{else if .Data.Veraltet}} + <p>Keine veralteten Einträge.</p> + <p><a href="/p/{{.Project.Slug}}/">Alle Einträge anzeigen</a></p> {{else}} <p>Noch keine Einträge.</p> <p class="hilfe">Agenten legen sie über MCP an, Menschen über „Neuer Eintrag“.</p> diff --git a/templates/entry.html b/templates/entry.html index ecb594e..8cf830f 100644 --- a/templates/entry.html +++ b/templates/entry.html @@ -6,7 +6,8 @@ </div> {{if .Session.CanWrite}} <div class="seitenkopf-aktionen"> - <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit">Bearbeiten</a> + <a class="knopf zweitrangig" href="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/edit" + data-kuerzel="e" aria-keyshortcuts="e" title="Bearbeiten (Taste e)">Bearbeiten</a> <form method="POST" action="/p/{{.Project.Slug}}/e/{{.Data.Entry.Slug}}/delete" class="reihe" data-confirm="Eintrag „{{.Data.Entry.Title}}“ in den Papierkorb legen? Er lässt sich dort wiederherstellen."> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> @@ -47,4 +48,19 @@ </aside> {{end}} +{{/* Zweite Reihe im Verweisgraphen: Einträge, die über einen Zwischeneintrag + mit diesem verknüpft sind. Findet Zusammenhänge, die kein Suchwort teilt. */}} +{{if .Data.Verwandt}} +<aside class="rueckverweise"> + <h2>Auch verwandt {{template "hilfelink" "weboberflaeche#verwandte-eintraege"}}</h2> + <ul> + {{$slug := .Project.Slug}} + {{range .Data.Verwandt}} + <li><a href="/p/{{$slug}}/e/{{.Slug}}">{{.Title}}</a> + <span class="hilfe">über <a href="/p/{{$slug}}/e/{{.Via}}">{{.Via}}</a></span></li> + {{end}} + </ul> +</aside> +{{end}} + {{template "fuss" .}} diff --git a/templates/import.html b/templates/import.html index d249668..31127a1 100644 --- a/templates/import.html +++ b/templates/import.html @@ -22,7 +22,7 @@ <input type="file" id="archiv" name="archiv" accept=".zip,application/zip" required> </div> - <div class="feld kurz"> + <div class="feld lang"> <label for="modus">Wenn es den Bezeichner schon gibt</label> <select id="modus" name="modus"> <option value="skip" {{if eq .Data.Modus "skip"}}selected{{end}}>überspringen — Vorhandenes bleibt, wie es ist</option> diff --git a/templates/keys.html b/templates/keys.html index bb22389..cb46212 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -63,6 +63,14 @@ </div> {{end}} +{{if .Data.BaldAbgelaufen}} +<div class="meldung hinweis"> + {{if eq .Data.BaldAbgelaufen 1}}Ein Zugang läuft{{else}}{{.Data.BaldAbgelaufen}} Zugänge laufen{{end}} + in den nächsten 14 Tagen ab. Wird er weiter gebraucht: einen neuen anlegen und in den + Clients austauschen, bevor der alte nicht mehr gilt. +</div> +{{end}} + {{if .Data.Keys}} <div class="tabelle-huelle"> <table class="tabelle"> @@ -72,6 +80,7 @@ <th>Rolle</th> <th>Angelegt</th> <th>Zuletzt benutzt</th> + <th>Gültig bis</th> <th>Status</th> <th class="schmal rechts"></th> </tr> @@ -79,17 +88,19 @@ <tbody> {{$csrf := .CSRFToken}}{{$slug := .Project.Slug}}{{$jetzt := .Data.Jetzt}} {{range .Data.Keys}} - <tr {{if .Revoked}}class="gedaempft"{{end}}> + <tr {{if not (.Valid $jetzt)}}class="gedaempft"{{end}}> <td>{{.Name}}</td> <td><code>{{.Role}}</code></td> <td>{{datum .CreatedAt}}</td> <td>{{if .LastUsedAt.IsZero}}<span class="hilfe">nie</span>{{else}}{{datum .LastUsedAt}}{{end}}</td> + <td>{{if .ExpiresAt.IsZero}}<span class="hilfe">unbegrenzt</span>{{else}}{{bisTag .ExpiresAt}}{{end}}</td> <td> - {{if .Revoked}}zurückgezogen{{else}}aktiv{{end}} + {{if .Revoked}}zurückgezogen{{else if .Expired $jetzt}}abgelaufen{{else}}aktiv{{end}} + {{if .ExpiresSoon $jetzt}}<span class="marke warnung" title="Läuft in den nächsten 14 Tagen ab">läuft bald ab</span>{{end}} {{if .Unused $jetzt}}<span class="marke warnung" title="Seit über 90 Tagen nicht benutzt">ungenutzt</span>{{end}} </td> <td class="schmal rechts"> - {{if not .Revoked}} + {{if .Valid $jetzt}} <form method="POST" action="/p/{{$slug}}/keys/revoke" class="reihe" data-confirm="Key „{{.Name}}“ zurückziehen? Alle Clients, die ihn benutzen, verlieren sofort den Zugang."> <input type="hidden" name="csrf_token" value="{{$csrf}}"> @@ -123,7 +134,7 @@ <input type="text" id="name" name="name" required placeholder="z.B. tom-cline oder ci-pipeline"> </div> - <div class="feld kurz"> + <div class="feld mittel"> <label for="role">Rolle</label> <select id="role" name="role"> <option value="read">read — nur lesen und suchen</option> @@ -132,6 +143,14 @@ </select> </div> + <div class="feld kurz"> + <label for="expires">Gültig bis</label> + <span class="hilfe">Optional. Leer = unbegrenzt. Gilt bis einschließlich dieses Tages, + danach wie zurückgezogen. Sinnvoll für Zugänge auf Zeit, etwa für ein Projekt oder + eine Person, die nur eine Weile dabei ist.</span> + <input type="date" id="expires" name="expires" min="{{.Data.Heute}}"> + </div> + <div class="formular-aktionen"> <button type="submit">Key erzeugen</button> </div> diff --git a/templates/nutzung.html b/templates/nutzung.html index 7054cb2..98a7b90 100644 --- a/templates/nutzung.html +++ b/templates/nutzung.html @@ -26,6 +26,12 @@ <span class="kennzahl-wert">{{$u.Untagged}}</span> <span class="kennzahl-name">ohne Schlagwort</span> </div> + {{/* Veraltet = seit über einem Jahr unverändert. Verlinkt auf die + gefilterte Liste, dort lässt sich gleich aufräumen. */}} + <div class="kennzahl"> + <span class="kennzahl-wert">{{if .Data.Stale}}<a href="/p/{{.Project.Slug}}/?veraltet=1">{{.Data.Stale}}</a>{{else}}0{{end}}</span> + <span class="kennzahl-name">veraltet (über ein Jahr)</span> + </div> <div class="kennzahl"> <span class="kennzahl-wert">{{if $u.LastUpdatedAt.IsZero}}—{{else}}{{datum $u.LastUpdatedAt}}{{end}}</span> <span class="kennzahl-name">zuletzt geändert</span> diff --git a/templates/partials.html b/templates/partials.html index f6deeb5..f36ea26 100644 --- a/templates/partials.html +++ b/templates/partials.html @@ -27,14 +27,21 @@ <script src="/static/autocomplete.js"></script> <script src="/static/graph.js"></script> <script src="/static/tagfilter.js"></script> +<script src="/static/ungespeichert.js"></script> +<script src="/static/aehnlich.js"></script> +<script src="/static/tastenkuerzel.js"></script> </body> </html> {{end}} {{define "kopf"}}{{template "html-anfang" .}} -{{/* Schmale Kopfleiste über die ganze Breite: Marke, Workspace-Umschalter und - Abmelden. Alles Weitere steht in der Seitenleiste. +{{/* Schmale Kopfleiste über die ganze Breite: Marke, Workspace-Umschalter, + Hilfe und Abmelden. Alles Weitere steht in der Seitenleiste. + + Die Hilfe steht hier oben statt unten in der Seitenleiste: Dort rutschte + sie bei vielen Projekten und Schlagworten aus dem Blick — gerade dann, + wenn man sie sucht. Der Umschalter steht bewusst hier statt in der Seitenleiste — ein Plattform-Admin mit mehreren Abteilungen muss auf jeder Seite sehen, @@ -60,10 +67,14 @@ </span> {{end}} </div> - <form method="POST" action="/logout" class="reihe"> - <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> - <button type="submit" class="wie-link">Abmelden</button> - </form> + <div class="reihe kopfleiste-aktionen"> + <a href="/hilfe" class="kopfleiste-link{{if eq .Bereich "hilfe"}} aktiv{{end}}" + {{if eq .Bereich "hilfe"}}aria-current="page"{{end}}>Hilfe<span class="nur-breit"> & Einstieg</span></a> + <form method="POST" action="/logout" class="reihe"> + <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> + <button type="submit" class="wie-link">Abmelden</button> + </form> + </div> </div> </header> @@ -145,26 +156,24 @@ <nav class="nav-gruppe"> <h2 class="nav-titel">Plattform</h2> <ul class="nav-liste"> + <li><a href="/admin/overview" {{if eq .Bereich "ueberblick"}}class="aktiv"{{end}}>Überblick (Workspace)</a></li> + <li><a href="/admin/search" {{if eq .Bereich "suche"}}class="aktiv"{{end}}>Suche (alle Projekte)</a></li> <li><a href="/admin/audit">Protokoll (alle)</a></li> <li><a href="/admin/workspaces" {{if eq .Bereich "workspaces"}}class="aktiv"{{end}}>Workspaces</a></li> + <li><a href="/admin/system" {{if eq .Bereich "system"}}class="aktiv"{{end}}>System</a></li> </ul> </nav> {{else if .Session.IsWorkspaceAdmin}} <nav class="nav-gruppe"> <h2 class="nav-titel">Workspace</h2> <ul class="nav-liste"> + <li><a href="/admin/overview" {{if eq .Bereich "ueberblick"}}class="aktiv"{{end}}>Überblick</a></li> + <li><a href="/admin/search" {{if eq .Bereich "suche"}}class="aktiv"{{end}}>Suche (alle Projekte)</a></li> <li><a href="/admin/audit">Protokoll (alle)</a></li> </ul> </nav> {{end}} - {{/* Die Hilfe steht für alle ganz unten — gleich, mit welcher Rolle - man angemeldet ist. */}} - <nav class="nav-gruppe"> - <ul class="nav-liste"> - <li><a href="/hilfe" {{if eq .Bereich "hilfe"}}class="aktiv"{{end}}>Hilfe & Einstieg</a></li> - </ul> - </nav> </aside> <main class="inhalt"> @@ -191,3 +200,28 @@ </div> {{template "html-ende" .}} {{end}} + +{{/* Balkendiagramm der System- und Überblicksseite. Die Zahlen rechnet + internal/web/diagramm.go aus; hier wird nur gezeichnet. */}} +{{define "diagramm"}} +{{if .Leer}} +<p class="hilfe diagramm-leer">Keine im ganzen Zeitraum.</p> +{{else}} +<svg class="diagramm" viewBox="0 0 {{.Breite}} {{.Hoehe}}" role="img" aria-label="{{.Titel}}"> + {{range .Gitter}} + <line class="diagramm-gitter" x1="{{$.Links}}" x2="{{$.Rechts}}" y1="{{.Y}}" y2="{{.Y}}"/> + <text class="diagramm-text" x="{{$.Links}}" dx="-6" y="{{.Y}}" dy="4" text-anchor="end">{{.Text}}</text> + {{end}} + {{range .Balken}} + <g> + <title>{{.Titel}} + + {{range .Teile}}{{end}} + + {{end}} + {{range .Achse}} + {{.Text}} + {{end}} + +{{end}} +{{end}} diff --git a/templates/suche.html b/templates/suche.html new file mode 100644 index 0000000..91934af --- /dev/null +++ b/templates/suche.html @@ -0,0 +1,51 @@ +{{template "kopf" .}} + +
+
+

Suche in allen Projekten {{template "hilfelink" "plattform-admin#suche-in-allen-projekten"}}

+ Alle Projekte dieses Workspace auf einmal — „In welchem Projekt stand das nochmal?“. + Treffer nach Projekt gruppiert, das passendste Projekt oben. +
+
+ + + +{{with .Data}} +{{if .Query}} +

{{.Treffer}} Treffer in {{len .Gruppen}} von {{.Projekte}} Projekten für „{{.Query}}“.

+ +{{range .Gruppen}} +{{$slug := .Project.Slug}} +
+

{{.Project.Name}}

+
    + {{range .Items}} +
  • +
    + {{.Entry.Title}} +
    + {{if .Entry.Curated}}kuratiert{{end}} + {{range .Entry.Tags}}{{.}}{{end}} + {{datum .Entry.UpdatedAt}} +
    + {{if .Snippet}}

    {{.Snippet}}

    {{end}} +
    +
  • + {{end}} +
+ {{if .Mehr}}

Weitere Treffer in {{.Project.Name}} …

{{end}} +
+{{else}} +
+

Nichts gefunden für „{{.Query}}“.

+

Es wird auch nach Wortanfängen und ähnlich geschriebenen Wörtern gesucht — vielleicht hilft ein anderer Begriff.

+
+{{end}} +{{end}} +{{end}} + +{{template "fuss" .}} diff --git a/templates/system.html b/templates/system.html new file mode 100644 index 0000000..3ae7c9e --- /dev/null +++ b/templates/system.html @@ -0,0 +1,265 @@ +{{template "kopf" .}} + +
+
+

System {{template "hilfelink" "plattform-admin#system"}}

+ Ob etwas zu tun ist, wie Munin genutzt wird und worauf es läuft. +
+
+ +{{with .Data}} +
+
+ {{if .Bereit}}bereit{{else}}nicht bereit{{end}} + Datenbank (wie /ready) +
+
+ {{.Offen}}{{if .Kapazitaet.Begrenzt}} / {{.Kapazitaet.Erlaubt}}{{end}} + offene Verbindungen{{if .Kapazitaet.Begrenzt}} / max_connections{{end}} +
+
+ {{.AnzahlWorkspaces}}{{if .Kapazitaet.Begrenzt}} / {{.Kapazitaet.MaxWorkspaces}}{{end}} + Workspaces{{if .Kapazitaet.Begrenzt}} / höchstens möglich{{end}} +
+
+ {{.Laufzeit}} + seit dem Start +
+
+ +{{if not .Bereit}} +
Die Kontroll-Datenbank antwortet nicht: {{.BereitText}}
+{{end}} + +
+

Zustand

+ {{if .Hinweise}} +
    + {{range .Hinweise}} +
  • + {{if .Warnung}}Warnung{{else}}Hinweis{{end}} + {{.Text}} +
  • + {{end}} +
+ {{else if .Bereit}} +
Alles in Ordnung — nichts zu tun.
+ {{end}} +
+ +{{if not .Stand.Erhoben.IsZero}} +
+

Aktivität der letzten {{.TageImDiagramm}} Tage

+ Alle Workspaces. Stand {{.Stand.Erhoben.Local.Format "15:04"}} — gezählt wird höchstens + alle 5 Minuten neu. + +
+
+
+ Schreibvorgänge + Agenten (MCP) + Menschen (Weboberfläche) +
+ {{template "diagramm" .DiagrammSchreiben}} +
+
+
+ Suchen + recall von Agenten +
+ {{template "diagramm" .DiagrammSuchen}} +
+
+ Schreiben heißt angelegt, geändert oder gelöscht. Tage in UTC; mit der Maus über einen + Tag zeigt die genauen Zahlen. + +
+ Als Tabelle +
+ + + + + + {{range .Stand.Tage}} + + + + + + + {{end}} + +
TagAgentenMenschenSuchen
{{.Datum.Format "02.01.2006"}}{{.SchreibenAgent}}{{.SchreibenMensch}}{{.Suchen}}
+
+
+ +

Am aktivsten

+ {{if .TopProjekte}} +
+ + + + + + + + + + + + {{range .TopProjekte}} + + + + + + + + {{end}} + +
ProjektWorkspaceSchreiben AgentenSchreiben MenschenSuchen
{{.Name}} {{.Slug}}{{.Workspace}}{{.SchreibenAgent}}{{.SchreibenMensch}}{{.Suchen}}
+
+ {{if .WeitereAktive}}… und {{.WeitereAktive}} weitere mit Aktivität.{{end}} + {{else}} +

In den letzten {{.TageImDiagramm}} Tagen hat in keinem Projekt jemand geschrieben oder gesucht.

+ {{end}} + + {{with .Stand.Ruhig}} +
+ {{len .}} {{if eq (len .) 1}}Projekt{{else}}Projekte{{end}} ohne jede Aktivität + Weder geschrieben noch gesucht. Wird das Projekt noch gebraucht, oder fehlt dem Team + die Anbindung? +
    + {{range .}}
  • {{.Name}} {{.Workspace}}
  • {{end}} +
+
+ {{end}} +
+ +
+

Bestand je Workspace

+
+ + + + + + + + + + + + + + {{range .Stand.Workspaces}} + + + + + + + + + + {{end}} + {{if gt (len .Stand.Workspaces) 1}} + + + + + + + + + + {{end}} + +
WorkspaceProjekteEinträgeFassungenPapierkorbKeysPlatz
{{.Name}}
{{.Ort}}
{{.Bestand.Projekte}}{{.Bestand.Eintraege}}{{.Bestand.Fassungen}}{{.Bestand.Papierkorb}}{{.Bestand.Keys}}{{groesse .Belegung.Gesamt}}{{if .Belegung.WAL}} +
davon WAL {{groesse .Belegung.WAL}}
{{end}}
Zusammen{{.Stand.Summe.Projekte}}{{.Stand.Summe.Eintraege}}{{.Stand.Summe.Fassungen}}{{.Stand.Summe.Papierkorb}}{{.Stand.Summe.Keys}}{{groesse .Stand.Belegung.Gesamt}}
+
+ Keys: nur gültige. Platz: bei SQLite die Datei samt WAL, bei PostgreSQL die Tabellen + des Schemas samt Indizes. Sicherungen und Export kommen hinzu. +
+{{end}} + +
+

Speicher

+
+
Datenbank
{{.Speicher}}
+ {{if .Kapazitaet.Begrenzt}} +
Verbindungsbudget
+
{{.Kapazitaet.JeWorkspace}} je geöffnetem Workspace, {{.Kapazitaet.Fest}} fest, + {{.Kapazitaet.Reserve}} Reserve, bei max_connections {{.Kapazitaet.Erlaubt}} + → höchstens {{.Kapazitaet.MaxWorkspaces}} Workspaces. + Darüber lehnt Munin neue Workspaces ab bzw. startet nicht.
+ {{else}} +
Verbindungsbudget
+
keins — jede SQLite-Datei hat ihre eigenen Verbindungen.
+ {{end}} +
Gewartet
+
{{.Gewartet}}× musste eine Anfrage auf eine freie Verbindung warten (seit dem Start). + Ab und zu ist normal. Wächst die Zahl stetig, sind die Pools zu klein für die Last.
+
+
+ +
+

Verbindungen je Workspace

+ Nur geöffnete Workspaces belegen Verbindungen. Ein Workspace wird beim ersten + Zugriff geöffnet; ruhige Pools geben ihre Verbindungen nach kurzer Zeit zurück. + Diese Zahlen sind immer aktuell. +
+ + + + + + + + + + + + + + + + + + + {{range .Workspaces}} + + + + + + + + {{end}} + +
Workspaceoffenin Benutzunglesen / schreibengewartet
Kontroll-Datenbank{{.Kontrolle.Offen}}{{.Kontrolle.InBenutzung}}{{.Kontrolle.Lesen.OpenConnections}} / {{.Kontrolle.Schreiben.OpenConnections}}{{.Kontrolle.Gewartet}}
{{if .Name}}{{.Name}}{{else}}gelöscht{{end}} +
{{.Ort}}
{{.Verbindungen.Offen}}{{.Verbindungen.InBenutzung}}{{.Verbindungen.Lesen.OpenConnections}} / {{.Verbindungen.Schreiben.OpenConnections}}{{.Verbindungen.Gewartet}}
+
+
+ +
+

Programm

+
+
Fassung
{{.Fassung}}
+
Go
{{.GoFassung}}
+
Gestartet
{{datum .Gestartet}}
+ {{with .Stand.Schemastand}}
Schema
{{.}}
{{end}} +
Markdown-Export
+
{{if not .ExportEingerichtet}}nicht eingerichtet (EXPORT_DIR fehlt) + {{else if .Export.Ende.IsZero}}seit dem Start noch kein Lauf fertig + {{else}}zuletzt {{datum .Export.Ende}}, {{.Export.Stats.Written}} {{if eq .Export.Stats.Written 1}}Datei{{else}}Dateien{{end}} + {{- if .Export.Stats.Removed}}, {{.Export.Stats.Removed}} verwaiste entfernt{{end}} + {{- if .Export.Fehler}} — unvollständig{{end}}{{end}}
+ {{if not .Stand.Erhoben.IsZero}}
Zählung
+
{{.Stand.Dauer.Round 1000000}} für alle Workspaces
{{end}} +
+
+{{end}} + +{{template "fuss" .}} diff --git a/templates/ueberblick.html b/templates/ueberblick.html new file mode 100644 index 0000000..0ff0137 --- /dev/null +++ b/templates/ueberblick.html @@ -0,0 +1,124 @@ +{{template "kopf" .}} + +{{with .Data}} +
+
+

Überblick: {{.Workspace}} {{template "hilfelink" "workspace-admin#ueberblick"}}

+ Was in deinem Workspace zu tun ist und wie die Teams Munin nutzen. +
+
+ +
+
+ {{.Summe.Projekte}} + Projekte +
+
+ {{.Summe.Eintraege}} + Einträge +
+
+ {{.Summe.Keys}} + gültige Keys +
+
+ {{.Schreiben}} + Schreibvorgänge, {{.TageImDiagramm}} Tage +
+
+ {{.Suchen}} + Suchen, {{.TageImDiagramm}} Tage +
+
+ +
+

Was zu tun ist

+ {{if .Gruppen}} +
    + {{range .Gruppen}} +
  • + {{.Projekt}} + +
  • + {{end}} +
+ Rot: Im Projekt ist etwas dringend (fett). Grün: bei Gelegenheit hinsehen. Ein Punkt + verschwindet von selbst, sobald er erledigt ist. + {{else}} +
Alles in Ordnung — nichts zu tun.
+ {{end}} +
+ +
+

Projekte

+ {{if .Projekte}} +
+ + + + + + + + + + + + + + {{range .Projekte}} + + + + + + + + + + {{end}} + +
ProjektEinträgeSchreiben AgentenSchreiben MenschenSuchenohne Trefferzuletzt geändert
{{.Name}} {{.Slug}}{{.Eintraege}}{{.SchreibenAgent}}{{.SchreibenMensch}}{{.Suchen}}{{if lt .OhneTreffer 0}}—{{else}}{{.OhneTreffer}} %{{end}}{{if .Zuletzt.IsZero}}noch nie{{else}}{{datum .Zuletzt}}{{end}}
+
+ Zahlen der letzten {{.TageImDiagramm}} Tage. Der Name führt zur Nutzung des Projekts mit + allen Einzelheiten. + {{else}} +
+

Noch keine Projekte in diesem Workspace.

+ Projekt anlegen +
+ {{end}} +
+ +{{if .Projekte}} +
+

Aktivität der letzten {{.TageImDiagramm}} Tage

+
+
+
+ Schreibvorgänge + Agenten (MCP) + Menschen (Weboberfläche) +
+ {{template "diagramm" .DiagrammSchreiben}} +
+
+
+ Suchen + recall von Agenten +
+ {{template "diagramm" .DiagrammSuchen}} +
+
+ Schreiben heißt angelegt, geändert oder gelöscht. Tage in UTC; mit der Maus über einen + Tag zeigt die genauen Zahlen. +
+{{end}} +{{end}} + +{{template "fuss" .}} diff --git a/templates/workspaces.html b/templates/workspaces.html index 766ba02..bbc906e 100644 --- a/templates/workspaces.html +++ b/templates/workspaces.html @@ -4,7 +4,8 @@

Workspaces {{template "hilfelink" "plattform-admin#workspaces"}}

Jede Abteilung hat ihren eigenen Workspace — eigene Projekte, - eigene Einträge, eigene Datei. Sie sehen sich gegenseitig nicht. + eigene Einträge, eigener Speicherbereich (Datei bzw. Schema, siehe unter dem + Namen). Sie sehen sich gegenseitig nicht.
@@ -28,6 +29,9 @@ {{.Name}} {{if eq .ID $aktuell}}Aktuell{{end}} + {{/* Wo die Daten liegen: PostgreSQL-Schema oder SQLite-Datei. + Zum Wiederfinden in der Datenbank, z.B. in psql. */}} +
{{.Ort}}
{{.Projects}} {{/* Aktive Workspace-Admins. "ungenutzt" ist das Signal, auf das