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 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}")
+9
View File
@@ -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,
)
@@ -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
# ---------------------------------------------------------------------------
+30
View File
@@ -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 ─────────────────────────────────────────
@@ -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,7 +789,9 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
if rs.restart_task and not rs.restart_task.done():
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()):
if proc.is_running:
try:
@@ -793,6 +803,21 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
# (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):
@@ -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
+15 -2
View File
@@ -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}")
+3
View File
@@ -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,
+13
View File
@@ -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;
@@ -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 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 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 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"/>';
@@ -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);
@@ -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-<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. */
function _getLogLevelItems(): { value: string; icon: string; label: string; desc: string }[] {
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();
loadExternalUrl();
loadAutoBackupSettings();
loadBackupList();
loadLogLevel();
loadShutdownAction();
}
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;
_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>) ───
_updateBgAnimAccent: (accent: string) => void;
_updateBgAnimTheme: (dark: boolean) => void;
@@ -397,6 +405,8 @@ startTargetOverlay: (...args: any[]) => any;
closeLogOverlay: (...args: any[]) => any;
loadLogLevel: (...args: any[]) => any;
setLogLevel: (...args: any[]) => any;
loadShutdownAction: (...args: any[]) => any;
setShutdownAction: (...args: any[]) => any;
saveExternalUrl: (...args: any[]) => any;
getBaseOrigin: (...args: any[]) => any;
@@ -1788,6 +1788,14 @@
"settings.log_level.desc.warning": "Potential problems",
"settings.log_level.desc.error": "Failures 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.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",
@@ -1604,6 +1604,14 @@
"settings.log_level.desc.warning": "Возможные проблемы",
"settings.log_level.desc.error": "Только ошибки",
"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.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.",
"settings.auto_backup.enable": "Включить авто-бэкап",
@@ -1604,6 +1604,14 @@
"settings.log_level.desc.warning": "潜在问题",
"settings.log_level.desc.error": "仅显示错误",
"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.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。",
"settings.auto_backup.enable": "启用自动备份",
+18 -5
View File
@@ -544,18 +544,31 @@
// Initialize on load
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() {
const pageLoadedAt = Date.now();
const el = document.getElementById('transport-uptime');
if (!el) return;
function pad(n) { return n < 10 ? '0' + n : String(n); }
function render() {
const secs = Math.floor((Date.now() - pageLoadedAt) / 1000);
const h = Math.floor(secs / 3600);
const ref = window.__serverUptime;
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 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();
setInterval(render, 1000);
@@ -57,6 +57,19 @@
</select>
</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) -->
<div class="form-group">
<div class="label-row">