Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions front/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -1946,7 +1948,7 @@ textarea[readonly],
}
#networkTree .netNodeText
{
position: absolute;
position: static;
}
#networkTree .netPort
{
Expand All @@ -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;
Expand Down Expand Up @@ -2034,8 +2037,8 @@ textarea[readonly],

#networkTree .netCollapse
{
position: absolute;
right: 0;
position: static;
float: right;
margin-right: -3px;
}

Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion front/js/network-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function initTree(myHierarchy)
// generate +/- icon if node has children nodes
collapseExpandHtml = nodeData.data.hasChildren ?
`<div class="netCollapse"
style="font-size:${nodeHeightPx/2}px;top:${Math.floor(nodeHeightPx / 4)}px"
style="font-size:${nodeHeightPx/2}px;margin-top:-${nodeHeightPx - Math.floor(nodeHeightPx / 4)}px"
data-mytreepath="${nodeData.data.path}"
data-mytreemac="${nodeData.data.devMac}">
<i class="fa fa-${collapseExpandIcon} pointer"></i>
Expand Down
20 changes: 20 additions & 0 deletions test/ui/TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

41 changes: 41 additions & 0 deletions test/ui/fixtures/network_topology.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>NetAlertX network topology regression fixture</title>
<link rel="stylesheet" href="/front/lib/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/front/lib/AdminLTE/dist/css/AdminLTE.css">
<link rel="stylesheet" href="/front/lib/font-awesome/all.min.css">
<link rel="stylesheet" href="/front/css/app.css">
<body>
<style>body { background: #fff; }</style>
<h1>Network topology β€” discussion #1379</h1>
<div class="networkTable"><div id="networkTree"></div></div>
<script src="/front/lib/jquery/jquery.min.js"></script>
<script src="/front/lib/treeviz/treeviz.iife.js"></script>
<script src="/front/js/network-tree.js"></script>
<script>
var emptyArr = [null, undefined, ""];
var clicks = [];
/** Provide a neutral device badge for fixed fixture data. */
function badgeFromDevice() { return {cssText: ""}; }
/** Return the fixed, trusted fixture labels. */
function encodeSpecialChars(value) { return String(value); }
/** Keep fixture timestamps independent of locale. */
function localizeTimestamp(value) { return value; }
/** Provide a fixed link color for visual assertions. */
function getRelationshipConf() { return {color: "#336c87"}; }
/** Attach the production collapse handlers after initialization. */
function hideSpinner() { attachTreeEvents(); }
/** Record node clicks for pointer interaction assertions. */
function handleNodeClick(node) { clicks.push(node.dataset.mac); }
/** Remove the collapsed MAC using the application helper contract. */
function removeItemFromArray(items, item) { items.splice(items.indexOf(item), 1); }
deviceListGlobal = [
{devMac: "internet", devName: "Internet", devParentMAC: "", devIsNetworkNodeDynamic: 1},
{devMac: "aa:bb:cc:dd:ee:01", devName: "Router", devParentMAC: "internet", devIsNetworkNodeDynamic: 1},
{devMac: "aa:bb:cc:dd:ee:02", devName: "Laptop", devParentMAC: "aa:bb:cc:dd:ee:01", devParentPort: "1"},
{devMac: "aa:bb:cc:dd:ee:03", devName: "Phone", devParentMAC: "aa:bb:cc:dd:ee:01", devSSID: "Home Wi-Fi"}
].map(device => ({devIcon: btoa('<i class="fa fa-desktop"></i>'), devSSID: "", devParentPort: "", devLastIP: "192.0.2.1", devVendor: "Test", devType: "Device", devParentRelType: "wired", devVlan: "", ...device}));
initTree(getHierarchy());
</script>
</body></html>
119 changes: 119 additions & 0 deletions test/ui/test_network_topology.cjs
Original file line number Diff line number Diff line change
@@ -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; });
Loading