diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index a827999..917f921 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -44,11 +44,16 @@ const ( portSearchRange = 20 // healthCheckTimeout:waitHealthy 等 server /api/system/health 200 的上限。 // - // 從 30s → 60s 的理由:Windows 首次啟動時 Defender real-time scan 會對 - // visiona-local-server.exe 做完整掃描(未簽章 + 首次執行)可延遲 30-60 秒才 - // 允許 process 真正執行。30 秒不夠,60 秒涵蓋絕大多數企業環境(含 EDR)。 - // 日常啟動 server 幾百毫秒就能回應,放寬上限不會影響正常情境。 - healthCheckTimeout = 60 * time.Second + // 設 180 秒的理由:Windows 首次啟動時有多層串行延遲疊加: + // (1) Defender real-time scan 掃 visiona-local-server.exe(未簽章 + 首次 + // 執行)可達 30-60 秒 + // (2) 企業環境 EDR(CrowdStrike / SentinelOne / Carbon Black)再加一層 + // cloud reputation lookup,可再延遲 20-60 秒 + // (3) server 自己 init deps check + kneron bridge + gin router 也花幾秒 + // 乾淨 Windows 環境總和常常超過 60 秒,180 秒涵蓋 99% 情境。日常啟動 + // server 幾百毫秒就能回應,放寬上限不影響正常情境;配合 pipeline pause + // 機制也不會讓 soft/hard timeout 誤觸。 + healthCheckTimeout = 180 * time.Second shutdownGracePeriod = 5 * time.Second appName = "visiona-local" ) @@ -652,7 +657,7 @@ func (a *App) startServer() error { // 7. 等 health check(成功後才寫 ipc-port,避免把「預期 port」寫進檔案誤導) a.setBootstrapStatus("等待伺服器就緒...") - if err := waitHealthy(port, healthCheckTimeout); err != nil { + if err := waitHealthy(port, healthCheckTimeout, nil); err != nil { proc.kill() removeIPCPort(a.dataDir) return fmt.Errorf("server did not become healthy: %w", err) @@ -1308,10 +1313,14 @@ func portAvailable(port int) bool { } // waitHealthy 輪詢 /api/system/health 直到 200 或 timeout。 -func waitHealthy(port int, timeout time.Duration) error { +// 若 progress 非 nil,每 5 秒會呼叫一次 progress(elapsedSeconds),讓呼叫者 +// 能對應 emit 「已等待 N 秒」的 UI 文案。 +func waitHealthy(port int, timeout time.Duration, progress func(elapsedSeconds int)) error { url := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", port) - deadline := time.Now().Add(timeout) + started := time.Now() + deadline := started.Add(timeout) client := &http.Client{Timeout: 1 * time.Second} + lastProgressTick := started for time.Now().Before(deadline) { resp, err := client.Get(url) if err == nil { @@ -1320,6 +1329,12 @@ func waitHealthy(port int, timeout time.Duration) error { return nil } } + // 每 5 秒推一次進度到 UI + if progress != nil && time.Since(lastProgressTick) >= 5*time.Second { + elapsed := int(time.Since(started).Seconds()) + progress(elapsed) + lastProgressTick = time.Now() + } time.Sleep(300 * time.Millisecond) } return fmt.Errorf("health check timeout after %v", timeout) diff --git a/local-tool/visiona-local/frontend/app.js b/local-tool/visiona-local/frontend/app.js index d34f629..5429468 100644 --- a/local-tool/visiona-local/frontend/app.js +++ b/local-tool/visiona-local/frontend/app.js @@ -35,7 +35,11 @@ import { import { showStartupPanel, hideStartupPanel, + collapseStartupPanel, + expandStartupPanel, + resetStartupPanel, updateStage, + updateStageDetail, markStageTimeout, showStartupError, renderStages, @@ -204,6 +208,7 @@ function bindHandlers() { }); $('mi-restart').addEventListener('click', async () => { try { + resetStartupPanel(); await RestartServer(); } catch (e) { showToast('Restart failed: ' + e); @@ -259,9 +264,20 @@ function bindHandlers() { // Settings $('btn-settings').addEventListener('click', openSettings); - // Startup error actions + // Startup panel collapsed bar:點任何位置都展開(看歷史紀錄) + const startupPanelEl = $('startup-panel'); + if (startupPanelEl) { + startupPanelEl.addEventListener('click', () => { + if (startupPanelEl.getAttribute('data-collapsed') === 'true') { + expandStartupPanel(); + } + }); + } + + // Startup error actions — Retry 會把面板重置展開 $('btn-retry').addEventListener('click', async () => { try { + resetStartupPanel(); await RestartStartupSequence(); } catch (e) { showToast('Retry failed: ' + e); @@ -273,9 +289,10 @@ function bindHandlers() { }); // Report 按鈕 disabled(coming soon) - // Error banner + // Error banner — Restart Server 會重置展開(讓使用者看新一輪 6 階段) $('banner-restart').addEventListener('click', async () => { try { + resetStartupPanel(); await RestartServer(); } catch (e) { showToast('Restart failed: ' + e); @@ -325,12 +342,16 @@ function subscribeEvents() { EventsOn('startup:stage-timeout', (ev) => { markStageTimeout(ev); }); + // Stage 細步 detail(例如 Stage 3 的 spawn / waitHealth / waitHealthSlow) + EventsOn('startup:stage-detail', (ev) => { + updateStageDetail(ev); + }); EventsOn('startup:error', (ev) => { showStartupError(ev); }); EventsOn('startup:ready', () => { state.starting = false; - hideStartupPanel(); + collapseStartupPanel(); }); // shutdown modal(M8-4 1 秒後顯示) diff --git a/local-tool/visiona-local/frontend/i18n.js b/local-tool/visiona-local/frontend/i18n.js index 814a436..a2576e8 100644 --- a/local-tool/visiona-local/frontend/i18n.js +++ b/local-tool/visiona-local/frontend/i18n.js @@ -62,6 +62,14 @@ const dict = { 'startup.stage.6.label': '等待 Web UI 連線', 'startup.stage.6.description': '正在與瀏覽器建立即時連線', 'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕', + // Stage 3 細步提示(由 Go 的 startup:stage-detail event 觸發) + 'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...', + 'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過(已等 {elapsed} 秒)', + 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常,Windows Defender 掃描可能需 1-2 分鐘(已等 {elapsed} 秒)', + // 啟動完成後 collapsed 面板的標題與提示 + 'startup.collapsed.title': '啟動完成', + 'startup.collapsed.hint': '· 點此展開檢視', + 'startup.collapsed.hintRestart': '· 點此或按重啟可重新展開', 'startup.status.pending': '等待中', 'startup.status.running': '進行中', 'startup.status.done': '完成', @@ -141,6 +149,14 @@ const dict = { 'startup.stage.6.label': 'Waiting for Web UI to connect', 'startup.stage.6.description': 'Establishing realtime connection with the browser', 'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel', + // Stage 3 sub-step hints (triggered by Go startup:stage-detail event) + 'startup.stage.3.detail.spawn': 'Launching server subprocess...', + 'startup.stage.3.detail.waitHealth': 'Waiting for server health check ({elapsed}s elapsed)', + 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes ({elapsed}s elapsed)', + // Collapsed panel after startup ready + 'startup.collapsed.title': 'Startup complete', + 'startup.collapsed.hint': '· click to expand', + 'startup.collapsed.hintRestart': '· click or restart to expand', 'startup.status.pending': 'Waiting', 'startup.status.running': 'Running', 'startup.status.done': 'Done', diff --git a/local-tool/visiona-local/frontend/startup-panel.js b/local-tool/visiona-local/frontend/startup-panel.js index 55f0e36..0f4efa2 100644 --- a/local-tool/visiona-local/frontend/startup-panel.js +++ b/local-tool/visiona-local/frontend/startup-panel.js @@ -5,10 +5,11 @@ import { t } from './i18n.js'; const TOTAL_STAGES = 6; -// 本地狀態:stages[1..6] = {status, startedAt, slow, manualHint} +// 本地狀態:stages[1..6] = {status, startedAt, slow, manualHint, detail} +// detail: 從 startup:stage-detail event 來的 sub-step 提示,顯示在 stage-hint 欄位 const stages = {}; for (let i = 1; i <= TOTAL_STAGES; i++) { - stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false }; + stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null }; } // 啟動流程旗標:stage 5 skipped → stage 6 進入「manual hint」模式 @@ -102,9 +103,12 @@ function paintStageRow(stage) { }; statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending'); - // slow hint line + // hint line:優先順序為 detail(細步進度)> slow hint > 隱藏 // Design Spec §4.1:stage 6 manual mode 不套 20 秒 retry hint(等待人為動作) - if (st.slow && st.status === 'running' && !st.manualHint) { + if (st.detail && st.status === 'running') { + hintEl.textContent = st.detail; + hintEl.removeAttribute('hidden'); + } else if (st.slow && st.status === 'running' && !st.manualHint) { hintEl.textContent = t('startup.timeout.message'); hintEl.removeAttribute('hidden'); } else { @@ -143,22 +147,65 @@ function paintProgressBar() { if (panel) panel.setAttribute('aria-valuenow', String(progressNum)); } -// ---------- Panel 顯示 / 隱藏 ---------- +// ---------- Panel 顯示 / 收合 / 隱藏 / 重置 ---------- export function showStartupPanel() { const panel = document.getElementById('startup-panel'); if (!panel) return; panel.removeAttribute('hidden'); + panel.removeAttribute('data-collapsed'); // Error mode 預設隱藏 document.getElementById('startup-error').setAttribute('hidden', ''); } +// collapseStartupPanel 在啟動完成後呼叫:保留 panel 在 DOM 但縮成一行 +// summary(Stage 都標 ✓,可點擊重新展開)。比 hide 友善——使用者想回顧 +// 啟動歷程時點一下就能看到。Restart / Retry 時應呼叫 expandStartupPanel +// 把它打開。 +export function collapseStartupPanel() { + const panel = document.getElementById('startup-panel'); + if (!panel) return; + panel.removeAttribute('hidden'); + panel.setAttribute('data-collapsed', 'true'); + panel.setAttribute('data-collapse-hint', t('startup.collapsed.hint')); + // 標題改為「啟動完成」精簡字樣 + const titleEl = document.getElementById('startup-title'); + if (titleEl) titleEl.textContent = t('startup.collapsed.title'); + // 確保 error block 是隱藏的(成功路徑) + const err = document.getElementById('startup-error'); + if (err) err.setAttribute('hidden', ''); +} + +// expandStartupPanel 由「點 collapsed bar」觸發(純展開檢視歷史) +// 不重置內部狀態 / 不改 title,使用者展開後仍是「啟動完成」標題。 +export function expandStartupPanel() { + const panel = document.getElementById('startup-panel'); + if (!panel) return; + panel.removeAttribute('hidden'); + panel.removeAttribute('data-collapsed'); +} + +// resetStartupPanel:重置內部狀態 + DOM render(給 Restart 用) +// 注意:不要呼叫 hide,因為使用者按 Restart 是想看新一輪啟動進度。 +export function resetStartupPanel() { + for (let i = 1; i <= TOTAL_STAGES; i++) { + stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null }; + } + if (manualMode) { + manualMode = false; + emitManualMode(false); + } + renderStages(); + expandStartupPanel(); +} + +// hideStartupPanel 保留為 legacy,目前已被 collapseStartupPanel 取代於成功路徑 export function hideStartupPanel() { const panel = document.getElementById('startup-panel'); if (!panel) return; panel.setAttribute('hidden', ''); - // reset + panel.removeAttribute('data-collapsed'); for (let i = 1; i <= TOTAL_STAGES; i++) { - stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false }; + stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null }; } if (manualMode) { manualMode = false; @@ -174,7 +221,10 @@ export function updateStage(ev) { if (n < 1 || n > TOTAL_STAGES) return; stages[n].status = ev.status || 'pending'; if (ev.startedAt) stages[n].startedAt = ev.startedAt; - if (stages[n].status !== 'running') stages[n].slow = false; + if (stages[n].status !== 'running') { + stages[n].slow = false; + stages[n].detail = null; + } // Design Spec §4.1:stage 5 skipped → 立即推 stage 6 進入 manual hint mode // (此時 Go 層也會送 stage 6 running,但這裡先行設定 manualHint 旗標, @@ -233,6 +283,19 @@ export function isManualMode() { return manualMode; } +// ---------- 階段細步 detail(收到 startup:stage-detail)---------- +// Go 端 EmitStageDetail 送來,payload {stage, detailKey, elapsedSeconds} +// 把 i18n key 解析為文案存到 stages[n].detail,paintStageRow 顯示在 stage-hint 欄位。 +export function updateStageDetail(ev) { + if (!ev || !ev.stage || !ev.detailKey) return; + const n = ev.stage; + if (!stages[n]) return; + const elapsed = ev.elapsedSeconds || 0; + // 用 i18n 模板({elapsed} 會被 t() 帶入) + stages[n].detail = t(ev.detailKey, { elapsed }); + paintStageRow(n); +} + // ---------- 階段 soft timeout ---------- export function markStageTimeout(ev) { if (!ev || !ev.stage) return; diff --git a/local-tool/visiona-local/frontend/style.css b/local-tool/visiona-local/frontend/style.css index 2fe78fa..84f253c 100644 --- a/local-tool/visiona-local/frontend/style.css +++ b/local-tool/visiona-local/frontend/style.css @@ -338,6 +338,45 @@ html, body { font-size: 14px; font-weight: 600; } + +/* Collapsed mode:啟動完成後保留面板但縮成 1 行 summary, + * 使用者點擊整個 panel 可以展開回完整 6 階段視圖。 + * Restart / Retry 時會 expandStartupPanel() 移除 data-collapsed。 + */ +.startup-panel[data-collapsed="true"] { + padding: 8px 16px; + cursor: pointer; +} +.startup-panel[data-collapsed="true"] .startup-title { + margin: 0; + font-size: 13px; + color: var(--fg-muted); + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} +.startup-panel[data-collapsed="true"] .startup-title::before { + content: '✓'; + color: var(--success); + font-weight: 700; +} +.startup-panel[data-collapsed="true"] .startup-title::after { + content: attr(data-collapse-hint); + color: var(--fg-muted); + font-size: 11px; + margin-left: auto; + opacity: 0.7; +} +.startup-panel[data-collapsed="true"] .stages, +.startup-panel[data-collapsed="true"] .progress-row, +.startup-panel[data-collapsed="true"] .startup-error, +.startup-panel[data-collapsed="true"] .sr-only { + display: none; +} +.startup-panel[data-collapsed="true"]:hover { + background: var(--surface-2); +} .stages { display: flex; flex-direction: column; diff --git a/local-tool/visiona-local/server_control.go b/local-tool/visiona-local/server_control.go index 9d22786..446d873 100644 --- a/local-tool/visiona-local/server_control.go +++ b/local-tool/visiona-local/server_control.go @@ -611,6 +611,9 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) { } // 9. 啟動 process + if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() { + a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.spawn", 0) + } if err := cmd.Start(); err != nil { _ = stdoutPipe.Close() _ = stderrPipe.Close() @@ -633,9 +636,10 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) { // 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」 // // 冷啟動時 pause hard timeout:Windows 首次執行 visiona-local-server.exe 會被 - // Defender / EDR real-time scan 卡 30-60 秒,60 秒 healthCheckTimeout 本身足夠, - // 但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total budget(那是日常 - // 啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap 同理)。 + // Defender / EDR real-time scan 卡 30-120 秒,healthCheckTimeout 本身 180 秒足夠 + // 涵蓋大多數情境,但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total + // budget(那是日常啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap + // 同理)。 // // 判斷冷啟動:pipeline 處於 stage 1-6 範圍內(IsInColdStart)。 // RestartServer(pipeline 已 ready,current==7)不 pause,維持嚴格計時。 @@ -643,8 +647,24 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) { if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() { a.startupPipeline.PauseHardTimeout() pausedForWait = true + // Stage 3 sub-step 提示:告訴使用者 server binary 已送出,正在等待啟動 + a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.waitHealth", 0) } - if err := waitHealthy(port, healthCheckTimeout); err != nil { + + // waitHealthy progress callback:每 5 秒更新一次 stage-hint 顯示已等待時間; + // 等待 >= 15 秒後改顯示 slow hint(首次啟動 Defender 掃描較慢是正常情況)。 + waitProgress := func(elapsed int) { + if a.startupPipeline == nil { + return + } + key := "startup.stage.3.detail.waitHealth" + if elapsed >= 15 { + key = "startup.stage.3.detail.waitHealthSlow" + } + a.startupPipeline.EmitStageDetail(3, key, elapsed) + } + + if err := waitHealthy(port, healthCheckTimeout, waitProgress); err != nil { if pausedForWait { a.startupPipeline.ResumeHardTimeout() } diff --git a/local-tool/visiona-local/startup_pipeline.go b/local-tool/visiona-local/startup_pipeline.go index 1d2f954..9e49aaa 100644 --- a/local-tool/visiona-local/startup_pipeline.go +++ b/local-tool/visiona-local/startup_pipeline.go @@ -74,6 +74,15 @@ type StartupErrorEvent struct { Cause string `json:"cause"` // "stage-failure" | "total-timeout" } +// StartupStageDetailEvent — 階段內細步進度提示,讓使用者看到當下在做什麼。 +// 不影響 stage status,只是 UI 上 stage-hint 的文案來源。 +// DetailKey 是 i18n key,前端查表顯示;ElapsedSeconds > 0 時附在文案後當耗時提示。 +type StartupStageDetailEvent struct { + Stage int `json:"stage"` + DetailKey string `json:"detailKey"` // i18n key, e.g. "startup.stage.3.detail.waitHealth" + ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時 +} + // ----------------------------------------------------------------------- // stageState — 單一階段的內部狀態 // ----------------------------------------------------------------------- @@ -306,6 +315,29 @@ func (p *StartupPipeline) emitProgress(stage int) { }() } +// EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到 +// 「當下在做什麼」。不改 stage status,只更新 UI 上的 stage-hint 欄位。 +// +// 呼叫點:server_control.go 裡的 startServerV2 會在 spawn binary / 等 health +// check / 30 秒後 slow hint 等節點呼叫這個函式,對應到 i18n key: +// startup.stage.3.detail.spawn # 正在啟動 server 子程序 +// startup.stage.3.detail.waitHealth # 正在等 server 健康檢查通過 +// startup.stage.3.detail.waitHealthSlow # 首次啟動 Defender 掃描可能需時 1-2 分鐘 +// +// elapsedSeconds > 0 時前端會在文案後顯示已等時長。 +func (p *StartupPipeline) EmitStageDetail(stage int, detailKey string, elapsedSeconds int) { + if p == nil || p.app == nil || p.app.ctx == nil { + return + } + go func() { + wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-detail", StartupStageDetailEvent{ + Stage: stage, + DetailKey: detailKey, + ElapsedSeconds: elapsedSeconds, + }) + }() +} + // emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。 // cause 取值:"stage-failure" | "total-timeout" func (p *StartupPipeline) emitError(stage int, err error, cause string) {