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-