Object storage test
Deploy / deploy (push) Successful in 2m40s

This commit is contained in:
twotalesanimation
2026-07-16 21:58:06 +02:00
parent 9ad878fb56
commit 3264abb432
3 changed files with 244 additions and 0 deletions
@@ -0,0 +1,177 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Upload, Trash2, CheckCircle2, AlertCircle, ExternalLink } from "lucide-react";
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
interface TestResult {
key: string;
streamUrl: string;
filename: string;
fileSize: number;
uploadMs: number;
expiresAt: string;
}
export default function StorageTestPage() {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResult(null);
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/storage-test", {
method: "POST",
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Upload failed");
setResult(data);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!result) return;
setDeleting(true);
try {
const res = await fetch(`/api/storage-test?key=${encodeURIComponent(result.key)}`, {
method: "DELETE",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Delete failed");
}
setResult(null);
setFile(null);
if (inputRef.current) inputRef.current.value = "";
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Delete failed");
} finally {
setDeleting(false);
}
};
return (
<div className="p-6 max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-semibold">Object Storage Playback Test</h1>
<p className="text-sm text-muted-foreground mt-1">
Upload a video to Hetzner object storage and play it back directly. Use this to compare
streaming performance against the local file server.
</p>
</div>
{/* Upload */}
<div className="rounded-lg border border-border p-4 space-y-3">
<div
className="border-2 border-dashed border-border rounded-md p-8 text-center cursor-pointer hover:border-primary/40 transition-colors"
onClick={() => inputRef.current?.click()}
>
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
{file ? (
<p className="text-sm font-medium">
{file.name}{" "}
<span className="text-muted-foreground">({formatBytes(file.size)})</span>
</p>
) : (
<p className="text-sm text-muted-foreground">Click to select a video file</p>
)}
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
setFile(e.target.files?.[0] ?? null);
setResult(null);
setError(null);
}}
/>
</div>
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full">
{uploading ? "Uploading to Hetzner…" : "Upload & Test Playback"}
</Button>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-400 rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Result */}
{result && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-emerald-400 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-2">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span>
Uploaded in <strong>{result.uploadMs}ms</strong> · {formatBytes(result.fileSize)} ·
URL expires at {new Date(result.expiresAt).toLocaleTimeString()}
</span>
</div>
{/* Player */}
<div className="rounded-lg overflow-hidden bg-black border border-border aspect-video">
<video
key={result.streamUrl}
src={result.streamUrl}
controls
className="w-full h-full"
preload="metadata"
/>
</div>
{/* URL + cleanup */}
<div className="flex items-start gap-2">
<code className="flex-1 text-xs text-muted-foreground bg-muted rounded px-2 py-1.5 break-all select-all">
{result.streamUrl}
</code>
<a
href={result.streamUrl}
target="_blank"
rel="noopener noreferrer"
title="Open URL directly"
className="shrink-0 p-1.5 rounded border border-border hover:bg-muted transition-colors"
>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-red-400 border-red-500/30 hover:bg-red-500/10"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? "Deleting…" : "Delete test file from Hetzner"}
</Button>
</div>
)}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadToHetzner, deleteFromHetzner, generateHetznerStreamUrl } from "@/lib/storage";
export const maxDuration = 120;
/** POST /api/storage-test — upload a file to Hetzner and return a playback URL */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const uploadStart = Date.now();
const { key } = await uploadToHetzner(buffer, file.name, file.type, "storage-test");
const uploadMs = Date.now() - uploadStart;
const streamUrl = await generateHetznerStreamUrl(key, 3600);
return NextResponse.json({
key,
streamUrl,
filename: file.name,
fileSize: file.size,
uploadMs,
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
});
}
/** DELETE /api/storage-test?key=storage-test/... — clean up a test file */
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const key = searchParams.get("key");
// Restrict deletions to the storage-test/ folder only
if (!key || !key.startsWith("storage-test/")) {
return NextResponse.json({ error: "Invalid key — only storage-test/ files can be deleted here" }, { status: 400 });
}
await deleteFromHetzner(key);
return NextResponse.json({ success: true });
}