依 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>
680 lines
28 KiB
Markdown
680 lines
28 KiB
Markdown
# v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay
|
||
|
||
> 所屬:TDD v2 §2.6
|
||
> 版本:v2.1(2026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播)
|
||
> 決策依據:R5-2(關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay)、三方共識 #14(boot-id + retry)、PM Minor 4(WebSocket 廣播即時觸發 overlay,消除 race condition)
|
||
> 對應 milestone:M8-7
|
||
> 相關文件:`v2/server-lifecycle.md` §8.3(Wails 端 WebSocket 廣播時機)、§9(server 端 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 event(v2.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 polling(10 s interval)
|
||
↓
|
||
下一次 poll:
|
||
├─ 成功 → 比對 bootId
|
||
│ ├─ 相同 → 無事
|
||
│ └─ 不同 → window.location.reload()(server 重啟,強制 reload)
|
||
└─ 失敗 → consecutiveFailures++
|
||
├─ < 2 → 不顯示 overlay(避免偶發網路 glitch)
|
||
└─ ≥ 2(連續 ~20 s)→ 顯示 <ServerOfflineOverlay>
|
||
+ 切 polling interval 為 3 s(active 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 選單選 Quit(macOS 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 s,boot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload
|
||
- 網路偶發抖動(< 10 s 單次 glitch)→ 最多 1 次 failure,下一次 poll 成功即清零,不觸發 overlay
|
||
|
||
### 3.3 Page Visibility API(R-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` | **新增** | 全域覆蓋層 UI(shadcn 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 4:restart 情境不立即顯示 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.1:WebSocket 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 4:WebSocket 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'`,只在瀏覽器 run,OK
|
||
- `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 次失敗 → 顯示 overlay(fallback,當 WebSocket 已斷或未建立)
|
||
3. **業務 API 呼叫的 network error**:次要,不當作 overlay 觸發器(避免單一 API 問題誤觸發)
|
||
|
||
Wails → server → WebSocket → 瀏覽器 的路徑:Wails 只與 server 進程通訊(透過 stdin/stdout 或 HTTP),server 再轉發到 WebSocket hub 廣播給所有連線 tab。這是單向 push,比 polling 更即時。
|
||
|
||
---
|
||
|
||
## 7. 驗收條件
|
||
|
||
| 檢查 | 操作 | 預期 |
|
||
|------|------|------|
|
||
| 正常啟動沒 overlay | 開 app → 點 Open in Browser → tab 載入 | overlay 不顯示 |
|
||
| 關 Wails → 立即顯示 overlay(WebSocket 管道)| 開 app → Open in Browser → 關 Wails 視窗 | < 1 s 內 tab 顯示 overlay(WebSocket `shutdown-imminent` 或 `onclose` 觸發)|
|
||
| 關 Wails → polling fallback | 同上但 WebSocket 未建立(mock) | 20 s 內 tab 顯示 overlay(連續 2 次 10 s poll 失敗)|
|
||
| 關 Wails → 瀏覽器 tab 顯示正確訊息 | 同上 | 看到 title「Server 已離線」 |
|
||
| 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。
|