// 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'); }