回應使用者三項需求: 1. healthCheckTimeout 60s → 180s(涵蓋 Defender + EDR 串行延遲最壞情境) 2. Stage 3「啟動本機伺服器」期間顯示細步在做什麼,並在 15 秒後改為「首次 啟動較久屬正常」slow hint,避免使用者看著 spinner 不動以為 app 掛了 3. 啟動完成後 6 階段面板自動收合成一行 summary,使用者點擊可展開檢視歷 史紀錄;Restart / Retry 會重置並展開新一輪 實作: Go 端 - healthCheckTimeout 60s → 180s(理由註解寫清楚 Defender + EDR 各自延遲) - waitHealthy() 加 progress callback,每 5 秒呼叫一次傳入 elapsedSeconds - StartupPipeline 加 StartupStageDetailEvent + EmitStageDetail() API - startServerV2 在 spawn 前 emit detail.spawn,等 health check 期間 callback emit detail.waitHealth(< 15s)或 detail.waitHealthSlow(>= 15s) 前端 - 新訂 startup:stage-detail event → updateStageDetail() 把 i18n key 解析為 文案存到 stages[n].detail,paintStageRow 優先顯示 detail(蓋過 slow hint) - collapseStartupPanel() / expandStartupPanel() / resetStartupPanel() 三個新 API 取代 hideStartupPanel;startup:ready 觸發 collapse、Retry/Restart 觸 發 reset+expand - collapsed CSS:保留 panel 但縮成一行 summary(標題改「啟動完成」+ ✓ + 「點此展開檢視」hint),整個 panel 可點擊;hover 加亮 - i18n 加 6 個 keys(zh-TW + en) 驗證: - visiona-local 套件 go build / vet / test -race 全綠 - macOS dmg 重 build 163MB OK - 乾淨 dataDir 啟動 wails app:startup 1 秒內完成(macOS 已 cache binary + Python venv),server listen 3721,Chrome 自動連上 — 整條 cold start 正常 Windows 首次安裝預期行為(修復後): - Stage 1 → Stage 2(首次 bootstrap pause hard timeout,跑 1-3 分鐘)→ Stage 3 spawn → 等 health check 30-90 秒(Defender 掃 binary)期間有「已等 N 秒」即時更新 → ready → 自動 collapse → 瀏覽器自動開啟 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
345 lines
13 KiB
JavaScript
345 lines
13 KiB
JavaScript
// 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 = `
|
||
<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;
|
||
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;
|
||
// 若有 slow 狀態,顯示 elapsed
|
||
let elapsedText = '';
|
||
for (let i = 1; i <= TOTAL_STAGES; i++) {
|
||
if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) {
|
||
const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000);
|
||
elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed });
|
||
break;
|
||
}
|
||
}
|
||
textEl.textContent = elapsedText || 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();
|
||
}
|
||
|
||
// ---------- 更新階段(收到 startup:progress)----------
|
||
export function updateStage(ev) {
|
||
if (!ev || !ev.stage) return;
|
||
const n = ev.stage;
|
||
if (n < 1 || n > TOTAL_STAGES) return;
|
||
stages[n].status = ev.status || 'pending';
|
||
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');
|
||
}
|