續 a209470 修 Windows 乾淨環境啟動問題。使用者回報:
- 紅 banner「伺服器無法啟動 / 啟動時間超過 60 秒」— 即 pipeline total-timeout
- 但上方狀態列顯示「執行中 :3721 PID 8568 uptime 00:00:44」— server 實際活著
- Settings popup 上疊 shutdown-modal「正在停止伺服器…」永遠卡住
三個獨立問題:
1. Stage 3 waitHealthy 在 Windows 首次啟動時,Defender real-time scan
會延遲 30-60 秒才讓 visiona-local-server.exe 真正 bind port。原本
30 秒 timeout 可能 stage-failure,且這段等候時間計入 pipeline 60 秒
total budget。修法:
(a) healthCheckTimeout 30 秒 → 60 秒
(b) startServerV2 的 waitHealthy call 在冷啟動時(IsInColdStart)
包進 Pause/Resume hard timeout — 和 Stage 2 Python bootstrap 同理,
首次 bootstrap 的 Windows Defender 掃描不該算進日常啟動預算。
Restart(pipeline 已 ready)維持嚴格計時,不 pause。
2. stopGraceful 只 emit "shutdown:modal-show" 沒有對稱的 hide event,
前端 popup 顯示後無法關閉(只能等應用重開)。修法:
(a) stopGraceful 用 defer emit "shutdown:modal-hide"(若曾 show)
(b) 前端 app.js 加對應 EventsOn listener 把 hidden attribute 設回
3. 配套:cwd bash working dir 會在 session 內持久(system prompt 明說
"working directory persists between commands"),但 env vars 不持久
— 非本次 commit 相關,僅自己的 mental note。
驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 重 build 163MB OK
給 Windows 驗證用的 log 位置:
%APPDATA%\visiona-local\logs\server.stdout.log — server 端 log
%APPDATA%\visiona-local\logs\server.stderr.log — server 端 panic / 崩潰
%APPDATA%\visiona-local\logs\wails.log — Wails app (appLog) 訊息
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
359 lines
10 KiB
JavaScript
359 lines
10 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,
|
||
updateStage,
|
||
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 {
|
||
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 error actions
|
||
$('btn-retry').addEventListener('click', async () => {
|
||
try {
|
||
await RestartStartupSequence();
|
||
} catch (e) {
|
||
showToast('Retry failed: ' + e);
|
||
}
|
||
});
|
||
$('btn-view-log').addEventListener('click', () => {
|
||
hideStartupPanel();
|
||
flashLastError();
|
||
});
|
||
// Report 按鈕 disabled(coming soon)
|
||
|
||
// Error banner
|
||
$('banner-restart').addEventListener('click', async () => {
|
||
try {
|
||
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);
|
||
});
|
||
EventsOn('startup:error', (ev) => {
|
||
showStartupError(ev);
|
||
});
|
||
EventsOn('startup:ready', () => {
|
||
state.starting = false;
|
||
hideStartupPanel();
|
||
});
|
||
|
||
// shutdown modal(M8-4 1 秒後顯示)
|
||
EventsOn('shutdown:modal-show', () => {
|
||
const m = document.getElementById('shutdown-modal');
|
||
if (m) m.removeAttribute('hidden');
|
||
});
|
||
// stopGraceful 結束時對稱 hide,避免 popup 卡住。
|
||
EventsOn('shutdown:modal-hide', () => {
|
||
const m = document.getElementById('shutdown-modal');
|
||
if (m) m.setAttribute('hidden', '');
|
||
});
|
||
|
||
// 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();
|
||
}
|