Erweiterung um Postgres und Systemstats, Bugfixes und Erweiterung UI

This commit is contained in:
Thomas Adamek 2026-10-16 22:36:24 +02:00
parent 1d07485e72
commit 1ce9be1e2e
110 changed files with 9185 additions and 497 deletions

View file

@ -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_<id> 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

View file

@ -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.
---

View file

@ -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=<schema>` 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_<id>` | 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 '<Workspace-Name>'`. 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_<id>`, 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_<id>.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 … </dev/urandom | head` beendet mit `pipefail` das Skript (SIGPIPE), und
`sqlite3` bricht bei schwerem Schaden selbst ab, bevor die eigene Meldung
kommt. Beides ist abgefangen.
---
## Teil 5 — Oberfläche
@ -1093,10 +1633,255 @@ Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering |
| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering |
| ✅ Ausgewählte Einträge löschen (5.5) | Aufräumen ohne jeden Eintrag einzeln zu öffnen | gering |
| ✅ Verweise in Code ignorieren (5.6) | Beispiele in Code werden nicht zu falschen Verweisen | gering |
| ✅ Ähnliche Einträge beim Anlegen (5.7) | „Gibt es schon?“ auch für Menschen | gering |
| ✅ Rückfrage bei ungespeicherten Änderungen (5.8) | Kein verlorener Text | gering |
| ✅ Verwandte Einträge in der Ansicht (5.9) | Zweite Reihe im Verweisgraphen | gering |
| ✅ Veraltete Einträge sichtbar (5.10) | Gezielt aufräumen | gering |
| ✅ Protokoll filtern (5.11) | „Was hat dieser Zugang gestern gemacht?“ | gering |
| ✅ Systemseite (5.12) | Verbindungen gegen das Budget, Fassung | mittel |
| ✅ Suche über alle Projekte (5.13) | „In welchem Projekt stand das?“ | mittel |
| ✅ Tastenkürzel (5.14) | Schneller pflegen | gering |
| ✅ Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare überarbeitet (5.15) | Orientierung und Lesbarkeit | gering |
| ✅ Systemseite: Zustand, Aktivität, Bestand (5.16) | Auf einen Blick: Ist etwas zu tun, wird Munin genutzt? | mittel |
| ✅ Überblick für Workspace-Admins (5.17) | Was im eigenen Workspace zu tun ist, je Projekt mit Link | gering |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
der erste.
### 5.7 Ähnliche Einträge beim Anlegen — ✅ umgesetzt (2026-10-16)
Agenten bekamen bei `remember` eine Rückfrage mit Kandidaten (3.3), Menschen
im Formular nicht. Das war die letzte Lücke im Schutz gegen doppeltes Wissen.
- Die Prüfung zog dafür aus `internal/mcpserver` in ein eigenes Paket
`internal/dubletten` (`Pruefe`). MCP und Weboberfläche urteilen so mit
derselben Logik; zwei Fassungen liefen mit der Zeit auseinander.
- `GET /p/{projekt}/entries/similar?title=…&slug=…` (Schreibrecht) liefert die
Kandidaten als JSON, samt gemeinsamer Wörter. `static/aehnlich.js` fragt
nach einer Tipp-Pause (400 ms) und zeigt unter dem Titel „Gibt es schon?“.
- **Nur ein Hinweis, keine Sperre:** Ein Mensch sieht die Liste und
entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit
der angefangene Text bleibt.
- Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben,
ist es eine Aktualisierung — dann gibt es keinen Hinweis.
- Tests: `TestAehnlicheEintraege`, `TestFormularUndKuerzel`.
### 5.8 Rückfrage bei ungespeicherten Änderungen — ✅ umgesetzt (2026-10-16)
`static/ungespeichert.js` an Formularen mit `data-ungespeichert` (das
Bearbeitungsformular): Verlässt jemand die Seite mit Änderungen, fragt der
Browser nach (`beforeunload`). „Geändert“ heißt: anders als beim Laden — wer
tippt und wieder löscht, wird nicht gefragt. Beim Speichern keine Rückfrage.
Den Wortlaut legt der Browser fest; eigene Texte erlauben moderne Browser
nicht mehr. Bewusst kein Entwurf im Browser-Speicher: Das wären Inhalte, die
nach dem Abmelden auf dem Rechner liegen bleiben.
### 5.9 Verwandte Einträge in der Ansicht — ✅ umgesetzt (2026-10-16)
Unter einem Eintrag steht jetzt neben *Verweist hierher* der Kasten *Auch
verwandt*: die **zweite Reihe** aus `store.RelatedEntries` (dieselbe Funktion
wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt
dort absichtlich: Eigene Verweise stehen im Text, eingehende unter *Verweist
hierher*. Test: `TestAuchVerwandt`.
### 5.10 Veraltete Einträge sichtbar machen — ✅ umgesetzt (2026-10-16)
„Veraltet“ (seit über einem Jahr unverändert, 3.5) stand nur in der
Einzelansicht. Jetzt:
- Marke *veraltet* in der Eintragsliste.
- Filterlink *N veraltet* neben der Suche (nur, wenn es welche gibt),
`?veraltet=1` zeigt genau diese.
- Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste.
- Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`.
Tests: `TestVeralteteEintraegeFilternUndZaehlen` (auch PostgreSQL),
`TestVeralteteEintraege`.
### 5.11 Protokoll filtern — ✅ umgesetzt (2026-10-16)
Über der Tabelle: Vorgang (mit verständlichem Namen, `store.AuditActions`),
Zugang (Liste aus `store.AuditActors`), Zeitraum (ganze Tage), im Protokoll
über alle Projekte zusätzlich das Projekt. Ein GET-Formular, damit sich eine
gefilterte Ansicht als Adresse weitergeben lässt. Kaputte Werte in der Adresse
ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200
Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL),
`TestProtokollFiltern`.
### 5.12 Systemseite — ✅ umgesetzt (2026-10-16)
`/admin/system`, nur Plattform-Admin:
- Datenbank (`Speicher.Beschreibung`, ohne Passwort) und Bereitschaft wie
`/ready`.
- Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und
die daraus folgende Höchstzahl an Workspaces (`Speicher.Kapazitaet`,
`Kapazitaet.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in
Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über
`Store.Verbindungen`).
- Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`,
`vcs.modified`), Go-Fassung, Start und Laufzeit.
Test: `TestSystemseite`.
### 5.13 Suche über alle Projekte — ✅ umgesetzt (2026-10-16)
`/admin/search`, für Plattform- und Workspace-Admins (wer alle Projekte
verwalten darf, darf auch in allen suchen). Durchsucht jeden Projektindex und
zeigt die Treffer **nach Projekt gruppiert**, je Projekt die besten fünf, die
Projekte nach ihrem besten Treffer sortiert. Eine gemeinsame Rangfolge gäbe es
nicht ehrlich: Jedes Projekt hat seinen eigenen BM25-Index, die Werte sind
nicht vergleichbar.
Bewusst getrennt von 4.9: Am Zugriff der Keys ändert sich nichts, ein Key und
ein Agent sehen weiter genau ein Projekt. Test: `TestSucheInAllenProjekten`
(auch: ein Projektzugang kommt nicht dran).
### 5.14 Tastenkürzel — ✅ umgesetzt (2026-10-16)
`static/tastenkuerzel.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten,
`Strg+Enter`/`Cmd+Enter` speichern. Das Ziel einer Taste steht im Template
(`data-kuerzel="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte
bleiben so an einer Stelle. Buchstaben gelten nur außerhalb von
Eingabefeldern, mit Strg/Alt/Cmd bleiben sie dem Browser.
### 5.15 Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare — ✅ umgesetzt (2026-10-16)
- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/hilfe/seiten/workspace-admin.md`):
Anmelden, was sie dürfen und was nicht, ein Projekt einrichten, Überblick
behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege,
Projekt löschen. Sichtbar für Workspace- und Plattform-Admin. Dafür ersetzt
eine **Zielgruppe** (`FuerAlle`, `FuerAdmins`, `FuerPlattformAdmin`) das
bisherige `NurAdmin`. Tests: Sichtbarkeit je Lesergruppe, keine Links auf
Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link
innerhalb der Hilfe trifft eine vorhandene Überschrift**
(`TestAnkerInLinksGibtEs`).
- **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt
unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus
dem Blick rutschte. Auf schmalen Bildschirmen nur „Hilfe“.
- **Formulare:** Die Breitenklassen (`kurz`, `mittel`, `lang`) begrenzen jetzt
nur das Eingabefeld, nicht mehr Beschriftung und Hinweis — lange Hinweise
brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen
Feldern, Hinweise mit Zeilenabstand 1,5 und bis zur Lesebreite. Zahlen-,
Datums- und Dateifelder sind gestaltet wie die übrigen (vorher
Browser-Standard). Alle einzeiligen Bedienelemente und Knöpfe haben eine
gemeinsame Höhe (`--hoehe-bedienelement`, 2,5 rem); vorher 36 bis 42 Pixel,
in der Filterleiste standen Beschriftungen dadurch versetzt. Auswahllisten
mit langen Optionen (Rolle, Import-Modus) sind breiter. Hinweise an
Ankreuzfeldern stehen unter der Beschriftung.
- **Schmale Bildschirme:** Die Navigationszeilen scrollen in sich, statt die
ganze Seite zu verbreitern (`min-width: 0` fehlte); ein langer
Workspace-Name wird gekürzt.
- Geprüft mit Screenshots aus Chrome im Hintergrundmodus (1280 und 500 Pixel
breit; schmaler rendert Chrome dort nicht).
### 5.16 Systemseite: Zustand, Aktivität, Bestand — ✅ umgesetzt (2026-10-16)
Die Systemseite (5.12) beantwortete nur „Spielt die Datenbank mit?“. Dazu
kommen drei Abschnitte:
1. **Zustand** — ein Kasten mit allem, was zu tun ist, leer heißt „Alles in
Ordnung“. *Warnungen:* Kapazität ab 80 % der höchstens möglichen
Workspaces; Hinweise des Speichers (`Speicher.Hinweise`, heute: PostgreSQL
sortiert nicht nach `C`); ein Workspace ließ sich nicht auswerten;
WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf
einem anderen Schema-Stand als das Programm; der letzte Export
unvollständig, überfällig (Intervall + 2 Std.) oder seit dem Start nach
einer Stunde noch keiner fertig. *Hinweise:* Anfragen mussten seit der
vorigen Zählung auf eine Verbindung warten; Keys laufen in 14 Tagen ab;
Keys seit 90 Tagen ungenutzt (je Workspace genannt); ungenutzte
Workspace-Admin-Zugänge.
2. **Aktivität der letzten 30 Tage** über alle Workspaces: Schreibvorgänge je
Tag (Agenten und Menschen gestapelt) und Suchen je Tag als Balken, dazu
„Als Tabelle“, die zehn aktivsten Projekte und die Projekte ganz ohne
Aktivität. Quelle sind Protokoll (`entry_write`, `entry_delete`) und
Suchprotokoll (recall) — keine neue Zählung. 30 Tage, weil das
Suchprotokoll danach aufgeräumt wird. Tage in UTC.
3. **Bestand je Workspace:** Projekte, Einträge, Fassungen, Papierkorb,
gültige Keys, Platz (SQLite: Datei samt WAL; PostgreSQL: Tabellen des
Schemas samt Indizes über `pg_total_relation_size`), Summenzeile. Unter
„Programm“ zusätzlich Schema-Stand und letzter Export.
**Umsetzung:**
- `internal/store/systemstand.go`: `Store.Bestand`, `Store.Belegung` (über
eine neue Dialekt-Methode `belegung`), `Store.Aktivitaet`,
`Store.Schemastand`. Keys werden in Go gezählt, mit denselben Regeln wie
die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`).
- `internal/statistik`: Der `Sammler` fragt alle Workspaces ab und merkt sich
das Ergebnis 5 Minuten (`MindestAbstand`). Gerechnet wird beim Aufruf der
Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das
Aufräumen und der Export beim Start ohnehin.
- `internal/web/diagramm.go`: Balkendiagramme als SVG, vom Server
ausgerechnet. Kein JavaScript, keine Bibliothek, keine `style`-Attribute
(CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt.
Tooltip je Tag über `<title>`.
- `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

View file

@ -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/<zeitstempel>/` 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_<id>` (Bindestrich der ID als `_`). Der Name steht als Kommentar daran und unter *Workspaces* in der Weboberfläche |
| Sicherungen | `BACKUP_DIR/<zeitstempel>/control.dump` und `…/workspaces/munin_ws_<id>.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://<server>/healthz # erwartet: ok
curl -s https://<server>/live # Prozess läuft? erwartet: ok
curl -s https://<server>/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_<id> OWNER TO munin;` (für alles auf einmal `REASSIGN OWNED BY <alter> 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_<id>` | `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_<id> 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_<id> OWNER TO munin;` und
`ALTER TABLE … OWNER TO munin;`, einfacher als Superuser
`REASSIGN OWNED BY <anderer> 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/<id>.db
# 7. Starten und prüfen
docker start munin
curl -s https://<server>/healthz
curl -s https://<server>/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 <BACKUP_DIR>/<zeitstempel>/workspaces/munin_ws_<id>.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_<id> RENAME TO munin_vorher_ws_<id>;'
# 5. Zurückspielen. Legt das Schema samt Namenskommentar wieder an.
pg_restore --dbname="$DATABASE_URL" --no-owner <BACKUP_DIR>/<zeitstempel>/workspaces/munin_ws_<id>.dump
# 6. Starten und prüfen
docker start munin
curl -s https://<server>/ready
```
Danach wie oben **zurückgezogene Keys erneut zurückziehen**. Läuft alles, kann
das alte Schema weg: `DROP SCHEMA munin_vorher_ws_<id> 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 <container> 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 <BACKUP_DIR>/<zeitstempel>/control.dump <BACKUP_DIR>/<zeitstempel>/workspaces/*.dump; do
pg_restore --dbname="postgres://munin@<host>: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 <BACKUP_DIR>/<zeitstempel>` (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 <BACKUP_DIR>/<zeitstempel>
# 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@<host>:5432/munin_probe" \
scripts/sicherung-pruefen.sh <BACKUP_DIR>/<zeitstempel>
```
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@<host>:5432/munin_probe"
for f in <BACKUP_DIR>/<zeitstempel>/control.dump <BACKUP_DIR>/<zeitstempel>/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.

View file

@ -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"]

182
README.md
View file

@ -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_<id>`. 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_<id>`. 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_<id>.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/<zeitstempel>
DB_DRIVER=postgres PROBE_DATABASE_URL='postgres://munin@db.intern:5432/munin_probe' \
scripts/sicherung-pruefen.sh /pfad/sicherungen/<zeitstempel>
```
**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 ./...
```
---

View file

@ -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_<id>) 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

47
docker-compose.dev.yml Normal file
View file

@ -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:

6
go.mod
View file

@ -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

24
go.sum
View file

@ -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=

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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.

View file

@ -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,16 +93,15 @@ 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)
}
}
return ergebnis
}

View file

@ -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
}
}
if len(Sichtbar(true)) != len(Alle()) {
t.Error("ein Admin sieht nicht alle Seiten")
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(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) {
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.NurAdmin {
t.Errorf("Seite %q verlinkt auf die Admin-Seite %q", s.Slug, ziel.Slug)
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(`<h[1-6] id="([^"]+)"`)
anker := map[string]map[string]bool{}
for _, s := range Alle() {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
html, err := Render(md)
if err != nil {
t.Fatal(err)
}
anker[s.Slug] = map[string]bool{}
for _, m := range idMuster.FindAllStringSubmatch(html, -1) {
anker[s.Slug][m[1]] = true
}
}
link := regexp.MustCompile(`\]\((?:/hilfe/([a-z0-9-]+))?#([a-z0-9-]+)\)`)
for _, s := range Alle() {
md, _ := s.Markdown()
for _, m := range link.FindAllStringSubmatch(md, -1) {
ziel := m[1]
if ziel == "" {
ziel = s.Slug
}
if _, gibt := anker[ziel]; !gibt {
t.Errorf("%s: Link auf unbekannte Seite %q", s.Slug, ziel)
continue
}
if !anker[ziel][m[2]] {
t.Errorf("%s: Abschnitt #%s gibt es auf %q nicht", s.Slug, m[2], ziel)
}
}
}

View file

@ -42,7 +42,9 @@ viele leicht verschiedene.
`[[bezeichner]]` im Inhalt verlinkt auf einen anderen Eintrag. Die Gegenrichtung
entsteht automatisch: Jeder Eintrag zeigt, welche anderen auf ihn verweisen.
Ein Verweis darf auch auf einen Eintrag zeigen, den es noch nicht gibt. Das
markiert eine Lücke, die jemand füllen sollte.
markiert eine Lücke, die jemand füllen sollte. Steht die Schreibweise in Code
(zwischen Backticks oder in einem Codeblock), ist sie kein Verweis, sondern ein
Beispiel, und bleibt so stehen.
**Fassung** (version)
Zählt bei jeder Änderung eines Eintrags hoch. Die vorherigen Fassungen bleiben

View file

@ -155,9 +155,12 @@ In zwei Fällen schreibt `remember` nicht sofort, sondern antwortet mit
`judgment_required`. Das ist kein Fehler, sondern eine Rückfrage an den Agenten:
- **Ähnlicher Eintrag vorhanden:** Ein neuer Eintrag ähnelt einem vorhandenen
(gleiche Wörter im Titel oder in „Auch gesucht als“). Die Antwort nennt die
Kandidaten. Passt einer, schreibt der Agent diesen fort; ist es wirklich ein
anderes Thema, legt er mit `force: true` doch an.
(gleiche Wörter im Titel, oder ein Wort steht wörtlich in „Auch gesucht
als“). Die Antwort nennt die Kandidaten samt der gemeinsamen Wörter
(`matched_words`). Passt einer, schreibt der Agent diesen fort; ist es
wirklich ein anderes Thema, legt er mit `force: true` doch an. `force`
übergeht nur diese eine Rückfrage, nichts sonst. Ein gemeinsamer Titelanfang
wie „Munin – “ zählt nicht.
- **Kuratierter Eintrag:** Ein Mensch hat den Eintrag geprüft. Der Agent ändert
ihn nur mit `override_curated: true` und einer Begründung unter `reason`.
Dabei fällt der Haken „Kuratiert“ weg, und der Eintrag erscheint auf der

View file

@ -27,7 +27,12 @@ Der Name des gerade gewählten Workspace steht immer oben in der Kopfleiste.
Alles, was du unter *Projekte* und *Protokoll (alle)* siehst, bezieht sich nur
auf diesen Workspace.
**Löschen** entfernt die Datenbankdatei des Workspace mit allen Projekten,
Unter dem Namen steht blass, wo die Daten des Workspace liegen: mit SQLite die
Datei (`workspaces/<id>.db` im Datenverzeichnis), mit PostgreSQL das Schema
(`munin_ws_<id>`). 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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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.

View file

@ -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 {

View file

@ -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
}

View file

@ -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.

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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 {

118
internal/store/dialekt.go Normal file
View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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,

View file

@ -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

View file

@ -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
}

View file

@ -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);

View file

@ -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);

View file

@ -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 '';

View file

@ -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:

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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 '';

View file

@ -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);

View file

@ -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

View file

@ -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);

View file

@ -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 {

View file

@ -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)
`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
GROUP BY a.entry_slug
ORDER BY MAX(a.ts) DESC
) AS jeweils_juengster
WHERE nr = 1
ORDER BY ts DESC
LIMIT ?`,
projectID, ActionEntryWrite, DetailCuratedOverridden+"%", limit,
)

View file

@ -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) {

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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 <DATA_DIR>/control.db Schema munin_control
// je Workspace workspaces/<id>.db Schema munin_ws_<id>
//
// 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
}

View file

@ -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=<schema> 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=<schema>. 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 + "'"
}

View file

@ -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. "<DATA_DIR>/control.db"
workspaceOrdner string // z.B. "<DATA_DIR>/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")
}

View file

@ -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/<Unterordner des Dialekts> ü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)
}

View file

@ -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 <deployment>.\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)
}
}

View file

@ -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)
`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
GROUP BY lower(trim(query))
ORDER BY anzahl DESC, MAX(ts) DESC
) AS je_anfrage
WHERE nr = 1
ORDER BY anzahl DESC, ts DESC
LIMIT ?`,
projectID, seit, limit,
)

View file

@ -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 <id>.db-wal. In ihr sammelt
// SQLite Änderungen, bevor es sie in die Datenbank zurückschreibt
// (Checkpoint). Wächst sie immer weiter, kommt der Checkpoint nicht durch
// — meist, weil eine Leseverbindung nie endet. PostgreSQL führt sein WAL
// für den ganzen Server, nicht je Schema; dort bleibt der Wert 0.
WAL int64
}
// Gesamt ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte.
func (b Belegung) Gesamt() int64 { return b.Daten + b.WAL }
// Belegung misst den Platz des Workspace. Wie, hängt von der Datenbank ab
// (siehe dialekt.belegung).
func (s *Store) Belegung(ctx context.Context) (Belegung, error) {
return s.lesen.d.belegung(ctx, s.lesen)
}
// belegung (SQLite): Seitenzahl mal Seitengröße ist die Größe der Datei,
// ohne sie im Dateisystem suchen zu müssen. Die WAL-Datei kennt SQLite selbst
// nicht als Zahl; ihr Pfad ergibt sich aber aus dem der Datenbank, und den
// nennt PRAGMA database_list.
func (sqliteDialekt) belegung(ctx context.Context, p pool) (Belegung, error) {
var b Belegung
var seiten, seitengroesse int64
if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&seiten); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err)
}
if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&seitengroesse); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err)
}
b.Daten = seiten * seitengroesse
// database_list liefert je angehängter Datenbank eine Zeile: Nummer, Name
// ("main" ist die eigene) und Pfad.
var nummer int
var name, pfad string
if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&nummer, &name, &pfad); err != nil {
return b, fmt.Errorf("Pfad der Datenbank unbekannt: %w", err)
}
// Ohne WAL-Datei (gerade alles zurückgeschrieben) bleibt WAL 0 — das ist
// kein Fehler.
if info, err := os.Stat(pfad + "-wal"); err == nil {
b.WAL = info.Size()
}
return b, nil
}
// belegung (PostgreSQL): die Tabellen des eigenen Schemas zusammengezählt.
// pg_total_relation_size rechnet Indizes und ausgelagerte große Werte (TOAST)
// mit ein; deshalb nur über die Tabellen (relkind 'r') summieren, sonst
// zählten die Indizes doppelt.
//
// current_schema() ist das Schema des Workspace, weil jeder Pool seinen
// search_path darauf setzt (speicher_postgres.go). Die Summe ist numeric und
// wird nach bigint gewandelt, damit sie sich als int64 lesen lässt.
func (postgresDialekt) belegung(ctx context.Context, p pool) (Belegung, error) {
var b Belegung
err := p.QueryRowContext(ctx,
`SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)::bigint
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relkind = 'r'`,
).Scan(&b.Daten)
if err != nil {
return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err)
}
return b, nil
}
// Tagesaktivitaet zählt, was an einem Tag in einem Projekt geschah.
type Tagesaktivitaet struct {
ProjektID string
Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel
SchreibenAgent int // über MCP angelegt, geändert oder gelöscht
SchreibenMensch int // dasselbe über die Weboberfläche
Suchen int // recall-Aufrufe aus dem Suchprotokoll
}
// Aktivitaet liefert je Projekt und Tag die Schreibvorgänge und Suchen seit
// seit. Tage und Projekte ohne jede Aktivität fehlen.
//
// Geschrieben heißt hier dasselbe wie in der Nutzung (nutzung.go): angelegt,
// geändert, in den Papierkorb gelegt. Gelesen wird nur gezählt, was im
// Suchprotokoll steht, also recall. Ein get taucht nirgends auf — es gibt
// dafür kein Protokoll, und eines nur für diese Zahl wäre zu viel.
//
// Der Tag sind die ersten zehn Zeichen des Zeitstempels (RFC3339 in UTC).
// SUBSTR gibt es in beiden Datenbanken, und gruppiert wird nach genau
// diesem Ausdruck, nicht nach einer Spaltennummer.
func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivitaet, error) {
grenze := seit.UTC().Format(time.RFC3339Nano)
// Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in
// dieselbe map ein.
type schluessel struct{ projekt, tag string }
je := map[schluessel]*Tagesaktivitaet{}
eintrag := func(projekt, tag string) *Tagesaktivitaet {
k := schluessel{projekt, tag}
if je[k] == nil {
je[k] = &Tagesaktivitaet{ProjektID: projekt, Tag: tag}
}
return je[k]
}
rows, err := s.lesen.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), source, COUNT(*)
FROM audit_events
WHERE ts >= ? AND action IN (?, ?)
GROUP BY project_id, SUBSTR(ts, 1, 10), source`,
grenze, ActionEntryWrite, ActionEntryDelete,
)
if err != nil {
return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err)
}
for rows.Next() {
var projekt, tag, quelle string
var anzahl int
if err := rows.Scan(&projekt, &tag, &quelle, &anzahl); err != nil {
rows.Close()
return nil, err
}
switch quelle {
case SourceMCP:
eintrag(projekt, tag).SchreibenAgent += anzahl
case SourceWeb:
eintrag(projekt, tag).SchreibenMensch += anzahl
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
rows, err = s.lesen.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*)
FROM search_log
WHERE ts >= ?
GROUP BY project_id, SUBSTR(ts, 1, 10)`,
grenze,
)
if err != nil {
return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var projekt, tag string
var anzahl int
if err := rows.Scan(&projekt, &tag, &anzahl); err != nil {
return nil, err
}
eintrag(projekt, tag).Suchen += anzahl
}
if err := rows.Err(); err != nil {
return nil, err
}
liste := make([]Tagesaktivitaet, 0, len(je))
for _, a := range je {
liste = append(liste, *a)
}
return liste, nil
}
// Schemastand nennt die zuletzt angewandte Migration des Workspace (ist) und
// die neueste, die dieses Programm mitbringt (soll), z.B. "007_key_ablauf.sql".
//
// Beim Öffnen eines Workspace laufen fehlende Migrationen ohnehin durch
// (runMigrations). Weichen beide trotzdem voneinander ab, hat eine neuere
// Fassung von Munin die Datenbank schon weitergebracht als dieses Programm —
// etwa nach dem Zurückrollen auf eine ältere Fassung.
func (s *Store) Schemastand(ctx context.Context) (ist, soll string, err error) {
var name sql.NullString
if err := s.lesen.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil {
return "", "", fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
dateien, err := migrationFiles(migrationsFS, "migrations/"+s.lesen.d.migrationsUnterordner())
if err != nil {
return "", "", err
}
if len(dateien) > 0 {
soll = dateien[len(dateien)-1]
}
return name.String, soll, nil
}

View file

@ -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)
}
}

View file

@ -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() }

191
internal/web/diagramm.go Normal file
View file

@ -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 <title> 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
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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.

View file

@ -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")
}
}

View file

@ -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,
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).
//

View file

@ -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),
},
}

View file

@ -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],

View file

@ -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,
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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],

View file

@ -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"
}

View file

@ -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)
}
}
}

View file

@ -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 := ""

View file

@ -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)
}
}

View file

@ -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")
}
}

View file

@ -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.

View file

@ -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 {

View file

@ -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

104
main.go
View file

@ -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()

View file

@ -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

View file

@ -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,22 +62,21 @@
#
# ── 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"
# ── SQLite ───────────────────────────────────────────────────────────────────
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
@ -76,6 +107,84 @@ for datei in "$datenverzeichnis"/workspaces/*.db; do
anzahl=$((anzahl + 1))
done
echo "$anzahl Workspace-Datei(en) gesichert nach $ziel"
}
# ── PostgreSQL ───────────────────────────────────────────────────────────────
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

View file

@ -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;

188
scripts/sicherung-pruefen.sh Executable file
View file

@ -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"

96
static/aehnlich.js Normal file
View file

@ -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();
}
})();

View file

@ -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; }
}

61
static/tastenkuerzel.js Normal file
View file

@ -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();
}
});
})();

45
static/ungespeichert.js Normal file
View file

@ -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 = "";
});
});
})();

View file

@ -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}}

View file

@ -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>

Some files were not shown because too many files have changed in this diff Show more