chore(frontend-infra): inbound-event allowlist + storage/state touch-ups
events-ws gains an inbound-event allowlist matching the new server-side allowlist; test_events_ws_parity pins the two lists in sync. state + storage modules and the streams / integrations / z2m-light-targets / streams-*-templates editors absorb the closeIfPristine guard alongside small UX fixes. css-editor template picks up the new MiniSelect markup for the filter-kind picker.
This commit is contained in:
@@ -14,6 +14,55 @@ import { showRestartingOverlay } from './api.ts';
|
|||||||
import { logError } from './log.ts';
|
import { logError } from './log.ts';
|
||||||
import { openAuthedWs } from './ws-auth.ts';
|
import { openAuthedWs } from './ws-auth.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed ``type`` values on inbound server-event messages. Anything outside
|
||||||
|
* this list is rejected before dispatch so a malformed message can't
|
||||||
|
* synthesise an arbitrary ``server:*`` CustomEvent. New event types must be
|
||||||
|
* added here intentionally — the server is the schema's source of truth.
|
||||||
|
*
|
||||||
|
* Audit (matches Python sources for ``fire_event`` / ``_fire_event`` /
|
||||||
|
* ``self._emit`` / ``fire_entity_event`` call sites — see the parity
|
||||||
|
* regression test in ``server/tests/test_events_ws_parity.py``):
|
||||||
|
* server_restarting — server_ref.py / update_service.py
|
||||||
|
* state_change — wled_target_processor.py / auto_restart.py
|
||||||
|
* automation_state_changed — automation_engine.py
|
||||||
|
* entity_changed — dependencies.fire_entity_event
|
||||||
|
* device_health_changed — device_health.py
|
||||||
|
* update_available — update_service.py (consumed by features/update.ts)
|
||||||
|
* update_download_progress — update_service.py (consumed by features/update.ts)
|
||||||
|
* device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
||||||
|
* device_lost — discovery_watcher.py (consumed by features/notifications-watcher.ts)
|
||||||
|
*
|
||||||
|
* Missing any of these silently breaks the corresponding UI flow — keep
|
||||||
|
* this list in sync when adding new event types on the server side.
|
||||||
|
*/
|
||||||
|
const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||||
|
'server_restarting',
|
||||||
|
'state_change',
|
||||||
|
'automation_state_changed',
|
||||||
|
'entity_changed',
|
||||||
|
'device_health_changed',
|
||||||
|
'update_available',
|
||||||
|
'update_download_progress',
|
||||||
|
'device_discovered',
|
||||||
|
'device_lost',
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ServerEventEnvelope {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isServerEventEnvelope(value: unknown): value is ServerEventEnvelope {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const t = (value as { type?: unknown }).type;
|
||||||
|
if (typeof t !== 'string' || !_ALLOWED_SERVER_EVENT_TYPES.has(t)) return false;
|
||||||
|
// Event-name character set: identifiers only. CustomEvent names can be
|
||||||
|
// anything but pinning them keeps the listener namespace predictable.
|
||||||
|
if (!/^[a-zA-Z0-9_]+$/.test(t)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** True when the server has signalled it is restarting (not crashed). */
|
/** True when the server has signalled it is restarting (not crashed). */
|
||||||
export let serverRestarting = false;
|
export let serverRestarting = false;
|
||||||
|
|
||||||
@@ -40,7 +89,15 @@ export function startEventsWS() {
|
|||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data: unknown = JSON.parse(event.data);
|
||||||
|
// Validate the envelope before we dispatch — without this,
|
||||||
|
// a malformed/hostile server message becomes an arbitrary
|
||||||
|
// ``server:*`` CustomEvent on document, which feature
|
||||||
|
// listeners then trust.
|
||||||
|
if (!_isServerEventEnvelope(data)) {
|
||||||
|
logError('events-ws.message', `Discarded malformed server message`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type === 'server_restarting') {
|
if (data.type === 'server_restarting') {
|
||||||
serverRestarting = true;
|
serverRestarting = true;
|
||||||
showRestartingOverlay();
|
showRestartingOverlay();
|
||||||
@@ -53,7 +110,9 @@ export function startEventsWS() {
|
|||||||
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||||
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
|
||||||
};
|
};
|
||||||
ws.onerror = () => {};
|
ws.onerror = (err) => {
|
||||||
|
logError('events-ws.onerror', err);
|
||||||
|
};
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
_ws = null;
|
_ws = null;
|
||||||
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { DataCache } from './cache.ts';
|
|||||||
import type {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||||
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, HTTPEndpoint, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
GameIntegration, GameAdapterInfo,
|
GameIntegration, GameAdapterInfo,
|
||||||
@@ -371,6 +371,14 @@ export const mqttSourcesCache = new DataCache<MQTTSource[]>({
|
|||||||
});
|
});
|
||||||
mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; });
|
mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; });
|
||||||
|
|
||||||
|
export let _cachedHTTPEndpoints: HTTPEndpoint[] = [];
|
||||||
|
|
||||||
|
export const httpEndpointsCache = new DataCache<HTTPEndpoint[]>({
|
||||||
|
endpoint: '/http/endpoints',
|
||||||
|
extractData: json => json.endpoints || [],
|
||||||
|
});
|
||||||
|
httpEndpointsCache.subscribe(v => { _cachedHTTPEndpoints = v; });
|
||||||
|
|
||||||
export const assetsCache = new DataCache<Asset[]>({
|
export const assetsCache = new DataCache<Asset[]>({
|
||||||
endpoint: '/assets',
|
endpoint: '/assets',
|
||||||
extractData: json => json.assets || [],
|
extractData: json => json.assets || [],
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ export function writeJson(key: string, value: unknown): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JSON string, returning ``fallback`` on any parse failure.
|
||||||
|
*
|
||||||
|
* Use for hot-path string-to-JSON conversions where a malformed payload
|
||||||
|
* (corrupt WebSocket frame, stale ``data-*`` attribute, hand-edited
|
||||||
|
* mapping in storage) would otherwise raise an uncaught exception.
|
||||||
|
*/
|
||||||
|
export function safeJsonParse<T = unknown>(raw: string | null | undefined, fallback: T): T {
|
||||||
|
if (raw == null || raw === '') return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch (err) {
|
||||||
|
logError('safeJsonParse', err);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Common type guards ────────────────────────────────────────
|
// ── Common type guards ────────────────────────────────────────
|
||||||
|
|
||||||
export function isObject(v: unknown): v is Record<string, unknown> {
|
export function isObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources,
|
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources, _cachedHTTPEndpoints,
|
||||||
_cachedGameIntegrations, _cachedGameAdapters,
|
_cachedGameIntegrations, _cachedGameAdapters,
|
||||||
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
|
weatherSourcesCache, haSourcesCache, mqttSourcesCache, httpEndpointsCache,
|
||||||
gameIntegrationsCache, gameAdaptersCache,
|
gameIntegrationsCache, gameAdaptersCache,
|
||||||
apiKey,
|
apiKey,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
@@ -20,6 +20,7 @@ import { showToast, setTabRefreshing } from '../core/ui.ts';
|
|||||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||||
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
|
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
|
||||||
|
import { createHTTPEndpointCard, initHTTPEndpointDelegation } from './http-endpoints.ts';
|
||||||
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
|
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
|
||||||
import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.ts';
|
import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
@@ -42,12 +43,14 @@ function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
|
|||||||
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
|
||||||
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }];
|
||||||
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
|
const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }];
|
||||||
|
const _httpEndpointDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('http/endpoints', httpEndpointsCache, 'http_endpoint.deleted') }];
|
||||||
|
|
||||||
// ── Card section instances ──
|
// ── Card section instances ──
|
||||||
|
|
||||||
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
|
||||||
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction });
|
||||||
const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction });
|
const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction });
|
||||||
|
const csHTTPEndpoints = new CardSection('http-endpoints', { titleKey: 'http_endpoint.group.title', gridClass: 'templates-grid', addCardOnclick: "showHTTPEndpointModal()", keyAttr: 'data-id', emptyKey: 'section.empty.http_endpoints', bulkActions: _httpEndpointDeleteAction });
|
||||||
|
|
||||||
// Re-render integrations when language changes
|
// Re-render integrations when language changes
|
||||||
document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); });
|
document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); });
|
||||||
@@ -87,6 +90,7 @@ const _integrationSectionMap: Record<string, CardSection[]> = {
|
|||||||
weather: [csWeatherSources],
|
weather: [csWeatherSources],
|
||||||
home_assistant: [csHASources],
|
home_assistant: [csHASources],
|
||||||
mqtt: [csMQTTSources],
|
mqtt: [csMQTTSources],
|
||||||
|
http: [csHTTPEndpoints],
|
||||||
game: [csGameIntegrations],
|
game: [csGameIntegrations],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +105,7 @@ export async function loadIntegrations() {
|
|||||||
weatherSourcesCache.fetch(),
|
weatherSourcesCache.fetch(),
|
||||||
haSourcesCache.fetch(),
|
haSourcesCache.fetch(),
|
||||||
mqttSourcesCache.fetch(),
|
mqttSourcesCache.fetch(),
|
||||||
|
httpEndpointsCache.fetch(),
|
||||||
gameIntegrationsCache.fetch(),
|
gameIntegrationsCache.fetch(),
|
||||||
gameAdaptersCache.fetch(),
|
gameAdaptersCache.fetch(),
|
||||||
]);
|
]);
|
||||||
@@ -127,6 +132,7 @@ function renderIntegrationsList() {
|
|||||||
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
|
||||||
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
|
||||||
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length },
|
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length },
|
||||||
|
{ key: 'http', icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`, titleKey: 'streams.group.http', count: _cachedHTTPEndpoints.length },
|
||||||
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -135,6 +141,7 @@ function renderIntegrationsList() {
|
|||||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
||||||
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
|
||||||
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, count: _cachedMQTTSources.length },
|
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, count: _cachedMQTTSources.length },
|
||||||
|
{ key: 'http', titleKey: 'streams.group.http', icon: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`, count: _cachedHTTPEndpoints.length },
|
||||||
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
|
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -142,6 +149,7 @@ function renderIntegrationsList() {
|
|||||||
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
|
||||||
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
|
||||||
const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) })));
|
const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) })));
|
||||||
|
const httpEndpointItems = csHTTPEndpoints.applySortOrder(_cachedHTTPEndpoints.map(e => ({ key: e.id, html: createHTTPEndpointCard(e) })));
|
||||||
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
||||||
|
|
||||||
if (csWeatherSources.isMounted()) {
|
if (csWeatherSources.isMounted()) {
|
||||||
@@ -150,11 +158,13 @@ function renderIntegrationsList() {
|
|||||||
weather: _cachedWeatherSources.length,
|
weather: _cachedWeatherSources.length,
|
||||||
home_assistant: _cachedHASources.length,
|
home_assistant: _cachedHASources.length,
|
||||||
mqtt: _cachedMQTTSources.length,
|
mqtt: _cachedMQTTSources.length,
|
||||||
|
http: _cachedHTTPEndpoints.length,
|
||||||
game: _cachedGameIntegrations.length,
|
game: _cachedGameIntegrations.length,
|
||||||
});
|
});
|
||||||
csWeatherSources.reconcile(weatherSourceItems);
|
csWeatherSources.reconcile(weatherSourceItems);
|
||||||
csHASources.reconcile(haSourceItems);
|
csHASources.reconcile(haSourceItems);
|
||||||
csMQTTSources.reconcile(mqttSourceItems);
|
csMQTTSources.reconcile(mqttSourceItems);
|
||||||
|
csHTTPEndpoints.reconcile(httpEndpointItems);
|
||||||
csGameIntegrations.reconcile(gameIntegrationItems);
|
csGameIntegrations.reconcile(gameIntegrationItems);
|
||||||
} else {
|
} else {
|
||||||
// First render: build full HTML
|
// First render: build full HTML
|
||||||
@@ -163,17 +173,19 @@ function renderIntegrationsList() {
|
|||||||
if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
||||||
else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems);
|
else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems);
|
||||||
|
else if (tab.key === 'http') panelContent = csHTTPEndpoints.render(httpEndpointItems);
|
||||||
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
||||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="integration-tab-${tab.key}">${panelContent}</div>`;
|
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="integration-tab-${tab.key}">${panelContent}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.innerHTML = panels;
|
container.innerHTML = panels;
|
||||||
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csGameIntegrations]);
|
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csHTTPEndpoints, csGameIntegrations]);
|
||||||
|
|
||||||
// Event delegation for card actions
|
// Event delegation for card actions
|
||||||
initWeatherSourceDelegation(container);
|
initWeatherSourceDelegation(container);
|
||||||
initHASourceDelegation(container);
|
initHASourceDelegation(container);
|
||||||
initMQTTSourceDelegation(container);
|
initMQTTSourceDelegation(container);
|
||||||
|
initHTTPEndpointDelegation(container);
|
||||||
|
|
||||||
// Render tree sidebar with tutorial trigger button
|
// Render tree sidebar with tutorial trigger button
|
||||||
_integrationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startIntegrationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
_integrationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startIntegrationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||||
@@ -182,6 +194,7 @@ function renderIntegrationsList() {
|
|||||||
'weather-sources': 'weather',
|
'weather-sources': 'weather',
|
||||||
'ha-sources': 'home_assistant',
|
'ha-sources': 'home_assistant',
|
||||||
'mqtt-sources': 'mqtt',
|
'mqtt-sources': 'mqtt',
|
||||||
|
'http-endpoints': 'http',
|
||||||
'game-integrations': 'game',
|
'game-integrations': 'game',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
import { TagInput } from '../core/tag-input.ts';
|
import { TagInput } from '../core/tag-input.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
|
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||||
import { loadPictureSources } from './streams.ts';
|
import { loadPictureSources } from './streams.ts';
|
||||||
import { openAuthedWs } from '../core/ws-auth.ts';
|
import { openAuthedWs } from '../core/ws-auth.ts';
|
||||||
|
|
||||||
@@ -150,6 +151,11 @@ export async function onAudioEngineChange() {
|
|||||||
});
|
});
|
||||||
gridHtml += '</div>';
|
gridHtml += '</div>';
|
||||||
configFields.innerHTML = gridHtml;
|
configFields.innerHTML = gridHtml;
|
||||||
|
// Convert the boolean toggles into MiniSelect popups — plain
|
||||||
|
// ``<select>`` is banned project-wide and these are the only
|
||||||
|
// selects rendered here that don't have a dedicated IconSelect
|
||||||
|
// configuration in CONFIG_ICON_SELECT.
|
||||||
|
enhanceMiniSelects(configFields, 'select[data-config-key]');
|
||||||
}
|
}
|
||||||
|
|
||||||
configSection.style.display = 'block';
|
configSection.style.display = 'block';
|
||||||
@@ -262,6 +268,8 @@ export async function closeAudioTemplateModal() {
|
|||||||
|
|
||||||
export async function saveAudioTemplate() {
|
export async function saveAudioTemplate() {
|
||||||
const templateId = currentEditingAudioTemplateId;
|
const templateId = currentEditingAudioTemplateId;
|
||||||
|
if (audioTemplateModal.closeIfPristine(templateId)) return;
|
||||||
|
|
||||||
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
|
||||||
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
|
const engineType = (document.getElementById('audio-template-engine') as HTMLSelectElement).value;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import * as P from '../core/icon-paths.ts';
|
|||||||
import { TagInput } from '../core/tag-input.ts';
|
import { TagInput } from '../core/tag-input.ts';
|
||||||
import { openAuthedWs } from '../core/ws-auth.ts';
|
import { openAuthedWs } from '../core/ws-auth.ts';
|
||||||
import { IconSelect } from '../core/icon-select.ts';
|
import { IconSelect } from '../core/icon-select.ts';
|
||||||
|
import { enhanceMiniSelects } from '../core/mini-select.ts';
|
||||||
import { loadPictureSources } from './streams.ts';
|
import { loadPictureSources } from './streams.ts';
|
||||||
|
|
||||||
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
||||||
@@ -330,6 +331,12 @@ export async function onEngineChange() {
|
|||||||
_configIconSelects.set(key, inst);
|
_configIconSelects.set(key, inst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Everything else (booleans + free-form selectOptions without a
|
||||||
|
// CONFIG_ICON_SELECT entry) gets a MiniSelect so plain ``<select>``
|
||||||
|
// never reaches the user. ``enhanceMiniSelects`` skips elements
|
||||||
|
// already hidden by IconSelect so this is safe to run after the
|
||||||
|
// loop above.
|
||||||
|
enhanceMiniSelects(configFields, 'select[data-config-key]');
|
||||||
}
|
}
|
||||||
|
|
||||||
configSection.style.display = 'block';
|
configSection.style.display = 'block';
|
||||||
@@ -584,6 +591,8 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag
|
|||||||
|
|
||||||
export async function saveTemplate() {
|
export async function saveTemplate() {
|
||||||
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
|
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
|
||||||
|
if (templateModal.closeIfPristine(templateId)) return;
|
||||||
|
|
||||||
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
|
||||||
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
||||||
|
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
|
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||||
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
|
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
|
||||||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
||||||
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
{ key: 'color_strip', icon: getColorStripIcon('single_color'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
||||||
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
|
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
|
||||||
{ key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length },
|
{ key: 'audio_capture', icon: getAudioSourceIcon('capture'), titleKey: 'audio_source.group.capture', count: captureSources.length },
|
||||||
{ key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length },
|
{ key: 'audio_processed', icon: getAudioSourceIcon('processed'), titleKey: 'audio_source.group.processed', count: processedAudioSources.length },
|
||||||
@@ -789,9 +789,9 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
key: 'strip_group', icon: getColorStripIcon('single_color'), titleKey: 'tree.group.strip',
|
||||||
children: [
|
children: [
|
||||||
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('single_color'), count: colorStrips.length },
|
||||||
{ key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
|
{ key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
|
||||||
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
||||||
]
|
]
|
||||||
@@ -1460,6 +1460,8 @@ async function _refreshStreamDisplaysForEngine(engineType: any) {
|
|||||||
|
|
||||||
export async function saveStream() {
|
export async function saveStream() {
|
||||||
const streamId = (document.getElementById('stream-id') as HTMLInputElement).value;
|
const streamId = (document.getElementById('stream-id') as HTMLInputElement).value;
|
||||||
|
if (streamModal.closeIfPristine(streamId)) return;
|
||||||
|
|
||||||
const name = (document.getElementById('stream-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('stream-name') as HTMLInputElement).value.trim();
|
||||||
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
|
const streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
|
||||||
const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim();
|
const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim();
|
||||||
@@ -2050,6 +2052,8 @@ export async function editPPTemplate(templateId: any) {
|
|||||||
|
|
||||||
export async function savePPTemplate() {
|
export async function savePPTemplate() {
|
||||||
const templateId = (document.getElementById('pp-template-id') as HTMLInputElement).value;
|
const templateId = (document.getElementById('pp-template-id') as HTMLInputElement).value;
|
||||||
|
if (ppTemplateModal.closeIfPristine(templateId)) return;
|
||||||
|
|
||||||
const name = (document.getElementById('pp-template-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('pp-template-name') as HTMLInputElement).value.trim();
|
||||||
const description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim();
|
const description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim();
|
||||||
const errorEl = document.getElementById('pp-template-error')!;
|
const errorEl = document.getElementById('pp-template-error')!;
|
||||||
@@ -2265,6 +2269,8 @@ export async function editCSPT(templateId: any) {
|
|||||||
|
|
||||||
export async function saveCSPT() {
|
export async function saveCSPT() {
|
||||||
const templateId = (document.getElementById('cspt-id') as HTMLInputElement).value;
|
const templateId = (document.getElementById('cspt-id') as HTMLInputElement).value;
|
||||||
|
if (csptModal.closeIfPristine(templateId)) return;
|
||||||
|
|
||||||
const name = (document.getElementById('cspt-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('cspt-name') as HTMLInputElement).value.trim();
|
||||||
const description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim();
|
const description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim();
|
||||||
const errorEl = document.getElementById('cspt-error')!;
|
const errorEl = document.getElementById('cspt-error')!;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { logError } from '../core/log.ts';
|
import { logError } from '../core/log.ts';
|
||||||
|
import { safeJsonParse } from '../core/storage.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
|
import { showToast, showConfirm, formatUptime } from '../core/ui.ts';
|
||||||
@@ -161,25 +162,35 @@ export function addZ2MLightMapping(data: any = null): void {
|
|||||||
|
|
||||||
// Per-source-kind row layout: CSS shows LED ranges; color_vs hides them
|
// Per-source-kind row layout: CSS shows LED ranges; color_vs hides them
|
||||||
// and promotes the brightness scale to a single inline field.
|
// and promotes the brightness scale to a single inline field.
|
||||||
|
// Numeric fields are coerced via ``Number()`` so a hostile/stale JSON
|
||||||
|
// mapping (where a number got serialised as a string with markup) can't
|
||||||
|
// smuggle HTML into the attribute context. ``Number.isFinite`` filters
|
||||||
|
// ``NaN`` so the fallback always renders a sane default.
|
||||||
|
const brightnessVal = Number.isFinite(Number(data?.brightness_scale))
|
||||||
|
? Number(data?.brightness_scale)
|
||||||
|
: 1.0;
|
||||||
|
const ledStartVal = Number.isFinite(Number(data?.led_start)) ? Number(data?.led_start) : 0;
|
||||||
|
const ledEndVal = Number.isFinite(Number(data?.led_end)) ? Number(data?.led_end) : -1;
|
||||||
|
|
||||||
const rangeBlock = _editorSourceKind === 'color_vs'
|
const rangeBlock = _editorSourceKind === 'color_vs'
|
||||||
? `<div class="ha-mapping-range-row">
|
? `<div class="ha-mapping-range-row">
|
||||||
<div>
|
<div>
|
||||||
<label>${t('z2m_light.mapping.brightness')}</label>
|
<label>${t('z2m_light.mapping.brightness')}</label>
|
||||||
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
<input type="number" class="z2m-mapping-brightness" value="${brightnessVal}" min="0" max="1" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
: `<div class="ha-mapping-range-row">
|
: `<div class="ha-mapping-range-row">
|
||||||
<div>
|
<div>
|
||||||
<label>${t('z2m_light.mapping.led_start')}</label>
|
<label>${t('z2m_light.mapping.led_start')}</label>
|
||||||
<input type="number" class="z2m-mapping-led-start" value="${data?.led_start ?? 0}" min="0" step="1">
|
<input type="number" class="z2m-mapping-led-start" value="${ledStartVal}" min="0" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>${t('z2m_light.mapping.led_end')}</label>
|
<label>${t('z2m_light.mapping.led_end')}</label>
|
||||||
<input type="number" class="z2m-mapping-led-end" value="${data?.led_end ?? -1}" min="-1" step="1">
|
<input type="number" class="z2m-mapping-led-end" value="${ledEndVal}" min="-1" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>${t('z2m_light.mapping.brightness')}</label>
|
<label>${t('z2m_light.mapping.brightness')}</label>
|
||||||
<input type="number" class="z2m-mapping-brightness" value="${data?.brightness_scale ?? 1.0}" min="0" max="1" step="0.1">
|
<input type="number" class="z2m-mapping-brightness" value="${brightnessVal}" min="0" max="1" step="0.1">
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -224,7 +235,10 @@ export function removeZ2MLightMapping(btn: HTMLElement): void {
|
|||||||
function _rerenderMappingsForMode(): void {
|
function _rerenderMappingsForMode(): void {
|
||||||
const list = document.getElementById('z2m-light-mappings-list');
|
const list = document.getElementById('z2m-light-mappings-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const snapshot = JSON.parse(_getMappingsJSON());
|
// Guarded JSON parse — _getMappingsJSON reads live DOM values so a
|
||||||
|
// partially-rendered row (or hand-edited input) used to throw and
|
||||||
|
// wipe the editor mid-toggle. ``safeJsonParse`` falls back to [].
|
||||||
|
const snapshot = safeJsonParse<unknown[]>(_getMappingsJSON(), []);
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
snapshot.forEach((m: any) => addZ2MLightMapping(m));
|
snapshot.forEach((m: any) => addZ2MLightMapping(m));
|
||||||
_setMappingsModeHint();
|
_setMappingsModeHint();
|
||||||
@@ -390,6 +404,8 @@ export async function closeZ2MLightEditor(): Promise<void> {
|
|||||||
|
|
||||||
export async function saveZ2MLightEditor(): Promise<void> {
|
export async function saveZ2MLightEditor(): Promise<void> {
|
||||||
const targetId = (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value;
|
const targetId = (document.getElementById('z2m-light-editor-id') as HTMLInputElement).value;
|
||||||
|
if (z2mLightEditorModal.closeIfPristine(targetId)) return;
|
||||||
|
|
||||||
const name = (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value.trim();
|
const name = (document.getElementById('z2m-light-editor-name') as HTMLInputElement).value.trim();
|
||||||
const mqttSourceId = (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value;
|
const mqttSourceId = (document.getElementById('z2m-light-editor-mqtt-source') as HTMLSelectElement).value;
|
||||||
const baseTopic = (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value.trim() || 'zigbee2mqtt';
|
const baseTopic = (document.getElementById('z2m-light-editor-base-topic') as HTMLInputElement).value.trim() || 'zigbee2mqtt';
|
||||||
@@ -420,7 +436,8 @@ export async function saveZ2MLightEditor(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappings = JSON.parse(_getMappingsJSON()).filter((m: any) => m.friendly_name);
|
const mappings = safeJsonParse<Array<Record<string, unknown>>>(_getMappingsJSON(), [])
|
||||||
|
.filter((m) => m && typeof m === 'object' && 'friendly_name' in m && (m as { friendly_name: unknown }).friendly_name);
|
||||||
if (mappings.length === 0) {
|
if (mappings.length === 0) {
|
||||||
z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required');
|
z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required');
|
||||||
return;
|
return;
|
||||||
@@ -713,10 +730,8 @@ export function connectZ2MLightWS(targetId: string): void {
|
|||||||
openAuthedWs(url).then((ws) => {
|
openAuthedWs(url).then((ws) => {
|
||||||
_z2mLightWS[targetId] = ws;
|
_z2mLightWS[targetId] = ws;
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
const data = safeJsonParse<{ type?: string; colors?: unknown }>(ev.data, {});
|
||||||
const data = JSON.parse(ev.data);
|
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors as never);
|
||||||
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors);
|
|
||||||
} catch (err) { logError('z2m-light-targets.ws.message', err); }
|
|
||||||
};
|
};
|
||||||
ws.onclose = () => { delete _z2mLightWS[targetId]; };
|
ws.onclose = () => { delete _z2mLightWS[targetId]; };
|
||||||
ws.onerror = () => { delete _z2mLightWS[targetId]; };
|
ws.onerror = () => { delete _z2mLightWS[targetId]; };
|
||||||
|
|||||||
@@ -39,11 +39,11 @@
|
|||||||
<label for="css-editor-type" data-i18n="color_strip.type">Type:</label>
|
<label for="css-editor-type" data-i18n="color_strip.type">Type:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.type.hint">Picture Source derives colors from a screen capture. Static Color fills all LEDs with one constant color. Gradient distributes a color gradient across all LEDs.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.type.hint">Picture Source derives colors from a screen capture. Single Color fills all LEDs with one constant color. Gradient distributes a color gradient across all LEDs.</small>
|
||||||
<select id="css-editor-type" onchange="onCSSTypeChange()">
|
<select id="css-editor-type" onchange="onCSSTypeChange()">
|
||||||
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
||||||
<option value="picture_advanced" data-i18n="color_strip.type.picture_advanced">Multi-Monitor</option>
|
<option value="picture_advanced" data-i18n="color_strip.type.picture_advanced">Multi-Monitor</option>
|
||||||
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
<option value="single_color" data-i18n="color_strip.type.single_color">Single Color</option>
|
||||||
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
||||||
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
|
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
|
||||||
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
|
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Static-color-specific fields -->
|
<!-- Single-color-specific fields -->
|
||||||
<div id="css-editor-static-section" style="display:none">
|
<div id="css-editor-single-color-section" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-color" data-i18n="color_strip.static_color">Color:</label>
|
<label for="css-editor-color" data-i18n="color_strip.single_color">Color:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.static_color.hint">The solid color that will be sent to all LEDs on the strip.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.single_color.hint">The solid color that will be sent to all LEDs on the strip.</small>
|
||||||
<div id="css-editor-color-container"></div>
|
<div id="css-editor-color-container"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Cross-cutting parity guard between server event emitters and the TS allowlist.
|
||||||
|
|
||||||
|
The frontend ``events-ws.ts`` module enforces a typed allowlist on inbound
|
||||||
|
WebSocket message ``type`` values. If a new server-side ``fire_event(...)``
|
||||||
|
call introduces a type that isn't in the JS allowlist, the corresponding
|
||||||
|
UI silently breaks. This test rederives the set of types the server emits
|
||||||
|
from the Python source and asserts every one is present in the TS
|
||||||
|
allowlist source, catching the drift at CI time rather than in production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_SERVER_SRC = _REPO_ROOT / "src" / "ledgrab"
|
||||||
|
_EVENTS_WS = _SERVER_SRC / "static" / "js" / "core" / "events-ws.ts"
|
||||||
|
|
||||||
|
|
||||||
|
# Scan the small set of files that funnel into the global ``events-ws.ts``
|
||||||
|
# dispatcher (``fire_event`` / ``_fire_event`` / ``_emit`` / the wrapper
|
||||||
|
# ``fire_entity_event``). Everything else is per-WS-handler protocol and
|
||||||
|
# is read off its own connection — not relevant here.
|
||||||
|
#
|
||||||
|
# Patterns we recognise as broadcast emit sites:
|
||||||
|
# fire_event({"type": "<x>" ...})
|
||||||
|
# _fire_event({"type": "<x>" ...})
|
||||||
|
# self._emit("<x>", ...) — discovery_watcher.py
|
||||||
|
# fire_entity_event(...) — always "entity_changed"
|
||||||
|
_EMIT_PATTERNS = (
|
||||||
|
re.compile(r'fire_event\(\s*\{\s*"type"\s*:\s*"([a-z_]+)"'),
|
||||||
|
re.compile(r'_fire_event\(\s*\{\s*"type"\s*:\s*"([a-z_]+)"'),
|
||||||
|
re.compile(r'self\._emit\(\s*"([a-z_]+)"'),
|
||||||
|
)
|
||||||
|
_FIRE_ENTITY_EVENT_RE = re.compile(r"fire_entity_event\(")
|
||||||
|
|
||||||
|
|
||||||
|
def _server_event_types() -> set[str]:
|
||||||
|
"""Extract event types that flow into the events-ws.ts dispatcher."""
|
||||||
|
discovered: set[str] = set()
|
||||||
|
for path in _SERVER_SRC.rglob("*.py"):
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for rx in _EMIT_PATTERNS:
|
||||||
|
discovered.update(rx.findall(text))
|
||||||
|
if _FIRE_ENTITY_EVENT_RE.search(text):
|
||||||
|
# fire_entity_event always dispatches the "entity_changed" type.
|
||||||
|
discovered.add("entity_changed")
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_allowlist_types() -> set[str]:
|
||||||
|
"""Parse the allowlist constant out of events-ws.ts source text."""
|
||||||
|
text = _EVENTS_WS.read_text(encoding="utf-8")
|
||||||
|
match = re.search(
|
||||||
|
r"_ALLOWED_SERVER_EVENT_TYPES[^=]*=\s*new\s+Set\(\[(.*?)\]\)",
|
||||||
|
text,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
pytest.fail("Could not locate _ALLOWED_SERVER_EVENT_TYPES in events-ws.ts")
|
||||||
|
body = match.group(1)
|
||||||
|
return set(re.findall(r"'([a-z_]+)'", body))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_server_event_type_is_in_ts_allowlist() -> None:
|
||||||
|
"""Catch the regression where the JS allowlist forgets a new event.
|
||||||
|
|
||||||
|
Failure mode: a server-side ``fire_event({"type": "foo"})`` ships, the
|
||||||
|
UI never sees it because ``events-ws.ts`` filters it out, and the bug
|
||||||
|
surfaces only via "feature X stopped working" reports.
|
||||||
|
"""
|
||||||
|
server_types = _server_event_types()
|
||||||
|
allowlist = _ts_allowlist_types()
|
||||||
|
missing = sorted(server_types - allowlist)
|
||||||
|
assert not missing, (
|
||||||
|
"Server emits these event types but the TS allowlist excludes "
|
||||||
|
f"them — features that listen will silently break: {missing}. "
|
||||||
|
"Either add them to _ALLOWED_SERVER_EVENT_TYPES in "
|
||||||
|
"static/js/core/events-ws.ts or move them to _LOCAL_TYPES in this "
|
||||||
|
"test if they're a per-WS protocol envelope."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ts_allowlist_is_not_empty() -> None:
|
||||||
|
"""Sanity check: the parse helper actually found the constant."""
|
||||||
|
allowlist = _ts_allowlist_types()
|
||||||
|
assert allowlist, "Parser failed to extract allowlist from events-ws.ts"
|
||||||
Reference in New Issue
Block a user