feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in a78c1cd behoben — das Volume "uploads" im
App-Deployment ist dort vom Platzhalter "emptyDir" auf einen
PersistentVolumeClaim umgestellt. Der Commit liegt auch auf origin/main.
Was fehlte, ist der Schritt danach: die Gitea-Pipeline baut nur das Image
und wendet KEINE Kubernetes-Manifeste an. Ein "kubectl rollout restart"
holt das neue Image, laesst die Deployment-Definition im Cluster aber
unveraendert — dort steht also weiterhin emptyDir. k8s/README.md bekommt
dafuer einen eigenen Abschnitt mit dem genauen Ablauf: erst pruefen, was
im Cluster tatsaechlich als Volume eingetragen ist, dann PVC und
Deployment anwenden, dann gegenpruefen.
Zusaetzlich das eigentliche Problem an diesem Ausfall: er ist unsichtbar.
Die Datenbank behaelt ihre Bildpfade, die Dateien dahinter fehlen, und im
Shop erscheinen einfach leere Flaechen — keine Fehlermeldung, kein
Log-Eintrag. Gemerkt hat man es erst, wenn jemand hingeschaut hat.
lib/upload-health.ts gleicht deshalb ab, ob die in Product/Harvest/Batch
und in den Inhaltsfeldern hinterlegten Pfade als Dateien vorliegen. Das
Admin-Dashboard zeigt bei Abweichung eine Warnung mit Anzahl und
Beispielpfaden samt Hinweis auf die Ursache. Der Abgleich laeuft bewusst
nach den Kennzahlen und mit eigenem catch: schlaegt er fehl, zeigt das
Dashboard trotzdem seine Zahlen. Obergrenze 300 gepruefte Pfade, damit die
Seite nicht an Dateisystemzugriffen haengt.
Bilder und Videos nutzen denselben UPLOAD_DIR (lib/uploads.ts), auch der
Video-Upload ueber den Umweg ueber ein Temp-Verzeichnis — es gibt also
keinen zweiten Speicherort, der getrennt behandelt werden muesste.
Gegen die lokale Datenbank geprueft: ohne Referenzen meldet der Abgleich
0/0, mit einem absichtlich auf eine nicht vorhandene Datei gesetzten
Produktbild 1 von 1 fehlend samt Pfad als Beleg, nach dem Zuruecksetzen
wieder 0/0.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
bdce9b6a0a
commit
c8ddcec04c
6 files changed
+182
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { UPLOAD_DIR } from "@/lib/uploads";
|
||||
|
||||
/**
|
||||
* Gleicht ab, ob die in der Datenbank hinterlegten Bild-/Videopfade auch
|
||||
* tatsaechlich als Dateien vorliegen.
|
||||
*
|
||||
* Hintergrund: Datenbank und Uploads liegen an getrennten Orten. Geht das
|
||||
* Upload-Verzeichnis verloren — etwa weil es in Kubernetes als "emptyDir"
|
||||
* statt als PersistentVolumeClaim gemountet ist und damit an die Lebensdauer
|
||||
* des Pods gebunden bleibt —, behaelt die Datenbank ihre Pfade, die Dateien
|
||||
* dahinter fehlen aber. Nach aussen sieht das nicht wie ein Fehler aus: es
|
||||
* erscheint schlicht ueberall ein kaputtes Bild, ohne Fehlermeldung und ohne
|
||||
* Eintrag im Log.
|
||||
*
|
||||
* Genau diese Luecke schliesst der Abgleich: das Admin-Dashboard zeigt den
|
||||
* Befund beim naechsten Aufruf an, statt dass er erst auffaellt, wenn
|
||||
* Kundschaft im Shop leere Produktkacheln sieht.
|
||||
*/
|
||||
|
||||
/** Obergrenze fuer die Dateipruefungen — das Dashboard soll nicht an ihnen haengen. */
|
||||
const MAX_CHECKED_PATHS = 300;
|
||||
|
||||
/** Anzahl der Beispielpfade im Befund, damit die Meldung nachpruefbar bleibt. */
|
||||
const EXAMPLE_COUNT = 3;
|
||||
|
||||
const UPLOAD_PATH_PREFIX = "/uploads/";
|
||||
|
||||
export interface UploadHealthReport {
|
||||
/** Wie viele verschiedene Upload-Pfade die Datenbank referenziert. */
|
||||
referenced: number;
|
||||
/** Wie viele davon geprueft wurden (durch MAX_CHECKED_PATHS begrenzt). */
|
||||
checked: number;
|
||||
/** Wie viele der geprueften Dateien fehlen. */
|
||||
missing: number;
|
||||
/** Einige fehlende Pfade als Beleg. */
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
/** Sammelt alle in Inhalten referenzierten Upload-Pfade, ohne Dubletten. */
|
||||
async function collectReferencedPaths(): Promise<string[]> {
|
||||
const [products, harvests, batches, content] = await Promise.all([
|
||||
prisma.product.findMany({ select: { images: true } }),
|
||||
prisma.harvest.findMany({ select: { images: true } }),
|
||||
prisma.batch.findMany({ select: { images: true } }),
|
||||
prisma.siteContent.findMany({ select: { valueDe: true, valueEn: true } }),
|
||||
]);
|
||||
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const row of [...products, ...harvests, ...batches]) {
|
||||
for (const image of asStringArray(row.images)) {
|
||||
if (image.startsWith(UPLOAD_PATH_PREFIX)) paths.add(image);
|
||||
}
|
||||
}
|
||||
|
||||
// Inhaltsfelder halten einen einzelnen Pfad als kompletten Wert (Bild- und
|
||||
// Videofelder der Content-Registry) — alles andere ist Fliesstext und wird
|
||||
// hier nicht durchsucht.
|
||||
for (const row of content) {
|
||||
for (const value of [row.valueDe, row.valueEn]) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed?.startsWith(UPLOAD_PATH_PREFIX)) paths.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
export async function checkUploadFiles(): Promise<UploadHealthReport> {
|
||||
const referencedPaths = await collectReferencedPaths();
|
||||
const toCheck = referencedPaths.slice(0, MAX_CHECKED_PATHS);
|
||||
|
||||
const results = await Promise.all(
|
||||
toCheck.map(async (uploadPath) => {
|
||||
// Der Pfad in der Datenbank ist die oeffentliche URL ("/uploads/<datei>"),
|
||||
// auf der Platte liegt die Datei direkt unter UPLOAD_DIR.
|
||||
const fileName = uploadPath.slice(UPLOAD_PATH_PREFIX.length);
|
||||
try {
|
||||
await access(path.join(UPLOAD_DIR, fileName));
|
||||
return null;
|
||||
} catch {
|
||||
return uploadPath;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const missing = results.filter((result): result is string => result !== null);
|
||||
|
||||
return {
|
||||
referenced: referencedPaths.length,
|
||||
checked: toCheck.length,
|
||||
missing: missing.length,
|
||||
examples: missing.slice(0, EXAMPLE_COUNT),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user