jim800121chen b10fbb8091 feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
影片分頁上傳從舊 tunnel 路徑(/api/media/upload/video + 90MB)切到同機
localhost 直連(取 token → resolveLocalAgent → /api/local/media/upload/video)。
端到端啟用 ADR-019,取代 90MB 過渡限制。

- 新 lib/local-media.ts 編排層:getLocalUploadTicket + uploadVideoViaLocalAgent
- MAX_LOCAL_VIDEO_BYTES=500MB + validateLocalVideoFile(只綁 localhost 路徑;
  舊 MAX_VIDEO_BYTES=90MB + tunnel uploadVideo 完全不碰,向下相容)
- R-3 tunnel 離線三層防護:UI disable 不渲染 uploader + ticket 502 + 錯誤映射
- 5 種錯誤 i18n(NOT_FOUND/MISMATCH/離線/401/413)+ AbortError 靜默

reviewer 通過(0C/0M)。tsc/eslint/build 0 error、WP-4 相關 70 test pass。
⚠️ 實機驗證(真序號 hash 同形 fail-closed / PNA / 500MB 大檔實傳 / 混合路徑
結果面)待 stage 部署後驗證。Minor M-1/M-2 留 WP-6 一併處理。

Refs: ADR-019 WP-4。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:23:21 +08:00

478 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 推論工作區 Client — /workspace/[deviceId]
*
* 對齊 pages.md §8.4、flow-offline-handling.md §6。
*
* 塊 1 範圍Camera MJPEG 串流顯示 + 開始/停止狀態機):
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕
* - TabsCamera / Image / Video / BatchCamera 已接真串流,其餘塊 3 補)
* - Camera tab 內CameraFeed 顯示 MJPEG streamstart 成功後)
* - start/stop 契約修正:改打 `/api/camera/start` + `/api/camera/stop`(見評估 §3.1 R-C1
* - 裝置掉線remoteStatus != online顯示全頁遮罩 + 返回按鈕;掉線時自動清串流
*
* 塊 1 不做(後續塊補):
* - InferencePanel 的 classification result / performance metrics塊 2 接 WS
* - canvas overlay 邊界框繪製(塊 2
* - Image / Video / Batch tab 上傳與顯示(塊 3
* - Camera 來源選擇SourceSelector
*
* 重要:雲端版 Workspace 對「裝置離線」極敏感 —
* 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。
*
* ADR-018 serial 路由WP-C
* - 路由到 local agent 的操作camera start/stop、media upload、inference WS
* 識別值一律帶 `device.serialNumber`kn_number不是雲端 UUID。
* - 路由段 `[deviceId]` 仍是雲端 UUIDfetchDevice 是純 DB 操作、FE-A 混合模型),
* serial 從 fetch 回來的 selectedDevice 取。
* - serial 為空(舊資料未回報 / 假序號)→ 無法路由:顯示提示 banner、
* 「開始推論」disable、media tab 顯示提示不給上傳。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowLeft, WifiOff } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { CameraFeed } from "@/components/workspace/camera-feed";
import { CameraOverlay } from "@/components/workspace/camera-overlay";
import { InferencePanel } from "@/components/workspace/inference-panel";
import { MediaTab } from "@/components/workspace/media-tab";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInferenceStream } from "@/hooks/use-inference-stream";
import { api, ApiError } from "@/lib/api";
import { buildStreamUrl } from "@/lib/camera";
import {
IMAGE_ACCEPT,
VIDEO_ACCEPT,
uploadBatchImages,
uploadImage,
validateBatchFiles,
validateImageFile,
validateLocalVideoFile,
} from "@/lib/media";
import {
LocalMediaError,
uploadVideoViaLocalAgent,
} from "@/lib/local-media";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
import { useInferenceStore } from "@/stores/inference-store";
import type { MediaUploadResponse } from "@/types/camera";
import { toast } from "sonner";
/**
* 影片上傳 timeout0 = 不限。
*
* ADR-019 WP-4影片改走 localhost 直連loopback0.12.5s),大檔不再經雲端 tunnel
* 理論上很快但保留不限0避免慢速磁碟 / 大檔≤500MB時前端搶先 timeout。
*/
const VIDEO_UPLOAD_TIMEOUT_MS = 0;
interface WorkspaceClientProps {
deviceId: string;
}
export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
const t = useT();
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
const isLoading = useDeviceStore((s) => s.isLoading);
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
const [isRunning, setIsRunning] = useState(false);
const [busy, setBusy] = useState(false);
// 目前選中的 tabcamera / image / video / batch
// 用於:離開 camera tab 時停掉 camera 串流 + WS避免與 media tab 的 WS 訂閱同時寫入同一個 store。
const [activeTab, setActiveTab] = useState("camera");
// 塊 1Camera MJPEG 串流 URLstart 成功後由後端回傳的 streamUrl 組出)
const [streamUrl, setStreamUrl] = useState("");
// 塊 2CameraFeed `<img>` 實際 render 尺寸(供 overlay 換算 normalized bbox → px
// review M1初值為 null尚未從 ResizeObserver 拿到真實顯示尺寸。overlay 在拿到
// 真實尺寸前不繪製避免用寫死猜測640×480換算 → MJPEG 非 4:3 時 bbox 框錯位。
const [feedSize, setFeedSize] = useState<{ w: number; h: number } | null>(null);
const resetInference = useInferenceStore((s) => s.reset);
// handleStop 最新引用handleTabChange 用;見下方 handleStop 定義後賦值)
const handleStopRef = useRef<() => Promise<void>>(async () => {});
useEffect(() => {
if (deviceId) void fetchDevice(deviceId);
}, [deviceId, fetchDevice]);
/** 停止串流的共用清理stop / unmount 都用)。 */
const clearStream = useCallback(() => {
setStreamUrl("");
setIsRunning(false);
setFeedSize(null); // 重置尺寸,下次 start 重新等 ResizeObserver 回報真實值
resetInference();
}, [resetInference]);
const handleDimensionsChange = useCallback((w: number, h: number) => {
setFeedSize({ w, h });
}, []);
// 塊 2裝置線上性hooks 必須無條件呼叫,故在此處先算,早退前)
const isOnline = selectedDevice?.remoteStatus === "online";
// WP-CADR-018路由到 local agent 的操作一律用 serial 當識別值。
// serial 為空(舊資料 / 未回報序號)→ 操作全部 disable無法路由
const serialNumber = selectedDevice?.serialNumber ?? null;
const hasSerial = !!serialNumber;
// ADR-019 WP-4影片 localhost 直連上傳失敗 → 依 error.code 對應 friendly i18n。
// MediaUploader 以 thrown error 的 message 顯示,故這裡把 code 轉成已本地化的字串再拋。
const localVideoErrorMessage = useCallback(
(err: unknown): string => {
const code =
err instanceof LocalMediaError || err instanceof ApiError
? err.code
: undefined;
switch (code) {
case "LOCAL_AGENT_NOT_FOUND":
return t("workspace.media.local.notFound");
case "LOCAL_AGENT_MISMATCH":
return t("workspace.media.local.mismatch");
case "LOCAL_TOKEN_INVALID":
return t("workspace.media.local.tokenInvalid");
case "LOCAL_UPLOAD_TOO_LARGE":
return t("workspace.media.local.tooLarge");
// tunnel 離線時取 token 會失敗502理論上 UI 已先 disable見 video tab
// uploadDisabled此為防禦性 fallback。
case "TUNNEL_DISCONNECTED":
case "TUNNEL_ERROR":
return t("workspace.media.local.offline");
default:
// 其他network / timeout / 未預期)→ 保留原始訊息(含 abort 由 uploader 自行忽略)
return err instanceof Error ? err.message : String(err);
}
},
[t],
);
// 塊 3切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store
const handleTabChange = useCallback(
(value: string) => {
setActiveTab(value);
if (value !== "camera" && isRunning) {
void handleStopRef.current();
}
},
[isRunning],
);
// 塊 2/3訂閱 camera WS `inference:<serial>`(只在 camera tab + 推論中 + 線上 + 有 serial 時連線)。
// media tab 有自己的 useInferenceStream在 MediaTab 內),兩者靠 activeTab 互斥。
// WP-CWS 識別值用 serial與 camera start 的 body deviceId 一致room key 才對得上)。
useInferenceStream(
serialNumber ?? "",
activeTab === "camera" && isRunning && isOnline && hasSerial,
);
// 塊 2最新結果的偵測框供 overlay 繪製)
const liveResult = useInferenceStore((s) => s.result);
const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold);
async function handleStart() {
// WP-Cserial 為空無法路由(按鈕已 disable此為防禦性 guard
if (!serialNumber) return;
setBusy(true);
try {
// 契約修正(評估 §3.1 R-C1camera pipeline 入口為 POST /api/camera/start
// body 帶 deviceId後端回傳 { streamUrl, sourceType }camera_handler.go:119-125
// WP-CADR-018deviceId 欄位帶 serial欄位名不動、值換 serialNumber
const data = await api.post<MediaUploadResponse>("/api/camera/start", {
deviceId: serialNumber,
});
const url = data?.streamUrl ?? "/api/camera/stream";
// cache-bust每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線
setStreamUrl(buildStreamUrl(url, `${serialNumber}-${Date.now()}`));
setIsRunning(true);
} catch (err) {
// 後端尚未接上 proxy 時可能 501不當致命錯誤僅提示
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
toast.info(t("workspace.camera.startNotWired"));
} else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
}
async function handleStop() {
setBusy(true);
try {
// WP-CADR-018與 start 一致deviceId 欄位帶 serial。
// serial 為空時不可能已 starthandleStart 有 guard直接清本地串流即可。
if (serialNumber) {
await api.post("/api/camera/stop", { deviceId: serialNumber });
}
} catch (err) {
if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
clearStream();
setBusy(false);
}
}
// handleTabChange 需要在切離 camera tab 時呼叫 handleStop用 ref 保持最新引用,
// 避免 handleTabChange 的 deps 依賴每次 render 重建的 handleStop。
// 於 effect 內同步 ref不在 render 期寫 ref符合 react-hooks/refs 規則)。
useEffect(() => {
handleStopRef.current = handleStop;
});
if (isLoading && !selectedDevice) {
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[60vh] rounded-lg" />
</div>
);
}
// 裝置尚未載入雛形selectedDevice 可能為 null— 簡易占位
const device = selectedDevice;
// 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 <img> 卡在已死的 MJPEG 連線)。
// 用 render 期衍生而非 effect + setState避免 cascading render。
const effectiveStreamUrl = isOnline ? streamUrl : "";
// WP-Cmedia tab 在 serial 為空時的占位提示(無法上傳——上傳的識別值就是 serial
const noSerialNotice = (
<Card className="min-h-[60vh]">
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
<div className="max-w-md space-y-2 text-center" data-testid="media-no-serial">
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
<p className="text-muted-foreground text-sm">
{t("workspace.noSerial.description")}
</p>
</div>
</CardContent>
</Card>
);
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href={device ? `/devices/${device.id}` : "/devices"}>
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("workspace.header.backToDevices")}
</Button>
</Link>
<h1 className="text-xl font-bold">
{t("workspace.header.title")} · {device?.alias || device?.name || deviceId}
</h1>
{device && (
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
)}
</div>
<div className="flex gap-2">
{isRunning ? (
<Button variant="destructive" onClick={handleStop} disabled={busy}>
{t("workspace.inference.stop")}
</Button>
) : (
// WP-Cserial 為空無法路由 → disable原因見下方 no-serial banner
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
{t("workspace.inference.start")}
</Button>
)}
</div>
</div>
{/* WP-CADR-018serial 為空 → 無法路由到 local agent說明原因與解法 */}
{device && !hasSerial && (
<div
role="alert"
className="bg-warning-subtle border-warning text-warning-foreground flex items-start gap-3 rounded-lg border p-4"
data-testid="workspace-no-serial-banner"
>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
<p className="text-xs opacity-90">
{t("workspace.noSerial.description")}
</p>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
<TabsTrigger value="image">{t("workspace.tabs.image")}</TabsTrigger>
<TabsTrigger value="video">{t("workspace.tabs.video")}</TabsTrigger>
<TabsTrigger value="batch">{t("workspace.tabs.batch")}</TabsTrigger>
</TabsList>
<TabsContent value="camera" className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
<Card className="min-h-[60vh]">
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
{effectiveStreamUrl ? (
<CameraFeed
streamUrl={effectiveStreamUrl}
sourceType="camera"
width={640}
onDimensionsChange={handleDimensionsChange}
overlay={
// M1feedSize 未就緒ResizeObserver 尚未回報真實顯示尺寸)前不繪製 overlay
// 避免用寫死猜測換算 bbox → 框錯位。
feedSize ? (
<CameraOverlay
detections={liveResult?.detections ?? []}
width={feedSize.w}
height={feedSize.h}
confidenceThreshold={confidenceThreshold}
/>
) : undefined
}
/>
) : (
<div className="text-center">
<p className="text-muted-foreground text-sm">
{t("workspace.camera.idleHint")}
</p>
</div>
)}
</CardContent>
</Card>
<Card className="min-h-[60vh]">
<CardHeader>
<CardTitle className="text-base">
{t("workspace.inference.panelTitle")}
</CardTitle>
</CardHeader>
<CardContent>
{/* 塊 2接 WS `inference:<deviceId>` → 效能指標 + 結果清單 */}
<InferencePanel isRunning={isRunning && isOnline} />
</CardContent>
</Card>
</div>
</TabsContent>
{/* 塊 3圖片 / 影片 / 批次 — 共用 MediaTab上傳 → CameraFeed + overlay + panel
WP-CADR-018上傳識別值帶 serialserial 為空 → 顯示提示、不給上傳。 */}
<TabsContent value="image" className="space-y-4">
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="image"
accept={IMAGE_ACCEPT}
validate={(files) =>
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
return uploadImage(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
)}
</TabsContent>
{/* 影片分頁ADR-019 WP-4 走 localhost 直連 local-agent非 tunnel
- validate 用 500MB 上限localhost 硬牆,見 media.ts validateLocalVideoFile
- R-3tunnel 離線(!isOnline→ 停用上傳 + 明確提示(不做 tunnel fallback
- 上傳失敗 → 依 error.code 對應 friendly i18nlocalVideoErrorMessage */}
<TabsContent value="video" className="space-y-4">
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="video"
accept={VIDEO_ACCEPT}
uploadDisabled={!isOnline}
uploadDisabledMessage={t("workspace.media.local.offline")}
validate={(files) =>
files[0] ? validateLocalVideoFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
try {
return await uploadVideoViaLocalAgent(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
});
} catch (err) {
// 使用者取消AbortErrorapi.ts code=ABORTED / name=AbortError→ 原樣拋回,
// 讓 MediaUploader 依 signal.aborted 靜默回 idle不顯示錯誤
if (err instanceof Error && err.name === "AbortError") throw err;
// 其餘NOT_FOUND / MISMATCH / token / 413 / network …)轉 friendly i18n
throw new Error(localVideoErrorMessage(err));
}
}}
/>
)}
</TabsContent>
<TabsContent value="batch" className="space-y-4">
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="batch_image"
accept={IMAGE_ACCEPT}
multiple
validate={validateBatchFiles}
upload={async (files, ctx) => {
return uploadBatchImages(serialNumber!, files, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
)}
</TabsContent>
</Tabs>
{/* 裝置掉線遮罩flow-offline-handling §6.2 */}
{device && !isOnline && (
<div
role="alertdialog"
aria-labelledby="workspace-offline-title"
className="bg-background/80 fixed inset-0 z-50 grid place-items-center backdrop-blur-sm"
data-testid="workspace-offline-overlay"
>
<Card className="max-w-md">
<CardContent className="space-y-4 py-8 text-center">
<WifiOff
aria-hidden="true"
className="text-muted-foreground mx-auto size-12"
/>
<h2 id="workspace-offline-title" className="text-xl font-semibold">
{t("workspace.offline.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("workspace.offline.description").replace(
"{deviceName}",
device.alias || device.name,
)}
</p>
<Link href="/devices">
<Button>{t("workspace.offline.backToList")}</Button>
</Link>
</CardContent>
</Card>
</div>
)}
</div>
);
}