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
@@ -121,6 +121,50 @@ Redeploy.
|
|||||||
`02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen,
|
`02-secret.yaml` einfach auf den externen Endpunkt zeigen lassen,
|
||||||
`03-postgres.yaml`/`03-mysql.yaml` dann nicht anwenden.
|
`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
|
## Sicherung und Wiederherstellung
|
||||||
|
|
||||||
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
|
`07-backup-cronjob.yaml` legt jede Nacht um 03:15 Uhr eine
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { checkUploadFiles } from "@/lib/upload-health";
|
||||||
|
|
||||||
const DASHBOARD_ICON_PROPS = {
|
const DASHBOARD_ICON_PROPS = {
|
||||||
width: 20,
|
width: 20,
|
||||||
@@ -224,9 +225,37 @@ export default async function AdminDashboardPage() {
|
|||||||
const revenueTrend = [...revenueByDay.entries()];
|
const revenueTrend = [...revenueByDay.entries()];
|
||||||
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
|
||||||
|
|
||||||
|
{/* 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 ? (
|
||||||
|
<div className="mb-8 border border-red-700/40 bg-red-50 p-4">
|
||||||
|
<p className="text-sm text-red-800">
|
||||||
|
{t.adminDashboardPage.missingUploadsTitle(uploadHealth.missing, uploadHealth.checked)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-red-800/80">
|
||||||
|
{t.adminDashboardPage.missingUploadsHint}
|
||||||
|
</p>
|
||||||
|
{uploadHealth.examples.length > 0 ? (
|
||||||
|
<ul className="mt-2 text-xs text-red-800/70">
|
||||||
|
{uploadHealth.examples.map((example) => (
|
||||||
|
<li key={example}>{example}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{stats.map((stat) => {
|
{stats.map((stat) => {
|
||||||
const Icon = statIcons[stat.key];
|
const Icon = statIcons[stat.key];
|
||||||
|
|||||||
@@ -1096,6 +1096,10 @@ export const de: Dictionary = {
|
|||||||
noOrdersYet: "Noch keine Bestellungen vorhanden.",
|
noOrdersYet: "Noch keine Bestellungen vorhanden.",
|
||||||
},
|
},
|
||||||
adminDashboardPage: {
|
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",
|
title: "Dashboard",
|
||||||
statRevenueToday: "Umsatz heute",
|
statRevenueToday: "Umsatz heute",
|
||||||
statRevenueMonth: "Umsatz Monat",
|
statRevenueMonth: "Umsatz Monat",
|
||||||
|
|||||||
@@ -1096,6 +1096,10 @@ export const en: Dictionary = {
|
|||||||
noOrdersYet: "No orders yet.",
|
noOrdersYet: "No orders yet.",
|
||||||
},
|
},
|
||||||
adminDashboardPage: {
|
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",
|
title: "Dashboard",
|
||||||
statRevenueToday: "Revenue today",
|
statRevenueToday: "Revenue today",
|
||||||
statRevenueMonth: "Revenue this month",
|
statRevenueMonth: "Revenue this month",
|
||||||
|
|||||||
@@ -797,6 +797,8 @@ export interface Dictionary {
|
|||||||
noOrdersYet: string;
|
noOrdersYet: string;
|
||||||
};
|
};
|
||||||
adminDashboardPage: {
|
adminDashboardPage: {
|
||||||
|
missingUploadsTitle: (missing: number, checked: number) => string;
|
||||||
|
missingUploadsHint: string;
|
||||||
title: string;
|
title: string;
|
||||||
statRevenueToday: string;
|
statRevenueToday: string;
|
||||||
statRevenueMonth: string;
|
statRevenueMonth: string;
|
||||||
|
|||||||
@@ -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