feat(local-tool): hard timeout 5min + Stage 6 隱藏到 header + 全屏 splash

回應使用者三項需求:

1. 整體 hard timeout 180s → 300s(5 分鐘)
   每個 stage 已有 soft timeout 20s 提示機制,整體 budget 不需緊湊。
   5 分鐘是「使用者點完一杯咖啡都還沒好」的心理上限。pause 機制
   (Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy)
   仍維持作為「一次性 bootstrap 完全不算 budget」的快速通道。
   - 同步更新 i18n 紅 banner 文案 180 → 5 分鐘
   - 同步更新 unit tests(HardTimeout 用 -305s,SkipBypass 用 -320s,
     PreventsHardTimeout 註解 effective<300s)

2. Stage 6「等待 Web UI 連線」從 6 階段面板隱藏到 header 連線指示燈
   Go 端 pipeline 仍保持 6 階段(不動),前端 UI 只顯示 5 階段:
   - startup-panel.js: TOTAL_STAGES=5 顯示用,PIPELINE_STAGES=6 內部
     state 用。renderStages / paintProgressBar / 進度數字都用 5。
   - updateStage 仍會收 stage 6 events 更新內部 state(控 collapse 時機)
     但 stage 6 不 paint UI(n > TOTAL_STAGES early return)
   - 新增 onConnectionStatusChange listener 機制:stage 6 status 變化
     時通知外層
   - control-panel.js: setWebUIStatus 把連線狀態 (pending/running/
     completed/failed) 渲染到 header 的 meta-webui 指示燈:圓點顏色
     + 文字 (等待連線/已連線/未連線)
   - index.html: server-meta 新增 <dd id="meta-webui"> 指示燈位置
   - i18n: control.meta.webui / control.webui.{connected,waiting,disconnected}
   - style.css: .webui-status::before 圓點 + pulse 動畫 + 顏色對應
     state (pending=灰 / running=warning+pulse / connected=success / failed=destructive)
   - app.js: 註冊 onConnectionStatusChange listener,初始化呼叫
     setWebUIStatus('pending')

3. 全屏 spinner splash 取代「啟動中...」三個字
   原本 app 啟動最一開始的「啟動中」狀態只有 header 上三個字很不
   明顯,使用者體感像沒反應。改為 DOM ready 時就顯示 fullscreen
   spinner overlay,收到第一個 startup:progress event 才隱藏。
   - index.html: <div id="boot-splash"> 內含 logo + spinner-lg + 文字
   - style.css: .boot-splash position:fixed inset:0 z-index:1000,
     .boot-splash.hidden { display:none } 用 class 控制(避免和
     [hidden]!important 衝突)
   - app.js: hideBootSplash() helper,4 個 hide 觸發點:
     (a) 收到 startup:progress event
     (b) snapshot 補漏發現 pipeline 已啟動
     (c) 收到 startup:error event(即使失敗也要看到錯誤)
     (d) handleServerStatus 收到非 idle 狀態(restart wails app
         server 還活著的情境)

更新 fix marker 為「d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)」

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-16 01:23:55 +08:00
parent d946561362
commit f5655e38b1
9 changed files with 188 additions and 26 deletions

View File

@ -188,7 +188,7 @@ func (a *App) startup(ctx context.Context) {
a.appLog("==================================================")
a.appLog("visionA-local startup build=%s buildTime=%s", appVersionString(), appBuildTimeString())
a.appLog("platform=%s arch=%s dataDir=%s", runtime.GOOS, runtime.GOARCH, dataDir)
a.appLog("fix marker: 9c9e005+ (180s hard timeout + all-stage sub-step detail + Stage1 seed pause)")
a.appLog("fix marker: d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)")
a.appLog("==================================================")
// M8-4載入 preferences.json讀取失敗 → 用 DefaultPreferences 預設)

View File

@ -31,6 +31,7 @@ import {
showToast,
initHeaderClock,
setPrimaryCTAPulse,
setWebUIStatus,
STATE_ERROR,
} from './control-panel.js';
import {
@ -45,6 +46,7 @@ import {
showStartupError,
renderStages,
onManualModeChange,
onConnectionStatusChange,
} from './startup-panel.js';
import {
initLogPanel,
@ -63,6 +65,14 @@ const state = {
starting: false, // 啟動進度面板是否顯示
};
// ---------- Boot splash 控制 ----------
// 全屏 spinner overlay。DOM ready 即顯示,收到第一個 startup:progress event
// 或 init 完成後拉 snapshot 發現 pipeline 已啟動時 hide。
function hideBootSplash() {
const splash = document.getElementById('boot-splash');
if (splash) splash.classList.add('hidden');
}
// ---------- 初始化 ----------
async function init() {
// 1. 讀 preferences → 決定 locale
@ -119,6 +129,7 @@ async function init() {
// pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式
// 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退)
state.starting = true;
hideBootSplash();
showStartupPanel();
for (const s of snapshot.stages || []) {
updateStage(
@ -168,6 +179,13 @@ async function init() {
onManualModeChange((enabled) => {
setPrimaryCTAPulse(enabled);
});
// 12. Web UI 連線指示燈startup-panel 偵測到 stage 6 status 變化時通知
onConnectionStatusChange((status) => {
setWebUIStatus(status);
});
// 初始一次pending
setWebUIStatus('pending');
}
// ---------- 處理 server status ----------
@ -178,6 +196,11 @@ function handleServerStatus(status) {
updateServerMeta(status);
updatePrimaryControls(status);
// 任何非 idle 狀態都代表 server 已經被 ServerController 接管 → hide splash
if (status.state && status.state !== 'idle') {
hideBootSplash();
}
// Error state runtime banner非 startup error
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
showErrorBanner(status.lastError);
@ -367,6 +390,8 @@ function subscribeEvents() {
state.starting = true;
showStartupPanel();
}
// 收到第一個 progress event 即 hide 全屏 splash
hideBootSplash();
updateStage(ev);
});
EventsOn('startup:stage-timeout', (ev) => {
@ -377,9 +402,11 @@ function subscribeEvents() {
updateStageDetail(ev);
});
EventsOn('startup:error', (ev) => {
hideBootSplash();
showStartupError(ev);
});
EventsOn('startup:ready', () => {
hideBootSplash();
state.starting = false;
collapseStartupPanel();
});

View File

@ -47,6 +47,31 @@ export function updateServerMeta(status) {
// uptime 由 initHeaderClock 定時刷新
}
// ---------- Web UI 連線指示燈(取代 stage 6 的 UI 顯示)----------
// 由 startup-panel 的 connection listener 觸發stage 6 status 變化時呼叫。
// status 取值:'pending' | 'running' | 'completed' | 'done' | 'failed' | 'skipped'
export function setWebUIStatus(status) {
const el = document.getElementById('meta-webui');
if (!el) return;
el.setAttribute('data-state', status || 'pending');
let textKey;
switch (status) {
case 'completed':
case 'done':
textKey = 'control.webui.connected';
break;
case 'running':
textKey = 'control.webui.waiting';
break;
case 'failed':
textKey = 'control.webui.disconnected';
break;
default:
textKey = 'control.webui.waiting';
}
el.textContent = t(textKey);
}
export function initHeaderClock(getServer) {
if (headerClockTimer) clearInterval(headerClockTimer);
const uptimeEl = document.getElementById('meta-uptime');

View File

@ -16,6 +16,10 @@ const dict = {
'control.meta.uptime': '執行時間',
'control.meta.pid': '程序 ID',
'control.meta.version': '版本',
'control.meta.webui': 'Web UI',
'control.webui.connected': '已連線',
'control.webui.waiting': '等待連線',
'control.webui.disconnected': '未連線',
'control.action.openBrowser': '在瀏覽器開啟',
'control.action.start': '啟動',
'control.action.stop': '停止',
@ -96,7 +100,7 @@ const dict = {
'startup.status.skipped': '跳過(依偏好設定)',
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
'startup.error.title': '啟動失敗',
'startup.error.description.timeout': '啟動時間超過 180 秒,可能是系統環境異常或網路中斷。',
'startup.error.description.timeout': '啟動時間超過 5 分鐘,可能是系統環境異常或網路中斷。',
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
'startup.error.failedStage': '失敗階段:{n} · {label}',
'startup.error.retry': '重試',
@ -122,6 +126,10 @@ const dict = {
'control.meta.uptime': 'Uptime',
'control.meta.pid': 'PID',
'control.meta.version': 'Version',
'control.meta.webui': 'Web UI',
'control.webui.connected': 'Connected',
'control.webui.waiting': 'Waiting',
'control.webui.disconnected': 'Disconnected',
'control.action.openBrowser': 'Open in Browser',
'control.action.start': 'Start',
'control.action.stop': 'Stop',
@ -202,7 +210,7 @@ const dict = {
'startup.status.skipped': 'Skipped (per preference)',
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
'startup.error.title': 'Startup failed',
'startup.error.description.timeout': 'Startup exceeded 180 seconds. Your environment may have issues or the network is interrupted.',
'startup.error.description.timeout': 'Startup exceeded 5 minutes. Your environment may have issues or the network is interrupted.',
'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.',
'startup.error.failedStage': 'Failed stage: {n} · {label}',
'startup.error.retry': 'Retry',

View File

@ -8,6 +8,13 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- 全屏 splash overlayDOM ready 時就 visible收到第一個 startup:progress
event 後 app.js 會把它 hide。給使用者一個明確「app 在啟動中」的視覺回饋。 -->
<div id="boot-splash" class="boot-splash">
<img class="boot-splash-logo" src="icon.png" alt="visionA-local">
<div class="spinner-lg" aria-hidden="true"></div>
<p class="boot-splash-text" data-i18n="control.status.starting">啟動中...</p>
</div>
<div id="app" class="control-panel" data-state="idle">
<!-- Header -->
<header class="header">
@ -22,6 +29,7 @@
<div class="meta-item"><dt data-i18n="control.meta.port">Port</dt><dd id="meta-port"></dd></div>
<div class="meta-item"><dt data-i18n="control.meta.uptime">Uptime</dt><dd id="meta-uptime"></dd></div>
<div class="meta-item"><dt data-i18n="control.meta.pid">PID</dt><dd id="meta-pid"></dd></div>
<div class="meta-item"><dt data-i18n="control.meta.webui">Web UI</dt><dd id="meta-webui" class="webui-status" data-state="pending"></dd></div>
</dl>
</div>
<div class="brand-version">

View File

@ -1,14 +1,23 @@
// startup-panel.js — 6 階段啟動進度面板
// startup-panel.js — 啟動進度面板
// 對齊 Design Spec v2.1 startup-progress.md
//
// Go 端 pipeline 是 6 階段,但前端 UI 只顯示 5 階段Stage 1-5
// Stage 6「等待 Web UI 連線」對使用者是技術細節,不該佔一個 step
// 它的狀態移到 header 連線指示燈control-panel.js 那邊),此處只用
// stage 6 status 控制 collapse 時機stage 6 = completed 才整面板收合)。
import { t } from './i18n.js';
const TOTAL_STAGES = 6;
// UI 顯示的階段數Stage 1-5
const TOTAL_STAGES = 5;
// Go 端 pipeline 的真實階段數(包含 stage 6 隱藏 stage
const PIPELINE_STAGES = 6;
// 本地狀態stages[1..6] = {status, startedAt, slow, manualHint, detail}
// detail: 從 startup:stage-detail event 來的 sub-step 提示,顯示在 stage-hint 欄位
// stages[6] 仍存在但不顯示在 panel只用來判斷整體 ready 時機
const stages = {};
for (let i = 1; i <= TOTAL_STAGES; i++) {
for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
@ -27,7 +36,21 @@ function emitManualMode(enabled) {
});
}
// 連線狀態stage 6 隱藏後新增的觀察者機制stage 6 status 變化時
// 通知 control-panel 的連線指示燈。
const connectionListeners = new Set();
export function onConnectionStatusChange(fn) {
connectionListeners.add(fn);
return () => connectionListeners.delete(fn);
}
function emitConnectionStatus(status) {
connectionListeners.forEach((fn) => {
try { fn(status); } catch (e) { console.warn('connection listener error:', e); }
});
}
// ---------- 渲染 stage 列 skeleton ----------
// 只 render TOTAL_STAGES (= 5) 個 stagestage 6 在背景追蹤但不顯示
export function renderStages() {
const container = document.getElementById('stages');
if (!container) return;
@ -123,6 +146,7 @@ function paintProgressBar() {
bar.innerHTML = '';
let completed = 0;
let current = 0;
// 只計算 UI 顯示的 5 個 stagestage 6 在背景但不算進進度條
for (let i = 1; i <= TOTAL_STAGES; i++) {
const cell = document.createElement('span');
cell.className = 'progress-cell state-' + stages[i].status;
@ -182,7 +206,7 @@ export function expandStartupPanel() {
// resetStartupPanel重置內部狀態 + DOM render給 Restart 用)
// 注意:不要呼叫 hide因為使用者按 Restart 是想看新一輪啟動進度。
export function resetStartupPanel() {
for (let i = 1; i <= TOTAL_STAGES; i++) {
for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
@ -199,7 +223,7 @@ export function hideStartupPanel() {
if (!panel) return;
panel.setAttribute('hidden', '');
panel.removeAttribute('data-collapsed');
for (let i = 1; i <= TOTAL_STAGES; i++) {
for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
@ -225,10 +249,11 @@ function statusRank(s) {
// ---------- 更新階段(收到 startup:progress----------
// monotonic = true 時不會用較舊的狀態覆蓋較新的snapshot 補漏用)
// stage 6 仍會被收到並更新內部 state控制 collapse 時機),但不會 paint UI
export function updateStage(ev, opts = {}) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (n < 1 || n > TOTAL_STAGES) return;
if (n < 1 || n > PIPELINE_STAGES) return;
const newStatus = ev.status || 'pending';
if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) {
// snapshot 帶來的狀態比 event 流的狀態還舊,忽略
@ -257,6 +282,14 @@ export function updateStage(ev, opts = {}) {
}
}
// 通知連線狀態指示燈聽眾stage 6 status 變化時)
if (n === 6) {
emitConnectionStatus(stages[6].status);
}
// stage 6 不在 UI 上,跳過 paint
if (n > TOTAL_STAGES) return;
// running 狀態下,把其他仍 pending 的顯示維持
paintStageRow(n);
paintProgressBar();

View File

@ -143,6 +143,31 @@ html, body {
.server-meta dt::after { content: ':'; margin-right: 2px; }
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
/* Web UI 連線指示燈:圓點 + 文字 */
.webui-status {
display: inline-flex !important;
align-items: center;
gap: 4px;
}
.webui-status::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-muted);
}
.webui-status[data-state="pending"]::before { background: var(--fg-muted); }
.webui-status[data-state="running"]::before { background: var(--warning); animation: pulse 1.5s ease-in-out infinite; }
.webui-status[data-state="connected"]::before,
.webui-status[data-state="completed"]::before,
.webui-status[data-state="done"]::before { background: var(--success); }
.webui-status[data-state="failed"]::before { background: var(--destructive); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.brand-version {
display: flex;
flex-direction: column;
@ -629,6 +654,40 @@ html, body {
z-index: 100;
animation: fadeIn 150ms ease-out;
}
/* Boot splashapp 啟動最一開始的全屏 spinner overlay
* 預設 visibleDOM ready 即顯示app.js init 完成 + 收到第一個
* startup:progress event 後加 hidden 隱藏
* 不用 [hidden] 屬性 class .hidden 控制避免被 [hidden]!important
* 蓋掉時的重複邏輯
*/
.boot-splash {
position: fixed;
inset: 0;
background: var(--bg);
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
animation: fadeIn 200ms ease-out;
}
.boot-splash.hidden {
display: none;
}
.boot-splash-logo {
width: 80px;
height: 80px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.boot-splash-text {
margin: 0;
font-size: 14px;
color: var(--fg-muted);
font-weight: 500;
}
/* CSS specificity 修補`.modal-backdrop` `display: flex` 規則
* specificity = (0,1,0) user agent stylesheet `[hidden] { display: none }`
* 相同但因為寫在後面 cascade 勝出 結果是即使 div `hidden` 屬性

View File

@ -38,15 +38,17 @@ import (
const (
startupTotalStages = 6
// 每階段 soft timeout 20 秒(用 wall clock 計時,不受 pause 影響)
// 觸發後 emit "startup:stage-timeout" 提示「正在重試」但不中斷流程。
// 這是「單一階段卡太久」的保護,搭配下方 hard timeout 兩層防線。
startupSoftTimeout = 20 * time.Second
// startupHardTimeout 從 R5-E1 原定 60 秒放寬到 180 秒。理由:即使有
// Stage 1 (seedUserDataDir) / Stage 2 (Python bootstrap) / Stage 3
// (waitHealthy) 三段 pause 機制豁免Windows 乾淨環境首次啟動仍可能在
// 段落間Defender 掃多個檔/EDR cloud lookup/段落間小工作)累積延遲,
// 使用者體感「應該還在啟動但被當失敗」非常挫折。180 秒給意料之外的
// 延遲足夠 buffer搭配 pause 機制 + 細步進度 emit 涵蓋 99% 情境。
// 日常啟動只要幾秒放寬不影響正常情境second launch 通常 < 5 秒)。
startupHardTimeout = 180 * time.Second
// startupHardTimeout 從 R5-E1 原定 60 秒一路放寬到 300 秒5 分鐘)。
// 理由:每階段已經有 soft timeout 提示機制20 秒),整體 budget 不需
// 緊湊也能擋住真的卡死的情境。300 秒是「使用者點完一杯咖啡都還沒好」
// 的心理上限,這時候再 fail 才合理。
// pause 機制Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy
// 仍維持,作為「一次性 bootstrap 完全不算 budget」的快速通道。
startupHardTimeout = 300 * time.Second
startupWatcherTick = 1 * time.Second
)

View File

@ -180,9 +180,9 @@ func TestStartupPipeline_Watcher_SoftTimeout(t *testing.T) {
func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 模擬「總時已經 185 秒(超過 180 秒 hard timeout當前在階段 3」
// 模擬「總時已經 305 秒(超過 300 秒 hard timeout當前在階段 3」
now := time.Now()
p.startedAt = now.Add(-185 * time.Second)
p.startedAt = now.Add(-305 * time.Second)
p.current = 3
p.stages[3].status = "running"
p.stages[3].startedAt = now.Add(-30 * time.Second)
@ -263,7 +263,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
p := NewStartupPipeline(a)
// 模擬 wall clock 已過 300 秒,但其中 250 秒是「首次 bootstrap」暫停
// effective = 50s < 180s hard timeoutpipeline 不該 fail
// effective = 50s < 300s hard timeoutpipeline 不該 fail
now := time.Now()
p.startedAt = now.Add(-300 * time.Second)
p.pausedDuration = 250 * time.Second
@ -284,7 +284,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
p.mu.Unlock()
if cur == -1 {
t.Fatal("pipeline failed due to hard timeout, but effective=50s should be under the 180s limit")
t.Fatal("pipeline failed due to hard timeout, but effective=50s should be under the 300s limit")
}
if status == "failed" {
t.Fatalf("stage 2 failed, want still running (effective time under limit)")
@ -316,12 +316,12 @@ func TestStartupPipeline_Watcher_SkippedStageNoTimeout(t *testing.T) {
a.prefs.AutoOpenBrowser = false
p := NewStartupPipeline(a)
// 階段 6 + AutoOpenBrowser=false總時 200s已超 180s hard timeout也不該觸發
// 階段 6 + AutoOpenBrowser=false總時 320s已超 300s hard timeout也不該觸發
now := time.Now()
p.startedAt = now.Add(-200 * time.Second)
p.startedAt = now.Add(-320 * time.Second)
p.current = 6
p.stages[6].status = "running"
p.stages[6].startedAt = now.Add(-200 * time.Second)
p.stages[6].startedAt = now.Add(-320 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -349,10 +349,10 @@ func TestStartupPipeline_Watcher_SkippedStatusBypassesTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 階段 5 已 skipped總時 200s 不該觸發 hard timeoutskipped 跳過所有檢查)
// 階段 5 已 skipped總時 320s 不該觸發 hard timeoutskipped 跳過所有檢查)
// 注意skip 之後實際上 current 會是 6但這裡測試的是 skip 狀態本身的 bypass 行為
now := time.Now()
p.startedAt = now.Add(-200 * time.Second)
p.startedAt = now.Add(-320 * time.Second)
p.current = 5
p.stages[5].status = "skipped"
p.stages[5].startedAt = now.Add(-30 * time.Second)