// startup-panel.js — 6 階段啟動進度面板
// 對齊 Design Spec v2.1 startup-progress.md
import { t } from './i18n.js';
const TOTAL_STAGES = 6;
// 本地狀態: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, 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 列 skeleton ----------
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 = `
${i} ·
`;
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 = ''; 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;
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 <= 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', '');
panel.removeAttribute('data-collapsed');
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();
}
// 階段狀態優先級:愈大愈「前進」。回填 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 補漏用)
export function updateStage(ev, opts = {}) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (n < 1 || n > TOTAL_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);
}
}
// 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');
}