Add WebSocket device type, capability-driven settings, hide filter on collapse
- New WS device type: broadcaster singleton + LEDClient that sends binary
frames to connected WebSocket clients during processing
- FastAPI WS endpoint at /api/v1/devices/{device_id}/ws with token auth
- Frontend: add/edit WS devices, connection URL with copy button in settings
- Add health_check and auto_restore capabilities to WLED and Serial providers;
hide health interval and auto-restore toggle for devices without them
- Skip health check loop for virtual devices (Mock, MQTT, WS) — set always-online
- Copy buttons and labels for API CSS push endpoints (REST POST / WebSocket)
- Hide mock:// and ws:// URLs in target device dropdown
- Hide filter textbox when card section is collapsed (cs-collapsed CSS class)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
"""Device routes: CRUD, health state, brightness, power, calibration."""
|
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.core.devices.led_client import (
|
from wled_controller.core.devices.led_client import (
|
||||||
@@ -122,6 +124,11 @@ async def create_device(
|
|||||||
rgbw=device_data.rgbw or False,
|
rgbw=device_data.rgbw or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# WS devices: auto-set URL to ws://{device_id}
|
||||||
|
if device_type == "ws":
|
||||||
|
store.update_device(device_id=device.id, url=f"ws://{device.id}")
|
||||||
|
device = store.get_device(device.id)
|
||||||
|
|
||||||
# Register in processor manager for health monitoring
|
# Register in processor manager for health monitoring
|
||||||
manager.add_device(
|
manager.add_device(
|
||||||
device_id=device.id,
|
device_id=device.id,
|
||||||
@@ -486,3 +493,55 @@ async def set_device_power(
|
|||||||
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== WEBSOCKET DEVICE STREAM =====
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/devices/{device_id}/ws")
|
||||||
|
async def device_ws_stream(
|
||||||
|
websocket: WebSocket,
|
||||||
|
device_id: str,
|
||||||
|
token: str = Query(""),
|
||||||
|
):
|
||||||
|
"""WebSocket stream of LED pixel data for WS device type.
|
||||||
|
|
||||||
|
Wire format: [brightness_byte][R G B R G B ...]
|
||||||
|
Auth via ?token=<api_key>.
|
||||||
|
"""
|
||||||
|
from wled_controller.config import get_config
|
||||||
|
|
||||||
|
authenticated = False
|
||||||
|
cfg = get_config()
|
||||||
|
if token and cfg.auth.api_keys:
|
||||||
|
for _label, api_key in cfg.auth.api_keys.items():
|
||||||
|
if secrets.compare_digest(token, api_key):
|
||||||
|
authenticated = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not authenticated:
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
store = get_device_store()
|
||||||
|
device = store.get_device(device_id)
|
||||||
|
if not device:
|
||||||
|
await websocket.close(code=4004, reason="Device not found")
|
||||||
|
return
|
||||||
|
if device.device_type != "ws":
|
||||||
|
await websocket.close(code=4003, reason="Device is not a WebSocket device")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
from wled_controller.core.devices.ws_client import get_ws_broadcaster
|
||||||
|
|
||||||
|
broadcaster = get_ws_broadcaster()
|
||||||
|
broadcaster.add_client(device_id, websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
broadcaster.remove_client(device_id, websocket)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -285,5 +285,8 @@ def _register_builtin_providers():
|
|||||||
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
|
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
|
||||||
register_provider(MQTTDeviceProvider())
|
register_provider(MQTTDeviceProvider())
|
||||||
|
|
||||||
|
from wled_controller.core.devices.ws_provider import WSDeviceProvider
|
||||||
|
register_provider(WSDeviceProvider())
|
||||||
|
|
||||||
|
|
||||||
_register_builtin_providers()
|
_register_builtin_providers()
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class SerialDeviceProvider(LEDDeviceProvider):
|
|||||||
# manual_led_count: user must specify LED count (can't auto-detect)
|
# manual_led_count: user must specify LED count (can't auto-detect)
|
||||||
# power_control: can blank LEDs by sending all-black pixels
|
# power_control: can blank LEDs by sending all-black pixels
|
||||||
# brightness_control: software brightness (multiplies pixel values before sending)
|
# brightness_control: software brightness (multiplies pixel values before sending)
|
||||||
return {"manual_led_count", "power_control", "brightness_control"}
|
# health_check: serial port availability probe
|
||||||
|
# auto_restore: blank LEDs when targets stop
|
||||||
|
return {"manual_led_count", "power_control", "brightness_control", "health_check", "auto_restore"}
|
||||||
|
|
||||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
# Generic serial port health check — enumerate COM ports
|
# Generic serial port health check — enumerate COM ports
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def capabilities(self) -> set:
|
def capabilities(self) -> set:
|
||||||
return {"brightness_control", "power_control", "standby_required", "static_color"}
|
return {"brightness_control", "power_control", "standby_required", "static_color", "health_check", "auto_restore"}
|
||||||
|
|
||||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
from wled_controller.core.devices.wled_client import WLEDClient
|
from wled_controller.core.devices.wled_client import WLEDClient
|
||||||
|
|||||||
130
server/src/wled_controller/core/devices/ws_client.py
Normal file
130
server/src/wled_controller/core/devices/ws_client.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""WebSocket LED client — broadcasts pixel data to connected WebSocket clients."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WSDeviceBroadcaster:
|
||||||
|
"""Global registry of WebSocket clients subscribed to WS device streams.
|
||||||
|
|
||||||
|
Each WS device (identified by device_id) can have zero or more connected
|
||||||
|
WebSocket clients. The WSLEDClient.send_pixels() method uses this to
|
||||||
|
broadcast frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._clients: Dict[str, List] = {}
|
||||||
|
|
||||||
|
def add_client(self, device_id: str, ws) -> None:
|
||||||
|
self._clients.setdefault(device_id, []).append(ws)
|
||||||
|
logger.info(
|
||||||
|
"WS device %s: client connected (%d total)",
|
||||||
|
device_id, len(self._clients[device_id]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_client(self, device_id: str, ws) -> None:
|
||||||
|
clients = self._clients.get(device_id)
|
||||||
|
if clients and ws in clients:
|
||||||
|
clients.remove(ws)
|
||||||
|
logger.info(
|
||||||
|
"WS device %s: client disconnected (%d remaining)",
|
||||||
|
device_id, len(clients),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_clients(self, device_id: str) -> List:
|
||||||
|
return self._clients.get(device_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
_broadcaster = WSDeviceBroadcaster()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ws_broadcaster() -> WSDeviceBroadcaster:
|
||||||
|
return _broadcaster
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ws_url(url: str) -> str:
|
||||||
|
"""Extract device_id from a ws:// URL.
|
||||||
|
|
||||||
|
Format: ws://device-id
|
||||||
|
"""
|
||||||
|
if url.startswith("ws://"):
|
||||||
|
return url[5:]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class WSLEDClient(LEDClient):
|
||||||
|
"""Broadcasts binary pixel data to WebSocket clients via the global broadcaster."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, led_count: int = 0, **kwargs):
|
||||||
|
self._device_id = parse_ws_url(url)
|
||||||
|
self._led_count = led_count
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
self._connected = True
|
||||||
|
logger.info("WS device client connected for device %s", self._device_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._connected = False
|
||||||
|
logger.info("WS device client closed for device %s", self._device_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
async def send_pixels(
|
||||||
|
self,
|
||||||
|
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||||
|
brightness: int = 255,
|
||||||
|
) -> bool:
|
||||||
|
if not self._connected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
clients = _broadcaster.get_clients(self._device_id)
|
||||||
|
if not clients:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Build binary frame: [brightness_byte][R G B R G B ...]
|
||||||
|
if isinstance(pixels, np.ndarray):
|
||||||
|
pixel_bytes = pixels.astype(np.uint8).tobytes()
|
||||||
|
else:
|
||||||
|
pixel_bytes = bytes(c for rgb in pixels for c in rgb)
|
||||||
|
|
||||||
|
data = bytes([brightness]) + pixel_bytes
|
||||||
|
|
||||||
|
async def _send_safe(ws):
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[_send_safe(ws) for ws in clients])
|
||||||
|
|
||||||
|
disconnected = [ws for ws, ok in zip(clients, results) if not ok]
|
||||||
|
for ws in disconnected:
|
||||||
|
_broadcaster.remove_client(self._device_id, ws)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def check_health(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
http_client,
|
||||||
|
prev_health: Optional[DeviceHealth] = None,
|
||||||
|
) -> DeviceHealth:
|
||||||
|
return DeviceHealth(
|
||||||
|
online=True,
|
||||||
|
latency_ms=0.0,
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
)
|
||||||
44
server/src/wled_controller/core/devices/ws_provider.py
Normal file
44
server/src/wled_controller/core/devices/ws_provider.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""WebSocket device provider — factory, validation, health checks."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from wled_controller.core.devices.led_client import (
|
||||||
|
DeviceHealth,
|
||||||
|
DiscoveredDevice,
|
||||||
|
LEDClient,
|
||||||
|
LEDDeviceProvider,
|
||||||
|
)
|
||||||
|
from wled_controller.core.devices.ws_client import WSLEDClient, parse_ws_url
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WSDeviceProvider(LEDDeviceProvider):
|
||||||
|
"""Provider for WebSocket-based virtual LED devices."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_type(self) -> str:
|
||||||
|
return "ws"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set:
|
||||||
|
return {"manual_led_count"}
|
||||||
|
|
||||||
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
|
return WSLEDClient(url, **kwargs)
|
||||||
|
|
||||||
|
async def check_health(
|
||||||
|
self, url: str, http_client, prev_health=None,
|
||||||
|
) -> DeviceHealth:
|
||||||
|
return DeviceHealth(
|
||||||
|
online=True, latency_ms=0.0, last_checked=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_device(self, url: str) -> dict:
|
||||||
|
"""Validate WS device URL — accepts any URL since it will be auto-set."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||||
|
return []
|
||||||
@@ -11,6 +11,7 @@ from wled_controller.core.devices.led_client import (
|
|||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
check_device_health,
|
check_device_health,
|
||||||
create_led_client,
|
create_led_client,
|
||||||
|
get_device_capabilities,
|
||||||
get_provider,
|
get_provider,
|
||||||
)
|
)
|
||||||
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
from wled_controller.core.audio.audio_capture import AudioCaptureManager
|
||||||
@@ -810,6 +811,11 @@ class ProcessorManager:
|
|||||||
state = self._devices.get(device_id)
|
state = self._devices.get(device_id)
|
||||||
if not state:
|
if not state:
|
||||||
return
|
return
|
||||||
|
# Skip periodic health checks for virtual devices (always online)
|
||||||
|
if "health_check" not in get_device_capabilities(state.device_type):
|
||||||
|
from datetime import datetime
|
||||||
|
state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow())
|
||||||
|
return
|
||||||
if state.health_task and not state.health_task.done():
|
if state.health_task and not state.health_task.done():
|
||||||
return
|
return
|
||||||
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
|
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
|
||||||
|
|||||||
@@ -414,6 +414,15 @@ textarea:focus-visible {
|
|||||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WS device connection URL */
|
||||||
|
.ws-url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ws-url-row input { flex: 1; }
|
||||||
|
.ws-url-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; }
|
||||||
|
.endpoint-label { display: block; font-weight: 600; margin-bottom: 2px; opacity: 0.7; font-size: 0.8em; }
|
||||||
|
|
||||||
/* Scene target selector */
|
/* Scene target selector */
|
||||||
.scene-target-add-row {
|
.scene-target-add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -682,6 +682,10 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cs-collapsed .cs-filter-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.cs-filter-wrap {
|
.cs-filter-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
|
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
|
||||||
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
|
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
|
||||||
turnOffDevice, removeDevice, loadDevices,
|
turnOffDevice, removeDevice, loadDevices,
|
||||||
updateSettingsBaudFpsHint,
|
updateSettingsBaudFpsHint, copyWsUrl,
|
||||||
} from './features/devices.js';
|
} from './features/devices.js';
|
||||||
import {
|
import {
|
||||||
loadDashboard, stopUptimeTimer,
|
loadDashboard, stopUptimeTimer,
|
||||||
@@ -113,6 +113,7 @@ import {
|
|||||||
onAudioVizChange,
|
onAudioVizChange,
|
||||||
applyGradientPreset,
|
applyGradientPreset,
|
||||||
cloneColorStrip,
|
cloneColorStrip,
|
||||||
|
copyEndpointUrl,
|
||||||
} from './features/color-strips.js';
|
} from './features/color-strips.js';
|
||||||
|
|
||||||
// Layer 5: audio sources
|
// Layer 5: audio sources
|
||||||
@@ -201,6 +202,7 @@ Object.assign(window, {
|
|||||||
removeDevice,
|
removeDevice,
|
||||||
loadDevices,
|
loadDevices,
|
||||||
updateSettingsBaudFpsHint,
|
updateSettingsBaudFpsHint,
|
||||||
|
copyWsUrl,
|
||||||
|
|
||||||
// dashboard
|
// dashboard
|
||||||
loadDashboard,
|
loadDashboard,
|
||||||
@@ -368,6 +370,7 @@ Object.assign(window, {
|
|||||||
onAudioVizChange,
|
onAudioVizChange,
|
||||||
applyGradientPreset,
|
applyGradientPreset,
|
||||||
cloneColorStrip,
|
cloneColorStrip,
|
||||||
|
copyEndpointUrl,
|
||||||
|
|
||||||
// audio sources
|
// audio sources
|
||||||
showAudioSourceModal,
|
showAudioSourceModal,
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export function isMqttDevice(type) {
|
|||||||
return type === 'mqtt';
|
return type === 'mqtt';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWsDevice(type) {
|
||||||
|
return type === 'ws';
|
||||||
|
}
|
||||||
|
|
||||||
export function handle401Error() {
|
export function handle401Error() {
|
||||||
if (!apiKey) return; // Already handled or no session
|
if (!apiKey) return; // Already handled or no session
|
||||||
localStorage.removeItem('wled_api_key');
|
localStorage.removeItem('wled_api_key');
|
||||||
|
|||||||
@@ -74,13 +74,14 @@ export class CardSection {
|
|||||||
const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
|
const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
|
||||||
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
|
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
|
||||||
const contentDisplay = isCollapsed ? ' style="display:none"' : '';
|
const contentDisplay = isCollapsed ? ' style="display:none"' : '';
|
||||||
|
const collapsedClass = isCollapsed ? ' cs-collapsed' : '';
|
||||||
|
|
||||||
const addCard = this.addCardOnclick
|
const addCard = this.addCardOnclick
|
||||||
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="subtab-section" data-card-section="${this.sectionKey}">
|
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
|
||||||
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
|
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
|
||||||
<span class="cs-chevron"${chevronStyle}>▶</span>
|
<span class="cs-chevron"${chevronStyle}>▶</span>
|
||||||
<span class="cs-title">${t(this.titleKey)}</span>
|
<span class="cs-title">${t(this.titleKey)}</span>
|
||||||
@@ -277,7 +278,9 @@ export class CardSection {
|
|||||||
map[s.sectionKey] = false;
|
map[s.sectionKey] = false;
|
||||||
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
|
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
|
||||||
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
|
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
|
||||||
|
const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`);
|
||||||
if (content) content.style.display = '';
|
if (content) content.style.display = '';
|
||||||
|
if (section) section.classList.remove('cs-collapsed');
|
||||||
if (header) {
|
if (header) {
|
||||||
const chevron = header.querySelector('.cs-chevron');
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
if (chevron) chevron.style.transform = 'rotate(90deg)';
|
if (chevron) chevron.style.transform = 'rotate(90deg)';
|
||||||
@@ -293,7 +296,9 @@ export class CardSection {
|
|||||||
map[s.sectionKey] = true;
|
map[s.sectionKey] = true;
|
||||||
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
|
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
|
||||||
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
|
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
|
||||||
|
const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`);
|
||||||
if (content) content.style.display = 'none';
|
if (content) content.style.display = 'none';
|
||||||
|
if (section) section.classList.add('cs-collapsed');
|
||||||
if (header) {
|
if (header) {
|
||||||
const chevron = header.querySelector('.cs-chevron');
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
if (chevron) chevron.style.transform = '';
|
if (chevron) chevron.style.transform = '';
|
||||||
@@ -312,6 +317,8 @@ export class CardSection {
|
|||||||
map[this.sectionKey] = false;
|
map[this.sectionKey] = false;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
content.style.display = '';
|
content.style.display = '';
|
||||||
|
const section = header.closest('[data-card-section]');
|
||||||
|
if (section) section.classList.remove('cs-collapsed');
|
||||||
const chevron = header.querySelector('.cs-chevron');
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
if (chevron) chevron.style.transform = 'rotate(90deg)';
|
if (chevron) chevron.style.transform = 'rotate(90deg)';
|
||||||
}
|
}
|
||||||
@@ -374,6 +381,9 @@ export class CardSection {
|
|||||||
map[this.sectionKey] = nowCollapsed;
|
map[this.sectionKey] = nowCollapsed;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
|
||||||
|
const section = header.closest('[data-card-section]');
|
||||||
|
if (section) section.classList.toggle('cs-collapsed', nowCollapsed);
|
||||||
|
|
||||||
const chevron = header.querySelector('.cs-chevron');
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';
|
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';
|
||||||
|
|
||||||
|
|||||||
@@ -1080,12 +1080,26 @@ function _showApiInputEndpoints(cssId) {
|
|||||||
const base = `${window.location.origin}/api/v1`;
|
const base = `${window.location.origin}/api/v1`;
|
||||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsBase = `${wsProto}//${window.location.host}/api/v1`;
|
const wsBase = `${wsProto}//${window.location.host}/api/v1`;
|
||||||
|
const restUrl = `${base}/color-strip-sources/${cssId}/colors`;
|
||||||
|
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||||||
|
const wsUrl = `${wsBase}/color-strip-sources/${cssId}/ws?token=${encodeURIComponent(apiKey)}`;
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="margin-bottom:4px"><strong>REST POST:</strong><br>${base}/color-strip-sources/${cssId}/colors</div>
|
<small class="endpoint-label">REST POST</small>
|
||||||
<div><strong>WebSocket:</strong><br>${wsBase}/color-strip-sources/${cssId}/ws?token=<api_key></div>
|
<div class="ws-url-row" style="margin-bottom:6px"><input type="text" value="${restUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">📋</button></div>
|
||||||
|
<small class="endpoint-label">WebSocket</small>
|
||||||
|
<div class="ws-url-row"><input type="text" value="${wsUrl}" readonly style="font-size:0.85em"><button type="button" class="btn btn-sm btn-secondary" onclick="copyEndpointUrl(this)" title="Copy">📋</button></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function copyEndpointUrl(btn) {
|
||||||
|
const input = btn.parentElement.querySelector('input');
|
||||||
|
if (input && input.value) {
|
||||||
|
navigator.clipboard.writeText(input.value).then(() => {
|
||||||
|
showToast(t('settings.copied') || 'Copied!', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Clone ────────────────────────────────────────────────────── */
|
/* ── Clone ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export async function cloneColorStrip(cssId) {
|
export async function cloneColorStrip(cssId) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
_discoveryScanRunning, set_discoveryScanRunning,
|
_discoveryScanRunning, set_discoveryScanRunning,
|
||||||
_discoveryCache, set_discoveryCache,
|
_discoveryCache, set_discoveryCache,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, escapeHtml } from '../core/api.js';
|
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, escapeHtml } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast } from '../core/ui.js';
|
import { showToast } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
@@ -76,6 +76,17 @@ export function onDeviceTypeChanged() {
|
|||||||
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
|
if (sendLatencyGroup) sendLatencyGroup.style.display = '';
|
||||||
if (discoverySection) discoverySection.style.display = 'none';
|
if (discoverySection) discoverySection.style.display = 'none';
|
||||||
if (scanBtn) scanBtn.style.display = 'none';
|
if (scanBtn) scanBtn.style.display = 'none';
|
||||||
|
} else if (isWsDevice(deviceType)) {
|
||||||
|
urlGroup.style.display = 'none';
|
||||||
|
urlInput.removeAttribute('required');
|
||||||
|
serialGroup.style.display = 'none';
|
||||||
|
serialSelect.removeAttribute('required');
|
||||||
|
ledCountGroup.style.display = '';
|
||||||
|
baudRateGroup.style.display = 'none';
|
||||||
|
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||||
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||||
|
if (discoverySection) discoverySection.style.display = 'none';
|
||||||
|
if (scanBtn) scanBtn.style.display = 'none';
|
||||||
} else if (isSerialDevice(deviceType)) {
|
} else if (isSerialDevice(deviceType)) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
urlInput.removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
@@ -338,6 +349,8 @@ export async function handleAddDevice(event) {
|
|||||||
let url;
|
let url;
|
||||||
if (isMockDevice(deviceType)) {
|
if (isMockDevice(deviceType)) {
|
||||||
url = 'mock://';
|
url = 'mock://';
|
||||||
|
} else if (isWsDevice(deviceType)) {
|
||||||
|
url = 'ws://';
|
||||||
} else if (isSerialDevice(deviceType)) {
|
} else if (isSerialDevice(deviceType)) {
|
||||||
url = document.getElementById('device-serial-port').value;
|
url = document.getElementById('device-serial-port').value;
|
||||||
} else {
|
} else {
|
||||||
@@ -349,7 +362,7 @@ export async function handleAddDevice(event) {
|
|||||||
url = 'mqtt://' + url;
|
url = 'mqtt://' + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name || (!isMockDevice(deviceType) && !url)) {
|
if (!name || (!isMockDevice(deviceType) && !isWsDevice(deviceType) && !url)) {
|
||||||
error.textContent = t('device_discovery.error.fill_all_fields');
|
error.textContent = t('device_discovery.error.fill_all_fields');
|
||||||
error.style.display = 'block';
|
error.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import {
|
import {
|
||||||
_deviceBrightnessCache, updateDeviceBrightness,
|
_deviceBrightnessCache, updateDeviceBrightness,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice } from '../core/api.js';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice } from '../core/api.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
@@ -34,6 +34,10 @@ class DeviceSettingsModal extends Modal {
|
|||||||
const deviceId = this.$('settings-device-id')?.value || '';
|
const deviceId = this.$('settings-device-id')?.value || '';
|
||||||
return `mock://${deviceId}`;
|
return `mock://${deviceId}`;
|
||||||
}
|
}
|
||||||
|
if (isWsDevice(this.deviceType)) {
|
||||||
|
const deviceId = this.$('settings-device-id')?.value || '';
|
||||||
|
return `ws://${deviceId}`;
|
||||||
|
}
|
||||||
if (isSerialDevice(this.deviceType)) {
|
if (isSerialDevice(this.deviceType)) {
|
||||||
return this.$('settings-serial-port').value;
|
return this.$('settings-serial-port').value;
|
||||||
}
|
}
|
||||||
@@ -83,7 +87,7 @@ export function createDeviceCard(device) {
|
|||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||||
${device.name || device.id}
|
${device.name || device.id}
|
||||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||||
${healthLabel}
|
${healthLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,13 +178,14 @@ export async function showSettings(deviceId) {
|
|||||||
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
|
document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30;
|
||||||
|
|
||||||
const isMock = isMockDevice(device.device_type);
|
const isMock = isMockDevice(device.device_type);
|
||||||
|
const isWs = isWsDevice(device.device_type);
|
||||||
const isMqtt = isMqttDevice(device.device_type);
|
const isMqtt = isMqttDevice(device.device_type);
|
||||||
const urlGroup = document.getElementById('settings-url-group');
|
const urlGroup = document.getElementById('settings-url-group');
|
||||||
const serialGroup = document.getElementById('settings-serial-port-group');
|
const serialGroup = document.getElementById('settings-serial-port-group');
|
||||||
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
|
const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]');
|
||||||
const urlHint = urlGroup.querySelector('.input-hint');
|
const urlHint = urlGroup.querySelector('.input-hint');
|
||||||
const urlInput = document.getElementById('settings-device-url');
|
const urlInput = document.getElementById('settings-device-url');
|
||||||
if (isMock) {
|
if (isMock || isWs) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
urlInput.removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
serialGroup.style.display = 'none';
|
serialGroup.style.display = 'none';
|
||||||
@@ -245,6 +250,31 @@ export async function showSettings(deviceId) {
|
|||||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WS connection URL
|
||||||
|
const wsUrlGroup = document.getElementById('settings-ws-url-group');
|
||||||
|
if (wsUrlGroup) {
|
||||||
|
if (isWs) {
|
||||||
|
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||||||
|
const wsUrl = `${wsProto}//${location.host}/api/v1/devices/${device.id}/ws?token=${encodeURIComponent(apiKey)}`;
|
||||||
|
document.getElementById('settings-ws-url').value = wsUrl;
|
||||||
|
wsUrlGroup.style.display = '';
|
||||||
|
} else {
|
||||||
|
wsUrlGroup.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide health check for devices without health_check capability
|
||||||
|
const healthIntervalGroup = document.getElementById('settings-health-interval-group');
|
||||||
|
if (healthIntervalGroup) {
|
||||||
|
healthIntervalGroup.style.display = caps.includes('health_check') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide auto-restore for devices without auto_restore capability
|
||||||
|
const autoShutdownGroup = document.getElementById('settings-auto-shutdown-group');
|
||||||
|
if (autoShutdownGroup) {
|
||||||
|
autoShutdownGroup.style.display = caps.includes('auto_restore') ? '' : 'none';
|
||||||
|
}
|
||||||
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
||||||
settingsModal.snapshot();
|
settingsModal.snapshot();
|
||||||
settingsModal.open();
|
settingsModal.open();
|
||||||
@@ -442,6 +472,15 @@ async function _populateSettingsSerialPorts(currentUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function copyWsUrl() {
|
||||||
|
const input = document.getElementById('settings-ws-url');
|
||||||
|
if (input && input.value) {
|
||||||
|
navigator.clipboard.writeText(input.value).then(() => {
|
||||||
|
showToast(t('settings.copied') || 'Copied!', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDevices() {
|
export async function loadDevices() {
|
||||||
await window.loadTargetsTab();
|
await window.loadTargetsTab();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = d.id;
|
opt.value = d.id;
|
||||||
opt.dataset.name = d.name;
|
opt.dataset.name = d.name;
|
||||||
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
|
const shortUrl = d.url && d.url.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : '';
|
||||||
const devType = (d.device_type || 'wled').toUpperCase();
|
const devType = (d.device_type || 'wled').toUpperCase();
|
||||||
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||||
deviceSelect.appendChild(opt);
|
deviceSelect.appendChild(opt);
|
||||||
|
|||||||
@@ -131,6 +131,8 @@
|
|||||||
"device.mqtt_topic": "MQTT Topic:",
|
"device.mqtt_topic": "MQTT Topic:",
|
||||||
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
|
"device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)",
|
||||||
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
|
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room",
|
||||||
|
"device.ws_url": "Connection URL:",
|
||||||
|
"device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data",
|
||||||
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
|
||||||
"device.name": "Device Name:",
|
"device.name": "Device Name:",
|
||||||
"device.name.placeholder": "Living Room TV",
|
"device.name.placeholder": "Living Room TV",
|
||||||
|
|||||||
@@ -131,6 +131,8 @@
|
|||||||
"device.mqtt_topic": "MQTT Топик:",
|
"device.mqtt_topic": "MQTT Топик:",
|
||||||
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
|
"device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)",
|
||||||
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
|
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная",
|
||||||
|
"device.ws_url": "URL подключения:",
|
||||||
|
"device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных",
|
||||||
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
|
||||||
"device.name": "Имя Устройства:",
|
"device.name": "Имя Устройства:",
|
||||||
"device.name.placeholder": "ТВ в Гостиной",
|
"device.name.placeholder": "ТВ в Гостиной",
|
||||||
|
|||||||
@@ -131,6 +131,8 @@
|
|||||||
"device.mqtt_topic": "MQTT 主题:",
|
"device.mqtt_topic": "MQTT 主题:",
|
||||||
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)",
|
"device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)",
|
||||||
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
|
"device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅",
|
||||||
|
"device.ws_url": "连接 URL:",
|
||||||
|
"device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL",
|
||||||
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
"device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)",
|
||||||
"device.name": "设备名称:",
|
"device.name": "设备名称:",
|
||||||
"device.name.placeholder": "客厅电视",
|
"device.name.placeholder": "客厅电视",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<option value="adalight">Adalight</option>
|
<option value="adalight">Adalight</option>
|
||||||
<option value="ambiled">AmbiLED</option>
|
<option value="ambiled">AmbiLED</option>
|
||||||
<option value="mqtt">MQTT</option>
|
<option value="mqtt">MQTT</option>
|
||||||
|
<option value="ws">WebSocket</option>
|
||||||
<option value="mock">Mock</option>
|
<option value="mock">Mock</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<input type="number" id="settings-send-latency" min="0" max="5000" value="0">
|
<input type="number" id="settings-send-latency" min="0" max="5000" value="0">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" id="settings-health-interval-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group settings-toggle-group">
|
<div class="form-group settings-toggle-group" id="settings-auto-shutdown-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label data-i18n="settings.auto_shutdown">Auto Restore:</label>
|
<label data-i18n="settings.auto_shutdown">Auto Restore:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
@@ -99,6 +99,18 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="settings-ws-url-group" style="display: none;">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="settings-ws-url" data-i18n="device.ws_url">Connection URL:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="device.ws_url.hint">WebSocket URL for clients to connect and receive LED data</small>
|
||||||
|
<div class="ws-url-row">
|
||||||
|
<input type="text" id="settings-ws-url" readonly>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="copyWsUrl()" title="Copy">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display: none;"></div>
|
<div id="settings-error" class="error-message" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user