jim800121chen f5655e38b1 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>
2026-04-16 01:23:55 +08:00

393 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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';
// 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 <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
// 啟動流程旗標stage 5 skipped → stage 6 進入「manual hint」模式
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
let manualMode = false;
// 觀察者:當進入 manual mode 時通知外層app.js加 pulse / enable primary CTA
const manualModeListeners = new Set();
export function onManualModeChange(fn) {
manualModeListeners.add(fn);
return () => manualModeListeners.delete(fn);
}
function emitManualMode(enabled) {
manualModeListeners.forEach((fn) => {
try { fn(enabled); } catch (e) { console.warn('manualMode listener error:', e); }
});
}
// 連線狀態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) 個 stagestage 6 在背景追蹤但不顯示
export function renderStages() {
const container = document.getElementById('stages');
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= TOTAL_STAGES; i++) {
const row = document.createElement('div');
row.className = 'stage-item';
row.dataset.stage = String(i);
row.dataset.state = stages[i].status;
row.innerHTML = `
<span class="stage-icon" aria-hidden="true"></span>
<span class="stage-number">${i} ·</span>
<div class="stage-label">
<div class="stage-label-primary"></div>
<div class="stage-label-secondary"></div>
</div>
<span class="stage-status"></span>
<div class="stage-hint" hidden></div>
`;
container.appendChild(row);
paintStageRow(i);
}
// 更新標題
const titleEl = document.getElementById('startup-title');
if (titleEl) titleEl.textContent = t('startup.panel.title');
paintProgressBar();
}
function paintStageRow(stage) {
const row = document.querySelector(`.stage-item[data-stage="${stage}"]`);
if (!row) return;
const st = stages[stage];
row.dataset.state = st.slow && st.status === 'running' ? 'running-slow' : st.status;
const iconEl = row.querySelector('.stage-icon');
const labelPrimary = row.querySelector('.stage-label-primary');
const labelSecondary = row.querySelector('.stage-label-secondary');
const statusEl = row.querySelector('.stage-status');
const hintEl = row.querySelector('.stage-hint');
// icon
let icon = '○';
switch (st.status) {
case 'pending': icon = '○'; break;
case 'running': icon = '<span class="spinner-sm"></span>'; break;
case 'completed':
case 'done': icon = '✓'; break;
case 'failed': icon = '✕'; break;
case 'skipped': icon = '⏭'; break;
}
iconEl.innerHTML = icon;
// label
labelPrimary.textContent = t(`startup.stage.${stage}.label`);
// Stage 5 skipped → label secondary 顯示 skipped 原因
// Stage 6 manual mode → description 改顯示 manual hint
if (stage === 5 && st.status === 'skipped') {
labelSecondary.textContent = t('startup.stage.5.skipped.label');
} else if (stage === 6 && st.manualHint) {
labelSecondary.textContent = t('startup.stage.6.manualHint');
} else {
labelSecondary.textContent = t(`startup.stage.${stage}.description`);
}
// status text
const statusMap = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
done: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
};
statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending');
// hint line優先順序為 detail細步進度> slow hint > 隱藏
// Design Spec §4.1stage 6 manual mode 不套 20 秒 retry hint等待人為動作
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 {
hintEl.setAttribute('hidden', '');
}
}
function paintProgressBar() {
const bar = document.getElementById('progress-bar');
const textEl = document.getElementById('progress-text');
if (!bar || !textEl) return;
bar.innerHTML = '';
let completed = 0;
let current = 0;
// 只計算 UI 顯示的 5 個 stagestage 6 在背景但不算進進度條
for (let i = 1; i <= TOTAL_STAGES; i++) {
const cell = document.createElement('span');
cell.className = 'progress-cell state-' + stages[i].status;
bar.appendChild(cell);
if (stages[i].status === 'completed' || stages[i].status === 'done' || stages[i].status === 'skipped') completed++;
if (stages[i].status === 'running') current = i;
}
const progressNum = current || completed;
// 不顯示已等待秒數 — pause 機制讓 elapsed 計算失準pause 期間 wall clock
// 仍走但 startedAt 沒重設,會顯示明顯比真實還久的數字),且使用者覺得
// 沒必要看秒數。直接顯示 progress N/M 即可slow hint 改由 stage-hint
// 文案傳達。
textEl.textContent = t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
// aria
const panel = document.getElementById('startup-panel');
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
}
// ---------- 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 <= PIPELINE_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', '');
panel.removeAttribute('data-collapsed');
for (let i = 1; i <= PIPELINE_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false, detail: null };
}
if (manualMode) {
manualMode = false;
emitManualMode(false);
}
renderStages();
}
// 階段狀態優先級:愈大愈「前進」。回填 snapshot 時用來避免倒退覆蓋
// 已收到的事件race window 解法的一部分)。
const STAGE_STATUS_RANK = {
pending: 0,
running: 1,
skipped: 2,
failed: 2,
completed: 3,
done: 3,
};
function statusRank(s) {
return STAGE_STATUS_RANK[s] ?? 0;
}
// ---------- 更新階段(收到 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 > PIPELINE_STAGES) return;
const newStatus = ev.status || 'pending';
if (opts.monotonic && statusRank(newStatus) < statusRank(stages[n].status)) {
// snapshot 帶來的狀態比 event 流的狀態還舊,忽略
return;
}
stages[n].status = newStatus;
if (ev.startedAt) stages[n].startedAt = ev.startedAt;
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 旗標,
// 以便收到後續 stage 6 progress 時仍能維持 hint 文案。)
if (n === 5 && stages[5].status === 'skipped') {
enterManualMode();
}
// stage 6 收到 done → 離開 manual mode使用者已成功點擊並建立連線
if (n === 6 && (stages[6].status === 'completed' || stages[6].status === 'done')) {
if (manualMode) {
manualMode = false;
stages[6].manualHint = false;
emitManualMode(false);
}
}
// 通知連線狀態指示燈聽眾stage 6 status 變化時)
if (n === 6) {
emitConnectionStatus(stages[6].status);
}
// stage 6 不在 UI 上,跳過 paint
if (n > TOTAL_STAGES) return;
// running 狀態下,把其他仍 pending 的顯示維持
paintStageRow(n);
paintProgressBar();
// 無障礙 live region
const live = document.getElementById('startup-live');
if (live) {
const statusKey = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
}[stages[n].status] || 'startup.status.pending';
live.textContent = `${n} · ${t(`startup.stage.${n}.label`)} · ${t(statusKey)}`;
}
}
// ---------- 進入 Manual Hint 模式stage 5 skipped → stage 6 等待人為動作)----------
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
function enterManualMode() {
if (manualMode) return;
manualMode = true;
// 把 stage 6 標成 running + manualHint若 Go 層還沒送 stage 6 running這裡主動 paint
if (stages[6].status === 'pending') {
stages[6].status = 'running';
stages[6].startedAt = Date.now();
}
stages[6].manualHint = true;
// 不套 soft timeout因為是等人為動作
stages[6].slow = false;
paintStageRow(6);
paintProgressBar();
emitManualMode(true);
}
// 外部呼叫:查詢是否處於 manual mode
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;
const n = ev.stage;
if (!stages[n]) return;
// Design Spec §4.1stage 6 manual mode 不套 20 秒 retry hint
if (n === 6 && stages[6].manualHint) return;
stages[n].slow = true;
if (!stages[n].startedAt) stages[n].startedAt = Date.now();
paintStageRow(n);
paintProgressBar();
}
// ---------- Error mode ----------
export function showStartupError(ev) {
const errorBlock = document.getElementById('startup-error');
const descEl = document.getElementById('error-desc');
const stageEl = document.getElementById('error-stage');
if (!errorBlock) return;
const cause = ev && ev.cause || 'stage-failure';
const failedStage = ev && ev.stage || 0;
if (cause === 'total-timeout') {
descEl.textContent = t('startup.error.description.timeout');
} else {
const stageLabel = failedStage ? t(`startup.stage.${failedStage}.label`) : '';
descEl.textContent = t('startup.error.description.stageFailed', { stageLabel });
}
if (failedStage) {
stageEl.textContent = t('startup.error.failedStage', {
n: failedStage,
label: t(`startup.stage.${failedStage}.label`),
});
// 標記該階段為 failed
stages[failedStage].status = 'failed';
paintStageRow(failedStage);
paintProgressBar();
}
errorBlock.removeAttribute('hidden');
// 確保 panel 顯示
const panel = document.getElementById('startup-panel');
if (panel) panel.removeAttribute('hidden');
}