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:
2026-05-23 00:50:15 +03:00
parent 898912f8b1
commit ddae5719cf
10 changed files with 254 additions and 25 deletions
+61 -2
View File
@@ -14,6 +14,55 @@ import { showRestartingOverlay } from './api.ts';
import { logError } from './log.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). */
export let serverRestarting = false;
@@ -40,7 +89,15 @@ export function startEventsWS() {
ws.onmessage = (event) => {
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') {
serverRestarting = true;
showRestartingOverlay();
@@ -53,7 +110,9 @@ export function startEventsWS() {
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
};
ws.onerror = () => {};
ws.onerror = (err) => {
logError('events-ws.onerror', err);
};
}).catch(() => {
_ws = null;
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
+9 -1
View File
@@ -16,7 +16,7 @@ import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
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,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
GameIntegration, GameAdapterInfo,
@@ -371,6 +371,14 @@ export const mqttSourcesCache = new DataCache<MQTTSource[]>({
});
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[]>({
endpoint: '/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 ────────────────────────────────────────
export function isObject(v: unknown): v is Record<string, unknown> {
@@ -4,9 +4,9 @@
*/
import {
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources,
_cachedWeatherSources, _cachedHASources, _cachedMQTTSources, _cachedHTTPEndpoints,
_cachedGameIntegrations, _cachedGameAdapters,
weatherSourcesCache, haSourcesCache, mqttSourcesCache,
weatherSourcesCache, haSourcesCache, mqttSourcesCache, httpEndpointsCache,
gameIntegrationsCache, gameAdaptersCache,
apiKey,
} from '../core/state.ts';
@@ -20,6 +20,7 @@ import { showToast, setTabRefreshing } from '../core/ui.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
import { createHTTPEndpointCard, initHTTPEndpointDelegation } from './http-endpoints.ts';
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
import { ICON_GAMEPAD, ICON_TRASH, ICON_HELP } from '../core/icons.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 _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 _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 ──
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 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
document.addEventListener('languageChanged', () => { if (apiKey) loadIntegrations(); });
@@ -87,6 +90,7 @@ const _integrationSectionMap: Record<string, CardSection[]> = {
weather: [csWeatherSources],
home_assistant: [csHASources],
mqtt: [csMQTTSources],
http: [csHTTPEndpoints],
game: [csGameIntegrations],
};
@@ -101,6 +105,7 @@ export async function loadIntegrations() {
weatherSourcesCache.fetch(),
haSourcesCache.fetch(),
mqttSourcesCache.fetch(),
httpEndpointsCache.fetch(),
gameIntegrationsCache.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: '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: '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 },
];
@@ -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: '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: '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 },
];
@@ -142,6 +149,7 @@ function renderIntegrationsList() {
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 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) })));
if (csWeatherSources.isMounted()) {
@@ -150,11 +158,13 @@ function renderIntegrationsList() {
weather: _cachedWeatherSources.length,
home_assistant: _cachedHASources.length,
mqtt: _cachedMQTTSources.length,
http: _cachedHTTPEndpoints.length,
game: _cachedGameIntegrations.length,
});
csWeatherSources.reconcile(weatherSourceItems);
csHASources.reconcile(haSourceItems);
csMQTTSources.reconcile(mqttSourceItems);
csHTTPEndpoints.reconcile(httpEndpointItems);
csGameIntegrations.reconcile(gameIntegrationItems);
} else {
// First render: build full HTML
@@ -163,17 +173,19 @@ function renderIntegrationsList() {
if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
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);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="integration-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csGameIntegrations]);
CardSection.bindAll([csWeatherSources, csHASources, csMQTTSources, csHTTPEndpoints, csGameIntegrations]);
// Event delegation for card actions
initWeatherSourceDelegation(container);
initHASourceDelegation(container);
initMQTTSourceDelegation(container);
initHTTPEndpointDelegation(container);
// 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>`);
@@ -182,6 +194,7 @@ function renderIntegrationsList() {
'weather-sources': 'weather',
'ha-sources': 'home_assistant',
'mqtt-sources': 'mqtt',
'http-endpoints': 'http',
'game-integrations': 'game',
});
}
@@ -23,6 +23,7 @@ import {
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { loadPictureSources } from './streams.ts';
import { openAuthedWs } from '../core/ws-auth.ts';
@@ -150,6 +151,11 @@ export async function onAudioEngineChange() {
});
gridHtml += '</div>';
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';
@@ -262,6 +268,8 @@ export async function closeAudioTemplateModal() {
export async function saveAudioTemplate() {
const templateId = currentEditingAudioTemplateId;
if (audioTemplateModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('audio-template-name') as HTMLInputElement).value.trim();
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 { openAuthedWs } from '../core/ws-auth.ts';
import { IconSelect } from '../core/icon-select.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { loadPictureSources } from './streams.ts';
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);
}
}
// 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';
@@ -584,6 +591,8 @@ export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessag
export async function saveTemplate() {
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 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: '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: '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: '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 },
@@ -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: [
{ 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: '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() {
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 streamType = (document.getElementById('stream-type') as HTMLSelectElement).value;
const description = (document.getElementById('stream-description') as HTMLInputElement).value.trim();
@@ -2050,6 +2052,8 @@ export async function editPPTemplate(templateId: any) {
export async function savePPTemplate() {
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 description = (document.getElementById('pp-template-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('pp-template-error')!;
@@ -2265,6 +2269,8 @@ export async function editCSPT(templateId: any) {
export async function saveCSPT() {
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 description = (document.getElementById('cspt-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('cspt-error')!;
@@ -18,6 +18,7 @@ import {
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { logError } from '../core/log.ts';
import { safeJsonParse } from '../core/storage.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.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
// 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'
? `<div class="ha-mapping-range-row">
<div>
<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 class="ha-mapping-range-row">
<div>
<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>
<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>
<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>`;
@@ -224,7 +235,10 @@ export function removeZ2MLightMapping(btn: HTMLElement): void {
function _rerenderMappingsForMode(): void {
const list = document.getElementById('z2m-light-mappings-list');
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 = '';
snapshot.forEach((m: any) => addZ2MLightMapping(m));
_setMappingsModeHint();
@@ -390,6 +404,8 @@ export async function closeZ2MLightEditor(): Promise<void> {
export async function saveZ2MLightEditor(): Promise<void> {
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 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';
@@ -420,7 +436,8 @@ export async function saveZ2MLightEditor(): Promise<void> {
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) {
z2mLightEditorModal.showError(t('z2m_light.error.mapping_required') || 'At least one bulb mapping is required');
return;
@@ -713,10 +730,8 @@ export function connectZ2MLightWS(targetId: string): void {
openAuthedWs(url).then((ws) => {
_z2mLightWS[targetId] = ws;
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors);
} catch (err) { logError('z2m-light-targets.ws.message', err); }
const data = safeJsonParse<{ type?: string; colors?: unknown }>(ev.data, {});
if (data.type === 'colors_update') _updateSwatchColors(targetId, data.colors as never);
};
ws.onclose = () => { delete _z2mLightWS[targetId]; };
ws.onerror = () => { delete _z2mLightWS[targetId]; };
@@ -39,11 +39,11 @@
<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>
</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()">
<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="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="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
@@ -98,14 +98,14 @@
</div>
<!-- Static-color-specific fields -->
<div id="css-editor-static-section" style="display:none">
<!-- Single-color-specific fields -->
<div id="css-editor-single-color-section" style="display:none">
<div class="form-group">
<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>
</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>
</div>
+94
View File
@@ -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"