Add tags to all entity types with chip-based input and autocomplete

- Add `tags: List[str]` field to all 13 entity types (devices, output targets,
  CSS sources, picture sources, audio sources, value sources, sync clocks,
  automations, scene presets, capture/audio/PP/pattern templates)
- Update all stores, schemas, and route handlers for tag CRUD
- Add GET /api/v1/tags endpoint aggregating unique tags across all stores
- Create TagInput component with chip display, autocomplete dropdown,
  keyboard navigation, and API-backed suggestions
- Display tag chips on all entity cards (searchable via existing text filter)
- Add tag input to all 14 editor modals with dirty check support
- Add CSS styles and i18n keys (en/ru/zh) for tag UI
- Also includes code review fixes: thread safety, perf, store dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 22:20:19 +03:00
parent 2712c6682e
commit 30fa107ef7
120 changed files with 2471 additions and 1949 deletions

View File

@@ -1,7 +1,7 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional, Tuple
import numpy as np
@@ -199,7 +199,7 @@ class AdalightClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=prev_health.device_name if prev_health else None,
device_version=None,
device_led_count=prev_health.device_led_count if prev_health else None,
@@ -207,12 +207,12 @@ class AdalightClient(LEDClient):
else:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=f"Serial port {port} not found",
)
except Exception as e:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=str(e),
)

View File

@@ -190,9 +190,12 @@ class DDPClient:
try:
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
# Convert to numpy to avoid per-pixel Python loop
# Accept numpy arrays directly to avoid per-pixel Python loop
bpp = 4 if self.rgbw else 3 # bytes per pixel
pixel_array = np.array(pixels, dtype=np.uint8)
if isinstance(pixels, np.ndarray):
pixel_array = pixels
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if self.rgbw:
n = pixel_array.shape[0]
if n != self._rgbw_buf_n:
@@ -219,7 +222,7 @@ class DDPClient:
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end])
chunk = pixel_bytes[start:end]
is_last = (i == num_packets - 1)
# Increment sequence number

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
@@ -139,7 +139,7 @@ class LEDClient(ABC):
http_client: Shared httpx.AsyncClient for HTTP requests
prev_health: Previous health result (for preserving cached metadata)
"""
return DeviceHealth(online=True, last_checked=datetime.utcnow())
return DeviceHealth(online=True, last_checked=datetime.now(timezone.utc))
async def __aenter__(self):
await self.connect()

View File

@@ -1,7 +1,7 @@
"""Mock LED client — simulates an LED strip with configurable latency for testing."""
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional, Tuple, Union
import numpy as np
@@ -69,5 +69,5 @@ class MockClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
)

View File

@@ -1,6 +1,6 @@
"""Mock device provider — virtual LED strip for testing."""
from datetime import datetime
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
@@ -28,7 +28,7 @@ class MockDeviceProvider(LEDDeviceProvider):
return MockClient(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())
return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc))
async def validate_device(self, url: str) -> dict:
return {}

View File

@@ -87,12 +87,12 @@ class MQTTLEDClient(LEDClient):
http_client,
prev_health=None,
) -> DeviceHealth:
from datetime import datetime
from datetime import datetime, timezone
svc = _mqtt_service
if svc is None or not svc.is_enabled:
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow())
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc))
return DeviceHealth(
online=svc.is_connected,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=None if svc.is_connected else "MQTT broker disconnected",
)

View File

@@ -4,7 +4,7 @@ import asyncio
import socket
import struct
import threading
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
import numpy as np
@@ -428,13 +428,13 @@ class OpenRGBLEDClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=latency,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=device_name,
device_led_count=device_led_count,
)
except Exception as e:
return DeviceHealth(
online=False,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
error=str(e),
)

View File

@@ -3,7 +3,7 @@
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse
@@ -540,7 +540,7 @@ class WLEDClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=round(latency, 1),
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=data.get("name"),
device_version=data.get("ver"),
device_led_count=leds_info.get("count"),
@@ -553,7 +553,7 @@ class WLEDClient(LEDClient):
return DeviceHealth(
online=False,
latency_ms=None,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
device_name=prev_health.device_name if prev_health else None,
device_version=prev_health.device_version if prev_health else None,
device_led_count=prev_health.device_led_count if prev_health else None,

View File

@@ -1,7 +1,7 @@
"""WebSocket LED client — broadcasts pixel data to connected WebSocket clients."""
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
@@ -126,5 +126,5 @@ class WSLEDClient(LEDClient):
return DeviceHealth(
online=True,
latency_ms=0.0,
last_checked=datetime.utcnow(),
last_checked=datetime.now(timezone.utc),
)

View File

@@ -1,6 +1,6 @@
"""WebSocket device provider — factory, validation, health checks."""
from datetime import datetime
from datetime import datetime, timezone
from typing import List
from wled_controller.core.devices.led_client import (
@@ -33,7 +33,7 @@ class WSDeviceProvider(LEDDeviceProvider):
self, url: str, http_client, prev_health=None,
) -> DeviceHealth:
return DeviceHealth(
online=True, latency_ms=0.0, last_checked=datetime.utcnow(),
online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc),
)
async def validate_device(self, url: str) -> dict: