FE+BE: performance improvements

This commit is contained in:
jokob-sk committed 2026-09-14 08:29:02 +10:00
1 parent cff3ddfc38
commit f52de6cbdc
23 files changed
+428 -31

No files matched your search

@@ -0,0 +1,35 @@
---
name: ux-design-patterns
description: Read before adding or changing any front/ UI element - a control, layout, button, or interaction pattern. Covers the don't-invent-new-UX-without-a-PRD rule and the priority order for design tradeoffs (existing behavior > intuitiveness > information density > usability > utility > uniqueness > industry practices > generic UI).
---
# UX / Frontend Design Patterns
## Core principle: reuse before inventing
Don't introduce new UX behavior or visual patterns unless a PRD explicitly calls for it. Before building any new UI element, search the existing frontend for a pattern that already solves this exact need, and reuse its markup/CSS/behavior instead of inventing a new one.
Real, recent example: a presence-page Prev/Next pager was first built with custom `<button class="btn btn-xs">` elements floated in a `.box-header`. The DataTables pagination pattern (`dataTables_wrapper` / `dataTables_paginate` / `ul.pagination` / `li.paginate_button.previous|next`) already existed elsewhere in the app and does the exact same job. The custom version looked visually broken in the actual UI and had to be reimplemented using the existing pattern once that was caught in manual testing - reusing it also picked up dark-mode theming (`front/css/dark-patch.css`'s `.pagination li > a` / `.disabled` rules) for free, which the hand-rolled version didn't have. Grep first: e.g. `grep -rn "pagination\|paginate_button" front/` before adding a "previous/next" control of your own; the same applies to modals, filter inputs, badges, tooltips, tables - anything that already has an established shape somewhere in `front/`.
## Priority order for design decisions
When several options are all locally reasonable, resolve the choice in this order - highest wins on conflict:
1. **Existing behavior** - what does this codebase already do for the same or a similar need? Copy it.
2. **Intuitiveness** - will a user already familiar with the rest of the app understand this without being told?
3. **Information density** - does it show what's needed without wasting space or hiding what matters?
4. **Usability** - is it easy and low-friction to actually use (reachability, click count, error tolerance)?
5. **Utility** - does it solve the real problem, not just resemble a solution?
6. **Uniqueness** - is this the app's own distinct answer, used only where nothing generic fits well?
7. **Industry practices** - conventions users bring in from other apps.
8. **Generic UI** - a default/framework-provided look, used only when nothing above applies.
This list exists to end debates quickly, not to be argued from the bottom up. The reason it's written down is that #1 is exactly the step that gets skipped under time pressure - checking it first is meant to be fast, not a detour.
## Practical checklist before building a new UI element
1. Grep `front/js/`, `front/css/`, `front/php/` for an existing implementation of the same interaction - a table, a pager, a filter box, a modal, a badge, a status indicator.
2. If found, reuse its markup and CSS classes directly rather than writing new ones - matching classes inherit theming (dark mode, responsive breakpoints) a new hand-rolled version won't have.
3. If nothing fits, check whether the PRD driving this change actually calls for new UX. If it doesn't, that's a signal to look harder for an existing pattern, not license to invent one.
4. If a new pattern is genuinely warranted and the PRD says so, design it using the priority order above, and record the choice and why existing patterns didn't fit in the PRD - the next change will hit the same fork and shouldn't have to re-derive the answer.
5. Verify visually in a real browser/devcontainer before calling it done. A change that "should work" per the markup isn't confirmed until it's actually rendered - matches the project's general "test the golden path in a browser" rule for frontend changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+1
View File
@@ -31,6 +31,7 @@ Skills with the same purpose exist in more than one, sometimes under different n
| Scan pipeline internals | `scan-pipeline` | `scan-pipeline` | `scan-pipeline` | `process_scan()` call order and why it's load-bearing, `CurrentScan`/`Events`/`Sessions`/`DevicesView` relationships, how a session actually closes (no `close_session()` exists), and the `FIELD_SPECS` field-write authority mechanism. Complements `database-patterns` (Devices write-path/`*Source` attribution) rather than duplicating it. |
| Database patterns | `database-patterns` | `database-patterns` | `database-patterns` | Devices table write-path inventory, the `FIELD_SOURCE_MAP`/`*Source` attribution system in `server/db/authoritative_handler.py`, SQLite trigger vs. Python-hook tradeoffs, and event-sourced vs. snapshot audit logging. |
| PRD writing | `prd-writing` | `prd-writing` | `prd-writing` | Methodology for writing a design doc: challenge the idea, verify every claim against actual code, trace every downstream consumer of a new mechanism, evaluate performance impact against the real schema/indexes, record rejected alternatives and open-issue decisions explicitly, final-check pass before done. Distilled from the `plugin-import-behavior-controls` PRD process, including real mistakes caught mid-review. |
| UX/frontend design | `ux-design-patterns` | `ux-design-patterns` | `ux-design-patterns` | Don't invent new UX behavior/visual patterns unless a PRD calls for it - search `front/` for an existing pattern first and reuse it. Priority order for design tradeoffs when several options are reasonable: existing behavior > intuitiveness > information density > usability > utility > uniqueness > industry practices > generic UI. Distilled from a real Prev/Next pager that was hand-rolled instead of reusing the existing DataTables pagination pattern, and had to be redone after it looked broken in manual testing. |
---
@@ -0,0 +1,35 @@
---
name: ux-design-patterns
description: Read before adding or changing any front/ UI element - a control, layout, button, or interaction pattern. Covers the don't-invent-new-UX-without-a-PRD rule and the priority order for design tradeoffs (existing behavior > intuitiveness > information density > usability > utility > uniqueness > industry practices > generic UI).
---
# UX / Frontend Design Patterns
## Core principle: reuse before inventing
Don't introduce new UX behavior or visual patterns unless a PRD explicitly calls for it. Before building any new UI element, search the existing frontend for a pattern that already solves this exact need, and reuse its markup/CSS/behavior instead of inventing a new one.
Real, recent example: a presence-page Prev/Next pager was first built with custom `<button class="btn btn-xs">` elements floated in a `.box-header`. The DataTables pagination pattern (`dataTables_wrapper` / `dataTables_paginate` / `ul.pagination` / `li.paginate_button.previous|next`) already existed elsewhere in the app and does the exact same job. The custom version looked visually broken in the actual UI and had to be reimplemented using the existing pattern once that was caught in manual testing - reusing it also picked up dark-mode theming (`front/css/dark-patch.css`'s `.pagination li > a` / `.disabled` rules) for free, which the hand-rolled version didn't have. Grep first: e.g. `grep -rn "pagination\|paginate_button" front/` before adding a "previous/next" control of your own; the same applies to modals, filter inputs, badges, tooltips, tables - anything that already has an established shape somewhere in `front/`.
## Priority order for design decisions
When several options are all locally reasonable, resolve the choice in this order - highest wins on conflict:
1. **Existing behavior** - what does this codebase already do for the same or a similar need? Copy it.
2. **Intuitiveness** - will a user already familiar with the rest of the app understand this without being told?
3. **Information density** - does it show what's needed without wasting space or hiding what matters?
4. **Usability** - is it easy and low-friction to actually use (reachability, click count, error tolerance)?
5. **Utility** - does it solve the real problem, not just resemble a solution?
6. **Uniqueness** - is this the app's own distinct answer, used only where nothing generic fits well?
7. **Industry practices** - conventions users bring in from other apps.
8. **Generic UI** - a default/framework-provided look, used only when nothing above applies.
This list exists to end debates quickly, not to be argued from the bottom up. The reason it's written down is that #1 is exactly the step that gets skipped under time pressure - checking it first is meant to be fast, not a detour.
## Practical checklist before building a new UI element
1. Grep `front/js/`, `front/css/`, `front/php/` for an existing implementation of the same interaction - a table, a pager, a filter box, a modal, a badge, a status indicator.
2. If found, reuse its markup and CSS classes directly rather than writing new ones - matching classes inherit theming (dark mode, responsive breakpoints) a new hand-rolled version won't have.
3. If nothing fits, check whether the PRD driving this change actually calls for new UX. If it doesn't, that's a signal to look harder for an existing pattern, not license to invent one.
4. If a new pattern is genuinely warranted and the PRD says so, design it using the priority order above, and record the choice and why existing patterns didn't fit in the PRD - the next change will hit the same fork and shouldn't have to re-derive the answer.
5. Verify visually in a real browser/devcontainer before calling it done. A change that "should work" per the markup isn't confirmed until it's actually rendered - matches the project's general "test the golden path in a browser" rule for frontend changes.
+29
View File
@@ -25,6 +25,8 @@ description: NetAlertX coding standards and conventions. Use this when writing c
- all code needs to be scalable to handle large networks with thousands of devices (10k+) without performance degradation
- no inline imports, all imports must be at the top of the file
- when using `server/logger.py` `mylog()`, only use valid levels: `none`, `minimal`, `verbose`, `debug`, `trace`; invalid levels silently degrade to `none`
- every Python function/method needs a succinct docstring describing its current use and behavior — not what changed or why (see Docstrings section below)
- before adding a new frontend language string, search `front/php/templates/language/en_us.json` for an existing key with the same text/purpose and reuse it — don't add a near-duplicate key just because it's needed on a new page (see Language Strings section below)
## File Length
@@ -80,6 +82,33 @@ Use timeNowUTC(as_string=False) for datetime operations (scheduling, comparisons
Use sanitizers from `server/helper.py` before storing user input. MAC addresses are always lowercased and normalized. IP addresses should be validated.
## Docstrings
Every Python function/method gets a docstring — one or two sentences, describing what it does and how it's used *right now*. Not a changelog:
```python
# Correct
def count_children_by_parent_mac(devices):
"""Return {parentMac: childCount} for the given device list, keyed by devParentMAC."""
# Wrong — narrates the diff instead of the current behavior
def count_children_by_parent_mac(devices):
"""Replaces the old get_number_of_children() to fix the O(n^2) scan."""
```
That history belongs in the commit message or PR description, not the docstring — it rots the moment the next change lands. Keep it succinct; only go past a couple of lines when the contract genuinely needs it (non-obvious return shape, units, a caller-visible side effect).
## Language Strings — Reuse Before Adding (DRY)
Before adding a new key to `front/php/templates/language/en_us.json`, grep it for an existing key with the same text or purpose and reuse that key instead of adding a near-duplicate:
```bash
grep -n "\"Gen_" front/php/templates/language/en_us.json # generic, reusable strings
grep -n "Next\|Previous\|Showing" front/php/templates/language/en_us.json
```
Prefer the generic `Gen_*` keys (e.g. `Gen_Prev`, `Gen_Next`) over a page-scoped name (`Presence_Page_Prev`) for genuinely generic UI text — a future page needing the same label should find it already there. Only add a new key when nothing existing fits; only that one file needs the addition — `getString()`/`lang()` fall back to the English string for any locale missing a key, so the other ~23 locale files don't need touching.
## Devcontainer Constraints
- Never `chmod` or `chown` during operations
@@ -0,0 +1,35 @@
---
name: netalertx-ux-design-patterns
description: Read before adding or changing any front/ UI element - a control, layout, button, or interaction pattern. Covers the don't-invent-new-UX-without-a-PRD rule and the priority order for design tradeoffs (existing behavior > intuitiveness > information density > usability > utility > uniqueness > industry practices > generic UI).
---
# UX / Frontend Design Patterns
## Core principle: reuse before inventing
Don't introduce new UX behavior or visual patterns unless a PRD explicitly calls for it. Before building any new UI element, search the existing frontend for a pattern that already solves this exact need, and reuse its markup/CSS/behavior instead of inventing a new one.
Real, recent example: a presence-page Prev/Next pager was first built with custom `<button class="btn btn-xs">` elements floated in a `.box-header`. The DataTables pagination pattern (`dataTables_wrapper` / `dataTables_paginate` / `ul.pagination` / `li.paginate_button.previous|next`) already existed elsewhere in the app and does the exact same job. The custom version looked visually broken in the actual UI and had to be reimplemented using the existing pattern once that was caught in manual testing - reusing it also picked up dark-mode theming (`front/css/dark-patch.css`'s `.pagination li > a` / `.disabled` rules) for free, which the hand-rolled version didn't have. Grep first: e.g. `grep -rn "pagination\|paginate_button" front/` before adding a "previous/next" control of your own; the same applies to modals, filter inputs, badges, tooltips, tables - anything that already has an established shape somewhere in `front/`.
## Priority order for design decisions
When several options are all locally reasonable, resolve the choice in this order - highest wins on conflict:
1. **Existing behavior** - what does this codebase already do for the same or a similar need? Copy it.
2. **Intuitiveness** - will a user already familiar with the rest of the app understand this without being told?
3. **Information density** - does it show what's needed without wasting space or hiding what matters?
4. **Usability** - is it easy and low-friction to actually use (reachability, click count, error tolerance)?
5. **Utility** - does it solve the real problem, not just resemble a solution?
6. **Uniqueness** - is this the app's own distinct answer, used only where nothing generic fits well?
7. **Industry practices** - conventions users bring in from other apps.
8. **Generic UI** - a default/framework-provided look, used only when nothing above applies.
This list exists to end debates quickly, not to be argued from the bottom up. The reason it's written down is that #1 is exactly the step that gets skipped under time pressure - checking it first is meant to be fast, not a detour.
## Practical checklist before building a new UI element
1. Grep `front/js/`, `front/css/`, `front/php/` for an existing implementation of the same interaction - a table, a pager, a filter box, a modal, a badge, a status indicator.
2. If found, reuse its markup and CSS classes directly rather than writing new ones - matching classes inherit theming (dark mode, responsive breakpoints) a new hand-rolled version won't have.
3. If nothing fits, check whether the PRD driving this change actually calls for new UX. If it doesn't, that's a signal to look harder for an existing pattern, not license to invent one.
4. If a new pattern is genuinely warranted and the PRD says so, design it using the priority order above, and record the choice and why existing patterns didn't fit in the PRD - the next change will hit the same fork and shouldn't have to re-derive the answer.
5. Verify visually in a real browser/devcontainer before calling it done. A change that "should work" per the markup isn't confirmed until it's actually rendered - matches the project's general "test the golden path in a browser" rule for frontend changes.
+8
View File
@@ -21,6 +21,14 @@ front/log/*
/log/*
.gemini/internal-docs/PRDs/*
!.gemini/internal-docs/PRDs/.gitkeep
!.gemini/internal-docs/PRDs/completed
!.gemini/internal-docs/PRDs/to_review
.gemini/internal-docs/PRDs/completed/*
!.gemini/internal-docs/PRDs/completed/.gitkeep
.gemini/internal-docs/PRDs/to_review/*
!.gemini/internal-docs/PRDs/to_review/.gitkeep
.gemini/internal-docs/research/*
!.gemini/internal-docs/research/.gitkeep
/log/plugins/*
front/api/*
/api/*
+2
View File
@@ -89,3 +89,5 @@ Procedural/how-to knowledge (running tests, resetting the DB, devcontainer manag
- No inline imports — everything at module top level.
- Reuse `test/db_test_helpers.py` for DB mocks/fixtures in tests rather than redefining `DummyDB`/`make_db` locally.
- Keep files under ~500 lines; split rather than grow.
- Every Python function/method gets a succinct docstring describing its current use and behavior — one or two sentences, not a changelog of what changed or why (that belongs in the commit/PR, not the docstring).
- Before adding a new key to `front/php/templates/language/en_us.json`, search it for an existing key with the same text/purpose and reuse it — prefer generic `Gen_*` keys over page-scoped names for genuinely generic UI text (e.g. `Gen_Prev`/`Gen_Next`, not `Presence_Page_Prev`). Only the English file needs a new key; other locales fall back to it automatically.
+33 -13
View File
@@ -10,40 +10,59 @@ var hiddenChildren = [];
var deviceListGlobal = null;
var myTree;
/**
* Build an index of children grouped by parent MAC in a single pass,
* so getChildren() doesn't have to rescan the full device list per node.
* @param {Array} list - Full device list
* @returns {Map<string, Array>} parentMac (lowercased) -> array of child devices
*/
function buildChildrenIndex(list)
{
const index = new Map();
for (var i in list) {
const item = list[i];
const parentMac = item.devParentMAC?.toLowerCase() || ""; // null-safe
if (parentMac != "") {
if (!index.has(parentMac)) index.set(parentMac, []);
index.get(parentMac).push(item);
}
}
return index;
}
/**
* Recursively get children nodes and build a tree
* @param {Object} node - Current node
* @param {Array} list - Full device list
* @param {Map} childrenIndex - Index built by buildChildrenIndex()
* @param {string} path - Path to current node
* @param {Array} visited - Visited nodes (for cycle detection)
* @returns {Object} Tree node with children
*/
function getChildren(node, list, path, visited = [])
function getChildren(node, childrenIndex, path, visited = [])
{
var children = [];
const nodeMac = node.devMac?.toLowerCase() || ""; // null-safe
// Check for infinite recursion by seeing if the node has been visited before
if (visited.includes(node.devMac.toLowerCase())) {
if (visited.includes(nodeMac)) {
console.error("Infinite recursion detected at node:", node.devMac);
write_notification("[ERROR] ⚠ Infinite recursion detected. You probably have assigned the Internet node to another children node or to itself. Please open a new issue on GitHub and describe how you did it.", 'interrupt')
return { error: "Infinite recursion detected", node: node.devMac };
}
// Add current node to visited list
visited.push(node.devMac.toLowerCase());
visited.push(nodeMac);
// Loop through all items to find children of the current node
for (var i in list) {
const item = list[i];
const parentMac = item.devParentMAC?.toLowerCase() || ""; // null-safe
const nodeMac = node.devMac?.toLowerCase() || ""; // null-safe
if (parentMac != "" && parentMac == nodeMac && !hiddenMacs.includes(parentMac)) {
// Look up this node's children directly instead of scanning the full list
if (!hiddenMacs.includes(nodeMac)) {
const candidates = childrenIndex.get(nodeMac) || [];
for (var i in candidates) {
const item = candidates[i];
visibleNodesCount++;
// Process children recursively, passing a copy of the visited list
children.push(getChildren(list[i], list, path + ((path == "") ? "" : '|') + parentMac, visited));
children.push(getChildren(item, childrenIndex, path + ((path == "") ? "" : '|') + nodeMac, visited));
}
}
@@ -100,6 +119,7 @@ function getHierarchy()
parentNodesCount = 0;
let internetNode = null;
const childrenIndex = buildChildrenIndex(deviceListGlobal);
for(i in deviceListGlobal)
{
@@ -107,7 +127,7 @@ function getHierarchy()
{
internetNode = deviceListGlobal[i];
return (getChildren(internetNode, deviceListGlobal, ''))
return (getChildren(internetNode, childrenIndex, ''))
break;
}
}
+2
View File
@@ -351,10 +351,12 @@
"Gen_LockedDB": "ERROR - DB might be locked - Check F12 Dev tools -> Console or try later.",
"Gen_NetworkMask": "Network mask",
"Gen_New": "New",
"Gen_Next": "Next",
"Gen_No_Data": "No data",
"Gen_Offline": "Offline",
"Gen_Okay": "Ok",
"Gen_Online": "Online",
"Gen_Prev": "Previous",
"Gen_Purge": "Purge",
"Gen_ReadDocs": "Read more in the docs.",
"Gen_Remove_All": "Remove all",
+50 -2
View File
@@ -160,6 +160,21 @@
<!-- Calendar -->
<div id="calendar"></div>
<!-- Presence pager - same markup/classes DataTables generates for its own Previous/Next
(wrapped in .dataTables_wrapper so the same vendor CSS right-aligns it identically) -->
<div class="dataTables_wrapper">
<div class="dataTables_paginate">
<ul class="pagination">
<li id="presencePrev" class="paginate_button previous">
<a href="#" onclick="changePresencePage(-1); return false;"><?= lang('Gen_Prev');?></a>
</li>
<li id="presenceNext" class="paginate_button next">
<a href="#" onclick="changePresencePage(1); return false;"><?= lang('Gen_Next');?></a>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -210,6 +225,7 @@ switch ($UI_THEME) {
<script>
var deviceStatus = 'all';
var presencePage = 0;
// Read parameters & Initialize components
main();
@@ -420,6 +436,11 @@ function getDevicesTotals () {
// -----------------------------------------------------------------------------
function getDevicesPresence (status) {
// Reset to the first page whenever a new status is selected (not on Prev/Next)
if (status !== deviceStatus) {
presencePage = 0;
}
// Save status selected
deviceStatus = status;
@@ -473,7 +494,9 @@ function getDevicesPresence (status) {
// -----------------------------
// Load Devices as Resources
// -----------------------------
const devicesUrl = `${apiBaseUrl}/devices/by-status?status=${deviceStatus}`;
const pageSize = parseInt(getSetting("UI_DEFAULT_PAGE_SIZE"));
const devicesUrl = `${apiBaseUrl}/devices/by-status?status=${deviceStatus}`
+ `&limit=${pageSize + 1}&offset=${presencePage * pageSize}`;
$.ajax({
url: devicesUrl,
@@ -482,14 +505,20 @@ function getDevicesPresence (status) {
"Authorization": `Bearer ${apiToken}`
},
success: function(devices) {
// Peek-ahead: requested one extra device to know if there's a next page
// without a separate count request.
const hasNextPage = devices.length > pageSize;
const pageDevices = hasNextPage ? devices.slice(0, pageSize) : devices;
// FullCalendar expects resources array
const resources = devices.map(dev => ({
const resources = pageDevices.map(dev => ({
id: dev.devMac,
title: dev.devName
}));
$('#calendar').fullCalendar('option', 'resources', resources);
$('#calendar').fullCalendar('refetchResources');
updatePresencePagerControls(hasNextPage);
}
});
@@ -517,6 +546,25 @@ function getDevicesPresence (status) {
});
};
// -----------------------------------------------------------------------------
// Move the presence resources page by delta (-1 = Prev, 1 = Next) and reload.
function changePresencePage (delta) {
const button = delta < 0 ? $('#presencePrev') : $('#presenceNext');
if (button.hasClass('disabled')) {
return;
}
presencePage = Math.max(0, presencePage + delta);
getDevicesPresence(deviceStatus);
}
// -----------------------------------------------------------------------------
// Enable/disable the Prev/Next pager buttons (DataTables' own convention:
// a "disabled" class on the <li>, not a disabled attribute on the <a>).
function updatePresencePagerControls (hasNextPage) {
$('#presencePrev').toggleClass('disabled', presencePage === 0);
$('#presenceNext').toggleClass('disabled', !hasNextPage);
}
function hidePresenceSkeleton() {
hideSpinner();
$('#presence-skeleton').fadeOut(0, function() { $(this).remove(); });
+1
View File
@@ -31,6 +31,7 @@ GROUPS = [
[".gemini/skills/scan-pipeline/SKILL.md", ".github/skills/scan-pipeline/SKILL.md", ".claude/skills/scan-pipeline/SKILL.md"],
[".gemini/skills/database-patterns/SKILL.md", ".github/skills/database-patterns/SKILL.md", ".claude/skills/database-patterns/SKILL.md"],
[".gemini/skills/prd-writing/SKILL.md", ".github/skills/prd-writing/SKILL.md", ".claude/skills/prd-writing/SKILL.md"],
[".gemini/skills/ux-design-patterns/SKILL.md", ".github/skills/ux-design-patterns/SKILL.md", ".claude/skills/ux-design-patterns/SKILL.md"],
[".gemini/skills/settings/SKILL.md", ".github/skills/settings-management/SKILL.md"],
[".gemini/skills/mcp-activation/SKILL.md", ".github/skills/mcp-activation/SKILL.md"],
[".gemini/skills/project-navigation/SKILL.md", ".github/skills/project-navigation/SKILL.md"],
+15 -1
View File
@@ -832,6 +832,18 @@ def api_devices_totals_named(payload=None):
"connected", "down", "favorites", "new", "archived", "all", "my",
"offline"
]}
}, {
"name": "limit",
"in": "query",
"required": False,
"description": "Max devices to return",
"schema": {"type": "integer", "minimum": 1, "maximum": 1000}
}, {
"name": "offset",
"in": "query",
"required": False,
"description": "Number of devices to skip",
"schema": {"type": "integer", "minimum": 0}
}],
links={
"GetOpenPorts": {
@@ -859,8 +871,10 @@ def api_devices_totals_named(payload=None):
)
def api_devices_by_status(payload: DeviceListRequest = None):
status = payload.status if payload else request.args.get("status")
limit = payload.limit if payload else request.args.get("limit", type=int)
offset = payload.offset if payload else request.args.get("offset", type=int)
device_handler = DeviceInstance()
return jsonify(device_handler.getByStatus(status))
return jsonify(device_handler.getByStatus(status, limit, offset))
@app.route('/devices/search', methods=['POST'])
+4 -3
View File
@@ -12,7 +12,7 @@ from logger import mylog # noqa: E402 [flake8 lint suppression]
from const import apiPath, NULL_EQUIVALENTS # noqa: E402 [flake8 lint suppression]
from helper import ( # noqa: E402 [flake8 lint suppression]
is_random_mac,
get_number_of_children,
count_children_by_parent_mac,
format_ip_long,
get_setting_value,
)
@@ -178,10 +178,11 @@ class Query(ObjectType):
]
# Add dynamic fields to each device
children_counts = count_children_by_parent_mac(devices_data)
for device in devices_data:
device["devIsRandomMac"] = 1 if is_random_mac(device["devMac"]) else 0
device["devParentChildrenCount"] = get_number_of_children(
device["devMac"], devices_data
device["devParentChildrenCount"] = children_counts.get(
device["devMac"].strip(), 0
)
# Return as string — IPv4 long values can exceed Int's signed 32-bit max (2,147,483,647)
device["devIpLong"] = str(format_ip_long(device.get("devLastIP", "")))
+2
View File
@@ -265,6 +265,8 @@ class DeviceListRequest(BaseModel):
"- offline: Devices not present in the last scan"
)
)
limit: Optional[int] = Field(None, ge=1, le=1000, description="Max devices to return")
offset: Optional[int] = Field(None, ge=0, description="Number of devices to skip")
class DeviceListResponse(RootModel):
+9 -6
View File
@@ -650,12 +650,15 @@ def is_random_mac(mac):
# -------------------------------------------------------------------------------
# Helper function to calculate number of children
def get_number_of_children(mac, devices):
# Count children by checking devParentMAC for each device
return sum(
1 for dev in devices if dev.get("devParentMAC", "").strip() == mac.strip()
)
def count_children_by_parent_mac(devices):
"""Return {parentMac: childCount} for a device list, keyed by devParentMAC exactly
as stored (already lowercased upstream by normalize_mac(), so no case-folding here)."""
counts = {}
for dev in devices:
parent_mac = dev.get("devParentMAC", "").strip()
if parent_mac:
counts[parent_mac] = counts.get(parent_mac, 0) + 1
return counts
# -------------------------------------------------------------------------------
+10 -5
View File
@@ -451,10 +451,11 @@ class DeviceInstance:
return json_obj
def getByStatus(self, status=None):
def getByStatus(self, status=None, limit=None, offset=None):
"""
Return devices filtered by status. Returns all if no status provided.
Possible statuses: my, connected, favorites, new, down, archived
Return devices filtered by status, ordered by devMac for stable pagination.
Returns all matching devices if limit is omitted. Possible statuses:
my, connected, favorites, new, down, archived (see get_device_conditions()).
"""
conn = get_temp_db_connection()
sql = conn.cursor()
@@ -463,8 +464,12 @@ class DeviceInstance:
condition = get_device_condition_by_status(status) if status else ""
# Only DevicesView has devFlapping
query = f"SELECT * FROM DevicesView {condition}"
sql.execute(query)
query = f"SELECT * FROM DevicesView {condition} ORDER BY devMac"
params = []
if limit is not None:
query += " LIMIT ? OFFSET ?"
params.extend([limit, offset or 0])
sql.execute(query, params)
table_data = []
for row in sql.fetchall():
@@ -208,6 +208,53 @@ def test_devices_by_status(client, api_token, test_mac):
delete_dummy(client, api_token, test_mac)
def test_devices_by_status_pagination(client, api_token):
"""limit/offset must page through the same set ORDER BY devMac gives
unpaginated, with no gaps or duplicates, and must reject invalid values.
Doesn't assume an otherwise-empty DB: reconstructs the full 'my' list from
pages and compares it to the unpaginated response instead of asserting
exact positions for the 3 dummies.
"""
macs = [f"aa:bb:cc:dd:ee:0{i}" for i in (1, 2, 3)]
for mac in macs:
create_dummy(client, api_token, mac)
try:
full_resp = client.get("/devices/by-status?status=my", headers=auth_headers(api_token))
assert full_resp.status_code == 200
full_macs = [d["id"] for d in full_resp.json]
assert set(macs).issubset(set(full_macs))
# Page through the full set in halves and confirm the reassembled
# list matches the unpaginated one exactly (no gaps/duplicates).
total = len(full_macs)
half = (total + 1) // 2
page1 = client.get(
f"/devices/by-status?status=my&limit={half}&offset=0",
headers=auth_headers(api_token),
).json
page2 = client.get(
f"/devices/by-status?status=my&limit={total - half}&offset={half}",
headers=auth_headers(api_token),
).json
paged_macs = [d["id"] for d in page1] + [d["id"] for d in page2]
assert paged_macs == full_macs
# Invalid limit/offset are rejected, not silently clamped.
resp_bad_limit = client.get(
"/devices/by-status?status=my&limit=0", headers=auth_headers(api_token)
)
assert resp_bad_limit.status_code == 422
resp_bad_offset = client.get(
"/devices/by-status?status=my&offset=-1", headers=auth_headers(api_token)
)
assert resp_bad_offset.status_code == 422
finally:
for mac in macs:
delete_dummy(client, api_token, mac)
def test_delete_test_devices(client, api_token):
# Delete by MAC
+42 -1
View File
@@ -5,7 +5,7 @@ import pytest
INSTALL_PATH = "/app"
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
from helper import get_setting_value, count_children_by_parent_mac # noqa: E402 [flake8 lint suppression]
from api_server.api_server_start import app # noqa: E402 [flake8 lint suppression]
@@ -79,6 +79,47 @@ def test_graphql_post_devices(client, api_token):
assert isinstance(data["devices"]["count"], int)
def test_graphql_devices_parent_children_count_matches_recount(client, api_token):
"""devParentChildrenCount for every returned device must match an independent
recount of the same response (regression guard for the O(n^2)->O(n) rewrite of
resolve_devices()/count_children_by_parent_mac() in graphql_endpoint.py/helper.py).
Not seeded against a freshly-created fixture pair: table_devices.json (what
resolve_devices() reads) is only refreshed by the periodic update_api() loop,
not synchronously on a POST /device/<mac> create - a create-then-query test
would be flaky against snapshot staleness. Recomputing from the response
itself avoids that while still exercising the real resolver wiring.
"""
query = {
"query": """
{
devices {
devices {
devMac
devParentMAC
devParentChildrenCount
}
count
}
}
"""
}
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
assert resp.status_code == 200
devices = resp.get_json()["data"]["devices"]["devices"]
expected_counts = count_children_by_parent_mac(
[{"devParentMAC": d["devParentMAC"]} for d in devices]
)
for device in devices:
expected = expected_counts.get((device["devMac"] or "").strip(), 0)
assert device["devParentChildrenCount"] == expected, (
f"devMac={device['devMac']}: expected {expected}, "
f"got {device['devParentChildrenCount']}"
)
# --- SETTINGS TESTS ---
def test_graphql_post_settings(client, api_token):
"""POST /graphql should return settings data"""
+68
View File
@@ -0,0 +1,68 @@
"""
Unit tests for helper.py's count_children_by_parent_mac().
Tests verify the O(n) bucket-count replacement for the old per-device
O(n) scan (get_number_of_children) produces identical results.
"""
import sys
import os
INSTALL_PATH = os.getenv('NETALERTX_APP', '/app')
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
from helper import count_children_by_parent_mac # noqa: E402
class TestCountChildrenByParentMac:
"""Test suite for count_children_by_parent_mac()"""
def test_empty_list_returns_empty_dict(self):
assert count_children_by_parent_mac([]) == {}
def test_parent_with_two_children_and_a_grandchild(self):
devices = [
{"devMac": "aa:aa:aa:aa:aa:aa", "devParentMAC": ""},
{"devMac": "bb:bb:bb:bb:bb:bb", "devParentMAC": "aa:aa:aa:aa:aa:aa"},
{"devMac": "cc:cc:cc:cc:cc:cc", "devParentMAC": "aa:aa:aa:aa:aa:aa"},
{"devMac": "dd:dd:dd:dd:dd:dd", "devParentMAC": "bb:bb:bb:bb:bb:bb"},
]
counts = count_children_by_parent_mac(devices)
assert counts["aa:aa:aa:aa:aa:aa"] == 2
assert counts["bb:bb:bb:bb:bb:bb"] == 1
# leaf devices are absent from the dict, not present with a 0 value
assert "cc:cc:cc:cc:cc:cc" not in counts
assert "dd:dd:dd:dd:dd:dd" not in counts
def test_missing_or_empty_parent_mac_excluded(self):
devices = [
{"devMac": "aa:aa:aa:aa:aa:aa", "devParentMAC": ""},
{"devMac": "bb:bb:bb:bb:bb:bb"}, # devParentMAC key absent entirely
]
assert count_children_by_parent_mac(devices) == {}
def test_result_independent_of_input_order(self):
devices = [
{"devMac": "bb:bb:bb:bb:bb:bb", "devParentMAC": "aa:aa:aa:aa:aa:aa"},
{"devMac": "aa:aa:aa:aa:aa:aa", "devParentMAC": ""},
{"devMac": "cc:cc:cc:cc:cc:cc", "devParentMAC": "aa:aa:aa:aa:aa:aa"},
]
assert count_children_by_parent_mac(devices) == count_children_by_parent_mac(
list(reversed(devices))
)
def test_lookup_uses_stripped_devmac_like_original(self):
# Caller strips devMac before lookup (graphql_endpoint.py); the count
# dict's keys must match that stripped form for the lookup to hit.
devices = [
{"devMac": "aa:aa:aa:aa:aa:aa", "devParentMAC": ""},
{"devMac": "bb:bb:bb:bb:bb:bb", "devParentMAC": " aa:aa:aa:aa:aa:aa "},
]
counts = count_children_by_parent_mac(devices)
assert counts["aa:aa:aa:aa:aa:aa".strip()] == 1