refactor: comprehensive code quality, security, and release readiness improvements
Lint & Test / test (push) Failing after 48s

Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
This commit is contained in:
2026-03-22 00:38:28 +03:00
parent 07bb89e9b7
commit f2871319cb
115 changed files with 9808 additions and 5818 deletions
@@ -269,7 +269,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
_clampElementInContainer(el, container);
}
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -279,7 +279,7 @@ function _makeDraggable(el: HTMLElement, handle: HTMLElement, { loadFn, saveFn }
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = el.offsetWidth, eh = el.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -470,10 +470,10 @@ function _applyFilter(query?: string): void {
// Parse structured filters: type:device, tag:foo, running:true
let textPart = q;
const parsedKinds = new Set();
const parsedTags = [];
const parsedKinds = new Set<string>();
const parsedTags: string[] = [];
const tokens = q.split(/\s+/);
const plainTokens = [];
const plainTokens: string[] = [];
for (const tok of tokens) {
if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); }
else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); }
@@ -720,8 +720,8 @@ function _renderGraph(container: HTMLElement): void {
const nodeGroup = svgEl.querySelector('.graph-nodes') as SVGGElement;
const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement;
renderEdges(edgeGroup, _edges);
renderNodes(nodeGroup, _nodeMap, {
renderEdges(edgeGroup, _edges!);
renderNodes(nodeGroup, _nodeMap!, {
onNodeClick: _onNodeClick,
onNodeDblClick: _onNodeDblClick,
onEditNode: _onEditNode,
@@ -732,14 +732,14 @@ function _renderGraph(container: HTMLElement): void {
onCloneNode: _onCloneNode,
onActivatePreset: _onActivatePreset,
});
markOrphans(nodeGroup, _nodeMap, _edges);
markOrphans(nodeGroup, _nodeMap!, _edges!);
// Animated flow dots for running nodes
const runningIds = new Set<string>();
for (const node of _nodeMap.values()) {
for (const node of _nodeMap!.values()) {
if (node.running) runningIds.add(node.id);
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
// Set bounds for view clamping, then fit
if (_bounds) _canvas.setBounds(_bounds);
@@ -889,6 +889,8 @@ function _renderGraph(container: HTMLElement): void {
}
});
// Remove previous keydown listener to prevent leaks on re-render
container.removeEventListener('keydown', _onKeydown);
container.addEventListener('keydown', _onKeydown);
container.setAttribute('tabindex', '0');
container.style.outline = 'none';
@@ -1039,8 +1041,9 @@ function _initLegendDrag(legendEl: Element | null): void {
/* ── Minimap (draggable header & resize handle) ── */
function _initMinimap(mmEl: HTMLElement | null): void {
if (!mmEl || !_nodeMap || !_bounds) return;
function _initMinimap(mmElArg: HTMLElement | null): void {
if (!mmElArg || !_nodeMap || !_bounds) return;
const mmEl: HTMLElement = mmElArg;
const svg = mmEl.querySelector('svg') as SVGSVGElement | null;
if (!svg) return;
const container = mmEl.closest('.graph-container') as HTMLElement;
@@ -1108,7 +1111,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
function _initResizeHandle(rh: HTMLElement | null, corner: string): void {
if (!rh) return;
let rs = null, rss = null;
let rs: { x: number; y: number } | null = null, rss: { w: number; h: number; left: number } | null = null;
rh.addEventListener('pointerdown', (e) => {
e.preventDefault(); e.stopPropagation();
rs = { x: e.clientX, y: e.clientY };
@@ -1116,7 +1119,7 @@ function _initMinimap(mmEl: HTMLElement | null): void {
rh.setPointerCapture(e.pointerId);
});
rh.addEventListener('pointermove', (e) => {
if (!rs) return;
if (!rs || !rss) return;
const cr = container.getBoundingClientRect();
const dy = e.clientY - rs.y;
const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy));
@@ -1274,7 +1277,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
const dock = saved?.dock || 'tl';
_applyToolbarDock(tbEl, container, dock, false);
let dragStart = null, dragStartPos = null;
let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
@@ -1289,7 +1292,7 @@ function _initToolbarDrag(tbEl: HTMLElement | null): void {
});
});
handle.addEventListener('pointermove', (e) => {
if (!dragStart) return;
if (!dragStart || !dragStartPos) return;
const cr = container.getBoundingClientRect();
const ew = tbEl.offsetWidth, eh = tbEl.offsetHeight;
let l = dragStartPos.left + (e.clientX - dragStart.x);
@@ -1410,7 +1413,7 @@ async function _bulkDeleteSelected(): Promise<void> {
);
if (!ok) return;
for (const id of _selectedIds) {
const node = _nodeMap.get(id);
const node = _nodeMap?.get(id);
if (node) _onDeleteNode(node);
}
_selectedIds.clear();
@@ -1506,10 +1509,12 @@ function _updateNodeRunning(nodeId: string, running: boolean): void {
// Update flow dots since running set changed
if (edgeGroup) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
if (_nodeMap) {
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
}
}
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1559,7 +1564,7 @@ function _onKeydown(e: KeyboardEvent): void {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
const node = _nodeMap?.get(nodeId);
if (node) _onDeleteNode(node);
} else if (_selectedIds.size > 1) {
_bulkDeleteSelected();
@@ -1614,13 +1619,13 @@ function _navigateDirection(dir: string): void {
if (!_nodeMap || _nodeMap.size === 0) return;
// Get current anchor node
let anchor = null;
let anchor: any = null;
if (_selectedIds.size === 1) {
anchor = _nodeMap.get([..._selectedIds][0]);
}
if (!anchor) {
// Select first visible node (topmost-leftmost)
let best = null;
let best: any = null;
for (const n of _nodeMap.values()) {
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
}
@@ -1638,7 +1643,7 @@ function _navigateDirection(dir: string): void {
const cx = anchor.x + anchor.width / 2;
const cy = anchor.y + anchor.height / 2;
let bestNode = null;
let bestNode: any = null;
let bestDist = Infinity;
for (const n of _nodeMap.values()) {
@@ -1702,8 +1707,8 @@ function _selectAll(): void {
/* ── Edge click ── */
function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void {
const fromId = edgePath.getAttribute('data-from');
const toId = edgePath.getAttribute('data-to');
const fromId = edgePath.getAttribute('data-from') ?? '';
const toId = edgePath.getAttribute('data-to') ?? '';
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
@@ -1819,10 +1824,10 @@ function _onDragPointerMove(e: PointerEvent): void {
node.x = item.startX + gdx;
node.y = item.startY + gdy;
if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges);
if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap!, _edges!);
_updateMinimapNode(item.id, node);
}
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges);
if (edgeGroup) updateFlowDotsForNode(edgeGroup, null as any, _nodeMap!, _edges!);
} else {
const ds = _dragState as DragStateSingle;
const node = _nodeMap!.get(ds.nodeId);
@@ -1831,8 +1836,8 @@ function _onDragPointerMove(e: PointerEvent): void {
node.y = ds.startNode.y + gdy;
ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
if (edgeGroup) {
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap, _edges);
updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!);
}
_updateMinimapNode(ds.nodeId, node);
}
@@ -1867,7 +1872,7 @@ function _onDragPointerUp(): void {
if (edgeGroup && _edges && _nodeMap) {
const runningIds = new Set<string>();
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
renderFlowDots(edgeGroup, _edges, runningIds);
renderFlowDots(edgeGroup, _edges!, runningIds);
}
}
@@ -1886,7 +1891,7 @@ function _initRubberBand(svgEl: SVGSVGElement): void {
e.preventDefault();
_rubberBand = {
startGraph: _canvas.screenToGraph(e.clientX, e.clientY),
startGraph: _canvas!.screenToGraph(e.clientX, e.clientY),
startClient: { x: e.clientX, y: e.clientY },
active: false,
};
@@ -1930,10 +1935,10 @@ function _onRubberBandUp(): void {
const rect = document.querySelector('.graph-selection-rect') as SVGElement | null;
if (_rubberBand.active && rect && _nodeMap) {
const rx = parseFloat(rect.getAttribute('x'));
const ry = parseFloat(rect.getAttribute('y'));
const rw = parseFloat(rect.getAttribute('width'));
const rh = parseFloat(rect.getAttribute('height'));
const rx = parseFloat(rect.getAttribute('x') ?? '0');
const ry = parseFloat(rect.getAttribute('y') ?? '0');
const rw = parseFloat(rect.getAttribute('width') ?? '0');
const rh = parseFloat(rect.getAttribute('height') ?? '0');
_selectedIds.clear();
for (const node of _nodeMap.values()) {
@@ -2014,9 +2019,9 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNodeId = port.getAttribute('data-node-id') ?? '';
const sourceKind = port.getAttribute('data-node-kind') ?? '';
const portType = port.getAttribute('data-port-type') ?? '';
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
@@ -2029,7 +2034,7 @@ function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup:
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
const root = svgEl.querySelector('.graph-root')!;
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
@@ -2108,9 +2113,9 @@ function _onConnectPointerUp(e: PointerEvent): void {
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
const targetNodeId = targetPort.getAttribute('data-node-id') ?? '';
const targetKind = targetPort.getAttribute('data-node-kind') ?? '';
const targetPortType = targetPort.getAttribute('data-port-type') ?? '';
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
@@ -2143,8 +2148,8 @@ async function _doConnect(targetId: string, targetKind: string, field: string, s
/* ── Undo / Redo ── */
const _undoStack = [];
const _redoStack = [];
const _undoStack: UndoAction[] = [];
const _redoStack: UndoAction[] = [];
const _MAX_UNDO = 30;
/** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */
@@ -2167,7 +2172,7 @@ export async function graphRedo(): Promise<void> { await _redo(); }
async function _undo(): Promise<void> {
if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; }
const action = _undoStack.pop();
const action = _undoStack.pop()!;
try {
await action.undo();
_redoStack.push(action);
@@ -2182,7 +2187,7 @@ async function _undo(): Promise<void> {
async function _redo(): Promise<void> {
if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; }
const action = _redoStack.pop();
const action = _redoStack.pop()!;
try {
await action.redo();
_undoStack.push(action);
@@ -2201,7 +2206,7 @@ let _helpVisible = false;
function _loadHelpPos(): AnchoredRect | null {
try {
const saved = JSON.parse(localStorage.getItem('graph_help_pos'));
const saved = JSON.parse(localStorage.getItem('graph_help_pos')!);
return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 };
} catch { return { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; }
}
@@ -2265,7 +2270,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toId = edgePath.getAttribute('data-to') ?? '';
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
@@ -2289,7 +2294,7 @@ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLEle
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
container.querySelector('.graph-container')!.appendChild(menu);
_edgeContextMenu = menu;
}
@@ -2318,11 +2323,11 @@ async function _detachSelectedEdge(): Promise<void> {
let _hoverTooltip: HTMLDivElement | null = null; // the <div> element, created once per graph render
let _hoverTooltipChart: any = null; // Chart.js instance
let _hoverTimer: ReturnType<typeof setTimeout> | null = null; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | null = null; // 1s polling interval
let _hoverTimer: ReturnType<typeof setTimeout> | undefined = undefined; // 300ms delay timer
let _hoverPollInterval: ReturnType<typeof setInterval> | undefined = undefined; // 1s polling interval
let _hoverNodeId: string | null = null; // currently shown node id
let _hoverFpsHistory = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
let _hoverFpsHistory: number[] = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory: number[] = []; // rolling fps_current samples
const HOVER_DELAY_MS = 300;
const HOVER_HISTORY_LEN = 20;
@@ -2374,7 +2379,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
if (related && nodeEl.contains(related)) return;
clearTimeout(_hoverTimer);
_hoverTimer = null;
_hoverTimer = undefined;
const nodeId = nodeEl.getAttribute('data-id');
if (nodeId === _hoverNodeId) {
@@ -2384,7 +2389,7 @@ function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement):
}
function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void {
if (!_canvas || !_hoverTooltip) return;
if (!_canvas || !_hoverTooltip || !_hoverNodeId) return;
const node = _nodeMap?.get(_hoverNodeId);
if (!node) return;
@@ -2467,7 +2472,7 @@ async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTML
function _hideNodeTooltip(): void {
clearInterval(_hoverPollInterval);
_hoverPollInterval = null;
_hoverPollInterval = undefined;
_hoverNodeId = null;
if (_hoverTooltipChart) {
@@ -2478,7 +2483,7 @@ function _hideNodeTooltip(): void {
_hoverTooltip.classList.remove('gnt-fade-in');
_hoverTooltip.classList.add('gnt-fade-out');
_hoverTooltip.addEventListener('animationend', () => {
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
if (_hoverTooltip?.classList.contains('gnt-fade-out')) {
_hoverTooltip.style.display = 'none';
}
}, { once: true });
@@ -2506,6 +2511,7 @@ async function _fetchTooltipMetrics(nodeId: string, container: HTMLElement, node
const uptimeSec = metrics.uptime_seconds ?? 0;
// Update text rows
if (!_hoverTooltip) return;
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');