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:
OpenCode Test
2025-12-24 12:45:22 -08:00
parent 232b4f2488
commit 4e2ab7cdd8
13 changed files with 1444 additions and 131 deletions

View 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;
}
}

View File

@@ -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>
);
}

View File

@@ -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={{