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:
parent
d946561362
commit
f5655e38b1
@ -188,7 +188,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.appLog("==================================================")
|
a.appLog("==================================================")
|
||||||
a.appLog("visionA-local startup build=%s buildTime=%s", appVersionString(), appBuildTimeString())
|
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("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("==================================================")
|
a.appLog("==================================================")
|
||||||
|
|
||||||
// M8-4:載入 preferences.json(讀取失敗 → 用 DefaultPreferences 預設)
|
// M8-4:載入 preferences.json(讀取失敗 → 用 DefaultPreferences 預設)
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
initHeaderClock,
|
initHeaderClock,
|
||||||
setPrimaryCTAPulse,
|
setPrimaryCTAPulse,
|
||||||
|
setWebUIStatus,
|
||||||
STATE_ERROR,
|
STATE_ERROR,
|
||||||
} from './control-panel.js';
|
} from './control-panel.js';
|
||||||
import {
|
import {
|
||||||
@ -45,6 +46,7 @@ import {
|
|||||||
showStartupError,
|
showStartupError,
|
||||||
renderStages,
|
renderStages,
|
||||||
onManualModeChange,
|
onManualModeChange,
|
||||||
|
onConnectionStatusChange,
|
||||||
} from './startup-panel.js';
|
} from './startup-panel.js';
|
||||||
import {
|
import {
|
||||||
initLogPanel,
|
initLogPanel,
|
||||||
@ -63,6 +65,14 @@ const state = {
|
|||||||
starting: false, // 啟動進度面板是否顯示
|
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() {
|
async function init() {
|
||||||
// 1. 讀 preferences → 決定 locale
|
// 1. 讀 preferences → 決定 locale
|
||||||
@ -119,6 +129,7 @@ async function init() {
|
|||||||
// pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式
|
// pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式
|
||||||
// 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退)
|
// 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退)
|
||||||
state.starting = true;
|
state.starting = true;
|
||||||
|
hideBootSplash();
|
||||||
showStartupPanel();
|
showStartupPanel();
|
||||||
for (const s of snapshot.stages || []) {
|
for (const s of snapshot.stages || []) {
|
||||||
updateStage(
|
updateStage(
|
||||||
@ -168,6 +179,13 @@ async function init() {
|
|||||||
onManualModeChange((enabled) => {
|
onManualModeChange((enabled) => {
|
||||||
setPrimaryCTAPulse(enabled);
|
setPrimaryCTAPulse(enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 12. Web UI 連線指示燈:startup-panel 偵測到 stage 6 status 變化時通知
|
||||||
|
onConnectionStatusChange((status) => {
|
||||||
|
setWebUIStatus(status);
|
||||||
|
});
|
||||||
|
// 初始一次(pending)
|
||||||
|
setWebUIStatus('pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 處理 server status ----------
|
// ---------- 處理 server status ----------
|
||||||
@ -178,6 +196,11 @@ function handleServerStatus(status) {
|
|||||||
updateServerMeta(status);
|
updateServerMeta(status);
|
||||||
updatePrimaryControls(status);
|
updatePrimaryControls(status);
|
||||||
|
|
||||||
|
// 任何非 idle 狀態都代表 server 已經被 ServerController 接管 → hide splash
|
||||||
|
if (status.state && status.state !== 'idle') {
|
||||||
|
hideBootSplash();
|
||||||
|
}
|
||||||
|
|
||||||
// Error state runtime banner(非 startup error)
|
// Error state runtime banner(非 startup error)
|
||||||
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
|
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
|
||||||
showErrorBanner(status.lastError);
|
showErrorBanner(status.lastError);
|
||||||
@ -367,6 +390,8 @@ function subscribeEvents() {
|
|||||||
state.starting = true;
|
state.starting = true;
|
||||||
showStartupPanel();
|
showStartupPanel();
|
||||||
}
|
}
|
||||||
|
// 收到第一個 progress event 即 hide 全屏 splash
|
||||||
|
hideBootSplash();
|
||||||
updateStage(ev);
|
updateStage(ev);
|
||||||
});
|
});
|
||||||
EventsOn('startup:stage-timeout', (ev) => {
|
EventsOn('startup:stage-timeout', (ev) => {
|
||||||
@ -377,9 +402,11 @@ function subscribeEvents() {
|
|||||||
updateStageDetail(ev);
|
updateStageDetail(ev);
|
||||||
});
|
});
|
||||||
EventsOn('startup:error', (ev) => {
|
EventsOn('startup:error', (ev) => {
|
||||||
|
hideBootSplash();
|
||||||
showStartupError(ev);
|
showStartupError(ev);
|
||||||
});
|
});
|
||||||
EventsOn('startup:ready', () => {
|
EventsOn('startup:ready', () => {
|
||||||
|
hideBootSplash();
|
||||||
state.starting = false;
|
state.starting = false;
|
||||||
collapseStartupPanel();
|
collapseStartupPanel();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -47,6 +47,31 @@ export function updateServerMeta(status) {
|
|||||||
// uptime 由 initHeaderClock 定時刷新
|
// 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) {
|
export function initHeaderClock(getServer) {
|
||||||
if (headerClockTimer) clearInterval(headerClockTimer);
|
if (headerClockTimer) clearInterval(headerClockTimer);
|
||||||
const uptimeEl = document.getElementById('meta-uptime');
|
const uptimeEl = document.getElementById('meta-uptime');
|
||||||
|
|||||||
@ -16,6 +16,10 @@ const dict = {
|
|||||||
'control.meta.uptime': '執行時間',
|
'control.meta.uptime': '執行時間',
|
||||||
'control.meta.pid': '程序 ID',
|
'control.meta.pid': '程序 ID',
|
||||||
'control.meta.version': '版本',
|
'control.meta.version': '版本',
|
||||||
|
'control.meta.webui': 'Web UI',
|
||||||
|
'control.webui.connected': '已連線',
|
||||||
|
'control.webui.waiting': '等待連線',
|
||||||
|
'control.webui.disconnected': '未連線',
|
||||||
'control.action.openBrowser': '在瀏覽器開啟',
|
'control.action.openBrowser': '在瀏覽器開啟',
|
||||||
'control.action.start': '啟動',
|
'control.action.start': '啟動',
|
||||||
'control.action.stop': '停止',
|
'control.action.stop': '停止',
|
||||||
@ -96,7 +100,7 @@ const dict = {
|
|||||||
'startup.status.skipped': '跳過(依偏好設定)',
|
'startup.status.skipped': '跳過(依偏好設定)',
|
||||||
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
|
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
|
||||||
'startup.error.title': '啟動失敗',
|
'startup.error.title': '啟動失敗',
|
||||||
'startup.error.description.timeout': '啟動時間超過 180 秒,可能是系統環境異常或網路中斷。',
|
'startup.error.description.timeout': '啟動時間超過 5 分鐘,可能是系統環境異常或網路中斷。',
|
||||||
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
|
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
|
||||||
'startup.error.failedStage': '失敗階段:{n} · {label}',
|
'startup.error.failedStage': '失敗階段:{n} · {label}',
|
||||||
'startup.error.retry': '重試',
|
'startup.error.retry': '重試',
|
||||||
@ -122,6 +126,10 @@ const dict = {
|
|||||||
'control.meta.uptime': 'Uptime',
|
'control.meta.uptime': 'Uptime',
|
||||||
'control.meta.pid': 'PID',
|
'control.meta.pid': 'PID',
|
||||||
'control.meta.version': 'Version',
|
'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.openBrowser': 'Open in Browser',
|
||||||
'control.action.start': 'Start',
|
'control.action.start': 'Start',
|
||||||
'control.action.stop': 'Stop',
|
'control.action.stop': 'Stop',
|
||||||
@ -202,7 +210,7 @@ const dict = {
|
|||||||
'startup.status.skipped': 'Skipped (per preference)',
|
'startup.status.skipped': 'Skipped (per preference)',
|
||||||
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
|
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
|
||||||
'startup.error.title': 'Startup failed',
|
'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.description.stageFailed': 'Stage "{stageLabel}" failed.',
|
||||||
'startup.error.failedStage': 'Failed stage: {n} · {label}',
|
'startup.error.failedStage': 'Failed stage: {n} · {label}',
|
||||||
'startup.error.retry': 'Retry',
|
'startup.error.retry': 'Retry',
|
||||||
|
|||||||
@ -8,6 +8,13 @@
|
|||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- 全屏 splash overlay:DOM 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">
|
<div id="app" class="control-panel" data-state="idle">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="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.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.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.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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-version">
|
<div class="brand-version">
|
||||||
|
|||||||
@ -1,14 +1,23 @@
|
|||||||
// startup-panel.js — 6 階段啟動進度面板
|
// startup-panel.js — 啟動進度面板
|
||||||
// 對齊 Design Spec v2.1 startup-progress.md
|
// 對齊 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';
|
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}
|
// 本地狀態:stages[1..6] = {status, startedAt, slow, manualHint, detail}
|
||||||
// detail: 從 startup:stage-detail event 來的 sub-step 提示,顯示在 stage-hint 欄位
|
// detail: 從 startup:stage-detail event 來的 sub-step 提示,顯示在 stage-hint 欄位
|
||||||
|
// stages[6] 仍存在但不顯示在 panel,只用來判斷整體 ready 時機
|
||||||
const stages = {};
|
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 };
|
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 ----------
|
// ---------- 渲染 stage 列 skeleton ----------
|
||||||
|
// 只 render TOTAL_STAGES (= 5) 個 stage,stage 6 在背景追蹤但不顯示
|
||||||
export function renderStages() {
|
export function renderStages() {
|
||||||
const container = document.getElementById('stages');
|
const container = document.getElementById('stages');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@ -123,6 +146,7 @@ function paintProgressBar() {
|
|||||||
bar.innerHTML = '';
|
bar.innerHTML = '';
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
let current = 0;
|
let current = 0;
|
||||||
|
// 只計算 UI 顯示的 5 個 stage,stage 6 在背景但不算進進度條
|
||||||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
||||||
const cell = document.createElement('span');
|
const cell = document.createElement('span');
|
||||||
cell.className = 'progress-cell state-' + stages[i].status;
|
cell.className = 'progress-cell state-' + stages[i].status;
|
||||||
@ -182,7 +206,7 @@ export function expandStartupPanel() {
|
|||||||
// resetStartupPanel:重置內部狀態 + DOM render(給 Restart 用)
|
// resetStartupPanel:重置內部狀態 + DOM render(給 Restart 用)
|
||||||
// 注意:不要呼叫 hide,因為使用者按 Restart 是想看新一輪啟動進度。
|
// 注意:不要呼叫 hide,因為使用者按 Restart 是想看新一輪啟動進度。
|
||||||
export function resetStartupPanel() {
|
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 };
|
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
|
||||||
}
|
}
|
||||||
if (manualMode) {
|
if (manualMode) {
|
||||||
@ -199,7 +223,7 @@ export function hideStartupPanel() {
|
|||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.setAttribute('hidden', '');
|
panel.setAttribute('hidden', '');
|
||||||
panel.removeAttribute('data-collapsed');
|
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 };
|
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
|
||||||
}
|
}
|
||||||
if (manualMode) {
|
if (manualMode) {
|
||||||
@ -225,10 +249,11 @@ function statusRank(s) {
|
|||||||
|
|
||||||
// ---------- 更新階段(收到 startup:progress)----------
|
// ---------- 更新階段(收到 startup:progress)----------
|
||||||
// monotonic = true 時不會用較舊的狀態覆蓋較新的(snapshot 補漏用)
|
// monotonic = true 時不會用較舊的狀態覆蓋較新的(snapshot 補漏用)
|
||||||
|
// stage 6 仍會被收到並更新內部 state(控制 collapse 時機),但不會 paint UI
|
||||||
export function updateStage(ev, opts = {}) {
|
export function updateStage(ev, opts = {}) {
|
||||||
if (!ev || !ev.stage) return;
|
if (!ev || !ev.stage) return;
|
||||||
const n = ev.stage;
|
const n = ev.stage;
|
||||||
if (n < 1 || n > TOTAL_STAGES) return;
|
if (n < 1 || n > PIPELINE_STAGES) return;
|
||||||
const newStatus = ev.status || 'pending';
|
const newStatus = ev.status || 'pending';
|
||||||
if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) {
|
if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) {
|
||||||
// snapshot 帶來的狀態比 event 流的狀態還舊,忽略
|
// 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 的顯示維持
|
// running 狀態下,把其他仍 pending 的顯示維持
|
||||||
paintStageRow(n);
|
paintStageRow(n);
|
||||||
paintProgressBar();
|
paintProgressBar();
|
||||||
|
|||||||
@ -143,6 +143,31 @@ html, body {
|
|||||||
.server-meta dt::after { content: ':'; margin-right: 2px; }
|
.server-meta dt::after { content: ':'; margin-right: 2px; }
|
||||||
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
|
.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 {
|
.brand-version {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -629,6 +654,40 @@ html, body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
animation: fadeIn 150ms ease-out;
|
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` 規則
|
/* CSS specificity 修補:`.modal-backdrop` 的 `display: flex` 規則
|
||||||
* specificity = (0,1,0),和 user agent stylesheet 的 `[hidden] { display: none }`
|
* specificity = (0,1,0),和 user agent stylesheet 的 `[hidden] { display: none }`
|
||||||
* 相同,但因為寫在後面 cascade 勝出 → 結果是即使 div 有 `hidden` 屬性,
|
* 相同,但因為寫在後面 cascade 勝出 → 結果是即使 div 有 `hidden` 屬性,
|
||||||
|
|||||||
@ -38,15 +38,17 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
startupTotalStages = 6
|
startupTotalStages = 6
|
||||||
|
// 每階段 soft timeout 20 秒(用 wall clock 計時,不受 pause 影響)
|
||||||
|
// 觸發後 emit "startup:stage-timeout" 提示「正在重試」但不中斷流程。
|
||||||
|
// 這是「單一階段卡太久」的保護,搭配下方 hard timeout 兩層防線。
|
||||||
startupSoftTimeout = 20 * time.Second
|
startupSoftTimeout = 20 * time.Second
|
||||||
// startupHardTimeout 從 R5-E1 原定 60 秒放寬到 180 秒。理由:即使有
|
// startupHardTimeout 從 R5-E1 原定 60 秒一路放寬到 300 秒(5 分鐘)。
|
||||||
// Stage 1 (seedUserDataDir) / Stage 2 (Python bootstrap) / Stage 3
|
// 理由:每階段已經有 soft timeout 提示機制(20 秒),整體 budget 不需
|
||||||
// (waitHealthy) 三段 pause 機制豁免,Windows 乾淨環境首次啟動仍可能在
|
// 緊湊也能擋住真的卡死的情境。300 秒是「使用者點完一杯咖啡都還沒好」
|
||||||
// 段落間(Defender 掃多個檔/EDR cloud lookup/段落間小工作)累積延遲,
|
// 的心理上限,這時候再 fail 才合理。
|
||||||
// 使用者體感「應該還在啟動但被當失敗」非常挫折。180 秒給意料之外的
|
// pause 機制(Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy)
|
||||||
// 延遲足夠 buffer,搭配 pause 機制 + 細步進度 emit 涵蓋 99% 情境。
|
// 仍維持,作為「一次性 bootstrap 完全不算 budget」的快速通道。
|
||||||
// 日常啟動只要幾秒,放寬不影響正常情境(second launch 通常 < 5 秒)。
|
startupHardTimeout = 300 * time.Second
|
||||||
startupHardTimeout = 180 * time.Second
|
|
||||||
startupWatcherTick = 1 * time.Second
|
startupWatcherTick = 1 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -180,9 +180,9 @@ func TestStartupPipeline_Watcher_SoftTimeout(t *testing.T) {
|
|||||||
func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) {
|
func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) {
|
||||||
a, _ := newPipelineTestApp(t)
|
a, _ := newPipelineTestApp(t)
|
||||||
p := NewStartupPipeline(a)
|
p := NewStartupPipeline(a)
|
||||||
// 模擬「總時已經 185 秒(超過 180 秒 hard timeout),當前在階段 3」
|
// 模擬「總時已經 305 秒(超過 300 秒 hard timeout),當前在階段 3」
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
p.startedAt = now.Add(-185 * time.Second)
|
p.startedAt = now.Add(-305 * time.Second)
|
||||||
p.current = 3
|
p.current = 3
|
||||||
p.stages[3].status = "running"
|
p.stages[3].status = "running"
|
||||||
p.stages[3].startedAt = now.Add(-30 * time.Second)
|
p.stages[3].startedAt = now.Add(-30 * time.Second)
|
||||||
@ -263,7 +263,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
|
|||||||
p := NewStartupPipeline(a)
|
p := NewStartupPipeline(a)
|
||||||
|
|
||||||
// 模擬 wall clock 已過 300 秒,但其中 250 秒是「首次 bootstrap」暫停
|
// 模擬 wall clock 已過 300 秒,但其中 250 秒是「首次 bootstrap」暫停
|
||||||
// effective = 50s < 180s hard timeout,pipeline 不該 fail
|
// effective = 50s < 300s hard timeout,pipeline 不該 fail
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
p.startedAt = now.Add(-300 * time.Second)
|
p.startedAt = now.Add(-300 * time.Second)
|
||||||
p.pausedDuration = 250 * time.Second
|
p.pausedDuration = 250 * time.Second
|
||||||
@ -284,7 +284,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
if cur == -1 {
|
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" {
|
if status == "failed" {
|
||||||
t.Fatalf("stage 2 failed, want still running (effective time under limit)")
|
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
|
a.prefs.AutoOpenBrowser = false
|
||||||
p := NewStartupPipeline(a)
|
p := NewStartupPipeline(a)
|
||||||
|
|
||||||
// 階段 6 + AutoOpenBrowser=false:總時 200s(已超 180s hard timeout)也不該觸發
|
// 階段 6 + AutoOpenBrowser=false:總時 320s(已超 300s hard timeout)也不該觸發
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
p.startedAt = now.Add(-200 * time.Second)
|
p.startedAt = now.Add(-320 * time.Second)
|
||||||
p.current = 6
|
p.current = 6
|
||||||
p.stages[6].status = "running"
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@ -349,10 +349,10 @@ func TestStartupPipeline_Watcher_SkippedStatusBypassesTimeout(t *testing.T) {
|
|||||||
a, _ := newPipelineTestApp(t)
|
a, _ := newPipelineTestApp(t)
|
||||||
p := NewStartupPipeline(a)
|
p := NewStartupPipeline(a)
|
||||||
|
|
||||||
// 階段 5 已 skipped,總時 200s 不該觸發 hard timeout(skipped 跳過所有檢查)
|
// 階段 5 已 skipped,總時 320s 不該觸發 hard timeout(skipped 跳過所有檢查)
|
||||||
// 注意:skip 之後實際上 current 會是 6,但這裡測試的是 skip 狀態本身的 bypass 行為
|
// 注意:skip 之後實際上 current 會是 6,但這裡測試的是 skip 狀態本身的 bypass 行為
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
p.startedAt = now.Add(-200 * time.Second)
|
p.startedAt = now.Add(-320 * time.Second)
|
||||||
p.current = 5
|
p.current = 5
|
||||||
p.stages[5].status = "skipped"
|
p.stages[5].status = "skipped"
|
||||||
p.stages[5].startedAt = now.Add(-30 * time.Second)
|
p.stages[5].startedAt = now.Add(-30 * time.Second)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user