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

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

View File

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

View File

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

View File

@@ -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()

View 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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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": "Переключить тему",

View File

@@ -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": "切换主题",

View File

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

View File

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

View File

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