diff --git a/k8s/README.md b/k8s/README.md
index de716a6..e253a5a 100644
--- a/k8s/README.md
+++ b/k8s/README.md
@@ -121,6 +121,50 @@ Redeploy.
`02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen,
`03-postgres.yaml`/`03-mysql.yaml` dann nicht anwenden.
+## Bilder/Videos verschwinden nach jedem Redeploy
+
+Symptom: nach einem Redeploy sind alle hochgeladenen Produkt-, Ernten- und
+Inhaltsbilder weg und muessen neu hochgeladen werden.
+
+Ursache: das Volume `uploads` im App-Deployment ist noch ein `emptyDir`.
+Das ist an die Lebensdauer des Pods gebunden — jeder Neustart loescht es.
+Die Datenbank behaelt dabei ihre Bildpfade, die Dateien dahinter sind aber
+fort; im Shop erscheinen deshalb leere Flaechen statt einer Fehlermeldung.
+
+Wichtig: Die Gitea-Pipeline baut nur das Image. Sie wendet **keine**
+Kubernetes-Manifeste an. Ein `kubectl rollout restart` holt zwar das neue
+Image, laesst die Deployment-Definition im Cluster aber unveraendert — die
+Korrektur unten muss deshalb einmal von Hand angewendet werden:
+
+```bash
+# 1. Aktuellen Zustand pruefen — steht hier "emptyDir", ist genau das die Ursache:
+kubectl get deployment wabenhain-app -n wabenhain -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
+"}'
+
+# 2. Dauerhaften Speicher anlegen und Deployment aktualisieren:
+kubectl apply -f k8s/04-uploads-pvc.yaml
+kubectl apply -f k8s/05-app-deployment.yaml
+
+# 3. Gegenprobe — jetzt muss dort ein persistentVolumeClaim stehen:
+kubectl get deployment wabenhain-app -n wabenhain -o jsonpath='{.spec.template.spec.volumes[?(@.name=="uploads")]}{"
+"}'
+kubectl get pvc wabenhain-uploads -n wabenhain
+```
+
+Danach ueberstehen Uploads jeden Redeploy. Bereits verlorene Dateien lassen
+sich nicht wiederherstellen — sie muessen einmalig neu hochgeladen werden.
+
+Das Deployment nutzt dabei `strategy: Recreate` (siehe Abschnitt oben):
+ohne das bliebe der neue Pod beim Redeploy an einem
+`Multi-Attach error for volume` haengen, weil ein
+`ReadWriteOnce`-Claim nur an einen Pod gebunden sein kann.
+
+**Kontrolle im laufenden Betrieb:** Das Admin-Dashboard gleicht bei jedem
+Aufruf ab, ob die in der Datenbank hinterlegten Bild-/Videopfade als Dateien
+vorliegen, und zeigt sonst eine Warnung mit Anzahl und Beispielpfaden
+(`lib/upload-health.ts`). Dieser Fall faellt damit sofort auf, statt erst
+dann, wenn Kundschaft im Shop leere Flaechen sieht.
+
## Sicherung und Wiederherstellung
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index f9249ed..92bb541 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
+import { checkUploadFiles } from "@/lib/upload-health";
const DASHBOARD_ICON_PROPS = {
width: 20,
@@ -224,9 +225,37 @@ export default async function AdminDashboardPage() {
const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
+ // Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all:
+ // schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar
+ // nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen.
+ const uploadHealth = await checkUploadFiles().catch(() => null);
+
return (
{t.adminDashboardPage.title}
+
+ {/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
+ fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
+ ein "emptyDir" statt eines PersistentVolumeClaim). Ohne diesen
+ Hinweis faellt das erst auf, wenn Kundschaft im Shop leere
+ Produktkacheln sieht — im Log steht nichts. */}
+ {uploadHealth && uploadHealth.missing > 0 ? (
+
+
+ {t.adminDashboardPage.missingUploadsTitle(uploadHealth.missing, uploadHealth.checked)}
+
+
+ {t.adminDashboardPage.missingUploadsHint}
+
+ {uploadHealth.examples.length > 0 ? (
+
+ {uploadHealth.examples.map((example) => (
+ - {example}
+ ))}
+
+ ) : null}
+
+ ) : null}
{stats.map((stat) => {
const Icon = statIcons[stat.key];
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index f9f5146..52de3f8 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -1096,6 +1096,10 @@ export const de: Dictionary = {
noOrdersYet: "Noch keine Bestellungen vorhanden.",
},
adminDashboardPage: {
+ missingUploadsTitle: (missing: number, checked: number) =>
+ `${missing} von ${checked} hinterlegten Bild-/Videodateien fehlen auf dem Server.`,
+ missingUploadsHint:
+ "Die Datenbank kennt die Pfade noch, die Dateien selbst sind weg — im Shop erscheinen dort leere Flächen. Fast immer liegt das an einem nicht dauerhaften Upload-Verzeichnis: In Kubernetes muss das Volume \"uploads\" ein PersistentVolumeClaim sein (k8s/04-uploads-pvc.yaml), kein emptyDir, sonst geht es bei jedem Redeploy verloren. Nach dem Umstellen müssen die betroffenen Dateien einmalig neu hochgeladen werden.",
title: "Dashboard",
statRevenueToday: "Umsatz heute",
statRevenueMonth: "Umsatz Monat",
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index bd6df2d..908ce32 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -1096,6 +1096,10 @@ export const en: Dictionary = {
noOrdersYet: "No orders yet.",
},
adminDashboardPage: {
+ missingUploadsTitle: (missing: number, checked: number) =>
+ `${missing} of ${checked} referenced image/video files are missing on the server.`,
+ missingUploadsHint:
+ "The database still holds the paths, but the files themselves are gone — the shop shows empty areas there. This is almost always a non-persistent upload directory: in Kubernetes the \"uploads\" volume must be a PersistentVolumeClaim (k8s/04-uploads-pvc.yaml), not an emptyDir, otherwise it is lost on every redeploy. After fixing it, the affected files have to be uploaded once more.",
title: "Dashboard",
statRevenueToday: "Revenue today",
statRevenueMonth: "Revenue this month",
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index 0b0b788..5d3d4d0 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -797,6 +797,8 @@ export interface Dictionary {
noOrdersYet: string;
};
adminDashboardPage: {
+ missingUploadsTitle: (missing: number, checked: number) => string;
+ missingUploadsHint: string;
title: string;
statRevenueToday: string;
statRevenueMonth: string;
diff --git a/src/lib/upload-health.ts b/src/lib/upload-health.ts
new file mode 100644
index 0000000..d6c5ee7
--- /dev/null
+++ b/src/lib/upload-health.ts
@@ -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 {
+ 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();
+
+ 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 {
+ 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/"),
+ // 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),
+ };
+}