feat: server shutdown action with public cancel_task lifecycle method

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.
This commit is contained in:
2026-04-25 15:10:48 +03:00
parent 2bae304107
commit 3f80ef2101
18 changed files with 359 additions and 24 deletions
+21 -3
View File
@@ -12,6 +12,8 @@ import threading
import time import time
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
def _fix_embedded_tcl_paths() -> None: def _fix_embedded_tcl_paths() -> None:
@@ -54,9 +56,25 @@ def _run_server(server: uvicorn.Server) -> None:
loop.run_until_complete(server.serve()) loop.run_until_complete(server.serve())
def _open_browser(port: int, delay: float = 2.0) -> None: def _wait_for_server(port: int, timeout: float = 30.0, interval: float = 0.25) -> bool:
"""Open the UI in the default browser after a short delay.""" """Poll /health until the server responds or *timeout* seconds elapse."""
time.sleep(delay) 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}") webbrowser.open(f"http://localhost:{port}")
+9
View File
@@ -7,6 +7,7 @@ import asyncio
import platform import platform
import subprocess import subprocess
import sys import sys
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -92,6 +93,13 @@ def _get_cpu_name() -> str | None:
_cpu_name: str | None = _get_cpu_name() _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() router = APIRouter()
@@ -122,6 +130,7 @@ async def health_check(request: Request):
setup_required=setup_required, setup_required=setup_required,
repo_url=REPO_URL, repo_url=REPO_URL,
donate_url=DONATE_URL, donate_url=DONATE_URL,
uptime_seconds=time.monotonic() - _APP_START_MONOTONIC,
) )
@@ -19,6 +19,9 @@ from ledgrab.api.schemas.system import (
LogLevelResponse, LogLevelResponse,
MQTTSettingsRequest, MQTTSettingsRequest,
MQTTSettingsResponse, MQTTSettingsResponse,
ShutdownAction,
ShutdownActionRequest,
ShutdownActionResponse,
) )
from ledgrab.config import get_config from ledgrab.config import get_config
from ledgrab.storage.database import Database from ledgrab.storage.database import Database
@@ -150,6 +153,55 @@ async def update_external_url(
return ExternalUrlResponse(external_url=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 # Live log viewer WebSocket
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+30
View File
@@ -26,6 +26,10 @@ class HealthResponse(BaseModel):
) )
repo_url: str = Field(default="", description="Source code repository URL") repo_url: str = Field(default="", description="Source code repository URL")
donate_url: str = Field(default="", description="Donation page 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): class VersionResponse(BaseModel):
@@ -200,6 +204,32 @@ class ExternalUrlRequest(BaseModel):
external_url: str = Field(default="", description="External base URL. Empty string to clear.") 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 ───────────────────────────────────────── # ─── Log level schemas ─────────────────────────────────────────
@@ -770,8 +770,16 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# ===== LIFECYCLE ===== # ===== LIFECYCLE =====
async def stop_all(self): async def stop_all(self, restore_devices: bool = True):
"""Stop processing and health monitoring for all targets and devices.""" """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._metrics_history.stop()
await self.stop_health_monitoring() await self.stop_health_monitoring()
@@ -781,18 +789,35 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
if rs.restart_task and not rs.restart_task.done(): if rs.restart_task and not rs.restart_task.done():
rs.restart_task.cancel() rs.restart_task.cancel()
# Stop all processors if restore_devices:
for target_id, proc in list(self._processors.items()): # Stop all processors (per-device auto_shutdown decides whether
if proc.is_running: # the prior device state is restored).
try: for target_id, proc in list(self._processors.items()):
await proc.stop() if proc.is_running:
except Exception as e: try:
logger.error(f"Error stopping target {target_id}: {e}") 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 # Restore idle state for devices that have auto-restore enabled
# (serial devices already dark from processor close; WLED restored by snapshot) # (serial devices already dark from processor close; WLED restored by snapshot)
for device_id in self._devices: for device_id in self._devices:
await self._restore_device_idle_state(device_id) 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) # Close any cached idle LED clients (WLED only; serial has no cached clients)
for did in list(self._idle_clients): for did in list(self._idle_clients):
@@ -16,6 +16,10 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple
from ledgrab.utils import get_logger
logger = get_logger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager from ledgrab.core.processing.color_strip_stream_manager import ColorStripStreamManager
from ledgrab.core.processing.live_stream_manager import LiveStreamManager 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 ----- # ----- Settings -----
@abstractmethod @abstractmethod
+15 -2
View File
@@ -412,9 +412,22 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Error stopping OS notification listener: {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: 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") logger.info("Stopped all processors")
except Exception as e: except Exception as e:
logger.error(f"Error stopping processors: {e}") logger.error(f"Error stopping processors: {e}")
+3
View File
@@ -219,6 +219,7 @@ import {
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
openLogOverlay, closeLogOverlay, openLogOverlay, closeLogOverlay,
loadLogLevel, setLogLevel, loadLogLevel, setLogLevel,
loadShutdownAction, setShutdownAction,
saveExternalUrl, getBaseOrigin, loadExternalUrl, saveExternalUrl, getBaseOrigin, loadExternalUrl,
} from './features/settings.ts'; } from './features/settings.ts';
import { import {
@@ -615,6 +616,8 @@ Object.assign(window, {
closeLogOverlay, closeLogOverlay,
loadLogLevel, loadLogLevel,
setLogLevel, setLogLevel,
loadShutdownAction,
setShutdownAction,
saveExternalUrl, saveExternalUrl,
getBaseOrigin, getBaseOrigin,
+13
View File
@@ -305,6 +305,19 @@ export async function loadServerInfo() {
if (data.repo_url) serverRepoUrl = data.repo_url; if (data.repo_url) serverRepoUrl = data.repo_url;
if (data.donate_url) serverDonateUrl = data.donate_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 // Demo mode detection
if (data.demo_mode && !demoMode) { if (data.demo_mode && !demoMode) {
demoMode = true; demoMode = true;
@@ -23,6 +23,7 @@ export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>'; export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>'; export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>'; export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
export const circle = '<circle cx="12" cy="12" r="9"/>';
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>'; export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>'; export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
export const ruler = '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>'; export const ruler = '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>';
@@ -342,6 +342,8 @@ export const ICON_GITHUB = _svg(P.github);
export const ICON_CHEVRON_UP = _svg(P.chevronUp); export const ICON_CHEVRON_UP = _svg(P.chevronUp);
export const ICON_CHEVRON_DOWN = _svg(P.chevronDown); export const ICON_CHEVRON_DOWN = _svg(P.chevronDown);
export const ICON_PLUS = _svg(P.plus); 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_GIT_MERGE = _svg(P.gitMerge);
export const ICON_COPY = _svg(P.copy); export const ICON_COPY = _svg(P.copy);
@@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.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 { IconSelect } from '../core/icon-select.ts';
import { openAuthedWs } from '../core/ws-auth.ts'; import { openAuthedWs } from '../core/ws-auth.ts';
@@ -260,6 +260,13 @@ const settingsModal = new Modal('settings-modal');
let _logLevelIconSelect: IconSelect | null = null; let _logLevelIconSelect: IconSelect | null = null;
let _autoBackupIntervalIconSelect: 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. /** Build interval items (hour-tiles) for auto-backup and update check pickers.
* Labels match the existing native-<option> text verbatim so no new i18n keys are needed. * Labels match the existing native-<option> text verbatim so no new i18n keys are needed.
@@ -275,6 +282,24 @@ export function _getHourIntervalItems(): { value: string; icon: string; label: s
]; ];
} }
/** Build shutdown-action items lazily so t() has locale data loaded. */
function _getShutdownActionItems(): { value: string; icon: string; label: string; desc: string }[] {
return [
{
value: 'stop_targets',
icon: ICON_SQUARE,
label: t('settings.shutdown_action.opt.stop'),
desc: t('settings.shutdown_action.opt.stop_desc'),
},
{
value: 'nothing',
icon: ICON_CIRCLE,
label: t('settings.shutdown_action.opt.nothing'),
desc: t('settings.shutdown_action.opt.nothing_desc'),
},
];
}
/** Build log-level items lazily so t() has locale data loaded. */ /** Build log-level items lazily so t() has locale data loaded. */
function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] { function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] {
return [ return [
@@ -325,11 +350,25 @@ export function openSettingsModal(): void {
} }
} }
// Initialize shutdown-action icon select
if (!_shutdownActionIconSelect) {
const sel = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (sel) {
_shutdownActionIconSelect = new IconSelect({
target: sel,
items: _getShutdownActionItems(),
columns: 2,
onChange: () => setShutdownAction(),
});
}
}
loadApiKeysList(); loadApiKeysList();
loadExternalUrl(); loadExternalUrl();
loadAutoBackupSettings(); loadAutoBackupSettings();
loadBackupList(); loadBackupList();
loadLogLevel(); loadLogLevel();
loadShutdownAction();
} }
export function closeSettingsModal(): void { export function closeSettingsModal(): void {
@@ -669,3 +708,43 @@ export async function setLogLevel(): Promise<void> {
} }
} }
// ─── Shutdown action ──────────────────────────────────────────
export async function loadShutdownAction(): Promise<void> {
try {
const resp = await fetchWithAuth('/system/shutdown-action');
if (!resp.ok) return;
const data = await resp.json();
const action: ShutdownAction = _isShutdownAction(data.action) ? data.action : 'stop_targets';
if (_shutdownActionIconSelect) {
_shutdownActionIconSelect.setValue(action);
} else {
const select = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (select) select.value = action;
}
} catch (err) {
console.error('Failed to load shutdown action:', err);
}
}
export async function setShutdownAction(): Promise<void> {
const select = document.getElementById('settings-shutdown-action') as HTMLSelectElement | null;
if (!select) return;
const value = select.value;
if (!_isShutdownAction(value)) return;
try {
const resp = await fetchWithAuth('/system/shutdown-action', {
method: 'PUT',
body: JSON.stringify({ action: value }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('settings.shutdown_action.saved'), 'success');
} catch (err) {
console.error('Failed to set shutdown action:', err);
showToast(t('settings.shutdown_action.save_error') + ': ' + err.message, 'error');
}
}
+10
View File
@@ -22,6 +22,14 @@ interface Window {
setApiKey: (key: string | null) => void; setApiKey: (key: string | null) => void;
_authRequired: boolean | undefined; _authRequired: boolean | undefined;
// ─── Transport bar ───
/** Server-process uptime seed for the transport-bar ticker. Set by
* api.ts on every /health response; read by the inline ticker in
* index.html. ``recordedAtPerf`` is a ``performance.now()`` reading,
* not Date.now(), so the extrapolation is immune to wall-clock jumps
* (NTP step, DST). */
__serverUptime: { uptimeSec: number; recordedAtPerf: number } | undefined;
// ─── Visual effects (called from inline <script>) ─── // ─── Visual effects (called from inline <script>) ───
_updateBgAnimAccent: (accent: string) => void; _updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void; _updateBgAnimTheme: (dark: boolean) => void;
@@ -397,6 +405,8 @@ startTargetOverlay: (...args: any[]) => any;
closeLogOverlay: (...args: any[]) => any; closeLogOverlay: (...args: any[]) => any;
loadLogLevel: (...args: any[]) => any; loadLogLevel: (...args: any[]) => any;
setLogLevel: (...args: any[]) => any; setLogLevel: (...args: any[]) => any;
loadShutdownAction: (...args: any[]) => any;
setShutdownAction: (...args: any[]) => any;
saveExternalUrl: (...args: any[]) => any; saveExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any; getBaseOrigin: (...args: any[]) => any;
@@ -1788,6 +1788,14 @@
"settings.log_level.desc.warning": "Potential problems", "settings.log_level.desc.warning": "Potential problems",
"settings.log_level.desc.error": "Failures only", "settings.log_level.desc.error": "Failures only",
"settings.log_level.desc.critical": "Fatal errors only", "settings.log_level.desc.critical": "Fatal errors only",
"settings.shutdown_action.label": "Shutdown action",
"settings.shutdown_action.hint": "What happens to LED targets when the server shuts down. \"Stop targets\" runs the normal stop sequence so devices with auto-restore restore their prior state. \"Nothing\" leaves the lights showing the last frame.",
"settings.shutdown_action.saved": "Shutdown action saved",
"settings.shutdown_action.save_error": "Failed to save shutdown action",
"settings.shutdown_action.opt.stop": "Stop targets",
"settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)",
"settings.shutdown_action.opt.nothing": "Nothing",
"settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame",
"settings.auto_backup.label": "Auto-Backup", "settings.auto_backup.label": "Auto-Backup",
"settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.",
"settings.auto_backup.enable": "Enable auto-backup", "settings.auto_backup.enable": "Enable auto-backup",
@@ -1604,6 +1604,14 @@
"settings.log_level.desc.warning": "Возможные проблемы", "settings.log_level.desc.warning": "Возможные проблемы",
"settings.log_level.desc.error": "Только ошибки", "settings.log_level.desc.error": "Только ошибки",
"settings.log_level.desc.critical": "Только критические ошибки", "settings.log_level.desc.critical": "Только критические ошибки",
"settings.shutdown_action.label": "Действие при выключении",
"settings.shutdown_action.hint": "Что происходит с LED-целями при остановке сервера. «Остановить цели» — обычная последовательность остановки, устройства с авто-восстановлением восстановят прежнее состояние. «Ничего» — оставить свет таким, каким он был на последнем кадре.",
"settings.shutdown_action.saved": "Действие при выключении сохранено",
"settings.shutdown_action.save_error": "Не удалось сохранить действие при выключении",
"settings.shutdown_action.opt.stop": "Остановить цели",
"settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)",
"settings.shutdown_action.opt.nothing": "Ничего",
"settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре",
"settings.auto_backup.label": "Авто-бэкап", "settings.auto_backup.label": "Авто-бэкап",
"settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап", "settings.auto_backup.enable": "Включить авто-бэкап",
@@ -1604,6 +1604,14 @@
"settings.log_level.desc.warning": "潜在问题", "settings.log_level.desc.warning": "潜在问题",
"settings.log_level.desc.error": "仅显示错误", "settings.log_level.desc.error": "仅显示错误",
"settings.log_level.desc.critical": "仅显示致命错误", "settings.log_level.desc.critical": "仅显示致命错误",
"settings.shutdown_action.label": "关机时执行",
"settings.shutdown_action.hint": "服务器关闭时对 LED 目标的处理方式。「停止目标」执行正常的停止流程,启用自动恢复的设备会恢复先前状态。「无」会让灯保持显示最后一帧。",
"settings.shutdown_action.saved": "已保存关机动作",
"settings.shutdown_action.save_error": "保存关机动作失败",
"settings.shutdown_action.opt.stop": "停止目标",
"settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)",
"settings.shutdown_action.opt.nothing": "无",
"settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧",
"settings.auto_backup.label": "自动备份", "settings.auto_backup.label": "自动备份",
"settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份", "settings.auto_backup.enable": "启用自动备份",
+18 -5
View File
@@ -544,18 +544,31 @@
// Initialize on load // Initialize on load
updateAuthUI(); updateAuthUI();
// Transport-bar session uptime ticker — time since page load. // Transport-bar uptime ticker — shows the SERVER's process uptime,
// not the browser session. api.ts populates window.__serverUptime
// from /health on initial load and on every connection re-check;
// until that lands we fall back to "—" so a refresh doesn't briefly
// flash 00:00:00. After 99h the format widens to D HH:MM:SS so the
// counter stays meaningful for long-running services.
// The drift between fetch and now is computed against
// performance.now() (monotonic) so an NTP step / DST change /
// user clock-set on the host doesn't visibly jump the counter.
(function() { (function() {
const pageLoadedAt = Date.now();
const el = document.getElementById('transport-uptime'); const el = document.getElementById('transport-uptime');
if (!el) return; if (!el) return;
function pad(n) { return n < 10 ? '0' + n : String(n); } function pad(n) { return n < 10 ? '0' + n : String(n); }
function render() { function render() {
const secs = Math.floor((Date.now() - pageLoadedAt) / 1000); const ref = window.__serverUptime;
const h = Math.floor(secs / 3600); if (!ref) { el.textContent = '—'; return; }
const elapsedSinceFetch = (performance.now() - ref.recordedAtPerf) / 1000;
const secs = Math.max(0, Math.floor(ref.uptimeSec + elapsedSinceFetch));
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60); const m = Math.floor((secs % 3600) / 60);
const s = secs % 60; const s = secs % 60;
el.textContent = `${pad(h)}:${pad(m)}:${pad(s)}`; el.textContent = d > 0
? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`
: `${pad(h)}:${pad(m)}:${pad(s)}`;
} }
render(); render();
setInterval(render, 1000); setInterval(render, 1000);
@@ -57,6 +57,19 @@
</select> </select>
</div> </div>
<!-- Shutdown action section -->
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.shutdown_action.label">Shutdown action</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.shutdown_action.hint">What happens to LED targets when the server shuts down. "Stop targets" runs the normal stop sequence so devices with auto-restore restore their prior state. "Nothing" leaves the lights showing the last frame.</small>
<select id="settings-shutdown-action">
<option value="stop_targets">Stop targets</option>
<option value="nothing">Nothing</option>
</select>
</div>
<!-- Server Logs button (opens overlay) --> <!-- Server Logs button (opens overlay) -->
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">