Compare commits
2 Commits
ef925ad0a9
...
f83cd81937
| Author | SHA1 | Date | |
|---|---|---|---|
| f83cd81937 | |||
| 45634836b6 |
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
136
server/src/wled_controller/core/devices/serial_provider.py
Normal file
136
server/src/wled_controller/core/devices/serial_provider.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 class="dashboard-fps-label">
|
||||||
|
<span class="dashboard-metric-value" data-fps-text="${target.id}">${fpsActual}<span class="dashboard-fps-target">/${fpsTarget}</span></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-metric">
|
<div class="dashboard-metric" title="${t('dashboard.uptime')}">
|
||||||
<div class="dashboard-metric-value">${uptime}</div>
|
<div class="dashboard-metric-value" data-uptime-text="${target.id}">🕐 ${uptime}</div>
|
||||||
<div class="dashboard-metric-label">${t('dashboard.uptime')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-metric">
|
<div class="dashboard-metric" title="${t('dashboard.errors')}">
|
||||||
<div class="dashboard-metric-value">${errors}</div>
|
<div class="dashboard-metric-value" data-errors-text="${target.id}">${errors > 0 ? '⚠️' : '✅'} ${errors}</div>
|
||||||
<div class="dashboard-metric-label">${t('dashboard.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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Профили не настроены. Создайте профиль для автоматизации целей.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user