Compare commits

..

2 Commits

Author SHA1 Message Date
f83cd81937 Extract SerialDeviceProvider base class and power off serial devices on shutdown
Create SerialDeviceProvider as the common base for Adalight and AmbiLED
providers, replacing the misleading Adalight→AmbiLED inheritance chain.
Subclasses now only override device_type and create_client(). Also send
explicit black frames to all serial LED devices during server shutdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:04:27 +03:00
45634836b6 Add FPS sparkline charts, configurable poll interval, and uptime interpolation
Replace text FPS labels with Chart.js sparklines on running targets,
use emoji icons for metrics, add in-place DOM updates to preserve
chart animations, and add a 1-10s poll interval slider that controls
all dashboard timers. Uptime now ticks every second via client-side
interpolation regardless of poll interval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:04:17 +03:00
13 changed files with 443 additions and 243 deletions

View File

@@ -11,12 +11,14 @@ from wled_controller.core.devices.led_client import (
get_device_capabilities, get_device_capabilities,
get_provider, get_provider,
) )
from wled_controller.core.devices.serial_provider import SerialDeviceProvider
__all__ = [ __all__ = [
"DeviceHealth", "DeviceHealth",
"DiscoveredDevice", "DiscoveredDevice",
"LEDClient", "LEDClient",
"LEDDeviceProvider", "LEDDeviceProvider",
"SerialDeviceProvider",
"check_device_health", "check_device_health",
"create_led_client", "create_led_client",
"get_all_providers", "get_all_providers",

View File

@@ -1,34 +1,16 @@
"""Adalight device provider — serial LED controller support.""" """Adalight device provider — serial LED controller using Adalight protocol."""
from typing import List, Tuple from wled_controller.core.devices.led_client import LEDClient
from wled_controller.core.devices.serial_provider import SerialDeviceProvider
import numpy as np
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AdalightDeviceProvider(LEDDeviceProvider): class AdalightDeviceProvider(SerialDeviceProvider):
"""Provider for Adalight serial LED controllers.""" """Provider for Adalight serial LED controllers."""
@property @property
def device_type(self) -> str: def device_type(self) -> str:
return "adalight" return "adalight"
@property
def capabilities(self) -> set:
# manual_led_count: user must specify LED count (can't auto-detect)
# power_control: can blank LEDs by sending all-black pixels
# brightness_control: software brightness (multiplies pixel values before sending)
return {"manual_led_count", "power_control", "brightness_control", "static_color"}
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.devices.adalight_client import AdalightClient from wled_controller.core.devices.adalight_client import AdalightClient
@@ -36,111 +18,3 @@ class AdalightDeviceProvider(LEDDeviceProvider):
baud_rate = kwargs.pop("baud_rate", None) baud_rate = kwargs.pop("baud_rate", None)
kwargs.pop("use_ddp", None) # Not applicable for serial kwargs.pop("use_ddp", None) # Not applicable for serial
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs) return AdalightClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
from wled_controller.core.devices.adalight_client import AdalightClient
return await AdalightClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate that the serial port exists.
Returns:
Empty dict — Adalight devices don't report LED count,
so it must be provided by the user.
"""
from wled_controller.core.devices.adalight_client import parse_adalight_url
port, _baud = parse_adalight_url(url)
try:
import serial.tools.list_ports
available_ports = [p.device for p in serial.tools.list_ports.comports()]
port_upper = port.upper()
if not any(p.upper() == port_upper for p in available_ports):
raise ValueError(
f"Serial port {port} not found. "
f"Available ports: {', '.join(available_ports) or 'none'}"
)
except ValueError:
raise
except Exception as e:
raise ValueError(f"Failed to enumerate serial ports: {e}")
logger.info(f"Adalight device validated: port {port}")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover serial ports that could be Adalight devices."""
try:
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
results = []
for port_info in ports:
results.append(
DiscoveredDevice(
name=port_info.description or port_info.device,
url=port_info.device,
device_type="adalight",
ip=port_info.device,
mac="",
led_count=None,
version=None,
)
)
logger.info(f"Serial port scan found {len(results)} port(s)")
return results
except Exception as e:
logger.error(f"Serial port discovery failed: {e}")
return []
async def get_power(self, url: str, **kwargs) -> bool:
# Adalight has no hardware power query; assume on
return True
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Turn Adalight device on/off by sending an all-black frame (off) or no-op (on).
Requires kwargs: led_count (int), baud_rate (int | None).
"""
if on:
return # "on" is a no-op — next processing frame lights LEDs up
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError("led_count is required to send black frame to Adalight device")
from wled_controller.core.devices.adalight_client import AdalightClient
client = AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
black = np.zeros((led_count, 3), dtype=np.uint8)
await client.send_pixels(black, brightness=255)
logger.info(f"Adalight power off: sent black frame to {url}")
finally:
await client.close()
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
"""Send a solid color frame to the Adalight device.
Requires kwargs: led_count (int), baud_rate (int | None).
"""
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError("led_count is required to send color frame to Adalight device")
from wled_controller.core.devices.adalight_client import AdalightClient
client = AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
frame = np.full((led_count, 3), color, dtype=np.uint8)
await client.send_pixels(frame, brightness=255)
logger.info(f"Adalight set_color: sent solid {color} to {url}")
finally:
await client.close()

View File

@@ -1,17 +1,10 @@
"""AmbiLED device provider — serial LED controller using AmbiLED protocol.""" """AmbiLED device provider — serial LED controller using AmbiLED protocol."""
from typing import List, Tuple from wled_controller.core.devices.led_client import LEDClient
from wled_controller.core.devices.serial_provider import SerialDeviceProvider
import numpy as np
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
from wled_controller.core.devices.led_client import DiscoveredDevice, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AmbiLEDDeviceProvider(AdalightDeviceProvider): class AmbiLEDDeviceProvider(SerialDeviceProvider):
"""Provider for AmbiLED serial LED controllers.""" """Provider for AmbiLED serial LED controllers."""
@property @property
@@ -25,87 +18,3 @@ class AmbiLEDDeviceProvider(AdalightDeviceProvider):
baud_rate = kwargs.pop("baud_rate", None) baud_rate = kwargs.pop("baud_rate", None)
kwargs.pop("use_ddp", None) kwargs.pop("use_ddp", None)
return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs) return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
async def validate_device(self, url: str) -> dict:
from wled_controller.core.devices.adalight_client import parse_adalight_url
port, _baud = parse_adalight_url(url)
try:
import serial.tools.list_ports
available_ports = [p.device for p in serial.tools.list_ports.comports()]
port_upper = port.upper()
if not any(p.upper() == port_upper for p in available_ports):
raise ValueError(
f"Serial port {port} not found. "
f"Available ports: {', '.join(available_ports) or 'none'}"
)
except ValueError:
raise
except Exception as e:
raise ValueError(f"Failed to enumerate serial ports: {e}")
logger.info(f"AmbiLED device validated: port {port}")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
try:
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
results = []
for port_info in ports:
results.append(
DiscoveredDevice(
name=port_info.description or port_info.device,
url=port_info.device,
device_type="ambiled",
ip=port_info.device,
mac="",
led_count=None,
version=None,
)
)
logger.info(f"AmbiLED serial port scan found {len(results)} port(s)")
return results
except Exception as e:
logger.error(f"AmbiLED serial port discovery failed: {e}")
return []
async def set_power(self, url: str, on: bool, **kwargs) -> None:
if on:
return
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError("led_count is required to send black frame to AmbiLED device")
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
client = AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
black = np.zeros((led_count, 3), dtype=np.uint8)
await client.send_pixels(black, brightness=255)
logger.info(f"AmbiLED power off: sent black frame to {url}")
finally:
await client.close()
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError("led_count is required to send color frame to AmbiLED device")
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
client = AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
frame = np.full((led_count, 3), color, dtype=np.uint8)
await client.send_pixels(frame, brightness=255)
logger.info(f"AmbiLED set_color: sent solid {color} to {url}")
finally:
await client.close()

View File

@@ -0,0 +1,136 @@
"""Base provider for serial LED controllers (Adalight, AmbiLED, etc.).
Subclasses only need to override ``device_type`` and ``create_client()``.
All common serial-device logic (COM port validation, discovery, health
checks, power control via black frames, static colour) lives here.
"""
from typing import List, Tuple
import numpy as np
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class SerialDeviceProvider(LEDDeviceProvider):
"""Base provider for serial LED controllers."""
@property
def capabilities(self) -> set:
# manual_led_count: user must specify LED count (can't auto-detect)
# power_control: can blank LEDs by sending all-black pixels
# brightness_control: software brightness (multiplies pixel values before sending)
# static_color: can send a solid colour frame
return {"manual_led_count", "power_control", "brightness_control", "static_color"}
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
# Generic serial port health check — enumerate COM ports
from wled_controller.core.devices.adalight_client import AdalightClient
return await AdalightClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
"""Validate that the serial port exists.
Returns empty dict — serial devices don't report LED count,
so it must be provided by the user.
"""
from wled_controller.core.devices.adalight_client import parse_adalight_url
port, _baud = parse_adalight_url(url)
try:
import serial.tools.list_ports
available_ports = [p.device for p in serial.tools.list_ports.comports()]
port_upper = port.upper()
if not any(p.upper() == port_upper for p in available_ports):
raise ValueError(
f"Serial port {port} not found. "
f"Available ports: {', '.join(available_ports) or 'none'}"
)
except ValueError:
raise
except Exception as e:
raise ValueError(f"Failed to enumerate serial ports: {e}")
logger.info(f"{self.device_type} device validated: port {port}")
return {}
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
"""Discover serial ports that could be LED devices."""
try:
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
results = []
for port_info in ports:
results.append(
DiscoveredDevice(
name=port_info.description or port_info.device,
url=port_info.device,
device_type=self.device_type,
ip=port_info.device,
mac="",
led_count=None,
version=None,
)
)
logger.info(f"{self.device_type} serial port scan found {len(results)} port(s)")
return results
except Exception as e:
logger.error(f"{self.device_type} serial port discovery failed: {e}")
return []
async def get_power(self, url: str, **kwargs) -> bool:
# Serial devices have no hardware power query; assume on
return True
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Turn device on/off by sending an all-black frame (off) or no-op (on).
Requires kwargs: led_count (int), baud_rate (int | None).
"""
if on:
return # "on" is a no-op — next processing frame lights LEDs up
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError(f"led_count is required to send black frame to {self.device_type} device")
client = self.create_client(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
black = np.zeros((led_count, 3), dtype=np.uint8)
await client.send_pixels(black, brightness=255)
logger.info(f"{self.device_type} power off: sent black frame to {url}")
finally:
await client.close()
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
"""Send a solid color frame to the device.
Requires kwargs: led_count (int), baud_rate (int | None).
"""
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError(f"led_count is required to send color frame to {self.device_type} device")
client = self.create_client(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
frame = np.full((led_count, 3), color, dtype=np.uint8)
await client.send_pixels(frame, brightness=255)
logger.info(f"{self.device_type} set_color: sent solid {color} to {url}")
finally:
await client.close()

View File

@@ -658,6 +658,14 @@ class ProcessorManager:
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)
# Power off serial LED devices before closing connections
for device_id, ds in self._devices.items():
if ds.device_type != "wled":
try:
await self._send_clear_pixels(device_id)
except Exception as e:
logger.error(f"Failed to power off {device_id} on shutdown: {e}")
# Close any cached idle LED clients # Close any cached idle LED clients
for did in list(self._idle_clients): for did in list(self._idle_clients):
await self._close_idle_client(did) await self._close_idle_client(did)

View File

@@ -35,9 +35,9 @@ import {
updateSettingsBaudFpsHint, updateSettingsBaudFpsHint,
} from './features/devices.js'; } from './features/devices.js';
import { import {
loadDashboard, startDashboardWS, stopDashboardWS, loadDashboard, startDashboardWS, stopDashboardWS, stopUptimeTimer,
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
toggleDashboardSection, toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js'; } from './features/dashboard.js';
import { import {
startPerfPolling, stopPerfPolling, startPerfPolling, stopPerfPolling,
@@ -157,6 +157,8 @@ Object.assign(window, {
dashboardStopTarget, dashboardStopTarget,
dashboardStopAll, dashboardStopAll,
toggleDashboardSection, toggleDashboardSection,
changeDashboardPollInterval,
stopUptimeTimer,
startPerfPolling, startPerfPolling,
stopPerfPolling, stopPerfPolling,

View File

@@ -121,6 +121,15 @@ export function setConfirmResolve(v) { confirmResolve = v; }
export let _dashboardLoading = false; export let _dashboardLoading = false;
export function set_dashboardLoading(v) { _dashboardLoading = v; } export function set_dashboardLoading(v) { _dashboardLoading = v; }
// Dashboard poll interval (ms), persisted in localStorage
const _POLL_KEY = 'dashboard_poll_interval';
const _POLL_DEFAULT = 2000;
export let dashboardPollInterval = parseInt(localStorage.getItem(_POLL_KEY), 10) || _POLL_DEFAULT;
export function setDashboardPollInterval(v) {
dashboardPollInterval = v;
localStorage.setItem(_POLL_KEY, String(v));
}
// Pattern template editor state // Pattern template editor state
export let patternEditorRects = []; export let patternEditorRects = [];
export function setPatternEditorRects(v) { patternEditorRects = v; } export function setPatternEditorRects(v) { patternEditorRects = v; }

View File

@@ -2,14 +2,192 @@
* Dashboard — real-time target status overview. * Dashboard — real-time target status overview.
*/ */
import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading } from '../core/state.js'; import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { escapeHtml, handle401Error } from '../core/api.js'; import { escapeHtml, handle401Error } from '../core/api.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh } from './tabs.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const FPS_HISTORY_KEY = 'dashboard_fps_history';
const MAX_FPS_SAMPLES = 30;
let _fpsHistory = _loadFpsHistory(); // { targetId: number[] }
let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null;
function _loadFpsHistory() {
try {
const raw = sessionStorage.getItem(FPS_HISTORY_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return {};
}
function _saveFpsHistory() {
try { sessionStorage.setItem(FPS_HISTORY_KEY, JSON.stringify(_fpsHistory)); }
catch {}
}
function _pushFps(targetId, value) {
if (!_fpsHistory[targetId]) _fpsHistory[targetId] = [];
_fpsHistory[targetId].push(value);
if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift();
}
function _setUptimeBase(targetId, seconds) {
_uptimeBase[targetId] = { seconds, timestamp: Date.now() };
}
function _getInterpolatedUptime(targetId) {
const base = _uptimeBase[targetId];
if (!base) return null;
const elapsed = (Date.now() - base.timestamp) / 1000;
return base.seconds + elapsed;
}
function _startUptimeTimer() {
if (_uptimeTimer) return;
_uptimeTimer = setInterval(() => {
for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`);
if (!el) continue;
const seconds = _getInterpolatedUptime(id);
if (seconds != null) {
el.textContent = `🕐 ${formatUptime(seconds)}`;
}
}
}, 1000);
}
function _stopUptimeTimer() {
if (_uptimeTimer) {
clearInterval(_uptimeTimer);
_uptimeTimer = null;
}
_uptimeBase = {};
}
function _destroyFpsCharts() {
for (const id of Object.keys(_fpsCharts)) {
if (_fpsCharts[id]) { _fpsCharts[id].destroy(); }
}
_fpsCharts = {};
}
function _createFpsChart(canvasId, history, fpsTarget) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
return new Chart(canvas, {
type: 'line',
data: {
labels: history.map(() => ''),
datasets: [{
data: [...history],
borderColor: '#2196F3',
backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: true,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { min: 0, max: fpsTarget * 1.15, display: false },
},
layout: { padding: 0 },
},
});
}
function _initFpsCharts(runningTargetIds) {
_destroyFpsCharts();
// Clean up history for targets that are no longer running
for (const id of Object.keys(_fpsHistory)) {
if (!runningTargetIds.includes(id)) delete _fpsHistory[id];
}
for (const id of runningTargetIds) {
const canvas = document.getElementById(`dashboard-fps-${id}`);
if (!canvas) continue;
const history = _fpsHistory[id] || [];
const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30;
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, fpsTarget);
}
_saveFpsHistory();
}
/** Update running target metrics in-place (no HTML rebuild). */
function _updateRunningMetrics(enrichedRunning) {
for (const target of enrichedRunning) {
const state = target.state || {};
const metrics = target.metrics || {};
const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
const errors = metrics.errors_count || 0;
// Push FPS and update chart
if (state.fps_actual != null) {
_pushFps(target.id, state.fps_actual);
}
const chart = _fpsCharts[target.id];
if (chart) {
const history = _fpsHistory[target.id] || [];
chart.data.datasets[0].data = [...history];
chart.data.labels = history.map(() => '');
chart.update();
}
// Refresh uptime base for interpolation
if (metrics.uptime_seconds != null) {
_setUptimeBase(target.id, metrics.uptime_seconds);
}
// Update text values
const fpsEl = document.querySelector(`[data-fps-text="${target.id}"]`);
if (fpsEl) fpsEl.innerHTML = `${fpsActual}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
const errorsEl = document.querySelector(`[data-errors-text="${target.id}"]`);
if (errorsEl) errorsEl.textContent = `${errors > 0 ? '⚠️' : '✅'} ${errors}`;
// Update health dot
const isLed = target.target_type === 'led' || target.target_type === 'wled';
if (isLed) {
const row = document.querySelector(`[data-target-id="${target.id}"]`);
if (row) {
const dot = row.querySelector('.health-dot');
if (dot && state.device_last_checked != null) {
dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`;
}
}
}
}
_saveFpsHistory();
}
function _renderPollIntervalSelect() {
const sec = Math.round(dashboardPollInterval / 1000);
return `<span class="dashboard-poll-wrap" onclick="event.stopPropagation()"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
export function changeDashboardPollInterval(value) {
const ms = parseInt(value, 10) * 1000;
setDashboardPollInterval(ms);
startAutoRefresh();
stopPerfPolling();
startPerfPolling();
const label = document.querySelector('.dashboard-poll-value');
if (label) label.textContent = `${value}s`;
}
function _getCollapsedSections() { function _getCollapsedSections() {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; } try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
@@ -85,6 +263,7 @@ export async function loadDashboard() {
// Build dynamic HTML (targets, profiles) // Build dynamic HTML (targets, profiles)
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds = [];
if (targets.length === 0 && profiles.length === 0) { if (targets.length === 0 && profiles.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
@@ -106,6 +285,16 @@ export async function loadDashboard() {
const running = enriched.filter(t => t.state && t.state.processing); const running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing); const stopped = enriched.filter(t => !t.state || !t.state.processing);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
if (hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') {
_updateRunningMetrics(running);
set_dashboardLoading(false);
return;
}
if (profiles.length > 0) { if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active); const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active);
@@ -118,6 +307,7 @@ export async function loadDashboard() {
} }
if (running.length > 0) { if (running.length > 0) {
runningIds = running.map(t => t.id);
const stopAllBtn = `<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>`; const stopAllBtn = `<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>`;
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join(''); const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join('');
@@ -139,9 +329,10 @@ export async function loadDashboard() {
// First load: build everything in one innerHTML to avoid flicker // First load: build everything in one innerHTML to avoid flicker
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent'); const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect();
if (isFirstLoad) { if (isFirstLoad) {
container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section"> container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '')} ${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)}
${_sectionContent('perf', renderPerfSection())} ${_sectionContent('perf', renderPerfSection())}
</div> </div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`; <div class="dashboard-dynamic">${dynamicHtml}</div>`;
@@ -152,6 +343,9 @@ export async function loadDashboard() {
dynamic.innerHTML = dynamicHtml; dynamic.innerHTML = dynamicHtml;
} }
} }
_lastRunningIds = runningIds;
_initFpsCharts(runningIds);
_startUptimeTimer();
startPerfPolling(); startPerfPolling();
} catch (error) { } catch (error) {
@@ -183,13 +377,23 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) {
const uptime = formatUptime(metrics.uptime_seconds); const uptime = formatUptime(metrics.uptime_seconds);
const errors = metrics.errors_count || 0; const errors = metrics.errors_count || 0;
// Set uptime base for interpolation
if (metrics.uptime_seconds != null) {
_setUptimeBase(target.id, metrics.uptime_seconds);
}
// Push FPS sample to history
if (state.fps_actual != null) {
_pushFps(target.id, state.fps_actual);
}
let healthDot = ''; let healthDot = '';
if (isLed && state.device_last_checked != null) { if (isLed && state.device_last_checked != null) {
const cls = state.device_online ? 'health-online' : 'health-offline'; const cls = state.device_online ? 'health-online' : 'health-offline';
healthDot = `<span class="health-dot ${cls}"></span>`; healthDot = `<span class="health-dot ${cls}"></span>`;
} }
return `<div class="dashboard-target"> return `<div class="dashboard-target" data-target-id="${target.id}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span> <span class="dashboard-target-icon">${icon}</span>
<div> <div>
@@ -198,17 +402,19 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) {
</div> </div>
</div> </div>
<div class="dashboard-target-metrics"> <div class="dashboard-target-metrics">
<div class="dashboard-metric"> <div class="dashboard-metric dashboard-fps-metric">
<div class="dashboard-metric-value">${fpsActual}/${fpsTarget}</div> <div class="dashboard-fps-sparkline">
<div class="dashboard-metric-label">${t('dashboard.fps')}</div> <canvas id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
</div> </div>
<div class="dashboard-metric"> <div class="dashboard-fps-label">
<div class="dashboard-metric-value">${uptime}</div> <span class="dashboard-metric-value" data-fps-text="${target.id}">${fpsActual}<span class="dashboard-fps-target">/${fpsTarget}</span></span>
<div class="dashboard-metric-label">${t('dashboard.uptime')}</div>
</div> </div>
<div class="dashboard-metric"> </div>
<div class="dashboard-metric-value">${errors}</div> <div class="dashboard-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-label">${t('dashboard.errors')}</div> <div class="dashboard-metric-value" data-uptime-text="${target.id}">🕐 ${uptime}</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.errors')}">
<div class="dashboard-metric-value" data-errors-text="${target.id}">${errors > 0 ? '⚠️' : '✅'} ${errors}</div>
</div> </div>
</div> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
@@ -374,6 +580,10 @@ export function startDashboardWS() {
} }
} }
export function stopUptimeTimer() {
_stopUptimeTimer();
}
export function stopDashboardWS() { export function stopDashboardWS() {
if (_dashboardWS) { if (_dashboardWS) {
_dashboardWS.close(); _dashboardWS.close();

View File

@@ -4,9 +4,9 @@
import { API_BASE, getHeaders } from '../core/api.js'; import { API_BASE, getHeaders } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { dashboardPollInterval } from '../core/state.js';
const MAX_SAMPLES = 60; const MAX_SAMPLES = 60;
const POLL_INTERVAL_MS = 2000;
const STORAGE_KEY = 'perf_history'; const STORAGE_KEY = 'perf_history';
let _pollTimer = null; let _pollTimer = null;
@@ -168,7 +168,7 @@ async function _fetchPerformance() {
export function startPerfPolling() { export function startPerfPolling() {
if (_pollTimer) return; if (_pollTimer) return;
_fetchPerformance(); _fetchPerformance();
_pollTimer = setInterval(_fetchPerformance, POLL_INTERVAL_MS); _pollTimer = setInterval(_fetchPerformance, dashboardPollInterval);
} }
export function stopPerfPolling() { export function stopPerfPolling() {

View File

@@ -2,7 +2,7 @@
* Tab switching — switchTab, initTabs, startAutoRefresh. * Tab switching — switchTab, initTabs, startAutoRefresh.
*/ */
import { apiKey, refreshInterval, setRefreshInterval } from '../core/state.js'; import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js';
export function switchTab(name) { export function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
@@ -15,6 +15,7 @@ export function switchTab(name) {
} else { } else {
if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS();
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling(); if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
if (name === 'streams') { if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') { } else if (name === 'targets') {
@@ -50,5 +51,5 @@ export function startAutoRefresh() {
if (typeof window.loadDashboard === 'function') window.loadDashboard(); if (typeof window.loadDashboard === 'function') window.loadDashboard();
} }
} }
}, 2000)); }, dashboardPollInterval));
} }

View File

@@ -473,6 +473,7 @@
"dashboard.perf.ram": "RAM", "dashboard.perf.ram": "RAM",
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "unavailable", "dashboard.perf.unavailable": "unavailable",
"dashboard.poll_interval": "Refresh interval",
"profiles.title": "\uD83D\uDCCB Profiles", "profiles.title": "\uD83D\uDCCB Profiles",
"profiles.empty": "No profiles configured. Create one to automate target activation.", "profiles.empty": "No profiles configured. Create one to automate target activation.",

View File

@@ -473,6 +473,7 @@
"dashboard.perf.ram": "ОЗУ", "dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП", "dashboard.perf.gpu": "ГП",
"dashboard.perf.unavailable": "недоступно", "dashboard.perf.unavailable": "недоступно",
"dashboard.poll_interval": "Интервал обновления",
"profiles.title": "\uD83D\uDCCB Профили", "profiles.title": "\uD83D\uDCCB Профили",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",

View File

@@ -3284,12 +3284,32 @@ input:-webkit-autofill:focus {
flex: 0 0 auto; flex: 0 0 auto;
} }
.dashboard-poll-wrap {
margin-left: auto;
display: flex;
align-items: center;
gap: 3px;
}
.dashboard-poll-slider {
width: 48px;
height: 12px;
accent-color: var(--primary-color);
cursor: pointer;
}
.dashboard-poll-value {
font-size: 0.6rem;
color: var(--text-secondary);
min-width: 18px;
}
.dashboard-target { .dashboard-target {
display: grid; display: grid;
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 12px; padding: 6px 12px;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
@@ -3358,6 +3378,33 @@ input:-webkit-autofill:focus {
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.dashboard-fps-metric {
display: flex;
align-items: center;
gap: 6px;
min-width: auto;
}
.dashboard-fps-sparkline {
position: relative;
width: 100px;
height: 36px;
}
.dashboard-fps-label {
display: flex;
flex-direction: column;
align-items: center;
min-width: 36px;
line-height: 1.1;
}
.dashboard-fps-target {
font-weight: 400;
opacity: 0.5;
font-size: 0.75rem;
}
.dashboard-target-actions { .dashboard-target-actions {
display: flex; display: flex;
align-items: center; align-items: center;