diff --git a/BEFUNDE.md b/BEFUNDE.md index c62abe9..964ce4d 100644 --- a/BEFUNDE.md +++ b/BEFUNDE.md @@ -9,7 +9,8 @@ > | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt | > | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen | > | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) | -> | Go-Tests `rescan`, `audit`, `thumbnails` | **offen** | +> | Go-Tests `rescan` | ✅ 5 Testfunktionen (26.09.) – dabei eine fehlende Notbremse gefunden | +> | Go-Tests `audit`, `thumbnails` | **bewusst nicht** – siehe unten | > | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests | > > Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil @@ -182,7 +183,7 @@ Zwei Tests in `pkg/middleware/logger_test.go` halten es fest. | `internal/tags` | 1338 | **4** ✅ | keine | | `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine | | `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen | -| `internal/rescan` | 435 | **0** | Python-Selbsttest, 20 Prüfungen | +| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen | | `internal/audit` | 180 | **0** | keine | | `internal/thumbnails` | 95 | **0** | keine | @@ -236,6 +237,16 @@ Python-Selbsttest), `internal/audit` und `internal/thumbnails`. --- +### `audit` und `thumbnails`: bewusst ohne Go-Tests (26.09.2026) + +Nicht vergessen, sondern entschieden. Ein Fehler in `thumbnails` (95 Zeilen) +erzeugt ein hässliches Vorschaubild; einer in `audit` (180 Zeilen) kostet einen +Protokolleintrag. Beide schreiben nichts kaputt und löschen nichts. + +Tests dafür wären Häkchen, keine Absicherung – und sie kosten dieselbe +Aufmerksamkeit wie die Stellen, an denen wirklich etwas passieren kann. Wer sie +später doch will, hat damit wenigstens die Gegenrede. + ## 4 · Was in Ordnung ist Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild diff --git a/TESTING.md b/TESTING.md index 7361dc9..290d5a2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1562,6 +1562,69 @@ bleibt `node` (schnell); wer ein DOM braucht, schreibt --- +## Der Abgleich bekommt eine Notbremse (26.09.2026) + +`internal/rescan` ist das einzige Stueck Aether, das **hart** loescht: +`DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang ueber +die Platte nicht gesehen wurde. + +Der Ernstfall ist nicht ausgedacht ([localfs.go:199](../backend/internal/storage/localfs.go)): + + if _, err := os.Stat(base); os.IsNotExist(err) { + return nil // kein Fehler, keine Eintraege + } + +Existiert das Datenverzeichnis nicht, meldet `Walk` **keinen Fehler** – es +meldet gar nichts. Genau der Fingerabdruck eines Bind-Mounts, der nach einem +Neustart nicht hochkam, oder eines Tippfehlers in `DATA_PATH`. Dann gilt jede +Datei als verwaist, und **ein** Aufruf mit `?anwenden=true` loescht den +gesamten Bestand aus der Datenbank. Die Bytes laegen noch da, aber niemand +wuesste mehr, dass es sie gibt – und der GC raeumte sie 24 Stunden spaeter ab. + +Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank +ab, nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: +Eine Seite antwortet faelschlich mit "leer". Der GC loescht dann Blobs – die +Datenbank weiss hinterher noch, was fehlt. Hier faellt die Zeile selbst. **Die +gefaehrlichere Seite war die ungesicherte.** + +### Zwei Bremsen, eine davon nicht uebersteuerbar + +| Bremse | wann | bestaetigbar | +|---|---|---| +| **Notbremse** | nichts auf der Platte gefunden, Datenbank kennt Dateien | **nein** | +| **Mengenbremse** | mehr als die Haelfte wuerde fallen | ja, mit `&bestaetigt=true` | + +Die leere Installation (nichts hier, nichts dort) ist ausgenommen – da gibt es +nichts zu verwechseln. + +Dazu umgebaut: erst sammeln, dann entscheiden, dann loeschen. Vorher wurde +mitten in der Schleife geloescht; die Gesamtzahl kennt man dort erst, wenn die +Haelfte schon weg ist, und eine Bremse kaeme zu spaet. + +- [x] Fuenf Tests in `bremse_test.go`. Unter Mutation nachgewiesen: Wird die + Notbremse entfernt, werden `TestNotbremseBeiLeeremSpeicher` **und** der + Ernstfalltest rot. +- [x] Ernstfall am echten Geraet: fuenf Zeilen in der Datenbank, ein + Wurzelverzeichnis ohne `users/`. Nach `Apply` stehen alle fuenf noch – + auch mit ausdruecklicher Bestaetigung. +- [ ] Im Admin-Bereich: "Rescan storage" -> Check -> Apply bei heilem Bestand + arbeitet wie vorher 🔎 +- [ ] Ein abgebrochener Lauf steht rot in der Karte, mit Grund, und die Karte + bleibt im Zustand "geprueft" (der Anwenden-Knopf verschwindet nicht) +- [ ] Ein abgebrochener Lauf steht im Audit-Log – gerade der: Er ist der + Hinweis darauf, dass mit dem Datenordner etwas nicht stimmt + +### Nebenbefund: der Teilen-Selbsttest leckte App-Passwoerter + +`kalender-teilen-selbsttest.py` legte je Lauf zwei App-Passwoerter an und +raeumte nur das des Empfaengers weg (das faellt mit dessen Konto). Das des +Besitzers blieb liegen. Nach **achtzehn** Laeufen war +`auth.maxAppPasswords = 20` erreicht, und caldav wie kalender-teilen +scheiterten mit `409` – ohne dass am Code etwas kaputt gewesen waere. + +Aufgeraeumt und behoben; zweimal hintereinander laufen lassen, danach stehen +wieder nur die beiden echten (`CalDAV`, `DAV`). + ## Freigabelinks: Prüfsumme, Gruppenordner, Grenzen (25.09.2026) Beim Lesen von `internal/sharing` fiel eine ungleiche Zusage auf: Der diff --git a/backend/internal/admin/audit_handler.go b/backend/internal/admin/audit_handler.go index 492ea21..63bf490 100644 --- a/backend/internal/admin/audit_handler.go +++ b/backend/internal/admin/audit_handler.go @@ -142,12 +142,16 @@ func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) { } anwenden := r.URL.Query().Get("anwenden") == "true" + // Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen + // soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint + // aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen. + bestaetigt := r.URL.Query().Get("bestaetigt") == "true" var ( bef *rescan.Report err error ) if anwenden { - bef, err = h.rescan.Apply(r.Context()) + bef, err = h.rescan.Apply(r.Context(), bestaetigt) } else { bef, err = h.rescan.Check(r.Context()) } @@ -157,9 +161,17 @@ func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) { } if anwenden { - h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", - fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors", - bef.Adopted, bef.Corrected, bef.Removed, bef.Errors)) + // Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein + // ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit + // dem Datenordner etwas nicht stimmt. + if bef.Aborted { + h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", + "rescan aborted, nothing removed: "+bef.AbortReason) + } else { + h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", + fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors", + bef.Adopted, bef.Corrected, bef.Removed, bef.Errors)) + } } response.JSON(w, http.StatusOK, bef) } diff --git a/backend/internal/rescan/bremse_test.go b/backend/internal/rescan/bremse_test.go new file mode 100644 index 0000000..1b71299 --- /dev/null +++ b/backend/internal/rescan/bremse_test.go @@ -0,0 +1,164 @@ +package rescan + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "aether/internal/files" + "aether/internal/storage" +) + +// Der Abgleich ist das einzige Stück Aether, das **hart** löscht: +// `DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang über +// die Platte nicht gesehen wurde. +// +// Der Ernstfall ist nicht ausgedacht: `LocalFS.Walk` gibt für ein nicht +// vorhandenes Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau +// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam. +// Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit +// `?anwenden=true` löschte den ganzen Bestand aus der Datenbank. +// +// Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der +// gefährlicheren von beiden Stellen. + +func TestNotbremseBeiLeeremSpeicher(t *testing.T) { + // Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar. + grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false) + if grund == "" { + t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen") + } + if uebersteuerbar { + t.Fatal("die Notbremse darf sich nicht bestaetigen lassen") + } + // Auch mit ausdruecklicher Bestaetigung nicht. + if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" { + t.Fatal("auch mit Bestaetigung muss die Notbremse halten") + } +} + +func TestLeereInstallationDarfDurch(t *testing.T) { + // Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu + // verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie + // abgleichen. + if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" { + t.Fatalf("leere Installation darf nicht bremsen: %s", grund) + } +} + +func TestMengenbremseLaesstSichBestaetigen(t *testing.T) { + // Mehr als die Hälfte auf einmal: erst nachfragen. + grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false) + if grund == "" { + t.Fatal("mehr als die Haelfte muss nachfragen") + } + if !uebersteuerbar { + t.Fatal("die Mengenbremse muss sich bestaetigen lassen") + } + if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" { + t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund) + } +} + +func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) { + // Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die + // Funktion unbrauchbar – und jemand baute die Bremse wieder aus. + for _, f := range []struct { + gescannt, lebende, fallen int + }{ + {1000, 1000, 1}, + {1000, 1000, 17}, + {1000, 1000, 500}, // genau die Hälfte: noch erlaubt + {1, 1, 0}, + } { + if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" { + t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund) + } + } +} + +// Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein +// Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch +// da sein. +// +// Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/ +func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) { + dsn := os.Getenv("TEST_DSN") + if dsn == "" { + t.Skip("TEST_DSN nicht gesetzt") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("verbinden: %v", err) + } + defer pool.Close() + + var nutzer uuid.UUID + if err := pool.QueryRow(ctx, + `INSERT INTO users (username, display_name, password, is_admin) + VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8], + ).Scan(&nutzer); err != nil { + t.Fatalf("konto: %v", err) + } + defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer) + + for i := 0; i < 5; i++ { + if _, err := pool.Exec(ctx, + `INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key) + VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`, + nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(), + ); err != nil { + t.Fatalf("datei: %v", err) + } + } + + // Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das + // sieht der Server, wenn der Bind-Mount nicht hochkam. + wurzel := t.TempDir() + st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp")) + if err != nil { + t.Fatalf("speicher: %v", err) + } + + scanner := New(pool, files.NewStore(pool), st) + bericht, err := scanner.Apply(ctx, false) + if err != nil { + t.Fatalf("anwenden: %v", err) + } + + if !bericht.Aborted { + t.Fatal("der Lauf haette abbrechen muessen") + } + if bericht.Removed != 0 { + t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed) + } + if bericht.Overridable { + t.Fatal("die Notbremse darf sich nicht bestaetigen lassen") + } + + var uebrig int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil { + t.Fatalf("zaehlen: %v", err) + } + if uebrig != 5 { + t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig) + } + + // Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen. + if _, err := scanner.Apply(ctx, true); err != nil { + t.Fatalf("anwenden mit Bestaetigung: %v", err) + } + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil { + t.Fatalf("zaehlen: %v", err) + } + if uebrig != 5 { + t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig) + } +} diff --git a/backend/internal/rescan/rescan.go b/backend/internal/rescan/rescan.go index 50d090c..878080c 100644 --- a/backend/internal/rescan/rescan.go +++ b/backend/internal/rescan/rescan.go @@ -22,6 +22,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" "aether/internal/files" "aether/internal/storage" @@ -66,6 +67,13 @@ type Report struct { Scanned int `json:"scanned"` Samples []Entry `json:"samples"` Aborted bool `json:"aborted,omitempty"` + // AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true" + // ohne Grund lässt genau die Frage offen, die man dann hat. + AbortReason string `json:"abort_reason,omitempty"` + // Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt + // sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen + // Knopf anbieten, der nichts bewirkt. + Overridable bool `json:"overridable,omitempty"` // Nur bei Apply gefüllt. Adopted int `json:"adopted,omitempty"` @@ -137,13 +145,16 @@ func splitKey(key string) (root, owner, folderPath, name string, ok bool) { } // Check liest nur und ändert nichts – jederzeit gefahrlos. -func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false) } +func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) } // Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste // Einträge. -func (s *Scanner) Apply(ctx context.Context) (*Report, error) { return s.run(ctx, true) } +// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse. +func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) { + return s.run(ctx, true, trotzdem) +} -func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) { +func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) { ctx, cancel := context.WithTimeout(ctx, maxDuration) defer cancel() @@ -217,7 +228,19 @@ func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) { // Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen // außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie // pauschal für verwaist zu erklären. + // **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in + // der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die + // Hälfte schon weg ist, und eine Bremse käme zu spät. + type waise struct { + key string + id uuid.UUID + } + var waisen []waise + var lebende int for key, row := range known { + if !row.deleted { + lebende++ + } if row.seen || row.deleted { continue } @@ -226,10 +249,34 @@ func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) { continue } } - rep.Orphaned++ - e := Entry{Kind: KindOrphaned, StorageKey: key, Path: key} + waisen = append(waisen, waise{key: key, id: row.id}) + } + rep.Orphaned = len(waisen) + + if apply { + if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" { + rep.Aborted = true + rep.AbortReason = grund + rep.Overridable = uebersteuerbar + log.Ctx(ctx).Error(). + Int("scanned", rep.Scanned). + Int("lebende_eintraege", lebende). + Int("wuerden_fallen", len(waisen)). + Str("grund", grund). + Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus") + // Die Waisen trotzdem in die Stichprobe, damit man im Bericht + // sieht, worum es gegangen wäre. + for _, o := range waisen { + sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}) + } + return rep, nil + } + } + + for _, o := range waisen { + e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key} if apply { - if err := s.remove(ctx, row.id); err != nil { + if err := s.remove(ctx, o.id); err != nil { e.Reason = err.Error() rep.Errors++ } else { @@ -241,3 +288,47 @@ func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) { return rep, nil } + +// ── Die Bremsen ────────────────────────────────────────────────────────────── + +// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr +// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist +// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner +// entfernt hat, bestätigt einmal und kommt durch. +const mengenAnteil = 2 + +// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe- +// wert heißt: ja. +// +// **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen +// Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben +// denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC +// löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt +// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die +// Datei gab. Die gefährlichere Seite war die ungesicherte. +// +// Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht +// vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau +// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam, +// oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede +// Datei als verwaist. +func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) { + // **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen, + // obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der + // verschwunden ist, das ist ein Speicher, der nicht da war. + if gescannt == 0 && lebende > 0 { + return "auf der Platte wurde keine einzige Datei gefunden, " + + "obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " + + "nicht eingehaengt. Es wurde nichts geloescht.", false + } + + // **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die + // Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor. + if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende { + return fmt.Sprintf( + "%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+ + "Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.", + wuerdenFallen, lebende), true + } + return "", false +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 06bbbb9..63f091d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -122,6 +122,10 @@ export interface RescanReport { scanned: number samples: RescanEntry[] aborted?: boolean + /** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */ + abort_reason?: string + /** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */ + overridable?: boolean /** Nur nach dem Anwenden gefüllt. */ adopted?: number removed?: number @@ -909,8 +913,10 @@ export const adminApi = { request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'), /** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */ - rescan: (apply = false) => - request('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}`), + /** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde + * fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */ + rescan: (apply = false, bestaetigt = false) => + request('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`), runCleanup: () => request<{ scanned: number; orphans: number; deleted: number diff --git a/frontend/src/pages/admin/RescanCard.tsx b/frontend/src/pages/admin/RescanCard.tsx index 3677208..cb5cf85 100644 --- a/frontend/src/pages/admin/RescanCard.tsx +++ b/frontend/src/pages/admin/RescanCard.tsx @@ -36,13 +36,16 @@ export default function RescanCard() { const pending = report ? report.new + report.orphaned + report.changed : 0 - async function run(apply: boolean) { + async function run(apply: boolean, bestaetigt = false) { setBusy(apply ? 'apply' : 'check') setError('') try { - const r = await adminApi.rescan(apply) + const r = await adminApi.rescan(apply, bestaetigt) setReport(r) - setApplied(apply) + // Ein abgebrochener Lauf hat **nichts** angewendet – also bleibt die + // Karte im Zustand „geprüft", sonst verschwände der Anwenden-Knopf und + // es sähe aus, als wäre etwas geschehen. + setApplied(apply && !r.aborted) } catch { setError(apply ? 'Applying failed' : 'Check failed') } finally { @@ -81,7 +84,7 @@ export default function RescanCard() { {report && !applied && pending > 0 && ( )} + {report?.aborted && report.abort_reason && ( +
+ Nothing was removed. {report.abort_reason} +
+ )} + {report && report.samples.length > 0 && (