Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m10s
Live beobachtet: der Upload scheitert mit HTTP 502, nicht mit 504. Das ist ein wesentlicher Unterschied. 502 bedeutet, dass der vorgeschaltete Proxy vom Backend gar keine Antwort bekam — der Prozess hat die Verbindung mitten im Request fallen gelassen. Eine Zeitueberschreitung waere 504. Die bisher verfolgte Timeout-Spur war damit falsch. Uebrig blieb als Erklaerung der Arbeitsspeicher, und dort lag noch die eigentliche Ursache: request.formData() liest den GESAMTEN Body in den Speicher, bevor eine einzige Zeile eigener Code laeuft. Der vorherige Streaming-Umbau hat nur die zusaetzlichen Kopien danach beseitigt (arrayBuffer, Buffer, readFile) — diese erste nicht. Bei einem Video bis 100 MB genuegt das, um zusammen mit dem Grundverbrauch des Servers das Speicherlimit des Pods zu sprengen; der Kernel beendet den Prozess (OOMKilled), und der Proxy meldet 502. Die Datei kommt jetzt als roher Request-Body statt als multipart/form-data. Damit laesst sich request.body direkt auf die Platte durchreichen, und der Speicherbedarf haengt nicht mehr von der Dateigroesse ab. Ein Formularfeld wurde ohnehin nicht gebraucht, es wird genau eine Datei uebertragen. Die Groessengrenze prueft jetzt ein mitzaehlender Transform-Stream, weil Content-Length nur eine Ankuendigung des Clients ist. Gemessen im Produktions-Build mit einem 95-MB-Video: Status 200 nach 5,9 Sekunden, Arbeitsspeicher des Servers von 67 MB auf in der Spitze 149 MB — also rund ein Drittel des Limits von 512Mi, mit dem der Pod bisher lief. Ausserdem berichtigt: der eigene Meldungstext behauptete bei 502 "fast immer eine Zeitueberschreitung". Das ist falsch und hat die Fehlersuche in die falsche Richtung gelenkt. 502 und 504 werden jetzt getrennt erklaert — 502 als weggebrochenes Backend (meist Speichermangel), 504 als echtes Zeitlimit.
119 lines
4.6 KiB
TypeScript
119 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, type ChangeEvent } from "react";
|
|
import { useI18n } from "@/i18n/i18n-provider";
|
|
import { readUploadResponse } from "@/lib/upload-client";
|
|
|
|
interface AdminVideoFieldProps {
|
|
/** Formularfeld-Name, unter dem der aktuelle Videopfad beim Speichern des
|
|
* umgebenden <form> mitgeschickt wird (siehe admin/actions/content.ts). */
|
|
name: string;
|
|
label: string;
|
|
initialValue: string;
|
|
}
|
|
|
|
/**
|
|
* Video-Upload-Feld fuer admin/inhalte (Content-Registry-Feldtyp "video",
|
|
* aktuell nur home.hero.video). Analog zu AdminImageField: laedt die Datei
|
|
* sofort per fetch() an app/api/admin/upload-video hoch und haelt den
|
|
* zurueckgegebenen Pfad in einem versteckten Formularfeld — das eigentliche
|
|
* Speichern passiert weiterhin ueber das umgebende
|
|
* <form action={updateSiteContentAction}>.
|
|
*/
|
|
export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldProps) {
|
|
const { t } = useI18n();
|
|
const [value, setValue] = useState(initialValue);
|
|
const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
setStatus("uploading");
|
|
setErrorMessage("");
|
|
try {
|
|
let response: Response;
|
|
try {
|
|
// Die Datei wird als ROHER Body gesendet, nicht als FormData: auf der
|
|
// Serverseite liest request.formData() den gesamten Body erst
|
|
// vollstaendig in den Arbeitsspeicher, was bei einem Video bis 100 MB
|
|
// das Speicherlimit des Pods sprengte (ausfuehrliche Begruendung in
|
|
// app/api/admin/upload-video/route.ts). Als roher Body laesst er sich
|
|
// dort direkt auf die Platte durchreichen. fetch() streamt ein
|
|
// File-Objekt, es landet also auch hier nie am Stueck im Speicher.
|
|
response = await fetch("/api/admin/upload-video", {
|
|
method: "POST",
|
|
headers: { "content-type": file.type || "application/octet-stream" },
|
|
body: file,
|
|
});
|
|
} catch {
|
|
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
|
|
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
|
|
// hier, sondern wird unten ausgewertet.
|
|
throw new Error(t.adminVideoField.uploadErrorNetwork);
|
|
}
|
|
const result = await readUploadResponse(response);
|
|
if (!result.ok) {
|
|
throw new Error(
|
|
result.kind === "message" ? result.message : t.adminVideoField.uploadErrorStatus(result.status)
|
|
);
|
|
}
|
|
setValue(result.path);
|
|
setStatus("idle");
|
|
} catch (error: unknown) {
|
|
setStatus("error");
|
|
setErrorMessage(error instanceof Error ? error.message : t.adminVideoField.uploadError);
|
|
} finally {
|
|
if (inputRef.current) inputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<span className="text-sm text-ink/70">{label}</span>
|
|
<input type="hidden" name={name} value={value} />
|
|
<div className="mt-2 flex items-center gap-4">
|
|
{value ? (
|
|
<video src={value} className="h-20 w-32 object-cover border border-ink/10" muted loop autoPlay playsInline />
|
|
) : (
|
|
<div className="h-20 w-32 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1">
|
|
{t.adminVideoField.none}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="video/mp4,video/webm"
|
|
onChange={handleFileChange}
|
|
disabled={status === "uploading"}
|
|
className="hidden"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => inputRef.current?.click()}
|
|
disabled={status === "uploading"}
|
|
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
|
>
|
|
{t.adminVideoField.chooseFile}
|
|
</button>
|
|
{status === "uploading" ? (
|
|
<p className="mt-1 text-xs text-ink/70">{t.adminVideoField.uploading}</p>
|
|
) : null}
|
|
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
|
{value ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setValue("")}
|
|
className="mt-1 text-xs text-ink/70 underline"
|
|
>
|
|
{t.adminVideoField.remove}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|