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 ? ( + + ) : 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), + }; +}