feat: graceful shutdown with store persistence and restart overlay
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:
2026-03-24 15:50:32 +03:00
parent 73947eb6cb
commit 9b4dbac088
14 changed files with 242 additions and 72 deletions

View File

@@ -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;

View File

@@ -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 {}
};

View File

@@ -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');