回應使用者三項需求:
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>
393 lines
15 KiB
JavaScript
393 lines
15 KiB
JavaScript
// 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) 個 stage,stage 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.1:stage 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 個 stage,stage 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 但縮成一行
|
||
// 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 <= 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.1:stage 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].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;
|
||
const n = ev.stage;
|
||
if (!stages[n]) return;
|
||
// Design Spec §4.1:stage 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');
|
||
}
|