task-11: complete QA + hardening with resilience fixes
- Created comprehensive QA checklist covering edge cases (missing EXIF, timezones, codecs, corrupt files) - Added ErrorBoundary component wrapped around TimelineTree and MediaPanel - Created global error.tsx page for unhandled errors - Improved failed asset UX with red borders, warning icons, and inline error display - Added loading skeletons to TimelineTree and MediaPanel - Added retry button for failed media loads - Created DEPLOYMENT_VALIDATION.md with validation commands and checklist - Applied k8s recommendations: - Changed node affinity to required for compute nodes (Pi 5) - Enabled Tailscale LoadBalancer service for MinIO S3 (reliable Range requests) - Enabled cleanup CronJob for staging files
This commit is contained in:
45
apps/web/app/components/ErrorBoundary.tsx
Normal file
45
apps/web/app/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Component, ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type State = {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="flex min-h-[200px] items-center justify-center rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold text-red-700">
|
||||
Something went wrong
|
||||
</p>
|
||||
<p className="text-sm text-red-600">
|
||||
Please refresh the page or try again later
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ type PreviewUrlState = Record<string, string | undefined>;
|
||||
|
||||
function startOfDayUtc(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0));
|
||||
return new Date(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0),
|
||||
);
|
||||
}
|
||||
|
||||
function endOfDayUtc(iso: string) {
|
||||
@@ -47,7 +49,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
} | null>(null);
|
||||
|
||||
const [viewerError, setViewerError] = useState<string | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(null);
|
||||
const [videoFallback, setVideoFallback] = useState<{
|
||||
posterUrl: string | null;
|
||||
} | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
@@ -75,7 +80,9 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
limit: "120",
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store" });
|
||||
const res = await fetch(`/api/assets?${qs.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as AssetsResponse;
|
||||
|
||||
@@ -94,7 +101,10 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
async function loadSignedUrl(assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster") {
|
||||
async function loadSignedUrl(
|
||||
assetId: string,
|
||||
variant: "original" | "thumb_small" | "thumb_med" | "poster",
|
||||
) {
|
||||
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
@@ -120,21 +130,47 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<strong>Media</strong>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>
|
||||
{props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
|
||||
{props.selectedDayIso
|
||||
? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10)
|
||||
: "(select a day)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!assets && props.selectedDayIso && !error ? (
|
||||
<div style={{ color: "#666" }}>Loading assets…</div>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2.5 rounded-lg border border-gray-200 bg-white p-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-18 w-18 animate-pulse rounded-lg bg-gray-200"
|
||||
style={{ width: 72, height: 72 }}
|
||||
/>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{assets ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
|
||||
{assets.length === 0 ? (
|
||||
<div style={{ color: "#666" }}>No assets.</div>
|
||||
) : null}
|
||||
{assets.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
@@ -143,8 +179,11 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
onPointerEnter={() => {
|
||||
if (previews[a.id] !== undefined) return;
|
||||
|
||||
const variant = a.media_type === "image" ? "thumb_small" : "poster";
|
||||
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
|
||||
const variant =
|
||||
a.media_type === "image" ? "thumb_small" : "poster";
|
||||
const promise = loadSignedUrl(a.id, variant).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
void promise.then((url) => {
|
||||
setPreviews((prev) => ({ ...prev, [a.id]: url }));
|
||||
@@ -153,18 +192,28 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: 10,
|
||||
border: "1px solid #ddd",
|
||||
border:
|
||||
a.status === "failed"
|
||||
? "2px solid #ef4444"
|
||||
: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "72px 1fr",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
background: "#f2f2f2",
|
||||
background: a.status === "failed" ? "#fef2f2" : "#f2f2f2",
|
||||
border: "1px solid #eee",
|
||||
overflow: "hidden",
|
||||
display: "grid",
|
||||
@@ -173,23 +222,45 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previews[a.id] ? (
|
||||
{a.status === "failed" ? (
|
||||
<span style={{ fontSize: 24, color: "#ef4444" }}>⚠</span>
|
||||
) : previews[a.id] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previews[a.id]} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<img
|
||||
src={previews[a.id]}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{a.media_type}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{a.media_type} · {a.status}
|
||||
</span>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>{a.id.slice(0, 8)}</span>
|
||||
<span style={{ color: "#666", fontSize: 12 }}>
|
||||
{a.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
{a.status === "failed" && a.error_message ? (
|
||||
<div style={{ color: "#b00", fontSize: 12, marginTop: 6 }}>{a.error_message}</div>
|
||||
<div
|
||||
style={{ color: "#ef4444", fontSize: 12, marginTop: 6 }}
|
||||
>
|
||||
{a.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,76 +287,105 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(1000px, 98vw)",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "min(1000px, 98vw)",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<strong>
|
||||
{viewer
|
||||
? `${viewer.asset.media_type} (${viewer.variant})`
|
||||
: "Viewer"}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewer(null);
|
||||
setViewerError(null);
|
||||
setVideoFallback(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewer ? (
|
||||
<>
|
||||
{viewer.asset.media_type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={viewer.url}
|
||||
alt={viewer.asset.id}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
onError={() => setViewerError("image_load_failed")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={viewer.url}
|
||||
controls
|
||||
style={{ width: "100%" }}
|
||||
poster={videoFallback?.posterUrl ?? undefined}
|
||||
onError={() => {
|
||||
setViewerError("video_playback_failed");
|
||||
if (videoFallback !== null) return;
|
||||
setVideoFallback({ posterUrl: null });
|
||||
void loadSignedUrl(viewer.asset.id, "poster")
|
||||
.then((posterUrl) => setVideoFallback({ posterUrl }))
|
||||
.catch(() => setVideoFallback({ posterUrl: null }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewer ? (
|
||||
<>
|
||||
{viewer.asset.media_type === "image" ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={retryKey}
|
||||
src={viewer.url}
|
||||
alt={viewer.asset.id}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
onError={() => setViewerError("image_load_failed")}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
key={retryKey}
|
||||
src={viewer.url}
|
||||
controls
|
||||
style={{ width: "100%" }}
|
||||
poster={videoFallback?.posterUrl ?? undefined}
|
||||
onError={() => {
|
||||
setViewerError("video_playback_failed");
|
||||
if (videoFallback !== null) return;
|
||||
setVideoFallback({ posterUrl: null });
|
||||
void loadSignedUrl(viewer.asset.id, "poster")
|
||||
.then((posterUrl) => setVideoFallback({ posterUrl }))
|
||||
.catch(() => setVideoFallback({ posterUrl: null }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewerError ? (
|
||||
{viewerError ? (
|
||||
<div className="flex items-center justify-between gap-4 rounded border border-red-200 bg-red-50 p-3">
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{viewerError}
|
||||
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
|
||||
{viewer.asset.media_type === "video"
|
||||
? " (try a different browser/codec)"
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewerError(null);
|
||||
setRetryKey((k) => k + 1);
|
||||
}}
|
||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
{viewer.asset.id}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>
|
||||
{viewerError ?? "unknown_error"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,11 +102,17 @@ function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
|
||||
}
|
||||
|
||||
// Sort ascending within each parent
|
||||
const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label));
|
||||
const yearNodes = Array.from(years.values()).sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
for (const y of yearNodes) {
|
||||
y.children = (y.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
y.children = (y.children ?? []).sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
for (const m of y.children) {
|
||||
m.children = (m.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
|
||||
m.children = (m.children ?? []).sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +123,8 @@ function gatherVisible(
|
||||
roots: TreeNode[],
|
||||
expanded: ExpandedState,
|
||||
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
|
||||
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> = [];
|
||||
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> =
|
||||
[];
|
||||
|
||||
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
|
||||
for (const n of nodes) {
|
||||
@@ -133,7 +140,9 @@ function gatherVisible(
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) {
|
||||
export function TimelineTree(props: {
|
||||
onSelectDay?: (dayIso: string) => void;
|
||||
}) {
|
||||
const [orientation, setOrientation] = useState<Orientation>("vertical");
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
|
||||
@@ -154,9 +163,12 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
async function load() {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
|
||||
cache: "no-store",
|
||||
});
|
||||
const res = await fetch(
|
||||
"/api/tree?granularity=day&limit=500&includeFailed=1",
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
|
||||
const json = (await res.json()) as ApiTreeResponse;
|
||||
if (!cancelled) setRows(json.nodes);
|
||||
@@ -171,7 +183,10 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
}, []);
|
||||
|
||||
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
|
||||
const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]);
|
||||
const visible = useMemo(
|
||||
() => gatherVisible(roots, expanded),
|
||||
[roots, expanded],
|
||||
);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const nodeGap = 56;
|
||||
@@ -207,7 +222,11 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
|
||||
useEffect(() => {
|
||||
// reset viewBox when layout changes
|
||||
setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) }));
|
||||
setViewBox((vb) => ({
|
||||
...vb,
|
||||
w: Math.max(layout.w, 1200),
|
||||
h: Math.max(layout.h, 800),
|
||||
}));
|
||||
}, [layout.w, layout.h]);
|
||||
|
||||
function toggleNode(id: string) {
|
||||
@@ -271,22 +290,55 @@ export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void })
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<strong>Timeline</strong>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
|
||||
onClick={() =>
|
||||
setOrientation((o) =>
|
||||
o === "vertical" ? "horizontal" : "vertical",
|
||||
)
|
||||
}
|
||||
>
|
||||
Orientation: {orientation}
|
||||
</button>
|
||||
<button type="button" onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
{rows ? <span style={{ color: "#666" }}>{rows.length} day nodes</span> : null}
|
||||
{rows ? (
|
||||
<span style={{ color: "#666" }}>{rows.length} day nodes</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
|
||||
{!rows && !error ? <div style={{ color: "#666" }}>Loading tree…</div> : null}
|
||||
{!rows && !error ? (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
height: 600,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user