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

@@ -2,7 +2,7 @@
* Dashboard — real-time target status overview.
*/
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
@@ -418,27 +418,23 @@ export async function loadDashboard(forceFullRender = false) {
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
fetchWithAuth('/output-targets'),
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
fetchWithAuth('/automations').catch(() => null),
fetchWithAuth('/devices').catch(() => null),
fetchWithAuth('/color-strip-sources').catch(() => null),
devicesCache.fetch().catch(() => []),
colorStripSourcesCache.fetch().catch(() => []),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
loadScenePresets(),
fetchWithAuth('/sync-clocks').catch(() => null),
]);
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
const automations = automationsData.automations || [];
const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] };
const devicesMap = {};
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] };
for (const d of devicesArr) { devicesMap[d.id] = d; }
const cssSourceMap = {};
for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; }
for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; }
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
const syncClocks = syncClocksData.clocks || [];
@@ -782,14 +778,13 @@ export async function dashboardStopTarget(targetId) {
export async function dashboardStopAll() {
try {
const [targetsResp, statesResp] = await Promise.all([
fetchWithAuth('/output-targets'),
const [allTargets, statesResp] = await Promise.all([
outputTargetsCache.fetch().catch(() => []),
fetchWithAuth('/output-targets/batch/states'),
]);
const data = await targetsResp.json();
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
const states = statesData.states || {};
const running = (data.targets || []).filter(t => states[t.id]?.processing);
const running = allTargets.filter(t => states[t.id]?.processing);
await Promise.all(running.map(t =>
fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
));