Erweiterung um Postgres und Systemstats, Bugfixes und Erweiterung UI
This commit is contained in:
parent
1d07485e72
commit
1ce9be1e2e
110 changed files with 9185 additions and 497 deletions
58
.env.example
58
.env.example
|
|
@ -16,13 +16,30 @@ SESSION_SECRET=
|
|||
# openssl rand -hex 24
|
||||
ADMIN_TOKEN=
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────────────────────
|
||||
# ── Datenbank ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Datenverzeichnis: enthält die zentrale Kontroll-Datenbank (control.db) und
|
||||
# den Ordner workspaces/ mit einer SQLite-Datei je Workspace (Abteilung).
|
||||
# Wird beim Start angelegt.
|
||||
# Wo die Daten liegen: "sqlite" (Standard) oder "postgres".
|
||||
# Siehe AUSBAUSTUFEN.md 4.10.
|
||||
# DB_DRIVER=sqlite
|
||||
|
||||
# Nur bei DB_DRIVER=sqlite: Datenverzeichnis mit der zentralen
|
||||
# Kontroll-Datenbank (control.db) und dem Ordner workspaces/ mit einer
|
||||
# SQLite-Datei je Workspace (Abteilung). Wird beim Start angelegt.
|
||||
DATA_DIR=data
|
||||
|
||||
# Nur bei DB_DRIVER=postgres: Verbindung zur Datenbank. Munin legt darin das
|
||||
# Schema munin_control und je Workspace ein Schema munin_ws_<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
813
AUSBAUSTUFEN.md
813
AUSBAUSTUFEN.md
|
|
@ -210,6 +210,16 @@ grob 0,5–2 GB Speicher (einmal, nicht je Workspace) und in Go entweder cgo —
|
|||
dann ist der statische Build weg — oder einen zweiten Dienst. Der Zusatznutzen
|
||||
gegenüber Synonymen vom Client ist klein.
|
||||
|
||||
**pgvector: geprüft und nicht nötig (2026-10-16).** Mit PostgreSQL (4.10) liegt
|
||||
pgvector nahe. Es löst aber nur den kleineren Teil: Vektoren speichern und
|
||||
vergleichen. Das Einbettungsmodell, an dem diese Stufe scheitert, bleibt
|
||||
nötig. Außerdem gibt es pgvector nur in PostgreSQL, die Suche verhielte sich
|
||||
dann je nach Datenbank anders. Und bei dieser Größe braucht es keinen Index:
|
||||
10.000 Einträge × 1.024 Werte sind rund 40 MB, ein Vergleich mit allen dauert
|
||||
in Go wenige Millisekunden. **Falls semantische Suche doch kommt:** Vektoren
|
||||
als `BLOB`/`bytea` in einer Tabelle (gleich in beiden Datenbanken), Suche im
|
||||
Speicher wie beim BM25-Index. pgvector erst bei Millionen von Vektoren.
|
||||
|
||||
### 2.2a Ausschnitt an der Fundstelle — ✅ umgesetzt (2026-10-14)
|
||||
|
||||
`entry.Snippet` suchte die ganze Anfrage wörtlich. Bei Agenten-Fragen („wie
|
||||
|
|
@ -335,7 +345,8 @@ Erstbefüllung aus vorhandener Dokumentation.
|
|||
|
||||
### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14)
|
||||
|
||||
**Umsetzung:** `internal/mcpserver/dubletten.go`. Bei `remember` mit neuem
|
||||
**Umsetzung:** `internal/dubletten` (bis 2026-10-16 `internal/mcpserver/dubletten.go`;
|
||||
seitdem eigenes Paket, weil auch die Weboberfläche es nutzt, siehe 5.7). Bei `remember` mit neuem
|
||||
Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
|
||||
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich),
|
||||
sondern der Anteil gemeinsamer Titelwörter (`search.Ueberdeckung`, in beide
|
||||
|
|
@ -384,6 +395,46 @@ Braucht keine Einbettungen — der BM25-Index liegt bereits vor.
|
|||
das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster
|
||||
ist gut und ließe sich mit unserem Index eins zu eins übernehmen.
|
||||
|
||||
**Nachgebessert 2026-10-16: gemeinsames Präfix im Titel.** Im Betrieb mit
|
||||
Titeln nach dem Muster „Projekt – Thema“ wurde fast jeder neue Eintrag
|
||||
gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein
|
||||
gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein
|
||||
Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn
|
||||
**beide** Titel dasselbe haben (`ohneGemeinsamesPraefix` in
|
||||
`internal/dubletten`). Verschiedene Präfixe bleiben im
|
||||
Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne
|
||||
Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter
|
||||
gleichem Präfix fällt weiter auf. Tests: `TestOhneGemeinsamesPraefix`,
|
||||
`TestRememberGemeinsamesPraefixKeineDublette`.
|
||||
|
||||
Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen
|
||||
Projekt mit zwei Einträgen drückt das auch „Deployment-Prozess“ gegen
|
||||
„Deployment-Ablauf“ unter die Schwelle, also genau den Fall, um den es geht.
|
||||
Titel ohne Trenner („Munin Architektur“) erfasst die Regel nicht; das bleibt
|
||||
ein Fehlalarm, den `force` auflöst.
|
||||
|
||||
**Zweite Nachbesserung 2026-10-16: Suchbegriffe nur wörtlich, gemeinsame
|
||||
Wörter in der Antwort.** Beim Dokumentieren in Munin selbst kamen zwei weitere
|
||||
Fehlalarme mit je 50 %:
|
||||
|
||||
1. „Munin – Betrieb mit PostgreSQL“ gegen einen Statuseintrag mit dem
|
||||
Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von
|
||||
„postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt
|
||||
jetzt nur wörtlich, Titelwörter weiter mit allen Varianten
|
||||
(`search.UeberdeckungMitSuchbegriffen`). Der Kernfall „Release-Ablauf“
|
||||
gegen den Suchbegriff `release` bleibt erkannt.
|
||||
2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit
|
||||
PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet —
|
||||
strukturell ist das derselbe Fall wie „Deployment-Ablauf“ gegen
|
||||
„Deployment-Prozess“, den die Prüfung finden soll. Wortvergleiche können
|
||||
die beiden nicht trennen, nur Bedeutung könnte es. Dafür nennt jeder
|
||||
Kandidat jetzt die gemeinsamen Wörter (`matched_words`). Steht dort nur ein
|
||||
allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den
|
||||
Kandidaten erst zu laden.
|
||||
|
||||
Tests: `TestUeberdeckungMitSuchbegriffen`, `TestRememberSuchbegriffNurWoertlich`,
|
||||
`matched_words` in `TestRememberMeldetDublette`.
|
||||
|
||||
### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14)
|
||||
|
||||
**Problem:** `remember` überschrieb kuratierte Einträge ohne Rückfrage; der
|
||||
|
|
@ -645,10 +696,20 @@ kein Betriebsdatum.
|
|||
Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`),
|
||||
den die Anmelde-Middleware befüllt.
|
||||
|
||||
### 4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt
|
||||
### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell)
|
||||
|
||||
`.github/workflows/test.yml` prüft bei jedem Push und jedem Pull Request:
|
||||
Formatierung, `go vet`, `go test -race` und einen Bau mit `CGO_ENABLED=0`.
|
||||
Es gibt keine automatische Pipeline (kein GitHub, keine CI). Die Prüfungen
|
||||
werden vor jedem Commit von Hand aufgerufen:
|
||||
|
||||
```
|
||||
gofmt -l . # muss leer bleiben
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
CGO_ENABLED=0 go build -o /dev/null .
|
||||
```
|
||||
|
||||
*Korrektur 2026-10-16:* Hier stand früher, `.github/workflows/test.yml` führe
|
||||
diese Prüfungen automatisch aus. Diese Datei hat es im Repository nie gegeben.
|
||||
|
||||
`-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools
|
||||
werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl,
|
||||
|
|
@ -785,11 +846,29 @@ und am selben Tag behoben):
|
|||
Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr
|
||||
an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden.
|
||||
|
||||
### 4.5 Kennzahlen
|
||||
### 4.5 Kennzahlen — geplant, sobald es ein zentrales Monitoring gibt
|
||||
|
||||
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
|
||||
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
|
||||
|
||||
*Stand 2026-10-16:* Ein zentrales Monitoring ist angekündigt, aber noch nicht
|
||||
da. Bis dahin beantwortet die Systemseite (5.12, 5.16) die Fragen „Ist etwas
|
||||
zu tun?“ und „Wird Munin genutzt?“. Was erst mit dem Monitoring kommt:
|
||||
|
||||
- **Laufzeitwerte, die heute niemand zählt:** MCP-Aufrufe je Werkzeug,
|
||||
Fehlerquote, Dauer (Median, langsamste 5 %), Treffer auf das Schreibbudget je
|
||||
Projekt, gesperrte IPs, Go-Laufzeit (Speicher, Goroutinen, GC),
|
||||
Wartezeiten der Verbindungspools als Verlauf.
|
||||
- **Weg:** entweder `/metrics` im Prometheus-Textformat (von Hand
|
||||
geschrieben, ohne Bibliothek machbar) oder OpenTelemetry, je nachdem, was das
|
||||
Monitoring erwartet. OpenTelemetry brächte eine Bibliothek mit sich und ist
|
||||
erst zu wählen, wenn klar ist, wohin die Daten gehen.
|
||||
- **Bewusst nicht in Munin:** eine eigene Ablage für Messwerte über Monate mit
|
||||
eigenen Diagrammen. Verläufe zeichnet das Monitoring; die Systemseite bleibt
|
||||
der schnelle Blick.
|
||||
- **Schutz:** `/metrics` nicht öffentlich — nur aus dem internen Netz oder
|
||||
mit eigenem Token, wie `/live` und `/ready` ohne Inhalte der Projekte.
|
||||
|
||||
### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt
|
||||
|
||||
Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der
|
||||
|
|
@ -845,7 +924,7 @@ unterscheiden.
|
|||
„keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl
|
||||
kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt.
|
||||
|
||||
### 4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt
|
||||
### 4.7 Ungenutzte Zugänge sichtbar machen — ✅ umgesetzt (Stufe 2: 2026-10-16)
|
||||
|
||||
`last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem
|
||||
Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben,
|
||||
|
|
@ -871,7 +950,28 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
|
|||
- **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine
|
||||
Grenze von 90 Tagen spielt das keine Rolle.
|
||||
- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
|
||||
- **Offen:** Stufe 2 (Ablaufdatum).
|
||||
|
||||
**✅ Stufe 2 umgesetzt 2026-10-16: Ablaufdatum.**
|
||||
|
||||
- **Migration 007** (`007_key_ablauf.sql`, in beiden Ordnern gleich):
|
||||
`api_keys.expires_at TEXT NOT NULL DEFAULT ''`. Leer = läuft nie ab, wie bei
|
||||
`revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst
|
||||
keine Daten an.
|
||||
- **Gilt nicht mehr ab dem Ablauf**, an beiden Stellen, an denen geprüft wird,
|
||||
ob ein Key gilt: `ResolveKeyHash` (jede MCP-Anfrage, jede Anmeldung;
|
||||
Bedingung `expires_at = '' OR expires_at > jetzt`) und `sessionFromSubject`
|
||||
(jede Seite: `APIKey.Valid`). Eine laufende Browser-Sitzung endet also bei
|
||||
der nächsten Anfrage nach dem Ablauf.
|
||||
- **Oberfläche:** Feld *Gültig bis* beim Anlegen (optional, gilt bis
|
||||
einschließlich des Tages). Spalte *Gültig bis*, Status *abgelaufen*, Marke
|
||||
*läuft bald ab* zwei Wochen vorher (`ExpiresSoonWithin`) und ein Hinweis über
|
||||
der Tabelle. Ein Datum in der Vergangenheit wird abgelehnt. Das Protokoll
|
||||
vermerkt das Ablaufdatum bei `key_created`.
|
||||
- **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr.
|
||||
- **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf
|
||||
derselbe Weg in der Kontroll-Datenbank.
|
||||
- **Tests:** `TestKeyAblauf` (Store, auch gegen PostgreSQL),
|
||||
`TestKeyMitAblaufdatum` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung).
|
||||
|
||||
### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30)
|
||||
|
||||
|
|
@ -1076,6 +1176,446 @@ Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft
|
|||
nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die
|
||||
Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
|
||||
|
||||
### 4.10 PostgreSQL als zweiter Speicherort — in Arbeit (begonnen 2026-10-16)
|
||||
|
||||
PostgreSQL soll **neben** SQLite nutzbar sein, umschaltbar über die
|
||||
Konfiguration. SQLite bleibt der Standard.
|
||||
|
||||
**Stand 2026-10-16:** Schritte 1, 2, 3 und 5 sind umgesetzt (siehe
|
||||
*Reihenfolge und Aufwand* am Ende). Alle Tests laufen gegen beide Datenbanken,
|
||||
Sicherung und Wiederherstellung sind durchgespielt. PostgreSQL ist damit
|
||||
produktiv nutzbar, mit einer Schreibverbindung je Workspace. Offen ist
|
||||
Schritt 4 (mehrere Schreiber).
|
||||
|
||||
**Grenze: Auch mit PostgreSQL läuft genau ein Munin-Prozess.** PostgreSQL
|
||||
ändert den Speicherort, nicht die Architektur. Einiges lebt nur im
|
||||
Arbeitsspeicher des Prozesses:
|
||||
|
||||
- **Suchindex** (`internal/search`): Ein zweiter Prozess sähe nicht, was der
|
||||
erste geschrieben hat, und fände veraltete Stände, ohne Fehlermeldung.
|
||||
- **Schreibbudget und Rate-Limiter** (`internal/auth/ratelimit.go`): Jeder
|
||||
Prozess zählte für sich, die Grenzen gälten doppelt.
|
||||
|
||||
Die Anmeldung wäre kein Problem (signierte Cookies, kein Sitzungsspeicher).
|
||||
Mehrere Prozesse wären eine eigene, deutlich größere Ausbaustufe.
|
||||
|
||||
**Ausgangslage, was schon passt:**
|
||||
|
||||
- **SQL gibt es nur in `internal/store`.** Handler, MCP-Tools und Templates
|
||||
arbeiten mit Go-Typen. Genau für diesen Fall ist das Package gebaut (siehe
|
||||
Kopfkommentar in `store.go`).
|
||||
- **Die Migrationen folgen portablen Regeln** (Kopf von
|
||||
`migrations/001_init.sql`): IDs als TEXT aus Go, Zeitstempel als
|
||||
RFC3339-Text, Wahrheitswerte als 0/1. Kein `AUTOINCREMENT`, kein
|
||||
`INSERT OR REPLACE`, kein `datetime()`.
|
||||
- **Die Volltextsuche ist eigener BM25-Code in Go** (`internal/search`), nicht
|
||||
FTS5 (siehe 1.1). Sie hängt also nicht an der Datenbank.
|
||||
- `RETURNING` und `ALTER TABLE … ADD COLUMN` können beide Datenbanken.
|
||||
`isUniqueViolation` erkennt die Fehlermeldungen beider (`models.go`).
|
||||
|
||||
**Was sich ändern muss:**
|
||||
|
||||
| # | Stelle | Änderung | Aufwand |
|
||||
|---|---|---|---|
|
||||
| 1 | Konfiguration (`main.go`) | `DB_DRIVER=sqlite\|postgres` (Standard `sqlite`) und `DATABASE_URL` | gering |
|
||||
| 2 | Treiber | `github.com/jackc/pgx/v5/stdlib`. Ist reines Go, das statische Alpine-Image bleibt möglich | gering |
|
||||
| 3 | Platzhalter | PostgreSQL erwartet `$1, $2 …` statt `?`, betroffen sind rund 100 Abfragen. Lösung: `Rebind` im Dialekt (siehe unten), kein Umschreiben von Hand | gering bis mittel |
|
||||
| 4 | `openPool` (`store.go`) | `_pragma`-Parameter, `_txlock=immediate` und die einzelne Schreibverbindung gelten nur für SQLite. PostgreSQL braucht einen eigenen Öffnungsweg | gering |
|
||||
| 5 | Workspace-Modell (`internal/workspace`) | Ein Schema je Workspace statt einer Datei, siehe unten | mittel |
|
||||
| 6 | Migrationen | Eigenes Verzeichnis je Datenbank, siehe unten. Konkreter Unterschied heute: `search_log.id INTEGER PRIMARY KEY` (Migration 006) zählt nur in SQLite von selbst hoch. PostgreSQL braucht `BIGINT GENERATED ALWAYS AS IDENTITY` | gering |
|
||||
| 7 | Gleichzeitiges Schreiben | Stellen absichern, die heute nur durch die eine Schreibverbindung sicher sind, siehe unten | mittel |
|
||||
| 8 | Tests | Die Store-Tests zusätzlich gegen PostgreSQL laufen lassen | mittel |
|
||||
| 9 | Betrieb | `scripts/backup.sh` und `BETRIEB.md` setzen auf `sqlite3`. Für PostgreSQL braucht es `pg_dump` und angepasste Abschnitte | gering |
|
||||
|
||||
#### Entscheidung: ein Store mit Dialekt, keine zwei Stores
|
||||
|
||||
Naheliegend wären zwei vollständige Umsetzungen hinter einem Interface
|
||||
(`SQLiteStore`, `PostgresStore`). Das hieße, rund 100 Abfragen doppelt zu
|
||||
pflegen und jede Methode zweimal zu testen.
|
||||
|
||||
Stattdessen bleibt es bei **einem** `Store`. Umgesetzt in drei Teilen:
|
||||
|
||||
| Teil | Datei | Aufgabe |
|
||||
|---|---|---|
|
||||
| `Speicher` | `speicher.go`, `speicher_sqlite.go`, `speicher_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. |
|
||||
| `dialekt` | `dialekt.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. |
|
||||
| `pool`, `transaktion` | `verbindung.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. |
|
||||
|
||||
```go
|
||||
type dialekt interface {
|
||||
rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert)
|
||||
migrationsUnterordner() string // "sqlite" bzw. "postgres"
|
||||
}
|
||||
```
|
||||
|
||||
Braucht eine einzelne Abfrage später wirklich zwei Fassungen, bekommt der
|
||||
Dialekt dafür gezielt eine eigene Methode. Der Rest bleibt gemeinsam.
|
||||
`isUniqueViolation` erkennt die Meldungen beider Datenbanken schon und bleibt
|
||||
deshalb, wo sie ist.
|
||||
|
||||
**Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides`
|
||||
(`nutzung.go`) und `EmptySearches` (`suchprotokoll.go`) nutzten `MAX()` mit
|
||||
Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide
|
||||
nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab
|
||||
3.25). Neuer Test: `TestCuratedOverridesJuengsterVorgang`.
|
||||
|
||||
#### Entscheidung: bewusst schlichtes SQL
|
||||
|
||||
In PostgreSQL entstehen dieselben Tabellen wie in SQLite, keine JSONB-Spalten,
|
||||
keine Arrays, keine PostgreSQL-Volltextsuche, keine Erweiterungen (pgvector:
|
||||
siehe 2.2). Gründe:
|
||||
|
||||
- Die Daten haben eine feste Form. Schlagworte und Verweise liegen schon in
|
||||
eigenen Tabellen, es gibt nichts Frei-Geformtes.
|
||||
- Jedes PostgreSQL-Feature in einer Abfrage braucht eine zweite Fassung für
|
||||
SQLite. Damit finge das Auseinanderlaufen an.
|
||||
- Der Schema-Vergleichstest setzt gleiche Strukturen voraus.
|
||||
- Die Suche liegt in Go (1.1). Ein zweiter Suchweg in der Datenbank wäre
|
||||
dieselbe Abwägung wie bei FTS5.
|
||||
- Ein Umzug bleibt reines Kopieren, in beide Richtungen.
|
||||
|
||||
Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text,
|
||||
`BOOLEAN` statt 0/1, `TEXT[]` für die kommagetrennten Felder
|
||||
`entry_versions.tags` und `search_log.synonyms`.
|
||||
|
||||
#### Entscheidung: Migrationen je Datenbank getrennt
|
||||
|
||||
```
|
||||
internal/store/migrations/sqlite/001_init.sql … 006_suchprotokoll.sql
|
||||
internal/store/migrations/postgres/001_init.sql … 006_suchprotokoll.sql
|
||||
internal/store/migrations_control/sqlite/…
|
||||
internal/store/migrations_control/postgres/…
|
||||
```
|
||||
|
||||
- **Warum:** Jede Datenbank bekommt ihr passendes SQL, ohne die andere
|
||||
einzuschränken. Laufen die beiden später auseinander, ist das sichtbar und
|
||||
gewollt statt in Sonderfällen versteckt.
|
||||
- **Preis:** Jede Schemaänderung wird zweimal geschrieben.
|
||||
- **Absicherung, zwei Tests:**
|
||||
- Beide Verzeichnisse enthalten dieselben Dateinamen. So bleibt keine
|
||||
Migration auf einer Seite liegen. Läuft ohne PostgreSQL.
|
||||
- Nach allen Migrationen haben beide Datenbanken dasselbe Schema: Tabellen,
|
||||
Spalten, NOT NULL, Standardwerte, Indizes (SQLite über `pragma_table_info`,
|
||||
PostgreSQL über `information_schema`). Erst dieser Test merkt, wenn eine
|
||||
Spalte auf einer Seite anders angelegt wurde. Braucht PostgreSQL, siehe
|
||||
*Tests* unten.
|
||||
- **Für den Anfang:** Die PostgreSQL-Fassungen bleiben so nah wie möglich an
|
||||
SQLite (weiter TEXT-Zeitstempel, 0/1). Dann bleiben die Go-Typen gleich, und
|
||||
der Umzug von Bestandsdaten ist ein reines Kopieren. Echte PostgreSQL-Typen
|
||||
(`TIMESTAMPTZ`, `BOOLEAN`) erst in einem späteren Schritt.
|
||||
|
||||
#### Entscheidung: ein Schema je Workspace
|
||||
|
||||
Heute hat jeder Workspace eine eigene Datei, dazu kommt `control.db` (4.4). In
|
||||
PostgreSQL entspricht dem am ehesten **ein Schema je Workspace** plus ein
|
||||
Schema für die Kontroll-Datenbank. Jeder Workspace bekommt einen eigenen Pool
|
||||
mit `search_path=<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
|
||||
|
|
|
|||
255
BETRIEB.md
255
BETRIEB.md
|
|
@ -8,6 +8,14 @@ Die Befehle gehen vom Docker-Aufbau aus dem README aus: Container `munin`,
|
|||
Volume unter `/data`. Ohne Docker gilt dasselbe mit dem eigenen `DATA_DIR`
|
||||
statt `/data`, und die Ausgabe des Prozesses statt `docker logs`.
|
||||
|
||||
Munin läuft entweder mit **SQLite** (Standard, eine Datei je Workspace) oder
|
||||
mit **PostgreSQL** (`DB_DRIVER=postgres`, ein Schema je Workspace). Wo sich
|
||||
die Schritte unterscheiden, steht beides da. Welches gilt, zeigt die Zeile
|
||||
`Speicher:` beim Start.
|
||||
|
||||
Für die PostgreSQL-Befehle: `$DATABASE_URL` ist dieselbe Adresse wie beim
|
||||
Server, das Passwort kommt aus `PGPASSWORD` oder `~/.pgpass`.
|
||||
|
||||
---
|
||||
|
||||
## Auf einen Blick
|
||||
|
|
@ -21,12 +29,25 @@ statt `/data`, und die Ausgabe des Prozesses statt `docker logs`.
|
|||
| Sicherungen | `BACKUP_DIR/<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.
|
||||
|
|
|
|||
|
|
@ -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
182
README.md
|
|
@ -339,8 +339,9 @@ und entscheidet:
|
|||
|
||||
Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die
|
||||
Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein
|
||||
Mensch ihn wieder kuratiert. Einzelheiten: `internal/mcpserver/dubletten.go`
|
||||
und `kuratiert.go`.
|
||||
Mensch ihn wieder kuratiert. Einzelheiten: `internal/dubletten` und
|
||||
`internal/mcpserver/kuratiert.go`. Jeder Kandidat nennt die gemeinsamen Wörter
|
||||
(`matched_words`).
|
||||
|
||||
### Gleichzeitiges Schreiben
|
||||
|
||||
|
|
@ -368,21 +369,25 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
|||
| `/admin/projects` | Projekte des gewählten Workspace anlegen und auflisten, mit Schreibvorgängen der letzten 30 Tage je Projekt; Markdown-Export anstoßen |
|
||||
| `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) |
|
||||
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
|
||||
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
|
||||
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen |
|
||||
| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) |
|
||||
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
|
||||
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace, filterbar nach Vorgang, Zugang, Projekt und Zeitraum |
|
||||
| `/admin/search` | Suche in allen Projekten des gewählten Workspace, nach Projekt gruppiert (Plattform- und Workspace-Admin) |
|
||||
| `/admin/overview` | Überblick des Workspace: was zu tun ist (je Projekt mit Link), Projekte, Aktivität der letzten 30 Tage (Workspace- und Plattform-Admin) |
|
||||
| `/admin/system` | Zustand (was zu tun ist), Aktivität der letzten 30 Tage, Bestand und Platz je Workspace, Speicher, Bereitschaft, offene Datenbankverbindungen gegen das Budget, Fassung und Laufzeit (nur Plattform-Admin) |
|
||||
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, veraltete zeigen (`?veraltet=1`), für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen |
|
||||
| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor), mit Hinweis auf ähnliche vorhandene Einträge |
|
||||
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen und verwandten Einträgen über einen Zwischeneintrag |
|
||||
| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau |
|
||||
| `/p/{projekt}/e/{bezeichner}/versions` | Alle Fassungen eines Eintrags |
|
||||
| `/p/{projekt}/e/{bezeichner}/versions/{n}` | Fassung `n` mit ihrer Nachfolgerin vergleichen |
|
||||
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
|
||||
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
|
||||
| `/p/{projekt}/keys` | Zugänge verwalten, optional mit Ablaufdatum (Rolle `admin`) |
|
||||
| `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) |
|
||||
| `/p/{projekt}/usage` | Nutzung: geänderte kuratierte Einträge zur Prüfung, Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Suchen der Agenten samt Anfragen ohne Treffer, Bestand, Verweise ins Leere, aktive Zugänge |
|
||||
| `/p/{projekt}/audit` | Protokoll des Projekts |
|
||||
| `/p/{projekt}/audit` | Protokoll des Projekts, filterbar nach Vorgang, Zugang und Zeitraum |
|
||||
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
|
||||
| `/hilfe` | Eingebaute Onboarding-Hilfe für alle Rollen: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Die Seite für Plattform-Admins sieht nur der Plattform-Admin. |
|
||||
| `/healthz` | Healthcheck, ohne Anmeldung |
|
||||
| `/hilfe` | Eingebaute Onboarding-Hilfe, oben rechts in der Kopfleiste: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Dazu je eine Seite für Workspace-Admins (sehen Workspace- und Plattform-Admin) und für Plattform-Admins (nur dieser). |
|
||||
| `/live` | Läuft der Prozess? Ohne Anmeldung, ohne Datenbank. Für den Neustart des Containers (`/healthz` ist der alte Name und geht weiter) |
|
||||
| `/ready` | Kann er Anfragen bedienen? Prüft die Datenbank, sonst `503`. Ohne Anmeldung. Für Load-Balancer und Monitoring |
|
||||
|
||||
### Funktionen beim Bearbeiten und Lesen
|
||||
|
||||
|
|
@ -409,9 +414,21 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
|||
- **Schlagworte für viele Einträge:** Mit Schreibrecht hat die Eintragsliste
|
||||
Auswahlkästchen. Für die ausgewählten Einträge lassen sich Schlagworte auf
|
||||
einmal hinzufügen oder entfernen.
|
||||
- **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene
|
||||
Einträge mit ähnlichem Titel und die gemeinsamen Wörter — dieselbe Prüfung
|
||||
wie bei `remember` über MCP (`internal/dubletten`), hier nur als Hinweis.
|
||||
- **Rückfrage beim Verlassen:** Wer im Formular etwas geändert hat und die
|
||||
Seite verlässt, ohne zu speichern, bekommt eine Rückfrage des Browsers.
|
||||
- **Auch verwandt:** Unter einem Eintrag stehen neben den Rückverweisen die
|
||||
Einträge, die über einen Zwischeneintrag verknüpft sind — wie `related`.
|
||||
- **Veraltet:** Einträge, die seit über einem Jahr niemand geändert hat, tragen
|
||||
eine Marke; ein Filter neben der Suche zeigt genau diese, die Nutzungsseite
|
||||
nennt ihre Zahl.
|
||||
- **Tastenkürzel:** `/` Suche, `n` neuer Eintrag, `e` bearbeiten,
|
||||
`Strg+Enter` speichern.
|
||||
|
||||
Vorschau, Autovervollständigung und Graph brauchen JavaScript. Alles andere
|
||||
funktioniert auch ohne.
|
||||
Vorschau, Autovervollständigung, „Gibt es schon?“, Rückfrage, Tastenkürzel und
|
||||
Graph brauchen JavaScript. Alles andere funktioniert auch ohne.
|
||||
|
||||
### Rollen
|
||||
|
||||
|
|
@ -429,7 +446,9 @@ funktioniert auch ohne.
|
|||
|---|---|---|
|
||||
| `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. |
|
||||
| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. |
|
||||
| `DATA_DIR` | `data` | Datenverzeichnis: Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace |
|
||||
| `DB_DRIVER` | `sqlite` | Wo die Daten liegen: `sqlite` oder `postgres` (siehe AUSBAUSTUFEN.md 4.10) |
|
||||
| `DATA_DIR` | `data` | Nur SQLite: Datenverzeichnis mit Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace |
|
||||
| `DATABASE_URL` | — | Nur PostgreSQL, dann **Pflicht**: z.B. `postgres://munin:geheim@host:5432/munin`. Schemas `munin_control` und je Workspace `munin_ws_<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 ./...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
132
REPOMAP.md
132
REPOMAP.md
|
|
@ -56,6 +56,20 @@ munin/
|
|||
│ │ │ der Treiber modernc.org/sqlite ist: Er ist reines
|
||||
│ │ │ Go und erlaubt damit ein statisches Binary.
|
||||
│ │ │
|
||||
│ │ ├── speicher.go ← Wo liegen die Daten? Interface Speicher, von
|
||||
│ │ │ main.go anhand von DB_DRIVER gewählt
|
||||
│ │ │ (AUSBAUSTUFEN.md 4.10).
|
||||
│ │ ├── speicher_sqlite.go ← Eine Datei je Workspace plus control.db (Standard).
|
||||
│ │ ├── speicher_postgres.go← Ein Schema je Workspace (munin_ws_<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
47
docker-compose.dev.yml
Normal 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
6
go.mod
|
|
@ -3,6 +3,7 @@ module munin
|
|||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
|
|
@ -13,6 +14,9 @@ require (
|
|||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
|
|
@ -20,7 +24,9 @@ require (
|
|||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
modernc.org/libc v1.75.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
|
|
|
|||
24
go.sum
24
go.sum
|
|
@ -1,3 +1,6 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
|
|
@ -12,6 +15,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
|
|
@ -20,12 +31,19 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u
|
|||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
|
|
@ -38,8 +56,14 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
|||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
|
|
|
|||
202
internal/dubletten/dubletten.go
Normal file
202
internal/dubletten/dubletten.go
Normal 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
|
||||
}
|
||||
48
internal/dubletten/dubletten_test.go
Normal file
48
internal/dubletten/dubletten_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -30,11 +30,42 @@ type Seite struct {
|
|||
Titel string // Überschrift und Name in der Navigation
|
||||
Kurz string // ein Satz für die Übersicht
|
||||
|
||||
// NurAdmin blendet die Seite für alle aus, die nicht als Plattform-Admin
|
||||
// angemeldet sind: Sie steht dann weder in der Navigation, noch lässt sie
|
||||
// sich direkt aufrufen. Geheim ist darin nichts — aber für ein Team mit
|
||||
// Projekt-Key beschreibt sie nur Knöpfe, die es gar nicht sieht.
|
||||
NurAdmin bool
|
||||
// Fuer legt fest, wer die Seite sieht. Wer nicht dazugehört, findet sie
|
||||
// weder in der Navigation, noch lässt sie sich direkt aufrufen (404).
|
||||
// Geheim ist darin nichts — aber sie beschreibt Knöpfe, die andere gar
|
||||
// nicht sehen.
|
||||
Fuer Zielgruppe
|
||||
}
|
||||
|
||||
// Zielgruppe einer Hilfeseite.
|
||||
type Zielgruppe int
|
||||
|
||||
const (
|
||||
// FuerAlle: jeder, der angemeldet ist.
|
||||
FuerAlle Zielgruppe = iota
|
||||
// FuerAdmins: Workspace-Admins und der Plattform-Admin.
|
||||
FuerAdmins
|
||||
// FuerPlattformAdmin: nur der Plattform-Admin.
|
||||
FuerPlattformAdmin
|
||||
)
|
||||
|
||||
// Leser beschreibt, wer die Hilfe gerade liest — so viel, wie für die
|
||||
// Auswahl der Seiten nötig ist.
|
||||
type Leser struct {
|
||||
PlattformAdmin bool
|
||||
WorkspaceAdmin bool
|
||||
}
|
||||
|
||||
// DarfLesen sagt, ob l die Seite sehen darf.
|
||||
func (s Seite) DarfLesen(l Leser) bool {
|
||||
switch s.Fuer {
|
||||
case FuerPlattformAdmin:
|
||||
return l.PlattformAdmin
|
||||
case FuerAdmins:
|
||||
return l.PlattformAdmin || l.WorkspaceAdmin
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// seiten legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer
|
||||
|
|
@ -47,7 +78,8 @@ var seiten = []Seite{
|
|||
{Slug: "mit-agenten-arbeiten", Titel: "Mit Agenten arbeiten", Kurz: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."},
|
||||
{Slug: "weboberflaeche", Titel: "Weboberfläche", Kurz: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."},
|
||||
{Slug: "zugaenge", Titel: "Zugänge und Sicherheit", Kurz: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."},
|
||||
{Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces und Projekte anlegen, Schreibbudget, Export.", NurAdmin: true},
|
||||
{Slug: "workspace-admin", Titel: "Für Workspace-Admins", Kurz: "Projekte und Zugänge einer Abteilung verwalten, Protokoll, Suche über alle Projekte.", Fuer: FuerAdmins},
|
||||
{Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces, Workspace-Admins, Schreibbudget, System, Sicherung.", Fuer: FuerPlattformAdmin},
|
||||
}
|
||||
|
||||
// Alle gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die
|
||||
|
|
@ -61,15 +93,14 @@ func Alle() []Seite {
|
|||
return kopie
|
||||
}
|
||||
|
||||
// Sichtbar gibt die Seiten zurück, die jemand sehen darf: alle für einen
|
||||
// Plattform-Admin, sonst alle außer den NurAdmin-Seiten.
|
||||
func Sichtbar(istAdmin bool) []Seite {
|
||||
// Sichtbar gibt die Seiten zurück, die l sehen darf, in der Reihenfolge der
|
||||
// Navigation.
|
||||
func Sichtbar(l Leser) []Seite {
|
||||
var ergebnis []Seite
|
||||
for _, s := range seiten {
|
||||
if s.NurAdmin && !istAdmin {
|
||||
continue
|
||||
if s.DarfLesen(l) {
|
||||
ergebnis = append(ergebnis, s)
|
||||
}
|
||||
ergebnis = append(ergebnis, s)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,30 +80,108 @@ func TestAlleGibtKopie(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSichtbarBlendetAdminSeitenAus(t *testing.T) {
|
||||
for _, s := range Sichtbar(false) {
|
||||
if s.NurAdmin {
|
||||
t.Errorf("Seite %q ist nur für Admins, wird aber allen gezeigt", s.Slug)
|
||||
// Die drei Lesergruppen, für die Tests.
|
||||
var (
|
||||
projektLeser = Leser{}
|
||||
workspaceAdmin = Leser{WorkspaceAdmin: true}
|
||||
plattformAdmin = Leser{PlattformAdmin: true}
|
||||
)
|
||||
|
||||
func TestSichtbarJeLesergruppe(t *testing.T) {
|
||||
enthaelt := func(seiten []Seite, slug string) bool {
|
||||
for _, s := range seiten {
|
||||
if s.Slug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
leser Leser
|
||||
workspaceSeite, plattSei bool
|
||||
}{
|
||||
{"Projektzugang", projektLeser, false, false},
|
||||
{"Workspace-Admin", workspaceAdmin, true, false},
|
||||
{"Plattform-Admin", plattformAdmin, true, true},
|
||||
} {
|
||||
sichtbar := Sichtbar(f.leser)
|
||||
if enthaelt(sichtbar, "workspace-admin") != f.workspaceSeite {
|
||||
t.Errorf("%s: Seite für Workspace-Admins sichtbar = %v", f.name, !f.workspaceSeite)
|
||||
}
|
||||
if enthaelt(sichtbar, "plattform-admin") != f.plattSei {
|
||||
t.Errorf("%s: Seite für Plattform-Admins sichtbar = %v", f.name, !f.plattSei)
|
||||
}
|
||||
if !enthaelt(sichtbar, "start") {
|
||||
t.Errorf("%s sieht die Startseite nicht", f.name)
|
||||
}
|
||||
}
|
||||
if len(Sichtbar(true)) != len(Alle()) {
|
||||
t.Error("ein Admin sieht nicht alle Seiten")
|
||||
if len(Sichtbar(plattformAdmin)) != len(Alle()) {
|
||||
t.Error("der Plattform-Admin sieht nicht alle Seiten")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllgemeineSeitenVerlinkenKeineAdminSeiten: Ein Link auf eine Admin-Seite
|
||||
// liefe für alle anderen ins Leere (404).
|
||||
func TestAllgemeineSeitenVerlinkenKeineAdminSeiten(t *testing.T) {
|
||||
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`)
|
||||
// TestSeitenVerlinkenNurSichtbares: Ein Link auf eine Seite, die der Leser
|
||||
// nicht sehen darf, liefe für ihn ins Leere (404). Geprüft für jede
|
||||
// Lesergruppe: Was sie sieht, verlinkt nur auf das, was sie auch sieht.
|
||||
func TestSeitenVerlinkenNurSichtbares(t *testing.T) {
|
||||
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)[#)]`)
|
||||
|
||||
for _, s := range Sichtbar(false) {
|
||||
md, err := s.Markdown()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, treffer := range link.FindAllStringSubmatch(md, -1) {
|
||||
if ziel, ok := Finde(treffer[1]); ok && ziel.NurAdmin {
|
||||
t.Errorf("Seite %q verlinkt auf die Admin-Seite %q", s.Slug, ziel.Slug)
|
||||
for _, leser := range []Leser{projektLeser, workspaceAdmin} {
|
||||
for _, s := range Sichtbar(leser) {
|
||||
md, err := s.Markdown()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, treffer := range link.FindAllStringSubmatch(md, -1) {
|
||||
if ziel, ok := Finde(treffer[1]); ok && !ziel.DarfLesen(leser) {
|
||||
t.Errorf("Seite %q verlinkt auf %q, die dieser Leser nicht sieht (%+v)", s.Slug, ziel.Slug, leser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnkerInLinksGibtEs: Jeder Link auf einen Abschnitt einer Hilfeseite —
|
||||
// (/hilfe/seite#abschnitt) oder innerhalb der Seite (#abschnitt) — zeigt auf
|
||||
// eine Überschrift, die es dort gibt. Sonst landet man oben auf der Seite
|
||||
// statt beim gemeinten Abschnitt, und niemand merkt es.
|
||||
//
|
||||
// Die Hilfe-Links der Templates prüft TestHilfelinksZeigenAufVorhandeneAbschnitte
|
||||
// in internal/web.
|
||||
func TestAnkerInLinksGibtEs(t *testing.T) {
|
||||
idMuster := regexp.MustCompile(`<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
103
internal/hilfe/seiten/workspace-admin.md
Normal file
103
internal/hilfe/seiten/workspace-admin.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
107
internal/importer/postgres_test.go
Normal file
107
internal/importer/postgres_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
290
internal/statistik/statistik.go
Normal file
290
internal/statistik/statistik.go
Normal 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
|
||||
}
|
||||
124
internal/statistik/statistik_test.go
Normal file
124
internal/statistik/statistik_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
118
internal/store/dialekt.go
Normal 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()
|
||||
}
|
||||
127
internal/store/dialekt_test.go
Normal file
127
internal/store/dialekt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
136
internal/store/migrations/postgres/001_init.sql
Normal file
136
internal/store/migrations/postgres/001_init.sql
Normal 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);
|
||||
46
internal/store/migrations/postgres/006_suchprotokoll.sql
Normal file
46
internal/store/migrations/postgres/006_suchprotokoll.sql
Normal 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);
|
||||
13
internal/store/migrations/postgres/007_key_ablauf.sql
Normal file
13
internal/store/migrations/postgres/007_key_ablauf.sql
Normal 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 '';
|
||||
|
|
@ -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:
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
43
internal/store/migrations/sqlite/004_aenderungshistorie.sql
Normal file
43
internal/store/migrations/sqlite/004_aenderungshistorie.sql
Normal 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);
|
||||
41
internal/store/migrations/sqlite/005_papierkorb.sql
Normal file
41
internal/store/migrations/sqlite/005_papierkorb.sql
Normal 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;
|
||||
13
internal/store/migrations/sqlite/007_key_ablauf.sql
Normal file
13
internal/store/migrations/sqlite/007_key_ablauf.sql
Normal 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 '';
|
||||
36
internal/store/migrations_control/postgres/001_init.sql
Normal file
36
internal/store/migrations_control/postgres/001_init.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -336,17 +336,26 @@ type CuratedOverride struct {
|
|||
// ein nie geprüfter Eintrag aus der Liste — er ist dann aber immer noch als
|
||||
// nicht kuratiert zu erkennen.
|
||||
func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit int) ([]CuratedOverride, error) {
|
||||
// MAX(a.ts) mit Spalten ohne Aggregat: SQLite nimmt diese dann aus genau
|
||||
// der Zeile mit dem Höchstwert — Zugang und Begründung gehören also zum
|
||||
// jüngsten Vorgang (wie in EmptySearches).
|
||||
// Je Eintrag zählt nur der jüngste Vorgang, samt Zugang und Begründung
|
||||
// genau dieser Zeile. ROW_NUMBER() nummeriert die Vorgänge je Eintrag,
|
||||
// jüngster zuerst; nr = 1 ist dann der jüngste. a.id entscheidet, falls
|
||||
// zwei Vorgänge denselben Zeitstempel haben.
|
||||
//
|
||||
// Früher stand hier MAX(a.ts) mit Spalten ohne Aggregat. Das kann nur
|
||||
// SQLite (es nimmt die übrigen Spalten dann aus der Zeile mit dem
|
||||
// Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide
|
||||
// (SQLite ab 3.25), siehe AUSBAUSTUFEN.md 4.10.
|
||||
rows, err := s.lesen.QueryContext(ctx,
|
||||
`SELECT a.entry_slug, e.title, a.actor, a.detail, MAX(a.ts)
|
||||
FROM audit_events a
|
||||
JOIN entries e ON e.project_id = a.project_id AND e.slug = a.entry_slug
|
||||
WHERE a.project_id = ? AND a.action = ? AND a.detail LIKE ?
|
||||
AND e.curated = 0 AND e.deleted_at IS NULL
|
||||
GROUP BY a.entry_slug
|
||||
ORDER BY MAX(a.ts) DESC
|
||||
`SELECT entry_slug, title, actor, detail, ts FROM (
|
||||
SELECT a.entry_slug, e.title, a.actor, a.detail, a.ts,
|
||||
ROW_NUMBER() OVER (PARTITION BY a.entry_slug ORDER BY a.ts DESC, a.id DESC) AS nr
|
||||
FROM audit_events a
|
||||
JOIN entries e ON e.project_id = a.project_id AND e.slug = a.entry_slug
|
||||
WHERE a.project_id = ? AND a.action = ? AND a.detail LIKE ?
|
||||
AND e.curated = 0 AND e.deleted_at IS NULL
|
||||
) AS jeweils_juengster
|
||||
WHERE nr = 1
|
||||
ORDER BY ts DESC
|
||||
LIMIT ?`,
|
||||
projectID, ActionEntryWrite, DetailCuratedOverridden+"%", limit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
418
internal/store/postgres_test.go
Normal file
418
internal/store/postgres_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
79
internal/store/speicher.go
Normal file
79
internal/store/speicher.go
Normal 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
|
||||
}
|
||||
406
internal/store/speicher_postgres.go
Normal file
406
internal/store/speicher_postgres.go
Normal 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 + "'"
|
||||
}
|
||||
81
internal/store/speicher_sqlite.go
Normal file
81
internal/store/speicher_sqlite.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,15 +109,24 @@ type EmptySearch struct {
|
|||
func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.Time, limit int) ([]EmptySearch, error) {
|
||||
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
|
||||
|
||||
// MAX(ts) ist der jüngste Zeitpunkt. Die Synonyme der jüngsten Zeile holt
|
||||
// eine Eigenheit von SQLite: Steht MAX() in der Abfrage, kommen die übrigen
|
||||
// Spalten ohne Aggregat aus genau der Zeile mit dem Höchstwert.
|
||||
// Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am
|
||||
// Rand) eine Zeile: die jüngste, mit ihren Synonymen. COUNT(*) OVER zählt
|
||||
// alle Male, die die Anfrage gestellt wurde; ROW_NUMBER() nummeriert sie,
|
||||
// jüngste zuerst. id entscheidet bei gleichem Zeitstempel.
|
||||
//
|
||||
// Früher stand hier MAX(ts) mit Spalten ohne Aggregat — eine Eigenheit von
|
||||
// SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe
|
||||
// CuratedOverrides und AUSBAUSTUFEN.md 4.10).
|
||||
rows, err := s.lesen.QueryContext(ctx,
|
||||
`SELECT query, synonyms, COUNT(*) AS anzahl, MAX(ts)
|
||||
FROM search_log
|
||||
WHERE project_id = ? AND ts >= ? AND results = 0
|
||||
GROUP BY lower(trim(query))
|
||||
ORDER BY anzahl DESC, MAX(ts) DESC
|
||||
`SELECT query, synonyms, anzahl, ts FROM (
|
||||
SELECT query, synonyms, ts,
|
||||
COUNT(*) OVER (PARTITION BY lower(trim(query))) AS anzahl,
|
||||
ROW_NUMBER() OVER (PARTITION BY lower(trim(query)) ORDER BY ts DESC, id DESC) AS nr
|
||||
FROM search_log
|
||||
WHERE project_id = ? AND ts >= ? AND results = 0
|
||||
) AS je_anfrage
|
||||
WHERE nr = 1
|
||||
ORDER BY anzahl DESC, ts DESC
|
||||
LIMIT ?`,
|
||||
projectID, seit, limit,
|
||||
)
|
||||
|
|
|
|||
293
internal/store/systemstand.go
Normal file
293
internal/store/systemstand.go
Normal 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
|
||||
}
|
||||
189
internal/store/systemstand_test.go
Normal file
189
internal/store/systemstand_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
90
internal/store/verbindung.go
Normal file
90
internal/store/verbindung.go
Normal 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
191
internal/web/diagramm.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/web/diagramm_test.go
Normal file
82
internal/web/diagramm_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
68
internal/web/handlers_bereitschaft.go
Normal file
68
internal/web/handlers_bereitschaft.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"munin/internal/dubletten"
|
||||
"munin/internal/entry"
|
||||
"munin/internal/store"
|
||||
)
|
||||
|
|
@ -25,6 +26,12 @@ type entryListData struct {
|
|||
Tag string
|
||||
Total int
|
||||
|
||||
// Veraltet: Die Liste zeigt nur Einträge, die seit über einem Jahr
|
||||
// unverändert sind (?veraltet=1). VeraltetAnzahl nennt, wie viele das im
|
||||
// ganzen Projekt sind — für den Filterlink, auch wenn er nicht aktiv ist.
|
||||
Veraltet bool
|
||||
VeraltetAnzahl int
|
||||
|
||||
// RetentionDays: so lange liegen gelöschte Einträge im Papierkorb — für
|
||||
// die Rückfrage beim Sammellöschen, damit dort dieselbe Zahl steht, nach
|
||||
// der auch aufgeräumt wird.
|
||||
|
|
@ -35,6 +42,10 @@ type entryListData struct {
|
|||
type entryListItem struct {
|
||||
Entry store.Entry
|
||||
Snippet template.HTML // bei einer Suche: Textstelle mit hervorgehobenem Treffer
|
||||
|
||||
// Stale: seit über einem Jahr unverändert (store.Entry.Stale). Die Liste
|
||||
// zeigt dann dieselbe Marke wie die Einzelansicht.
|
||||
Stale bool
|
||||
}
|
||||
|
||||
// handleEntryList zeigt die Einträge eines Projekts — gefiltert, gesucht oder
|
||||
|
|
@ -46,19 +57,32 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
||||
veraltet := r.URL.Query().Get("veraltet") != ""
|
||||
jetzt := time.Now()
|
||||
|
||||
daten := entryListData{Query: query, Tag: tag, RetentionDays: store.TrashRetentionDays}
|
||||
daten := entryListData{Query: query, Tag: tag, Veraltet: veraltet, RetentionDays: store.TrashRetentionDays}
|
||||
|
||||
var err error
|
||||
if query != "" {
|
||||
daten.Entries, err = s.searchEntries(r, project, query)
|
||||
} else {
|
||||
daten.Entries, err = s.listEntries(r, project, tag)
|
||||
filter := store.EntryFilter{Tag: tag, Limit: 100}
|
||||
if veraltet {
|
||||
filter.ChangedBefore = store.StaleCutoff(jetzt)
|
||||
}
|
||||
daten.Entries, err = s.listEntries(r, project, filter)
|
||||
}
|
||||
if err != nil {
|
||||
s.serverError(w, "Einträge konnten nicht geladen werden", err)
|
||||
return
|
||||
}
|
||||
for i := range daten.Entries {
|
||||
daten.Entries[i].Stale = daten.Entries[i].Entry.Stale(jetzt)
|
||||
}
|
||||
if daten.VeraltetAnzahl, err = ws.Store.CountStaleEntries(ctx, project.ID, jetzt); err != nil {
|
||||
s.serverError(w, "Einträge konnten nicht gezählt werden", err)
|
||||
return
|
||||
}
|
||||
|
||||
if daten.Tags, err = ws.Store.ListTags(ctx, project.ID); err != nil {
|
||||
s.serverError(w, "Schlagworte konnten nicht geladen werden", err)
|
||||
|
|
@ -111,9 +135,9 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
|
|||
}
|
||||
|
||||
// listEntries lädt die Einträge ohne Suche, zuletzt geänderte zuerst.
|
||||
func (s *Server) listEntries(r *http.Request, project store.Project, tag string) ([]entryListItem, error) {
|
||||
func (s *Server) listEntries(r *http.Request, project store.Project, filter store.EntryFilter) ([]entryListItem, error) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, store.EntryFilter{Tag: tag, Limit: 100})
|
||||
eintraege, err := ws.Store.ListEntries(r.Context(), project.ID, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -134,6 +158,12 @@ type entryViewData struct {
|
|||
HTML template.HTML
|
||||
Backlinks []store.Backlink
|
||||
|
||||
// Verwandt sind Einträge über einen Zwischeneintrag (zweite Reihe im
|
||||
// Verweisgraphen, store.RelatedEntries). Die erste Reihe steht schon im
|
||||
// Text (eigene Verweise) bzw. unter Backlinks — doppelt wäre Rauschen.
|
||||
// Dasselbe liefert MCP "related" den Agenten.
|
||||
Verwandt []store.RelatedEntry
|
||||
|
||||
// Stale: seit über einem Jahr unverändert (siehe store.Entry.Stale). Die
|
||||
// Seite zeigt dann einen Vermerk, damit Menschen den Eintrag genauso
|
||||
// vorsichtig lesen wie ein Agent, dem MCP dasselbe sagt.
|
||||
|
|
@ -169,17 +199,35 @@ func (s *Server) handleEntryView(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
nachbarn, err := ws.Store.RelatedEntries(r.Context(), project.ID, slug, 2, verwandtLimit)
|
||||
if err != nil {
|
||||
s.serverError(w, "Verwandte Einträge konnten nicht geladen werden", err)
|
||||
return
|
||||
}
|
||||
var verwandt []store.RelatedEntry
|
||||
for _, n := range nachbarn {
|
||||
if n.Distance == 2 {
|
||||
verwandt = append(verwandt, n)
|
||||
}
|
||||
}
|
||||
|
||||
s.render(w, r, "entry.html", http.StatusOK, pageData{
|
||||
Title: e.Title,
|
||||
Bereich: "eintraege",
|
||||
Flash: r.URL.Query().Get("hinweis"), // z. B. nach einer Wiederherstellung
|
||||
Data: entryViewData{
|
||||
Entry: e, HTML: template.HTML(inhalt), Backlinks: backlinks,
|
||||
Stale: e.Stale(time.Now()),
|
||||
Verwandt: verwandt,
|
||||
Stale: e.Stale(time.Now()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// verwandtLimit begrenzt die Nachbarn, die die Eintragsansicht lädt (beide
|
||||
// Reihen zusammen). Mehr als eine Handvoll in der zweiten Reihe hilft beim
|
||||
// Lesen nicht, sondern lenkt ab; wer mehr will, hat den Graph.
|
||||
const verwandtLimit = 30
|
||||
|
||||
// entryEditData sind die Daten des Bearbeitungsformulars.
|
||||
type entryEditData struct {
|
||||
Entry store.Entry
|
||||
|
|
@ -284,6 +332,54 @@ func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// aehnlichItem ist ein vorhandener Eintrag, der dem gerade angelegten ähnelt
|
||||
// — für den Hinweis „Gibt es schon?" im Formular (static/aehnlich.js).
|
||||
type aehnlichItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Overlap int `json:"overlap"`
|
||||
Gemeinsam []string `json:"gemeinsam"`
|
||||
}
|
||||
|
||||
// handleEntrySimilar liefert vorhandene Einträge, die einem neuen Titel
|
||||
// ähneln — dieselbe Prüfung, mit der "remember" über MCP bei Agenten
|
||||
// nachfragt (internal/dubletten).
|
||||
//
|
||||
// Anders als bei MCP ist das nur ein Hinweis: Das Formular zeigt die
|
||||
// Kandidaten unter dem Titelfeld an, speichern lässt sich trotzdem. Ein
|
||||
// Mensch, der die Liste sieht, entscheidet selbst; eine Sperre wäre hier nur
|
||||
// lästig.
|
||||
func (s *Server) handleEntrySimilar(w http.ResponseWriter, r *http.Request) {
|
||||
ws, _ := WorkspaceFrom(r)
|
||||
project, _ := ProjectFrom(r)
|
||||
titel := strings.TrimSpace(r.URL.Query().Get("title"))
|
||||
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
|
||||
|
||||
// Nicht nil, damit die Antwort immer ein JSON-Array ist.
|
||||
antwort := []aehnlichItem{}
|
||||
|
||||
if titel != "" {
|
||||
if slug == "" {
|
||||
slug = store.Slugify(titel)
|
||||
}
|
||||
kandidaten, err := dubletten.Pruefe(r.Context(), ws, project, slug, titel, "")
|
||||
if err != nil {
|
||||
s.serverError(w, "Ähnliche Einträge konnten nicht gesucht werden", err)
|
||||
return
|
||||
}
|
||||
for _, k := range kandidaten {
|
||||
antwort = append(antwort, aehnlichItem{
|
||||
Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, Gemeinsam: k.MatchedWords,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(antwort); err != nil {
|
||||
log.Printf("Ähnliche Einträge konnten nicht gesendet werden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEntryPreview rendert eingereichten Markdown-Text als HTML — für die
|
||||
// Vorschau im Bearbeitungsformular (siehe static/preview.js).
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
109
internal/web/handlers_suche.go
Normal file
109
internal/web/handlers_suche.go
Normal 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,
|
||||
})
|
||||
}
|
||||
363
internal/web/handlers_system.go
Normal file
363
internal/web/handlers_system.go
Normal 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
|
||||
}
|
||||
286
internal/web/handlers_ueberblick.go
Normal file
286
internal/web/handlers_ueberblick.go
Normal 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
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
388
internal/web/oberflaeche_test.go
Normal file
388
internal/web/oberflaeche_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
104
main.go
|
|
@ -24,6 +24,7 @@ import (
|
|||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -102,9 +103,6 @@ const (
|
|||
writeLimitWindow = time.Hour
|
||||
)
|
||||
|
||||
// exportInterval bestimmt, wie oft der Markdown-Export automatisch läuft.
|
||||
const exportInterval = 24 * time.Hour
|
||||
|
||||
// auditRetention bestimmt, wie lange Protokolleinträge aufbewahrt werden.
|
||||
//
|
||||
// Das Protokoll enthält IP-Adressen und Zugangsnamen, aus denen sich Personen
|
||||
|
|
@ -127,37 +125,52 @@ func main() {
|
|||
|
||||
cfg := readConfig()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// ── Datenbank ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Aufbau: eine zentrale Kontroll-Datenbank (Workspaces, Key-Routing) und je
|
||||
// Workspace eine eigene Datei mit Projekten, Einträgen und Keys. Eine
|
||||
// Abteilung kann so strukturell nie auf die Datei einer anderen zugreifen —
|
||||
// Workspace ein eigener Bereich mit Projekten, Einträgen und Keys. Eine
|
||||
// Abteilung kann so strukturell nie auf die Daten einer anderen zugreifen —
|
||||
// siehe AUSBAUSTUFEN.md 4.4.
|
||||
workspacesDir := filepath.Join(cfg.dataDir, "workspaces")
|
||||
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
|
||||
log.Fatal("Datenverzeichnis konnte nicht angelegt werden: ", err)
|
||||
//
|
||||
// Wo diese Bereiche liegen — SQLite-Dateien oder PostgreSQL-Schemas —
|
||||
// entscheidet DB_DRIVER (AUSBAUSTUFEN.md 4.10). Ab hier weiß davon nur
|
||||
// noch der Speicher.
|
||||
speicher, err := oeffneSpeicher(ctx, cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer speicher.Close()
|
||||
|
||||
control, err := store.OpenControl(filepath.Join(cfg.dataDir, "control.db"))
|
||||
control, err := speicher.OpenControl()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer control.Close()
|
||||
|
||||
// workspaces öffnet und cached die Workspace-Dateien bei Bedarf — jede
|
||||
// Abteilung bekommt so ihre eigene Datenbank samt Suchindex, siehe
|
||||
// workspaces öffnet und cached die Workspaces bei Bedarf — jede Abteilung
|
||||
// bekommt so ihre eigene Datenbank samt Suchindex, siehe
|
||||
// internal/workspace.
|
||||
workspaces := workspace.NewManager(workspacesDir)
|
||||
workspaces := workspace.NewManager(speicher)
|
||||
defer workspaces.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Beim allerersten Start gibt es noch keinen Workspace — dann wird einer
|
||||
// angelegt, damit der Server ohne Zwischenschritt benutzbar ist.
|
||||
if _, err := control.EnsureDefaultWorkspace(ctx, "Intern"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Reichen die Datenbankverbindungen für alle Workspaces? Unter PostgreSQL
|
||||
// lieber jetzt mit klarer Meldung nicht starten als später unter Last mit
|
||||
// abgelehnten Verbindungen scheitern. Unter SQLite gibt es diese Grenze
|
||||
// nicht.
|
||||
if alle, err := control.ListWorkspaces(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
} else if err := speicher.PruefeKapazitaet(ctx, len(alle)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Der Export ist abgeschaltet, solange EXPORT_DIR nicht gesetzt ist.
|
||||
var exporter *export.Exporter
|
||||
if cfg.exportDir != "" {
|
||||
|
|
@ -215,7 +228,7 @@ func main() {
|
|||
go func() {
|
||||
alle, _ := control.ListWorkspaces(ctx)
|
||||
log.Printf("Munin läuft auf http://localhost:%s (%d Workspaces)", cfg.port, len(alle))
|
||||
log.Printf("Workspaces: %s (Kontrolle: %s)", workspacesDir, filepath.Join(cfg.dataDir, "control.db"))
|
||||
log.Printf("Speicher: %s", speicher.Beschreibung())
|
||||
if !cfg.enableHSTS {
|
||||
log.Println("Hinweis: ENABLE_HSTS ist aus — bei Betrieb unter HTTPS auf true setzen.")
|
||||
}
|
||||
|
|
@ -306,8 +319,18 @@ func baueHandler(control *store.ControlStore, workspaces *workspace.Manager, exp
|
|||
|
||||
// config sammelt alle Einstellungen aus der Umgebung.
|
||||
type config struct {
|
||||
// dataDir enthält die Kontroll-Datenbank (control.db) und den Ordner
|
||||
// workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md 4.4.
|
||||
// dbDriver wählt den Speicher: "sqlite" (Standard) oder "postgres" —
|
||||
// siehe AUSBAUSTUFEN.md 4.10.
|
||||
dbDriver string
|
||||
|
||||
// databaseURL ist die Verbindung zu PostgreSQL, z.B.
|
||||
// postgres://munin:geheim@localhost:5432/munin?sslmode=disable.
|
||||
// Nur bei dbDriver "postgres".
|
||||
databaseURL string
|
||||
|
||||
// dataDir enthält bei SQLite die Kontroll-Datenbank (control.db) und den
|
||||
// Ordner workspaces/ mit einer Datei je Workspace — siehe AUSBAUSTUFEN.md
|
||||
// 4.4. Bei PostgreSQL wird er nicht gebraucht.
|
||||
dataDir string
|
||||
port string
|
||||
adminToken string
|
||||
|
|
@ -328,6 +351,8 @@ type config struct {
|
|||
// jedes Projekt. Ein erratbarer Wert wäre eine Hintertür.
|
||||
func readConfig() config {
|
||||
cfg := config{
|
||||
dbDriver: strings.ToLower(envOr("DB_DRIVER", dbDriverSQLite)),
|
||||
databaseURL: os.Getenv("DATABASE_URL"),
|
||||
dataDir: envOr("DATA_DIR", "data"),
|
||||
port: envOr("PORT", "8080"),
|
||||
adminToken: os.Getenv("ADMIN_TOKEN"),
|
||||
|
|
@ -339,6 +364,16 @@ func readConfig() config {
|
|||
writeLimitHourly: envInt("WRITE_LIMIT_HOURLY", defaultWriteLimitHourly),
|
||||
}
|
||||
|
||||
switch cfg.dbDriver {
|
||||
case dbDriverSQLite:
|
||||
case dbDriverPostgres:
|
||||
if cfg.databaseURL == "" {
|
||||
log.Fatal("DB_DRIVER=postgres, aber DATABASE_URL ist nicht gesetzt. Beispiel: postgres://munin:geheim@localhost:5432/munin?sslmode=disable")
|
||||
}
|
||||
default:
|
||||
log.Fatalf("DB_DRIVER=%q ist unbekannt — erlaubt sind %q (Standard) und %q.", cfg.dbDriver, dbDriverSQLite, dbDriverPostgres)
|
||||
}
|
||||
|
||||
if cfg.sessionSecret == "" {
|
||||
log.Fatal("SESSION_SECRET ist nicht gesetzt — signiert Sitzungen und CSRF-Tokens. Erzeugen mit: openssl rand -hex 32")
|
||||
}
|
||||
|
|
@ -364,6 +399,29 @@ func readConfig() config {
|
|||
return cfg
|
||||
}
|
||||
|
||||
// Die Werte für DB_DRIVER.
|
||||
const (
|
||||
dbDriverSQLite = "sqlite"
|
||||
dbDriverPostgres = "postgres"
|
||||
)
|
||||
|
||||
// oeffneSpeicher wählt anhand von DB_DRIVER, wo die Daten liegen.
|
||||
//
|
||||
// Bei SQLite wird der Ordner für die Workspace-Dateien angelegt, falls er
|
||||
// fehlt. Bei PostgreSQL wartet NewPostgres nötigenfalls, bis die Datenbank
|
||||
// erreichbar ist, und legt das Schema der Kontroll-Datenbank an.
|
||||
func oeffneSpeicher(ctx context.Context, cfg config) (store.Speicher, error) {
|
||||
if cfg.dbDriver == dbDriverPostgres {
|
||||
return store.NewPostgres(ctx, cfg.databaseURL)
|
||||
}
|
||||
|
||||
workspacesDir := filepath.Join(cfg.dataDir, "workspaces")
|
||||
if err := os.MkdirAll(workspacesDir, 0750); err != nil {
|
||||
return nil, fmt.Errorf("Datenverzeichnis konnte nicht angelegt werden: %w", err)
|
||||
}
|
||||
return store.NewSQLite(filepath.Join(cfg.dataDir, "control.db"), workspacesDir), nil
|
||||
}
|
||||
|
||||
// fuerJedenWorkspace ruft fn für jeden Workspace mit dessen geöffneter Datei
|
||||
// auf.
|
||||
//
|
||||
|
|
@ -415,7 +473,17 @@ func exportiereAlle(ctx context.Context, e *export.Exporter, control *store.Cont
|
|||
// durchläuft, einen aktuellen Stand hinterlässt.
|
||||
func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces *workspace.Manager) {
|
||||
lauf := func() {
|
||||
beginn := time.Now()
|
||||
stats, err := exportiereAlle(context.Background(), e, control, workspaces)
|
||||
|
||||
// Für die Systemseite: Dort steht, wann der letzte Lauf endete und ob
|
||||
// er vollständig war.
|
||||
ergebnis := export.Lauf{Ende: time.Now(), Dauer: time.Since(beginn), Stats: stats}
|
||||
if err != nil {
|
||||
ergebnis.Fehler = err.Error()
|
||||
}
|
||||
e.MerkeLauf(ergebnis)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Export fehlgeschlagen: %v", err)
|
||||
}
|
||||
|
|
@ -426,7 +494,7 @@ func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces
|
|||
|
||||
lauf()
|
||||
|
||||
ticker := time.NewTicker(exportInterval)
|
||||
ticker := time.NewTicker(export.Intervall)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
lauf()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,23 +1,55 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Sichert die zentrale Kontroll-Datenbank und jede Workspace-Datei einzeln.
|
||||
# Sichert die zentrale Kontroll-Datenbank und jeden Workspace einzeln — mit
|
||||
# SQLite (Standard) oder PostgreSQL, je nach DB_DRIVER wie beim Server.
|
||||
#
|
||||
# Jede Sicherung landet in einem eigenen, zeitgestempelten Ordner. Da jeder
|
||||
# Workspace für sich gesichert wird, lässt sich später auch nur eine einzelne
|
||||
# Abteilung gezielt wiederherstellen, ohne die übrigen anzufassen.
|
||||
#
|
||||
# SQLite PostgreSQL
|
||||
# <stempel>/control.db <stempel>/control.dump
|
||||
# <stempel>/workspaces/<id>.db <stempel>/workspaces/munin_ws_<id>.dump
|
||||
#
|
||||
# ── SQLite ───────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# sqlite3 .backup ist dafür gebaut, auch während der Server im WAL-Modus
|
||||
# schreibt (siehe AUSBAUSTUFEN.md 4.4 und README.md "Datensicherung") — eine
|
||||
# einfache Dateikopie wäre hier riskant.
|
||||
#
|
||||
# Jede Sicherung landet in einem eigenen, zeitgestempelten Ordner. Da jeder
|
||||
# Workspace seine eigene Datei ist, lässt sich später auch nur eine einzelne
|
||||
# Abteilung gezielt wiederherstellen, ohne die übrigen anzufassen.
|
||||
#
|
||||
# ── Aufruf ───────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# scripts/backup.sh [Datenverzeichnis] [Sicherungsverzeichnis]
|
||||
#
|
||||
# Beide Pfade lassen sich auch über die Umgebungsvariablen DATA_DIR und
|
||||
# BACKUP_DIR setzen (Vorgaben: "data" und "backups") — praktisch für einen
|
||||
# Cronjob, der ohnehin feste Pfade mitgibt.
|
||||
#
|
||||
# ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||
#
|
||||
# DB_DRIVER=postgres DATABASE_URL=postgres://… BACKUP_DIR=… scripts/backup.sh
|
||||
#
|
||||
# pg_dump sichert jedes Schema (munin_control, munin_ws_…) in eine eigene
|
||||
# Datei im custom-Format, im laufenden Betrieb und ohne den Server zu
|
||||
# bremsen. Jede Datei enthält das Schema selbst samt dem Workspace-Namen als
|
||||
# Kommentar, lässt sich also mit pg_restore ohne Vorarbeit zurückspielen
|
||||
# (BETRIEB.md, "Aus einer Sicherung wiederherstellen").
|
||||
#
|
||||
# Nicht verwenden: einen einzigen Dump aller Schemas. Aus dem lässt sich ein
|
||||
# einzelner Workspace nicht zurückholen — pg_restore --schema legt das Schema
|
||||
# selbst nicht an (am 2026-10-16 ausprobiert).
|
||||
#
|
||||
# Gebraucht werden psql, pg_dump und pg_restore in mindestens der Hauptversion
|
||||
# des Servers (pg_dump verweigert sonst die Arbeit). Fehlen sie auf dem
|
||||
# Sicherungsrechner, lassen sie sich aus dem PostgreSQL-Container leihen:
|
||||
#
|
||||
# PSQL="docker exec -i munin-postgres psql" \
|
||||
# PG_DUMP="docker exec -i munin-postgres pg_dump" \
|
||||
# PG_RESTORE="docker exec -i munin-postgres pg_restore" \
|
||||
# DB_DRIVER=postgres DATABASE_URL=postgres://munin@localhost:5432/munin scripts/backup.sh
|
||||
#
|
||||
# Das Passwort besser nicht in DATABASE_URL schreiben — die Adresse steht als
|
||||
# Aufrufparameter in der Prozessliste. Stattdessen ~/.pgpass oder PGPASSWORD
|
||||
# (bei docker exec mit -e PGPASSWORD weiterreichen).
|
||||
#
|
||||
# ── Beispiel für Cron ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Täglich um 3 Uhr, mit 14 Tagen Aufbewahrung:
|
||||
|
|
@ -30,52 +62,129 @@
|
|||
#
|
||||
# ── Wiederherstellen ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Server stoppen, die gewünschte .db-Datei aus einem Sicherungsordner an ihren
|
||||
# Platz unter DATA_DIR zurückkopieren, Server wieder starten. Für einen
|
||||
# einzelnen Workspace reicht dessen Datei unter workspaces/ — die
|
||||
# Kontroll-Datenbank bleibt davon unberührt.
|
||||
#
|
||||
# Vorher die -wal- und -shm-Dateien der alten Fassung entfernen, sonst
|
||||
# übernimmt SQLite deren Inhalt in die zurückgespielte Datei. Die genauen
|
||||
# Schritte stehen in BETRIEB.md, Abschnitt "Aus einer Sicherung wiederherstellen".
|
||||
# Die genauen Schritte für beide Datenbanken stehen in BETRIEB.md, Abschnitt
|
||||
# "Aus einer Sicherung wiederherstellen". Kurz für SQLite: Server stoppen,
|
||||
# -wal/-shm der alten Fassung entfernen, .db-Datei zurückkopieren, starten.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
treiber="$(echo "${DB_DRIVER:-sqlite}" | tr '[:upper:]' '[:lower:]')"
|
||||
datenverzeichnis="${1:-${DATA_DIR:-data}}"
|
||||
sicherungsverzeichnis="${2:-${BACKUP_DIR:-backups}}"
|
||||
stempel="$(date +%Y-%m-%dT%H-%M-%S)"
|
||||
ziel="$sicherungsverzeichnis/$stempel"
|
||||
|
||||
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
echo "sqlite3 ist nicht installiert — ohne dieses Werkzeug keine sichere Sicherung im laufenden Betrieb." >&2
|
||||
exit 1
|
||||
fi
|
||||
# ── SQLite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if [ ! -d "$datenverzeichnis" ]; then
|
||||
echo "Datenverzeichnis $datenverzeichnis existiert nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
sichere_sqlite() {
|
||||
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
echo "sqlite3 ist nicht installiert — ohne dieses Werkzeug keine sichere Sicherung im laufenden Betrieb." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ziel/workspaces"
|
||||
if [ ! -d "$datenverzeichnis" ]; then
|
||||
echo "Datenverzeichnis $datenverzeichnis existiert nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sichern() {
|
||||
sqlite3 "$1" ".backup '$2'"
|
||||
mkdir -p "$ziel/workspaces"
|
||||
|
||||
sichern() {
|
||||
sqlite3 "$1" ".backup '$2'"
|
||||
}
|
||||
|
||||
if [ -f "$datenverzeichnis/control.db" ]; then
|
||||
sichern "$datenverzeichnis/control.db" "$ziel/control.db"
|
||||
echo "control.db gesichert"
|
||||
else
|
||||
echo "Warnung: $datenverzeichnis/control.db nicht gefunden — übersprungen." >&2
|
||||
fi
|
||||
|
||||
anzahl=0
|
||||
for datei in "$datenverzeichnis"/workspaces/*.db; do
|
||||
[ -e "$datei" ] || continue # kein Workspace vorhanden (glob lief leer)
|
||||
sichern "$datei" "$ziel/workspaces/$(basename "$datei")"
|
||||
anzahl=$((anzahl + 1))
|
||||
done
|
||||
echo "$anzahl Workspace-Datei(en) gesichert nach $ziel"
|
||||
}
|
||||
|
||||
if [ -f "$datenverzeichnis/control.db" ]; then
|
||||
sichern "$datenverzeichnis/control.db" "$ziel/control.db"
|
||||
echo "control.db gesichert"
|
||||
else
|
||||
echo "Warnung: $datenverzeichnis/control.db nicht gefunden — übersprungen." >&2
|
||||
fi
|
||||
# ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||
|
||||
anzahl=0
|
||||
for datei in "$datenverzeichnis"/workspaces/*.db; do
|
||||
[ -e "$datei" ] || continue # kein Workspace vorhanden (glob lief leer)
|
||||
sichern "$datei" "$ziel/workspaces/$(basename "$datei")"
|
||||
anzahl=$((anzahl + 1))
|
||||
done
|
||||
echo "$anzahl Workspace-Datei(en) gesichert nach $ziel"
|
||||
sichere_postgres() {
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo "DB_DRIVER=postgres, aber DATABASE_URL ist nicht gesetzt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Die Befehle dürfen aus mehreren Wörtern bestehen ("docker exec -i … pg_dump"),
|
||||
# deshalb als Liste eingelesen.
|
||||
read -r -a psql <<<"${PSQL:-psql}"
|
||||
read -r -a pg_dump <<<"${PG_DUMP:-pg_dump}"
|
||||
read -r -a pg_restore <<<"${PG_RESTORE:-pg_restore}"
|
||||
|
||||
for befehl in "${psql[0]}" "${pg_dump[0]}" "${pg_restore[0]}"; do
|
||||
if ! command -v "$befehl" >/dev/null 2>&1; then
|
||||
echo "$befehl ist nicht installiert. Siehe Kopf dieses Skripts: PSQL, PG_DUMP und PG_RESTORE lassen sich auf einen Container umstellen." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Alle Schemas von Munin: die Kontroll-Datenbank und je Workspace eines.
|
||||
# Der Unterstrich muss im LIKE maskiert werden, sonst steht er für ein
|
||||
# beliebiges Zeichen.
|
||||
schemas="$("${psql[@]}" --dbname="$DATABASE_URL" --no-align --tuples-only --command \
|
||||
"SELECT nspname FROM pg_namespace WHERE nspname = 'munin_control' OR nspname LIKE 'munin\_ws\_%' ORDER BY nspname")"
|
||||
|
||||
if ! grep -qx 'munin_control' <<<"$schemas"; then
|
||||
echo "Schema munin_control nicht gefunden — ist DATABASE_URL die Datenbank von Munin?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ziel/workspaces"
|
||||
|
||||
# Bewusst "for" statt "while read": Mit "docker exec -i" liest pg_dump die
|
||||
# Standardeingabe — in einer while-read-Schleife also den Rest der Liste,
|
||||
# und nach dem ersten Schema wäre Schluss. Schemanamen enthalten keine
|
||||
# Leerzeichen, das Aufteilen an Zeilenumbrüchen ist deshalb sicher.
|
||||
anzahl=0
|
||||
for schema in $schemas; do
|
||||
if [ "$schema" = "munin_control" ]; then
|
||||
datei="$ziel/control.dump"
|
||||
else
|
||||
datei="$ziel/workspaces/$schema.dump"
|
||||
fi
|
||||
|
||||
# Ausgabe über die Standardausgabe statt --file, damit es auch mit
|
||||
# "docker exec" funktioniert: Die Datei entsteht dann auf dem Rechner,
|
||||
# der das Skript ausführt, nicht im Container.
|
||||
"${pg_dump[@]}" --dbname="$DATABASE_URL" --format=custom --schema="$schema" </dev/null >"$datei"
|
||||
|
||||
# Gleich prüfen, ob sich die Datei lesen lässt. Eine Sicherung, die
|
||||
# beim Zurückspielen scheitert, fällt sonst erst im Ernstfall auf.
|
||||
if ! "${pg_restore[@]}" --list <"$datei" >/dev/null; then
|
||||
echo "Sicherung von $schema ist nicht lesbar: $datei" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$schema" = "munin_control" ]; then
|
||||
echo "munin_control gesichert"
|
||||
else
|
||||
anzahl=$((anzahl + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$anzahl Workspace-Schema(s) gesichert nach $ziel"
|
||||
}
|
||||
|
||||
case "$treiber" in
|
||||
sqlite) sichere_sqlite ;;
|
||||
postgres) sichere_postgres ;;
|
||||
*)
|
||||
echo "DB_DRIVER=$treiber ist unbekannt — erlaubt sind sqlite und postgres." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Alte Sicherungen aufräumen, falls eine Aufbewahrungsfrist gesetzt ist.
|
||||
if [ -n "${RETENTION_DAYS:-}" ]; then
|
||||
|
|
|
|||
7
scripts/postgres-dev-init.sql
Normal file
7
scripts/postgres-dev-init.sql
Normal 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
188
scripts/sicherung-pruefen.sh
Executable 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
96
static/aehnlich.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
220
static/style.css
220
static/style.css
|
|
@ -67,7 +67,7 @@
|
|||
/* Höhe der Bedienelemente. Wichtig ist nicht der Wert, sondern dass Knopf
|
||||
* und Eingabefeld denselben verwenden — sonst stehen sie in einer Zeile
|
||||
* nebeneinander unterschiedlich hoch, und das fällt sofort auf. */
|
||||
--hoehe-bedienelement: 2.25rem;
|
||||
--hoehe-bedienelement: 2.5rem; /* Knöpfe, Eingabefelder, Auswahllisten: alle gleich hoch */
|
||||
--hoehe-kopfleiste: 3.5rem;
|
||||
|
||||
--radius: 6px;
|
||||
|
|
@ -156,6 +156,15 @@ pre code { background: none; border: none; padding: 0; }
|
|||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Speicherort eines Workspace (Schema oder Datei) als blasse zweite Zeile in
|
||||
* der Workspace-Übersicht. Festbreitenschrift, weil es ein Bezeichner zum
|
||||
* Abtippen ist; markierbar, damit er sich kopieren lässt. */
|
||||
.speicherort {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: var(--t-klein);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* ── 3. Gerüst ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Schmale Kopfleiste über die ganze Breite. Sie trägt nur Marke und Abmelden —
|
||||
|
|
@ -459,6 +468,16 @@ button.warnung:hover { background: var(--warn-flach); filter: none; }
|
|||
}
|
||||
.wie-link:hover { filter: none; text-decoration: underline; }
|
||||
|
||||
/* Rechte Seite der Kopfleiste: Hilfe und Abmelden, im selben leisen Ton.
|
||||
* Doppelte Klasse, weil .reihe weiter unten sonst den Abstand überschreibt. */
|
||||
.reihe.kopfleiste-aktionen { gap: var(--s5); }
|
||||
.kopfleiste-link {
|
||||
font-size: var(--t-meta);
|
||||
color: var(--text-leise);
|
||||
}
|
||||
.kopfleiste-link:hover { color: var(--text); }
|
||||
.kopfleiste-link.aktiv { color: var(--text); font-weight: 500; }
|
||||
|
||||
.reihe { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
|
||||
form.reihe { display: inline-flex; }
|
||||
/* In rechtsbündigen Tabellenspalten (z. B. Verlauf) sollen die Aktionen
|
||||
|
|
@ -468,15 +487,22 @@ form.reihe { display: inline-flex; }
|
|||
/* Formulare.
|
||||
*
|
||||
* Jedes Feld ist ein .feld mit Beschriftung, optionalem Hinweis und Bedienelement.
|
||||
* Die Breite richtet sich nach dem erwarteten Inhalt statt immer 100 % zu sein:
|
||||
* Ein Feld für einen Bezeichner, das über die halbe Seite läuft, sieht aus wie
|
||||
* ein Fehler. */
|
||||
.formular { display: flex; flex-direction: column; gap: var(--s4); }
|
||||
* Die Breite des BEDIENELEMENTS richtet sich nach dem erwarteten Inhalt statt
|
||||
* immer 100 % zu sein: Ein Feld für einen Bezeichner, das über die halbe Seite
|
||||
* läuft, sieht aus wie ein Fehler. Beschriftung und Hinweis dürfen breiter
|
||||
* sein (bis zur Lesebreite) — sonst bricht ein Hinweis in eine schmale Spalte
|
||||
* um und wirkt gedrückt (so war es bis 2026-10-16). */
|
||||
.formular { display: flex; flex-direction: column; gap: var(--s5); }
|
||||
.feld { display: flex; flex-direction: column; gap: var(--s1); }
|
||||
.feld > label { font-weight: 500; font-size: var(--t-meta); }
|
||||
.feld > .hilfe { margin-bottom: var(--s1); }
|
||||
.feld > .hilfe {
|
||||
max-width: var(--breite-lesen);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
input[type=text], input[type=search], input[type=password], select, textarea {
|
||||
input[type=text], input[type=search], input[type=password], input[type=number],
|
||||
input[type=date], input[type=email], input[type=url], select, textarea {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s3);
|
||||
min-height: var(--hoehe-bedienelement);
|
||||
|
|
@ -487,7 +513,26 @@ input[type=text], input[type=search], input[type=password], select, textarea {
|
|||
font: inherit;
|
||||
font-size: var(--t-meta);
|
||||
}
|
||||
/* Einzeilige Bedienelemente bekommen eine feste Höhe statt nur einer
|
||||
* Mindesthöhe. Sonst sind Textfeld, Auswahlliste und Datumsfeld je nach
|
||||
* Browser unterschiedlich hoch (beobachtet: 40, 36 und 42 Pixel), und in
|
||||
* einer Zeile wie der Filterleiste stehen ihre Beschriftungen versetzt. */
|
||||
input:is([type=text], [type=search], [type=password], [type=number], [type=date], [type=email], [type=url]),
|
||||
select {
|
||||
height: var(--hoehe-bedienelement);
|
||||
padding-block: 0;
|
||||
}
|
||||
input::placeholder { color: var(--text-leise); opacity: 0.7; }
|
||||
/* Dateiauswahl: kein Textfeld, aber derselbe Rahmen, damit sie im Formular
|
||||
* nicht wie ein Fremdkörper wirkt. */
|
||||
input[type=file] {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
border: 1px dashed var(--rahmen-stark);
|
||||
border-radius: var(--radius);
|
||||
background: var(--flaeche);
|
||||
font-size: var(--t-meta);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--akzent);
|
||||
outline: 2px solid var(--akzent-flach);
|
||||
|
|
@ -572,10 +617,11 @@ textarea {
|
|||
.autovervollstaendigung li.aktiv { background: var(--akzent-flach); }
|
||||
.autovervollstaendigung li.aktiv span { color: var(--akzent-text); }
|
||||
|
||||
/* Feldbreiten nach Inhalt. */
|
||||
.feld.kurz { max-width: 18rem; }
|
||||
.feld.mittel { max-width: 28rem; }
|
||||
.feld.lang { max-width: 40rem; }
|
||||
/* Breiten der Bedienelemente nach Inhalt — nur das Eingabefeld wird
|
||||
* begrenzt, nicht Beschriftung und Hinweis (siehe oben). */
|
||||
.feld.kurz > :is(input, select) { max-width: 18rem; }
|
||||
.feld.mittel > :is(input, select) { max-width: 28rem; }
|
||||
.feld.lang > :is(input, select) { max-width: 40rem; }
|
||||
|
||||
/* Ankreuzfeld: Kästchen und Text nebeneinander, Text nicht fett. */
|
||||
.ankreuz {
|
||||
|
|
@ -587,12 +633,15 @@ textarea {
|
|||
* optisch auf der ersten Textzeile liegt statt darüber. */
|
||||
.ankreuz input { width: auto; min-height: 0; margin-top: 0.3rem; }
|
||||
.ankreuz label { font-weight: normal; }
|
||||
/* Der Hinweis steht unter der Beschriftung, nicht in derselben Zeile. */
|
||||
.ankreuz label .hilfe { display: block; margin-top: var(--s1); max-width: var(--breite-lesen); }
|
||||
|
||||
.formular-aktionen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
padding-top: var(--s2);
|
||||
padding-top: var(--s1);
|
||||
}
|
||||
|
||||
/* Tabellen. */
|
||||
|
|
@ -656,6 +705,137 @@ table.tabelle {
|
|||
margin-bottom: var(--s4);
|
||||
}
|
||||
.werkzeugleiste input[type=search] { flex: 1; }
|
||||
/* Hinweis "Gibt es schon?" unter dem Titel eines neuen Eintrags. */
|
||||
.aehnlich-hinweis {
|
||||
margin-top: var(--s2);
|
||||
padding: var(--s2) var(--s3);
|
||||
border-left: 3px solid var(--akzent);
|
||||
background: var(--flaeche-leis);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.aehnlich-hinweis ul { margin: var(--s1) 0 0; padding-left: var(--s4); }
|
||||
.aehnlich-hinweis li { font-size: var(--t-meta); }
|
||||
|
||||
/* Eigenschaftsliste (Begriff: Wert), z.B. auf der Systemseite. */
|
||||
.eigenschaften {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: var(--s2) var(--s4);
|
||||
margin: var(--s3) 0 0;
|
||||
}
|
||||
.eigenschaften dt { color: var(--text-leise); }
|
||||
.eigenschaften dd { margin: 0; }
|
||||
@media (max-width: 60rem) {
|
||||
.eigenschaften { grid-template-columns: 1fr; gap: var(--s1); }
|
||||
.eigenschaften dd { margin-bottom: var(--s2); }
|
||||
}
|
||||
|
||||
/* Systemseite: Kasten "Zustand". Warnungen rot, Hinweise grün — dieselben
|
||||
* Farben wie .meldung, aber als Liste, damit mehrere untereinander passen. */
|
||||
.zustand { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s2); }
|
||||
.zustand > li {
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
align-items: baseline;
|
||||
padding: var(--s3) var(--s4);
|
||||
border: 1px solid var(--rahmen);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--t-meta);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.zustand > li.zustand-warnung { background: var(--warn-flach); border-left-color: var(--warn); }
|
||||
.zustand > li.zustand-hinweis { background: var(--akzent-flach); border-left-color: var(--akzent); }
|
||||
.zustand-art { font-weight: 600; flex: none; min-width: 5rem; max-width: 12rem; overflow-wrap: anywhere; }
|
||||
.zustand-warnung .zustand-art { color: var(--warn); }
|
||||
/* Überblick: mehrere Befunde eines Projekts in einer Karte. */
|
||||
.zustand-punkte { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--s1); flex: 1; min-width: 0; }
|
||||
.zustand-punkte .dringend { font-weight: 600; }
|
||||
.weiter-link { white-space: nowrap; font-weight: 400; }
|
||||
.zustand-hinweis .zustand-art { color: var(--akzent-text); }
|
||||
|
||||
/* Systemseite: Balkendiagramme (internal/web/diagramm.go). Untereinander
|
||||
* und höchstens so breit wie Fließtext: Das SVG skaliert samt Schrift mit
|
||||
* seiner Breite — nebeneinander wären die Beschriftungen zu klein, über die
|
||||
* ganze Seite die Balken unnötig flach. */
|
||||
.diagramme {
|
||||
display: grid;
|
||||
gap: var(--s4);
|
||||
max-width: var(--breite-lesen);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
.diagramm-rahmen {
|
||||
margin: 0;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rahmen);
|
||||
border-radius: var(--radius-gross);
|
||||
padding: var(--s4);
|
||||
min-width: 0;
|
||||
}
|
||||
.diagramm-rahmen figcaption {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s1) var(--s4);
|
||||
margin-bottom: var(--s3);
|
||||
font-size: var(--t-meta);
|
||||
}
|
||||
.legende { display: inline-flex; align-items: center; gap: var(--s1); color: var(--text-leise); }
|
||||
.legende-farbe { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 2px; }
|
||||
.diagramm { display: block; width: 100%; height: auto; }
|
||||
.diagramm-gitter { stroke: var(--rahmen); stroke-width: 1; }
|
||||
.diagramm-text { fill: var(--text-leise); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
/* Unsichtbar, aber da: Über ihr erscheint der Tooltip eines Tages. */
|
||||
.diagramm-treffer { fill: transparent; }
|
||||
.diagramm-treffer:hover { fill: var(--flaeche-leis); }
|
||||
.diagramm-leer { margin: var(--s5) 0; text-align: center; }
|
||||
/* Die Farben gelten für die Balken (SVG: fill) und die Legende (HTML:
|
||||
* background). Menschen in derselben Farbe wie Agenten, nur heller — beides
|
||||
* ist "Schreiben". */
|
||||
rect.balken-agent { fill: var(--akzent); }
|
||||
rect.balken-mensch { fill: var(--akzent); fill-opacity: 0.4; }
|
||||
rect.balken-suche { fill: var(--text-leise); }
|
||||
.legende-farbe.balken-agent { background: var(--akzent); }
|
||||
.legende-farbe.balken-mensch { background: var(--akzent); opacity: 0.4; }
|
||||
.legende-farbe.balken-suche { background: var(--text-leise); }
|
||||
|
||||
/* Aufklappbarer Zusatz unter einer Tabelle oder einem Diagramm. */
|
||||
.aufklapper { margin-top: var(--s3); }
|
||||
.aufklapper > summary { cursor: pointer; color: var(--akzent-text); font-size: var(--t-meta); }
|
||||
.aufklapper[open] > summary { margin-bottom: var(--s3); }
|
||||
.abschnitt h3 { margin: var(--s5) 0 var(--s2); }
|
||||
.ruhig-liste { margin: var(--s2) 0 0; padding-left: var(--s5); columns: 2 16rem; font-size: var(--t-meta); }
|
||||
|
||||
/* Warnender Text in einer Kennzahl. */
|
||||
.warn-text { color: var(--warn); }
|
||||
|
||||
/* Filter über einer Tabelle, z.B. im Protokoll. Bricht auf schmalen
|
||||
* Bildschirmen um. */
|
||||
.filterleiste {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--s2) var(--s3);
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
.filterleiste label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
font-size: var(--t-meta);
|
||||
color: var(--text-leise);
|
||||
}
|
||||
.filterleiste a { align-self: center; font-size: var(--t-meta); }
|
||||
/* In der Filterleiste nehmen die Felder ihre natürliche Breite, nicht 100 %
|
||||
* des Etiketts — sonst schrumpfen sie auf die Breite der Beschriftung. */
|
||||
.filterleiste :is(input, select) { width: auto; min-width: 9rem; }
|
||||
|
||||
/* Filterlink neben der Suche, z.B. "3 veraltet". */
|
||||
.werkzeugleiste .filter-link {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
font-size: var(--t-klein);
|
||||
}
|
||||
|
||||
|
||||
/* Eintragsliste. */
|
||||
|
|
@ -1002,7 +1182,14 @@ table.tabelle {
|
|||
gap: 0;
|
||||
padding-inline: var(--s4);
|
||||
}
|
||||
.kopfleiste-innen { padding-inline: var(--s4); }
|
||||
.kopfleiste-innen { padding-inline: var(--s4); gap: var(--s3); }
|
||||
/* Kopfleiste: Auf schmalen Bildschirmen reicht "Hilfe", und ein langer
|
||||
* Workspace-Name wird gekürzt statt die Leiste zu sprengen. */
|
||||
.nur-breit { display: none; }
|
||||
.reihe.kopfleiste-aktionen { gap: var(--s4); flex-wrap: nowrap; }
|
||||
.kopfleiste-innen > .reihe:first-child { min-width: 0; flex-wrap: nowrap; }
|
||||
.arbeitsbereich { min-width: 0; }
|
||||
.arbeitsbereich-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.seitenleiste {
|
||||
position: static;
|
||||
|
|
@ -1025,9 +1212,13 @@ table.tabelle {
|
|||
}
|
||||
.nav-titel { margin-bottom: 0; padding-inline: 0; white-space: nowrap; }
|
||||
|
||||
/* min-width: 0, damit die Zeile in sich scrollt, statt die ganze Seite
|
||||
* zu verbreitern — ein Flex-Element gibt sonst nicht unter die Breite
|
||||
* seines Inhalts nach. */
|
||||
.nav-liste {
|
||||
display: flex;
|
||||
gap: var(--s1);
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none; /* die Leiste scrollt, ohne einen Balken zu zeigen */
|
||||
}
|
||||
|
|
@ -1045,7 +1236,8 @@ table.tabelle {
|
|||
|
||||
.karte { padding: var(--s4); }
|
||||
.tabelle th, .tabelle td { padding: var(--s2) var(--s3); }
|
||||
.feld.kurz, .feld.mittel, .feld.lang { max-width: none; }
|
||||
.feld.kurz > :is(input, select), .feld.mittel > :is(input, select),
|
||||
.feld.lang > :is(input, select) { max-width: none; }
|
||||
textarea { min-height: 14rem; }
|
||||
.diff-koepfe { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
|
|
|||
61
static/tastenkuerzel.js
Normal file
61
static/tastenkuerzel.js
Normal 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
45
static/ungespeichert.js
Normal 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 = "";
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -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}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue