兩個問題一次修:
1. Stage 順序亂跳 — 「Stage 1 等待中、Stage 2 完成、Stage 3 進行中」
根因:Wails Webview JS load 需 1-3 秒(Windows 乾淨環境更慢),這段
期間 Go 的 Pipeline.Start 已經 emit Stage 1 running event 甚至跑完
Stage 1 / Stage 2,但前端 EventsOn 還沒掛上去,events 全被丟掉。前端
接到的第一個 event 可能是 Stage 2 completed 或 Stage 3 running,
stages[1].status 仍是初始 pending 值,UI 顯示亂序。
修法:
- 新增 Go binding GetStartupSnapshot() 回傳 pipeline 當前所有 stages
狀態(含 current / startedAt / status)。
- 前端 init 流程在 subscribeEvents 後立即拉一次 snapshot,補上漏掉
的 stage 狀態。
- updateStage 加 monotonic 模式:snapshot 補漏時不會用較舊狀態覆蓋
已收到的較新狀態(避免 race condition 倒退)。
- status 優先級 STAGE_STATUS_RANK = pending<running<{skipped,failed}<completed
2. 進度條已等待秒數顯示錯誤 — 「進度 3 / 6 · 已等待 20 秒」
根因:pause 機制讓 elapsed 計算失準(pause 期間 wall clock 仍走但
stages[i].startedAt 沒重設,會顯示明顯比真實還久的數字)。使用者
覺得不需要顯示秒數。
修法:
- paintProgressBar 移除 elapsedText 邏輯,永遠顯示 progressLabel
- i18n 文案移除 {elapsed} placeholder(zh-TW + en):
stage.1.detail.seedSlow / stage.3.detail.waitHealth /
stage.3.detail.waitHealthSlow 都改為固定文案
- Go 端 emit 仍會傳 elapsed(waitProgress callback 不變),但前端
i18n template 不再用該變數,自然就不顯示
驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
- Wails bindings 自動 regen 含 GetStartupSnapshot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
438 lines
14 KiB
JavaScript
438 lines
14 KiB
JavaScript
// visionA-local 控制台 main entry
|
||
// - 負責 bindings / events 註冊、初始化 UI
|
||
// - M8-5 Wails 控制台 UI(對齊 Design Spec v2.1)
|
||
|
||
import {
|
||
StartServer,
|
||
StopServer,
|
||
RestartServer,
|
||
ForceKillServer,
|
||
GetServerStatusV2,
|
||
GetStartupSnapshot,
|
||
GetRecentLogs,
|
||
ClearLogs,
|
||
GetSystemInfo,
|
||
OpenInBrowser,
|
||
RevealLogsFolder,
|
||
ExportLog,
|
||
GetPreferences,
|
||
SetPreferences,
|
||
RestartStartupSequence,
|
||
} from './wailsjs/go/main/App.js';
|
||
|
||
import { EventsOn } from './wailsjs/runtime/runtime.js';
|
||
import { t, applyI18n, setLocale, getLocale, detectLocale, LOCALES } from './i18n.js';
|
||
import {
|
||
setServerState,
|
||
updateServerMeta,
|
||
updatePrimaryControls,
|
||
showErrorBanner,
|
||
hideErrorBanner,
|
||
showToast,
|
||
initHeaderClock,
|
||
setPrimaryCTAPulse,
|
||
STATE_ERROR,
|
||
} from './control-panel.js';
|
||
import {
|
||
showStartupPanel,
|
||
hideStartupPanel,
|
||
collapseStartupPanel,
|
||
expandStartupPanel,
|
||
resetStartupPanel,
|
||
updateStage,
|
||
updateStageDetail,
|
||
markStageTimeout,
|
||
showStartupError,
|
||
renderStages,
|
||
onManualModeChange,
|
||
} from './startup-panel.js';
|
||
import {
|
||
initLogPanel,
|
||
appendLogs,
|
||
clearLog,
|
||
flashLastError,
|
||
applyLogFilter,
|
||
} from './log-panel.js';
|
||
import { initSettingsPanel, openSettings } from './settings-panel.js';
|
||
|
||
// ---------- 全域狀態 ----------
|
||
const state = {
|
||
server: null, // ServerStatusV2
|
||
prefs: null, // Preferences
|
||
sysInfo: null, // SystemInfo
|
||
starting: false, // 啟動進度面板是否顯示
|
||
};
|
||
|
||
// ---------- 初始化 ----------
|
||
async function init() {
|
||
// 1. 讀 preferences → 決定 locale
|
||
try {
|
||
state.prefs = await GetPreferences();
|
||
} catch (e) {
|
||
console.warn('GetPreferences failed:', e);
|
||
state.prefs = { autoOpenBrowser: true, locale: '' };
|
||
}
|
||
const locale = detectLocale(state.prefs && state.prefs.locale);
|
||
setLocale(locale);
|
||
|
||
// 2. 讀系統資訊
|
||
try {
|
||
state.sysInfo = await GetSystemInfo();
|
||
} catch (e) {
|
||
console.warn('GetSystemInfo failed:', e);
|
||
}
|
||
|
||
// 3. 套 i18n 到 DOM
|
||
applyI18n();
|
||
document.title = t('control.title');
|
||
if (state.sysInfo && state.sysInfo.appVersion) {
|
||
document.getElementById('app-version').textContent = state.sysInfo.appVersion;
|
||
}
|
||
|
||
// 4. render 6 階段 UI skeleton
|
||
renderStages();
|
||
|
||
// 5. 初始化 log panel(拉既有 buffer)
|
||
try {
|
||
const existingLogs = await GetRecentLogs(2000);
|
||
initLogPanel(existingLogs || []);
|
||
} catch (e) {
|
||
console.warn('GetRecentLogs failed:', e);
|
||
initLogPanel([]);
|
||
}
|
||
|
||
// 6. 綁按鈕 handlers
|
||
bindHandlers();
|
||
|
||
// 7. 訂閱 Wails events
|
||
subscribeEvents();
|
||
|
||
// 7.5. 補上前端 init 完成前 Go 端已 emit 但被丟掉的 startup events。
|
||
// Wails Webview JS load 完成需 1-3 秒(Windows 乾淨環境更慢),這段
|
||
// 期間 Go 的 Pipeline.Start 已經跑完 emit Stage 1 running、可能也
|
||
// 跑完 Stage 1 complete + Stage 2 running 等多個 events,前端 EventsOn
|
||
// 還沒掛上去就被丟掉。拉一次 snapshot 把這些 stage 狀態補回來,
|
||
// 避免 UI 顯示「Stage 1 等待中、Stage 2 完成」這種亂序畫面。
|
||
try {
|
||
const snapshot = await GetStartupSnapshot();
|
||
if (snapshot && snapshot.current > 0) {
|
||
// pipeline 還在進行中或已完成 → 顯示 panel + 用 monotonic 模式
|
||
// 補上各 stage 狀態(已收到較新狀態的 stage 不會被倒退)
|
||
state.starting = true;
|
||
showStartupPanel();
|
||
for (const s of snapshot.stages || []) {
|
||
updateStage(
|
||
{ stage: s.stage, status: s.status, startedAt: s.startedAt },
|
||
{ monotonic: true },
|
||
);
|
||
}
|
||
// 已 ready (current=7) → 直接 collapse 面板
|
||
if (snapshot.current > snapshot.totalStages) {
|
||
state.starting = false;
|
||
collapseStartupPanel();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('GetStartupSnapshot failed:', e);
|
||
}
|
||
|
||
// 8. 初始 state query
|
||
try {
|
||
const status = await GetServerStatusV2();
|
||
handleServerStatus(status);
|
||
} catch (e) {
|
||
console.warn('GetServerStatusV2 failed:', e);
|
||
}
|
||
|
||
// 9. 初始化 settings panel
|
||
initSettingsPanel({
|
||
prefs: state.prefs,
|
||
sysInfo: state.sysInfo,
|
||
getPreferences: GetPreferences,
|
||
setPreferences: SetPreferences,
|
||
onLocaleChange: (newLoc) => {
|
||
setLocale(newLoc || detectLocale(''));
|
||
applyI18n();
|
||
document.title = t('control.title');
|
||
renderStages();
|
||
// re-render current state text
|
||
if (state.server) setServerState(state.server);
|
||
},
|
||
});
|
||
|
||
// 10. header uptime 時鐘
|
||
initHeaderClock(() => state.server);
|
||
|
||
// 11. Manual mode 監聽:stage 5 skipped 時引導使用者點 Open in Browser
|
||
// 對齊 Design Spec §4.1
|
||
onManualModeChange((enabled) => {
|
||
setPrimaryCTAPulse(enabled);
|
||
});
|
||
}
|
||
|
||
// ---------- 處理 server status ----------
|
||
function handleServerStatus(status) {
|
||
if (!status) return;
|
||
state.server = status;
|
||
setServerState(status);
|
||
updateServerMeta(status);
|
||
updatePrimaryControls(status);
|
||
|
||
// Error state runtime banner(非 startup error)
|
||
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
|
||
showErrorBanner(status.lastError);
|
||
} else if (status.state !== STATE_ERROR) {
|
||
hideErrorBanner();
|
||
}
|
||
}
|
||
|
||
// ---------- 按鈕 handlers ----------
|
||
function bindHandlers() {
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
// Open in Browser
|
||
$('btn-open-browser').addEventListener('click', async () => {
|
||
try {
|
||
await OpenInBrowser('');
|
||
} catch (e) {
|
||
showToast('Failed to open browser: ' + e);
|
||
}
|
||
});
|
||
|
||
// Start
|
||
$('btn-start').addEventListener('click', async () => {
|
||
try {
|
||
await StartServer();
|
||
} catch (e) {
|
||
showToast('Start failed: ' + e);
|
||
}
|
||
});
|
||
|
||
// Manage menu
|
||
const manageBtn = $('btn-manage');
|
||
const manageMenu = $('manage-menu');
|
||
manageBtn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
const hidden = manageMenu.hasAttribute('hidden');
|
||
if (hidden) {
|
||
manageMenu.removeAttribute('hidden');
|
||
manageBtn.setAttribute('aria-expanded', 'true');
|
||
} else {
|
||
manageMenu.setAttribute('hidden', '');
|
||
manageBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
document.addEventListener('click', () => {
|
||
if (!manageMenu.hasAttribute('hidden')) {
|
||
manageMenu.setAttribute('hidden', '');
|
||
manageBtn.setAttribute('aria-expanded', 'false');
|
||
}
|
||
});
|
||
|
||
$('mi-stop').addEventListener('click', async () => {
|
||
try {
|
||
await StopServer();
|
||
} catch (e) {
|
||
showToast('Stop failed: ' + e);
|
||
}
|
||
});
|
||
$('mi-restart').addEventListener('click', async () => {
|
||
try {
|
||
resetStartupPanel();
|
||
await RestartServer();
|
||
} catch (e) {
|
||
showToast('Restart failed: ' + e);
|
||
}
|
||
});
|
||
$('mi-open-folder').addEventListener('click', async () => {
|
||
try {
|
||
await RevealLogsFolder();
|
||
} catch (e) {
|
||
showToast('Open folder failed: ' + e);
|
||
}
|
||
});
|
||
|
||
// Log actions
|
||
$('btn-clear-log').addEventListener('click', async () => {
|
||
try {
|
||
await ClearLogs();
|
||
clearLog();
|
||
showToast(t('control.log.clearToast'));
|
||
} catch (e) {
|
||
showToast('Clear failed: ' + e);
|
||
}
|
||
});
|
||
$('btn-copy-log').addEventListener('click', async () => {
|
||
const output = document.getElementById('log-output');
|
||
try {
|
||
await navigator.clipboard.writeText(output.innerText || '');
|
||
showToast(t('control.log.copied'));
|
||
} catch (e) {
|
||
showToast('Copy failed: ' + e);
|
||
}
|
||
});
|
||
$('btn-export-log').addEventListener('click', async () => {
|
||
try {
|
||
const path = await ExportLog();
|
||
showToast(t('control.log.exported', { path }));
|
||
} catch (e) {
|
||
showToast('Export failed: ' + e);
|
||
}
|
||
});
|
||
$('btn-open-folder').addEventListener('click', async () => {
|
||
try {
|
||
await RevealLogsFolder();
|
||
} catch (e) {
|
||
showToast('Open folder failed: ' + e);
|
||
}
|
||
});
|
||
|
||
// Filter
|
||
$('filter-input').addEventListener('input', (e) => applyLogFilter({ text: e.target.value }));
|
||
$('level-filter').addEventListener('change', (e) => applyLogFilter({ level: e.target.value }));
|
||
|
||
// Settings
|
||
$('btn-settings').addEventListener('click', openSettings);
|
||
|
||
// Startup panel collapsed bar:點任何位置都展開(看歷史紀錄)
|
||
const startupPanelEl = $('startup-panel');
|
||
if (startupPanelEl) {
|
||
startupPanelEl.addEventListener('click', () => {
|
||
if (startupPanelEl.getAttribute('data-collapsed') === 'true') {
|
||
expandStartupPanel();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Startup error actions — Retry 會把面板重置展開
|
||
$('btn-retry').addEventListener('click', async () => {
|
||
try {
|
||
resetStartupPanel();
|
||
await RestartStartupSequence();
|
||
} catch (e) {
|
||
showToast('Retry failed: ' + e);
|
||
}
|
||
});
|
||
$('btn-view-log').addEventListener('click', () => {
|
||
hideStartupPanel();
|
||
flashLastError();
|
||
});
|
||
// Report 按鈕 disabled(coming soon)
|
||
|
||
// Error banner — Restart Server 會重置展開(讓使用者看新一輪 6 階段)
|
||
$('banner-restart').addEventListener('click', async () => {
|
||
try {
|
||
resetStartupPanel();
|
||
await RestartServer();
|
||
} catch (e) {
|
||
showToast('Restart failed: ' + e);
|
||
}
|
||
});
|
||
$('banner-view').addEventListener('click', () => {
|
||
flashLastError();
|
||
});
|
||
|
||
// 鍵盤快捷鍵:⌘F / Ctrl+F focus filter
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
||
e.preventDefault();
|
||
$('filter-input').focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------- 訂閱 Wails events ----------
|
||
function subscribeEvents() {
|
||
// server state / error / recovered
|
||
EventsOn('server:state-change', (payload) => handleServerStatus(payload));
|
||
EventsOn('server:error', (payload) => {
|
||
// Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error")
|
||
if (payload && payload.reason) showErrorBanner(payload.reason);
|
||
});
|
||
EventsOn('server:recovered', () => {
|
||
hideErrorBanner();
|
||
showToast('Server recovered');
|
||
});
|
||
|
||
// log stream
|
||
EventsOn('log:append', (entries) => {
|
||
if (Array.isArray(entries)) appendLogs(entries);
|
||
else if (entries) appendLogs([entries]);
|
||
});
|
||
EventsOn('log:clear', () => clearLog());
|
||
|
||
// startup pipeline
|
||
EventsOn('startup:progress', (ev) => {
|
||
if (!state.starting) {
|
||
state.starting = true;
|
||
showStartupPanel();
|
||
}
|
||
updateStage(ev);
|
||
});
|
||
EventsOn('startup:stage-timeout', (ev) => {
|
||
markStageTimeout(ev);
|
||
});
|
||
// Stage 細步 detail(例如 Stage 3 的 spawn / waitHealth / waitHealthSlow)
|
||
EventsOn('startup:stage-detail', (ev) => {
|
||
updateStageDetail(ev);
|
||
});
|
||
EventsOn('startup:error', (ev) => {
|
||
showStartupError(ev);
|
||
});
|
||
EventsOn('startup:ready', () => {
|
||
state.starting = false;
|
||
collapseStartupPanel();
|
||
});
|
||
|
||
// shutdown modal(M8-4 1 秒後顯示)
|
||
// 用 watchdog 做 safety net:popup show 後最多顯示 15 秒,即使 Go 端
|
||
// 沒 emit hide(stopGraceful 卡死、Process.Wait 阻塞等邊界情境)前端也
|
||
// 會自己 hide,避免使用者卡在無法關閉的 popup。
|
||
let shutdownModalWatchdog = null;
|
||
const hideShutdownModal = () => {
|
||
const m = document.getElementById('shutdown-modal');
|
||
if (m) m.setAttribute('hidden', '');
|
||
if (shutdownModalWatchdog) {
|
||
clearTimeout(shutdownModalWatchdog);
|
||
shutdownModalWatchdog = null;
|
||
}
|
||
};
|
||
EventsOn('shutdown:modal-show', () => {
|
||
const m = document.getElementById('shutdown-modal');
|
||
if (m) m.removeAttribute('hidden');
|
||
if (shutdownModalWatchdog) clearTimeout(shutdownModalWatchdog);
|
||
shutdownModalWatchdog = setTimeout(() => {
|
||
hideShutdownModal();
|
||
showToast('伺服器停止耗時過久,popup 已自動關閉。請用工作管理員確認 server 是否已結束。');
|
||
}, 15000);
|
||
});
|
||
// stopGraceful 結束時對稱 hide。
|
||
EventsOn('shutdown:modal-hide', hideShutdownModal);
|
||
// 點 backdrop 空白處可手動關閉 popup(Escape hatch)
|
||
const shutdownBackdrop = document.getElementById('shutdown-modal');
|
||
if (shutdownBackdrop) {
|
||
shutdownBackdrop.addEventListener('click', (e) => {
|
||
if (e.target === shutdownBackdrop) hideShutdownModal();
|
||
});
|
||
}
|
||
// Esc 鍵也可以關
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
const m = document.getElementById('shutdown-modal');
|
||
if (m && !m.hasAttribute('hidden')) hideShutdownModal();
|
||
}
|
||
});
|
||
|
||
// app level fatal(保留相容)
|
||
EventsOn('app:error', (msg) => {
|
||
showErrorBanner(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||
});
|
||
}
|
||
|
||
// ---------- 啟動 ----------
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|