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:
@@ -1,12 +1,76 @@
|
|||||||
# Restart the WLED Screen Controller server
|
# Restart the WLED Screen Controller server
|
||||||
# Stop any running instance
|
# Uses graceful shutdown first (lets the server persist data to disk),
|
||||||
|
# then force-kills as a fallback.
|
||||||
|
|
||||||
|
$serverRoot = 'c:\Users\Alexei\Documents\wled-screen-controller\server'
|
||||||
|
|
||||||
|
# Read API key from config for authenticated shutdown request
|
||||||
|
$configPath = Join-Path $serverRoot 'config\default_config.yaml'
|
||||||
|
$apiKey = $null
|
||||||
|
if (Test-Path $configPath) {
|
||||||
|
$inKeys = $false
|
||||||
|
foreach ($line in Get-Content $configPath) {
|
||||||
|
if ($line -match '^\s*api_keys:') { $inKeys = $true; continue }
|
||||||
|
if ($inKeys -and $line -match '^\s+\w+:\s*"(.+)"') {
|
||||||
|
$apiKey = $Matches[1]; break
|
||||||
|
}
|
||||||
|
if ($inKeys -and $line -match '^\S') { break } # left the api_keys block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find running server processes
|
||||||
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
$procs = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||||
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||||
foreach ($p in $procs) {
|
|
||||||
|
if ($procs) {
|
||||||
|
# Step 1: Request graceful shutdown via API (triggers lifespan shutdown + store save)
|
||||||
|
$shutdownOk = $false
|
||||||
|
if ($apiKey) {
|
||||||
|
Write-Host "Requesting graceful shutdown..."
|
||||||
|
try {
|
||||||
|
$headers = @{ Authorization = "Bearer $apiKey" }
|
||||||
|
Invoke-RestMethod -Uri 'http://localhost:8080/api/v1/system/shutdown' `
|
||||||
|
-Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop | Out-Null
|
||||||
|
$shutdownOk = $true
|
||||||
|
} catch {
|
||||||
|
Write-Host " API shutdown failed ($($_.Exception.Message)), falling back to process kill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($shutdownOk) {
|
||||||
|
# Step 2: Wait for the server to exit gracefully (up to 15 seconds)
|
||||||
|
# The server needs time to stop processors, disconnect devices, and persist stores.
|
||||||
|
Write-Host "Waiting for graceful shutdown..."
|
||||||
|
$waited = 0
|
||||||
|
while ($waited -lt 15) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
$waited++
|
||||||
|
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||||
|
if (-not $still) {
|
||||||
|
Write-Host " Server exited cleanly after ${waited}s"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Step 3: Force-kill stragglers
|
||||||
|
$still = Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -like '*wled_controller*' -and $_.CommandLine -notlike '*demo*' -and $_.CommandLine -notlike '*vscode*' -and $_.CommandLine -notlike '*isort*' }
|
||||||
|
if ($still) {
|
||||||
|
Write-Host " Force-killing remaining processes..."
|
||||||
|
foreach ($p in $still) {
|
||||||
|
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# No API key or API call failed — force-kill directly
|
||||||
|
foreach ($p in $procs) {
|
||||||
Write-Host "Stopping server (PID $($p.ProcessId))..."
|
Write-Host "Stopping server (PID $($p.ProcessId))..."
|
||||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($procs) { Start-Sleep -Seconds 2 }
|
|
||||||
|
|
||||||
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
|
# Merge registry PATH with current PATH so newly-installed tools (e.g. scrcpy) are visible
|
||||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||||
@@ -19,15 +83,16 @@ if ($regUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start server detached
|
# Start server detached (set WLED_RESTART=1 to skip browser open)
|
||||||
Write-Host "Starting server..."
|
Write-Host "Starting server..."
|
||||||
|
$env:WLED_RESTART = "1"
|
||||||
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
|
$pythonExe = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||||
if (-not $pythonExe) {
|
if (-not $pythonExe) {
|
||||||
# Fallback to known install location
|
# Fallback to known install location
|
||||||
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe"
|
$pythonExe = "$env:LOCALAPPDATA\Programs\Python\Python313\python.exe"
|
||||||
}
|
}
|
||||||
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'wled_controller' `
|
Start-Process -FilePath $pythonExe -ArgumentList '-m', 'wled_controller' `
|
||||||
-WorkingDirectory 'c:\Users\Alexei\Documents\wled-screen-controller\server' `
|
-WorkingDirectory $serverRoot `
|
||||||
-WindowStyle Hidden
|
-WindowStyle Hidden
|
||||||
|
|
||||||
Start-Sleep -Seconds 3
|
Start-Sleep -Seconds 3
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from wled_controller.config import get_config
|
from wled_controller.config import get_config
|
||||||
|
from wled_controller.server_ref import set_server, set_tray
|
||||||
from wled_controller.tray import PYSTRAY_AVAILABLE, TrayManager
|
from wled_controller.tray import PYSTRAY_AVAILABLE, TrayManager
|
||||||
from wled_controller.utils import setup_logging, get_logger
|
from wled_controller.utils import setup_logging, get_logger
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ def main() -> None:
|
|||||||
log_level=config.server.log_level.lower(),
|
log_level=config.server.log_level.lower(),
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(uv_config)
|
server = uvicorn.Server(uv_config)
|
||||||
|
set_server(server)
|
||||||
|
|
||||||
use_tray = PYSTRAY_AVAILABLE and (
|
use_tray = PYSTRAY_AVAILABLE and (
|
||||||
sys.platform == "win32" or _force_tray()
|
sys.platform == "win32" or _force_tray()
|
||||||
@@ -80,6 +82,7 @@ def main() -> None:
|
|||||||
port=config.server.port,
|
port=config.server.port,
|
||||||
on_exit=lambda: _request_shutdown(server),
|
on_exit=lambda: _request_shutdown(server),
|
||||||
)
|
)
|
||||||
|
set_tray(tray)
|
||||||
tray.run()
|
tray.run()
|
||||||
|
|
||||||
# Tray exited — wait for server to finish its graceful shutdown
|
# Tray exited — wait for server to finish its graceful shutdown
|
||||||
|
|||||||
@@ -228,10 +228,25 @@ def backup_config(_: AuthRequired):
|
|||||||
@router.post("/api/v1/system/restart", tags=["System"])
|
@router.post("/api/v1/system/restart", tags=["System"])
|
||||||
def restart_server(_: AuthRequired):
|
def restart_server(_: AuthRequired):
|
||||||
"""Schedule a server restart and return immediately."""
|
"""Schedule a server restart and return immediately."""
|
||||||
|
from wled_controller.server_ref import _broadcast_restarting
|
||||||
|
_broadcast_restarting()
|
||||||
_schedule_restart()
|
_schedule_restart()
|
||||||
return {"status": "restarting"}
|
return {"status": "restarting"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/system/shutdown", tags=["System"])
|
||||||
|
def shutdown_server(_: AuthRequired):
|
||||||
|
"""Gracefully shut down the server.
|
||||||
|
|
||||||
|
Signals uvicorn to exit, which triggers the lifespan shutdown handler
|
||||||
|
(persists all stores to disk, stops processors, etc.).
|
||||||
|
Used by the restart script to ensure data is saved before the process exits.
|
||||||
|
"""
|
||||||
|
from wled_controller.server_ref import request_shutdown
|
||||||
|
request_shutdown()
|
||||||
|
return {"status": "shutting_down"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
@router.post("/api/v1/system/restore", response_model=RestoreResponse, tags=["System"])
|
||||||
async def restore_config(
|
async def restore_config(
|
||||||
_: AuthRequired,
|
_: AuthRequired,
|
||||||
|
|||||||
@@ -92,6 +92,29 @@ processor_manager = ProcessorManager(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all_stores() -> None:
|
||||||
|
"""Persist every store to disk.
|
||||||
|
|
||||||
|
Called during graceful shutdown to ensure in-memory data survives
|
||||||
|
restarts even if no CRUD happened during the session.
|
||||||
|
"""
|
||||||
|
all_stores = [
|
||||||
|
device_store, template_store, pp_template_store,
|
||||||
|
picture_source_store, output_target_store, pattern_template_store,
|
||||||
|
color_strip_store, audio_source_store, audio_template_store,
|
||||||
|
value_source_store, automation_store, scene_preset_store,
|
||||||
|
sync_clock_store, cspt_store, gradient_store,
|
||||||
|
]
|
||||||
|
saved = 0
|
||||||
|
for store in all_stores:
|
||||||
|
try:
|
||||||
|
store._save(force=True)
|
||||||
|
saved += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save {store._json_key} on shutdown: {e}")
|
||||||
|
logger.info(f"Shutdown save: persisted {saved}/{len(all_stores)} stores to disk")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan manager.
|
"""Application lifespan manager.
|
||||||
@@ -230,6 +253,11 @@ async def lifespan(app: FastAPI):
|
|||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down LED Grab")
|
logger.info("Shutting down LED Grab")
|
||||||
|
|
||||||
|
# Persist all stores to disk before stopping anything.
|
||||||
|
# This ensures in-memory data survives force-kills and restarts
|
||||||
|
# where no CRUD happened during the session.
|
||||||
|
_save_all_stores()
|
||||||
|
|
||||||
# Stop auto-backup engine
|
# Stop auto-backup engine
|
||||||
try:
|
try:
|
||||||
await auto_backup_engine.stop()
|
await auto_backup_engine.stop()
|
||||||
|
|||||||
59
server/src/wled_controller/server_ref.py
Normal file
59
server/src/wled_controller/server_ref.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Module-level holder for the uvicorn Server and TrayManager references.
|
||||||
|
|
||||||
|
Allows the shutdown API endpoint to trigger graceful shutdown via
|
||||||
|
``server.should_exit = True`` + ``tray.stop()``, which is the same
|
||||||
|
mechanism the system tray "Shutdown" menu item uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
_server: Optional[Any] = None # uvicorn.Server
|
||||||
|
_tray: Optional[Any] = None # TrayManager
|
||||||
|
|
||||||
|
|
||||||
|
def set_server(server: Any) -> None:
|
||||||
|
"""Store the uvicorn Server instance (called from __main__)."""
|
||||||
|
global _server
|
||||||
|
_server = server
|
||||||
|
|
||||||
|
|
||||||
|
def set_tray(tray: Any) -> None:
|
||||||
|
"""Store the TrayManager instance (called from __main__)."""
|
||||||
|
global _tray
|
||||||
|
_tray = tray
|
||||||
|
|
||||||
|
|
||||||
|
def request_shutdown() -> None:
|
||||||
|
"""Signal uvicorn + tray to perform a graceful shutdown.
|
||||||
|
|
||||||
|
Broadcasts a ``server_restarting`` event so the frontend can show
|
||||||
|
a restart indicator, persists all stores to disk, then sets
|
||||||
|
``should_exit = True`` on the uvicorn Server and stops the tray.
|
||||||
|
"""
|
||||||
|
# Notify connected clients that a restart is in progress
|
||||||
|
_broadcast_restarting()
|
||||||
|
|
||||||
|
# Persist stores before signaling shutdown.
|
||||||
|
# The lifespan shutdown handler also saves, but it may not run
|
||||||
|
# reliably when uvicorn is in a daemon thread.
|
||||||
|
try:
|
||||||
|
from wled_controller.main import _save_all_stores
|
||||||
|
_save_all_stores()
|
||||||
|
except Exception:
|
||||||
|
pass # best-effort; lifespan handler is the backup
|
||||||
|
|
||||||
|
if _server is not None:
|
||||||
|
_server.should_exit = True
|
||||||
|
if _tray is not None:
|
||||||
|
_tray.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_restarting() -> None:
|
||||||
|
"""Push a server_restarting event to all connected WebSocket clients."""
|
||||||
|
try:
|
||||||
|
from wled_controller.api.dependencies import _deps
|
||||||
|
pm = _deps.get("processor_manager")
|
||||||
|
if pm is not None:
|
||||||
|
pm.fire_event({"type": "server_restarting"})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -6,6 +6,7 @@ import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setR
|
|||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
import { showToast } from './ui.ts';
|
import { showToast } from './ui.ts';
|
||||||
import { getEl, queryEl } from './dom-utils.ts';
|
import { getEl, queryEl } from './dom-utils.ts';
|
||||||
|
import { serverRestarting, clearRestartingFlag } from './events-ws.ts';
|
||||||
|
|
||||||
export const API_BASE = '/api/v1';
|
export const API_BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -168,6 +169,30 @@ export function handle401Error() {
|
|||||||
let _connCheckTimer: ReturnType<typeof setInterval> | null = null;
|
let _connCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let _serverOnline: boolean | null = null; // null = unknown, true/false
|
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) {
|
function _setConnectionState(online: boolean) {
|
||||||
const changed = _serverOnline !== online;
|
const changed = _serverOnline !== online;
|
||||||
_serverOnline = online;
|
_serverOnline = online;
|
||||||
@@ -176,8 +201,14 @@ function _setConnectionState(online: boolean) {
|
|||||||
if (online) {
|
if (online) {
|
||||||
if (banner) { (banner as HTMLElement).style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
|
if (banner) { (banner as HTMLElement).style.display = 'none'; banner.setAttribute('aria-hidden', 'true'); }
|
||||||
if (badge) badge.className = 'status-badge online';
|
if (badge) badge.className = 'status-badge online';
|
||||||
|
// Clear the restarting flag once the server is back
|
||||||
|
if (serverRestarting) clearRestartingFlag();
|
||||||
} else {
|
} 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';
|
if (badge) badge.className = 'status-badge offline';
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiKey, authRequired } from './state.ts';
|
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 _ws: WebSocket | null = null;
|
||||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -32,6 +40,10 @@ export function startEventsWS() {
|
|||||||
_ws.onmessage = (event) => {
|
_ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'server_restarting') {
|
||||||
|
serverRestarting = true;
|
||||||
|
showRestartingOverlay();
|
||||||
|
}
|
||||||
document.dispatchEvent(new CustomEvent(`server:${data.type}`, { detail: data }));
|
document.dispatchEvent(new CustomEvent(`server:${data.type}`, { detail: data }));
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -347,9 +347,6 @@ export async function handleRestoreFileSelected(input: HTMLInputElement): Promis
|
|||||||
showToast(data.message || t('settings.restore.success'), 'success');
|
showToast(data.message || t('settings.restore.success'), 'success');
|
||||||
settingsModal.forceClose();
|
settingsModal.forceClose();
|
||||||
|
|
||||||
if (data.restart_scheduled) {
|
|
||||||
showRestartOverlay();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Restore failed:', err);
|
console.error('Restore failed:', err);
|
||||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
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}`);
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
}
|
}
|
||||||
settingsModal.forceClose();
|
settingsModal.forceClose();
|
||||||
showRestartOverlay(t('settings.restarting'));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Server restart failed:', err);
|
console.error('Server restart failed:', err);
|
||||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
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 ─────────────────────────────────
|
// ─── Auto-Backup settings ─────────────────────────────────
|
||||||
|
|
||||||
export async function loadAutoBackupSettings(): Promise<void> {
|
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');
|
showToast(data.message || t('settings.restore.success'), 'success');
|
||||||
settingsModal.forceClose();
|
settingsModal.forceClose();
|
||||||
|
|
||||||
if (data.restart_scheduled) {
|
|
||||||
showRestartOverlay();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Restore from saved backup failed:', err);
|
console.error('Restore from saved backup failed:', err);
|
||||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
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');
|
showToast(data.message || t('settings.partial.import_success'), 'success');
|
||||||
settingsModal.forceClose();
|
settingsModal.forceClose();
|
||||||
|
|
||||||
if (data.restart_scheduled) {
|
|
||||||
showRestartOverlay();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Partial import failed:', err);
|
console.error('Partial import failed:', err);
|
||||||
showToast(t('settings.partial.import_error') + ': ' + err.message, 'error');
|
showToast(t('settings.partial.import_error') + ': ' + err.message, 'error');
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"app.api_docs": "API Documentation",
|
"app.api_docs": "API Documentation",
|
||||||
"app.connection_lost": "Server unreachable",
|
"app.connection_lost": "Server unreachable",
|
||||||
"app.connection_retrying": "Attempting to reconnect…",
|
"app.connection_retrying": "Attempting to reconnect…",
|
||||||
|
"app.server_restarting": "Server restarting…",
|
||||||
|
"app.server_restarting_sub": "Please wait, the server will be back shortly.",
|
||||||
"demo.badge": "DEMO",
|
"demo.badge": "DEMO",
|
||||||
"demo.banner": "You're in demo mode — all devices and data are virtual. No real hardware is used.",
|
"demo.banner": "You're in demo mode — all devices and data are virtual. No real hardware is used.",
|
||||||
"theme.toggle": "Toggle theme",
|
"theme.toggle": "Toggle theme",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"app.api_docs": "Документация API",
|
"app.api_docs": "Документация API",
|
||||||
"app.connection_lost": "Сервер недоступен",
|
"app.connection_lost": "Сервер недоступен",
|
||||||
"app.connection_retrying": "Попытка переподключения…",
|
"app.connection_retrying": "Попытка переподключения…",
|
||||||
|
"app.server_restarting": "Сервер перезапускается…",
|
||||||
|
"app.server_restarting_sub": "Пожалуйста, подождите, сервер скоро вернётся.",
|
||||||
"demo.badge": "ДЕМО",
|
"demo.badge": "ДЕМО",
|
||||||
"demo.banner": "Вы в демо-режиме — все устройства и данные виртуальные. Реальное оборудование не используется.",
|
"demo.banner": "Вы в демо-режиме — все устройства и данные виртуальные. Реальное оборудование не используется.",
|
||||||
"theme.toggle": "Переключить тему",
|
"theme.toggle": "Переключить тему",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"app.api_docs": "API 文档",
|
"app.api_docs": "API 文档",
|
||||||
"app.connection_lost": "服务器不可达",
|
"app.connection_lost": "服务器不可达",
|
||||||
"app.connection_retrying": "正在尝试重新连接…",
|
"app.connection_retrying": "正在尝试重新连接…",
|
||||||
|
"app.server_restarting": "服务器正在重启…",
|
||||||
|
"app.server_restarting_sub": "请稍候,服务器即将恢复。",
|
||||||
"demo.badge": "演示",
|
"demo.badge": "演示",
|
||||||
"demo.banner": "您正处于演示模式 — 所有设备和数据均为虚拟。未使用任何真实硬件。",
|
"demo.banner": "您正处于演示模式 — 所有设备和数据均为虚拟。未使用任何真实硬件。",
|
||||||
"theme.toggle": "切换主题",
|
"theme.toggle": "切换主题",
|
||||||
|
|||||||
@@ -98,15 +98,18 @@ class BaseJsonStore(Generic[T]):
|
|||||||
f"{self._entity_name} store initialized with {len(self._items)} items"
|
f"{self._entity_name} store initialized with {len(self._items)} items"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self, *, force: bool = False) -> None:
|
||||||
"""Persist all items to disk atomically.
|
"""Persist all items to disk atomically.
|
||||||
|
|
||||||
Note: This is synchronous blocking I/O. When called from async route
|
Note: This is synchronous blocking I/O. When called from async route
|
||||||
handlers, it briefly blocks the event loop (typically < 5ms for small
|
handlers, it briefly blocks the event loop (typically < 5ms for small
|
||||||
stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
|
stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
|
||||||
Callers must hold ``self._lock``.
|
Callers must hold ``self._lock``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force: bypass the ``_saves_frozen`` flag (used by shutdown save).
|
||||||
"""
|
"""
|
||||||
if _saves_frozen:
|
if _saves_frozen and not force:
|
||||||
logger.warning(f"Save blocked (frozen after restore): {self._json_key}")
|
logger.warning(f"Save blocked (frozen after restore): {self._json_key}")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -25,9 +25,15 @@
|
|||||||
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
|
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
|
||||||
<div class="connection-overlay-content">
|
<div class="connection-overlay-content">
|
||||||
<div class="connection-spinner-lg"></div>
|
<div class="connection-spinner-lg"></div>
|
||||||
|
<div id="conn-msg-offline">
|
||||||
<h2 data-i18n="app.connection_lost">Server unreachable</h2>
|
<h2 data-i18n="app.connection_lost">Server unreachable</h2>
|
||||||
<p data-i18n="app.connection_retrying">Attempting to reconnect…</p>
|
<p data-i18n="app.connection_retrying">Attempting to reconnect…</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="conn-msg-restarting" style="display:none">
|
||||||
|
<h2 data-i18n="app.server_restarting">Server restarting…</h2>
|
||||||
|
<p data-i18n="app.server_restarting_sub">Please wait, the server will be back shortly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<header>
|
<header>
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class TrayManager:
|
|||||||
def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||||
if not messagebox.askyesno("LED Grab", "Restart the server?"):
|
if not messagebox.askyesno("LED Grab", "Restart the server?"):
|
||||||
return
|
return
|
||||||
|
from wled_controller.server_ref import _broadcast_restarting
|
||||||
|
_broadcast_restarting()
|
||||||
self._icon.stop()
|
self._icon.stop()
|
||||||
self._on_exit()
|
self._on_exit()
|
||||||
os.environ["WLED_RESTART"] = "1"
|
os.environ["WLED_RESTART"] = "1"
|
||||||
|
|||||||
Reference in New Issue
Block a user