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:
parent
a6cd1c12b2
commit
9c9e005d33
@ -44,11 +44,16 @@ const (
|
|||||||
portSearchRange = 20
|
portSearchRange = 20
|
||||||
// healthCheckTimeout:waitHealthy 等 server /api/system/health 200 的上限。
|
// healthCheckTimeout:waitHealthy 等 server /api/system/health 200 的上限。
|
||||||
//
|
//
|
||||||
// 從 30s → 60s 的理由:Windows 首次啟動時 Defender real-time scan 會對
|
// 設 180 秒的理由:Windows 首次啟動時有多層串行延遲疊加:
|
||||||
// visiona-local-server.exe 做完整掃描(未簽章 + 首次執行)可延遲 30-60 秒才
|
// (1) Defender real-time scan 掃 visiona-local-server.exe(未簽章 + 首次
|
||||||
// 允許 process 真正執行。30 秒不夠,60 秒涵蓋絕大多數企業環境(含 EDR)。
|
// 執行)可達 30-60 秒
|
||||||
// 日常啟動 server 幾百毫秒就能回應,放寬上限不會影響正常情境。
|
// (2) 企業環境 EDR(CrowdStrike / SentinelOne / Carbon Black)再加一層
|
||||||
healthCheckTimeout = 60 * time.Second
|
// 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
|
shutdownGracePeriod = 5 * time.Second
|
||||||
appName = "visiona-local"
|
appName = "visiona-local"
|
||||||
)
|
)
|
||||||
@ -652,7 +657,7 @@ func (a *App) startServer() error {
|
|||||||
|
|
||||||
// 7. 等 health check(成功後才寫 ipc-port,避免把「預期 port」寫進檔案誤導)
|
// 7. 等 health check(成功後才寫 ipc-port,避免把「預期 port」寫進檔案誤導)
|
||||||
a.setBootstrapStatus("等待伺服器就緒...")
|
a.setBootstrapStatus("等待伺服器就緒...")
|
||||||
if err := waitHealthy(port, healthCheckTimeout); err != nil {
|
if err := waitHealthy(port, healthCheckTimeout, nil); err != nil {
|
||||||
proc.kill()
|
proc.kill()
|
||||||
removeIPCPort(a.dataDir)
|
removeIPCPort(a.dataDir)
|
||||||
return fmt.Errorf("server did not become healthy: %w", err)
|
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。
|
// 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)
|
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}
|
client := &http.Client{Timeout: 1 * time.Second}
|
||||||
|
lastProgressTick := started
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -1320,6 +1329,12 @@ func waitHealthy(port int, timeout time.Duration) error {
|
|||||||
return nil
|
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)
|
time.Sleep(300 * time.Millisecond)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("health check timeout after %v", timeout)
|
return fmt.Errorf("health check timeout after %v", timeout)
|
||||||
|
|||||||
@ -35,7 +35,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
showStartupPanel,
|
showStartupPanel,
|
||||||
hideStartupPanel,
|
hideStartupPanel,
|
||||||
|
collapseStartupPanel,
|
||||||
|
expandStartupPanel,
|
||||||
|
resetStartupPanel,
|
||||||
updateStage,
|
updateStage,
|
||||||
|
updateStageDetail,
|
||||||
markStageTimeout,
|
markStageTimeout,
|
||||||
showStartupError,
|
showStartupError,
|
||||||
renderStages,
|
renderStages,
|
||||||
@ -204,6 +208,7 @@ function bindHandlers() {
|
|||||||
});
|
});
|
||||||
$('mi-restart').addEventListener('click', async () => {
|
$('mi-restart').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
resetStartupPanel();
|
||||||
await RestartServer();
|
await RestartServer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Restart failed: ' + e);
|
showToast('Restart failed: ' + e);
|
||||||
@ -259,9 +264,20 @@ function bindHandlers() {
|
|||||||
// Settings
|
// Settings
|
||||||
$('btn-settings').addEventListener('click', openSettings);
|
$('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 () => {
|
$('btn-retry').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
resetStartupPanel();
|
||||||
await RestartStartupSequence();
|
await RestartStartupSequence();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Retry failed: ' + e);
|
showToast('Retry failed: ' + e);
|
||||||
@ -273,9 +289,10 @@ function bindHandlers() {
|
|||||||
});
|
});
|
||||||
// Report 按鈕 disabled(coming soon)
|
// Report 按鈕 disabled(coming soon)
|
||||||
|
|
||||||
// Error banner
|
// Error banner — Restart Server 會重置展開(讓使用者看新一輪 6 階段)
|
||||||
$('banner-restart').addEventListener('click', async () => {
|
$('banner-restart').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
resetStartupPanel();
|
||||||
await RestartServer();
|
await RestartServer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Restart failed: ' + e);
|
showToast('Restart failed: ' + e);
|
||||||
@ -325,12 +342,16 @@ function subscribeEvents() {
|
|||||||
EventsOn('startup:stage-timeout', (ev) => {
|
EventsOn('startup:stage-timeout', (ev) => {
|
||||||
markStageTimeout(ev);
|
markStageTimeout(ev);
|
||||||
});
|
});
|
||||||
|
// Stage 細步 detail(例如 Stage 3 的 spawn / waitHealth / waitHealthSlow)
|
||||||
|
EventsOn('startup:stage-detail', (ev) => {
|
||||||
|
updateStageDetail(ev);
|
||||||
|
});
|
||||||
EventsOn('startup:error', (ev) => {
|
EventsOn('startup:error', (ev) => {
|
||||||
showStartupError(ev);
|
showStartupError(ev);
|
||||||
});
|
});
|
||||||
EventsOn('startup:ready', () => {
|
EventsOn('startup:ready', () => {
|
||||||
state.starting = false;
|
state.starting = false;
|
||||||
hideStartupPanel();
|
collapseStartupPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// shutdown modal(M8-4 1 秒後顯示)
|
// shutdown modal(M8-4 1 秒後顯示)
|
||||||
|
|||||||
@ -62,6 +62,14 @@ const dict = {
|
|||||||
'startup.stage.6.label': '等待 Web UI 連線',
|
'startup.stage.6.label': '等待 Web UI 連線',
|
||||||
'startup.stage.6.description': '正在與瀏覽器建立即時連線',
|
'startup.stage.6.description': '正在與瀏覽器建立即時連線',
|
||||||
'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕',
|
'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.pending': '等待中',
|
||||||
'startup.status.running': '進行中',
|
'startup.status.running': '進行中',
|
||||||
'startup.status.done': '完成',
|
'startup.status.done': '完成',
|
||||||
@ -141,6 +149,14 @@ const dict = {
|
|||||||
'startup.stage.6.label': 'Waiting for Web UI to connect',
|
'startup.stage.6.label': 'Waiting for Web UI to connect',
|
||||||
'startup.stage.6.description': 'Establishing realtime connection with the browser',
|
'startup.stage.6.description': 'Establishing realtime connection with the browser',
|
||||||
'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel',
|
'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.pending': 'Waiting',
|
||||||
'startup.status.running': 'Running',
|
'startup.status.running': 'Running',
|
||||||
'startup.status.done': 'Done',
|
'startup.status.done': 'Done',
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import { t } from './i18n.js';
|
|||||||
|
|
||||||
const TOTAL_STAGES = 6;
|
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 = {};
|
const stages = {};
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
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」模式
|
// 啟動流程旗標:stage 5 skipped → stage 6 進入「manual hint」模式
|
||||||
@ -102,9 +103,12 @@ function paintStageRow(stage) {
|
|||||||
};
|
};
|
||||||
statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending');
|
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(等待人為動作)
|
// 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.textContent = t('startup.timeout.message');
|
||||||
hintEl.removeAttribute('hidden');
|
hintEl.removeAttribute('hidden');
|
||||||
} else {
|
} else {
|
||||||
@ -143,22 +147,65 @@ function paintProgressBar() {
|
|||||||
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
|
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Panel 顯示 / 隱藏 ----------
|
// ---------- Panel 顯示 / 收合 / 隱藏 / 重置 ----------
|
||||||
export function showStartupPanel() {
|
export function showStartupPanel() {
|
||||||
const panel = document.getElementById('startup-panel');
|
const panel = document.getElementById('startup-panel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.removeAttribute('hidden');
|
panel.removeAttribute('hidden');
|
||||||
|
panel.removeAttribute('data-collapsed');
|
||||||
// Error mode 預設隱藏
|
// Error mode 預設隱藏
|
||||||
document.getElementById('startup-error').setAttribute('hidden', '');
|
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() {
|
export function hideStartupPanel() {
|
||||||
const panel = document.getElementById('startup-panel');
|
const panel = document.getElementById('startup-panel');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.setAttribute('hidden', '');
|
panel.setAttribute('hidden', '');
|
||||||
// reset
|
panel.removeAttribute('data-collapsed');
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
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) {
|
if (manualMode) {
|
||||||
manualMode = false;
|
manualMode = false;
|
||||||
@ -174,7 +221,10 @@ export function updateStage(ev) {
|
|||||||
if (n < 1 || n > TOTAL_STAGES) return;
|
if (n < 1 || n > TOTAL_STAGES) return;
|
||||||
stages[n].status = ev.status || 'pending';
|
stages[n].status = ev.status || 'pending';
|
||||||
if (ev.startedAt) stages[n].startedAt = ev.startedAt;
|
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
|
// Design Spec §4.1:stage 5 skipped → 立即推 stage 6 進入 manual hint mode
|
||||||
// (此時 Go 層也會送 stage 6 running,但這裡先行設定 manualHint 旗標,
|
// (此時 Go 層也會送 stage 6 running,但這裡先行設定 manualHint 旗標,
|
||||||
@ -233,6 +283,19 @@ export function isManualMode() {
|
|||||||
return manualMode;
|
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 ----------
|
// ---------- 階段 soft timeout ----------
|
||||||
export function markStageTimeout(ev) {
|
export function markStageTimeout(ev) {
|
||||||
if (!ev || !ev.stage) return;
|
if (!ev || !ev.stage) return;
|
||||||
|
|||||||
@ -338,6 +338,45 @@ html, body {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
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 {
|
.stages {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -611,6 +611,9 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. 啟動 process
|
// 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 {
|
if err := cmd.Start(); err != nil {
|
||||||
_ = stdoutPipe.Close()
|
_ = stdoutPipe.Close()
|
||||||
_ = stderrPipe.Close()
|
_ = stderrPipe.Close()
|
||||||
@ -633,9 +636,10 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
|
|||||||
// 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」
|
// 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」
|
||||||
//
|
//
|
||||||
// 冷啟動時 pause hard timeout:Windows 首次執行 visiona-local-server.exe 會被
|
// 冷啟動時 pause hard timeout:Windows 首次執行 visiona-local-server.exe 會被
|
||||||
// Defender / EDR real-time scan 卡 30-60 秒,60 秒 healthCheckTimeout 本身足夠,
|
// Defender / EDR real-time scan 卡 30-120 秒,healthCheckTimeout 本身 180 秒足夠
|
||||||
// 但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total budget(那是日常
|
// 涵蓋大多數情境,但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total
|
||||||
// 啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap 同理)。
|
// budget(那是日常啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap
|
||||||
|
// 同理)。
|
||||||
//
|
//
|
||||||
// 判斷冷啟動:pipeline 處於 stage 1-6 範圍內(IsInColdStart)。
|
// 判斷冷啟動:pipeline 處於 stage 1-6 範圍內(IsInColdStart)。
|
||||||
// RestartServer(pipeline 已 ready,current==7)不 pause,維持嚴格計時。
|
// 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() {
|
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
|
||||||
a.startupPipeline.PauseHardTimeout()
|
a.startupPipeline.PauseHardTimeout()
|
||||||
pausedForWait = true
|
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 {
|
if pausedForWait {
|
||||||
a.startupPipeline.ResumeHardTimeout()
|
a.startupPipeline.ResumeHardTimeout()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,6 +74,15 @@ type StartupErrorEvent struct {
|
|||||||
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
|
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 — 單一階段的內部狀態
|
// 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 通知。
|
// emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。
|
||||||
// cause 取值:"stage-failure" | "total-timeout"
|
// cause 取值:"stage-failure" | "total-timeout"
|
||||||
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
|
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user