visionA/local-tool/.autoflow/04-architecture/v2/web-ui-offline-overlay.md
jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

680 lines
28 KiB
Markdown
Raw Permalink 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.

# v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay
> 所屬TDD v2 §2.6
> 版本v2.12026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播)
> 決策依據R5-2關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay、三方共識 #14boot-id + retry、PM Minor 4WebSocket 廣播即時觸發 overlay消除 race condition
> 對應 milestoneM8-7
> 相關文件:`v2/server-lifecycle.md` §8.3Wails 端 WebSocket 廣播時機、§9server 端 boot-id API
---
## 1. 目的
當瀏覽器 tab 偵測到 server 連不上Wails app 被關了、使用者按 Stop、server crash顯示一個全域的覆蓋層告訴使用者「Server 已離線」,並提供重試機制。同時實作 boot-id polling 讓 server 重啟後瀏覽器自動 reload。
---
## 2. UI 設計
```
┌─────────────────────────────────────────────────────┐
│ │
│ ⚠️ │
│ │
│ Server 已離線 │
│ │
│ visionA Local 的本機伺服器沒有回應。 │
│ 可能原因: │
│ • 你關閉了 visionA Local 控制台 │
│ • Server 被手動停止 │
│ • Server 發生錯誤 │
│ │
│ 請重新開啟 visionA Local 應用程式後按「重試」。 │
│ │
│ [重試] [關閉這個頁面] │
│ │
└─────────────────────────────────────────────────────┘
```
- 全螢幕半透明 backdrop`rgba(0,0,0,0.65)`
- 中央卡片(`max-w-md`
- 中英文隨現有 i18n locale
- 不擋 devtools、不擋 keyboard使用者可用 devtools 檢查 network 看是不是真連不上)
---
## 3. 偵測機制
### 3.1 偵測參數定版v2.1 Architect 二次審閱補齊)
**Polling 間隔與失敗門檻(定版)**
| 參數 | 值 | 說明 |
|------|-----|------|
| Health check 間隔(正常模式) | **10 秒** | server 正常時的 boot-id polling 間隔 |
| Health check 間隔active retry 模式)| **3 秒** | overlay 顯示後自動重試的 polling 間隔 |
| 連續失敗門檻 | **2 次**(約 20 秒)| 避免偶發網路抖動誤觸發 |
| fetch 單次 timeout | 3 秒 | `AbortSignal.timeout(3000)` |
**Active retry 模式定義**:使用者第一次看到 overlay 後(或主動點 Retry 按鈕),前端**自動**每 3 秒重打一次 `/api/system/boot-id`,不需要使用者持續按按鈕。只要任一次成功 → 立即 `resetFailures()` → overlay 消失(若 bootId 變了則 `window.location.reload()`)。
**觸發管道**Overlay 顯示由三個獨立管道觸發,任一觸發即顯示(優先序由高到低):
1. **WebSocket close eventv2.1 新增)**:瀏覽器端 WebSocket 收到 `onclose` → **立即**顯示 overlay不等 polling理由close 事件代表 server 端已斷polling 也一定會失敗,不需要等 20 秒
2. **WebSocket `server:shutdown-imminent` 事件Minor 4**:收到廣播訊息 → **立即**顯示 overlay並依 `reason` 欄位決定文案:
- `reason="app-closing"` / `reason="manual-stop"` / `reason="quit"` → 立即顯示
- `reason="restart"` → 延遲 10 秒顯示(給 boot-id 變化 → `window.location.reload()` 的時間)
3. **被動偵測polling fallback**:連續 2 次 boot-id polling 失敗 → 顯示 overlay當 WebSocket 已斷線或從未建立時生效)
WebSocket 管道的優勢:
- **零延遲**Wails 開始 SIGTERM 的瞬間就發廣播(在實際 shutdown 完成前),瀏覽器 tab 幾乎同時看到 overlay
- **消除 race condition**:原 v2.0 設計下,使用者關 Wails 視窗到瀏覽器 tab 看到 overlay 有 0.5-20 s 的 window取決於 polling 時機),這段期間使用者可能對著一個「已失效但看起來正常」的頁面操作
- **回退**:若 WebSocket 已斷線或從未建立(極罕見,例如 Next.js 載入失敗polling fallback 仍有效
### 3.2 Polling 策略v2.1 定版)
```
瀏覽器 tab 載入
initial fetch /api/system/boot-id
成功 → 記錄 initialBootId + startedAt進 normal polling10 s interval
下一次 poll
├─ 成功 → 比對 bootId
│ ├─ 相同 → 無事
│ └─ 不同 → window.location.reload()server 重啟,強制 reload
└─ 失敗 → consecutiveFailures++
├─ < 2 → 不顯示 overlay避免偶發網路 glitch
└─ ≥ 2連續 ~20 s→ 顯示 <ServerOfflineOverlay>
+ 切 polling interval 為 3 sactive retry 模式)
Active retry 模式下:
每 3 s 自動重打 /api/system/boot-id
├─ 成功bootId 相同)→ resetFailures() + 切回 10 s interval → overlay 消失
└─ 成功bootId 不同)→ window.location.reload()
```
### 3.2a WebSocket `server:shutdown-imminent` 偵測
```
瀏覽器 tab 已連上 WebSocket例如 /ws/server-logs 或新增的 /ws/system
server 收到 Wails 的 ctrl.Stop() 呼叫
server 對 WebSocket hub 廣播:
{
"type": "server:shutdown-imminent",
"reason": "app-closing" | "manual-stop" | "restart",
"ts": 1744656180123
}
瀏覽器收到 message → store.forceShowOverlay()
<ServerOfflineOverlay> 立即顯示(不等 polling 失敗)
後續 SIGTERM、server 真的停止、polling 失敗 → 疊加在 overlay 上不重複觸發
```
**reason 欄位用途v2.1 定版)**
- `app-closing`Wails OnShutdown 觸發,應用即將退出 → 立即顯示 overlay文案「visionA Local 已關閉」
- `manual-stop`:使用者按控制台的 Stop → 立即顯示 overlay文案「Server 已停止,請在控制台按 Start」
- `quit`:使用者從 Wails 選單選 QuitmacOS Cmd+Q 等)→ 立即顯示 overlay文案同 `app-closing`
- `restart`:使用者按控制台的 Restart → **延遲 10 秒**再顯示 overlay給 boot-id 變化 → `window.location.reload()` 的時間);若 10 s 內 server 回來 + boot-id 變 → reload → overlay 不會真的顯示
### 3.3 失敗計數策略polling 管道v2.1 定版)
- **Normal 模式**10 s poll interval連續 2 次失敗(= 覆蓋 ~20 s 容忍期)才觸發 overlay
- **Active retry 模式**overlay 顯示後切 3 s poll interval自動重試至 server 回來
- Wails 關閉視窗的 `StopServer` 過程約 0.5-1 s瀏覽器 polling 通常靠 WebSocket close 事件或 `server:shutdown-imminent` 廣播立即顯示 overlay而非等 polling 失敗
- 使用者正常 Restart透過控制台約 2-3 sboot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload
- 網路偶發抖動(< 10 s 單次 glitch)→ 最多 1 failure下一次 poll 成功即清零不觸發 overlay
### 3.3 Page Visibility APIR-v2-6
瀏覽器背景 tab setInterval 會被降頻到 1 /分鐘會讓server 重啟後使用者切回 tab體驗不好
解決
```typescript
const BOOT_ID_POLL_MS = 5000;
let intervalHandle: number | null = null;
function startPolling() {
if (intervalHandle !== null) return;
intervalHandle = window.setInterval(pollOnce, BOOT_ID_POLL_MS);
}
function stopPolling() {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
pollOnce(); // 立刻 probe 一次
startPolling();
} else {
stopPolling(); // 背景 tab 不 poll回來再說
}
});
// 初始tab 可見時才啟動
if (document.visibilityState === 'visible') {
startPolling();
}
```
---
## 4. 實作
### 4.1 檔案清單
| 檔案 | 狀態 | 說明 |
|------|------|------|
| `frontend/src/stores/system-store.ts` | **新增** | Zustand store`serverOnline / bootId / forcedOffline / lastError` |
| `frontend/src/hooks/use-boot-id-watcher.ts` | **新增** | React hook useEffect 啟動 polling + visibility handling |
| `frontend/src/hooks/use-shutdown-watcher.ts` | **新增**v2.1| React hook訂閱 WebSocket `server:shutdown-imminent` 事件Minor 4|
| `frontend/src/components/server-offline-overlay.tsx` | **新增** | 全域覆蓋層 UIshadcn Dialog / Card |
| `frontend/src/components/boot-id-watcher-mount.tsx` | **新增** | Client wrapper `useBootIdWatcher()` + `useShutdownWatcher()` |
| `frontend/src/app/layout.tsx` | **修改** | root layout `<ServerOfflineOverlay />` `<BootIdWatcherMount />` |
| `frontend/src/lib/i18n/types.ts` | **修改** | 新增 `serverOffline` 區塊的 i18n key 定義 3 reason 文案|
| `frontend/src/lib/i18n/zh-TW.ts` | **修改** | 新增繁體中文字串 |
| `frontend/src/lib/i18n/en.ts` | **修改** | 新增英文字串 |
### 4.2 `frontend/src/stores/system-store.ts`
```typescript
import { create } from 'zustand';
interface SystemState {
serverOnline: boolean;
bootId: string | null;
consecutiveFailures: number;
lastProbeAt: number | null;
// Minor 4由 WebSocket shutdown-imminent 事件強制設定,讓 overlay 立即顯示
forcedOffline: boolean;
forcedOfflineReason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' | null;
setOnline: (bootId: string) => void;
recordFailure: () => void;
shouldShowOverlay: () => boolean;
resetFailures: () => void;
forceOffline: (reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit') => void;
}
const FAILURE_THRESHOLD = 2; // v2.1 定版2 次失敗(約 20 s即觸發
export const useSystemStore = create<SystemState>((set, get) => ({
serverOnline: true,
bootId: null,
consecutiveFailures: 0,
lastProbeAt: null,
forcedOffline: false,
forcedOfflineReason: null,
setOnline: (bootId) => set({
serverOnline: true,
bootId,
consecutiveFailures: 0,
lastProbeAt: Date.now(),
// 注意recover 時不自動清 forcedOffline要走 resetFailures使用者點「重試」
}),
recordFailure: () => set((s) => ({
consecutiveFailures: s.consecutiveFailures + 1,
serverOnline: s.consecutiveFailures + 1 < FAILURE_THRESHOLD,
lastProbeAt: Date.now(),
})),
shouldShowOverlay: () => {
const s = get();
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
},
// Active retry 模式overlay 顯示中時,前端自動切 3 s interval 重試
isActiveRetryMode: () => {
const s = get();
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
},
resetFailures: () => set({
consecutiveFailures: 0,
serverOnline: true,
forcedOffline: false,
forcedOfflineReason: null,
}),
forceOffline: (reason) => {
// Minor 4restart 情境不立即顯示 overlay給 10 s 等 boot-id 變化 reload
if (reason === 'restart') {
setTimeout(() => {
const s = get();
if (s.consecutiveFailures < FAILURE_THRESHOLD && s.bootId !== null) {
// 10 s 內 server 沒回來才強制顯示
set({ forcedOffline: true, forcedOfflineReason: 'restart' });
}
}, 10_000);
return;
}
set({ forcedOffline: true, forcedOfflineReason: reason });
},
}));
```
### 4.3 `frontend/src/hooks/use-boot-id-watcher.ts`
```typescript
'use client';
import { useEffect } from 'react';
import { useSystemStore } from '@/stores/system-store';
import { getBackendUrl } from '@/lib/api';
const POLL_INTERVAL_NORMAL_MS = 10_000; // v2.1 定版:正常模式 10 s
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000; // v2.1 定版overlay 顯示後自動每 3 s 重試
interface BootIdResponse {
success: boolean;
data: {
bootId: string;
startedAt: number;
};
}
async function pollOnce(): Promise<void> {
const store = useSystemStore.getState();
try {
const res = await fetch(`${getBackendUrl()}/api/system/boot-id`, {
cache: 'no-store',
// 短 timeout若 server 假死要快速失敗
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
store.recordFailure();
return;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return;
}
const newBootId = json.data.bootId;
if (store.bootId !== null && store.bootId !== newBootId) {
// Server 重啟force reload
window.location.reload();
return;
}
store.setOnline(newBootId);
} catch {
store.recordFailure();
}
}
export function useBootIdWatcher(): void {
useEffect(() => {
let intervalHandle: number | null = null;
let currentMode: 'normal' | 'active-retry' = 'normal';
const intervalFor = (mode: 'normal' | 'active-retry') =>
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
const start = (mode: 'normal' | 'active-retry') => {
if (intervalHandle !== null && currentMode === mode) return;
if (intervalHandle !== null) clearInterval(intervalHandle);
currentMode = mode;
intervalHandle = window.setInterval(pollOnce, intervalFor(mode));
};
const stop = () => {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
};
// 監聽 store 變化overlay 顯示 ↔ 隱藏時切換 polling 模式
const unsubscribe = useSystemStore.subscribe((state) => {
const shouldActiveRetry = state.forcedOffline || state.consecutiveFailures >= 2;
const targetMode = shouldActiveRetry ? 'active-retry' : 'normal';
if (intervalHandle !== null && targetMode !== currentMode) {
start(targetMode); // 自動切換 interval
}
});
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void pollOnce(); // 立即 probe
const s = useSystemStore.getState();
const mode = s.forcedOffline || s.consecutiveFailures >= 2 ? 'active-retry' : 'normal';
start(mode);
} else {
stop();
}
};
// 初次載入時執行一次 probe + 啟動 normal 輪詢
void pollOnce();
if (document.visibilityState === 'visible') {
start('normal');
}
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
unsubscribe();
stop();
};
}, []);
}
```
### 4.3a `frontend/src/hooks/use-shutdown-watcher.ts`Minor 4 新增)
訂閱 server WebSocket 廣播接收 `server:shutdown-imminent` 事件後立即設 `forcedOffline`
```typescript
'use client';
import { useEffect } from 'react';
import { useSystemStore } from '@/stores/system-store';
import { getBackendUrl } from '@/lib/api';
interface ShutdownImminentMessage {
type: 'server:shutdown-imminent';
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
ts: number;
}
export function useShutdownWatcher(): void {
useEffect(() => {
const wsUrl = getBackendUrl().replace(/^http/, 'ws') + '/ws/system';
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
const connect = () => {
try {
ws = new WebSocket(wsUrl);
} catch {
scheduleReconnect();
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data));
if (msg?.type === 'server:shutdown-imminent') {
const reason = (msg as ShutdownImminentMessage).reason;
useSystemStore.getState().forceOffline(reason);
}
} catch {
// 非 JSON 或格式錯誤,忽略
}
};
ws.onclose = (ev) => {
ws = null;
// v2.1WebSocket close 事件也視為 server 已斷,立即顯示 overlay
// (比等 polling 失敗 20 s 更即時;若 server 正常重啟,成功 poll 後會自動清 overlay
// 只在「曾經連上」的前提下才觸發,避免初次連線就失敗導致誤觸發
if (ev && ev.wasClean === false) {
const reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' = 'manual-stop';
useSystemStore.getState().forceOffline(reason);
}
scheduleReconnect();
};
ws.onerror = () => {
// 忽略 — 後續 onclose 會處理
};
};
const scheduleReconnect = () => {
if (reconnectTimer !== null) return;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
if (document.visibilityState === 'visible') {
connect();
}
}, 3000);
};
connect();
return () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
};
}, []);
}
```
**server 端配合server-lifecycle.md §8**`ctrl.Stop()` 開始時透過 server WebSocket hub建議新增 `/ws/system` endpoint 或沿用現有 `/ws/server-logs`廣播該事件
**注意** hook WebSocket 是從瀏覽器連到 server Wails IPC 無關Wails 端只需要在 `ctrl.Stop()` 時透過 HTTP / gRPC / 直接從 server 內呼叫 `hub.Broadcast(...)` 即可具體做法視 server 內部 API 而定)。
### 4.4 `frontend/src/components/server-offline-overlay.tsx`
```tsx
'use client';
import { useSystemStore } from '@/stores/system-store';
import { useTranslation } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
async function retryOnce() {
// 重試 = 立即打一次 boot-id若成功會自動清 failures
try {
const res = await fetch('/api/system/boot-id', { cache: 'no-store' });
if (res.ok) {
const json = await res.json();
if (json.success && json.data?.bootId) {
useSystemStore.getState().setOnline(json.data.bootId);
return;
}
}
useSystemStore.getState().recordFailure();
} catch {
useSystemStore.getState().recordFailure();
}
}
export function ServerOfflineOverlay() {
const show = useSystemStore((s) => s.forcedOffline || s.consecutiveFailures >= 2);
const reason = useSystemStore((s) => s.forcedOfflineReason);
const { t } = useTranslation();
if (!show) return null;
return (
<div
role="alertdialog"
aria-labelledby="server-offline-title"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/65 backdrop-blur-sm"
>
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-2xl">
<div className="flex items-start gap-4">
<AlertTriangle className="h-8 w-8 shrink-0 text-amber-500" />
<div className="flex-1">
<h2 id="server-offline-title" className="mb-2 text-xl font-semibold">
{t('serverOffline.title')}
</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t('serverOffline.body')}
</p>
<ul className="mb-4 list-disc pl-5 text-sm text-muted-foreground">
<li>{t('serverOffline.reasons.appClosed')}</li>
<li>{t('serverOffline.reasons.manuallyStopped')}</li>
<li>{t('serverOffline.reasons.crashed')}</li>
</ul>
<p className="mb-4 text-sm text-muted-foreground">
{t('serverOffline.instruction')}
</p>
<div className="flex gap-2">
<Button onClick={retryOnce}>{t('serverOffline.retry')}</Button>
<Button
variant="outline"
onClick={() => window.close()}
>
{t('serverOffline.closeTab')}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
```
### 4.5 `frontend/src/app/layout.tsx` 修改
```diff
+import { ServerOfflineOverlay } from '@/components/server-offline-overlay';
+import { BootIdWatcherMount } from '@/components/boot-id-watcher-mount';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html ...>
<body ...>
{/* 既有的 providers / sidebar / theme 等 */}
{children}
+ <BootIdWatcherMount />
+ <ServerOfflineOverlay />
</body>
</html>
);
}
```
`BootIdWatcherMount` 是一個空的 `'use client'` 元件作用只是掛 `useBootIdWatcher()`hook 不能直接在 layout server component 呼叫
```tsx
// frontend/src/components/boot-id-watcher-mount.tsx
'use client';
import { useBootIdWatcher } from '@/hooks/use-boot-id-watcher';
import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher';
export function BootIdWatcherMount() {
useBootIdWatcher();
useShutdownWatcher(); // Minor 4WebSocket shutdown-imminent 訂閱
return null;
}
```
### 4.6 i18n 字串
**`frontend/src/lib/i18n/types.ts`** 新增
```typescript
serverOffline: {
title: string;
body: string;
reasons: {
appClosed: string;
manuallyStopped: string;
crashed: string;
};
instruction: string;
retry: string;
closeTab: string;
};
```
**`frontend/src/lib/i18n/zh-TW.ts`**
```typescript
serverOffline: {
title: 'Server 已離線',
body: 'visionA Local 的本機伺服器沒有回應。可能的原因:',
reasons: {
appClosed: '你關閉了 visionA Local 應用程式',
manuallyStopped: 'Server 被手動停止',
crashed: 'Server 發生錯誤',
},
instruction: '請重新開啟 visionA Local 應用程式後按「重試」。',
retry: '重試',
closeTab: '關閉這個頁面',
},
```
**`frontend/src/lib/i18n/en.ts`**
```typescript
serverOffline: {
title: 'Server Offline',
body: 'The visionA Local server is not responding. Possible reasons:',
reasons: {
appClosed: 'You closed the visionA Local app',
manuallyStopped: 'Server was manually stopped',
crashed: 'Server encountered an error',
},
instruction: 'Please relaunch visionA Local and click "Retry".',
retry: 'Retry',
closeTab: 'Close this tab',
},
```
---
## 5. SSR 相容性
Next.js `static export` 會在 build 階段把頁面 render HTML以下程式碼都必須處理 SSR`typeof window === 'undefined'` 情境
- `use-boot-id-watcher.ts` `'use client'`只在瀏覽器 runOK
- `server-offline-overlay.tsx` `'use client'`OK
- `system-store.ts` Zustand 不需 `'use client'` store 只被 client component 使用OK
- `boot-id-watcher-mount.tsx` `'use client'`OK
**驗收**`pnpm build` frontend/ 目錄下要成功沒有 `ReferenceError: window is not defined` 或其他 SSR 錯誤
---
## 6. 與 ServerController 事件的關係v2.1 更新)
Wails 控制台的 log panel 會收到 `server:state-change` 事件並更新狀態卡片**瀏覽器 tab 無法收到 Wails event**不同 context)。瀏覽器的真相來源有三個優先序由高到低
1. **WebSocket `server:shutdown-imminent`最即時v2.1 Minor 4 新增)**Wails `ctrl.Stop()` 在開始 SIGTERM 前透過 server WebSocket hub 廣播瀏覽器 tab 幾乎同時收到 立即顯示 overlay
2. **`GET /api/system/boot-id` polling**
- 成功 + bootId `window.location.reload()`server 重啟後同 port
- 連續 2 次失敗 顯示 overlayfallback WebSocket 已斷或未建立
3. **業務 API 呼叫的 network error**次要不當作 overlay 觸發器避免單一 API 問題誤觸發
Wails server WebSocket 瀏覽器 的路徑Wails 只與 server 進程通訊透過 stdin/stdout HTTPserver 再轉發到 WebSocket hub 廣播給所有連線 tab這是單向 push polling 更即時
---
## 7. 驗收條件
| 檢查 | 操作 | 預期 |
|------|------|------|
| 正常啟動沒 overlay | app Open in Browser tab 載入 | overlay 不顯示 |
| Wails 立即顯示 overlayWebSocket 管道| app Open in Browser Wails 視窗 | < 1 s tab 顯示 overlayWebSocket `shutdown-imminent` `onclose` 觸發|
| Wails polling fallback | 同上但 WebSocket 未建立mock | 20 s tab 顯示 overlay連續 2 10 s poll 失敗|
| Wails 瀏覽器 tab 顯示正確訊息 | 同上 | 看到 titleServer 已離線 |
| Active retry 模式自動重試 | Overlay 顯示後 | 前端自動每 3 s boot-id非使用者手動按|
| Overlay 重試按鈕 | server 關著時點重試 | failures +1 或維持視結果overlay 仍在 |
| 重試 成功 | app overlay 重開 app | Active retry 3 s 內自動 dismiss overlay bootId 變則 reload |
| 控制台按 Restart 自動 reload | server running Wails Restart 3 s server 回來 | tab 自動 `window.location.reload()`載入後正常期間不顯示 overlay`restart` reason 延遲 10 s|
| 控制台按 Stop 立即 overlay | Stop | WebSocket 廣播 `manual-stop` 立即顯示 overlay |
| 背景 tab 不浪費資源 | 切到別的 tab `chrome://performance-metrics` | tab 不在時 setInterval visibility hidden |
| 切回背景 tab 立即 probe | 切回 tab | 立即打 boot-id不等 10 s interval |
| 網路 glitch < 10 s 不顯示 | 人工用 DevTools Network Offline 8 s Online | overlay 不顯示最多 1 次失敗未達 2 次門檻 |
---
## 8. 待確認
1. **`window.close()` 在非 `window.open()` 開的 tab 不能用** 使用者從 Wails Open in Browser 進來的 tab 確實是 browser 自己開的不是 `window.open`Chrome 會拒絕 `window.close()`備案關閉這個頁面按鈕改為顯示請手動關閉此分頁提示M8-7 執行者實測後決定 UI 文案
2. **boot-id cache header** 目前用 `cache: 'no-store'`但某些 Chrome 版本會忽略若發現 polling 拿到快取值bootId 不變改加 `?_=Date.now()` query 防快取
3. **Overlay z-index 衝突** 9999 高於 shadcn Dialog 50應夠若未來有其他更上層元件要放確認 z-index layering