mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-13 23:03:05 -04:00
FE+BE: performance improvements
This commit is contained in:
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.
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(); });
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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", "")))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user