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

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