影片分頁上傳從舊 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>
478 lines
20 KiB
TypeScript
478 lines
20 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 推論工作區 Client — /workspace/[deviceId]
|
||
*
|
||
* 對齊 pages.md §8.4、flow-offline-handling.md §6。
|
||
*
|
||
* 塊 1 範圍(Camera MJPEG 串流顯示 + 開始/停止狀態機):
|
||
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕
|
||
* - Tabs:Camera / Image / Video / Batch(Camera 已接真串流,其餘塊 3 補)
|
||
* - Camera tab 內:CameraFeed 顯示 MJPEG stream(start 成功後)
|
||
* - 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]` 仍是雲端 UUID(fetchDevice 是純 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";
|
||
|
||
/**
|
||
* 影片上傳 timeout;0 = 不限。
|
||
*
|
||
* ADR-019 WP-4:影片改走 localhost 直連(loopback,0.1–2.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);
|
||
// 目前選中的 tab(camera / image / video / batch)。
|
||
// 用於:離開 camera tab 時停掉 camera 串流 + WS,避免與 media tab 的 WS 訂閱同時寫入同一個 store。
|
||
const [activeTab, setActiveTab] = useState("camera");
|
||
// 塊 1:Camera MJPEG 串流 URL(start 成功後由後端回傳的 streamUrl 組出)
|
||
const [streamUrl, setStreamUrl] = useState("");
|
||
// 塊 2:CameraFeed `<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-C(ADR-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-C:WS 識別值用 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-C:serial 為空無法路由(按鈕已 disable;此為防禦性 guard)。
|
||
if (!serialNumber) return;
|
||
setBusy(true);
|
||
try {
|
||
// 契約修正(評估 §3.1 R-C1):camera pipeline 入口為 POST /api/camera/start
|
||
// body 帶 deviceId;後端回傳 { streamUrl, sourceType }(camera_handler.go:119-125)
|
||
// WP-C(ADR-018):deviceId 欄位帶 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-C(ADR-018):與 start 一致,deviceId 欄位帶 serial。
|
||
// serial 為空時不可能已 start(handleStart 有 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-C:media 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-C:serial 為空無法路由 → disable(原因見下方 no-serial banner)
|
||
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
|
||
{t("workspace.inference.start")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* WP-C(ADR-018):serial 為空 → 無法路由到 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={
|
||
// M1:feedSize 未就緒(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-C(ADR-018):上傳識別值帶 serial;serial 為空 → 顯示提示、不給上傳。 */}
|
||
<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-3:tunnel 離線(!isOnline)→ 停用上傳 + 明確提示(不做 tunnel fallback)
|
||
- 上傳失敗 → 依 error.code 對應 friendly i18n(localVideoErrorMessage) */}
|
||
<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) {
|
||
// 使用者取消(AbortError,api.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>
|
||
);
|
||
}
|