refactor: comprehensive code quality, security, and release readiness improvements
Lint & Test / test (push) Failing after 48s
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:
@@ -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"]');
|
||||
|
||||
Reference in New Issue
Block a user