From 071a4dded4596a46892696abf875565ab1312206 Mon Sep 17 00:00:00 2001 From: Alexander Shevchenko Date: Sun, 4 Oct 2026 16:45:18 +0200 Subject: [PATCH 1/2] fix(frontend): restore network topology rendering in Safari WebKit paints positioned HTML and opacity layers inside SVG foreignObject at the SVG origin. Device cards, labels and icons overlap there while their connections remain at the expected coordinates. Keep node content in normal flow, place collapse controls with a float and height-derived negative margin, and use alpha colors for muted icons. Scope all style changes to the network tree. Related: #1116 Discussion: https://github.com/netalertx/NetAlertX/discussions/1379 --- front/css/app.css | 18 ++++++++++++------ front/js/network-tree.js | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/front/css/app.css b/front/css/app.css index fdc2afbfc..b4fe82bc7 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -1924,6 +1924,8 @@ textarea[readonly], #networkTree .box { + /* Positioned HTML inside SVG foreignObject is misplaced by WebKit. */ + position:static; border-width:1px; border-top-color:grey; padding:0px; @@ -1946,7 +1948,7 @@ textarea[readonly], } #networkTree .netNodeText { - position: absolute; + position: static; } #networkTree .netPort { @@ -1957,7 +1959,8 @@ textarea[readonly], #networkTree .portBckgIcon { - opacity: 0.3; + color: var(--color-gray); + color: color-mix(in srgb, currentColor 30%, transparent); display: initial; float: left; width: 1em; @@ -2034,8 +2037,8 @@ textarea[readonly], #networkTree .netCollapse { - position: absolute; - right: 0; + position: static; + float: right; margin-right: -3px; } @@ -2058,9 +2061,12 @@ textarea[readonly], #networkTree .network-hw-icon { - position: absolute; + position: static; + display: inline-block; margin-left: -0.4em; - opacity: 0.3; + /* Alpha in the color avoids WebKit's foreignObject opacity layer bug. */ + color: var(--color-gray); + color: color-mix(in srgb, currentColor 30%, transparent); margin-top: 0.1em; } diff --git a/front/js/network-tree.js b/front/js/network-tree.js index 26ebcce59..7dc6fcc83 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -281,7 +281,7 @@ function initTree(myHierarchy) // generate +/- icon if node has children nodes collapseExpandHtml = nodeData.data.hasChildren ? `
From 1aceeae2183ffcfac84d7f54d371ae00abfb105e Mon Sep 17 00:00:00 2001 From: Alexander Shevchenko Date: Sun, 4 Oct 2026 16:45:29 +0200 Subject: [PATCH 2/2] test(frontend): cover network topology rendering and interactions Add a standalone fixture using the real topology renderer and styles. Check painted pixels in WebKit, Chromium and Firefox because DOM bounds can remain correct when WebKit paints foreignObject content incorrectly. Exercise node clicks, collapse/expand, wheel zoom and drag pan, including assertions that pointer gestures change the SVG transform. Document dependency installation and execution separately from pytest. The regression fails with the original frontend and passes with the fix. --- test/ui/TESTING_GUIDE.md | 20 +++++ test/ui/fixtures/network_topology.html | 41 +++++++++ test/ui/test_network_topology.cjs | 119 +++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 test/ui/fixtures/network_topology.html create mode 100644 test/ui/test_network_topology.cjs diff --git a/test/ui/TESTING_GUIDE.md b/test/ui/TESTING_GUIDE.md index 6afef9b1f..66632ab09 100644 --- a/test/ui/TESTING_GUIDE.md +++ b/test/ui/TESTING_GUIDE.md @@ -413,4 +413,24 @@ Tests that the settings form submits correctly to `php/server/util.php` with `fu Verifies that all settings are preserved when saved (no data loss during save operation). **Key Coverage**: Form submission flow → PHP `saveSettings()` → Config file generation with Python-compatible formatting +# Network topology rendering in WebKit, Chromium and Firefox + +The standalone regression uses the real Treeviz bundle, network renderer and styles +with four fixture devices. No running NetAlertX backend or database is required. +It checks painted pixels, node clicks, collapse/expand, wheel zoom and drag pan. +Pixel checks are necessary because WebKit may return correct DOM bounds while +painting positioned or translucent HTML at the SVG origin instead. + +Run from the repository root (Node.js required): + +```sh +npm install --prefix /tmp/netalertx-browser-tests playwright pngjs +/tmp/netalertx-browser-tests/node_modules/.bin/playwright install --with-deps webkit chromium firefox +NODE_PATH=/tmp/netalertx-browser-tests/node_modules node test/ui/test_network_topology.cjs +``` + +Screenshots are written to `/tmp/netalertx-topology`, or to the directory specified +by `NETALERTX_TOPOLOGY_ARTIFACTS`. This regression runs separately from pytest. +Also verify the fixture in native Safari when a Mac is available; Playwright +WebKit is useful regression coverage but is not the Safari application. diff --git a/test/ui/fixtures/network_topology.html b/test/ui/fixtures/network_topology.html new file mode 100644 index 000000000..fe7a4bf90 --- /dev/null +++ b/test/ui/fixtures/network_topology.html @@ -0,0 +1,41 @@ + + + +NetAlertX network topology regression fixture + + + + + + +

Network topology — discussion #1379

+
+ + + + + diff --git a/test/ui/test_network_topology.cjs b/test/ui/test_network_topology.cjs new file mode 100644 index 000000000..6ecc5053a --- /dev/null +++ b/test/ui/test_network_topology.cjs @@ -0,0 +1,119 @@ +// Standalone browser regression for SVG foreignObject rendering; see TESTING_GUIDE.md. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const {chromium, firefox, webkit} = require('playwright'); +const {PNG} = require('pngjs'); + +/** Verify actual painted pixels, since WebKit can report correct bounds for misplaced HTML. */ +async function assertPainted(page, selector) { + const items = await page.locator(selector).all(); + assert(items.length > 0, selector + ' is missing'); + for (const item of items) { + const rect = await item.boundingBox(); + assert(rect && rect.width > 0 && rect.height > 0, selector + ' has no bounds'); + const png = PNG.sync.read(await page.screenshot({clip: { + x: Math.ceil(rect.x + 1), y: Math.ceil(rect.y + 2), + width: Math.max(1, Math.floor(rect.width - 2)), height: Math.max(1, Math.floor(rect.height - 4)) + }})); + let pixels = 0; + for (let i = 0; i < png.data.length; i += 4) { + if (png.data[i] < 240 && png.data[i + 1] < 240 && png.data[i + 2] < 240) pixels++; + } + assert(pixels >= 3, selector + ' is not painted at its SVG coordinates'); + } +} + +/** Serve the fixture and repository assets on an ephemeral loopback port. */ +function createServer() { + const root = path.resolve(__dirname, '../..'); + return http.createServer((request, response) => { + const pathname = new URL(request.url, 'http://localhost').pathname; + const file = path.resolve(root, '.' + pathname); + if (!file.startsWith(root + path.sep)) { + response.writeHead(403).end(); + return; + } + fs.readFile(file, (error, data) => { + if (error) { + response.writeHead(404).end(); + return; + } + const types = {'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript'}; + response.writeHead(200, {'Content-Type': types[path.extname(file)] || 'application/octet-stream'}); + response.end(data); + }); + }); +} + +/** Check painting and real pointer interactions before and after tree updates and transforms. */ +async function checkBrowser(name, engine, url, artifacts) { + const browser = await engine.launch(); + try { + const page = await browser.newPage({viewport: {width: 1200, height: 800}}); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(url); + await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 4); + // Treeviz transitions take 600 ms; wait for both them and icon fonts to settle. + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(900); + await page.screenshot({path: path.join(artifacts, name + '-initial.png')}); + for (const selector of ['.spanNetworkTree', '.netIcon', '.portBckgIcon', '.network-hw-icon', '.netCollapse']) { + await assertPainted(page, '#networkTree ' + selector); + } + const laptop = page.locator('.node-inner[data-mac="aa:bb:cc:dd:ee:02"] .spanNetworkTree'); + await laptop.click(); + assert.deepEqual(await page.evaluate(() => clicks), ['aa:bb:cc:dd:ee:02']); + const collapse = page.locator('.netCollapse[data-mytreemac="aa:bb:cc:dd:ee:01"]'); + await collapse.click(); + await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 2); + await page.waitForTimeout(700); + await collapse.click(); + await page.waitForFunction(() => document.querySelectorAll('#networkTree g.node').length === 4); + await page.waitForTimeout(700); + await assertPainted(page, '#networkTree .spanNetworkTree'); + // Exercise Treeviz/D3's actual wheel zoom and drag pan handlers. + const canvas = page.locator('#networkTree svg > g'); + const beforeZoom = await canvas.getAttribute('transform'); + await page.mouse.move(600, 400); + await page.mouse.wheel(0, 300); + await page.waitForTimeout(500); + const afterZoom = await canvas.getAttribute('transform'); + assert.notEqual(afterZoom, beforeZoom, 'Wheel zoom did not change the SVG transform'); + await page.mouse.move(600, 400); + await page.mouse.down(); + await page.mouse.move(650, 440, {steps: 5}); + await page.mouse.up(); + assert.notEqual(await canvas.getAttribute('transform'), afterZoom, 'Drag pan did not change the SVG transform'); + await assertPainted(page, '#networkTree .spanNetworkTree'); + await laptop.click(); + assert.equal((await page.evaluate(() => clicks)).length, 2); + await page.screenshot({path: path.join(artifacts, name + '-zoom-pan.png')}); + // The bundled Treeviz demo expects #tree, which network.php also does not provide. + assert.deepEqual(errors.filter(error => error !== 'Cannot find dom element with id:tree'), []); + console.log(name + ': PASS — painting, clicks, collapse/expand, zoom and pan'); + } finally { + await browser.close(); + } +} + +/** Run the same rendering checks in WebKit, Chromium and Firefox without an app backend. */ +async function main() { + const artifacts = process.env.NETALERTX_TOPOLOGY_ARTIFACTS || path.join(os.tmpdir(), 'netalertx-topology'); + fs.mkdirSync(artifacts, {recursive: true}); + const server = createServer(); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/test/ui/fixtures/network_topology.html`; + try { + for (const [name, engine] of Object.entries({webkit, chromium, firefox})) { + await checkBrowser(name, engine, url, artifacts); + } + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; });