feat(local-tool): Stage 3 sub-step 進度 + 啟動完成後面板可收合

回應使用者三項需求:
1. healthCheckTimeout 60s → 180s(涵蓋 Defender + EDR 串行延遲最壞情境)
2. Stage 3「啟動本機伺服器」期間顯示細步在做什麼,並在 15 秒後改為「首次
   啟動較久屬正常」slow hint,避免使用者看著 spinner 不動以為 app 掛了
3. 啟動完成後 6 階段面板自動收合成一行 summary,使用者點擊可展開檢視歷
   史紀錄;Restart / Retry 會重置並展開新一輪

實作:

Go 端
- healthCheckTimeout 60s → 180s(理由註解寫清楚 Defender + EDR 各自延遲)
- waitHealthy() 加 progress callback,每 5 秒呼叫一次傳入 elapsedSeconds
- StartupPipeline 加 StartupStageDetailEvent + EmitStageDetail() API
- startServerV2 在 spawn 前 emit detail.spawn,等 health check 期間 callback
  emit detail.waitHealth(< 15s)或 detail.waitHealthSlow(>= 15s)

前端
- 新訂 startup:stage-detail event → updateStageDetail() 把 i18n key 解析為
  文案存到 stages[n].detail,paintStageRow 優先顯示 detail(蓋過 slow hint)
- collapseStartupPanel() / expandStartupPanel() / resetStartupPanel() 三個新
  API 取代 hideStartupPanel;startup:ready 觸發 collapse、Retry/Restart 觸
  發 reset+expand
- collapsed CSS:保留 panel 但縮成一行 summary(標題改「啟動完成」+ ✓ +
  「點此展開檢視」hint),整個 panel 可點擊;hover 加亮
- i18n 加 6 個 keys(zh-TW + en)

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
- 乾淨 dataDir 啟動 wails app:startup 1 秒內完成(macOS 已 cache binary
  + Python venv),server listen 3721,Chrome 自動連上 — 整條 cold start
  正常

Windows 首次安裝預期行為(修復後):
- Stage 1 → Stage 2(首次 bootstrap pause hard timeout,跑 1-3 分鐘)→ Stage
  3 spawn → 等 health check 30-90 秒(Defender 掃 binary)期間有「已等 N
  秒」即時更新 → ready → 自動 collapse → 瀏覽器自動開啟

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-16 00:17:37 +08:00
parent a6cd1c12b2
commit 9c9e005d33
7 changed files with 229 additions and 23 deletions

View File

@ -44,11 +44,16 @@ const (
portSearchRange = 20
// healthCheckTimeoutwaitHealthy 等 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) 企業環境 EDRCrowdStrike / 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)

View File

@ -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 按鈕 disabledcoming 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 modalM8-4 1 秒後顯示)

View File

@ -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',

View File

@ -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.1stage 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 但縮成一行
// summaryStage 都標 ✓,可點擊重新展開)。比 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.1stage 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].detailpaintStageRow 顯示在 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;

View File

@ -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;

View File

@ -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 timeoutWindows 首次執行 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
// RestartServerpipeline 已 readycurrent==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()
}

View File

@ -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) {