diff --git a/.gemini/skills/logging-standards/SKILL.md b/.gemini/skills/logging-standards/SKILL.md new file mode 100644 index 000000000..8395586b0 --- /dev/null +++ b/.gemini/skills/logging-standards/SKILL.md @@ -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. diff --git a/.gemini/skills/pr-analysis/SKILL.md b/.gemini/skills/pr-analysis/SKILL.md new file mode 100644 index 000000000..be9b408eb --- /dev/null +++ b/.gemini/skills/pr-analysis/SKILL.md @@ -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. diff --git a/.gemini/skills/skills-index/SKILL.md b/.gemini/skills/skills-index/SKILL.md index d5272a5d9..ea21fd9c0 100644 --- a/.gemini/skills/skills-index/SKILL.md +++ b/.gemini/skills/skills-index/SKILL.md @@ -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 | --- diff --git a/.github/skills/code-standards/SKILL.md b/.github/skills/code-standards/SKILL.md index 83c52d0a8..c81db6414 100644 --- a/.github/skills/code-standards/SKILL.md +++ b/.github/skills/code-standards/SKILL.md @@ -24,6 +24,7 @@ description: NetAlertX coding standards and conventions. Use this when writing c - follow existing code style and structure, and ensure backward compatibility with existing installations when submitting PRs - 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` ## File Length @@ -97,6 +98,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 diff --git a/.github/skills/logging-standards/SKILL.md b/.github/skills/logging-standards/SKILL.md new file mode 100644 index 000000000..8cbc834ca --- /dev/null +++ b/.github/skills/logging-standards/SKILL.md @@ -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. diff --git a/.github/skills/pr-analysis/SKILL.md b/.github/skills/pr-analysis/SKILL.md new file mode 100644 index 000000000..904d9f12a --- /dev/null +++ b/.github/skills/pr-analysis/SKILL.md @@ -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. diff --git a/.github/skills/skills-overview/SKILL.md b/.github/skills/skills-overview/SKILL.md index f526459ec..e02b5f96f 100644 --- a/.github/skills/skills-overview/SKILL.md +++ b/.github/skills/skills-overview/SKILL.md @@ -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 | --- diff --git a/docs/PLUGINS_DEV_DATA_CONTRACT.md b/docs/PLUGINS_DEV_DATA_CONTRACT.md index e0ab45abf..2765b39bb 100644 --- a/docs/PLUGINS_DEV_DATA_CONTRACT.md +++ b/docs/PLUGINS_DEV_DATA_CONTRACT.md @@ -32,17 +32,19 @@ plugin_objects.add_object( objectSecondaryId="192.168.1.1", DateTime="2023-01-02 15:56:30", watchedValue1="online", - watchedValue2=None, - watchedValue3=None, - watchedValue4=None, + watchedValue2="null", + watchedValue3="null", + watchedValue4="null", Extra="Additional data", ForeignKey="aa:bb:cc:dd:ee:ff", - helpVal1=None, - helpVal2=None, - helpVal3=None, - helpVal4=None + helpVal1="null", + helpVal2="null", + helpVal3="null", + helpVal4="null" ) +Please note unavailable values need to be set to `"null"` + # Write results (handles formatting, sanitization, and file creation) plugin_objects.write_result_file() ``` @@ -124,6 +126,33 @@ This allows NetAlertX to: - Send notifications when the parent device is involved - Link events across plugins + +### Target columns for config.json + +Typically, target columns would be pointing to the `CurrentScan` table, so, e.g. `scanSite` or `scanLastIP`. This mapping is defined in the `config.json` of the given plugin. As of writing this article, the `CurrentScan` table is defined as follows: + +```sql +CREATE TABLE CurrentScan ( + scanMac STRING(50) NOT NULL COLLATE NOCASE, + scanLastIP STRING(50) NOT NULL COLLATE NOCASE, + scanVendor STRING(250), + scanSourcePlugin STRING(10), + scanName STRING(250), + scanLastQuery STRING(250), + scanLastConnection STRING(250), + scanSyncHubNode STRING(50), + scanSite STRING(250), + scanSSID STRING(250), + scanVlan STRING(250), + scanParentMAC STRING(250), + scanParentPort STRING(250), + scanType STRING(250), + UNIQUE(scanMac) +) +``` + +As the documentation might become outdated, it's good practice to check the latest definition of the `CurrentScan` table in the `app.sql` script in the code base. + ## Examples ### Valid Data (9 columns, minimal) diff --git a/front/js/device-columns.js b/front/js/device-columns.js index 6881b4f0f..34022972b 100644 --- a/front/js/device-columns.js +++ b/front/js/device-columns.js @@ -50,6 +50,7 @@ const DEVICE_COLUMN_FIELDS = [ "devPrimaryIPv4", // 31 Device_TableHead_IPv4 "devPrimaryIPv6", // 32 Device_TableHead_IPv6 "devFlapping", // 33 Device_TableHead_Flapping + "devComments", // 34 Device_TableHead_Comments ]; // Named index constants — eliminates all mapIndx(N) magic numbers. @@ -128,6 +129,7 @@ const COLUMN_NAME_MAP = { "Device_TableHead_IPv4": "devPrimaryIPv4", "Device_TableHead_IPv6": "devPrimaryIPv6", "Device_TableHead_Flapping": "devFlapping", + "Device_TableHead_Comments": "devComments", }; console.log("init device-columns.js"); diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index d831d6f8c..de0545632 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "مخطط الاتصال", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "تنبيه عدم الاتصال", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "الأجهزة المتصلة", "Device_TableHead_CustomProps": "خصائص مخصصة", "Device_TableHead_FQDN": "اسم النطاق الكامل", @@ -830,4 +831,4 @@ "settings_system_label": "نظام", "settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. لم يتم إجراء التحقق.", "test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 5e0e34cac..d1996b0b6 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Dispositius detectats", "Device_Shortcut_Unstable": "Inestable", "Device_TableHead_AlertDown": "Cancel·lar alerta", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Props / Accions", "Device_TableHead_FQDN": "FQDN", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index 262f6846f..3344c396f 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Výskyt zařízení", "Device_Shortcut_Unstable": "Nestabilní", "Device_TableHead_AlertDown": "Upozornění na nedostupnost", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Spojení", "Device_TableHead_CustomProps": "Vlastnosti / Akce", "Device_TableHead_FQDN": "FQDN", @@ -784,13 +785,13 @@ "device_history_empty_state": "V rámci nastaveného okna uchovávání nebyly zaznamenány změny žádné z vlastností.", "device_history_tab_title": "Seznam změn", "device_history_table_title_changes": "Změny", + "devices_old": "Znovunačítání…", "gen_actions": "Akce", "gen_content": "Obsah", + "gen_device": "Zařízení", "gen_level": "Stupeň", "gen_read": "Číst", "gen_timestamp": "Časové razítko", - "devices_old": "Znovunačítání…", - "gen_device": "Zařízení", "general_event_description": "Událost kterou jste spustili může zabrat delší dobu, než budou procesy na pozadí dokončeny. Vykonávání skončilo jakmile se níže uvedená fronta vykonávání vyprázdní (pokud narazíte na problémy, podívejte se do záznamu chyb).

Fronta vykonávání:", "general_event_title": "Vykonávání jednorázové události", "go_to_device_event_tooltip": "Přejít na zařízení", @@ -830,4 +831,4 @@ "settings_system_label": "Systém", "settings_update_item_warning": "Zaktualizujte níže uvedenou hodnotu. Dávejte pozor, ať je dodržen předchozí formát. Ověřování správnosti není prováděno.", "test_event_tooltip": "Než budete svá nastavení zkoušet, nejprve vámi provedené změny uložte." -} +} \ No newline at end of file diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index e651b8d47..f5816b8a0 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -237,6 +237,7 @@ "Device_Shortcut_OnlineChart": "Gerätepräsenz im Laufe der Zeit", "Device_Shortcut_Unstable": "Instabil", "Device_TableHead_AlertDown": "Alarm aus", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Verbindungen", "Device_TableHead_CustomProps": "Eigenschaften / Aktionen", "Device_TableHead_FQDN": "FQDN", @@ -903,4 +904,4 @@ "settings_system_label": "System", "settings_update_item_warning": "", "test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen." -} +} \ No newline at end of file diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 41abd6ab4..097a96a98 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Device presence", "Device_Shortcut_Unstable": "Unstable", "Device_TableHead_AlertDown": "Alert Down", + "Device_TableHead_Comments": "Comments", "Device_TableHead_Connected_Devices": "Connections", "Device_TableHead_CustomProps": "Props / Actions", "Device_TableHead_FQDN": "FQDN", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 3c6463b11..317f2a3f8 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -235,6 +235,7 @@ "Device_Shortcut_OnlineChart": "Presencia del dispositivo a lo largo del tiempo", "Device_Shortcut_Unstable": "Inestable", "Device_TableHead_AlertDown": "Alerta desactivada", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Conexiones", "Device_TableHead_CustomProps": "Propiedades / Acciones", "Device_TableHead_FQDN": "FQDN", @@ -901,4 +902,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualice el valor a continuación. Tenga cuidado de seguir el formato anterior. O la validación no se realiza.", "test_event_tooltip": "Guarda tus cambios antes de probar nuevos ajustes." -} +} \ No newline at end of file diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 53a999336..0835e9dad 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", diff --git a/front/php/templates/language/fi_fi.json b/front/php/templates/language/fi_fi.json index 1f93b730e..3e01e76d6 100644 --- a/front/php/templates/language/fi_fi.json +++ b/front/php/templates/language/fi_fi.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 0663e054c..300815994 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Présence de l'appareil", "Device_Shortcut_Unstable": "Instable", "Device_TableHead_AlertDown": "Alerter si En panne", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Champs / Actions", "Device_TableHead_FQDN": "Nom de domaine FQDN", @@ -830,4 +831,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} +} \ No newline at end of file diff --git a/front/php/templates/language/he_il.json b/front/php/templates/language/he_il.json index 1f93b730e..3e01e76d6 100644 --- a/front/php/templates/language/he_il.json +++ b/front/php/templates/language/he_il.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", diff --git a/front/php/templates/language/id_id.json b/front/php/templates/language/id_id.json index 1f93b730e..3e01e76d6 100644 --- a/front/php/templates/language/id_id.json +++ b/front/php/templates/language/id_id.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index ef0b2fb1d..70b20dbba 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Presenza dispositivo", "Device_Shortcut_Unstable": "Instabile", "Device_TableHead_AlertDown": "Avviso disconnessione", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Connessioni", "Device_TableHead_CustomProps": "Proprietà/Azioni", "Device_TableHead_FQDN": "FQDN", @@ -830,4 +831,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index ff8eddcab..033cd4cb6 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "デバイス検出", "Device_Shortcut_Unstable": "不安定", "Device_TableHead_AlertDown": "ダウンアラート", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "接続", "Device_TableHead_CustomProps": "属性 / アクション", "Device_TableHead_FQDN": "FQDN", @@ -830,4 +831,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} +} \ No newline at end of file diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 52c143d24..6c4872ebe 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Enhetens tilstedeværelse", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Tilkoblinger", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", @@ -830,4 +831,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Oppdater verdien nedenfor. Pass på å følge forrige format. Validering etterpå utføres ikke.", "test_event_tooltip": "Lagre endringene først, før du tester innstillingene dine." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index cdc98b53b..76bb705e5 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Obecność urządzenia", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alert niedostępny", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Połączenia", "Device_TableHead_CustomProps": "Właściwości / Akcje", "Device_TableHead_FQDN": "FQDN", @@ -830,4 +831,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Zaktualizuj wartość poniżej. Uważaj, aby zachować poprzedni format. Walidacja nie jest wykonywana.", "test_event_tooltip": "Najpierw zapisz swoje zmiany, zanim przetestujesz ustawienia." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index b7fb0a5a9..1fd679e73 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Presença do dispositivo", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alerta em baixo", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Conexões", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", @@ -830,4 +831,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index 99633533d..931bd9d1d 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Presença do dispositivo", "Device_Shortcut_Unstable": "Instável", "Device_TableHead_AlertDown": "Alerta em baixo", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Conexões", "Device_TableHead_CustomProps": "Propriedades / Ações", "Device_TableHead_FQDN": "FQDN", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 7436a775d..a4a488703 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Присутствие устройств", "Device_Shortcut_Unstable": "Нестабильный", "Device_TableHead_AlertDown": "Оповещение о сост. ВЫКЛ", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Соединения", "Device_TableHead_CustomProps": "Свойства / Действия", "Device_TableHead_FQDN": "FQDN", @@ -830,4 +831,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} +} \ No newline at end of file diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 1f93b730e..3e01e76d6 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index f5a6c9fc0..15ccc2f35 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Cihaz Durumu", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Çalışmama Alarmı", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Bağlantılar", "Device_TableHead_CustomProps": "Özellikler / Eylemler", "Device_TableHead_FQDN": "", @@ -830,4 +831,4 @@ "settings_system_label": "Sistem", "settings_update_item_warning": "", "test_event_tooltip": "" -} +} \ No newline at end of file diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 9b42e6567..bfd006715 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "Наявність пристрою", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Агент Вниз", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Зв'язки", "Device_TableHead_CustomProps": "Реквізит / дії", "Device_TableHead_FQDN": "FQDN", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 1f93b730e..3e01e76d6 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", "Device_TableHead_FQDN": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index a6299fec1..0442ef059 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -233,6 +233,7 @@ "Device_Shortcut_OnlineChart": "设备统计", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "提醒宕机", + "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "链接", "Device_TableHead_CustomProps": "属性", "Device_TableHead_FQDN": "FQDN", @@ -830,4 +831,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} +} \ No newline at end of file diff --git a/install/proxmox/requirements.txt b/install/proxmox/requirements.txt index cabc786b0..8f6b050bf 100755 --- a/install/proxmox/requirements.txt +++ b/install/proxmox/requirements.txt @@ -4,7 +4,7 @@ aiohttp graphene flask flask-cors -unifi-sm-api>=0.2.3 +unifi-sm-api>=0.2.6 tplink-omada-client wakeonlan pycryptodome diff --git a/install/ubuntu24/requirements.txt b/install/ubuntu24/requirements.txt index cabc786b0..8f6b050bf 100755 --- a/install/ubuntu24/requirements.txt +++ b/install/ubuntu24/requirements.txt @@ -4,7 +4,7 @@ aiohttp graphene flask flask-cors -unifi-sm-api>=0.2.3 +unifi-sm-api>=0.2.6 tplink-omada-client wakeonlan pycryptodome diff --git a/requirements.txt b/requirements.txt index 2deb2cbd8..18fe9cbc3 100755 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ aiohttp graphene flask flask-cors -unifi-sm-api>=0.2.3 +unifi-sm-api>=0.2.6 tplink-omada-client wakeonlan pycryptodome diff --git a/server/messaging/reporting.py b/server/messaging/reporting.py index ea9f9a291..cd001b2a7 100755 --- a/server/messaging/reporting.py +++ b/server/messaging/reporting.py @@ -278,7 +278,7 @@ def skip_repeated_notifications(db): AND devLastNotification <>"" AND (strftime("%s", devLastNotification)/60 + devSkipRepeated * 60) > - (strftime('%s','now','localtime')/60 ) + (strftime('%s','now')/60 ) ) """) diff --git a/server/models/notification_instance.py b/server/models/notification_instance.py index 4423a5064..45327659f 100755 --- a/server/models/notification_instance.py +++ b/server/models/notification_instance.py @@ -1,8 +1,10 @@ +import html import json import re import uuid import socket from yattag import indent +from yattag.indentation import XMLTokenError from json2table import convert # Register NetAlertX modules @@ -146,20 +148,7 @@ def create(self, JSON, Extra=""): mail_html, conf.REPORT_DASHBOARD_URL + "/deviceDetails.php?mac=" ) - # Add preheader for inbox preview after all links have been generated. - # Invisible padding prevents email clients from showing the start of the email body. - preheader = " • ".join(preheaders) - - padding = (" ‌ " * 47) - - mail_html = mail_html.replace( - "PREHEADER", - preheader + padding, - ) - - final_html = indent( - mail_html, indentation=" ", newline="\r\n", indent_text=True - ) + final_html = finalize_html(mail_html, preheaders) send_api(self.JSON, final_text, final_html) @@ -335,8 +324,9 @@ def construct_notifications(JSON, section): text = tableTitle + "\n---------\n" # Convert a JSON into an HTML table + html_data = escape_html_rows(jsn) html = convert( - {"data": jsn}, + {"data": html_data}, build_direction=build_direction, table_attributes=table_attributes, ) @@ -398,6 +388,45 @@ def format_table(html, thValue, props, newThValue=""): ) +# ----------------------------------------------------------------------------- +# Escape free-text values before embedding them into notification HTML +def escape_html_rows(rows): + """Return a copy of notification rows with only string values HTML-escaped.""" + return [ + { + key: html.escape(value) if isinstance(value, str) else value + for key, value in row.items() + } + for row in rows + ] + + +# ----------------------------------------------------------------------------- +# Finalize HTML and tolerate pretty-print failures +def finalize_html(mail_html, preheaders): + """Insert an escaped preheader and pretty-print HTML, falling back to raw HTML on XML errors.""" + # Add preheader for inbox preview after all links have been generated. + # Invisible padding prevents email clients from showing the start of the email body. + preheader = " • ".join(html.escape(entry) for entry in preheaders) + padding = (" ‌ " * 47) + + mail_html = mail_html.replace( + "PREHEADER", + preheader + padding, + ) + + try: + return indent( + mail_html, indentation=" ", newline="\r\n", indent_text=True + ) + except XMLTokenError as err: + mylog( + "none", + f"[Notification] Failed to pretty-print HTML report, sending unindented HTML instead: {err}", + ) + return mail_html + + # ----------------------------------------------------------------------------- # Pre-header Preview def build_preheader(tableTitle, jsn, headers): diff --git a/server/plugins/adguard_import/adguard_import.py b/server/plugins/adguard_import/adguard_import.py index 46f4610db..48cf6dade 100644 --- a/server/plugins/adguard_import/adguard_import.py +++ b/server/plugins/adguard_import/adguard_import.py @@ -86,7 +86,7 @@ def main(): raw_clients = clients_json.get("auto_clients", []) or [] # ------------------------------------------- - # Fetch DHCP leases if DHCP enabled + # Fetch DHCP leases & static reservations # ------------------------------------------- dhcp_json = ag_request( "/control/dhcp/status", @@ -94,17 +94,29 @@ def main(): ) dhcp_leases = [] - if dhcp_json and dhcp_json.get("enabled"): - dhcp_leases = dhcp_json.get("leases", []) + static_leases = [] + + if dhcp_json: + dhcp_leases = dhcp_json.get("leases", []) or [] + static_leases = dhcp_json.get("static_leases", []) or [] - # Build MAC lookup table for DHCP + # Build MAC lookup table for DHCP (combining dynamic and static leases) dhcp_mac_map = {} + + # Process dynamic leases first for lease in dhcp_leases: ip = lease.get("ip") mac = lease.get("mac") if ip and mac: dhcp_mac_map[ip] = mac.upper() + # Process static leases (overriding or adding to the map) + for lease in static_leases: + ip = lease.get("ip") + mac = lease.get("mac") + if ip and mac: + dhcp_mac_map[ip] = mac.upper() + # ------------------------------------------- # Process devices # ------------------------------------------- @@ -155,4 +167,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/server/plugins/adguard_import/config.json b/server/plugins/adguard_import/config.json index 66faa600e..b6901ad1a 100644 --- a/server/plugins/adguard_import/config.json +++ b/server/plugins/adguard_import/config.json @@ -490,7 +490,7 @@ "column": "Dummy", "mapped_to_column": "scanSourcePlugin", "mapped_to_column_data": { - "value": "Example Plugin" + "value": "ADGUARDIMP" }, "css_classes": "col-sm-2", "show": false, @@ -501,7 +501,7 @@ "name": [ { "language_code": "en_us", - "string": "ADGUARDIMP" + "string": "Plugin" } ] }, diff --git a/server/plugins/ui_settings/config.json b/server/plugins/ui_settings/config.json index b3cfaaf27..df80b8a31 100755 --- a/server/plugins/ui_settings/config.json +++ b/server/plugins/ui_settings/config.json @@ -446,7 +446,8 @@ "Device_TableHead_Vlan", "Device_TableHead_IPv4", "Device_TableHead_IPv6", - "Device_TableHead_Flapping" + "Device_TableHead_Flapping", + "Device_TableHead_Comments" ], "localized": ["name", "description"], "name": [ diff --git a/server/plugins/unifi_api_import/config.json b/server/plugins/unifi_api_import/config.json index 92ba39aaf..f28466c09 100755 --- a/server/plugins/unifi_api_import/config.json +++ b/server/plugins/unifi_api_import/config.json @@ -692,6 +692,75 @@ } ] }, + { + "column": "helpVal1", + "mapped_to_column": "scanSite", + "css_classes": "col-sm-2", + "show": true, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "Site" + } + ] + }, + { + "column": "helpVal2", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "VLAN ID" + } + ] + }, + { + "column": "helpVal3", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "VLAN name" + } + ] + }, + { + "column": "helpVal4", + "css_classes": "col-sm-2", + "show": false, + "type": "label", + "default_value": "", + "options": [], + "localized": [ + "name" + ], + "name": [ + { + "language_code": "en_us", + "string": "WAN name" + } + ] + }, { "column": "Dummy", "mapped_to_column": "scanSourcePlugin", diff --git a/server/plugins/unifi_api_import/unifi_api_import.py b/server/plugins/unifi_api_import/unifi_api_import.py index b24e7859d..1fe7e00e4 100755 --- a/server/plugins/unifi_api_import/unifi_api_import.py +++ b/server/plugins/unifi_api_import/unifi_api_import.py @@ -1,183 +1,264 @@ #!/usr/bin/env python +import json import os import sys -import json from pytz import timezone from unifi_sm_api.api import SiteManagerAPI -# Define the installation path and extend the system path for plugin imports INSTALL_PATH = os.getenv('NETALERTX_APP', '/app') -sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) +sys.path.extend([ + f"{INSTALL_PATH}/server/plugins", + f"{INSTALL_PATH}/server" +]) -from plugin_helper import Plugin_Objects, decode_settings_base64 # noqa: E402 [flake8 lint suppression] -from logger import mylog, Logger # noqa: E402 [flake8 lint suppression] -from const import logPath # noqa: E402 [flake8 lint suppression] -from helper import get_setting_value # noqa: E402 [flake8 lint suppression] -import conf # noqa: E402 [flake8 lint suppression] +from plugin_helper import Plugin_Objects, decode_settings_base64 # noqa: E402 +from logger import mylog, Logger # noqa: E402 +from const import logPath # noqa: E402 +from helper import get_setting_value # noqa: E402 +import conf # noqa: E402 -# Make sure the TIMEZONE for logging is correct -conf.tz = timezone(get_setting_value('TIMEZONE')) -# Make sure log level is initialized correctly +conf.tz = timezone(get_setting_value('TIMEZONE')) Logger(get_setting_value('LOG_LEVEL')) pluginName = 'UNIFIAPI' -# Define the current path and log file paths -LOG_PATH = logPath + '/plugins' -LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log') -RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log') +RESULT_FILE = os.path.join( + logPath, + 'plugins', + f'last_result.{pluginName}.log' +) -# Initialize the Plugin obj output file plugin_objects = Plugin_Objects(RESULT_FILE) def main(): mylog('verbose', [f'[{pluginName}] In script']) - # Retrieve configuration settings - unifi_sites_configs = get_setting_value('UNIFIAPI_sites') + site_configs = get_setting_value('UNIFIAPI_sites') - mylog('verbose', [f'[{pluginName}] number of unifi_sites_configs: {len(unifi_sites_configs)}']) + mylog( + 'verbose', + [f'[{pluginName}] number of unifi_sites_configs: ' + f'{len(site_configs)}'] + ) - for site_config in unifi_sites_configs: + for site_config in site_configs: + site_config = decode_settings_base64(site_config) - siteDict = decode_settings_base64(site_config) + mylog( + 'verbose', + [f'[{pluginName}] siteDict: {json.dumps(site_config)}'] + ) - mylog('verbose', [f'[{pluginName}] siteDict: {json.dumps(siteDict)}']) - mylog('none', [f'[{pluginName}] Connecting to: {siteDict["UNIFIAPI_site_name"]}']) + mylog( + 'none', + [f'[{pluginName}] Connecting to: ' + f'{site_config["UNIFIAPI_site_name"]}'] + ) api = SiteManagerAPI( - api_key=siteDict["UNIFIAPI_api_key"], - version=siteDict["UNIFIAPI_api_version"], - base_url=siteDict["UNIFIAPI_base_url"], - verify_ssl=siteDict["UNIFIAPI_verify_ssl"] + api_key=site_config["UNIFIAPI_api_key"], + version=site_config["UNIFIAPI_api_version"], + base_url=site_config["UNIFIAPI_base_url"], + verify_ssl=site_config["UNIFIAPI_verify_ssl"] ) - sites_resp = api.get_sites() - sites = sites_resp.get("data", []) + sites = api.get_sites().get("data", []) for site in sites: - - # retrieve data device_data = get_device_data(site, api) - # Process the data into native application tables - if len(device_data) > 0: - - # insert devices into the lats_result.log - for device in device_data: - plugin_objects.add_object( - primaryId = device['dev_mac'], # mac - secondaryId = device['dev_ip'], # IP - watched1 = device['dev_name'], # name - watched2 = device['dev_type'], # device_type (AP/Switch etc) - watched3 = device['dev_connected'], # connectedAt or empty - watched4 = device['dev_parent_mac'], # parent_mac or "internet" - extra = '', - foreignKey = device['dev_mac'] - ) - - mylog('verbose', [f'[{pluginName}] New entries: "{len(device_data)}"']) - - # log result + if not device_data: + continue + + site_name = ( + site.get("name") + or site_config.get("UNIFIAPI_site_name") + ) + + for device in device_data: + plugin_objects.add_object( + primaryId=device["dev_mac"], + secondaryId=device["dev_ip"], + watched1=device["dev_name"], + watched2=device["dev_type"], + watched3=device["dev_connected"], + watched4=device["dev_parent_mac"], + extra="", + foreignKey=device["dev_mac"], + helpVal1=site_name, + helpVal2=device["dev_vlan_id"], + helpVal3=device["dev_vlan_name"], + helpVal4=device["dev_wan_name"] + ) + + mylog( + 'verbose', + [f'[{pluginName}] New entries: "{len(device_data)}"'] + ) + plugin_objects.write_result_file() return 0 -# retrieve data def get_device_data(site, api): - device_data = [] - - mylog('verbose', [f'[{pluginName}] Site: {site} ']) site_id = site["id"] site_name = site.get("name", "Unnamed Site") - mylog('verbose', [f'[{pluginName}] Site: {site_name} ({site_id})']) + mylog( + 'verbose', + [f'[{pluginName}] Site: {site_name} ({site_id})'] + ) + + # ------------------------------------------------------------------------- + # Networks + # ------------------------------------------------------------------------- + + networks_resp = api.get_networks(site_id) + networks = networks_resp.get("data", []) + + mylog( + 'trace', + [f'[{pluginName}] Site: {site_name} networks: ' + f'{json.dumps(networks_resp, indent=2)}'] + ) + + network_lookup = { + network["id"]: network + for network in networks + if network.get("id") + } + + default_network = next( + ( + network + for network in networks + if network.get("default") is True + ), + None + ) + + # ------------------------------------------------------------------------- + # WiFi broadcasts + # ------------------------------------------------------------------------- + + wifi_broadcasts_resp = api.get_wifi_broadcasts(site_id) + wifi_broadcasts = wifi_broadcasts_resp.get("data", []) + + mylog( + 'verbose', + [f'[{pluginName}] WIFI BROADCASTS: ' + f'{json.dumps(wifi_broadcasts_resp, indent=2)}'] + ) + + wifi_lookup = { + wifi["name"]: wifi + for wifi in wifi_broadcasts + if wifi.get("name") + } + + # ------------------------------------------------------------------------- + # WANs + # ------------------------------------------------------------------------- + + wans_resp = api.get_wans(site_id) + + mylog( + 'trace', + [f'[{pluginName}] Site: {site_name} WANs: ' + f'{json.dumps(wans_resp, indent=2)}'] + ) + + # The API exposes WAN definitions, but does not provide a client/device + # -> WAN association in the responses currently supported here. + + # ------------------------------------------------------------------------- + # UniFi devices + # ------------------------------------------------------------------------- + + devices_resp = api.get_unifi_devices(site_id) + devices = devices_resp.get("data", []) + + mylog( + 'trace', + [f'[{pluginName}] Site: {site_name} UniFi devices: ' + f'{json.dumps(devices_resp, indent=2)}'] + ) + + device_id_to_mac = { + device["id"]: device.get("macAddress", "") + for device in devices + if device.get("id") + } + + def resolve_parent_mac(uplink_device_id): + if not uplink_device_id: + return "internet" - # --- Devices --- - unifi_devices_resp = api.get_unifi_devices(site_id) - unifi_devices = unifi_devices_resp.get("data", []) - mylog('trace', [f'[{pluginName}] Site: {site_name} unifi devices: {json.dumps(unifi_devices_resp, indent=2)}']) + return device_id_to_mac.get(uplink_device_id, "Unknown") - # --- Clients --- - clients_resp = api.get_clients(site_id) - clients = clients_resp.get("data", []) - mylog('trace', [f'[{pluginName}] Site: {site_name} clients: {json.dumps(clients_resp, indent=2)}']) - - # Build a lookup for devices by their 'id' to find parent MAC easily - device_id_to_mac = {} - for dev in unifi_devices: - if "id" not in dev: - mylog("verbose", [f"[{pluginName}] Skipping device without 'id': {json.dumps(dev)}"]) - continue - device_id_to_mac[dev["id"]] = dev.get("macAddress", "") - - # Helper to resolve uplinkDeviceId to parent MAC, or "internet" if no uplink - def resolve_parent_mac(uplink_id): - if not uplink_id: - return "internet" - return device_id_to_mac.get(uplink_id, "Unknown") - - # Process Unifi devices - for device in unifi_devices: - dev_mac = device.get('macAddress', '') - dev_ip = device.get('ipAddress', '') - dev_name = device.get('name', '') - # Determine device_type based on features and type - # If device has "accessPoint" feature => type "AP" - # Else if "switching" feature => type "Switch" - # fallback to "Unknown" - features = device.get('features', []) - if 'accessPoint' in features: - device_type = 'AP' - elif 'switching' in features: - device_type = 'Switch' - else: - device_type = 'Unknown' + device_data = [] - dev_type = device_type - # No connectedAt for devices, so empty - dev_connected = '' + # ------------------------------------------------------------------------- + # UniFi infrastructure devices + # ------------------------------------------------------------------------- - uplinkDeviceId = device.get('uplinkDeviceId', '') - dev_parent_mac = resolve_parent_mac(uplinkDeviceId) + for device in devices: + features = device.get("features", []) + + if "accessPoint" in features: + device_type = "AP" + elif "switching" in features: + device_type = "Switch" + else: + device_type = "Unknown" device_data.append({ - "dev_mac": dev_mac, - "dev_ip": dev_ip, - "dev_name": dev_name, - "dev_type": dev_type, - "dev_connected": dev_connected, - "dev_parent_mac": dev_parent_mac + "dev_mac": device.get("macAddress", ""), + "dev_ip": device.get("ipAddress", ""), + "dev_name": device.get("name", ""), + "dev_type": device_type, + "dev_connected": "", + "dev_parent_mac": resolve_parent_mac( + device.get("uplinkDeviceId") + ), + "dev_vlan_id": "null", + "dev_vlan_name": "null", + "dev_wan_name": "null" }) - # Process Clients (child devices connected to APs or switches) - for client in clients: - dev_mac = client.get('macAddress', '') - dev_ip = client.get('ipAddress', '') - dev_name = client.get('name', '') - device_type = "" - - dev_type = device_type - dev_connected = client.get('connectedAt', '') + # ------------------------------------------------------------------------- + # Clients + # ------------------------------------------------------------------------- - uplinkDeviceId = client.get('uplinkDeviceId', '') - dev_parent_mac = resolve_parent_mac(uplinkDeviceId) + clients_resp = api.get_clients(site_id) + clients = clients_resp.get("data", []) - device_data.append({ - "dev_mac": dev_mac, - "dev_ip": dev_ip, - "dev_name": dev_name, - "dev_type": dev_type, - "dev_connected": dev_connected, - "dev_parent_mac": dev_parent_mac - }) + for client in clients: + client_data = { + "dev_mac": client.get("macAddress", ""), + "dev_ip": client.get("ipAddress", ""), + "dev_name": client.get("name", ""), + "dev_type": "", + "dev_connected": client.get("connectedAt", ""), + "dev_parent_mac": resolve_parent_mac( + client.get("uplinkDeviceId") + ), + "dev_vlan_id": "null", + "dev_vlan_name": "null", + "dev_wan_name": "null" + } + + # The Integration API client response currently does not expose + # network/VLAN information. Do not infer it from unsupported fields. + + device_data.append(client_data) return device_data if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index 523ad5152..1d4fa8993 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -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: diff --git a/test/backend/test_notification_templates.py b/test/backend/test_notification_templates.py index 1e8b8d9a1..a21e6ece7 100644 --- a/test/backend/test_notification_templates.py +++ b/test/backend/test_notification_templates.py @@ -295,6 +295,68 @@ def test_html_unchanged_with_template(self, mock_setting): self.assertEqual(html_without, html_with) + # ----------------------------------------------------------------- + # HTML output escapes free-text device values while text stays raw + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_html_escapes_free_text_values(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "", + }) + + devices = [ + { + "devName": "Meta Quest 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