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:
@@ -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}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,7 +789,9 @@ 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:
|
||||||
|
# Stop all processors (per-device auto_shutdown decides whether
|
||||||
|
# the prior device state is restored).
|
||||||
for target_id, proc in list(self._processors.items()):
|
for target_id, proc in list(self._processors.items()):
|
||||||
if proc.is_running:
|
if proc.is_running:
|
||||||
try:
|
try:
|
||||||
@@ -793,6 +803,21 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
|
|||||||
# (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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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": "启用自动备份",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user