diff --git a/.claude/skills/plugin-readme/SKILL.md b/.claude/skills/plugin-readme/SKILL.md new file mode 100644 index 000000000..f39b66c55 --- /dev/null +++ b/.claude/skills/plugin-readme/SKILL.md @@ -0,0 +1,48 @@ +--- +name: plugin-readme +description: Write or review a NetAlertX plugin's README.md (server/plugins//README.md). Use this when asked to create, enhance, audit, or clean up a plugin README, or plugin docs generally. +--- + +# Plugin README Documentation + +## Structure + +- `## Overview` — prose: what the plugin does and why, 1-3 sentences. Link out to a full guide under `docs/*.md` if one exists for this topic. +- `### Requirements` / `### Prerequisites` (optional) — only when there's a real precondition beyond installing the app (credentials, specific hardware/firmware, host networking, a third-party account). +- `### Usage` — how a user actually engages with the plugin: where to enable it, the overall flow, which non-obvious values they need to go find elsewhere (e.g. "grab your API key from your provider's dashboard"). Not a settings reference table. +- `### Notes` (optional) — caveats, gotchas, limitations, similar/related plugins worth cross-linking. +- `## Other info` (optional) — Version / Author / Maintainer(s) / Release Date. **Never drop this when rewriting a README** - if it's there, carry it forward verbatim even if you're rewriting everything else. + +## The core rule: don't re-document settings + +Every setting already gets a name and description shown directly in the Settings UI, generated straight from `config.json`. A README that re-lists each setting with its key and default value duplicates that and drifts out of sync the moment `config.json` changes — the UI is the single source of truth for field-level docs, not the README. + +Exception: call out a *specific* setting by name, in prose, only when its behavior is genuinely non-obvious - e.g. a setting whose name doesn't match what it actually holds (`_publisher_telegram`'s `HOST` setting is actually the chat ID, `URL` is the bot token), a recommended value or schedule, or an upstream bug tied to a specific value (see `unifi_import`'s `UNFIMP_version`/`UNFIMP_port` note). Don't turn this into a table of every field "just in case" - if you're listing more than one or two settings, ask whether that content belongs in the UI's per-field description instead. + +## Verify against the actual code first + +Read `config.json` (`unique_prefix`, `plugin_type`, `data_source`, `settings`) and the plugin's script before writing anything - don't guess at mechanism from the plugin's name alone. Real bugs found this way during a past audit: `dig_scan/README.md` described the `nbtscan` utility (copy-paste from a sibling plugin); `adguard_import/README.md` was a byte-for-byte copy of `__template/README.md`, never actually written. + +## Backfilling missing "Other info" + +Before concluding a plugin has no attribution to record, grep its script for a credit comment (e.g. `grep -rn "Based on\|Author:" server/plugins//*.py`) - two plugins (`dhcp_servers`, `website_monitor`) had `# Based on the work of https://github.com/leiweibau/Pi.Alert` in the script that nothing in the README reflected. Do **not** use `git log --diff-filter=A` "who first added this file" as an attribution source - `server/plugins/` has at least one bulk restructuring commit, so several unrelated plugins share the same "first added" date/author despite having nothing to do with each other. If you can't verify authorship from an in-source comment or an existing (already-correct) README, leave the section out rather than guess - most first-party/core plugins (`maintenance`, `custom_props`, `db_cleanup`, `set_password`, etc.) simply don't have one, which is the correct, honest state. + +## Cross-linking convention + +- Link to a top-level docs page: `https://docs.netalertx.com/PAGE_NAME`. Never `/docs/PAGE_NAME.md` or a `github.com/.../tree/main/...` URL - both break once the README is rendered inside the docs site (`docs/gen_plugin_pages.py` generates it at a different path than the repo, so repo-relative and GitHub-tree links don't resolve there). +- Link to *another plugin's* README: `https://docs.netalertx.com/plugins/` (matches the page `docs/gen_plugin_pages.py` generates for it). Never a GitHub tree URL. +- If a `docs/*.md` guide is dedicated to (or shared by) this plugin, link both directions - plugin → guide, and guide → plugin. Check the other side actually links back; it's easy to add one direction and forget the other (e.g. `PIHOLE_GUIDE.md` linked to four Pi-hole plugins, none of which linked back, until this was audited). +- If a sibling plugin is easily confused with this one (`unifi_import` vs `unifi_api_import`, `dig_scan` vs `nslookup_scan`, `adguard_export` vs `adguard_import`), say so in one sentence and link it - which one to prefer and why. + +## Common defects to check for when auditing existing READMEs + +- Template leftovers: grep for `Plugin name`, ``, `Some tip.`, `PREF_RUN` - a sign the README was never actually written. Diff against `server/plugins/__template/README.md` if unsure. +- Content copy-pasted from a sibling plugin without updating the tool/utility name. +- `TBC` or similarly empty content, especially for a prominent feature. +- Duplicate or orphaned sections (e.g. two `### Usage` headings) - usually a merge/edit artifact. +- Sibling non-README files (a provider-specific sub-guide, a translated `README_.md`) that aren't linked from the plugin's own `README.md` - `docs/gen_plugin_pages.py` generates a page for every `*.md` in the plugin folder, but only reachable if something links to it. + +## Reference + +- Repo-wide plugin catalog with icon/type legend: `docs/PLUGINS.md` +- Full plugin authoring reference (settings schema, execution phases, data contract): `docs/PLUGINS_DEV.md` and the `plugin-development` skill. diff --git a/.gemini/skills/plugin-readme/plugin-readme-skill.md b/.gemini/skills/plugin-readme/plugin-readme-skill.md new file mode 100644 index 000000000..9c3104ae7 --- /dev/null +++ b/.gemini/skills/plugin-readme/plugin-readme-skill.md @@ -0,0 +1,48 @@ +--- +name: netalertx-plugin-readme +description: Write or review a NetAlertX plugin's README.md (server/plugins//README.md). Use this when asked to create, enhance, audit, or clean up a plugin README, or plugin docs generally. +--- + +# Plugin README Documentation + +## Structure + +- `## Overview` — prose: what the plugin does and why, 1-3 sentences. Link out to a full guide under `docs/*.md` if one exists for this topic. +- `### Requirements` / `### Prerequisites` (optional) — only when there's a real precondition beyond installing the app (credentials, specific hardware/firmware, host networking, a third-party account). +- `### Usage` — how a user actually engages with the plugin: where to enable it, the overall flow, which non-obvious values they need to go find elsewhere (e.g. "grab your API key from your provider's dashboard"). Not a settings reference table. +- `### Notes` (optional) — caveats, gotchas, limitations, similar/related plugins worth cross-linking. +- `## Other info` (optional) — Version / Author / Maintainer(s) / Release Date. **Never drop this when rewriting a README** - if it's there, carry it forward verbatim even if you're rewriting everything else. + +## The core rule: don't re-document settings + +Every setting already gets a name and description shown directly in the Settings UI, generated straight from `config.json`. A README that re-lists each setting with its key and default value duplicates that and drifts out of sync the moment `config.json` changes — the UI is the single source of truth for field-level docs, not the README. + +Exception: call out a *specific* setting by name, in prose, only when its behavior is genuinely non-obvious - e.g. a setting whose name doesn't match what it actually holds (`_publisher_telegram`'s `HOST` setting is actually the chat ID, `URL` is the bot token), a recommended value or schedule, or an upstream bug tied to a specific value (see `unifi_import`'s `UNFIMP_version`/`UNFIMP_port` note). Don't turn this into a table of every field "just in case" - if you're listing more than one or two settings, ask whether that content belongs in the UI's per-field description instead. + +## Verify against the actual code first + +Read `config.json` (`unique_prefix`, `plugin_type`, `data_source`, `settings`) and the plugin's script before writing anything - don't guess at mechanism from the plugin's name alone. Real bugs found this way during a past audit: `dig_scan/README.md` described the `nbtscan` utility (copy-paste from a sibling plugin); `adguard_import/README.md` was a byte-for-byte copy of `__template/README.md`, never actually written. + +## Backfilling missing "Other info" + +Before concluding a plugin has no attribution to record, grep its script for a credit comment (e.g. `grep -rn "Based on\|Author:" server/plugins//*.py`) - two plugins (`dhcp_servers`, `website_monitor`) had `# Based on the work of https://github.com/leiweibau/Pi.Alert` in the script that nothing in the README reflected. Do **not** use `git log --diff-filter=A` "who first added this file" as an attribution source - `server/plugins/` has at least one bulk restructuring commit, so several unrelated plugins share the same "first added" date/author despite having nothing to do with each other. If you can't verify authorship from an in-source comment or an existing (already-correct) README, leave the section out rather than guess - most first-party/core plugins (`maintenance`, `custom_props`, `db_cleanup`, `set_password`, etc.) simply don't have one, which is the correct, honest state. + +## Cross-linking convention + +- Link to a top-level docs page: `https://docs.netalertx.com/PAGE_NAME`. Never `/docs/PAGE_NAME.md` or a `github.com/.../tree/main/...` URL - both break once the README is rendered inside the docs site (`docs/gen_plugin_pages.py` generates it at a different path than the repo, so repo-relative and GitHub-tree links don't resolve there). +- Link to *another plugin's* README: `https://docs.netalertx.com/plugins/` (matches the page `docs/gen_plugin_pages.py` generates for it). Never a GitHub tree URL. +- If a `docs/*.md` guide is dedicated to (or shared by) this plugin, link both directions - plugin → guide, and guide → plugin. Check the other side actually links back; it's easy to add one direction and forget the other (e.g. `PIHOLE_GUIDE.md` linked to four Pi-hole plugins, none of which linked back, until this was audited). +- If a sibling plugin is easily confused with this one (`unifi_import` vs `unifi_api_import`, `dig_scan` vs `nslookup_scan`, `adguard_export` vs `adguard_import`), say so in one sentence and link it - which one to prefer and why. + +## Common defects to check for when auditing existing READMEs + +- Template leftovers: grep for `Plugin name`, ``, `Some tip.`, `PREF_RUN` - a sign the README was never actually written. Diff against `server/plugins/__template/README.md` if unsure. +- Content copy-pasted from a sibling plugin without updating the tool/utility name. +- `TBC` or similarly empty content, especially for a prominent feature. +- Duplicate or orphaned sections (e.g. two `### Usage` headings) - usually a merge/edit artifact. +- Sibling non-README files (a provider-specific sub-guide, a translated `README_.md`) that aren't linked from the plugin's own `README.md` - `docs/gen_plugin_pages.py` generates a page for every `*.md` in the plugin folder, but only reachable if something links to it. + +## Reference + +- Repo-wide plugin catalog with icon/type legend: `docs/PLUGINS.md` +- Full plugin authoring reference (settings schema, execution phases, data contract): `docs/PLUGINS_DEV.md` and the `plugin-development` skill. diff --git a/.gemini/skills/skills-index/SKILL.md b/.gemini/skills/skills-index/SKILL.md index 45ffc23c5..924346fff 100644 --- a/.gemini/skills/skills-index/SKILL.md +++ b/.gemini/skills/skills-index/SKILL.md @@ -24,6 +24,7 @@ Skills with the same purpose exist in more than one, sometimes under different n | MCP activation | `mcp-activation` | `mcp-activation` | — | Gemini version covers Gemini CLI session restart; Copilot version covers VS Code window reload | | 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` | `plugin-development` | All three cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha (`timeoutMultiplier`/`per_item_timeout()`), and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md` | +| Plugin README docs | `plugin-readme` | `plugin-readme` | `plugin-readme` | All three cover README structure, the "don't re-document settings" rule, the `docs.netalertx.com` cross-linking convention, and common defects (template leftovers, copy-paste errors) found during a full-repo audit | | 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` | `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/copilot-instructions.md b/.github/copilot-instructions.md index 4269f0d14..fa632e24c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,6 +35,7 @@ Procedural knowledge lives in `.github/skills/`. Load the appropriate skill when | Build Docker images | `docker-build` | | Reprovision devcontainer | `devcontainer-setup` | | Create or run plugins | `plugin-run-development` | +| Write or review a plugin README | `plugin-readme` | | Analyze PR comments | `pr-analysis` | | Clean Docker resources | `docker-prune` | | Generate devcontainer configs | `devcontainer-configs` | diff --git a/.github/skills/plugin-readme/SKILL.md b/.github/skills/plugin-readme/SKILL.md new file mode 100644 index 000000000..9c3104ae7 --- /dev/null +++ b/.github/skills/plugin-readme/SKILL.md @@ -0,0 +1,48 @@ +--- +name: netalertx-plugin-readme +description: Write or review a NetAlertX plugin's README.md (server/plugins//README.md). Use this when asked to create, enhance, audit, or clean up a plugin README, or plugin docs generally. +--- + +# Plugin README Documentation + +## Structure + +- `## Overview` — prose: what the plugin does and why, 1-3 sentences. Link out to a full guide under `docs/*.md` if one exists for this topic. +- `### Requirements` / `### Prerequisites` (optional) — only when there's a real precondition beyond installing the app (credentials, specific hardware/firmware, host networking, a third-party account). +- `### Usage` — how a user actually engages with the plugin: where to enable it, the overall flow, which non-obvious values they need to go find elsewhere (e.g. "grab your API key from your provider's dashboard"). Not a settings reference table. +- `### Notes` (optional) — caveats, gotchas, limitations, similar/related plugins worth cross-linking. +- `## Other info` (optional) — Version / Author / Maintainer(s) / Release Date. **Never drop this when rewriting a README** - if it's there, carry it forward verbatim even if you're rewriting everything else. + +## The core rule: don't re-document settings + +Every setting already gets a name and description shown directly in the Settings UI, generated straight from `config.json`. A README that re-lists each setting with its key and default value duplicates that and drifts out of sync the moment `config.json` changes — the UI is the single source of truth for field-level docs, not the README. + +Exception: call out a *specific* setting by name, in prose, only when its behavior is genuinely non-obvious - e.g. a setting whose name doesn't match what it actually holds (`_publisher_telegram`'s `HOST` setting is actually the chat ID, `URL` is the bot token), a recommended value or schedule, or an upstream bug tied to a specific value (see `unifi_import`'s `UNFIMP_version`/`UNFIMP_port` note). Don't turn this into a table of every field "just in case" - if you're listing more than one or two settings, ask whether that content belongs in the UI's per-field description instead. + +## Verify against the actual code first + +Read `config.json` (`unique_prefix`, `plugin_type`, `data_source`, `settings`) and the plugin's script before writing anything - don't guess at mechanism from the plugin's name alone. Real bugs found this way during a past audit: `dig_scan/README.md` described the `nbtscan` utility (copy-paste from a sibling plugin); `adguard_import/README.md` was a byte-for-byte copy of `__template/README.md`, never actually written. + +## Backfilling missing "Other info" + +Before concluding a plugin has no attribution to record, grep its script for a credit comment (e.g. `grep -rn "Based on\|Author:" server/plugins//*.py`) - two plugins (`dhcp_servers`, `website_monitor`) had `# Based on the work of https://github.com/leiweibau/Pi.Alert` in the script that nothing in the README reflected. Do **not** use `git log --diff-filter=A` "who first added this file" as an attribution source - `server/plugins/` has at least one bulk restructuring commit, so several unrelated plugins share the same "first added" date/author despite having nothing to do with each other. If you can't verify authorship from an in-source comment or an existing (already-correct) README, leave the section out rather than guess - most first-party/core plugins (`maintenance`, `custom_props`, `db_cleanup`, `set_password`, etc.) simply don't have one, which is the correct, honest state. + +## Cross-linking convention + +- Link to a top-level docs page: `https://docs.netalertx.com/PAGE_NAME`. Never `/docs/PAGE_NAME.md` or a `github.com/.../tree/main/...` URL - both break once the README is rendered inside the docs site (`docs/gen_plugin_pages.py` generates it at a different path than the repo, so repo-relative and GitHub-tree links don't resolve there). +- Link to *another plugin's* README: `https://docs.netalertx.com/plugins/` (matches the page `docs/gen_plugin_pages.py` generates for it). Never a GitHub tree URL. +- If a `docs/*.md` guide is dedicated to (or shared by) this plugin, link both directions - plugin → guide, and guide → plugin. Check the other side actually links back; it's easy to add one direction and forget the other (e.g. `PIHOLE_GUIDE.md` linked to four Pi-hole plugins, none of which linked back, until this was audited). +- If a sibling plugin is easily confused with this one (`unifi_import` vs `unifi_api_import`, `dig_scan` vs `nslookup_scan`, `adguard_export` vs `adguard_import`), say so in one sentence and link it - which one to prefer and why. + +## Common defects to check for when auditing existing READMEs + +- Template leftovers: grep for `Plugin name`, ``, `Some tip.`, `PREF_RUN` - a sign the README was never actually written. Diff against `server/plugins/__template/README.md` if unsure. +- Content copy-pasted from a sibling plugin without updating the tool/utility name. +- `TBC` or similarly empty content, especially for a prominent feature. +- Duplicate or orphaned sections (e.g. two `### Usage` headings) - usually a merge/edit artifact. +- Sibling non-README files (a provider-specific sub-guide, a translated `README_.md`) that aren't linked from the plugin's own `README.md` - `docs/gen_plugin_pages.py` generates a page for every `*.md` in the plugin folder, but only reachable if something links to it. + +## Reference + +- Repo-wide plugin catalog with icon/type legend: `docs/PLUGINS.md` +- Full plugin authoring reference (settings schema, execution phases, data contract): `docs/PLUGINS_DEV.md` and the `plugin-development` skill. diff --git a/.github/skills/skills-overview/SKILL.md b/.github/skills/skills-overview/SKILL.md index 7045a0fd5..8c0b26184 100644 --- a/.github/skills/skills-overview/SKILL.md +++ b/.github/skills/skills-overview/SKILL.md @@ -24,6 +24,7 @@ Skills with the same purpose exist in more than one, sometimes under different n | MCP activation | `mcp-activation` | `mcp-activation` | — | Copilot version covers VS Code window reload; Gemini version covers Gemini CLI session restart | | 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` | `plugin-development` | All three cover data contract, phases, formats, the `RUN_TIMEOUT` kill-timer gotcha (`timeoutMultiplier`/`per_item_timeout()`), and a pre-PR pointer to the Conventions Checklist in `docs/PLUGINS_DEV.md` | +| Plugin README docs | `plugin-readme` | `plugin-readme` | `plugin-readme` | All three cover README structure, the "don't re-document settings" rule, the `docs.netalertx.com` cross-linking convention, and common defects (template leftovers, copy-paste errors) found during a full-repo audit | | 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` | `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/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index 27daf1e66..48a0551a2 100755 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -26,7 +26,9 @@ jobs: mkdocs==1.6.0 \ mkdocs-material==9.5.21 \ mkdocs-github-admonitions-plugin==0.1.1 \ - mkdocs-glightbox + mkdocs-glightbox \ + mkdocs-gen-files \ + mkdocs-literate-nav - name: Build MkDocs run: mkdocs build diff --git a/docs/ADVISORY_MULTI_NETWORK.md b/docs/ADVISORY_MULTI_NETWORK.md index 48248e161..d346d70df 100644 --- a/docs/ADVISORY_MULTI_NETWORK.md +++ b/docs/ADVISORY_MULTI_NETWORK.md @@ -8,7 +8,7 @@ Effective multi-network monitoring starts with understanding how NetAlertX "sees * **B. Plan Subnet & Scan Interfaces:** Explicitly configure each accessible segment in `SCAN_SUBNETS` with the corresponding interfaces. * **C. Remote & Inaccessible Networks:** For networks unreachable via ARP, use these strategies: * **Alternate Plugins:** Supplement discovery with [SNMPDSC](https://docs.netalertx.com/PLUGINS/?h=SNMPDSC#available-plugins) or [DHCP lease imports](https://docs.netalertx.com/PLUGINS/?h=DHCPLSS#available-plugins). -* **Sync Hub for MSP & Multi-Site Deployments:** Run secondary NetAlertX instances on isolated networks and aggregate data using the **SYNC plugin**. Use the [`SYNC_BEHAVIOR`](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/sync/README.md#hub-device-write-behavior-sync_behavior) setting on the hub to control whether the hub inherits device config from nodes or manages it independently. +* **Sync Hub for MSP & Multi-Site Deployments:** Run secondary NetAlertX instances on isolated networks and aggregate data using the **SYNC plugin**. Use the [`SYNC_BEHAVIOR`](https://docs.netalertx.com/plugins/sync#hub-device-write-behavior-sync_behavior) setting on the hub to control whether the hub inherits device config from nodes or manages it independently. * **Manual Entry:** For static assets where only ICMP (ping) status is needed. > [!TIP] diff --git a/docs/ADVISORY_MULTI_SITE_MONITORING.md b/docs/ADVISORY_MULTI_SITE_MONITORING.md index 9cc931018..f4caffee4 100644 --- a/docs/ADVISORY_MULTI_SITE_MONITORING.md +++ b/docs/ADVISORY_MULTI_SITE_MONITORING.md @@ -127,7 +127,7 @@ For best results in multi-site environments: ## Related Documentation * [Remote Networks](./REMOTE_NETWORKS.md) -* [Sync Hub Plugin](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/sync/README.md) +* [Sync Hub Plugin](https://docs.netalertx.com/plugins/sync) * [Workflows](./WORKFLOWS.md) * [Metrics API](./API_METRICS.md) * [Eyes on Glass / NOC Dashboard](./ADVISORY_EYES_ON_GLASS.md) diff --git a/docs/API_SYNC.md b/docs/API_SYNC.md index 04691f8f1..1976b26de 100755 --- a/docs/API_SYNC.md +++ b/docs/API_SYNC.md @@ -135,5 +135,5 @@ The `SYNC_BEHAVIOR` setting controls how the hub writes devices received from no | `carbon-copy` | | All MACs every sync (UPSERT) | | `hub-defaults` | | None — hub pipeline handles it | -For full details and per-mode behaviour, see [SYNC plugin README — Hub Device-Write Behavior](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/sync/README.md#hub-device-write-behavior-sync_behavior). +For full details and per-mode behaviour, see [SYNC plugin README — Hub Device-Write Behavior](https://docs.netalertx.com/plugins/sync#hub-device-write-behavior-sync_behavior). diff --git a/docs/BACKUPS.md b/docs/BACKUPS.md index 7e63ac96e..a7fd02f3d 100755 --- a/docs/BACKUPS.md +++ b/docs/BACKUPS.md @@ -37,7 +37,7 @@ This includes settings for: ### Device Data -Stored in `/data/config/devices_.csv` or `/data/config/devices.csv`, created by the [CSV Backup `CSVBCKP` Plugin](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/csv_backup). +Stored in `/data/config/devices_.csv` or `/data/config/devices.csv`, created by the [CSV Backup `CSVBCKP` Plugin](https://docs.netalertx.com/plugins/csv_backup). Contains: * Device names, icons, and categories diff --git a/docs/COMMON_ISSUES.md b/docs/COMMON_ISSUES.md index 85a7d42a5..a7b033c35 100755 --- a/docs/COMMON_ISSUES.md +++ b/docs/COMMON_ISSUES.md @@ -117,7 +117,7 @@ Slowness can be caused by: With `ARPSCAN` scans some devices might flip IP addresses after each scan triggering false notifications. This is because some devices respond to broadcast calls and thus different IPs after scans are logged. -See how to prevent IP flipping in the [ARPSCAN plugin guide](/server/plugins/arp_scan/README.md). +See how to prevent IP flipping in the [ARPSCAN plugin guide](https://github.com/netalertx/NetAlertX/blob/main/server/plugins/arp_scan/README.md). Alternatively adjust your [notification settings](./NOTIFICATIONS.md) to prevent false positives by filtering out events or devices. diff --git a/docs/CUSTOM_PROPERTIES.md b/docs/CUSTOM_PROPERTIES.md index c8c29b320..0a090f380 100755 --- a/docs/CUSTOM_PROPERTIES.md +++ b/docs/CUSTOM_PROPERTIES.md @@ -35,10 +35,11 @@ Visible properties (`CUSTPROP_show: true`) are displayed as interactive icons in - Manage devices with actions like delete. - Example: Quickly remove a device from the network. -4. **Plugins**: +4. **Plugins (Experimental 🧪)**: - - Future placeholder for running custom plugin scripts. - - **Note**: Not implemented yet. + - Manually trigger an on-demand run of a plugin, regardless of its configured `RUN` schedule. + - Example: Add a button to re-run a scan-type plugin like `NMAPDEV` on demand. + - **Note**: The plugin runs with whatever settings/params it already has configured - it is not passed any device-specific or ad-hoc arguments. Some plugins require settings (credentials, target host, subnet, etc.) to be configured before they'll run properly; triggering an unconfigured plugin this way may fail or do nothing useful. --- @@ -56,6 +57,10 @@ Visible properties (`CUSTPROP_show: true`) are displayed as interactive icons in - Enable device removal functionality using `CUSTPROP_type: delete_dev`. +4. **Run a Plugin On Demand (Experimental 🧪)**: + + - Use `CUSTPROP_type: run_plugin` with `CUSTPROP_args` set to the target plugin's unique prefix (e.g. `NMAPDEV`) to add a button that triggers that plugin immediately. Make sure the plugin's own settings (credentials, target host, subnet, etc.) are already configured, since none are passed in via this action. + --- ## Defining Custom Properties @@ -66,13 +71,30 @@ Custom properties are structured as a list of objects, where each property inclu |--------------------|-----------------------------------------------------------------------------| | `CUSTPROP_icon` | The icon (Base64-encoded HTML) displayed for the property. | | `CUSTPROP_type` | The action type (e.g., `show_notes`, `link`, `delete_dev`). | -| `CUSTPROP_name` | A short name or title for the property. | -| `CUSTPROP_args` | Arguments for the action (e.g., URL or modal text). | +| `CUSTPROP_name` | A short name or title for the property. Supports `{{fieldName}}` wildcards. | +| `CUSTPROP_args` | Arguments for the action (e.g., URL or modal text). Supports `{{fieldName}}` wildcards. | | `CUSTPROP_notes` | Additional notes or details displayed when applicable. | | `CUSTPROP_show` | A boolean to control visibility (`true` to show on the listing page). | --- +## Wildcards in `CUSTPROP_name` / `CUSTPROP_args` + +`CUSTPROP_name` and `CUSTPROP_args` are resolved per-device before rendering, so you can reference any of that device's own fields with `{{fieldName}}` - field names are matched case-insensitively, so `{{devLastIp}}` and `{{devLastIP}}` are equivalent. If a field name doesn't exist, the placeholder is left as-is (e.g. `{{devTypo}}` stays visible) rather than silently disappearing, to make a typo obvious while you're setting one up. + +This is what makes a single `link`/`link_new_tab` custom property work across every device rather than one URL per device - e.g. to jump to a device's traffic log in an AdGuard Home instance, filtered to that device's IP: + +``` +CUSTPROP_type: link_new_tab +CUSTPROP_args: http://my_adguard_url/#logs?search={{devLastIP}} +``` + +This same property definition can be reused two ways: set directly on a single device (affecting that device alone), or set once via the `NEWDEV_devCustomProps` setting, which becomes the default `devCustomProps` for devices discovered *after* the setting is saved. It is not applied retroactively - existing devices keep their current `devCustomProps` and need to be updated individually (or reset via the device's "reset properties" action) to pick up a changed default. + +Commonly useful fields: `devMac`, `devLastIP`, `devName`, `devVendor`, `devType`, `devGUID`. + +--- + ## Available Action Types - **Show Notes**: Displays a modal with a title and additional notes. @@ -80,14 +102,14 @@ Custom properties are structured as a list of objects, where each property inclu - **Link**: Redirects to a specified URL in the current browser tab. (**Arguments** Needs to contain the full URL.) - **Link (New Tab)**: Opens a specified URL in a new browser tab. (**Arguments** Needs to contain the full URL.) - **Delete Device**: Deletes the device using its MAC address. -- **Run Plugin**: Placeholder for executing custom plugins (not implemented yet). +- **Run Plugin (Experimental 🧪)**: Triggers an on-demand run of the plugin named in **Arguments** (its unique prefix, e.g. `NMAPDEV`), regardless of that plugin's configured `RUN` schedule. --- ## Notes -- **Plugin Functionality**: The `run_plugin` action type is currently not implemented and will show an alert if used. +- **Plugin Functionality (Experimental 🧪)**: `run_plugin` requires `CUSTPROP_args` to exactly match an enabled plugin's unique prefix (as listed in the `LOADED_PLUGINS` setting); an unrecognized or disabled prefix is rejected by the backend. It simply re-runs the plugin with its existing configuration - some plugins need required settings/params (e.g. credentials, target host, subnet) filled in first, or the run will fail or silently do nothing. Marked experimental until this is more clearly surfaced in the UI. - **Custom Icons (Experimental 🧪)**: Use Base64-encoded HTML to provide custom icons for each property. You can add your icons in Setttings via the `CUSTPROP_icon` settings - **Visibility Control**: Only properties with `CUSTPROP_show: true` will appear on the listing page. diff --git a/docs/HOME_ASSISTANT.md b/docs/HOME_ASSISTANT.md index 91387346d..713e9d124 100755 --- a/docs/HOME_ASSISTANT.md +++ b/docs/HOME_ASSISTANT.md @@ -9,7 +9,7 @@ NetAlertX includes MQTT support, allowing detected devices to appear as devices > > * Device discovery in Home Assistant takes approximately 10 seconds **per device**. > * Devices removed from NetAlertX are not automatically removed from Home Assistant. Use [MQTT Explorer](https://mqtt-explorer.com/) to delete them from the MQTT broker if required. -> * For performance reasons, device definitions are not always fully synchronized. To force a complete synchronization, delete the MQTT Plugin Objects as described in the [MQTT plugin](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_mqtt#forcing-an-update) documentation. +> * For performance reasons, device definitions are not always fully synchronized. To force a complete synchronization, delete the MQTT Plugin Objects as described in the [MQTT plugin](https://docs.netalertx.com/plugins/_publisher_mqtt#forcing-an-update) documentation. ## Mosquitto MQTT setup diff --git a/docs/INITIAL_SETUP.md b/docs/INITIAL_SETUP.md index cd524aa5c..af78ca6b2 100755 --- a/docs/INITIAL_SETUP.md +++ b/docs/INITIAL_SETUP.md @@ -25,7 +25,7 @@ Get **NetAlertX** up and running in a few simple steps. > [!NOTE] > Configure your SMTP settings or enable additional `▶️ publisher` plugins to send alerts. -> For more flexibility, try [📚 `_publisher_apprise`](/server/plugins/_publisher_apprise/), which supports over 80 notification services. +> For more flexibility, try [📚 `_publisher_apprise`](https://docs.netalertx.com/plugins/_publisher_apprise), which supports over 80 notification services. --- diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index f5b3cc20d..574ebb7a1 100755 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -50,7 +50,7 @@ In Notification Processing settings, you can specify blanket rules. These allow 2. Alert down after (`NTFPRCS_alert_down_time`) is useful if you want to wait for some time before the system sends out a down notification for a device. This is related to the on-device **Alert down** setting and only devices with this checked will trigger a down notification. 3. Alert down after (sleep) (`NTFPRCS_sleep_time`) sets the **sleep window** in minutes. If a device has **Can Sleep** enabled and goes offline, it is shown as **Sleeping** (aqua 🌙 badge) for this many minutes before down-alert logic kicks in. Default is `30` minutes. Changing this setting takes effect after saving — no restart required. -You can filter out unwanted notifications globally. This could be because of a misbehaving device (GoogleNest/GoogleHub (See also [ARPSAN docs and the `--exclude-broadcast` flag](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/arp_scan#ip-flipping-on-google-nest-devices))) which flips between IP addresses, or because you want to ignore new device notifications of a certain pattern. +You can filter out unwanted notifications globally. This could be because of a misbehaving device (GoogleNest/GoogleHub (See also [ARPSAN docs and the `--exclude-broadcast` flag](https://docs.netalertx.com/plugins/arp_scan#ip-flipping-on-google-nest-devices))) which flips between IP addresses, or because you want to ignore new device notifications of a certain pattern. 1. Events Filter (`NTFPRCS_event_condition`) - Filter out Events from notifications. 2. New Devices Filter (`NTFPRCS_new_dev_condition`) - Filter out New Devices from notifications, but log and keep a new device in the system. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 4753f38ba..1d510f7ca 100755 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -39,14 +39,14 @@ Two plugins help maintain the system’s performance: ### **1. Database Cleanup (DBCLNP)** * Handles database maintenance and cleanup. -* See the [DB Cleanup Plugin Docs](/server/plugins/db_cleanup/README.md). +* See the [DB Cleanup Plugin Docs](https://docs.netalertx.com/plugins/db_cleanup). * Ensure it’s not failing by checking logs. * Adjust the schedule (`DBCLNP_RUN_SCHD`) and timeout (`DBCLNP_RUN_TIMEOUT`) if necessary. ### **2. Maintenance (MAINT)** * Cleans logs and performs general maintenance tasks. -* See the [Maintenance Plugin Docs](/server/plugins/maintenance/README.md). +* See the [Maintenance Plugin Docs](https://docs.netalertx.com/plugins/maintenance). * Verify proper operation via logs. * Adjust the schedule (`MAINT_RUN_SCHD`) and timeout (`MAINT_RUN_TIMEOUT`) if needed. diff --git a/docs/PIHOLE_GUIDE.md b/docs/PIHOLE_GUIDE.md index ba085b3ae..7a7e30d6b 100755 --- a/docs/PIHOLE_GUIDE.md +++ b/docs/PIHOLE_GUIDE.md @@ -19,7 +19,7 @@ To use this approach, make sure a Web UI password is configured in **Pi-hole**. | `PIHOLEAPI_API_MAXCLIENTS` | Maximum number of devices to request from Pi-hole. The default value is usually sufficient. | `500` | | `PIHOLEAPI_FAKE_MAC` | Generate a deterministic fake MAC address from the IP address. | `False` | -Check the [PIHOLEAPI plugin README](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/pihole_api_scan/) for additional details and troubleshooting. +Check the [PIHOLEAPI plugin README](https://docs.netalertx.com/plugins/pihole_api_scan) for additional details and troubleshooting. ### docker-compose changes @@ -41,7 +41,7 @@ This approach requires mounting the Pi-hole DHCP leases file (`dhcp.leases`) int | `DHCPLSS_RUN_SCHD` | If you run multiple device scanner plugins, configure them to use the same schedule. | `*/5 * * * *` | | `DHCPLSS_paths_to_check` | Path to the mapped `dhcp.leases` file inside the container. The path must include `pihole` so the plugin can identify it as a Pi-hole leases file. | `['/etc/pihole/dhcp.leases']` | -Check the [DHCPLSS plugin README](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/dhcp_leases#overview) for additional details. +Check the [DHCPLSS plugin README](https://docs.netalertx.com/plugins/dhcp_leases#overview) for additional details. ### docker-compose changes @@ -65,7 +65,7 @@ This approach requires mounting the Pi-hole database file into the NetAlertX con | `PIHOLE_RUN_SCHD` | If you run multiple device scanner plugins, configure them to use the same schedule. | `*/5 * * * *` | | `PIHOLE_DB_PATH` | Path to the mapped Pi-hole database file inside the container. | `/etc/pihole/pihole-FTL.db` | -Check the [PIHOLE plugin README](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/pihole_scan) for additional details. +Check the [PIHOLE plugin README](https://docs.netalertx.com/plugins/pihole_scan) for additional details. ### docker-compose changes @@ -85,7 +85,7 @@ This plugin's main job is different from the other three above: it watches each * **Two Pi-hole instances, not one.** A primary and an optional secondary/failover are both checked and their results combined under one set of settings - useful if you run more than one Pi-hole (e.g. two resolvers for redundancy) and want a single device list and a single anomaly baseline across both, instead of a blind spot on whichever instance isn't being watched. * **Choose `PIHOLEAPI`** if you only run one Pi-hole and just want device import - it's the simpler, more focused option. **Choose `PIHOLEMON`** if you want the anomaly detection, or run two Pi-holes needing one configuration, or both. -Check the [PIHOLEMON plugin README](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/pihole_monitor/) for the full settings reference, the `http://` vs `https://` trade-off, and troubleshooting. +Check the [PIHOLEMON plugin README](https://docs.netalertx.com/plugins/pihole_monitor) for the full settings reference, the `http://` vs `https://` trade-off, and troubleshooting. ### docker-compose changes diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f9e675e8f..90bd45c9f 100755 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -43,57 +43,59 @@ NetAlertX supports additional plugins to extend its functionality, each with its Device-detecting plugins insert values into the `CurrentScan` database table. The plugins that are not required are safe to ignore, however, it makes sense to have at least some device-detecting plugins enabled, such as `ARPSCAN` or `NMAPDEV`. +The **Plugin docs** links below open each plugin's README rendered as part of this site (see the [Plugins reference](./plugins/) section) - generated automatically from `server/plugins//README.md`. + | ID | Plugin docs | Type | Description | Features | Required | | --------------- | ------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------------------------- | -------- | -------- | -| `APPRISE` | [_publisher_apprise](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_apprise/) | ▶️ | Apprise notification proxy | | | -| `ARPSCAN` | [arp_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/arp_scan/) | 🔍 | ARP-scan on current network | | | -| `AVAHISCAN` | [avahi_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/avahi_scan/) | 🆎 | Avahi (mDNS-based) name resolution | | | -| `ASUSWRT` | [asuswrt_import](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/asuswrt_import/) | 📥 | Import connected devices from AsusWRT | | | -| `CSVBCKP` | [csv_backup](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/csv_backup/) | ⚙ | CSV devices backup | | | -| `CUSTPROP` | [custom_props](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/custom_props/) | ⚙ | Managing custom device properties values | | Yes | -| `DBCLNP` | [db_cleanup](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/db_cleanup/) | ⚙ | Database cleanup | | Yes\* | -| `DDNS` | [ddns_update](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/ddns_update/) | ⚙ | DDNS update | | | -| `DHCPLSS` | [dhcp_leases](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/dhcp_leases/) | 📥/🆎 | Import devices from DHCP leases | | | -| `DHCPSRVS` | [dhcp_servers](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/dhcp_servers/) | ♻ | DHCP servers | | | -| `DIGSCAN` | [dig_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/dig_scan/) | 🆎 | Dig (DNS) Name resolution | | | -| `FREEBOX` | [freebox](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/freebox/) |📥/♻/🆎 | Pull data and names from Freebox/Iliadbox | | | -| `FRITZBOX` | [fritzbox](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/fritzbox/) | 📥 | Fritz!Box device scanner via TR-064 | | | -| `ICMP` | [icmp_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/icmp_scan/) | ♻ | ICMP (ping) status checker | | | -| `INTRNT` | [internet_ip](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/internet_ip/) | 🔍 | Internet IP scanner | | | -| `INTRSPD` | [internet_speedtest](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/internet_speedtest/) | ♻ | Internet speed test | | | -| `IPNEIGH` | [ipneigh](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | | -| `KEALSS` | [kea_api](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/kea_api/) | 📥/🆎 | Pull lease data from the Kea DHCP API | | | -| `LUCIRPC` | [luci_import](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/luci_import/) | 📥 | Import connected devices from OpenWRT | | | -| `MAINT` | [maintenance](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | | -| `MQTT` | [_publisher_mqtt](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_mqtt/) | ▶️ | MQTT for syncing to Home Assistant | | | -| `MTSCAN` | [mikrotik_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/mikrotik_scan/) | 🔍 | Mikrotik device import & sync | | | -| `NBTSCAN` | [nbtscan_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/nbtscan_scan/) | 🆎 | Nbtscan (NetBIOS-based) name resolution | | | -| `NEWDEV` | [newdev_template](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/newdev_template/) | ⚙ | New device template | | Yes | -| `NMAP` | [nmap_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/nmap_scan/) | ♻ | Nmap port scanning & discovery | | | -| `NMAPDEV` | [nmap_dev_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/nmap_dev_scan/) | 🔍 | Nmap dev scan on current network | | | -| `NSLOOKUP` | [nslookup_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/nslookup_scan/) | 🆎 | NSLookup (DNS-based) name resolution | | | -| `NTFPRCS` | [notification_processing](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/notification_processing/) | ⚙ | Notification processing | | Yes | -| `NTFY` | [_publisher_ntfy](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_ntfy/) | ▶️ | NTFY notifications | | | -| `OMDSDN` | [omada_sdn_imp](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/omada_sdn_imp/) | 📥/🆎 ❌ | UNMAINTAINED use `OMDSDNOPENAPI` | 🖧 🔄 | | -| `OMDSDNOPENAPI` | [omada_sdn_openapi](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/omada_sdn_openapi/) | 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | | -| `PIHOLE` | [pihole_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/pihole_scan/) | 🆎/📥 | Pi-hole device import & sync | | | -| `PIHOLEAPI` | [pihole_api_scan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/pihole_api_scan/) | 🆎/📥 | Pi-hole device import & sync via API v6+ | | | -| `PIHOLEMON` | [pihole_monitor](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/pihole_monitor/) | 🆎/📥 | Blocked-query anomaly detection (includes primary and secondary DNS import from Pi-hole) | | | -| `PUSHSAFER` | [_publisher_pushsafer](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_pushsafer/) | ▶️ | Pushsafer notifications | | | -| `PUSHOVER` | [_publisher_pushover](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_pushover/) | ▶️ | Pushover notifications | | | -| `RSTIMPRT` | [rest_import](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/rest_import/) | 📥/🆎 | Import via a REST API endpoint | 🖧 | | -| `SETPWD` | [set_password](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/set_password/) | ⚙ | Set password | | Yes | -| `SMTP` | [_publisher_email](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_email/) | ▶️ | Email notifications | | | -| `SNMPDSC` | [snmp_discovery](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/snmp_discovery/) | 🔍/📥 | SNMP device import & sync | | | -| `SYNC` | [sync](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/sync/) | ⚙/📥 | Sync & import from NetAlertX instances | 🖧 🔄 | Yes | -| `TELEGRAM` | [_publisher_telegram](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_telegram/) | ▶️ | Telegram notifications | | | -| `UI` | [ui_settings](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/ui_settings/) | ♻ | UI specific settings | | Yes | -| `UNFIMP` | [unifi_import](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/unifi_import/) | 📥/🆎 | UniFi device import & sync | 🖧 | | -| `UNIFIAPI` | [unifi_api_import](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/unifi_api_import/) | 📥/🆎 | UniFi device import (SM API, multi-site) | | | -| `VNDRPDT` | [vendor_update](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/vendor_update/) | ⚙ | Vendor database update | | | -| `WEBHOOK` | [_publisher_webhook](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_webhook/) | ▶️ | Webhook notifications | | | -| `WEBMON` | [website_monitor](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/website_monitor/) | ♻ | Website down monitoring | | | -| `WOL` | [wake_on_lan](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/wake_on_lan/) | ♻ | Automatic wake-on-lan | | | +| `APPRISE` | [_publisher_apprise](plugins/_publisher_apprise.md) | ▶️ | Apprise notification proxy | | | +| `ARPSCAN` | [arp_scan](plugins/arp_scan.md) | 🔍 | ARP-scan on current network | | | +| `AVAHISCAN` | [avahi_scan](plugins/avahi_scan.md) | 🆎 | Avahi (mDNS-based) name resolution | | | +| `ASUSWRT` | [asuswrt_import](plugins/asuswrt_import.md) | 📥 | Import connected devices from AsusWRT | | | +| `CSVBCKP` | [csv_backup](plugins/csv_backup.md) | ⚙ | CSV devices backup | | | +| `CUSTPROP` | [custom_props](plugins/custom_props.md) | ⚙ | Managing custom device properties values | | Yes | +| `DBCLNP` | [db_cleanup](plugins/db_cleanup.md) | ⚙ | Database cleanup | | Yes\* | +| `DDNS` | [ddns_update](plugins/ddns_update.md) | ⚙ | DDNS update | | | +| `DHCPLSS` | [dhcp_leases](plugins/dhcp_leases.md) | 📥/🆎 | Import devices from DHCP leases | | | +| `DHCPSRVS` | [dhcp_servers](plugins/dhcp_servers.md) | ♻ | DHCP servers | | | +| `DIGSCAN` | [dig_scan](plugins/dig_scan.md) | 🆎 | Dig (DNS) Name resolution | | | +| `FREEBOX` | [freebox](plugins/freebox.md) |📥/♻/🆎 | Pull data and names from Freebox/Iliadbox | | | +| `FRITZBOX` | [fritzbox](plugins/fritzbox.md) | 📥 | Fritz!Box device scanner via TR-064 | | | +| `ICMP` | [icmp_scan](plugins/icmp_scan.md) | ♻ | ICMP (ping) status checker | | | +| `INTRNT` | [internet_ip](plugins/internet_ip.md) | 🔍 | Internet IP scanner | | | +| `INTRSPD` | [internet_speedtest](plugins/internet_speedtest.md) | ♻ | Internet speed test | | | +| `IPNEIGH` | [ipneigh](plugins/ipneigh.md) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | | +| `KEALSS` | [kea_api](plugins/kea_api.md) | 📥/🆎 | Pull lease data from the Kea DHCP API | | | +| `LUCIRPC` | [luci_import](plugins/luci_import.md) | 📥 | Import connected devices from OpenWRT | | | +| `MAINT` | [maintenance](plugins/maintenance.md) | ⚙ | Maintenance of logs, etc. | | | +| `MQTT` | [_publisher_mqtt](plugins/_publisher_mqtt.md) | ▶️ | MQTT for syncing to Home Assistant | | | +| `MTSCAN` | [mikrotik_scan](plugins/mikrotik_scan.md) | 🔍 | Mikrotik device import & sync | | | +| `NBTSCAN` | [nbtscan_scan](plugins/nbtscan_scan.md) | 🆎 | Nbtscan (NetBIOS-based) name resolution | | | +| `NEWDEV` | [newdev_template](plugins/newdev_template.md) | ⚙ | New device template | | Yes | +| `NMAP` | [nmap_scan](plugins/nmap_scan.md) | ♻ | Nmap port scanning & discovery | | | +| `NMAPDEV` | [nmap_dev_scan](plugins/nmap_dev_scan.md) | 🔍 | Nmap dev scan on current network | | | +| `NSLOOKUP` | [nslookup_scan](plugins/nslookup_scan.md) | 🆎 | NSLookup (DNS-based) name resolution | | | +| `NTFPRCS` | [notification_processing](plugins/notification_processing.md) | ⚙ | Notification processing | | Yes | +| `NTFY` | [_publisher_ntfy](plugins/_publisher_ntfy.md) | ▶️ | NTFY notifications | | | +| `OMDSDN` | [omada_sdn_imp](plugins/omada_sdn_imp.md) | 📥/🆎 ❌ | UNMAINTAINED use `OMDSDNOPENAPI` | 🖧 🔄 | | +| `OMDSDNOPENAPI` | [omada_sdn_openapi](plugins/omada_sdn_openapi.md) | 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | | +| `PIHOLE` | [pihole_scan](plugins/pihole_scan.md) | 🆎/📥 | Pi-hole device import & sync | | | +| `PIHOLEAPI` | [pihole_api_scan](plugins/pihole_api_scan.md) | 🆎/📥 | Pi-hole device import & sync via API v6+ | | | +| `PIHOLEMON` | [pihole_monitor](plugins/pihole_monitor.md) | 🆎/📥 | Blocked-query anomaly detection (includes primary and secondary DNS import from Pi-hole) | | | +| `PUSHSAFER` | [_publisher_pushsafer](plugins/_publisher_pushsafer.md) | ▶️ | Pushsafer notifications | | | +| `PUSHOVER` | [_publisher_pushover](plugins/_publisher_pushover.md) | ▶️ | Pushover notifications | | | +| `RSTIMPRT` | [rest_import](plugins/rest_import.md) | 📥/🆎 | Import via a REST API endpoint | 🖧 | | +| `SETPWD` | [set_password](plugins/set_password.md) | ⚙ | Set password | | Yes | +| `SMTP` | [_publisher_email](plugins/_publisher_email.md) | ▶️ | Email notifications | | | +| `SNMPDSC` | [snmp_discovery](plugins/snmp_discovery.md) | 🔍/📥 | SNMP device import & sync | | | +| `SYNC` | [sync](plugins/sync.md) | ⚙/📥 | Sync & import from NetAlertX instances | 🖧 🔄 | Yes | +| `TELEGRAM` | [_publisher_telegram](plugins/_publisher_telegram.md) | ▶️ | Telegram notifications | | | +| `UI` | [ui_settings](plugins/ui_settings.md) | ♻ | UI specific settings | | Yes | +| `UNFIMP` | [unifi_import](plugins/unifi_import.md) | 📥/🆎 | UniFi device import & sync | 🖧 | | +| `UNIFIAPI` | [unifi_api_import](plugins/unifi_api_import.md) | 📥/🆎 | UniFi device import (SM API, multi-site) | | | +| `VNDRPDT` | [vendor_update](plugins/vendor_update.md) | ⚙ | Vendor database update | | | +| `WEBHOOK` | [_publisher_webhook](plugins/_publisher_webhook.md) | ▶️ | Webhook notifications | | | +| `WEBMON` | [website_monitor](plugins/website_monitor.md) | ♻ | Website down monitoring | | | +| `WOL` | [wake_on_lan](plugins/wake_on_lan.md) | ♻ | Automatic wake-on-lan | | | > \* The database cleanup plugin (`DBCLNP`) is not _required_ but the app will become unusable after a while if not executed. @@ -121,4 +123,4 @@ Plugins can be enabled via Settings, and can be disabled as needed. ## 🆕 Developing new custom plugins -If you want to develop a custom plugin, please read this [Plugin development guide](./PLUGINS_DEV.md). \ No newline at end of file +If you want to develop a custom plugin, please read this [Plugin development guide](./PLUGINS_DEV.md). diff --git a/docs/PLUGINS_DEV_DATA_CONTRACT.md b/docs/PLUGINS_DEV_DATA_CONTRACT.md index a73a06ab1..c2ad2f14e 100644 --- a/docs/PLUGINS_DEV_DATA_CONTRACT.md +++ b/docs/PLUGINS_DEV_DATA_CONTRACT.md @@ -18,7 +18,7 @@ Plugins communicate with NetAlertX by writing results to a **pipe-delimited log ## Using `plugin_helper.py` -The easiest way to ensure correct output is to use the [`plugin_helper.py`](../server/plugins/plugin_helper.py) library: +The easiest way to ensure correct output is to use the [`plugin_helper.py`](https://github.com/netalertx/NetAlertX/blob/main/server/plugins/plugin_helper.py) library: ```python from plugin_helper import Plugin_Objects diff --git a/docs/REMOTE_NETWORKS.md b/docs/REMOTE_NETWORKS.md index 18b3d0cda..523d594ec 100755 --- a/docs/REMOTE_NETWORKS.md +++ b/docs/REMOTE_NETWORKS.md @@ -58,10 +58,10 @@ Using supplementing plugins that employ alternate discovery methods is one of th ### Workaround: Multiple NetAlertX Instances if you have servers in all networks -If you have servers in different networks, you can set up separate NetAlertX instances on those subnets and synchronize the results into one instance using the [`SYNC` plugin](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/sync). +If you have servers in different networks, you can set up separate NetAlertX instances on those subnets and synchronize the results into one instance using the [`SYNC` plugin](https://docs.netalertx.com/plugins/sync). > [!TIP] -> The [`SYNC_BEHAVIOR`](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/sync/README.md#hub-device-write-behavior-sync_behavior) setting controls how the hub handles newly discovered devices from nodes - whether it inherits node config, overwrites on every sync, or applies its own `NEWDEV` defaults. +> The [`SYNC_BEHAVIOR`](https://docs.netalertx.com/plugins/sync#hub-device-write-behavior-sync_behavior) setting controls how the hub handles newly discovered devices from nodes - whether it inherits node config, overwrites on every sync, or applies its own `NEWDEV` defaults. ### Workaround: Manual Entry for devices you can `ping` @@ -78,4 +78,4 @@ Scanning remote networks with NMAP is possible (via the `NMAPDEV` plugin), but s Because the generated MAC address is derived from the IP address, changing the IP can cause the device to appear as a new device or create duplicate records. If this setting is disabled, devices with a missing MAC addresses will be skipped. -Check the [NMAPDEV plugin](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/nmap_dev_scan) for details. +Check the [NMAPDEV plugin](https://docs.netalertx.com/plugins/nmap_dev_scan) for details. diff --git a/docs/REST_IMPORT.md b/docs/REST_IMPORT.md index 0ed29d478..2a6009ad4 100755 --- a/docs/REST_IMPORT.md +++ b/docs/REST_IMPORT.md @@ -3,7 +3,7 @@ This guide shows you how to configure **OPNsense/Dnsmasq** in the **RSTIMPRT** plugin. > [!NOTE] -> See the [detailed documentation for the REST import plugin](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/rest_import/) for additional details. +> See the [detailed documentation for the REST import plugin](https://docs.netalertx.com/plugins/rest_import) for additional details. ## 1. Create an OPNsense user 1. In OPNsense, navigate to **System** → **Access** → **Users** diff --git a/docs/gen_plugin_pages.py b/docs/gen_plugin_pages.py new file mode 100644 index 000000000..76f22b9e5 --- /dev/null +++ b/docs/gen_plugin_pages.py @@ -0,0 +1,140 @@ +"""Generate docs pages for each plugin from its own README.md (and extras). + +Runs at `mkdocs build`/`mkdocs serve` time via the `gen-files` plugin +(see mkdocs.yml). Every server/plugins//README.md becomes a virtual +page at plugins/.md, so the docs site always mirrors the current +README instead of the two drifting out of sync or docs linking out to +GitHub. A plugins/SUMMARY.md is generated alongside them for the +`literate-nav` plugin, which turns it into the "Plugins reference" nav +section referenced from mkdocs.yml (`plugins/`). + +Two things beyond the README text itself get carried over, since a plugin +folder can contain more than just README.md: + - Sibling images (screenshots) are copied to plugins//, and + any reference to them in the README - absolute repo path, `./relative`, + or bare filename - is rewritten to point at the copied location. + - Any other *.md file in the same folder (e.g. a provider-specific + sub-guide) gets its own generated page at plugins//.md, + added to the nav under its own first H1 heading (or the filename if it + has none). + +To exclude a plugin from the generated reference (e.g. a dev scaffold, +not a real plugin), prefix its folder name with double underscores - +see the __template skip below. +""" + +import json +import re +from pathlib import Path + +import mkdocs_gen_files + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLUGINS_DIR = REPO_ROOT / "server" / "plugins" +GITHUB_BLOB_BASE = "https://github.com/netalertx/NetAlertX/blob/main" +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"} + +nav = mkdocs_gen_files.Nav() + + +def generated_note(source_rel_to_repo): + return ( + "!!! note \"Generated page\"\n" + f" This page mirrors [`{source_rel_to_repo}`]" + f"({GITHUB_BLOB_BASE}/{source_rel_to_repo}) and is regenerated on every docs build.\n\n" + ) + + +for readme_path in sorted(PLUGINS_DIR.glob("*/README.md")): + plugin_dir = readme_path.parent + slug = plugin_dir.name + + if slug.startswith("__"): + continue # dev scaffolding (e.g. __template), not a real plugin + + config_path = plugin_dir / "config.json" + display_name = slug + unique_prefix = None + if config_path.exists(): + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + except (ValueError, OSError): + config = {} + names = config.get("display_name") or [] + for entry in names: + if entry.get("language_code") == "en_us" and entry.get("string"): + display_name = entry["string"] + break + unique_prefix = config.get("unique_prefix") + + title = f"{display_name} ({unique_prefix})" if unique_prefix else display_name + + doc_path = f"{slug}.md" # relative to plugins/SUMMARY.md + full_doc_path = f"plugins/{doc_path}" + source_rel_to_repo = readme_path.relative_to(REPO_ROOT).as_posix() + + nav[title] = doc_path + + readme_text = readme_path.read_text(encoding="utf-8") + + # Copy sibling image assets and rewrite whatever form the README uses to + # reference them (absolute repo path, ./relative, or bare filename) to + # the copied location - only the README's *text* is otherwise pulled + # into the site, so a plugin's local screenshots would 404 silently. + for asset_path in sorted(plugin_dir.iterdir()): + if asset_path.suffix.lower() not in IMAGE_EXTENSIONS: + continue + asset_name = asset_path.name + with mkdocs_gen_files.open(f"plugins/{slug}/{asset_name}", "wb") as out: + out.write(asset_path.read_bytes()) + readme_text = re.sub( + rf'\]\((?:\./|/server/plugins/{re.escape(slug)}/)?{re.escape(asset_name)}\)', + f']({slug}/{asset_name})', + readme_text, + ) + + with mkdocs_gen_files.open(full_doc_path, "w") as f: + f.write(f"# {title}\n\n") + f.write(generated_note(source_rel_to_repo)) + f.write(readme_text) + + # Point the theme's "edit this page" button at the real source file + # instead of the virtual doc path, which doesn't exist in the repo. + mkdocs_gen_files.set_edit_path(full_doc_path, f"../{source_rel_to_repo}") + + # Any other markdown file alongside the README is a plugin-specific + # sub-guide (e.g. dhcp_leases/ASUS_ROUTERS.md) - give it its own page + # too, rather than leaving it undiscoverable outside GitHub. + for extra_md in sorted(plugin_dir.glob("*.md")): + if extra_md.name == "README.md": + continue + + extra_text = extra_md.read_text(encoding="utf-8") + lines = extra_text.lstrip("\n").split("\n", 1) + first_line = lines[0] + if first_line.startswith("# "): + extra_title = first_line[2:].strip() + body = lines[1].lstrip("\n") if len(lines) > 1 else "" + else: + extra_title = extra_md.stem.replace("_", " ").title() + body = extra_text + + extra_doc_path = f"{slug}/{extra_md.stem}.md" + extra_full_doc_path = f"plugins/{extra_doc_path}" + extra_source_rel = extra_md.relative_to(REPO_ROOT).as_posix() + + nav[extra_title] = extra_doc_path + + with mkdocs_gen_files.open(extra_full_doc_path, "w") as f: + f.write(f"# {extra_title}\n\n") + f.write(generated_note(extra_source_rel)) + f.write(body) + + # edit_path is concatenated directly after the fixed `edit_uri: blob/main/docs/` + # prefix (it is NOT relative to this generated file's own directory), so it + # always needs exactly one `../` to escape "docs/" regardless of how deeply + # nested the virtual page itself is - same as the top-level README case above. + mkdocs_gen_files.set_edit_path(extra_full_doc_path, f"../{extra_source_rel}") + +with mkdocs_gen_files.open("plugins/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/front/devices.php b/front/devices.php index ab956628c..fb06cdc73 100755 --- a/front/devices.php +++ b/front/devices.php @@ -151,1131 +151,8 @@ - - + + + + + diff --git a/front/js/common.js b/front/js/common.js index 13cc8340a..fb45fce65 100755 --- a/front/js/common.js +++ b/front/js/common.js @@ -1381,6 +1381,43 @@ function getDevicesList() return devicesList; } +// ----------------------------------------------------------------------------- +// Resolve {{fieldName}} wildcards in a template string against a device object +// (case-insensitive field names, so {{devLastIp}} matches devLastIP). Unknown +// fields are left as the literal "{{fieldName}}" placeholder so a typo while +// configuring e.g. a custom property stays visible instead of silently +// disappearing. escapeFn is applied to each substituted VALUE only (not the +// surrounding template text) - pass encodeURIComponent when the template is a +// URL, or leave the default identity function for plain text the caller will +// HTML-escape itself afterwards. +function resolveDeviceWildcards(str, device, escapeFn = (v) => v) { + if (!str || typeof str !== 'string' || str.indexOf('{{') === -1 || !device) { + return str; + } + + const lowerCaseFields = {}; + for (const key in device) { + lowerCaseFields[key.toLowerCase()] = device[key]; + } + + return str.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, fieldName) => { + const lowerFieldName = fieldName.toLowerCase(); + if (!(lowerFieldName in lowerCaseFields)) { + return match; // unknown field - leave the placeholder visible + } + const value = lowerCaseFields[lowerFieldName]; + return escapeFn(value === null || value === undefined ? '' : String(value)); + }); +} + +// ----------------------------------------------------------------------------- +// Convenience wrapper for callers that only have a MAC, not a full device +// object already in scope - looks the device up from the cached device list. +function resolveDeviceWildcardsByMac(str, mac, escapeFn = (v) => v) { + const device = getDevicesList().find(d => d.devMac === mac); + return device ? resolveDeviceWildcards(str, device, escapeFn) : str; +} + // ----------------------------------------------------------------------------- // apply theme diff --git a/front/js/device.js b/front/js/device.js index 7dbffa1c9..019fa8b00 100755 --- a/front/js/device.js +++ b/front/js/device.js @@ -43,4 +43,28 @@ function deleteDeviceByMac(mac) { }); } +// ----------------------------------------------------------------------------- +// Manually trigger an on-demand plugin run, regardless of its configured RUN +// schedule. Used by the "run_plugin" device custom property action, where +// `prefix` is the plugin's unique_prefix (e.g. NMAPDEV) supplied via CUSTPROP_args. +function runPlugin(prefix, name) { + const apiBase = getApiBase(); + const apiToken = getSetting("API_TOKEN"); + const url = `${apiBase}/plugin/${encodeURIComponent(prefix)}/run`; + const label = name || prefix; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + success: function(response) { + showMessage(response.success ? `Run triggered for ${label}` : (response.error || "Unknown error")); + }, + error: function(xhr, status, error) { + console.error("Error running plugin:", status, error); + showMessage("Error: " + (xhr.responseJSON?.error || error)); + } + }); +} + diff --git a/front/js/devices-custom-props.js b/front/js/devices-custom-props.js new file mode 100644 index 000000000..5ce204717 --- /dev/null +++ b/front/js/devices-custom-props.js @@ -0,0 +1,97 @@ +// ============================================================================= +// devices-custom-props.js — Devices list page: per-device custom property +// action icons (see docs/CUSTOM_PROPERTIES.md). +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Handle custom actions/properties on a device. +// +// CUSTPROP_name and CUSTPROP_args support {{fieldName}} wildcards (e.g. +// {{devLastIP}}) resolved against this row's own fields - see GH #1773. The +// resulting action is dispatched via a single delegated click handler reading +// data-* attributes (see below) rather than an inline onclick="..." string, +// so a device-controlled value (a DHCP hostname containing a quote, say) +// can't break out of inline JS the way it could with string-built onclick +// handlers. +function renderCustomProps(custProps, device) { + const mac = device.devMac; + + if (!isBase64(custProps)) { + console.error(`Unable to decode CustomProps for ${mac}`); + console.error(custProps); + return "Error, check browser Console log"; + } + + const props = JSON.parse(atob(custProps)); + let html = ""; + + props.forEach((propGroup) => { + const propMap = Object.fromEntries( + propGroup.map(prop => Object.entries(prop)[0]) // Convert array of objects to key-value pairs + ); + + if (propMap["CUSTPROP_show"] !== true) { + return; // Not visible + } + + const type = propMap["CUSTPROP_type"]; + const isUrlType = (type === "link" || type === "link_new_tab"); + + // Plain (unescaped) resolved values, for the human-readable tooltip. + const namePlain = resolveDeviceWildcards(propMap["CUSTPROP_name"], device); + const argsPlain = resolveDeviceWildcards(propMap["CUSTPROP_args"], device); + + // The value actually used for navigation: URL-encode substituted fields + // when this prop's args is a URL, so e.g. a device name with a space or + // "&" in it can't corrupt the query string. + const argsForAction = isUrlType + ? resolveDeviceWildcards(propMap["CUSTPROP_args"], device, encodeURIComponent) + : argsPlain; + + const notesPlain = propMap["CUSTPROP_notes"] || ""; + + html += `
+ ${atob(propMap["CUSTPROP_icon"])} +
`; + }); + + return html; +} + +// Single delegated handler for every custom-property action rendered by +// renderCustomProps() above - bound once, so it keeps working after +// DataTables redraws without needing to be re-attached per row. +$(document).on('click', '.devicePropAction', function () { + const el = $(this); + const action = el.data('action'); + const args = el.data('args') ?? ''; + const name = el.data('name') ?? ''; + const notes = el.data('notes') ?? ''; + const mac = el.data('mac') ?? ''; + + switch (action) { + case "show_notes": + showModalOK(name, notes); + break; + case "link": + window.location.href = args; + break; + case "link_new_tab": + openInNewTab(args); + break; + case "run_plugin": + runPlugin(args, name); + break; + case "delete_dev": + askDeleteDeviceByMac(mac); + break; + default: + break; + } +}); diff --git a/front/js/devices-filters.js b/front/js/devices-filters.js new file mode 100644 index 000000000..1296a9ba7 --- /dev/null +++ b/front/js/devices-filters.js @@ -0,0 +1,195 @@ +// ============================================================================= +// devices-filters.js — Devices list page: column filter dropdowns. +// +// Fetches the available filter values, renders the filter dropdowns, and +// collects the currently-selected filter values for the DataTable's ajax +// query (see devices-table.js). +// ============================================================================= + +// ----------------------------------------------------------------------------- +//Render filters if specified +// NOTE: this is the only top-level `let` among the devices-*.js files (everything +// else is `var`) — a second top-level `let`/`const columnFilters` anywhere else +// on this page would throw a page-breaking SyntaxError at parse time, unlike a +// duplicate `var` which is silently allowed. Keep this declaration unique. +let columnFilters = []; + +function initFilters() { + // Attempt to fetch data + $.ajax({ + url: 'php/server/query_json.php', + type: "GET", + dataType: "json", + data: { + file: 'table_devices_filters.json', // Pass the file parameter + nocache: Date.now() // Prevent caching with a timestamp + }, + success: function(response) { + if (response && response.data) { + + let resultJSON = response.data; + + // Save the result to cache + setCache("devicesFilters", JSON.stringify(resultJSON)); + + // Get the displayed filters from settings + const displayedFilters = createArray(getSetting("UI_columns_filters")); + + // Clear any existing filters in the DOM + $('#columnFilters').empty(); + + // Ensure displayedFilters is an array and not empty + if (Array.isArray(displayedFilters) && displayedFilters.length > 0) { + $('#columnFiltersWrap').removeClass("hidden"); + + displayedFilters.forEach(columnHeaderStringKey => { + // Get the column name using the mapping function + const columnName = getColumnNameFromLangString(columnHeaderStringKey); + + // Ensure columnName is valid before proceeding + if (columnName) { + // Add the filter to the columnFilters array as [columnName, columnHeaderStringKey] + columnFilters.push([columnName, columnHeaderStringKey]); + } else { + console.warn(`Invalid column header string key: ${columnHeaderStringKey}`); + } + }); + + // Filter resultJSON to include only entries with columnName in columnFilters + resultJSON = resultJSON.filter(entry => + columnFilters.some(filter => filter[0] === entry.columnName) + ); + + // Expand resultJSON to include the columnHeaderStringKey + resultJSON.forEach(entry => { + // Find the matching columnHeaderStringKey from columnFilters + const matchingFilter = columnFilters.find(filter => filter[0] === entry.columnName); + + // Add the columnHeaderStringKey to the entry + if (matchingFilter) { + entry['columnHeaderStringKey'] = matchingFilter[1]; + } + }); + + console.log(resultJSON); + + // Transforming the data + const transformed = { + filters: [] + }; + + // Build filters in the exact order of columnFilters + columnFilters.forEach(([columnName, headerKey]) => { + // Get matching entries for this column + const entries = resultJSON.filter(e => e.columnName === columnName); + + if (entries.length === 0) return; + + // Build options (unique) + const optionsMap = new Map(); + + entries.forEach(entry => { + const value = entry.columnValue; + const label = entry.columnLabel || value; + + if (!optionsMap.has(value)) { + optionsMap.set(value, { value, label }); + } + }); + + const options = Array.from(optionsMap.values()); + + // Sort options alphabetically + options.sort((a, b) => a.label.localeCompare(b.label)); + + transformed.filters.push({ + column: columnName, + headerKey: headerKey, + options: options + }); + }); + + // Sort options alphabetically by label for better readability + transformed.filters.forEach(filter => { + filter.options.sort((a, b) => a.label.localeCompare(b.label)); + }); + + // Output the result + transformedJson = transformed + + // Process the fetched data + renderFilters(transformedJson); + } else { + console.log("No filters to display."); + } + } else { + console.error("Invalid response format from API"); + } + }, + error: function(xhr, status, error) { + console.error("Failed to fetch devices data 'table_devices_filters.json':", error); + } + }); +} + + +// ------------------------------------------- +// Server side component +function renderFilters(customData) { + + // console.log(JSON.stringify(customData)); + + // Load filter data from the JSON file + $.ajax({ + url: 'php/components/devices_filters.php', // PHP script URL + data: { filterObject: JSON.stringify(customData) }, // Send customData as JSON + type: 'POST', + dataType: 'html', + success: function(response) { + // console.log(response); + + $('#columnFilters').html(response); // Replace container content with fetched HTML + $('#columnFilters').removeClass('hidden'); // Show the filters container + + // Trigger the draw after select change + $('.filter-dropdown').on('change', function() { + // Collect filters + const columnFilters = collectFilters(); + + // Apply column filters then draw once (previously drew twice — bug fixed). + const table = $('#tableDevices').DataTable(); + table.columnFilters = columnFilters; + table.draw(); + }); + + }, + error: function(xhr, status, error) { + console.error('Error fetching filters:', error); + } + }); +} + +// ------------------------------------------- +// Function to collect filters +function collectFilters() { + const columnFilters = []; + + // Loop through each filter group + document.querySelectorAll('.filter-group').forEach(filterGroup => { + const dropdown = filterGroup.querySelector('.filter-dropdown'); + + if (dropdown) { + const filterColumn = dropdown.getAttribute('data-column'); + const filterValue = dropdown.value; + + if (filterValue && filterColumn) { + columnFilters.push({ + filterColumn: filterColumn, + filterValue: filterValue + }); + } + } + }); + + return columnFilters; +} diff --git a/front/js/devices-init.js b/front/js/devices-init.js new file mode 100644 index 000000000..575a0d4e5 --- /dev/null +++ b/front/js/devices-init.js @@ -0,0 +1,109 @@ +// ============================================================================= +// devices-init.js — Page bootstrap for devices.php. +// +// Declares the shared page-level state (deviceStatus, tableColumnOrder, etc.) +// used across the other devices-*.js files, wires up the mobile-collapse +// behavior, and defines main() — the entry point run once the app has +// finished initializing (see callAfterAppInitialized below). +// +// Loaded last (after devices-totals.js, devices-filters.js, devices-table.js, +// devices-custom-props.js) since it's the orchestrator that calls into them — +// though since these are plain global var/function declarations sharing one +// scope, load order among the devices-*.js files doesn't affect correctness. +// ============================================================================= + +var deviceStatus = 'all'; + +var tableOrder = getCache ("nax_parTableOrder") == "" ? [[3,'desc'], [0,'asc']] : JSON.parse(getCache ("nax_parTableOrder")) ; + +var tableColumnHide = []; +var tableColumnOrder = []; +var tableColumnVisible = []; +headersDefaultOrder = []; +missingNumbers = []; + +// DEVICE_COLUMN_FIELDS, COL, NUMERIC_DEFAULTS, GRAPHQL_EXTRA_FIELDS, COLUMN_NAME_MAP +// are all defined in js/device-columns.js — edit that file to add new columns. + +// Collapse DevicePresence and Filters sections by default on small/mobile screens +(function collapseOnMobile() { + if (window.innerWidth < 768) { + ['#clients', '#columnFiltersWrap'].forEach(function(sel) { + var $box = $(sel); + if ($box.length) { + $box.addClass('collapsed-box'); + $box.find('.box-body, .box-footer').hide(); + $box.find('[data-widget="collapse"] i').removeClass('fa-minus').addClass('fa-plus'); + } + }); + } +})(); + +// Read parameters & Initialize components +callAfterAppInitialized(main) +showSpinner(); + +// ----------------------------------------------------------------------------- +function hideDevicesSkeleton() { + $('#devices-skeleton').fadeOut(0, function() { $(this).remove(); }); +} + +// Fallback: ensure skeleton is removed even if DataTable fails to initialize +setTimeout(hideDevicesSkeleton, 15000); + +// ----------------------------------------------------------------------------- +function main () { + + showSpinner(); + + initFilters(); + + // render tiles + getDevicesTotals(); + + //initialize the table headers in the correct order + var availableColumns = getSettingOptions("UI_device_columns").split(","); + headersDefaultOrder = availableColumns.map(val => getString(val)); + + var selectedColumns = JSON.parse(getSetting("UI_device_columns").replace(/'/g, '"')); + + // generate default order lists of given length + var columnsStr = JSON.stringify(Array.from({ length: headersDefaultOrder.length }, (_, i) => i)); + tableColumnOrder = Array.from({ length: headersDefaultOrder.length }, (_, i) => i); + tableColumnVisible = []; + + // Initialize tableColumnVisible by including all columns from selectedColumns, preserving their order. + tableColumnVisible = selectedColumns.map(column => availableColumns.indexOf(column)).filter(index => index !== -1); + + // Add any columns from availableColumns that are not in selectedColumns to the end. + const remainingColumns = availableColumns.map((column, index) => index).filter(index => !tableColumnVisible.includes(index)); + + // Combine both arrays. + tableColumnOrder = tableColumnVisible.concat(remainingColumns); + + // Generate the full array of numbers from 0 to totalLength - 1 of tableColumnOrder + const fullArray = Array.from({ length: tableColumnOrder.length }, (_, i) => i); + + // Filter out the elements already present in inputArray + missingNumbers = fullArray.filter(num => !tableColumnVisible.includes(num)); + + // Concatenate the inputArray with the missingNumbers + tableColumnOrder = [...tableColumnVisible, ...missingNumbers]; + + // Initialize components with parameters + initializeDatatable(getUrlAnchor('my_devices')); + + // check if data outdated and show spinner if so + handleLoadingDialog() + +} + +// ----------------------------------------------------------------------------- +// Update cache with shown devices before navigating away +window.addEventListener('beforeunload', function(event) { + // Call your function here + macs = getMacsOfShownDevices(); + + setCache("ntx_visible_macs", macs) + +}); diff --git a/front/js/devices-table.js b/front/js/devices-table.js new file mode 100644 index 000000000..8eb004a9d --- /dev/null +++ b/front/js/devices-table.js @@ -0,0 +1,686 @@ +// ============================================================================= +// devices-table.js — Devices list page: the main devices DataTable. +// +// Everything about the devices DataTable in one cohesive file: column-index +// mapping helpers, the scan-ETA/empty-state messaging it displays while no +// devices have been discovered yet, and the DataTable itself (server-side +// GraphQL-backed ajax source, column rendering, multi-edit, MAC collection). +// ============================================================================= + +// ----------------------------------------------------------------------------- +// mapping the default order to the user specified one +function mapIndx(oldIndex) +{ + // console.log(oldIndex); + // console.log(tableColumnOrder); + + for(i=0;i { + device[field] = rowData[mapIndx(oldIndex)]; + }); + return device; +} + +// --------------------------------------------------------- +// Build the rich empty-table onboarding message (HTML). +// Used as the DataTables 'emptyTable' language option. +function buildEmptyDeviceTableMessage(nextScanLabel) { + var etaLine = nextScanLabel + ? '' + nextScanLabel + '' + : ''; + return '
' + + '
' + + '' + getString('Device_NoData_Title') + '
' + + '' + getString('Device_NoData_Scanning') + '
' + + etaLine + + '' + getString('Device_NoData_Help') + '' + + '
'; +} + +// --------------------------------------------------------- +// Compute a live countdown label from an ISO next_scan_time string. +// next_scan_time is the earliest scheduled run time across enabled device_scanner plugins, +// computed by the backend and broadcast via SSE — no guesswork needed on the frontend. +function computeNextScanLabel(nextScanTime) { + if (!nextScanTime) return getString('Device_NextScan_Imminent'); + // Append Z if no UTC offset marker present — backend may emit naive UTC ISO strings. + var isoStr = /Z$|[+-]\d{2}:?\d{2}$/.test(nextScanTime.trim()) ? nextScanTime : nextScanTime + 'Z'; + var secsLeft = Math.round((new Date(isoStr).getTime() - Date.now()) / 1000); + if (secsLeft <= 0) return getString('Device_NextScan_Imminent'); + if (secsLeft >= 60) { + var m = Math.floor(secsLeft / 60); + var s = secsLeft % 60; + return getString('Device_NextScan_In') + m + 'm ' + s + 's'; + } + return getString('Device_NextScan_In') + secsLeft + 's'; +} + +// Anchor for next scheduled scan time, ticker handle, plugins data, and current state — module-level. +var _nextScanTimeAnchor = null; +var _currentStateAnchor = null; +var _scanEtaTickerId = null; +var _pluginsData = null; +var _wasImminent = false; // true once the countdown displayed "imminent"; gates the Scanning... label +var _imminentForTime = null; // the _nextScanTimeAnchor value that last set _wasImminent + // prevents re-arming on the same (already-consumed) timestamp + +// Returns true when the backend is actively scanning (not idle). +// Uses an exclusion approach — only "Process: Idle" and an empty/null state are non-scanning. +// This future-proofs against new states added to the scan pipeline (e.g. "Plugin: AVAHISCAN"). +function isScanningState(state) { + return !!state && state !== 'Process: Idle'; +} + +// Fetch plugins.json once on page load so we can guard ETA display to device_scanner plugins only. +$.get('php/server/query_json.php', { file: 'plugins.json', nocache: Date.now() }, function(res) { + _pluginsData = res['data'] || []; +}); + +// Returns true only when at least one device_scanner plugin is loaded and not disabled. +function hasEnabledDeviceScanners() { + if (!_pluginsData || !_pluginsData.length) return false; + return getPluginsByType(_pluginsData, 'device_scanner', true).length > 0; +} + +// --------------------------------------------------------- +// Update the title-bar ETA subtitle and the DataTables empty-state message. +// Called on every nax:scanEtaUpdate; the inner ticker keeps the title bar live between events. +function updateScanEtaDisplay(nextScanTime, currentState) { + // Detect scan-finished transition BEFORE updating _currentStateAnchor. + // justFinishedScanning is true only when the backend transitions scanning → idle. + var justFinishedScanning = (currentState === 'Process: Idle') && isScanningState(_currentStateAnchor); + + // Prefer the backend-computed values; keep previous anchors if not yet received. + _nextScanTimeAnchor = nextScanTime || _nextScanTimeAnchor; + _currentStateAnchor = currentState || _currentStateAnchor; + + // Reset the imminent gate when the scan finishes back to idle so the next cycle starts clean. + if (currentState === 'Process: Idle') { _wasImminent = false; } + + // Restart the per-second title-bar ticker + if (_scanEtaTickerId !== null) { clearInterval(_scanEtaTickerId); } + + function getEtaLabel() { + if (!hasEnabledDeviceScanners()) return ''; + if (isScanningState(_currentStateAnchor) && _wasImminent) return getString('Device_Scanning'); + var label = computeNextScanLabel(_nextScanTimeAnchor); + // Arm _wasImminent only for a NEW next_scan_time anchor — not the already-consumed one. + // This prevents the ticker from re-arming immediately after "Process: Idle" resets the flag + // while _nextScanTimeAnchor still holds the now-past timestamp. + if (label === getString('Device_NextScan_Imminent') && _nextScanTimeAnchor !== _imminentForTime) { + _wasImminent = true; + _imminentForTime = _nextScanTimeAnchor; + } + return label; + } + + function tickTitleBar() { + var eta = document.getElementById('nextScanEta'); + if (!eta) return; + var label = getEtaLabel(); + if (!label) { eta.style.display = 'none'; return; } + eta.textContent = label; + eta.style.display = ''; + } + + // Update DataTables empty message once per SSE event. + // NOTE: Do NOT call dt.draw() here — on page load the SSE queue replays all + // accumulated events at once, causing a draw() (= GraphQL AJAX call) per event. + // Instead, update the visible empty-state DOM cell directly. + var label = getEtaLabel(); + if ($.fn.DataTable.isDataTable('#tableDevices')) { + var dt = $('#tableDevices').DataTable(); + var newEmptyMsg = buildEmptyDeviceTableMessage(label); + dt.settings()[0].oLanguage.sEmptyTable = newEmptyMsg; + if (dt.page.info().recordsTotal === 0) { + // Patch the visible cell text without triggering a server-side AJAX reload. + $('#tableDevices tbody .dataTables_empty').html(newEmptyMsg); + } + + // When scanning just finished and the table is still empty, reload data so + // newly discovered devices appear automatically. Skip reload if there are + // already rows — no need to disturb the user's current view. + if (justFinishedScanning && dt.page.info().recordsTotal === 0) { + dt.ajax.reload(null, false); // false = keep current page position + } + } + + tickTitleBar(); + _scanEtaTickerId = setInterval(tickTitleBar, 1000); +} + +// Listen for scan ETA updates dispatched by sse_manager.js (SSE push or poll fallback) +document.addEventListener('nax:scanEtaUpdate', function(e) { + updateScanEtaDisplay(e.detail.nextScanTime, e.detail.currentState); +}); + +// --------------------------------------------------------- +// Initializes the main devices list datatable +function initializeDatatable (status) { + + if(!status) + { + status = 'my_devices' + } + + // retrieve page size + var tableRows = getCache ("nax_parTableRows") == "" ? parseInt(getSetting("UI_DEFAULT_PAGE_SIZE")) : getCache ("nax_parTableRows") ; + + // Save status selected + deviceStatus = status; + + // Define color & title for the status selected + switch (deviceStatus) { + case 'my_devices': tableTitle = getString('Device_Shortcut_AllDevices'); color = 'aqua'; break; + case 'connected': tableTitle = getString('Device_Shortcut_Connected'); color = 'green'; break; + case 'all': tableTitle = getString('Gen_All_Devices'); color = 'aqua'; break; + case 'favorites': tableTitle = getString('Device_Shortcut_Favorites'); color = 'yellow'; break; + case 'new': tableTitle = getString('Device_Shortcut_NewDevices'); color = 'yellow'; break; + case 'down': tableTitle = getString('Device_Shortcut_DownOnly'); color = 'red'; break; + case 'archived': tableTitle = getString('Device_Shortcut_Archived'); color = 'gray'; break; + case 'offline': tableTitle = getString('Gen_Offline'); color = 'gray'; break; + case 'all_devices': tableTitle = getString('Gen_All_Devices'); color = 'gray'; break; + case 'network_devices': tableTitle = getString('Network_Devices'); color = 'aqua'; break; + default: tableTitle = getString('Device_Shortcut_Devices'); color = 'gray'; break; + } + + // Set title and color + $('#tableDevicesTitle')[0].className = 'box-title text-'+ color; + $('#tableDevicesBox')[0].className = 'box box-'+ color; + $('#tableDevicesTitle').html (tableTitle); + + // render table headers + html = ''; + + for(index = 0; index < tableColumnOrder.length; index++) + { + html += '' + headersDefaultOrder[tableColumnOrder[index]] + ''; + } + + $('#tableDevices tr').html(html); + + hideUIelements("UI_DEV_SECTIONS") + + for(i = 0; i < tableColumnOrder.length; i++) + { + // hide this column if not in the tableColumnVisible variable (we need to keep the MAC address (index 11) for functionality reasons) + if(tableColumnVisible.includes(tableColumnOrder[i]) == false) + { + tableColumnHide.push(mapIndx(tableColumnOrder[i])); + } + } + + var table = $('#tableDevices').DataTable({ + "serverSide": true, + "processing": true, + "ajax": { + "url": 'php/server/query_graphql.php', // PHP endpoint that proxies to the GraphQL server + "type": "POST", + "contentType": "application/json", + "data": function (d) { + // GraphQL fields are derived from DEVICE_COLUMN_FIELDS + GRAPHQL_EXTRA_FIELDS + // (both defined in js/device-columns.js). No manual field list to maintain. + const _gqlFields = [...new Set([...DEVICE_COLUMN_FIELDS, ...GRAPHQL_EXTRA_FIELDS])] + .join('\n '); + let graphqlQuery = ` + query devices($options: PageQueryOptionsInput) { + devices(options: $options) { + devices { + ${_gqlFields} + } + count + dbCount + } + } + `; + + console.log(d); + + // Handle empty filters + let columnFilters = collectFilters(); + if (columnFilters.length === 0) { + columnFilters = []; + } + + + // Prepare query variables for pagination, sorting, and search + let query = { + "operationName": null, + "query": graphqlQuery, + "variables": { + "options": { + "page": Math.floor(d.start / d.length) + 1, // Page number (1-based) + "limit": parseInt(d.length, 10), // Page size (ensure it's an integer) + "sort": d.order && d.order[0] ? [{ + "field": mapColumnIndexToFieldName(d.order[0].column, tableColumnVisible), // Sort field from DataTable column + "order": d.order[0].dir.toUpperCase() // Sort direction (ASC/DESC) + }] : [], // Default to an empty array if no sorting is defined + "search": d.search.value, // Search query + "status": deviceStatus, + "filters" : columnFilters + } + + } + }; + + return JSON.stringify(query); // Send the JSON request + }, + "dataSrc": function (res) { + + console.log("Raw response:", res); + const json = res["data"]; + + // recordsTotal = raw DB count (before filters/search) so DataTables uses emptyTable + // only when the DB is genuinely empty, and zeroRecords when a filter returns nothing. + res.recordsTotal = json.devices.dbCount || 0; + res.recordsFiltered = json.devices.count || 0; + + // console.log("recordsTotal:", res.recordsTotal, "recordsFiltered:", res.recordsFiltered); + // console.log("tableRows:", tableRows); + + // Return only the array of rows for the table + return json.devices.devices.map(device => { + // Build positional row directly from DEVICE_COLUMN_FIELDS. + // NUMERIC_DEFAULTS controls which fields default to 0 vs "". + // Adding a new column: add to DEVICE_COLUMN_FIELDS (and NUMERIC_DEFAULTS + // if needed) in js/device-columns.js — nothing to change here. + const originalRow = DEVICE_COLUMN_FIELDS.map( + field => device[field] ?? (NUMERIC_DEFAULTS.has(field) ? 0 : "") + ); + + const newRow = []; + // Reorder data based on user-defined columns order + for (let index = 0; index < tableColumnOrder.length; index++) { + newRow.push(originalRow[tableColumnOrder[index]]); + } + // Append extra (non-display) fields after the display columns so + // they are accessible in createdCell via COL_EXTRA.* + GRAPHQL_EXTRA_FIELDS.forEach(field => { + newRow.push(device[field] ?? (NUMERIC_DEFAULTS.has(field) ? 0 : "")); + }); + return newRow; + }); + } + }, + 'paging' : true, + 'lengthChange' : true, + 'lengthMenu' : getLengthMenu(parseInt(getSetting("UI_DEFAULT_PAGE_SIZE"))), + 'searching' : true, + + 'ordering' : true, + 'info' : true, + 'autoWidth' : false, + 'dom': '<"top"f>rtl<"bottom"ip><"clear">', + + // Parameters + 'pageLength' : tableRows, + 'order' : tableOrder, + 'select' : true, // Enable selection + + 'fixedHeader': true, + 'fixedHeader': { + 'header': true, + 'footer': true + }, + + 'columnDefs' : [ + {visible: false, targets: tableColumnHide }, + {className: 'text-center', targets: [mapIndx(COL.devFavorite), mapIndx(COL.devIsRandomMac), mapIndx(COL.devStatus), mapIndx(COL.devParentChildrenCount), mapIndx(COL.devParentPort)] }, + {className: 'iconColumn text-center', targets: [mapIndx(COL.devIcon)]}, + {width: '80px', targets: [mapIndx(COL.devFirstConnection), mapIndx(COL.devLastConnection), mapIndx(COL.devParentChildrenCount), mapIndx(COL.devFQDN)] }, + {width: '85px', targets: [mapIndx(COL.devIsRandomMac)] }, + {width: '130px', targets: [mapIndx(COL.devLastIP), mapIndx(COL.devIpLong)] }, + {width: '30px', targets: [mapIndx(COL.devIcon), mapIndx(COL.devStatus), mapIndx(COL.rowid), mapIndx(COL.devParentPort)] }, + {orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) }, + + // Device Name and FQDN + // Use `render` (not `createdCell`) so the HTML is built before DataTables + // sets td.innerHTML – preventing raw cellData from being parsed as HTML. + {targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)], + 'render': function (data, type, row) { + if (type !== 'display') { + return data; // raw value for sort / filter / type detection + } + + var displayedValue = encodeSpecialChars(data); + + if (isEmpty(displayedValue)) { + displayedValue = "N/A"; + } + + return ( + `` + + `${displayedValue}` + + `` + ); + } }, + + // Connected Devices + {targets: [mapIndx(COL.devParentChildrenCount)], + 'createdCell': function (td, cellData, rowData, row, col) { + // check if this is a network device + if(getSetting("NETWORK_DEVICE_TYPES").includes(`'${rowData[mapIndx(COL.devType)]}'`) ) + { + $(td).html (''+ cellData +''); + } + else + { + $(td).html (``) + } + + } }, + + // Icon + {targets: [mapIndx(COL.devIcon)], + 'createdCell': function (td, cellData, rowData, row, col) { + + if (!emptyArr.includes(cellData)){ + $(td).html (atob(cellData)); + } else { + $(td).html (''); + } + } }, + + // Full MAC + {targets: [mapIndx(COL.devMac)], + 'createdCell': function (td, cellData, rowData, row, col) { + if (!emptyArr.includes(cellData)){ + $(td).html (''+cellData+''); + } else { + $(td).html (''); + } + } }, + + // IP address + {targets: [mapIndx(COL.devLastIP)], + 'createdCell': function (td, cellData, rowData, row, col) { + if (!emptyArr.includes(cellData)){ + $(td).html (``); + } else { + $(td).html (''); + } + } + }, + // IP address (orderable) + {targets: [mapIndx(COL.devIpLong)], + 'createdCell': function (td, cellData, rowData, row, col) { + if (!emptyArr.includes(cellData)){ + $(td).html (`${cellData}`); + } else { + $(td).html (''); + } + } + }, + + // Custom Properties + {targets: [mapIndx(COL.devCustomProps)], + 'createdCell': function (td, cellData, rowData, row, col) { + if (!emptyArr.includes(cellData)){ + $(td).html (`${renderCustomProps(cellData, deviceObjectFromRowData(rowData))}`); + } else { + $(td).html (''); + } + } + }, + + // Favorite + {targets: [mapIndx(COL.devFavorite)], + 'createdCell': function (td, cellData, rowData, row, col) { + if (cellData == 1){ + $(td).html (''); + } else { + $(td).html (''); + } + } }, + + // Dates + {targets: [mapIndx(COL.devFirstConnection), mapIndx(COL.devLastConnection)], + 'createdCell': function (td, cellData, rowData, row, col) { + var result = cellData.toString(); // Convert to string + if (result.includes("+")) { // Check if timezone offset is present + result = result.split('+')[0]; // Remove timezone offset + } + $(td).html (translateHTMLcodes (result)); + } }, + + // Random MAC + {targets: [mapIndx(COL.devIsRandomMac)], + 'createdCell': function (td, cellData, rowData, row, col) { + // console.log(cellData) + if (cellData == 1){ + $(td).html (''); + } else { + $(td).html (''); + } + } }, + + // Parent Mac + {targets: [mapIndx(COL.devParentMAC)], + 'createdCell': function (td, cellData, rowData, row, col) { + if (!isValidMac(cellData)) { + $(td).html(''); + return; + } + + const data = { + id: cellData, // MAC address + text: cellData // Optional display text (you could use a name or something else) + }; + + spanWrap = $(``) + + $(td).html(spanWrap); + + const chipHtml = renderDeviceLink(data, spanWrap, true); // pass the td as container + + $(spanWrap).append(chipHtml); + } + }, + // Status color + {targets: [mapIndx(COL.devStatus)], + 'createdCell': function (td, cellData, rowData, row, col) { + + const badge = badgeFromRowData(rowData); + + $(td).html(`${badge.iconHtml} ${badge.label}`); + } }, + ], + + // Processing + 'processing' : true, + 'language' : { + emptyTable: buildEmptyDeviceTableMessage(getString('Device_NextScan_Imminent')), + zeroRecords: getString('Device_NoMatch_Title'), + "lengthMenu": getString('Device_Tablelenght'), + "search": getString('Device_Searchbox') + ": ", + "paginate": { + "next": getString('Device_Table_nav_next'), + "previous": getString('Device_Table_nav_prev') + }, + "info": getString('Device_Table_info'), + }, + initComplete: function (settings, devices) { + // Handle any additional interactions or event listeners as required + + // Save cookie Rows displayed, and Parameters rows & order + $('#tableDevices').on( 'length.dt', function ( e, settings, len ) { + setCache ("nax_parTableRows", len, 129600); // save for 90 days + } ); + + $('#tableDevices').on( 'order.dt', function () { + setCache ("nax_parTableOrder", JSON.stringify (table.order()), 129600); // save for 90 days + } ); + + // add multi-edit button + $('#multiEditPlc').append( + ``) + + // Event listener for row selection in DataTable + $('#tableDevices').on('click', 'tr', function (e) { + setTimeout(function(){ + // Check if any row is selected + var anyRowSelected = $('#tableDevices tr.selected').length > 0; + + // Toggle visibility of element with ID 'multiEdit' + $('#multiEdit').toggle(anyRowSelected); + }, 100); + + }); + + // search only after idle + var typingTimer; // Timer identifier + var debounceTime = 750; // Delay in milliseconds + + $('input[aria-controls="tableDevices"]').off().on('keyup', function () { + clearTimeout(typingTimer); // Clear the previous timer + var searchValue = this.value; + + typingTimer = setTimeout(function () { + $('#tableDevices').DataTable().search(searchValue).draw(); // Trigger the search after delay + }, debounceTime); + }); + + initHoverNodeInfo(); + hideSpinner(); + hideDevicesSkeleton(); + + }, + createdRow: function(row, data, dataIndex) { + // add devMac to the table row + $(row).attr('my-devMac', data[mapIndx(COL.devMac)]); + + } + + }); +} + + +// ----------------------------------------------------------------------------- +function handleLoadingDialog(needsReload = false) +{ + // console.log(`needsReload: ${needsReload}`); + + $.get('php/server/query_logs.php?file=execution_queue.log&nocache=' + Date.now(), function(data) { + + if(data.includes("update_api|devices")) + { + showSpinner("devices_old") + + setTimeout(handleLoadingDialog(true), 1000); + + } else if (needsReload) + { + location.reload(); + }else + { + // hideSpinner(); + } + + }) + +} + +// ----------------------------------------------------------------------------- +// Function collects selected devices in the DataTable and redirects the user to +// the Miantenance section with a 'macs' query string identifying selected devices +function multiEditDevices() +{ + // get selected devices + var selectedDevicesDataTableData = $('#tableDevices').DataTable().rows({ selected: true, page: 'current' }).data().toArray(); + + console.log(selectedDevicesDataTableData); + + macs = "" + + for (var j = 0; j < selectedDevicesDataTableData.length; j++) { + macs += selectedDevicesDataTableData[j][mapIndx(COL.devMac)] + ","; // MAC + } + + // redirect to the Maintenance section + window.location.href = './maintenance.php#tab_multiEdit?macs=' + macs.slice(0, -1); +} + + +// ----------------------------------------------------------------------------- +// Function collects shown devices from the DataTable +function getMacsOfShownDevices() { + var table = $('#tableDevices').DataTable(); + + var macs = []; + + // Get all row indexes on current page, in display order + var allIndexes = table.rows({ page: 'current' }).indexes(); + + allIndexes.each(function(idx) { + var rowData = table.row(idx).data(); + if (rowData) { + macs.push(rowData[mapIndx(COL.devMac)]); // MAC column + } + }); + + return macs; +} diff --git a/front/js/devices-totals.js b/front/js/devices-totals.js new file mode 100644 index 000000000..b86701978 --- /dev/null +++ b/front/js/devices-totals.js @@ -0,0 +1,129 @@ +// ============================================================================= +// devices-totals.js — Devices list page: tile-card totals. +// +// Fetches per-status device counts and renders them as the tile cards at the +// top of devices.php (#TileCards). +// ============================================================================= + +//------------------------------------------------------------------------------ +// Query total numbers of Devices by status +//------------------------------------------------------------------------------ +function getDevicesTotals() { + maxDelay = 180; //cap at 180 seconds + + let maxRetries = Math.ceil(Math.log2(maxDelay)); // Calculate maximum retries to cap at maxDelay seconds + let attempt = 0; + let calledUpdateAPI = false; + + function fetchDataWithBackoff() { + // Calculate the delay (2^attempt seconds, capped at maxDelay seconds) + const delay = Math.min(2 ** attempt, maxDelay) * 1000; + + // Attempt to fetch data + $.ajax({ + url: 'php/server/query_json.php', + type: "GET", + dataType: "json", + data: { + file: 'table_devices_tiles.json', // Pass the file parameter + nocache: Date.now() // Prevent caching with a timestamp + }, + success: function(response) { + if (response && response.data) { + const resultJSON = response.data[0]; // Assuming the structure {"data": [ ... ]} + + // Save the result to cache + setCache("getDevicesTotals", JSON.stringify(resultJSON)); + + // Process the fetched data + processDeviceTotals(resultJSON); + } else { + console.error("Invalid response format from API"); + } + }, + error: function(xhr, status, error) { + console.error("Failed to fetch devices data (Attempt " + (attempt + 1) + "):", error); + + // try updating the API once + if(calledUpdateAPI == false) + { + calledUpdateAPI = true; + updateApi("devices_tiles"); + } + + // Retry logic + if (attempt < maxRetries) { + attempt++; + setTimeout(fetchDataWithBackoff, delay); + } else { + console.error("Maximum retries reached. Unable to fetch devices data."); + } + } + }); + } + + // Start the first fetch attempt + fetchDataWithBackoff(); +} + +function processDeviceTotals(devicesData) { + // Define filter conditions and corresponding objects + const filters = [ + { status: 'my_devices', color: 'bg-aqua', label: getString('Device_Shortcut_AllDevices'), icon: 'fa-laptop' }, + { status: 'all', color: 'bg-aqua', label: getString('Gen_All_Devices'), icon: 'fa-laptop' }, + { status: 'connected', color: 'bg-green', label: getString('Device_Shortcut_Connected'), icon: 'fa-plug' }, + { status: 'favorites', color: 'bg-yellow', label: getString('Device_Shortcut_Favorites'), icon: 'fa-star' }, + { status: 'new', color: 'bg-yellow', label: getString('Device_Shortcut_NewDevices'), icon: 'fa-plus' }, + { status: 'down', color: 'bg-red', label: getString('Device_Shortcut_DownOnly'), icon: 'fa-warning' }, + { status: 'archived', color: 'bg-gray', label: getString('Device_Shortcut_Archived'), icon: 'fa-eye-slash' }, + { status: 'offline', color: 'bg-gray', label: getString('Gen_Offline'), icon: 'fa-xmark' }, + { status: 'all_devices', color: 'bg-gray', label: getString('Gen_All_Devices'), icon: 'fa-laptop' }, + { status: 'network_devices', color: 'bg-aqua', label: getString('Network_Devices'), icon: 'fa-sitemap fa-rotate-270' } + ]; + + // Initialize an empty array to store the final objects + let dataArray = []; + + // Loop through each filter condition + filters.forEach(filter => { + // Get count directly from API response data + let count = devicesData[filter.status] || 0; + + // Check any condition to skip adding the object to dataArray + if ( + (['', 'False'].includes(getSetting('UI_hide_empty')) || (getSetting('UI_hide_empty') == "True" && count > 0)) && + (getSetting('UI_shown_cards') == "" || getSetting('UI_shown_cards').includes(filter.status)) + ) { + dataArray.push({ + onclickEvent: `forceLoadUrl('devices.php#${filter.status}')`, + color: filter.color, + title: count, + label: filter.label, + icon: filter.icon + }); + } + }); + + // Render info boxes/tile cards + renderInfoboxes(dataArray); +} + +//------------------------------------------------------------------------------ +// Render the info boxes/tiles on top +function renderInfoboxes(customData) { + if(customData.length > 0) + { + $.ajax({ + url: 'php/components/tile_cards.php', // PHP script URL + type: 'POST', // Use POST method to send data + dataType: 'html', // Expect HTML response + data: { items: JSON.stringify(customData) }, // Send customData as JSON + success: function(response) { + $('#TileCards').html(response); // Replace container content with fetched HTML + }, + error: function(xhr, status, error) { + console.error('Error fetching infoboxes:', error); + } + }); + } +} diff --git a/front/js/settings_utils.js b/front/js/settings_utils.js index 752ebb7fd..615f25b9f 100755 --- a/front/js/settings_utils.js +++ b/front/js/settings_utils.js @@ -265,9 +265,31 @@ function cloneDataTableRow(el){ // Clone the row (including its data and controls) let clonedRow = $(row).clone(true, true); // The true arguments copy the data and event handlers + // Use max(my-index) + 1, not rows().count(), so a clone made after a row was + // removed can't collide with a surviving row's index (count() shrinks on removal, + // indices don't get renumbered). + let newIndex = 0; + table.rows().nodes().to$().each(function () { + const idx = parseInt($(this).attr("my-index")); + if (!isNaN(idx) && idx >= newIndex) { newIndex = idx + 1; } + }); - $(clonedRow).attr("my-index",table.rows().count()) - + $(clonedRow).attr("my-index", newIndex); + + // jQuery's clone() copies every descendant id/name/data-* attribute verbatim, + // so the clone's inputs/select/action-icons still carry the SOURCE row's + // "_" identifiers - e.g. two elements now share the same id. + // That breaks anything that looks an element up by id (e.g. the icon picker's + // document.getElementById(setKey) in showIconSelection()), which then always + // resolves to the first (source) row instead of the new clone. Re-key every + // such attribute on the clone to use the new index instead. + const idxSuffix = new RegExp(`_${myIndex}(?!\\d)`, 'g'); + clonedRow.find('[id], [name], [data-myparam-setkey], [data-myparam]').each(function () { + ['id', 'name', 'data-myparam-setkey', 'data-myparam'].forEach(attr => { + const v = $(this).attr(attr); + if (v) { $(this).attr(attr, v.replace(idxSuffix, `_${newIndex}`)); } + }); + }); console.log(clonedRow); diff --git a/mkdocs.yml b/mkdocs.yml index 2a16e5081..4679b26f2 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - Getting started: - Subnets: SUBNETS.md - Enable Plugins: PLUGINS.md + - Plugins reference: plugins/ - Backups: BACKUPS.md - Security features: SECURITY_FEATURES.md - Security considerations: SECURITY.md @@ -227,6 +228,11 @@ plugins: - gh-admonitions - search - glightbox + - gen-files: + scripts: + - docs/gen_plugin_pages.py + - literate-nav: + nav_file: SUMMARY.md # Custom CSS extra_css: diff --git a/scripts/check_skill_pairs.py b/scripts/check_skill_pairs.py index ae58234c1..123318503 100644 --- a/scripts/check_skill_pairs.py +++ b/scripts/check_skill_pairs.py @@ -25,6 +25,7 @@ # Copilot); a few skills are also mirrored to .claude/skills/ as a 3rd member. GROUPS = [ [".gemini/skills/plugin-development/plugin-skill.md", ".github/skills/plugin-run-development/SKILL.md", ".claude/skills/plugin-development/SKILL.md"], + [".gemini/skills/plugin-readme/plugin-readme-skill.md", ".github/skills/plugin-readme/SKILL.md", ".claude/skills/plugin-readme/SKILL.md"], [".gemini/skills/testing-workflow/SKILL.md", ".github/skills/testing-workflow/SKILL.md", ".claude/skills/testing-workflow/SKILL.md"], [".gemini/skills/pr-analysis/SKILL.md", ".github/skills/pr-analysis/SKILL.md", ".claude/skills/pr-analysis/SKILL.md"], [".gemini/skills/settings/SKILL.md", ".github/skills/settings-management/SKILL.md"], diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index c214463d4..8460a30e2 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -2089,6 +2089,36 @@ def api_plugin_stats(payload=None): return jsonify({"success": True, "data": data}) +# -------------------------- +# Plugin Run endpoint +# -------------------------- +@app.route("/plugin//run", methods=["POST"]) +@validate_request( + operation_id="run_plugin", + summary="Run Plugin", + description="Manually trigger an on-demand run of a plugin by its unique prefix, regardless of its " + "configured RUN schedule (e.g. used by the 'Run Plugin' device custom property action).", + path_params=[{ + "name": "prefix", + "description": "Plugin unique prefix (e.g. NMAPDEV, ARPSCAN)", + "schema": {"type": "string"} + }], + response_model=BaseResponse, + tags=["plugins"], + validation_error_code=400, + auth_callable=is_authorized +) +def api_run_plugin(prefix, payload=None): + loaded_plugins = get_setting_value('LOADED_PLUGINS') + if prefix not in loaded_plugins: + return jsonify({"success": False, "error": f"Invalid plugin. Must be one of: {', '.join(loaded_plugins)}"}), 400 + + queue = UserEventsQueueInstance() + queue.add_event(f"run|{prefix}") + + return jsonify({"success": True, "message": f"Run triggered for plugin: {prefix}"}), 200 + + # -------------------------- # Background Server Start # -------------------------- diff --git a/server/plugin.py b/server/plugin.py index 69288712d..bd2e6d557 100755 --- a/server/plugin.py +++ b/server/plugin.py @@ -22,12 +22,14 @@ combine_plugin_objects, resolve_wildcards_arr, handle_empty, - decode_and_rename_files + decode_and_rename_files, + primary_id_is_mac ) from models.notification_instance import NotificationInstance from messaging.in_app import write_notification from models.user_events_queue_instance import UserEventsQueueInstance from utils.crypto_utils import generate_deterministic_guid +from plugin_helper import normalize_mac # ------------------------------------------------------------------------------- @@ -1082,6 +1084,8 @@ def __init__(self, plugin, objDbRow): self.index = objDbRow[0] self.pluginPref = objDbRow[1] self.primaryId = objDbRow[2] + if self.primaryId and primary_id_is_mac(plugin): + self.primaryId = normalize_mac(self.primaryId) self.secondaryId = objDbRow[3] self.created = objDbRow[4] # can be null self.changed = objDbRow[5] # never null (data coming from plugin) diff --git a/server/plugins/_publisher_pushover/README.md b/server/plugins/_publisher_pushover/README.md index 2717edd4b..268247763 100755 --- a/server/plugins/_publisher_pushover/README.md +++ b/server/plugins/_publisher_pushover/README.md @@ -1,8 +1,7 @@ ## Overview -A plugin to publish a notification via the Pushover gateway. Enable sending notifications via Pushover. +A plugin to publish notifications via the [Pushover](https://pushover.net/) gateway. ### Usage -- Go to settings and fill in relevant details. - +- Go to settings and provide your Pushover application token and user key (both come from your Pushover account/app dashboard). diff --git a/server/plugins/_publisher_pushsafer/README.md b/server/plugins/_publisher_pushsafer/README.md index 6b110e920..91b4443d4 100755 --- a/server/plugins/_publisher_pushsafer/README.md +++ b/server/plugins/_publisher_pushsafer/README.md @@ -1,8 +1,7 @@ ## Overview -A plugin to publish a notification via the Pushsafer gateway. Enable sending notifications via Pushsafer. +A plugin to publish notifications via the [Pushsafer](https://www.pushsafer.com/) gateway. ### Usage -- Go to settings and fill in relevant details. - +- Go to settings and provide your Pushsafer API key (from your Pushsafer account dashboard). diff --git a/server/plugins/_publisher_telegram/README.md b/server/plugins/_publisher_telegram/README.md index 4e39226a1..e199133f0 100755 --- a/server/plugins/_publisher_telegram/README.md +++ b/server/plugins/_publisher_telegram/README.md @@ -1,12 +1,11 @@ ## Overview -You can send notifications via Telegram -## Notes - -You need Telegram bot to send notifications +A plugin to publish notifications via a Telegram bot. ### Usage -- Go to settings and fill in relevant details. +- Create a bot via [BotFather](https://t.me/BotFather) and grab its token. +- Find the chat ID to send messages to (a user ID for a DM, or a group/channel ID). +- Enter both in settings; long messages are truncated to the configured size limit, with a `(text was truncated)` note appended. Made by [@doctorixx](https://github.com/doctorixx) 🙏 diff --git a/server/plugins/adguard_import/README.md b/server/plugins/adguard_import/README.md index f99a3b366..9bf12a22e 100644 --- a/server/plugins/adguard_import/README.md +++ b/server/plugins/adguard_import/README.md @@ -1,27 +1,14 @@ ## Overview -Plugin functionality overview and links to external resources if relevant. Include use cases if available. +Imports devices *from* AdGuard Home *into* NetAlertX. On each run it pulls AdGuard Home's auto-discovered clients (devices AdGuard has seen via DNS activity, not the manually-configured persistent client list) and cross-references its DHCP leases to resolve a MAC address for each one. -> [!TIP] -> Some tip. - -### Quick setup guide - -To set up the plugin correctly, make sure... - -#### Required Settings - -- When to run `PREF_RUN` -- +This is the reverse direction of the [`adguard_export`](https://docs.netalertx.com/plugins/adguard_export) plugin, which pushes NetAlertX's known devices *to* AdGuard Home as persistent clients. ### Usage -- Head to **Settings** > **Plugin name** to adjust the default values. +- Enable the `ADGUARDIMP` plugin and point it at your AdGuard Home instance's address and credentials. +- If a client has no MAC in AdGuard's DHCP leases (e.g. it was seen only via DNS, not DHCP), enable the fake-MAC option to still import it under a deterministic synthetic MAC rather than skipping it. ### Notes -- Additional notes, limitations, Author info. - -- Version: 1.0.0 -- Author: `` -- Release Date: `` \ No newline at end of file +- Requires AdGuard Home's REST API to be reachable from the NetAlertX container. diff --git a/server/plugins/asuswrt_import/README.md b/server/plugins/asuswrt_import/README.md index f081d57a1..8dcb94382 100755 --- a/server/plugins/asuswrt_import/README.md +++ b/server/plugins/asuswrt_import/README.md @@ -13,7 +13,7 @@ This Plugin is using awesome [asusrouter](https://github.com/Vaskivskyi/asusrout ### Notes - In case an existing imported device is renamed in Asus Router it will not be renamed in NetAlertX. In this case it has to be done manually or the device should be removed and it will appear on the next scan. -- Only clients listed in the main AsusWRT interface are imported. If using plugins, such as the `YazFi plugin`, check the [Asus routers DHCPLSS guide](/server/plugins/dhcp_leases/ASUS_ROUTERS.md) for a possible workaround. +- Only clients listed in the main AsusWRT interface are imported. If using plugins, such as the `YazFi plugin`, check the [Asus routers DHCPLSS guide](https://docs.netalertx.com/plugins/dhcp_leases/ASUS_ROUTERS) for a possible workaround. ## Other info diff --git a/server/plugins/avahi_scan/README.md b/server/plugins/avahi_scan/README.md index e07bac658..402492219 100755 --- a/server/plugins/avahi_scan/README.md +++ b/server/plugins/avahi_scan/README.md @@ -1,7 +1,11 @@ ## Overview -Plugin for device name discovery via the [avahi](https://wiki.alpinelinux.org/wiki/MDNS) network utility supporting mDNS. +Plugin for device name discovery via the [avahi](https://wiki.alpinelinux.org/wiki/MDNS) network utility, using mDNS. Runs against IPs already discovered by a device scanner - it doesn't discover devices itself, only tries to attach a name to ones that don't have one yet. Generally the most reliable naming source for local devices that advertise themselves over mDNS (most consumer IoT, Apple/Chromecast-style devices). ### Usage - Check the Settings page for details. + +### Notes + +- See the [Name resolution guide](https://docs.netalertx.com/NAME_RESOLUTION) for how this fits alongside the other naming plugins (`NBTSCAN`, `NSLOOKUP`, `DIGSCAN`). diff --git a/server/plugins/csv_backup/README.md b/server/plugins/csv_backup/README.md index 800b1c2fe..9c2fb8876 100755 --- a/server/plugins/csv_backup/README.md +++ b/server/plugins/csv_backup/README.md @@ -1,6 +1,6 @@ ## Overview -Plugin generating CSV backups of your Devices database table, including the network mappings. Can be used for importing your setup via the Maintenance > Backup / Restore > CSV Import feature (See also: [Devices Bulk Editing](https://docs.netalertx.com/DEVICES_BULK_EDITING)). +Plugin generating CSV backups of your Devices database table, including the network mappings. Can be used for importing your setup via the Maintenance > Backup / Restore > CSV Import feature (See also: [Devices Bulk Editing](https://docs.netalertx.com/DEVICES_BULK_EDITING)). For the full backup/restore picture across NetAlertX (not just this plugin), see the [Backups guide](https://docs.netalertx.com/BACKUPS). ### Usage diff --git a/server/plugins/custom_props/README.md b/server/plugins/custom_props/README.md index 946709d14..f6c47f5d6 100755 --- a/server/plugins/custom_props/README.md +++ b/server/plugins/custom_props/README.md @@ -1,8 +1,14 @@ ## Overview -Settings related to the custom properties functionality on a device. A user guide can be found here: [Custom Properties](/docs/CUSTOM_PROPERTIES.md) +Backs the **Custom Properties** feature on devices - interactive icons (links, notes, delete, run-plugin, etc.) shown in the device list. This plugin doesn't scan or collect anything itself; it only defines the field types and default option lists used wherever a `devCustomProps` table is edited: directly on a device (Device Details > Custom Properties) and via the `NEWDEV_devCustomProps` setting that seeds the default for newly discovered devices. Full usage guide: [Custom Properties](https://docs.netalertx.com/CUSTOM_PROPERTIES). -### Usage +### Settings + +- `CUSTPROP_icon`: the pool of icons offered by the icon picker when adding/editing a custom property. This is a shared list, not per-property - edit it to add or remove choices available everywhere. +- `CUSTPROP_type`: the list of available property types. The built-in ones each drive specific behavior when the property's icon is clicked - see [Available Action Types](https://docs.netalertx.com/CUSTOM_PROPERTIES#available-action-types) for exactly what `link`, `link_new_tab`, `show_notes`, `delete_dev`, and `run_plugin` each do; `none`/`data` are non-interactive. +- `CUSTPROP_args`, `CUSTPROP_name`, `CUSTPROP_notes`, `CUSTPROP_show`, `CUSTPROP_actions`: column definitions for a single custom-property row (the action's arguments, display name, tooltip notes, visibility toggle, and row action buttons). These aren't values you set once - they're the schema every Custom Properties table (per-device or `NEWDEV_devCustomProps`) is built from. -- Head to **Settings** > **Custom Properties** to adjust the default values. +### Usage +- Head to **Settings** > **Custom properties** to adjust the icon and type pools available to every device. +- Individual property rows are added/edited per device (Device Details > Custom Properties) or as the new-device default via `NEWDEV_devCustomProps` - not here. diff --git a/server/plugins/ddns_update/README.md b/server/plugins/ddns_update/README.md index f1983a9f5..7a45f0310 100755 --- a/server/plugins/ddns_update/README.md +++ b/server/plugins/ddns_update/README.md @@ -1,6 +1,18 @@ ## Overview -Plugin to run regular DDNS update tasks. +Keeps a Dynamic DNS (DDNS) hostname pointed at your current public IP. On each run, the plugin resolves the IP currently published for `DDNS_DOMAIN` (via `dig`) and compares it to the IP NetAlertX already has recorded for the special `Internet` device. If they differ, it calls `DDNS_UPDATE_URL` with your `DDNS_USER`/`DDNS_PASSWORD`/`DDNS_DOMAIN` - the DDNS provider is expected to detect the new IP from the request's own source address, which is how most `username=&password=&hostname=`-style DDNS update APIs work. The default `DDNS_UPDATE_URL` targets Dynu, but any provider using that same query-string convention works by changing the URL. + +### Requirements + +- A device with MAC `Internet` and an up-to-date `devLastIP` - normally maintained by the `internet_ip` (`INTRNT`) plugin. Without it, the "previous IP" the comparison relies on stays empty and every run looks like a change. +- `dig` and `curl` available in the container (already present in the default image). + +### Settings + +- `DDNS_DOMAIN` / `DDNS_USER` / `DDNS_PASSWORD`: your DDNS provider's hostname and login credentials. +- `DDNS_UPDATE_URL`: the provider's update endpoint. Defaults to Dynu's `https://api.dynu.com/nic/update?`; swap it for another provider that accepts the same query-string update format. +- `DDNS_RUN`: when to run. Since this only needs to catch a WAN IP change (not run on every scan), an hourly or daily `schedule` is the recommended value over `always_after_scan`. +- `DDNS_WATCH` / `DDNS_REPORT_ON`: control whether and when a notification is sent for this plugin's activity. ### Usage diff --git a/server/plugins/ddns_update/README_DE.md b/server/plugins/ddns_update/README_DE.md deleted file mode 100755 index b9945ab6b..000000000 --- a/server/plugins/ddns_update/README_DE.md +++ /dev/null @@ -1,7 +0,0 @@ -## Übersicht - -Ein Plugin zur regelmäßigen Aktualisierung eines DynDNS-Eintrags. - -### Verwendung - -- Einstellungen-Seite für Details ansehen. diff --git a/server/plugins/dhcp_leases/README.md b/server/plugins/dhcp_leases/README.md index 36f52e691..9564d2bab 100755 --- a/server/plugins/dhcp_leases/README.md +++ b/server/plugins/dhcp_leases/README.md @@ -2,6 +2,8 @@ A plugin allowing for importing devices from DHCP.leases files. +See the [Pi-hole guide](https://docs.netalertx.com/PIHOLE_GUIDE) for a walkthrough covering this plugin alongside the other Pi-hole integrations (`PIHOLE`, `PIHOLEAPI`, `PIHOLEMON`). If you're using an ASUS router with the YazFi plugin, see [Configuring the DHCPLSS plugin to import clients from the YazFi plugin](https://docs.netalertx.com/plugins/dhcp_leases/ASUS_ROUTERS) for a workaround to import guest-network clients. + ### Usage - Specify full paths of all `dhcp.leases` files you want to import and watch in the `DHCPLSS_paths_to_check`setting. diff --git a/server/plugins/dhcp_servers/README.md b/server/plugins/dhcp_servers/README.md index 6ed96c4da..ef1904635 100755 --- a/server/plugins/dhcp_servers/README.md +++ b/server/plugins/dhcp_servers/README.md @@ -1,11 +1,15 @@ ## Overview -A simple sample plugin allowing for detecting rogue DHCP servers on the network. +Detects DHCP servers answering on your network, using NMAP's `broadcast-dhcp-discover` probe - it broadcasts a DHCP discover request and lists every server that responds, the same way a rogue-DHCP detector would. NetAlertX doesn't know which responses are "expected" (your router) versus "rogue" (a misconfigured device, a second router, or something malicious) - that judgment call is yours; the plugin just gives you the full list so you can spot an unexpected one. ### Usage -- No specific configuration needed. +- Check the Settings page for details. ### Notes -- No specific configuration needed. \ No newline at end of file +- Requires the container to send/receive broadcast traffic on the scanned network (host networking or an equivalent setup) - a bridged/isolated network namespace will prevent the probe from seeing real responses. + +### Other info + +- Based on the work of [leiweibau](https://github.com/leiweibau/Pi.Alert) diff --git a/server/plugins/dig_scan/README.md b/server/plugins/dig_scan/README.md index d20121e10..8f69f1ba6 100755 --- a/server/plugins/dig_scan/README.md +++ b/server/plugins/dig_scan/README.md @@ -1,7 +1,12 @@ ## Overview -Plugin for device name discovery via the [nbtscan](https://linuxcommandlibrary.com/man/nbtscan) network utility supporting NetBIOS. +Plugin for device name discovery via reverse DNS (PTR) lookups, using the [dig](https://linux.die.net/man/1/dig) utility (`dig +short -x `). Runs against IPs already discovered by a device scanner - it doesn't discover devices itself, only tries to attach a name to ones that don't have one yet. Functionally similar to `NSLOOKUP` (both do a reverse DNS lookup, just via a different tool) - enabling both is redundant, pick whichever behaves better against your DNS server. ### Usage - Check the Settings page for details. + +### Notes + +- Only useful if your network's DNS server actually has PTR records for local devices (e.g. via your router's DHCP-to-DNS integration); many home networks don't, in which case this plugin will find little to nothing. +- See the [Name resolution guide](https://docs.netalertx.com/NAME_RESOLUTION) for how this fits alongside the other naming plugins (`AVAHISCAN`, `NBTSCAN`, `NSLOOKUP`). diff --git a/server/plugins/luci_import/README.md b/server/plugins/luci_import/README.md index b4009197a..1d61ea114 100755 --- a/server/plugins/luci_import/README.md +++ b/server/plugins/luci_import/README.md @@ -1,10 +1,14 @@ ## Overview -The plugin is used to import connected devices from OpenWRT +Imports connected devices from an OpenWRT router via its LuCI RPC API. + +### Usage + +- Point the plugin at your router's address and a login with access to LuCI RPC. A read-only user is recommended over using your admin account. +- If your router uses a self-signed HTTPS certificate, you'll need to disable certificate verification for the import to succeed. ### Other info - Version: 1.0 - Author: [vaga9938](https://github.com/vaga9938) - Release Date: 28-Dec-2024 - diff --git a/server/plugins/maintenance/README.md b/server/plugins/maintenance/README.md index 0818aba9a..262111b6f 100755 --- a/server/plugins/maintenance/README.md +++ b/server/plugins/maintenance/README.md @@ -1,9 +1,7 @@ ## Overview -A plugin responsible for general maintenance tasks. These currently include: - -- **`MAINT_LOG_LENGTH`**: app.log cleanup. Recommended value: `10000` lines. Increase if debugging an issue. +Handles routine housekeeping so long-running logs and in-app notifications don't grow unbounded: trims `app.log` down to a configured line count, and purges old in-app notification entries past a configured count. ### Usage -- N/A +- Runs automatically once configured - no manual action needed beyond setting a schedule and the retention values on the Settings page. diff --git a/server/plugins/nbtscan_scan/README.md b/server/plugins/nbtscan_scan/README.md index d20121e10..0ee275040 100755 --- a/server/plugins/nbtscan_scan/README.md +++ b/server/plugins/nbtscan_scan/README.md @@ -1,7 +1,11 @@ ## Overview -Plugin for device name discovery via the [nbtscan](https://linuxcommandlibrary.com/man/nbtscan) network utility supporting NetBIOS. +Plugin for device name discovery via the [nbtscan](https://linuxcommandlibrary.com/man/nbtscan) network utility, using NetBIOS. Runs against IPs already discovered by a device scanner - it doesn't discover devices itself, only tries to attach a name to ones that don't have one yet. Mainly useful for older/Windows-family devices that respond to NetBIOS name queries; most modern devices won't. ### Usage - Check the Settings page for details. + +### Notes + +- See the [Name resolution guide](https://docs.netalertx.com/NAME_RESOLUTION) for how this fits alongside the other naming plugins (`AVAHISCAN`, `NSLOOKUP`, `DIGSCAN`). diff --git a/server/plugins/nmap_scan/README.md b/server/plugins/nmap_scan/README.md index 436a34ce4..d6831eeb4 100755 --- a/server/plugins/nmap_scan/README.md +++ b/server/plugins/nmap_scan/README.md @@ -1,7 +1,11 @@ ## Overview -This plugin scans your network for open ports. Only IPs are scanned that are accessible by the app container. +Scans your known devices for open ports and the services running on them, using NMAP. Only IPs reachable from the app container are scanned. Results show up as a per-device list of open ports/services, and can optionally trigger a notification when a device's port list changes (e.g. a new service appears). + +### Usage + +- Check the Settings page for details. ### Notes -![Screenshot](nmap_ports_services.png) \ No newline at end of file +![Screenshot](nmap_ports_services.png) diff --git a/server/plugins/nslookup_scan/README.md b/server/plugins/nslookup_scan/README.md index b74a85f05..1ef610456 100755 --- a/server/plugins/nslookup_scan/README.md +++ b/server/plugins/nslookup_scan/README.md @@ -1,7 +1,11 @@ ## Overview -Plugin for device name discovery via the [nslookup](https://linux.die.net/man/1/nslookup) network utility. +Plugin for device name discovery via reverse DNS (PTR) lookups, using the [nslookup](https://linux.die.net/man/1/nslookup) utility. Runs against IPs already discovered by a device scanner - it doesn't discover devices itself, only tries to attach a name to ones that don't have one yet. Functionally similar to `DIGSCAN` (both do a reverse DNS lookup, just via a different tool) - enabling both is redundant, pick whichever behaves better against your DNS server. ### Usage - Check the Settings page for details. + +### Notes + +- See the [Name resolution guide](https://docs.netalertx.com/NAME_RESOLUTION) for how this fits alongside the other naming plugins (`AVAHISCAN`, `NBTSCAN`, `DIGSCAN`). diff --git a/server/plugins/pihole_api_scan/README.md b/server/plugins/pihole_api_scan/README.md index f2f936bca..df6f92cef 100644 --- a/server/plugins/pihole_api_scan/README.md +++ b/server/plugins/pihole_api_scan/README.md @@ -13,6 +13,8 @@ The plugin connects to your Pi-hole’s API and retrieves: NetAlertX then uses this information to match or create devices in your system. +See the [Pi-hole guide](https://docs.netalertx.com/PIHOLE_GUIDE) for a walkthrough covering this plugin alongside the other Pi-hole integrations (`PIHOLE`, `PIHOLEMON`, `DHCPLSS`). + ### Quick setup guide * You are running **Pi-hole v6** or newer. diff --git a/server/plugins/pihole_monitor/README.md b/server/plugins/pihole_monitor/README.md index 09af2cfdf..41547afa8 100644 --- a/server/plugins/pihole_monitor/README.md +++ b/server/plugins/pihole_monitor/README.md @@ -7,6 +7,8 @@ The **PIHOLEMON** plugin does two jobs against the same Pi-hole connection(s): Both share one login per Pi-hole instance and one settings page, instead of being two separately configured pieces that happen to need the same credentials. +See the [Pi-hole guide](https://docs.netalertx.com/PIHOLE_GUIDE) for a walkthrough covering this plugin alongside the other Pi-hole integrations (`PIHOLE`, `PIHOLEAPI`, `DHCPLSS`). + Two design choices worth knowing about: * **Both a primary and an optional secondary/failover Pi-hole are checked, and their results combined.** Watching only one leaves an obvious blind spot for the anomaly detection - a device can simply point at the other resolver and never show up. Leave the secondary URL blank if you only run one Pi-hole; most setups do. diff --git a/server/plugins/pihole_scan/README.md b/server/plugins/pihole_scan/README.md index 39ea60cef..f69e23611 100755 --- a/server/plugins/pihole_scan/README.md +++ b/server/plugins/pihole_scan/README.md @@ -2,6 +2,8 @@ A plugin allowing for importing devices from the PiHole database. This is an import plugin using an SQLite database as a source. +See the [Pi-hole guide](https://docs.netalertx.com/PIHOLE_GUIDE) for a walkthrough covering this plugin alongside the other Pi-hole integrations (`PIHOLEAPI`, `PIHOLEMON`, `DHCPLSS`). + ### Usage - You need to specify the following settings: diff --git a/server/plugins/snmp_discovery/script.py b/server/plugins/snmp_discovery/script.py index 90ba32eab..76e1606b3 100755 --- a/server/plugins/snmp_discovery/script.py +++ b/server/plugins/snmp_discovery/script.py @@ -85,10 +85,10 @@ def main(): if len(tmpSplt) == 3: ipStr = tmpSplt[0].split('.')[-4:] # Get the last 4 elements to extract the IP - macStr = tmpSplt[1].strip().split(' ') # Remove leading/trailing spaces from MAC + macStr = tmpSplt[1].split() # Split on any whitespace run, discarding empty tokens if len(ipStr) == 4: - macAddress = ':'.join(macStr) + macAddress = normalize_mac(':'.join(macStr)) ipAddress = '.'.join(ipStr) mylog('verbose', [f"[{pluginName}] IP: {ipAddress} MAC: {macAddress}"]) diff --git a/server/plugins/ui_settings/README.md b/server/plugins/ui_settings/README.md index 1ec558419..9aa05dacb 100755 --- a/server/plugins/ui_settings/README.md +++ b/server/plugins/ui_settings/README.md @@ -1,8 +1,18 @@ ## Overview -UI-related settings. +Settings that control the look, layout, and live behavior of the web UI — the Devices page, device icons, locale/date formatting, and how/when the UI polls or waits on the backend. None of these affect what data is scanned or stored, only how it's displayed and interacted with. + +### What each group changes + +- **Devices page layout** (`device_columns`, `columns_filters`, `shown_cards`, `hide_empty`, `PRESENCE`, `MY_DEVICES`, `DEV_SECTIONS`): which columns/filters/tiles appear on the Devices list and in what order, which device statuses populate the presence chart and the default *My devices* view, and which page sections can be hidden entirely. +- **Device filtering** (`hide_rel_types`): excludes devices whose parent relationship matches one of the given types (e.g. `nic`, `virtual`) from most device lists — useful for hiding virtual/container interfaces that would otherwise clutter the list. +- **Appearance** (`theme`, `ICONS`, `LOCALE`): the UI color theme (with a `System` option that follows the OS/browser), the pool of pre-defined icons offered in the device icon picker, and the locale used to format dates across the UI. +- **Live/refresh behavior** (`REFRESH`, `SCAN_PAUSE`, `DEFAULT_PAGE_SIZE`, `WAIT_FOR_SETTINGS`): how often the UI auto-reloads itself (`0` disables auto-refresh), how long a manual scan pause lasts, the default table page size, and whether saving settings blocks the UI until the backend finishes reloading — it only actually blocks when a plugin's configuration changed; other changes return immediately regardless of this setting. +- **Network page** (`TOPOLOGY_ORDER`): sort order for nodes in the Network topology view. +- **MAC handling** (`NOT_RANDOM_MAC`): MAC prefixes that should never be flagged as a randomized/private MAC, even if they'd otherwise match the randomization heuristic. + +Setting names and tooltips in the Settings UI are the source of truth for exact behavior and accepted values — this README only orients you to what each group is for. ### Usage - Head to **Settings** > **UI Settings** to adjust the default values. - diff --git a/server/plugins/unifi_api_import/README.md b/server/plugins/unifi_api_import/README.md index ea631dac5..70991bff9 100755 --- a/server/plugins/unifi_api_import/README.md +++ b/server/plugins/unifi_api_import/README.md @@ -1,6 +1,6 @@ ## Overview -Unifi import plugin using the Site Manager API. +UniFi import plugin using the newer, API-key-based Site Manager API - the successor to the username/password controller login used by the older [`unifi_import`](https://docs.netalertx.com/plugins/unifi_import) plugin. Prefer this one where available; fall back to `unifi_import` if your controller doesn't expose the Site Manager integration API yet. > [!TIP] > The Site Manager API doesn't seems to have feature parity with the old API yet, so certain limitations apply. @@ -16,7 +16,7 @@ Navigate to your UniFi Site Manager _Settings -> Control Plane -> Integrations_. ### Usage -- Head to **Settings** > **Plugin name** to adjust the default values. +- Head to **Settings** > **UniFi import (API)** to adjust the default values. ### Notes diff --git a/server/plugins/vendor_update/README.md b/server/plugins/vendor_update/README.md index 89cee0bd1..88fe4a7ce 100755 --- a/server/plugins/vendor_update/README.md +++ b/server/plugins/vendor_update/README.md @@ -1,7 +1,7 @@ ## Overview -A plugin to retrieve a MAC and vendor database to identify vendors for devices. The Plugin result objects will be a list of vendors mapped to the devices where the vendor was previously unknown. +Keeps the local MAC-vendor lookup database current by downloading the [IEEE OUI registry](http://standards-oui.ieee.org/oui/oui.txt), then re-resolves the vendor for any device whose vendor is still unknown. This is what fills in the `devVendor` field for devices your scanners couldn't already identify. ### Usage -- Check the Settings page for details. +- Check the Settings page for details. A daily or weekly `schedule` is plenty - the OUI registry doesn't change often enough to warrant running this on every scan. diff --git a/server/plugins/wake_on_lan/README.md b/server/plugins/wake_on_lan/README.md index 11b845f50..a8b8ba699 100755 --- a/server/plugins/wake_on_lan/README.md +++ b/server/plugins/wake_on_lan/README.md @@ -1,37 +1,12 @@ -# Wake-on-LAN Plugin User Guide - ## Overview -The Wake-on-LAN (WOL) plugin allows you to remotely wake devices on your network that support Wake-on-LAN functionality. This plugin sends a "magic packet" to the specified devices, which powers them on, provided they are configured to accept WOL requests. - -## Configuration -All settings for the plugin can be configured via the user interface. The key settings include: - -- **Broadcast IPs (`WOL_broadcast_ips`)**: - A list of IP addresses to use for broadcasting the WOL packet. Ensure these are valid network broadcast addresses for your environment. - -- **Devices to Wake (`WOL_devices_to_wake`)**: - Defines the group of devices to be woken. You can choose from: - - `offline`: Wake devices that are currently offline. - - `down`: Wake devices that are in a "down" state. - -- **Ports (`WOL_ports`)**: - A list of ports to use when sending the WOL packet. The default is usually port 9. -## Usage -1. Configure the settings through the UI. -2. The plugin will automatically detect devices based on the selected criteria (offline or down) and attempt to wake them by sending WOL magic packets. -3. The plugin logs the outcome of each attempt and processes results for monitoring and notifications. - -## Logs -Logs for each run of the plugin are stored in the specified log path, where you can track: -- WOL packet sending attempts. -- Success or failure of waking devices. - -## Notes -- Ensure the devices are configured to allow Wake-on-LAN in BIOS and the network adapter supports WOL when powered off. -- Make sure your network is configured to allow broadcast packets. +Automatically wakes devices on your network by sending them a Wake-on-LAN "magic packet" - useful for bringing machines back online on a schedule without touching them manually. On each run, the plugin picks devices matching your selected status (e.g. `offline` or `down`) and broadcasts a magic packet to each. ### Usage -- Head to **Settings** > **Plugin name** to adjust the default values. +- Head to **Settings** > **Wake on Lan (WOL)** to adjust the default values. + +### Notes +- The target device must have Wake-on-LAN enabled in its BIOS/UEFI and network adapter settings - the plugin can only send the packet, it can't enable WOL support on a device that doesn't have it turned on. +- Your network must allow broadcast packets between the NetAlertX container and the target devices (same broadcast domain, or a broadcast IP configured for the right subnet). diff --git a/server/plugins/website_monitor/README.md b/server/plugins/website_monitor/README.md index c5474ad18..099501858 100755 --- a/server/plugins/website_monitor/README.md +++ b/server/plugins/website_monitor/README.md @@ -9,4 +9,8 @@ A simple sample plugin allowing for monitoring web services or urls. The status ### Notes - Setting `(WEBMON_)SQL_internet_ip` is not used and specified for demonstration purposes only. -- Parameters `macs` and `internet_ip` in the `config.json` file are not used and specified for demonstration purposes only. \ No newline at end of file +- Parameters `macs` and `internet_ip` in the `config.json` file are not used and specified for demonstration purposes only. + +### Other info + +- Based on the work of [leiweibau](https://github.com/leiweibau/Pi.Alert) \ No newline at end of file diff --git a/server/plugins/workflows/README.md b/server/plugins/workflows/README.md index e024b14db..97f2692cb 100755 --- a/server/plugins/workflows/README.md +++ b/server/plugins/workflows/README.md @@ -1,6 +1,8 @@ ## Overview -TBC +Backing settings plugin for the Workflows automation engine (trigger → conditions → actions rules that react to device/scan events). The engine itself lives in `server/workflows/` and is configured on its own **Workflows** page, not through a settings tab - this plugin only holds the setting that controls how much Application Events history is kept, since that history feeds the Workflows UI. + +See the [Workflows guide](https://docs.netalertx.com/WORKFLOWS) for how to build rules, and [Workflow examples](https://docs.netalertx.com/WORKFLOW_EXAMPLES) for ready-made ones. ### Usage diff --git a/server/utils/plugin_utils.py b/server/utils/plugin_utils.py index 0b50454ec..f7dc53c09 100755 --- a/server/utils/plugin_utils.py +++ b/server/utils/plugin_utils.py @@ -172,6 +172,24 @@ def resolve_wildcards_arr(commandArr, params): return commandArr +# ------------------------------------------------------------------------------- +# True if a plugin's primaryId represents a MAC address, per its own config.json. +# Every device-scanning plugin (arp_scan, snmp_discovery, sync, ...) maps +# objectPrimaryId to CurrentScan.scanMac with type "device_mac"/"device_name_mac"; +# publishers, exporters and other non-scanning plugins don't. Used to decide +# whether a plugin object's primaryId can be safely run through normalize_mac() +# generically, rather than relying on every plugin author remembering to call it +# in every parsing branch - see GH #1775, where one branch of one plugin didn't. +def primary_id_is_mac(plugin): + if plugin.get("mapped_to_table") != "CurrentScan": + return False + + return any( + col.get("column") == "objectPrimaryId" and col.get("type") in ("device_mac", "device_name_mac") + for col in plugin.get("database_column_definitions", []) + ) + + # ------------------------------------------------------------------------------- # Function to extract layer number from "execution_order" def get_layer(plugin): diff --git a/test/api_endpoints/test_plugin_run_endpoint.py b/test/api_endpoints/test_plugin_run_endpoint.py new file mode 100644 index 000000000..8aedacb4f --- /dev/null +++ b/test/api_endpoints/test_plugin_run_endpoint.py @@ -0,0 +1,67 @@ +"""Tests for /plugin//run endpoint.""" + +import sys +import os +import pytest +from unittest.mock import patch, MagicMock + +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value # noqa: E402 +from api_server.api_server_start import app # noqa: E402 + + +@pytest.fixture(scope="session") +def api_token(): + return get_setting_value("API_TOKEN") + + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + + +def auth_headers(token): + return {"Authorization": f"Bearer {token}"} + + +def test_run_plugin_unauthorized(client): + """Missing token should be forbidden.""" + resp = client.post("/plugin/ARPSCAN/run") + assert resp.status_code == 403 + assert resp.get_json().get("success") is False + + +@patch("api_server.api_server_start.UserEventsQueueInstance") +def test_run_plugin_success(mock_queue_class, client, api_token): + """A known, loaded plugin prefix queues a run event.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + loaded_plugins = get_setting_value("LOADED_PLUGINS") + prefix = loaded_plugins[0] + + resp = client.post(f"/plugin/{prefix}/run", headers=auth_headers(api_token)) + + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + mock_queue.add_event.assert_called_once() + call_args = mock_queue.add_event.call_args[0] + assert f"run|{prefix}" in call_args[0] + + +@patch("api_server.api_server_start.UserEventsQueueInstance") +def test_run_plugin_invalid_prefix(mock_queue_class, client, api_token): + """An unknown plugin prefix is rejected before touching the queue.""" + mock_queue = MagicMock() + mock_queue_class.return_value = mock_queue + + resp = client.post("/plugin/NOT_A_REAL_PLUGIN/run", headers=auth_headers(api_token)) + + assert resp.status_code == 400 + data = resp.get_json() + assert data["success"] is False + mock_queue.add_event.assert_not_called() diff --git a/test/plugins/test_snmp_discovery.py b/test/plugins/test_snmp_discovery.py new file mode 100644 index 000000000..246c44ffe --- /dev/null +++ b/test/plugins/test_snmp_discovery.py @@ -0,0 +1,128 @@ +"""Tests for the SNMP discovery plugin (server/plugins/snmp_discovery/script.py). + +Regression test for https://github.com/netalertx/NetAlertX/issues/1775: +the primary "mib-2.3.1.1.2.15..." parsing branch built the MAC directly +from snmpwalk's raw (uppercase) hex dump without going through +normalize_mac(), unlike the plugin's other two parsing branches - so the +same device could be reported under two different MAC cases depending on +which SNMP output format matched that scan cycle, producing spurious +connect/disconnect events for what is really one device. + +Run from inside the NetAlertX container, or locally - NetAlertX-specific +modules are stubbed out automatically before the script is imported. + + pytest "test/plugins/test_snmp_discovery.py" -v +""" + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def _load_snmp_discovery_module(): + missing_module = object() + previous_modules = {} + + def stub(name, **attributes): + previous_modules[name] = sys.modules.get(name, missing_module) + module = types.ModuleType(name) + for attribute, value in attributes.items(): + setattr(module, attribute, value) + sys.modules[name] = module + + stub( + "plugin_helper", + Plugin_Objects=MagicMock, + handleEmpty=lambda v: v if v not in (None, "") else "(unknown)", + normalize_mac=lambda mac: mac.strip().lower().replace("-", ":"), + ) + stub("logger", mylog=MagicMock(), Logger=MagicMock()) + stub("helper", get_setting_value=MagicMock(return_value=60)) + stub("const", logPath="/tmp") + stub("conf", tz=None) + stub("pytz", timezone=MagicMock(return_value="UTC")) + + module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "snmp_discovery" / "script.py" + spec = importlib.util.spec_from_file_location("snmp_discovery", module_path) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + finally: + for name, previous_module in previous_modules.items(): + if previous_module is missing_module: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous_module + + return module + + +snmp_discovery = _load_snmp_discovery_module() + +ROUTER_CMD = "snmpwalk -v2c -c public -Oqn 192.168.1.14 .1.3.6.1.2.1.3.1.1.2" + + +def _run_main_with_output(output): + """Run main() with subprocess.check_output faked to return `output`, + and Plugin_Objects faked so add_object() calls can be inspected.""" + plugin_objects = MagicMock() + + with patch.object(snmp_discovery, "Plugin_Objects", return_value=plugin_objects), \ + patch("subprocess.check_output", return_value=output), \ + patch.object(sys, "argv", ["script.py", f"routers={ROUTER_CMD}"]): + snmp_discovery.main() + + return plugin_objects + + +def test_numeric_oid_branch_normalizes_mac_case(): + """The branch that historically skipped normalize_mac() (GH #1775).""" + output = 'mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4 32 18 61 43 "\n' + + plugin_objects = _run_main_with_output(output) + + assert plugin_objects.add_object.call_count == 1 + call_kwargs = plugin_objects.add_object.call_args_list[0].kwargs + assert call_kwargs["primaryId"] == "2c:f4:32:18:61:43" + assert call_kwargs["foreignKey"] == "2c:f4:32:18:61:43" + + +def test_numeric_oid_branch_tolerates_repeated_whitespace_between_bytes(): + """Some snmpwalk output has runs of repeated spaces or embedded tabs + between hex bytes rather than a single space. Splitting on a literal + single space (the pre-fix behaviour) turns each extra space into an + empty token, which normalize_mac() then zero-pads into a fabricated + "00" octet, and leaves a tab glued to its neighboring byte instead of + splitting it out - silently corrupting the MAC rather than just its + case.""" + output = 'mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4\t32 18 61 43 "\n' + + plugin_objects = _run_main_with_output(output) + + assert plugin_objects.add_object.call_count == 1 + call_kwargs = plugin_objects.add_object.call_args_list[0].kwargs + assert call_kwargs["primaryId"] == "2c:f4:32:18:61:43" + assert call_kwargs["foreignKey"] == "2c:f4:32:18:61:43" + + +def test_all_three_output_formats_agree_on_mac_case(): + """The same physical MAC, reported through each of the plugin's three + supported snmpwalk output formats, must normalize to the same devMac - + otherwise NetAlertX's plugin-object diffing (idsHash in server/plugin.py) + treats them as different devices and fires spurious connect/disconnect + events for what is really one device.""" + outputs = [ + 'mib-2.3.1.1.2.15.1.192.168.1.14 "2C F4 32 18 61 43 "\n', + "IP-MIB::ipNetToMediaPhysAddress.17.192.168.1.14 = STRING: 2C:F4:32:18:61:43\n", + "ipNetToMediaPhysAddress[3][192.168.1.14] 2C:F4:32:18:61:43\n", + ] + + macs_seen = set() + for output in outputs: + plugin_objects = _run_main_with_output(output) + assert plugin_objects.add_object.call_count == 1 + macs_seen.add(plugin_objects.add_object.call_args_list[0].kwargs["primaryId"]) + + assert macs_seen == {"2c:f4:32:18:61:43"} diff --git a/test/server/test_plugin_object_mac_normalization.py b/test/server/test_plugin_object_mac_normalization.py new file mode 100644 index 000000000..e1e2aa55a --- /dev/null +++ b/test/server/test_plugin_object_mac_normalization.py @@ -0,0 +1,99 @@ +""" +Tests for centralized MAC-case normalization in plugin_object_class. + +Regression coverage for https://github.com/netalertx/NetAlertX/issues/1775: +plugin authors are responsible for calling normalize_mac() themselves before +writing a MAC as primaryId, and it's easy for one parsing branch in one +plugin to forget (see server/plugins/snmp_discovery) - producing a device +reported under two different MAC cases and spurious connect/disconnect +events, since plugin_object_class.idsHash (used to detect new/missing +objects across scan cycles) is a case-sensitive hash of primaryId. + +plugin_object_class now normalizes primaryId itself as a generic safety +net, via utils.plugin_utils.primary_id_is_mac(), whenever the owning +plugin's config.json marks objectPrimaryId as a MAC (true for every +device-scanning plugin - arp_scan, snmp_discovery, sync, etc. - and false +for publishers/exporters/other non-scanning plugins, whose primaryId is not +a MAC and must not be silently rewritten). + +Run from inside the NetAlertX container - server/plugin.py isn't importable +standalone outside it (real conf/database/api imports). + + pytest "test/server/test_plugin_object_mac_normalization.py" -v +""" + +import os +import sys + +# --------------------------------------------------------------------------- +# Path setup +# --------------------------------------------------------------------------- +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import make_plugin_event_row # noqa: E402 + +from plugin import plugin_object_class # noqa: E402 +from utils.plugin_utils import primary_id_is_mac # noqa: E402 + +PREFIX = "TESTPLG" + + +def _scanner_plugin(prefix=PREFIX): + """Shaped like a real device-scanning plugin's config.json + (e.g. snmp_discovery, arp_scan): objectPrimaryId marked as a MAC.""" + return { + "unique_prefix": prefix, + "settings": [ + {"function": "WATCH", "value": ["watchedValue1", "watchedValue2"]}, + ], + "mapped_to_table": "CurrentScan", + "database_column_definitions": [ + {"column": "objectPrimaryId", "mapped_to_column": "scanMac", "type": "device_mac"}, + {"column": "objectSecondaryId", "mapped_to_column": "scanLastIP", "type": "device_ip"}, + ], + } + + +def _non_scanner_plugin(prefix=PREFIX): + """Shaped like a non-scanning plugin (publisher/exporter): no + CurrentScan mapping, primaryId is not a MAC.""" + return { + "unique_prefix": prefix, + "settings": [], + } + + +class TestScannerPluginNormalizesMacCase: + def test_uppercase_primary_id_is_normalized(self): + row = make_plugin_event_row(PREFIX, "AA:BB:CC:DD:EE:FF") + obj = plugin_object_class(_scanner_plugin(), row) + assert obj.primaryId == "aa:bb:cc:dd:ee:ff" + + def test_hyphenated_primary_id_is_normalized_to_colon_form(self): + row = make_plugin_event_row(PREFIX, "AA-BB-CC-DD-EE-FF") + obj = plugin_object_class(_scanner_plugin(), row) + assert obj.primaryId == "aa:bb:cc:dd:ee:ff" + + def test_idshash_agrees_across_case_variants(self): + """The actual bug: two readings of the same device that differ only + in MAC case must produce the same idsHash, or the scan-cycle diff + engine treats them as different objects (spurious connect/disconnect).""" + upper = plugin_object_class(_scanner_plugin(), make_plugin_event_row(PREFIX, "AA:BB:CC:DD:EE:FF")) + lower = plugin_object_class(_scanner_plugin(), make_plugin_event_row(PREFIX, "aa:bb:cc:dd:ee:ff")) + assert upper.idsHash == lower.idsHash + + +class TestNonScannerPluginLeavesPrimaryIdAlone: + def test_primary_id_untouched(self): + """A publisher/exporter's primaryId isn't a MAC - must not be run + through normalize_mac(), which would silently mangle it.""" + row = make_plugin_event_row(PREFIX, "Some-Mixed-Case-ID") + obj = plugin_object_class(_non_scanner_plugin(), row) + assert obj.primaryId == "Some-Mixed-Case-ID" + + +def test_helper_detects_scanner_vs_non_scanner_plugins(): + assert primary_id_is_mac(_scanner_plugin()) is True + assert primary_id_is_mac(_non_scanner_plugin()) is False