Merge pull request #1615 from netalertx/next_release
✅ Code checks / check-url-paths (push) Has been cancelled
✅ Code checks / lint (push) Has been cancelled
✅ Code checks / docker-tests (push) Has been cancelled
🐳 👩‍💻 docker dev / docker_dev (push) Has been cancelled
📘 Deploy MkDocs / deploy (push) Has been cancelled

Next release
This commit is contained in:
Jokob @NetAlertX
2026-04-15 22:52:13 +10:00
committed by GitHub
7 changed files with 190 additions and 41 deletions
+12
View File
@@ -74,6 +74,18 @@ Use sanitizers from `server/helper.py` before storing user input. MAC addresses
- Everything is already writable - Everything is already writable
- If permissions needed, fix `.devcontainer/scripts/setup.sh` - If permissions needed, fix `.devcontainer/scripts/setup.sh`
## Test Helpers — No Duplicate Mocks
Reuse shared mocks and factories from `test/db_test_helpers.py`. Never redefine `DummyDB`, `make_db`, or inline DDL in individual test files.
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import make_db, DummyDB, insert_device, minutes_ago
```
If a helper you need doesn't exist yet, add it to `db_test_helpers.py` — not locally in the test file.
## Path Hygiene ## Path Hygiene
- Use environment variables for runtime paths - Use environment variables for runtime paths
+11 -5
View File
@@ -436,22 +436,28 @@ function initFilters() {
if (existingFilter) { if (existingFilter) {
// Add the unique columnValue to options if not already present // Add the unique columnValue to options if not already present
if (!existingFilter.options.includes(entry.columnValue)) { if (!existingFilter.options.some(opt => opt.value === entry.columnValue)) {
existingFilter.options.push(entry.columnValue); existingFilter.options.push({
value: entry.columnValue,
label: entry.columnLabel || entry.columnValue
});
} }
} else { } else {
// Create a new filter entry // Create a new filter entry
transformed.filters.push({ transformed.filters.push({
column: entry.columnName, column: entry.columnName,
headerKey: entry.columnHeaderStringKey, headerKey: entry.columnHeaderStringKey,
options: [entry.columnValue] options: [{
value: entry.columnValue,
label: entry.columnLabel || entry.columnValue
}]
}); });
} }
}); });
// Sort options alphabetically for better readability // Sort options alphabetically by label for better readability
transformed.filters.forEach(filter => { transformed.filters.forEach(filter => {
filter.options.sort(); filter.options.sort((a, b) => a.label.localeCompare(b.label));
}); });
// Output the result // Output the result
+12 -3
View File
@@ -10,9 +10,18 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/php/templates/language/lang.php';
function renderFilterDropdown($headerKey, $columnName, $values) { function renderFilterDropdown($headerKey, $columnName, $values) {
// Generate dropdown options // Generate dropdown options
$optionsHtml = '<option value="" selected>All</option>'; // Default "All" option $optionsHtml = '<option value="" selected>All</option>'; // Default "All" option
foreach ($values as $value) { foreach ($values as $item) {
$escapedValue = htmlspecialchars($value); // Support both {value, label} objects and plain strings (backward compat)
$optionsHtml .= '<option value="' . $escapedValue . '">' . $escapedValue . '</option>'; if (is_array($item)) {
$val = $item['value'] ?? '';
$label = $item['label'] ?? $val;
} else {
$val = $item;
$label = $item;
}
$escapedValue = htmlspecialchars($val);
$escapedLabel = htmlspecialchars($label);
$optionsHtml .= '<option value="' . $escapedValue . '">' . $escapedLabel . '</option>';
} }
// Generate the dropdown HTML // Generate the dropdown HTML
+29 -25
View File
@@ -68,42 +68,46 @@ sql_devices_all = """
""" """
sql_appevents = """select * from AppEvents order by dateTimeCreated desc""" sql_appevents = """select * from AppEvents order by dateTimeCreated desc"""
sql_devices_filters = """ sql_devices_filters = f"""
SELECT DISTINCT 'devSite' AS columnName, devSite AS columnValue SELECT DISTINCT 'devSite' AS columnName, devSite AS columnValue, devSite AS columnLabel
FROM Devices WHERE devSite NOT IN ('', 'null') AND devSite IS NOT NULL FROM Devices WHERE devSite NOT IN ({NULL_EQUIVALENTS_SQL}) AND devSite IS NOT NULL
UNION UNION
SELECT DISTINCT 'devSourcePlugin' AS columnName, devSourcePlugin AS columnValue SELECT DISTINCT 'devSourcePlugin' AS columnName, devSourcePlugin AS columnValue, devSourcePlugin AS columnLabel
FROM Devices WHERE devSourcePlugin NOT IN ('', 'null') AND devSourcePlugin IS NOT NULL FROM Devices WHERE devSourcePlugin NOT IN ({NULL_EQUIVALENTS_SQL}) AND devSourcePlugin IS NOT NULL
UNION UNION
SELECT DISTINCT 'devOwner' AS columnName, devOwner AS columnValue SELECT DISTINCT 'devOwner' AS columnName, devOwner AS columnValue, devOwner AS columnLabel
FROM Devices WHERE devOwner NOT IN ('', 'null') AND devOwner IS NOT NULL FROM Devices WHERE devOwner NOT IN ({NULL_EQUIVALENTS_SQL}) AND devOwner IS NOT NULL
UNION UNION
SELECT DISTINCT 'devType' AS columnName, devType AS columnValue SELECT DISTINCT 'devType' AS columnName, devType AS columnValue, devType AS columnLabel
FROM Devices WHERE devType NOT IN ('', 'null') AND devType IS NOT NULL FROM Devices WHERE devType NOT IN ({NULL_EQUIVALENTS_SQL}) AND devType IS NOT NULL
UNION UNION
SELECT DISTINCT 'devGroup' AS columnName, devGroup AS columnValue SELECT DISTINCT 'devGroup' AS columnName, devGroup AS columnValue, devGroup AS columnLabel
FROM Devices WHERE devGroup NOT IN ('', 'null') AND devGroup IS NOT NULL FROM Devices WHERE devGroup NOT IN ({NULL_EQUIVALENTS_SQL}) AND devGroup IS NOT NULL
UNION UNION
SELECT DISTINCT 'devLocation' AS columnName, devLocation AS columnValue SELECT DISTINCT 'devLocation' AS columnName, devLocation AS columnValue, devLocation AS columnLabel
FROM Devices WHERE devLocation NOT IN ('', 'null') AND devLocation IS NOT NULL FROM Devices WHERE devLocation NOT IN ({NULL_EQUIVALENTS_SQL}) AND devLocation IS NOT NULL
UNION UNION
SELECT DISTINCT 'devVendor' AS columnName, devVendor AS columnValue SELECT DISTINCT 'devVendor' AS columnName, devVendor AS columnValue, devVendor AS columnLabel
FROM Devices WHERE devVendor NOT IN ('', 'null') AND devVendor IS NOT NULL FROM Devices WHERE devVendor NOT IN ({NULL_EQUIVALENTS_SQL}) AND devVendor IS NOT NULL
UNION UNION
SELECT DISTINCT 'devSyncHubNode' AS columnName, devSyncHubNode AS columnValue SELECT DISTINCT 'devSyncHubNode' AS columnName, devSyncHubNode AS columnValue, devSyncHubNode AS columnLabel
FROM Devices WHERE devSyncHubNode NOT IN ('', 'null') AND devSyncHubNode IS NOT NULL FROM Devices WHERE devSyncHubNode NOT IN ({NULL_EQUIVALENTS_SQL}) AND devSyncHubNode IS NOT NULL
UNION UNION
SELECT DISTINCT 'devVlan' AS columnName, devVlan AS columnValue SELECT DISTINCT 'devVlan' AS columnName, devVlan AS columnValue, devVlan AS columnLabel
FROM Devices WHERE devVlan NOT IN ('', 'null') AND devVlan IS NOT NULL FROM Devices WHERE devVlan NOT IN ({NULL_EQUIVALENTS_SQL}) AND devVlan IS NOT NULL
UNION UNION
SELECT DISTINCT 'devParentMAC' AS columnName, devParentMAC AS columnValue SELECT 'devParentMAC' AS columnName, d.devParentMAC AS columnValue,
FROM Devices WHERE devParentMAC NOT IN ('', 'null') AND devParentMAC IS NOT NULL COALESCE(p.devName, d.devParentMAC) AS columnLabel
FROM Devices d
LEFT JOIN Devices p ON LOWER(p.devMac) = LOWER(d.devParentMAC)
WHERE d.devParentMAC NOT IN ({NULL_EQUIVALENTS_SQL}) AND d.devParentMAC IS NOT NULL
GROUP BY d.devParentMAC COLLATE NOCASE
UNION UNION
SELECT DISTINCT 'devParentRelType' AS columnName, devParentRelType AS columnValue SELECT DISTINCT 'devParentRelType' AS columnName, devParentRelType AS columnValue, devParentRelType AS columnLabel
FROM Devices WHERE devParentRelType NOT IN ('', 'null') AND devParentRelType IS NOT NULL FROM Devices WHERE devParentRelType NOT IN ({NULL_EQUIVALENTS_SQL}) AND devParentRelType IS NOT NULL
UNION UNION
SELECT DISTINCT 'devSSID' AS columnName, devSSID AS columnValue SELECT DISTINCT 'devSSID' AS columnName, devSSID AS columnValue, devSSID AS columnLabel
FROM Devices WHERE devSSID NOT IN ('', 'null') AND devSSID IS NOT NULL FROM Devices WHERE devSSID NOT IN ({NULL_EQUIVALENTS_SQL}) AND devSSID IS NOT NULL
ORDER BY columnName; ORDER BY columnName;
""" """
+31 -8
View File
@@ -5,7 +5,7 @@ import ipaddress
from helper import get_setting_value, check_IP_format from helper import get_setting_value, check_IP_format
from utils.datetime_utils import timeNowUTC, normalizeTimeStamp from utils.datetime_utils import timeNowUTC, normalizeTimeStamp
from logger import mylog, Logger from logger import mylog, Logger
from const import vendorsPath, vendorsPathNewest, sql_generateGuid, NULL_EQUIVALENTS from const import vendorsPath, vendorsPathNewest, sql_generateGuid, NULL_EQUIVALENTS, NULL_EQUIVALENTS_SQL
from models.device_instance import DeviceInstance from models.device_instance import DeviceInstance
from scan.name_resolution import NameResolver from scan.name_resolution import NameResolver
from scan.device_heuristics import guess_icon, guess_type from scan.device_heuristics import guess_icon, guess_type
@@ -240,6 +240,29 @@ def update_devLastConnection_from_CurrentScan(db):
""") """)
def update_sync_hub_node(db):
"""
Backfill devSyncHubNode with SYNC_node_name for devices where it is empty.
Mirrors the fallback already used in create_new_devices.
"""
sql = db.sql
node_name = str(get_setting_value("SYNC_node_name") or "").strip()
if not node_name:
return
sql.execute(
f"""
UPDATE Devices
SET devSyncHubNode = ?
WHERE COALESCE(LOWER(TRIM(devSyncHubNode)), '') IN ({NULL_EQUIVALENTS_SQL})
""",
(node_name,),
)
db.commitDB()
def update_devices_data_from_scan(db): def update_devices_data_from_scan(db):
sql = db.sql sql = db.sql
@@ -378,11 +401,11 @@ def update_icons_and_types(db):
if get_setting_value("NEWDEV_replace_preset_icon"): if get_setting_value("NEWDEV_replace_preset_icon"):
query = f"""SELECT * FROM Devices query = f"""SELECT * FROM Devices
WHERE devIcon in ('', 'null', '{default_icon}') WHERE devIcon in ({NULL_EQUIVALENTS_SQL}, '{default_icon}')
OR devIcon IS NULL""" OR devIcon IS NULL"""
else: else:
query = """SELECT * FROM Devices query = f"""SELECT * FROM Devices
WHERE devIcon in ('', 'null') WHERE devIcon in ({NULL_EQUIVALENTS_SQL})
OR devIcon IS NULL""" OR devIcon IS NULL"""
for device in sql.execute(query): for device in sql.execute(query):
@@ -406,8 +429,8 @@ def update_icons_and_types(db):
# Guess Type # Guess Type
recordsToUpdate = [] recordsToUpdate = []
query = """SELECT * FROM Devices query = f"""SELECT * FROM Devices
WHERE devType in ('', 'null') WHERE devType in ({NULL_EQUIVALENTS_SQL})
OR devType IS NULL""" OR devType IS NULL"""
default_type = get_setting_value("NEWDEV_devType") default_type = get_setting_value("NEWDEV_devType")
@@ -529,7 +552,7 @@ def save_scanned_devices(db):
def print_scan_stats(db): def print_scan_stats(db):
sql = db.sql # TO-DO sql = db.sql # TO-DO
query = """ query = f"""
SELECT SELECT
(SELECT COUNT(*) FROM CurrentScan) AS devices_detected, (SELECT COUNT(*) FROM CurrentScan) AS devices_detected,
(SELECT COUNT(*) FROM CurrentScan WHERE NOT EXISTS (SELECT 1 FROM Devices WHERE devMac = scanMac)) AS new_devices, (SELECT COUNT(*) FROM CurrentScan WHERE NOT EXISTS (SELECT 1 FROM Devices WHERE devMac = scanMac)) AS new_devices,
@@ -544,7 +567,7 @@ def print_scan_stats(db):
(SELECT COUNT(*) FROM Devices, CurrentScan (SELECT COUNT(*) FROM Devices, CurrentScan
WHERE devMac = scanMac WHERE devMac = scanMac
AND scanLastIP IS NOT NULL AND scanLastIP IS NOT NULL
AND scanLastIP NOT IN ('', 'null', '(unknown)', '(Unknown)') AND scanLastIP NOT IN ({NULL_EQUIVALENTS_SQL})
AND scanLastIP <> COALESCE(devPrimaryIPv4, '') AND scanLastIP <> COALESCE(devPrimaryIPv4, '')
AND scanLastIP <> COALESCE(devPrimaryIPv6, '') AND scanLastIP <> COALESCE(devPrimaryIPv6, '')
AND scanLastIP <> COALESCE(devLastIP, '') AND scanLastIP <> COALESCE(devLastIP, '')
+5
View File
@@ -4,6 +4,7 @@ from scan.device_handling import (
save_scanned_devices, save_scanned_devices,
exclude_ignored_devices, exclude_ignored_devices,
update_devices_data_from_scan, update_devices_data_from_scan,
update_sync_hub_node,
update_vendors_from_mac, update_vendors_from_mac,
update_icons_and_types, update_icons_and_types,
update_devPresentLastScan_based_on_force_status, update_devPresentLastScan_based_on_force_status,
@@ -62,6 +63,10 @@ def process_scan(db):
mylog("verbose", "[Process Scan] Updating Devices Info") mylog("verbose", "[Process Scan] Updating Devices Info")
update_devices_data_from_scan(db) update_devices_data_from_scan(db)
# Backfill devSyncHubNode for devices where it is empty
mylog("verbose", "[Process Scan] Updating Sync Hub Node")
update_sync_hub_node(db)
# Last Connection Time stamp from CurrentScan # Last Connection Time stamp from CurrentScan
mylog("verbose", "[Process Scan] Updating devLastConnection from CurrentScan") mylog("verbose", "[Process Scan] Updating devLastConnection from CurrentScan")
update_devLastConnection_from_CurrentScan(db) update_devLastConnection_from_CurrentScan(db)
+90
View File
@@ -0,0 +1,90 @@
"""Tests for update_sync_hub_node backfill."""
import sys
import os
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import make_db, DummyDB # noqa: E402
from server.scan import device_handling
def _make_db(devices):
"""Create an in-memory DB with full schema and seed rows."""
conn = make_db()
cur = conn.cursor()
cur.executemany(
"INSERT INTO Devices (devMac, devSyncHubNode) VALUES (?, ?)",
devices,
)
conn.commit()
return conn
def _read_nodes(conn):
"""Return a dict of devMac -> devSyncHubNode."""
return {
row["devMac"]: row["devSyncHubNode"]
for row in conn.execute("SELECT devMac, devSyncHubNode FROM Devices")
}
@patch.object(device_handling, "get_setting_value", return_value="MyNode")
def test_backfill_empty_values(mock_setting):
"""Empty and null devSyncHubNode should be backfilled with SYNC_node_name."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", ""),
("AA:AA:AA:AA:AA:02", None),
("AA:AA:AA:AA:AA:03", "null"),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == "MyNode"
assert nodes["AA:AA:AA:AA:AA:02"] == "MyNode"
assert nodes["AA:AA:AA:AA:AA:03"] == "MyNode"
@patch.object(device_handling, "get_setting_value", return_value="MyNode")
def test_no_overwrite_existing(mock_setting):
"""Devices with a real devSyncHubNode should not be overwritten."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", "RemoteNode"),
("AA:AA:AA:AA:AA:02", ""),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == "RemoteNode"
assert nodes["AA:AA:AA:AA:AA:02"] == "MyNode"
@patch.object(device_handling, "get_setting_value", return_value="")
def test_noop_when_setting_empty(mock_setting):
"""No updates when SYNC_node_name is empty."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", ""),
("AA:AA:AA:AA:AA:02", None),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == ""
assert nodes["AA:AA:AA:AA:AA:02"] is None
@patch.object(device_handling, "get_setting_value", return_value=None)
def test_noop_when_setting_none(mock_setting):
"""No updates when SYNC_node_name is None."""
conn = _make_db([
("AA:AA:AA:AA:AA:01", ""),
])
device_handling.update_sync_hub_node(DummyDB(conn))
nodes = _read_nodes(conn)
assert nodes["AA:AA:AA:AA:AA:01"] == ""