feat: graceful shutdown with store persistence and restart overlay
Some checks failed
Lint & Test / test (push) Failing after 29s
Some checks failed
Lint & Test / test (push) Failing after 29s
- Add /api/v1/system/shutdown endpoint that triggers clean uvicorn exit - Persist all 15 stores to disk during shutdown via _save_all_stores() - Add force parameter to BaseJsonStore._save() to bypass restore freeze - Restart script now requests graceful shutdown via API (15s timeout), falls back to force-kill only if server doesn't exit in time - Broadcast server_restarting event over WebSocket before shutdown - Frontend shows "Server restarting..." overlay instantly on WS event, replacing the old dynamically-created overlay from settings.ts - Add server_ref module to share uvicorn Server + TrayManager refs - Add i18n keys for restart overlay (en/ru/zh)
This commit is contained in:
@@ -6,6 +6,7 @@ import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setR
|
||||
import { t } from './i18n.ts';
|
||||
import { showToast } from './ui.ts';
|
||||
import { getEl, queryEl } from './dom-utils.ts';
|
||||
import { serverRestarting, clearRestartingFlag } from './events-ws.ts';
|
||||
|
||||
export const API_BASE = '/api/v1';
|
||||
|
||||
@@ -168,6 +169,30 @@ export function handle401Error() {
|
||||
let _connCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let _serverOnline: boolean | null = null; // null = unknown, true/false
|
||||
|
||||
/** Toggle which message block is visible inside the connection overlay. */
|
||||
function _setOverlayMode(restarting: boolean) {
|
||||
const msgOffline = document.getElementById('conn-msg-offline');
|
||||
const msgRestarting = document.getElementById('conn-msg-restarting');
|
||||
if (msgOffline) msgOffline.style.display = restarting ? 'none' : '';
|
||||
if (msgRestarting) msgRestarting.style.display = restarting ? '' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the restart overlay immediately (called when server_restarting
|
||||
* event arrives via WebSocket, before the connection actually drops).
|
||||
*/
|
||||
export function showRestartingOverlay() {
|
||||
_serverOnline = false;
|
||||
const banner = document.getElementById('connection-overlay');
|
||||
const badge = document.getElementById('server-status');
|
||||
if (banner) {
|
||||
(banner as HTMLElement).style.display = 'flex';
|
||||
banner.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
_setOverlayMode(true);
|
||||
if (badge) badge.className = 'status-badge offline';
|
||||
}
|
||||
|
||||
function _setConnectionState(online: boolean) {
|
||||
const changed = _serverOnline !== online;
|
||||
_serverOnline = online;
|
||||
@@ -176,8 +201,14 @@ function _setConnectionState(online: boolean) {
|
||||
if (online) {
|
||||
if (banner) { (banner as HTMLElement).style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
|
||||
if (badge) badge.className = 'status-badge online';
|
||||
// Clear the restarting flag once the server is back
|
||||
if (serverRestarting) clearRestartingFlag();
|
||||
} else {
|
||||
if (banner) { (banner as HTMLElement).style.display = 'flex'; banner.setAttribute('aria-hidden', 'false'); }
|
||||
if (banner) {
|
||||
(banner as HTMLElement).style.display = 'flex';
|
||||
banner.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
_setOverlayMode(serverRestarting);
|
||||
if (badge) badge.className = 'status-badge offline';
|
||||
}
|
||||
return changed;
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
*/
|
||||
|
||||
import { apiKey, authRequired } from './state.ts';
|
||||
import { showRestartingOverlay } from './api.ts';
|
||||
|
||||
/** True when the server has signalled it is restarting (not crashed). */
|
||||
export let serverRestarting = false;
|
||||
|
||||
export function clearRestartingFlag() {
|
||||
serverRestarting = false;
|
||||
}
|
||||
|
||||
let _ws: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -32,6 +40,10 @@ export function startEventsWS() {
|
||||
_ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'server_restarting') {
|
||||
serverRestarting = true;
|
||||
showRestartingOverlay();
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent(`server:${data.type}`, { detail: data }));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
@@ -347,9 +347,6 @@ export async function handleRestoreFileSelected(input: HTMLInputElement): Promis
|
||||
showToast(data.message || t('settings.restore.success'), 'success');
|
||||
settingsModal.forceClose();
|
||||
|
||||
if (data.restart_scheduled) {
|
||||
showRestartOverlay();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Restore failed:', err);
|
||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||
@@ -369,63 +366,12 @@ export async function restartServer(): Promise<void> {
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
settingsModal.forceClose();
|
||||
showRestartOverlay(t('settings.restarting'));
|
||||
} catch (err) {
|
||||
console.error('Server restart failed:', err);
|
||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Restart overlay ───────────────────────────────────────
|
||||
|
||||
function showRestartOverlay(message?: string): void {
|
||||
const msg = message || t('settings.restore.restarting');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'restart-overlay';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;z-index:100000;display:flex;flex-direction:column;' +
|
||||
'align-items:center;justify-content:center;background:rgba(0,0,0,0.85);color:#fff;font-size:1.2rem;';
|
||||
overlay.innerHTML =
|
||||
'<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' +
|
||||
'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' +
|
||||
`<div id="restart-msg">${msg}</div>`;
|
||||
|
||||
// Add spinner animation if not present
|
||||
if (!document.getElementById('restart-spinner-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'restart-spinner-style';
|
||||
style.textContent = '@keyframes spin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
pollHealth();
|
||||
}
|
||||
|
||||
function pollHealth(): void {
|
||||
const start = Date.now();
|
||||
const maxWait = 30000;
|
||||
const interval = 1500;
|
||||
|
||||
const check = async () => {
|
||||
if (Date.now() - start > maxWait) {
|
||||
const msg = document.getElementById('restart-msg');
|
||||
if (msg) msg.textContent = t('settings.restore.restart_timeout');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/health', { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch { /* server still down */ }
|
||||
setTimeout(check, interval);
|
||||
};
|
||||
// Wait a moment before first check to let the server shut down
|
||||
setTimeout(check, 2000);
|
||||
}
|
||||
|
||||
// ─── Auto-Backup settings ─────────────────────────────────
|
||||
|
||||
export async function loadAutoBackupSettings(): Promise<void> {
|
||||
@@ -567,9 +513,6 @@ export async function restoreSavedBackup(filename: string): Promise<void> {
|
||||
showToast(data.message || t('settings.restore.success'), 'success');
|
||||
settingsModal.forceClose();
|
||||
|
||||
if (data.restart_scheduled) {
|
||||
showRestartOverlay();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Restore from saved backup failed:', err);
|
||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||
@@ -687,9 +630,6 @@ export async function handlePartialImportFileSelected(input: HTMLInputElement):
|
||||
showToast(data.message || t('settings.partial.import_success'), 'success');
|
||||
settingsModal.forceClose();
|
||||
|
||||
if (data.restart_scheduled) {
|
||||
showRestartOverlay();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Partial import failed:', err);
|
||||
showToast(t('settings.partial.import_error') + ': ' + err.message, 'error');
|
||||
|
||||
Reference in New Issue
Block a user