diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 35434ee..c186525 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -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 預設) diff --git a/local-tool/visiona-local/frontend/app.js b/local-tool/visiona-local/frontend/app.js index df2776f..0a71513 100644 --- a/local-tool/visiona-local/frontend/app.js +++ b/local-tool/visiona-local/frontend/app.js @@ -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(); }); diff --git a/local-tool/visiona-local/frontend/control-panel.js b/local-tool/visiona-local/frontend/control-panel.js index b2a0a53..3a19bc4 100644 --- a/local-tool/visiona-local/frontend/control-panel.js +++ b/local-tool/visiona-local/frontend/control-panel.js @@ -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'); diff --git a/local-tool/visiona-local/frontend/i18n.js b/local-tool/visiona-local/frontend/i18n.js index 6d92620..9be9268 100644 --- a/local-tool/visiona-local/frontend/i18n.js +++ b/local-tool/visiona-local/frontend/i18n.js @@ -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', diff --git a/local-tool/visiona-local/frontend/index.html b/local-tool/visiona-local/frontend/index.html index 291153a..1f2c4a4 100644 --- a/local-tool/visiona-local/frontend/index.html +++ b/local-tool/visiona-local/frontend/index.html @@ -8,6 +8,13 @@ + +
+ + +

啟動中...

+
@@ -22,6 +29,7 @@
Port
Uptime
PID
+
Web UI
diff --git a/local-tool/visiona-local/frontend/startup-panel.js b/local-tool/visiona-local/frontend/startup-panel.js index 147fb96..75902ef 100644 --- a/local-tool/visiona-local/frontend/startup-panel.js +++ b/local-tool/visiona-local/frontend/startup-panel.js @@ -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) 個 stage,stage 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 個 stage,stage 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(); diff --git a/local-tool/visiona-local/frontend/style.css b/local-tool/visiona-local/frontend/style.css index 84f253c..852aa19 100644 --- a/local-tool/visiona-local/frontend/style.css +++ b/local-tool/visiona-local/frontend/style.css @@ -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 splash:app 啟動最一開始的全屏 spinner overlay。 + * 預設 visible(DOM 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` 屬性, diff --git a/local-tool/visiona-local/startup_pipeline.go b/local-tool/visiona-local/startup_pipeline.go index 49ffb95..2133cc3 100644 --- a/local-tool/visiona-local/startup_pipeline.go +++ b/local-tool/visiona-local/startup_pipeline.go @@ -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 ) diff --git a/local-tool/visiona-local/startup_pipeline_test.go b/local-tool/visiona-local/startup_pipeline_test.go index 48993d7..25ef072 100644 --- a/local-tool/visiona-local/startup_pipeline_test.go +++ b/local-tool/visiona-local/startup_pipeline_test.go @@ -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 timeout,pipeline 不該 fail + // effective = 50s < 300s hard timeout,pipeline 不該 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 timeout(skipped 跳過所有檢查) + // 階段 5 已 skipped,總時 320s 不該觸發 hard timeout(skipped 跳過所有檢查) // 注意: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)