回應使用者三項需求:
1. 整體 hard timeout 180s → 300s(5 分鐘)
每個 stage 已有 soft timeout 20s 提示機制,整體 budget 不需緊湊。
5 分鐘是「使用者點完一杯咖啡都還沒好」的心理上限。pause 機制
(Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy)
仍維持作為「一次性 bootstrap 完全不算 budget」的快速通道。
- 同步更新 i18n 紅 banner 文案 180 → 5 分鐘
- 同步更新 unit tests(HardTimeout 用 -305s,SkipBypass 用 -320s,
PreventsHardTimeout 註解 effective<300s)
2. Stage 6「等待 Web UI 連線」從 6 階段面板隱藏到 header 連線指示燈
Go 端 pipeline 仍保持 6 階段(不動),前端 UI 只顯示 5 階段:
- startup-panel.js: TOTAL_STAGES=5 顯示用,PIPELINE_STAGES=6 內部
state 用。renderStages / paintProgressBar / 進度數字都用 5。
- updateStage 仍會收 stage 6 events 更新內部 state(控 collapse 時機)
但 stage 6 不 paint UI(n > TOTAL_STAGES early return)
- 新增 onConnectionStatusChange listener 機制:stage 6 status 變化
時通知外層
- control-panel.js: setWebUIStatus 把連線狀態 (pending/running/
completed/failed) 渲染到 header 的 meta-webui 指示燈:圓點顏色
+ 文字 (等待連線/已連線/未連線)
- index.html: server-meta 新增 <dd id="meta-webui"> 指示燈位置
- i18n: control.meta.webui / control.webui.{connected,waiting,disconnected}
- style.css: .webui-status::before 圓點 + pulse 動畫 + 顏色對應
state (pending=灰 / running=warning+pulse / connected=success / failed=destructive)
- app.js: 註冊 onConnectionStatusChange listener,初始化呼叫
setWebUIStatus('pending')
3. 全屏 spinner splash 取代「啟動中...」三個字
原本 app 啟動最一開始的「啟動中」狀態只有 header 上三個字很不
明顯,使用者體感像沒反應。改為 DOM ready 時就顯示 fullscreen
spinner overlay,收到第一個 startup:progress event 才隱藏。
- index.html: <div id="boot-splash"> 內含 logo + spinner-lg + 文字
- style.css: .boot-splash position:fixed inset:0 z-index:1000,
.boot-splash.hidden { display:none } 用 class 控制(避免和
[hidden]!important 衝突)
- app.js: hideBootSplash() helper,4 個 hide 觸發點:
(a) 收到 startup:progress event
(b) snapshot 補漏發現 pipeline 已啟動
(c) 收到 startup:error event(即使失敗也要看到錯誤)
(d) handleServerStatus 收到非 idle 狀態(restart wails app
server 還活著的情境)
更新 fix marker 為「d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)」
驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
465 lines
15 KiB
JavaScript
465 lines
15 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,
|
||
setWebUIStatus,
|
||
STATE_ERROR,
|
||
} from './control-panel.js';
|
||
import {
|
||
showStartupPanel,
|
||
hideStartupPanel,
|
||
collapseStartupPanel,
|
||
expandStartupPanel,
|
||
resetStartupPanel,
|
||
updateStage,
|
||
updateStageDetail,
|
||
markStageTimeout,
|
||
showStartupError,
|
||
renderStages,
|
||
onManualModeChange,
|
||
onConnectionStatusChange,
|
||
} 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, // 啟動進度面板是否顯示
|
||
};
|
||
|
||
// ---------- Boot splash 控制 ----------
|
||
// 全屏 spinner overlay。DOM ready 即顯示,收到第一個 startup:progress event
|
||
// 或 init 完成後拉 snapshot 發現 pipeline 已啟動時 hide。
|
||
function hideBootSplash() {
|
||
const splash = document.getElementById('boot-splash');
|
||
if (splash) splash.classList.add('hidden');
|
||
}
|
||
|
||
// ---------- 初始化 ----------
|
||
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;
|
||
hideBootSplash();
|
||
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);
|
||
});
|
||
|
||
// 12. Web UI 連線指示燈:startup-panel 偵測到 stage 6 status 變化時通知
|
||
onConnectionStatusChange((status) => {
|
||
setWebUIStatus(status);
|
||
});
|
||
// 初始一次(pending)
|
||
setWebUIStatus('pending');
|
||
}
|
||
|
||
// ---------- 處理 server status ----------
|
||
function handleServerStatus(status) {
|
||
if (!status) return;
|
||
state.server = status;
|
||
setServerState(status);
|
||
updateServerMeta(status);
|
||
updatePrimaryControls(status);
|
||
|
||
// 任何非 idle 狀態都代表 server 已經被 ServerController 接管 → hide splash
|
||
if (status.state && status.state !== 'idle') {
|
||
hideBootSplash();
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
// 收到第一個 progress event 即 hide 全屏 splash
|
||
hideBootSplash();
|
||
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) => {
|
||
hideBootSplash();
|
||
showStartupError(ev);
|
||
});
|
||
EventsOn('startup:ready', () => {
|
||
hideBootSplash();
|
||
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();
|
||
}
|