From 3f80ef2101431b15c0d51a4519258b1be31db699 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 25 Apr 2026 15:10:48 +0300 Subject: [PATCH] feat: server shutdown action with public cancel_task lifecycle method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users choose what happens to LED targets when the server shuts down. Default ("stop_targets") runs the existing per-device stop sequence, so devices with auto-restore replay their prior state. "Nothing" cancels the capture tasks without sending restore frames, so the LEDs keep displaying their last frame on shutdown. Backend: - New setting ``shutdown_action`` persisted in db.settings (``stop_targets`` default | ``nothing``) with GET/PUT ``/api/v1/system/shutdown-action`` endpoints - ``ProcessorManager.stop_all(restore_devices: bool = True)`` now picks the path based on the flag — ``proc.stop()`` for the normal branch, public ``proc.cancel_task()`` for the "nothing" branch. - ``TargetProcessor.cancel_task()`` (new, on the abstract base) cancels the loop task and *awaits* its termination so no half-written frame is in flight when the process exits. Replaces an earlier draft that reached into the private ``_task`` attribute via ``getattr``. - Lifespan in ``main.py`` reads the setting at shutdown and forwards the flag; falls back to ``stop_targets`` on any read error. - ``/health`` exposes ``uptime_seconds`` (process-wide monotonic clock captured at first import of ``api.routes.system``) so the WebUI can show the *server's* uptime instead of the browser session's. Browser launch: - ``__main__._open_browser`` now polls ``/health`` for up to 30 s instead of sleeping a flat 2 s, so the tab opens once the server actually accepts requests. Frontend: - New "Shutdown action" picker in Settings → General, rendered via IconSelect with ICON_SQUARE / ICON_CIRCLE (added to ``core/icons.ts`` + ``circle`` path to ``icon-paths.ts``). - Transport-bar uptime ticker reads ``window.__serverUptime`` (typed in ``global.d.ts``); shows "—" until the first /health response lands so refresh doesn't briefly flash 00:00:00. After 99 h the format widens to "Dd HH:MM:SS". - New i18n keys for the action picker (label, hint, opt.stop / opt.nothing + descriptions, saved / save_error toasts) in en/ru/zh. No data migration needed — the setting is additive and defaults to the existing behavior. --- server/src/ledgrab/__main__.py | 24 +++++- server/src/ledgrab/api/routes/system.py | 9 +++ .../src/ledgrab/api/routes/system_settings.py | 52 ++++++++++++ server/src/ledgrab/api/schemas/system.py | 30 +++++++ .../core/processing/processor_manager.py | 51 +++++++++--- .../core/processing/target_processor.py | 30 +++++++ server/src/ledgrab/main.py | 17 +++- server/src/ledgrab/static/js/app.ts | 3 + server/src/ledgrab/static/js/core/api.ts | 13 +++ .../src/ledgrab/static/js/core/icon-paths.ts | 1 + server/src/ledgrab/static/js/core/icons.ts | 2 + .../ledgrab/static/js/features/settings.ts | 81 ++++++++++++++++++- server/src/ledgrab/static/js/global.d.ts | 10 +++ server/src/ledgrab/static/locales/en.json | 8 ++ server/src/ledgrab/static/locales/ru.json | 8 ++ server/src/ledgrab/static/locales/zh.json | 8 ++ server/src/ledgrab/templates/index.html | 23 ++++-- .../ledgrab/templates/modals/settings.html | 13 +++ 18 files changed, 359 insertions(+), 24 deletions(-) diff --git a/server/src/ledgrab/__main__.py b/server/src/ledgrab/__main__.py index 7402001..c11d8ac 100644 --- a/server/src/ledgrab/__main__.py +++ b/server/src/ledgrab/__main__.py @@ -12,6 +12,8 @@ import threading import time import webbrowser from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen def _fix_embedded_tcl_paths() -> None: @@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None: loop.run_until_complete(server.serve()) -def _open_browser(port: int, delay: float = 2.0) -> None: - """Open the UI in the default browser after a short delay.""" - time.sleep(delay) +def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool: + """Poll /health until the server responds or *timeout* seconds elapse.""" + url = f"http://localhost:{port}/health" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urlopen(url, timeout=1) as resp: # noqa: S310 - localhost only + if 200 <= resp.status < 500: + return True + except (URLError, ConnectionError, OSError, TimeoutError): + pass + time.sleep(interval) + return False + + +def _open_browser(port: int) -> None: + """Open the UI in the default browser once the server is ready.""" + if not _wait_for_server(port): + logger.warning("Server did not become ready in time; opening browser anyway") webbrowser.open(f"http://localhost:{port}") diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py index 190ceac..b94876f 100644 --- a/server/src/ledgrab/api/routes/system.py +++ b/server/src/ledgrab/api/routes/system.py @@ -7,6 +7,7 @@ import asyncio import platform import subprocess import sys +import time from datetime import datetime, timezone from typing import Optional @@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None: _cpu_name: str | None = _get_cpu_name() +# Captured at first import of this module. Process-wide elapsed time is +# the closest the server has to "app start" without instrumenting main.py; +# the system module is imported during router setup, before the server +# accepts requests, so the drift is negligible. Used by /health to expose +# uptime_seconds for the transport-bar ticker. +_APP_START_MONOTONIC: float = time.monotonic() + router = APIRouter() @@ -122,6 +130,7 @@ async def health_check(request: Request): setup_required=setup_required, repo_url=REPO_URL, donate_url=DONATE_URL, + uptime_seconds=time.monotonic() - _APP_START_MONOTONIC, ) diff --git a/server/src/ledgrab/api/routes/system_settings.py b/server/src/ledgrab/api/routes/system_settings.py index fcfd55b..172a37d 100644 --- a/server/src/ledgrab/api/routes/system_settings.py +++ b/server/src/ledgrab/api/routes/system_settings.py @@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import ( LogLevelResponse, MQTTSettingsRequest, MQTTSettingsResponse, + ShutdownAction, + ShutdownActionRequest, + ShutdownActionResponse, ) from ledgrab.config import get_config from ledgrab.storage.database import Database @@ -150,6 +153,55 @@ async def update_external_url( return ExternalUrlResponse(external_url=url) +# --------------------------------------------------------------------------- +# Shutdown action setting +# --------------------------------------------------------------------------- + +_VALID_SHUTDOWN_ACTIONS: tuple[str, ...] = ("stop_targets", "nothing") +_DEFAULT_SHUTDOWN_ACTION: ShutdownAction = "stop_targets" + + +def load_shutdown_action(db: Database | None = None) -> ShutdownAction: + """Load the configured shutdown action. Returns the default if unset or corrupt.""" + if db is None: + from ledgrab.api.dependencies import get_database + + db = get_database() + data = db.get_setting("shutdown_action") + if not data: + return _DEFAULT_SHUTDOWN_ACTION + value = data.get("action") + if value in _VALID_SHUTDOWN_ACTIONS: + return value # type: ignore[return-value] + return _DEFAULT_SHUTDOWN_ACTION + + +@router.get( + "/api/v1/system/shutdown-action", + response_model=ShutdownActionResponse, + tags=["System"], +) +async def get_shutdown_action(_: AuthRequired, db: Database = Depends(get_database)): + """Get the configured server shutdown action.""" + return ShutdownActionResponse(action=load_shutdown_action(db)) + + +@router.put( + "/api/v1/system/shutdown-action", + response_model=ShutdownActionResponse, + tags=["System"], +) +async def update_shutdown_action( + _: AuthRequired, + body: ShutdownActionRequest, + db: Database = Depends(get_database), +): + """Set what happens to LED targets when the server shuts down.""" + db.set_setting("shutdown_action", {"action": body.action}) + logger.info("Shutdown action updated: %s", body.action) + return ShutdownActionResponse(action=body.action) + + # --------------------------------------------------------------------------- # Live log viewer WebSocket # --------------------------------------------------------------------------- diff --git a/server/src/ledgrab/api/schemas/system.py b/server/src/ledgrab/api/schemas/system.py index 23502dc..7ad43cb 100644 --- a/server/src/ledgrab/api/schemas/system.py +++ b/server/src/ledgrab/api/schemas/system.py @@ -26,6 +26,10 @@ class HealthResponse(BaseModel): ) repo_url: str = Field(default="", description="Source code repository URL") donate_url: str = Field(default="", description="Donation page URL") + uptime_seconds: float = Field( + default=0.0, + description="Process uptime in seconds since the server started.", + ) class VersionResponse(BaseModel): @@ -200,6 +204,32 @@ class ExternalUrlRequest(BaseModel): external_url: str = Field(default="", description="External base URL. Empty string to clear.") +# ─── Shutdown action schemas ─────────────────────────────────── + + +ShutdownAction = Literal["stop_targets", "nothing"] + + +class ShutdownActionResponse(BaseModel): + """Current server shutdown action setting.""" + + action: ShutdownAction = Field( + description=( + "What happens to LED targets when the server shuts down. " + "`stop_targets` runs the normal stop sequence (per-device " + "auto_shutdown decides whether prior state is restored). " + "`nothing` skips device-touching teardown — lights freeze on " + "their last frame regardless of per-device auto_shutdown." + ), + ) + + +class ShutdownActionRequest(BaseModel): + """Update the server shutdown action setting.""" + + action: ShutdownAction = Field(description="New shutdown action.") + + # ─── Log level schemas ───────────────────────────────────────── diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index 08ac370..a1bcde2 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -770,8 +770,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) # ===== LIFECYCLE ===== - async def stop_all(self): - """Stop processing and health monitoring for all targets and devices.""" + async def stop_all(self, restore_devices: bool = True): + """Stop processing and health monitoring for all targets and devices. + + When ``restore_devices`` is False, processor tasks are cancelled + directly instead of going through ``proc.stop()`` (which sends + per-device auto_shutdown restore frames), and the global + idle-state restore loop is skipped. Used by the "Nothing" + shutdown action so lights freeze on their last frame regardless + of per-device auto_shutdown. + """ await self._metrics_history.stop() await self.stop_health_monitoring() @@ -781,18 +789,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) if rs.restart_task and not rs.restart_task.done(): rs.restart_task.cancel() - # Stop all processors - for target_id, proc in list(self._processors.items()): - if proc.is_running: - try: - await proc.stop() - except Exception as e: - logger.error(f"Error stopping target {target_id}: {e}") + if restore_devices: + # Stop all processors (per-device auto_shutdown decides whether + # the prior device state is restored). + for target_id, proc in list(self._processors.items()): + if proc.is_running: + try: + await proc.stop() + except Exception as e: + logger.error(f"Error stopping target {target_id}: {e}") - # Restore idle state for devices that have auto-restore enabled - # (serial devices already dark from processor close; WLED restored by snapshot) - for device_id in self._devices: - await self._restore_device_idle_state(device_id) + # Restore idle state for devices that have auto-restore enabled + # (serial devices already dark from processor close; WLED restored by snapshot) + for device_id in self._devices: + await self._restore_device_idle_state(device_id) + else: + # "Nothing" mode: cancel processor capture tasks without sending + # restore frames so the LEDs keep displaying the last frame. + # ``cancel_task`` (defined on ``TargetProcessor``) awaits the + # cancellation so the loop's current iteration completes — no + # half-written frame on the wire when the process exits. + for target_id, proc in list(self._processors.items()): + try: + await proc.cancel_task() + except Exception as e: + logger.error(f"Error cancelling task for target {target_id}: {e}") + logger.info( + "Shutdown action 'nothing': skipped device restore for %d target(s)", + len(self._processors), + ) # Close any cached idle LED clients (WLED only; serial has no cached clients) for did in list(self._idle_clients): diff --git a/server/src/ledgrab/core/processing/target_processor.py b/server/src/ledgrab/core/processing/target_processor.py index ffe96ea..5fdb2cb 100644 --- a/server/src/ledgrab/core/processing/target_processor.py +++ b/server/src/ledgrab/core/processing/target_processor.py @@ -16,6 +16,10 @@ from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + if TYPE_CHECKING: from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager from ledgrab.core.processing.live_stream_manager import LiveStreamManager @@ -145,6 +149,32 @@ class TargetProcessor(ABC): """ ... + async def cancel_task(self) -> None: + """Cancel the processing task without restoring device state. + + Used by ``ProcessorManager.stop_all(restore_devices=False)`` at + server shutdown when the user has chosen "Nothing" — LEDs should + keep displaying their last frame, so we skip the per-device + ``stop()`` path that sends restore frames. We still flip + ``_is_running`` and await the cancellation so the loop's current + iteration completes (no half-written frame on the wire). + + Subclasses with extra non-device cleanup (e.g. live-stream + release) may override this; the default just stops the task. + """ + self._is_running = False + task = self._task + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + # Log but don't propagate — caller is shutting down. + logger.debug("Task raised during cancel_task", exc_info=True) + self._task = None + # ----- Settings ----- @abstractmethod diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index bced30b..12c8823 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -412,9 +412,22 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping OS notification listener: {e}") - # Stop all processing + # Stop all processing. + # The shutdown action setting controls whether per-device restore + # frames are sent: "stop_targets" (default) runs the normal stop + # sequence; "nothing" cancels capture tasks so the LEDs freeze on + # their last frame. try: - await processor_manager.stop_all() + from ledgrab.api.routes.system_settings import load_shutdown_action + + action = load_shutdown_action(db) + except Exception as e: + logger.error(f"Error reading shutdown action setting, defaulting to stop_targets: {e}") + action = "stop_targets" + + logger.info("Shutdown action: %s", action) + try: + await processor_manager.stop_all(restore_devices=action != "nothing") logger.info("Stopped all processors") except Exception as e: logger.error(f"Error stopping processors: {e}") diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 90315c6..0c4348f 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -219,6 +219,7 @@ import { connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, openLogOverlay, closeLogOverlay, loadLogLevel, setLogLevel, + loadShutdownAction, setShutdownAction, saveExternalUrl, getBaseOrigin, loadExternalUrl, } from './features/settings.ts'; import { @@ -615,6 +616,8 @@ Object.assign(window, { closeLogOverlay, loadLogLevel, setLogLevel, + loadShutdownAction, + setShutdownAction, saveExternalUrl, getBaseOrigin, diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 8c74ac4..5f596ca 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -305,6 +305,19 @@ export async function loadServerInfo() { if (data.repo_url) serverRepoUrl = data.repo_url; if (data.donate_url) serverDonateUrl = data.donate_url; + // Seed the transport-bar uptime ticker with the server's actual + // uptime. Survives page reloads and tracks the *server* process, + // not this browser session. The inline ticker reads this from + // ``window.__serverUptime`` and falls back to "—" if absent. + // ``recordedAtPerf`` uses ``performance.now()`` so wall-clock + // changes (NTP step, DST) don't make the counter jump. + if (typeof data.uptime_seconds === 'number') { + window.__serverUptime = { + uptimeSec: data.uptime_seconds, + recordedAtPerf: performance.now(), + }; + } + // Demo mode detection if (data.demo_mode && !demoMode) { demoMode = true; diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index 7c14bac..62cef63 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -23,6 +23,7 @@ export const flaskConical = ''; export const play = ''; export const square = ''; +export const circle = ''; export const pause = ''; export const settings = ''; export const ruler = ''; diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index e054ea8..6236f5a 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -342,6 +342,8 @@ export const ICON_GITHUB = _svg(P.github); export const ICON_CHEVRON_UP = _svg(P.chevronUp); export const ICON_CHEVRON_DOWN = _svg(P.chevronDown); export const ICON_PLUS = _svg(P.plus); +export const ICON_SQUARE = _svg(P.square); +export const ICON_CIRCLE = _svg(P.circle); export const ICON_GIT_MERGE = _svg(P.gitMerge); export const ICON_COPY = _svg(P.copy); diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts index 66e0378..443ddd0 100644 --- a/server/src/ledgrab/static/js/features/settings.ts +++ b/server/src/ledgrab/static/js/features/settings.ts @@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; -import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.ts'; +import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts'; import { IconSelect } from '../core/icon-select.ts'; import { openAuthedWs } from '../core/ws-auth.ts'; @@ -260,6 +260,13 @@ const settingsModal = new Modal('settings-modal'); let _logLevelIconSelect: IconSelect | null = null; let _autoBackupIntervalIconSelect: IconSelect | null = null; +let _shutdownActionIconSelect: IconSelect | null = null; + +type ShutdownAction = 'stop_targets' | 'nothing'; +const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const; +function _isShutdownAction(v: string): v is ShutdownAction { + return (_SHUTDOWN_ACTIONS as readonly string[]).includes(v); +} /** Build interval items (hour-tiles) for auto-backup and update check pickers. * Labels match the existing native-