回應使用者三項需求: 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>
408 lines
12 KiB
JavaScript
408 lines
12 KiB
JavaScript
// visionA-local 控制台 main entry
|
||
// - 負責 bindings / events 註冊、初始化 UI
|
||
// - M8-5 Wails 控制台 UI(對齊 Design Spec v2.1)
|
||
|
||
import {
|
||
StartServer,
|
||
StopServer,
|
||
RestartServer,
|
||
ForceKillServer,
|
||
GetServerStatusV2,
|
||
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();
|
||
|
||
// 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();
|
||
}
|