Merge pull request #1739 from netalertx/copilot/fix-nic-child-relationship-issue

Fix: NIC child presence no longer forces a directly-detected parent offline (#1736)
This commit is contained in:
Jokob @NetAlertX authored and GitHub committed 2026-08-13 19:20:28 +10:00
commit f14364335b
9 files changed
+432 -2

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
---
name: logging-standards
description: Logging conventions for NetAlertX backend Python code. Use this when adding, modifying, or reviewing log statements.
---
# Logging Standards
## Import
```python
from logger import mylog
```
Never import `logging` directly in application code. Use `mylog` exclusively.
## Function Signature
```python
mylog(level, message_or_list)
```
`message_or_list` can be a plain string or a list of values — the logger joins them with spaces.
## Log Levels
Levels from least to most verbose (higher number = more output):
| Level | Numeric | When to use |
|-------|---------|-------------|
| `"none"` | 0 | Always printed regardless of user setting. Reserve for startup, fatal errors, and one-time permission checks. |
| `"minimal"` | 1 | Important state transitions visible by default (scan start/end, plugin finish, restart). |
| `"verbose"` | 2 | Informational progress — what the system is doing without clutter (e.g. "No changes to report"). |
| `"debug"` | 3 | Developer-level detail — loop decisions, branch taken, counts. |
| `"trace"` | 4 | Granular per-item tracing — individual device rows, SQL queries, raw values. |
## Message Format
Prefix every message with a `[Module]` tag matching the file/function context:
```python
mylog("debug", [f"[device_handling] Processing MAC: {mac}"])
mylog("verbose", ["[Scan] Scan complete — devices updated:", count])
```
Use `f-strings` inside a list element, not string concatenation:
```python
# Correct
mylog("debug", [f"[NIC] parent={parent_mac} nic_online={nic_online}"])
# Avoid
mylog("debug", "[NIC] parent=" + parent_mac + " nic_online=" + str(nic_online))
```
## Timestamp
`mylog` / `file_print` prepend the current local-timezone time automatically via `timeNowTZ`. Do **not** add a timestamp manually inside the message.
## What NOT to Log
- Do not log raw user input without sanitization.
- Do not log full SQL query strings at `"none"` or `"minimal"` — use `"trace"` at most.
- Do not use `print()` in server code — use `mylog`. `file_print` is an internal helper; do not call it directly.
## Log File Location
Written to `{logPath}/app.log` (`logPath` from `const.py``/tmp/logs` at runtime). Do not hardcode this path.
+61
View File
@@ -0,0 +1,61 @@
---
name: pr-analysis
description: How to analyze and respond to GitHub PR review comments in NetAlertX. Use this whenever you are addressing PR feedback, review threads, or inline code comments.
---
# PR Analysis
## Before Writing Any Test Code — Non-Negotiable Checklist
Run through this before creating or editing any file under `test/`:
1. **Helpers first:** Check `test/db_test_helpers.py` for existing factories (`make_db`, `make_device_dict`, `insert_device_from_dict`, `DummyDB`). Use them. If what you need doesn't exist, add it there — never define it locally in the test file.
2. **MAC literals must be lowercase:** Every MAC string in fixtures, parametrize, assertions, docstrings, and comments must be lowercase hex (e.g. `aa:bb:cc:dd:ee:01`). No exceptions.
3. **Test file location:** Place tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Never put test files directly in `test/`.
4. **No inline imports:** All imports at the top of the file.
## Before Acting on Any PR Comment
1. Load `code-standards` skill — all code changes must comply with it before replying.
2. Load `testing-workflow` skill — any test additions or changes must follow it.
3. Load any domain-specific skill relevant to the files being changed (e.g. `database-patterns` for DB writes, `settings` for config).
## Comment Classification
For each comment, determine:
| Type | Action |
|------|--------|
| Request for code change | Make the change, validate it, then reply with the short commit hash |
| Question about code | Reply with a concise answer (no restatement of the question) |
| Suggestion / feedback | Decide if it is actionable. If yes, act and reply. If not, do not reply. |
| General / praise | Do not reply. |
## Acting on Comments — Step by Step
1. **Identify all actionable comments** before touching any file.
2. **Load relevant skills** to understand conventions that apply.
3. **Prepare a plan** — list each file and the exact change required.
4. **Make changes one comment at a time** — keep commits focused.
5. **Run targeted tests** after each change (`testing-workflow` skill).
6. **Reply** only after the commit is pushed. Include the short SHA.
## Reply Guidelines
- Be concise. Do not summarize or restate the original comment.
- State what was done and (optionally) why.
- Include the short commit hash when relevant.
- Do not thank or compliment the reviewer.
## What to Check After Every Batch of Changes
- **MAC literals lowercase** — grep for uppercase hex in every changed test file: `grep -Pn '[0-9A-F]{2}:[0-9A-F]' test/` must be empty.
- **No local DB helpers** — no `DummyDB`, `make_db`, or inline DDL defined outside `test/db_test_helpers.py`.
- No inline imports — all imports at the top of the file.
- Tests live under a subdirectory of `test/` matching the source path, not in `test/` root.
## Stacked / Base-Branch Issues
When a PR targets a non-default branch (e.g. `next_release`):
- Do **not** retarget the branch yourself; note it in a reply so the author can do it from the GitHub UI.
- Check CI failures on the **base branch** first before checking your branch.
+2
View File
@@ -24,6 +24,8 @@ Skills with the same purpose exist in both, sometimes under different names and
| Project navigation | `project-navigation` | `project-navigation` | Copilot version has full path tables and env vars; Gemini version is a brief reference |
| Plugin dev | `plugin-development` | `plugin-run-development` | Copilot version is comprehensive (data contract, phases, formats); Gemini version is a brief checklist pointing to `docs/PLUGINS_DEV.md` |
| Devcontainer | `devcontainer-management` | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | Gemini combines into one (uses `docker exec`); Copilot splits into 3 focused skills |
| PR review | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist |
| Logging | `logging-standards` | `logging-standards` | `mylog` levels, message format, what not to log |
---
+15
View File
@@ -97,6 +97,21 @@ 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.
## MAC Literals in Tests — ALWAYS Lowercase
**MANDATORY:** Every MAC address literal used in test fixtures, parametrize decorators, assertions, or comments must be lowercase hex:
```python
# Correct
make_device_dict("aa:bb:cc:dd:ee:01", ...)
# Wrong — will be rejected in review
make_device_dict("AA:BB:CC:DD:EE:01", ...)
make_device_dict("Aa:Bb:Cc:Dd:Ee:01", ...)
```
This applies to hardcoded strings in `assert`, `pytest.mark.parametrize`, docstrings, and comments too. There are no exceptions.
## Path Hygiene
- Use environment variables for runtime paths
+67
View File
@@ -0,0 +1,67 @@
---
name: netalertx-logging-standards
description: Logging conventions for NetAlertX backend Python code. Use this when adding, modifying, or reviewing log statements.
---
# Logging Standards
## Import
```python
from logger import mylog
```
Never import `logging` directly in application code. Use `mylog` exclusively.
## Function Signature
```python
mylog(level, message_or_list)
```
`message_or_list` can be a plain string or a list of values — the logger joins them with spaces.
## Log Levels
Levels from least to most verbose (higher number = more output):
| Level | Numeric | When to use |
|-------|---------|-------------|
| `"none"` | 0 | Always printed regardless of user setting. Reserve for startup, fatal errors, and one-time permission checks. |
| `"minimal"` | 1 | Important state transitions visible by default (scan start/end, plugin finish, restart). |
| `"verbose"` | 2 | Informational progress — what the system is doing without clutter (e.g. "No changes to report"). |
| `"debug"` | 3 | Developer-level detail — loop decisions, branch taken, counts. |
| `"trace"` | 4 | Granular per-item tracing — individual device rows, SQL queries, raw values. |
## Message Format
Prefix every message with a `[Module]` tag matching the file/function context:
```python
mylog("debug", [f"[device_handling] Processing MAC: {mac}"])
mylog("verbose", ["[Scan] Scan complete — devices updated:", count])
```
Use `f-strings` inside a list element, not string concatenation:
```python
# Correct
mylog("debug", [f"[NIC] parent={parent_mac} nic_online={nic_online}"])
# Avoid
mylog("debug", "[NIC] parent=" + parent_mac + " nic_online=" + str(nic_online))
```
## Timestamp
`mylog` / `file_print` prepend the current local-timezone time automatically via `timeNowTZ`. Do **not** add a timestamp manually inside the message.
## What NOT to Log
- Do not log raw user input without sanitization.
- Do not log full SQL query strings at `"none"` or `"minimal"` — use `"trace"` at most.
- Do not use `print()` in server code — use `mylog`. `file_print` is an internal helper; do not call it directly.
## Log File Location
Written to `{logPath}/app.log` (`logPath` from `const.py``/tmp/logs` at runtime). Do not hardcode this path.
+62
View File
@@ -0,0 +1,62 @@
---
name: netalertx-pr-analysis
description: How to analyze and respond to GitHub PR review comments in NetAlertX. Use this whenever you are addressing PR feedback, review threads, or inline code comments.
---
# PR Analysis
## Before Writing Any Test Code — Non-Negotiable Checklist
Run through this before creating or editing any file under `test/`:
1. **Helpers first:** Check `test/db_test_helpers.py` for existing factories (`make_db`, `make_device_dict`, `insert_device_from_dict`, `DummyDB`). Use them. If what you need doesn't exist, add it there — never define it locally in the test file.
2. **MAC literals must be lowercase:** Every MAC string in fixtures, `parametrize`, assertions, docstrings, and comments must be lowercase hex (e.g. `aa:bb:cc:dd:ee:01`). No exceptions.
3. **Test file location:** Place tests under a subdirectory of `test/` that mirrors the source path (e.g. `test/scan/` for `server/scan/`). Never put test files directly in `test/`.
4. **No inline imports:** All imports at the top of the file.
## Before Acting on Any PR Comment
1. Load `code-standards` skill — all code changes must comply with it before replying.
2. Load `testing-workflow` skill — any test additions or changes must follow it.
3. Load any domain-specific skill relevant to the files being changed (e.g. `database-patterns` for DB writes, `settings-management` for config).
## Comment Classification
For each comment, determine:
| Type | Action |
|------|--------|
| Request for code change | Make the change, validate it, then reply with the short commit hash |
| Question about code | Reply with a concise answer (no restatement of the question) |
| Suggestion / feedback | Decide if it is actionable. If yes, act and reply. If not, do not reply. |
| General / praise | Do not reply. |
## Acting on Comments — Step by Step
1. **Identify all actionable comments** before touching any file.
2. **Load relevant skills** to understand conventions that apply.
3. **Prepare a plan** — list each file and the exact change required.
4. **Make changes one comment at a time** — keep commits focused.
5. **Run targeted tests** after each change (`testing-workflow` skill).
6. **Reply** only after the commit is pushed via `report_progress`. Include the short SHA.
## Reply Guidelines
- Be concise. Do not summarize or restate the original comment.
- State what was done and (optionally) why.
- Include the short commit hash when relevant.
- Do not thank or compliment the reviewer.
## What to Check After Every Batch of Changes
- **MAC literals lowercase** — grep for uppercase hex in every changed test file: `grep -Pn '[0-9A-F]{2}:[0-9A-F]' test/` must be empty.
- **No local DB helpers** — no `DummyDB`, `make_db`, or inline DDL defined outside `test/db_test_helpers.py`.
- No inline imports — all imports at the top of the file.
- Tests live under a subdirectory of `test/` matching the source path, not in `test/` root.
- Secret scan (`runtime-tools-secret_scanning`) before committing.
## Stacked / Base-Branch Issues
When a PR targets a non-default branch (e.g. `next_release`):
- Do **not** retarget the branch yourself; note it in a reply so the author can do it from the GitHub UI.
- Check CI failures on the **base branch** first before checking your branch.
+2
View File
@@ -24,6 +24,8 @@ Skills with the same purpose exist in both, sometimes under different names and
| Project navigation | `project-navigation` | `project-navigation` | Copilot version has full path tables and env vars; Gemini version is a brief reference |
| Plugin dev | `plugin-run-development` | `plugin-development` | Copilot version is comprehensive (data contract, phases, formats); Gemini version is a brief checklist pointing to `docs/PLUGINS_DEV.md` |
| Devcontainer | `devcontainer-services` + `devcontainer-setup` + `devcontainer-configs` | `devcontainer-management` | Copilot splits into 3 focused skills; Gemini combines into one (uses `docker exec`) |
| PR review | `pr-analysis` | `pr-analysis` | How to classify and respond to PR comments; pre-flight skill loading checklist |
| Logging | `logging-standards` | `logging-standards` | `mylog` levels, message format, what not to log |
---
+10 -2
View File
@@ -1270,9 +1270,17 @@ def update_devPresentLastScan_based_on_nics(db):
if nics:
nic_statuses = [nic.get("devPresentLastScan") == 1 for nic in nics]
if req_all:
new_present = int(all(nic_statuses))
nic_online = all(nic_statuses)
else:
new_present = int(any(nic_statuses))
nic_online = any(nic_statuses)
if original == 1:
# Parent was directly detected this scan — NIC children cannot
# force it offline. Leave new_present = original (no change).
pass
else:
# Parent was not directly detected — NICs determine presence.
new_present = 1 if nic_online else 0
# Only add update if changed
if original != new_present:
+146
View File
@@ -0,0 +1,146 @@
"""Tests for update_devPresentLastScan_based_on_nics.
Regression coverage for the bug where a parent device with a 'nic' child
relationship had its own directly-detected presence overwritten by the NIC
child's absence, producing an endless one-directional Connected event stream.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import make_db, make_device_dict, insert_device_from_dict, DummyDB
from server.scan import device_handling
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _setup(devices: list[dict]):
"""Return a DummyDB seeded with the given device dicts."""
conn = make_db()
for dev in devices:
insert_device_from_dict(conn, dev)
return DummyDB(conn)
def _present(db: DummyDB, mac: str) -> int:
row = db._conn.execute(
"SELECT devPresentLastScan FROM Devices WHERE devMac = ?", (mac,)
).fetchone()
return row["devPresentLastScan"]
# ---------------------------------------------------------------------------
# Core bug regression: parent directly detected (present=1) + absent NIC child
# ---------------------------------------------------------------------------
class TestNicChildDoesNotForcePresentParentDown:
"""Parent was directly detected this scan; an absent NIC must not override that."""
def test_any_mode_absent_nic_does_not_clear_present_parent(self):
"""Bug: req_all=0, parent present=1, nic present=0 → parent must stay 1."""
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:01", devPresentLastScan=1,
devParentMAC="", devParentRelType="", devReqNicsOnline=0),
make_device_dict("bb:bb:bb:bb:bb:01", devPresentLastScan=0,
devParentMAC="aa:aa:aa:aa:aa:01",
devParentRelType="nic", devReqNicsOnline=0),
])
device_handling.update_devPresentLastScan_based_on_nics(db)
assert _present(db, "aa:aa:aa:aa:aa:01") == 1, (
"Parent directly detected as present must not be forced offline "
"by an absent NIC child."
)
def test_req_all_mode_absent_nic_does_not_clear_present_parent(self):
"""Bug: req_all=1, parent present=1, nic present=0 → parent must stay 1."""
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:02", devPresentLastScan=1,
devParentMAC="", devParentRelType="", devReqNicsOnline=1),
make_device_dict("bb:bb:bb:bb:bb:02", devPresentLastScan=0,
devParentMAC="aa:aa:aa:aa:aa:02",
devParentRelType="nic", devReqNicsOnline=0),
])
device_handling.update_devPresentLastScan_based_on_nics(db)
assert _present(db, "aa:aa:aa:aa:aa:02") == 1
# ---------------------------------------------------------------------------
# NIC can still raise an undetected parent (original=0)
# ---------------------------------------------------------------------------
class TestNicRaisesAbsentParent:
"""NIC children should be able to mark a parent present when it was not seen directly."""
def test_any_mode_online_nic_raises_absent_parent(self):
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:03", devPresentLastScan=0,
devParentMAC="", devParentRelType="", devReqNicsOnline=0),
make_device_dict("bb:bb:bb:bb:bb:03", devPresentLastScan=1,
devParentMAC="aa:aa:aa:aa:aa:03",
devParentRelType="nic", devReqNicsOnline=0),
])
device_handling.update_devPresentLastScan_based_on_nics(db)
assert _present(db, "aa:aa:aa:aa:aa:03") == 1
def test_req_all_mode_all_nics_online_raises_absent_parent(self):
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:04", devPresentLastScan=0,
devParentMAC="", devParentRelType="", devReqNicsOnline=1),
make_device_dict("bb:bb:bb:bb:bb:04", devPresentLastScan=1,
devParentMAC="aa:aa:aa:aa:aa:04",
devParentRelType="nic", devReqNicsOnline=0),
make_device_dict("cc:cc:cc:cc:cc:04", devPresentLastScan=1,
devParentMAC="aa:aa:aa:aa:aa:04",
devParentRelType="nic", devReqNicsOnline=0),
])
device_handling.update_devPresentLastScan_based_on_nics(db)
assert _present(db, "aa:aa:aa:aa:aa:04") == 1
def test_req_all_mode_partial_nics_does_not_raise_absent_parent(self):
"""req_all=1: if not all NICs are online, an absent parent stays absent."""
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:05", devPresentLastScan=0,
devParentMAC="", devParentRelType="", devReqNicsOnline=1),
make_device_dict("bb:bb:bb:bb:bb:05", devPresentLastScan=1,
devParentMAC="aa:aa:aa:aa:aa:05",
devParentRelType="nic", devReqNicsOnline=0),
make_device_dict("cc:cc:cc:cc:cc:05", devPresentLastScan=0,
devParentMAC="aa:aa:aa:aa:aa:05",
devParentRelType="nic", devReqNicsOnline=0),
])
device_handling.update_devPresentLastScan_based_on_nics(db)
assert _present(db, "aa:aa:aa:aa:aa:05") == 0
def test_any_mode_all_nics_absent_leaves_parent_absent(self):
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:06", devPresentLastScan=0,
devParentMAC="", devParentRelType="", devReqNicsOnline=0),
make_device_dict("bb:bb:bb:bb:bb:06", devPresentLastScan=0,
devParentMAC="aa:aa:aa:aa:aa:06",
devParentRelType="nic", devReqNicsOnline=0),
])
device_handling.update_devPresentLastScan_based_on_nics(db)
assert _present(db, "aa:aa:aa:aa:aa:06") == 0
# ---------------------------------------------------------------------------
# No NIC children → no change regardless of presence
# ---------------------------------------------------------------------------
class TestNoNicChildren:
def test_parent_with_no_nics_unchanged(self):
db = _setup([
make_device_dict("aa:aa:aa:aa:aa:07", devPresentLastScan=1,
devParentMAC="", devParentRelType="", devReqNicsOnline=0),
make_device_dict("aa:aa:aa:aa:aa:08", devPresentLastScan=0,
devParentMAC="", devParentRelType="", devReqNicsOnline=0),
])
updated = device_handling.update_devPresentLastScan_based_on_nics(db)
assert updated == 0
assert _present(db, "aa:aa:aa:aa:aa:07") == 1
assert _present(db, "aa:aa:aa:aa:aa:08") == 0