mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-05-24 08:40:31 -04:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95aaa48f8 | ||
|
|
b5d280644e | ||
|
|
b7cffe8c07 | ||
|
|
0387d74e9e | ||
|
|
1108534540 | ||
|
|
e9879c47a9 | ||
|
|
5910a7c2a1 | ||
|
|
0cb7ad6332 | ||
|
|
87a89f3a28 | ||
|
|
88231d97c8 | ||
|
|
821594f617 | ||
|
|
5f62d25e23 | ||
|
|
6580c4a953 | ||
|
|
bc87e39843 | ||
|
|
9eaaf50caf | ||
|
|
f0684c66c2 | ||
|
|
328e591fcd | ||
|
|
80c8a66396 | ||
|
|
0517da2405 | ||
|
|
35dc9f9fa0 | ||
|
|
af14dea40c | ||
|
|
292223e062 | ||
|
|
47a559197f | ||
|
|
bf4e0b4a7c | ||
|
|
92adaddabb | ||
|
|
7725697b87 | ||
|
|
09744f3acd | ||
|
|
f6b34845e0 | ||
|
|
198ca5d410 | ||
|
|
4761a688fc | ||
|
|
6e149f8ad3 | ||
|
|
4cac81b5ec | ||
|
|
03938d8c28 | ||
|
|
0127194816 | ||
|
|
13f8858319 | ||
|
|
f11a63bb7f | ||
|
|
556d104bbb | ||
|
|
eafbcd52f8 | ||
|
|
2fac875792 | ||
|
|
6d661dd12c | ||
|
|
39068e9824 | ||
|
|
957c779cb5 | ||
|
|
76612e5d0e | ||
|
|
7273899e3e | ||
|
|
608686e4bd | ||
|
|
b290d3c3d2 | ||
|
|
781ae2f91c | ||
|
|
f428f45ad2 | ||
|
|
b7ebc8206f | ||
|
|
9575692a39 | ||
|
|
1def218db5 | ||
|
|
300820e6bd | ||
|
|
8f7f7eaed7 | ||
|
|
190262a730 | ||
|
|
5ba202c6a1 | ||
|
|
e262f915e2 | ||
|
|
b8c4e62ad5 |
15
.github/skills/code-standards/SKILL.md
vendored
15
.github/skills/code-standards/SKILL.md
vendored
@@ -8,12 +8,23 @@ description: NetAlertX coding standards and conventions. Use this when writing c
|
|||||||
- ask me to review before going to each next step (mention n step out of x) (AI only)
|
- ask me to review before going to each next step (mention n step out of x) (AI only)
|
||||||
- before starting, prepare implementation plan (AI only)
|
- before starting, prepare implementation plan (AI only)
|
||||||
- ask me to review it and ask any clarifying questions first
|
- ask me to review it and ask any clarifying questions first
|
||||||
- add test creation as last step - follow repo architecture patterns - do not place in the root of /test
|
- add test creation as last step - follow repo architecture patterns - do not place in the root of `/test`
|
||||||
- code has to be maintainable, no duplicate code
|
- code has to be maintainable, no duplicate code
|
||||||
- follow DRY principle - maintainability of code is more important than speed of implementation
|
- follow DRY principle - maintainability of code is more important than speed of implementation
|
||||||
- code files should be less than 500 LOC for better maintainability
|
- code files should be less than 500 LOC for better maintainability
|
||||||
- DB columns must not contain underscores, use camelCase instead (e.g., deviceInstanceId, not device_instance_id)
|
- DB columns must not contain underscores, use camelCase instead (e.g., deviceInstanceId, not device_instance_id)
|
||||||
- treat DB as temporary storage for stats, long term configuration should be stored in the /config folder, the /config folder should allow you to restore most of your functionality (excluding historical data)
|
- treat DB as temporary storage for stats, long-term configuration should be stored in the `/config` folder, the `/config` folder should allow you to restore most of your functionality (excluding historical data)
|
||||||
|
- never access DB directly from application layers, always use helper functions in `server/db/db_helper.py` and implement new functionality in handlers (e.g., `DeviceInstance` in `server/models/device_instance.py`)
|
||||||
|
- always validate and normalize MAC addresses before writing to DB (use `normalize_mac` from `plugin_helper.py`)
|
||||||
|
- all subprocess calls must set explicit timeouts
|
||||||
|
- use `timeNowUTC` from `utils.datetime_utils` for all time-related operations and DB timestamps (store all timestamps in UTC)
|
||||||
|
- use sanitizers from `server/helper.py` for user input before storing in DB
|
||||||
|
- reuse shared mocks and factories from `test/db_test_helpers.py` for tests, never redefine them locally
|
||||||
|
- use environment variables for runtime paths, never hardcode paths or use relative paths
|
||||||
|
- follow existing code style and structure, and ensure backward compatibility with existing installations when submitting PRs
|
||||||
|
- all code needs to be scalable to handle large networks with thousands of devices (10k+) without performance degradation
|
||||||
|
- no inline imports, all imports must be at the top of the file
|
||||||
|
|
||||||
|
|
||||||
## File Length
|
## File Length
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ Check the [GitHub Issues](https://github.com/netalertx/NetAlertX/issues) for the
|
|||||||
## Everything else
|
## Everything else
|
||||||
<!--- --------------------------------------------------------------------- --->
|
<!--- --------------------------------------------------------------------- --->
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/12670" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12670" alt="jokob-sk%2FNetAlertX | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/19712" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19712" alt="jokob-sk%2FNetAlertX | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
### 📧 Get notified what's new
|
### 📧 Get notified what's new
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@ Get notified about a new release, what new functionality you can use and about b
|
|||||||
- [Fing](https://www.fing.com/) - Network scanner app for your Internet security (Commercial, Phone App, Proprietary hardware)
|
- [Fing](https://www.fing.com/) - Network scanner app for your Internet security (Commercial, Phone App, Proprietary hardware)
|
||||||
- [NetBox](https://netboxlabs.com/) - The gold standard for Network Source of Truth (NSoT) and IPAM.
|
- [NetBox](https://netboxlabs.com/) - The gold standard for Network Source of Truth (NSoT) and IPAM.
|
||||||
- [Zabbix](https://www.zabbix.com/) or [Nagios](https://www.nagios.org/) - Strong focus on infrastructure monitoring.
|
- [Zabbix](https://www.zabbix.com/) or [Nagios](https://www.nagios.org/) - Strong focus on infrastructure monitoring.
|
||||||
|
- [Domotz](https://www.domotz.com/) - Commercial network monitoring and remote management platform aimed at MSPs, IT teams, and multi-site environments.
|
||||||
- [NetAlertX](https://netalertx.com) - The streamlined, discovery-focused choice for real-time asset intelligence and noise-free alerting.
|
- [NetAlertX](https://netalertx.com) - The streamlined, discovery-focused choice for real-time asset intelligence and noise-free alerting.
|
||||||
|
|
||||||
### 💙 Donations
|
### 💙 Donations
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ REPORT_DASHBOARD_URL='update_REPORT_DASHBOARD_URL_setting'
|
|||||||
INTRNT_RUN='schedule'
|
INTRNT_RUN='schedule'
|
||||||
ARPSCAN_RUN='schedule'
|
ARPSCAN_RUN='schedule'
|
||||||
NSLOOKUP_RUN='before_name_updates'
|
NSLOOKUP_RUN='before_name_updates'
|
||||||
|
DIGSCAN_RUN='before_name_updates'
|
||||||
AVAHISCAN_RUN='before_name_updates'
|
AVAHISCAN_RUN='before_name_updates'
|
||||||
NBTSCAN_RUN='before_name_updates'
|
NBTSCAN_RUN='before_name_updates'
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ For Managed Service Providers (MSPs) and Network Operations Centers (NOC), "Eyes
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> If you are using Grafana check the `/metrics` endpoint that exposes **Prometheus-compatible metrics** for NetAlertX, including aggregate device counts and per-device status. See the [Metrics API endpoint](./API_METRICS.md) documentation for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1. Configure Auto-Refresh for Live Monitoring
|
### 1. Configure Auto-Refresh for Live Monitoring
|
||||||
|
|||||||
@@ -12,20 +12,25 @@ Effective multi-network monitoring starts with understanding how NetAlertX "sees
|
|||||||
* **Manual Entry:** For static assets where only ICMP (ping) status is needed.
|
* **Manual Entry:** For static assets where only ICMP (ping) status is needed.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Explore the [remote networks](./REMOTE_NETWORKS.md) documentation for more details on how to set up the approaches menationed above.
|
> Explore the [remote networks](./REMOTE_NETWORKS.md) documentation for more details on how to set up the approaches mentioned above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Automating IT Asset Inventory with Workflows
|
### 2. Automating IT Asset Inventory with Workflows
|
||||||
|
|
||||||
[Workflows](./WORKFLOWS.md) are the "engine" of NetAlertX, reducing manual overhead as your device list grows.
|
[Workflows](./WORKFLOWS.md) are the "engine" of NetAlertX, reducing manual overhead as your device list grows. See some examples below:
|
||||||
|
|
||||||
|
#### A. Logical Ownership & VLAN Tagging
|
||||||
|
|
||||||
|
Create a workflow triggered on **Device Creation** to:
|
||||||
|
|
||||||
* **A. Logical Ownership & VLAN Tagging:** Create a workflow triggered on **Device Creation** to:
|
|
||||||
1. Inspect the IP/Subnet.
|
1. Inspect the IP/Subnet.
|
||||||
2. Set `devVlan` or `devOwner` custom fields automatically.
|
2. Set `devVlan` or `devOwner` custom fields automatically.
|
||||||
|
|
||||||
|
#### B. Auto-Grouping:
|
||||||
|
|
||||||
|
Use conditional logic to categorize devices.
|
||||||
|
|
||||||
* **B. Auto-Grouping:** Use conditional logic to categorize devices.
|
|
||||||
* *Example:* If `devLastIP == 10.10.20.*`, then `Set devLocation = "BranchOffice"`.
|
* *Example:* If `devLastIP == 10.10.20.*`, then `Set devLocation = "BranchOffice"`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -56,9 +61,9 @@ Effective multi-network monitoring starts with understanding how NetAlertX "sees
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
#### C. Sync Node Tracking
|
||||||
|
|
||||||
|
When using multiple instances, ensure all sync hub nodes have a descriptive `SYNC_node_name` name to distinguish between sites.
|
||||||
* **C. Sync Node Tracking:** When using multiple instances, ensure all synchub nodes have a descriptive `SYNC_node_name` name to distinguish between sites.
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Always test new workflows in a "Staging" instance. A misconfigured workflow can trigger thousands of unintended updates across your database.
|
> Always test new workflows in a "Staging" instance. A misconfigured workflow can trigger thousands of unintended updates across your database.
|
||||||
@@ -107,6 +112,7 @@ As your environment grows, tuning the underlying engine is vital to maintain a s
|
|||||||
* **Plugin Scheduling:** Avoid "Scan Storms" by staggering plugin execution. Running intensive tasks like `NMAP` or `MASS_DNS` simultaneously can spike CPU and cause database locks.
|
* **Plugin Scheduling:** Avoid "Scan Storms" by staggering plugin execution. Running intensive tasks like `NMAP` or `MASS_DNS` simultaneously can spike CPU and cause database locks.
|
||||||
* **Database Health:** Large-scale monitoring generates massive event logs. Use the **[DBCLNP (Database Cleanup)](https://www.google.com/search?q=https://docs.netalertx.com/PLUGINS/%23dbclnp)** plugin to prune old records and keep the SQLite database performant.
|
* **Database Health:** Large-scale monitoring generates massive event logs. Use the **[DBCLNP (Database Cleanup)](https://www.google.com/search?q=https://docs.netalertx.com/PLUGINS/%23dbclnp)** plugin to prune old records and keep the SQLite database performant.
|
||||||
* **Resource Management:** For high-device counts, consider increasing the memory limit for the container and utilizing `tmpfs` for temporary files to reduce SD card/disk I/O bottlenecks.
|
* **Resource Management:** For high-device counts, consider increasing the memory limit for the container and utilizing `tmpfs` for temporary files to reduce SD card/disk I/O bottlenecks.
|
||||||
|
* Enable the `DEEP_SLEEP` setting.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> For a deep dive into hardware requirements, database vacuuming, and specific environment variables for high-load instances, refer to the full **[Performance Optimization Guide](https://docs.netalertx.com/PERFORMANCE/)**.
|
> For a deep dive into hardware requirements, database vacuuming, and specific environment variables for high-load instances, refer to the full **[Performance Optimization Guide](https://docs.netalertx.com/PERFORMANCE/)**.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Quick Reference Guide - Device Field Lock/Unlock System
|
# Quick Reference Guide - Device Field Lock/Unlock System
|
||||||
|
|
||||||
|
> For how scan overwrite rules (`SET_ALWAYS`, `SET_EMPTY`) and source tracking work under the hood, see [Device Source Fields](./DEVICE_SOURCE_FIELDS.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Understanding Device Source Fields and Field Updates
|
# Understanding Device Source Fields and Field Updates
|
||||||
|
|
||||||
|
> For the UI guide on locking and unlocking individual fields, see [Device Field Lock/Unlock](./DEVICE_FIELD_LOCK.md).
|
||||||
|
|
||||||
When the system scans a network, it finds various details about devices (like names, IP addresses, and manufacturers). To ensure the data remains accurate without accidentally overwriting manual changes, the system uses a set of "Source Rules."
|
When the system scans a network, it finds various details about devices (like names, IP addresses, and manufacturers). To ensure the data remains accurate without accidentally overwriting manual changes, the system uses a set of "Source Rules."
|
||||||
|
|
||||||

|

|
||||||
@@ -17,6 +19,8 @@ Every piece of information for a device has a **Source**. This source determines
|
|||||||
| **NEWDEV** | This value was initialized from `NEWDEV` plugin settings. | **Always** |
|
| **NEWDEV** | This value was initialized from `NEWDEV` plugin settings. | **Always** |
|
||||||
| **(Plugin Name)** | The value was found by a specific scanner (e.g., `NBTSCAN`). | **Only if specific rules are met** |
|
| **(Plugin Name)** | The value was found by a specific scanner (e.g., `NBTSCAN`). | **Only if specific rules are met** |
|
||||||
|
|
||||||
|
> For how `USER` and `LOCKED` sources are set through the UI (lock/unlock buttons), see [Device Field Lock/Unlock](./DEVICE_FIELD_LOCK.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How Scans Update Information
|
## How Scans Update Information
|
||||||
@@ -34,6 +38,8 @@ Some plugins are configured to be "authoritative." If a field is in the **SET_AL
|
|||||||
* The scanner will **always** overwrite the current value with the new one.
|
* The scanner will **always** overwrite the current value with the new one.
|
||||||
* *Note: It will still never overwrite a `USER` or `LOCKED` field.*
|
* *Note: It will still never overwrite a `USER` or `LOCKED` field.*
|
||||||
|
|
||||||
|
> On large networks, enabling `SET_ALWAYS` on name-resolution fields (e.g., `devName`) widens the resolution scope and increases DNS query volume. See [Performance — Plugin Field Authority](./PERFORMANCE.md#plugin-field-authority-set_always-and-set_empty) for details.
|
||||||
|
|
||||||
### 3. SET_EMPTY
|
### 3. SET_EMPTY
|
||||||
|
|
||||||
If a field is in the **SET_EMPTY** list:
|
If a field is in the **SET_EMPTY** list:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ docker run -d --rm --network=host \
|
|||||||
|
|
||||||
> Runtime UID/GID: The image defaults to a service user `netalertx` (UID/GID 20211). A separate readonly lock owner also uses UID/GID 20211 for 004/005 immutability. You can override the runtime UID/GID at build (ARG) or run (`--user` / compose `user:`) but must align writable mounts (`/data`, `/tmp*`) and tmpfs `uid/gid` to that choice.
|
> Runtime UID/GID: The image defaults to a service user `netalertx` (UID/GID 20211). A separate readonly lock owner also uses UID/GID 20211 for 004/005 immutability. You can override the runtime UID/GID at build (ARG) or run (`--user` / compose `user:`) but must align writable mounts (`/data`, `/tmp*`) and tmpfs `uid/gid` to that choice.
|
||||||
|
|
||||||
See alternative [docked-compose examples](https://docs.netalertx.com/DOCKER_COMPOSE).
|
See alternative [docker-compose examples](https://docs.netalertx.com/DOCKER_COMPOSE).
|
||||||
|
|
||||||
### Default ports
|
### Default ports
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,41 @@ For example, the **ICMP plugin** allows scanning only IPs that match a specific
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Plugin Field Authority: `SET_ALWAYS` and `SET_EMPTY`
|
||||||
|
|
||||||
|
Plugins can be configured to control how aggressively they overwrite existing device field values via two settings:
|
||||||
|
|
||||||
|
| Setting | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `<PLUGIN>_SET_ALWAYS` | Plugin always overwrites the field, as long as it can resolve a value and the field is not `USER`/`LOCKED` |
|
||||||
|
| `<PLUGIN>_SET_EMPTY` | Plugin only writes when the field is currently empty |
|
||||||
|
|
||||||
|
Both settings accept a list of field names (e.g., `devName`, `devFQDN`). See [Name resolution](./NAME_RESOLUTION.md) and [Field locking](./DEVICE_SOURCE_FIELDS.md) docs for details.
|
||||||
|
|
||||||
|
### Performance Impact of `SET_ALWAYS` on Name Resolution
|
||||||
|
|
||||||
|
By default, name resolution (DIGSCAN, NBTSCAN, NSLOOKUP, AVAHISCAN) only runs against **devices that have no name yet**. This keeps DNS query volume proportional to new devices discovered, not the total inventory.
|
||||||
|
|
||||||
|
When **any** name-resolution plugin has `devName` in its `SET_ALWAYS` list, the system additionally runs a second resolution pass against **all devices whose name is not `USER`/`LOCKED` protected**. This allows a higher-priority plugin (e.g., DIGSCAN) to overwrite names previously set by a lower-priority one (e.g., NBTSCAN).
|
||||||
|
|
||||||
|
**Cost:** one DNS query per unprotected, already-named device per name-resolution cycle.
|
||||||
|
|
||||||
|
| Scenario | Devices resolved per cycle |
|
||||||
|
|---|---|
|
||||||
|
| No `SET_ALWAYS` on `devName` | Only new/unknown devices |
|
||||||
|
| `SET_ALWAYS: devName` on any plugin | New/unknown devices **+** all unprotected named devices |
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> On large installations (thousands of devices), enabling `SET_ALWAYS: devName` significantly increases DNS query volume and cycle duration. To mitigate:
|
||||||
|
>
|
||||||
|
> * Increase the scan interval of name-resolution plugins (`DIGSCAN_RUN_SCHD`, `NBTSCAN_RUN_SCHD`, etc.).
|
||||||
|
> * Mark devices whose name should never change as `USER` or `LOCKED` — they are excluded from the re-resolve pass entirely.
|
||||||
|
> * Use `SET_ALWAYS` only on the highest-priority plugin; leave lower-priority plugins without it.
|
||||||
|
|
||||||
|
The actual number of DB rows updated is logged at `verbose` level under `[Update Device Name] SET_ALWAYS re-resolve - DB rows updated`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Storing Temporary Files in Memory
|
## Storing Temporary Files in Memory
|
||||||
|
|
||||||
On devices with slower I/O, you can improve performance by storing temporary files (and optionally the database) in memory using `tmpfs`.
|
On devices with slower I/O, you can improve performance by storing temporary files (and optionally the database) in memory using `tmpfs`.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T
|
|||||||
| `INTRNT` | [internet_ip](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_ip/) | 🔍 | Internet IP scanner | | |
|
| `INTRNT` | [internet_ip](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_ip/) | 🔍 | Internet IP scanner | | |
|
||||||
| `INTRSPD` | [internet_speedtest](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_speedtest/) | ♻ | Internet speed test | | |
|
| `INTRSPD` | [internet_speedtest](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_speedtest/) | ♻ | Internet speed test | | |
|
||||||
| `IPNEIGH` | [ipneigh](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | |
|
| `IPNEIGH` | [ipneigh](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | |
|
||||||
|
| `KEALSS` | [kea_api](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/kea_api/) | 🔍/🆎 | Pull lease data from the Kea DHCP API | | |
|
||||||
| `LUCIRPC` | [luci_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | |
|
| `LUCIRPC` | [luci_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | |
|
||||||
| `MAINT` | [maintenance](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | |
|
| `MAINT` | [maintenance](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | |
|
||||||
| `MQTT` | [_publisher_mqtt](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | |
|
| `MQTT` | [_publisher_mqtt](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Configure how your plugin's data is displayed in the NetAlertX web interface.
|
|||||||
Plugin results are displayed in the UI via the **Plugins page** and **Device details tabs**. You control the appearance and functionality of these displays by defining `database_column_definitions` in your plugin's `config.json`.
|
Plugin results are displayed in the UI via the **Plugins page** and **Device details tabs**. You control the appearance and functionality of these displays by defining `database_column_definitions` in your plugin's `config.json`.
|
||||||
|
|
||||||
Each column definition specifies:
|
Each column definition specifies:
|
||||||
|
|
||||||
- Which data field to display
|
- Which data field to display
|
||||||
- How to render it (label, link, color-coded badge, etc.)
|
- How to render it (label, link, color-coded badge, etc.)
|
||||||
- What CSS classes to apply
|
- What CSS classes to apply
|
||||||
@@ -275,6 +276,7 @@ Replaces specific values with display strings or HTML.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Output Examples:**
|
**Output Examples:**
|
||||||
|
|
||||||
- `"online"` → 🟢 Online
|
- `"online"` → 🟢 Online
|
||||||
- `"offline"` → 🔴 Offline
|
- `"offline"` → 🔴 Offline
|
||||||
- `"idle"` → 🟡 Idle
|
- `"idle"` → 🟡 Idle
|
||||||
|
|||||||
@@ -45,23 +45,23 @@ Sometimes devices are manually archived (e.g., no longer expected on the network
|
|||||||
],
|
],
|
||||||
"enabled": "Yes"
|
"enabled": "Yes"
|
||||||
}
|
}
|
||||||
```
|
````
|
||||||
|
|
||||||
### 🔍 Explanation
|
### 🔍 Explanation
|
||||||
|
|
||||||
- Trigger: Listens for updates to device records.
|
* **Trigger**: Listens for updates to device records.
|
||||||
- Conditions:
|
* **Conditions**:
|
||||||
- `devIsArchived` is `1` (archived).
|
|
||||||
- `devPresentLastScan` is `1` (device was detected in the latest scan).
|
* `devIsArchived` is `1` (archived).
|
||||||
- Action: Updates the device to set `devIsArchived` to `0` (unarchived).
|
* `devPresentLastScan` is `1` (device was detected in the latest scan).
|
||||||
|
* **Action**:
|
||||||
|
|
||||||
|
* Updates the device to set `devIsArchived` to `0` (unarchived).
|
||||||
|
|
||||||
### ✅ Result
|
### ✅ Result
|
||||||
|
|
||||||
Whenever a previously archived device shows up during a network scan, it will be automatically unarchived — allowing it to reappear in your device lists and dashboards.
|
Whenever a previously archived device shows up during a network scan, it will be automatically unarchived — allowing it to reappear in your device lists and dashboards.
|
||||||
|
|
||||||
|
|
||||||
Here is your updated version of **Example 2** and **Example 3**, fully aligned with the format and structure of **Example 1** for consistency and professionalism:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Example 2: Assign Device to Network Node Based on IP
|
## Example 2: Assign Device to Network Node Based on IP
|
||||||
@@ -107,7 +107,7 @@ When new devices join your network, assigning them to the correct network node i
|
|||||||
### 🔍 Explanation
|
### 🔍 Explanation
|
||||||
|
|
||||||
* **Trigger**: Activates when a new device is added.
|
* **Trigger**: Activates when a new device is added.
|
||||||
* **Condition**:
|
* **Conditions**:
|
||||||
|
|
||||||
* `devLastIP` contains `192.168.1.` (matches subnet).
|
* `devLastIP` contains `192.168.1.` (matches subnet).
|
||||||
* **Action**:
|
* **Action**:
|
||||||
@@ -173,12 +173,12 @@ You may want to automatically clear out newly detected Google devices (such as C
|
|||||||
* **Trigger**: Runs on device updates.
|
* **Trigger**: Runs on device updates.
|
||||||
* **Conditions**:
|
* **Conditions**:
|
||||||
|
|
||||||
* Vendor contains `Google`.
|
* `devVendor` contains `Google`.
|
||||||
* Device is marked as new (`devIsNew` is `1`).
|
* `devIsNew` is `1` (device marked as new).
|
||||||
* **Actions**:
|
* **Actions**:
|
||||||
|
|
||||||
1. Set `devIsNew` to `0` (mark as not new).
|
1. Sets `devIsNew` to `0` (mark as not new).
|
||||||
2. Delete the device.
|
2. Deletes the device.
|
||||||
|
|
||||||
### ✅ Result
|
### ✅ Result
|
||||||
|
|
||||||
|
|||||||
@@ -1417,6 +1417,7 @@ textarea[readonly],
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
|
||||||
gap: 0.75em;
|
gap: 0.75em;
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#columnFilters::before,
|
#columnFilters::before,
|
||||||
|
|||||||
@@ -307,7 +307,10 @@ function updateChevrons(currentMac) {
|
|||||||
pos = refreshedList.findIndex(item => item.devMac === currentMac);
|
pos = refreshedList.findIndex(item => item.devMac === currentMac);
|
||||||
|
|
||||||
if (pos === -1) {
|
if (pos === -1) {
|
||||||
console.error('Still not found after re-cache:', currentMac);
|
console.warn('Device not found in device list after re-cache — hiding navigation controls:', currentMac);
|
||||||
|
$('#txtRecord').hide();
|
||||||
|
$('#btnPrevious').hide();
|
||||||
|
$('#btnNext').hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,26 +502,9 @@ async function renderSmallBoxes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDevicePageName(mac) {
|
// ----------------------------------------
|
||||||
let name = getDevDataByMac(mac, "devName");
|
// Write device name/owner into page title DOM. Pure DOM side-effect, no data fetching.
|
||||||
let owner = getDevDataByMac(mac, "devOwner");
|
function applyDevicePageTitle(mac, name, owner) {
|
||||||
|
|
||||||
// If data is missing, re-cache and retry once
|
|
||||||
if (mac != 'new' && (name === null|| owner === null)) {
|
|
||||||
console.warn("Device not found in cache, retrying after re-cache:", mac);
|
|
||||||
showSpinner();
|
|
||||||
cacheDevices(true).then(() => {
|
|
||||||
hideSpinner();
|
|
||||||
// Retry after successful cache
|
|
||||||
updateDevicePageName(mac);
|
|
||||||
}).catch((err) => {
|
|
||||||
hideSpinner();
|
|
||||||
console.error("Failed to refresh devices:", err);
|
|
||||||
});
|
|
||||||
return; // Exit early to avoid showing bad data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page title - Name
|
|
||||||
let pageTitleText;
|
let pageTitleText;
|
||||||
|
|
||||||
if (mac === "new") {
|
if (mac === "new") {
|
||||||
@@ -530,12 +516,12 @@ function updateDevicePageName(mac) {
|
|||||||
`<i class="fa fa-circle-info"></i> ` + getString("Gen_create_new_device_info")
|
`<i class="fa fa-circle-info"></i> ` + getString("Gen_create_new_device_info")
|
||||||
);
|
);
|
||||||
$('#devicePageInfoPlc').show();
|
$('#devicePageInfoPlc').show();
|
||||||
} else if (!owner || name.toString().includes(owner)) {
|
} else if (!owner || (name && name.toString().includes(owner))) {
|
||||||
pageTitleText = name;
|
pageTitleText = encodeSpecialChars(name ?? getString("DevDetail_EveandAl_NewDevice"));
|
||||||
$('#pageTitle').html(pageTitleText);
|
$('#pageTitle').html(pageTitleText);
|
||||||
$('#devicePageInfoPlc').hide();
|
$('#devicePageInfoPlc').hide();
|
||||||
} else {
|
} else {
|
||||||
pageTitleText = `${name} (${owner})`;
|
pageTitleText = `${encodeSpecialChars(name ?? getString("DevDetail_EveandAl_NewDevice"))} (${encodeSpecialChars(owner)})`;
|
||||||
$('#pageTitle').html(pageTitleText);
|
$('#pageTitle').html(pageTitleText);
|
||||||
$('#devicePageInfoPlc').hide();
|
$('#devicePageInfoPlc').hide();
|
||||||
}
|
}
|
||||||
@@ -544,6 +530,53 @@ function updateDevicePageName(mac) {
|
|||||||
$('title').html(pageTitleText + ' - ' + $('title').html());
|
$('title').html(pageTitleText + ' - ' + $('title').html());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------
|
||||||
|
// Resolve device name/owner for the page title.
|
||||||
|
// Stage 1: localStorage cache (synchronous, fast path).
|
||||||
|
// Stage 2: one forced re-cache from table_devices.json.
|
||||||
|
// Stage 3: REST API fallback so a direct-link visit never loops.
|
||||||
|
async function updateDevicePageName(mac) {
|
||||||
|
let name = getDevDataByMac(mac, "devName");
|
||||||
|
let owner = getDevDataByMac(mac, "devOwner");
|
||||||
|
|
||||||
|
// Stage 2: one re-cache attempt
|
||||||
|
if (mac !== 'new' && name === null) {
|
||||||
|
console.warn("Device not in cache, attempting re-cache:", mac);
|
||||||
|
showSpinner();
|
||||||
|
try {
|
||||||
|
await cacheDevices(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Re-cache failed:", err);
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
name = getDevDataByMac(mac, "devName");
|
||||||
|
owner = getDevDataByMac(mac, "devOwner");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 3: REST fallback — same endpoint renderSmallBoxes uses, always DB-direct
|
||||||
|
if (mac !== 'new' && name === null) {
|
||||||
|
console.warn("Device not found in cache after re-cache, falling back to REST API:", mac);
|
||||||
|
try {
|
||||||
|
const { apiBase, authHeader } = getAuthContext();
|
||||||
|
const res = await fetch(`${apiBase}/device/${encodeURIComponent(mac)}`, {
|
||||||
|
headers: authHeader
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
name = data.devName ?? null;
|
||||||
|
owner = data.devOwner ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("REST fallback for device name returned:", res.status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("REST fallback error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDevicePageTitle(mac, name, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//-----------------------------------------------------------------------------------
|
//-----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ function getDeviceData() {
|
|||||||
// console.log(setting.setKey);
|
// console.log(setting.setKey);
|
||||||
// console.log(fieldData);
|
// console.log(fieldData);
|
||||||
|
|
||||||
|
|
||||||
// Additional form elements like the random MAC address button for devMac
|
// Additional form elements like the random MAC address button for devMac
|
||||||
let inlineControl = "";
|
let inlineControl = "";
|
||||||
// handle random mac
|
// handle random mac
|
||||||
@@ -329,6 +330,11 @@ function getDeviceData() {
|
|||||||
fieldOptionsOverride = fieldDataNew;
|
fieldOptionsOverride = fieldDataNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// XSS prevention - encode special characters for string fields, but not for arrays (like children dynamic)
|
||||||
|
// Don't move above the handle devChildrenDynamic block because it relies on the original fieldData to generate options
|
||||||
|
fieldData = encodeSpecialChars(fieldData);
|
||||||
|
// console.log(fieldData);
|
||||||
|
|
||||||
// Generate the input field HTML
|
// Generate the input field HTML
|
||||||
const inputFormHtml = `<div class="form-group col-xs-12">
|
const inputFormHtml = `<div class="form-group col-xs-12">
|
||||||
<label id="${setting.setKey}_label" class="${obj.labelClasses}" > ${setting.setName}
|
<label id="${setting.setKey}_label" class="${obj.labelClasses}" > ${setting.setName}
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ function initializeSessionsDatatable (sessionsRows) {
|
|||||||
|
|
||||||
if (!cellData.includes("missing event") && !cellData.includes("..."))
|
if (!cellData.includes("missing event") && !cellData.includes("..."))
|
||||||
{
|
{
|
||||||
if (cellData.includes("+")) { // Check if timezone offset is present
|
|
||||||
cellData = cellData.split('+')[0]; // Remove timezone offset
|
|
||||||
}
|
|
||||||
// console.log(cellData);
|
// console.log(cellData);
|
||||||
result = localizeTimestamp(cellData);
|
result = localizeTimestamp(cellData);
|
||||||
} else
|
} else
|
||||||
|
|||||||
@@ -883,40 +883,41 @@ function initializeDatatable (status) {
|
|||||||
{orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) },
|
{orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) },
|
||||||
|
|
||||||
// Device Name and FQDN
|
// 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)],
|
{targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)],
|
||||||
'createdCell': function (td, cellData, rowData, row, col) {
|
'render': function (data, type, row) {
|
||||||
|
if (type !== 'display') {
|
||||||
// console.log(cellData)
|
return data; // raw value for sort / filter / type detection
|
||||||
|
|
||||||
var displayedValue = cellData;
|
|
||||||
|
|
||||||
if(isEmpty(displayedValue))
|
|
||||||
{
|
|
||||||
displayedValue = "N/A"
|
|
||||||
}
|
}
|
||||||
$(td).html (
|
|
||||||
`<b class="anonymizeDev "
|
var displayedValue = encodeSpecialChars(data);
|
||||||
>
|
|
||||||
<a href="deviceDetails.php?mac=${rowData[mapIndx(COL.devMac)]}" class="hover-node-info"
|
if (isEmpty(displayedValue)) {
|
||||||
data-name="${displayedValue}"
|
displayedValue = "N/A";
|
||||||
data-ip="${rowData[mapIndx(COL.devLastIP)]}"
|
}
|
||||||
data-mac="${rowData[mapIndx(COL.devMac)]}"
|
|
||||||
data-vendor="${rowData[mapIndx(COL.devVendor)]}"
|
return (
|
||||||
data-type="${rowData[mapIndx(COL.devType)]}"
|
`<b class="anonymizeDev ">` +
|
||||||
data-firstseen="${rowData[mapIndx(COL.devFirstConnection)]}"
|
`<a href="deviceDetails.php?mac=${row[mapIndx(COL.devMac)]}" class="hover-node-info"` +
|
||||||
data-lastseen="${rowData[mapIndx(COL.devLastConnection)]}"
|
` data-name="${displayedValue}"` +
|
||||||
data-relationship="${rowData[mapIndx(COL.devParentRelType)]}"
|
` data-ip="${row[mapIndx(COL.devLastIP)]}"` +
|
||||||
data-status="${rowData[mapIndx(COL.devStatus)]}"
|
` data-mac="${row[mapIndx(COL.devMac)]}"` +
|
||||||
data-present="${rowData[mapIndx(COL.devPresentLastScan)]}"
|
` data-vendor="${row[mapIndx(COL.devVendor)]}"` +
|
||||||
data-alertdown="${rowData[mapIndx(COL.devAlertDown)]}"
|
` data-type="${row[mapIndx(COL.devType)]}"` +
|
||||||
data-flapping="${rowData[mapIndx(COL.devFlapping)]}"
|
` data-firstseen="${row[mapIndx(COL.devFirstConnection)]}"` +
|
||||||
data-sleeping="${rowData[COL_EXTRA.devIsSleeping] || 0}"
|
` data-lastseen="${row[mapIndx(COL.devLastConnection)]}"` +
|
||||||
data-archived="${rowData[COL_EXTRA.devIsArchived] || 0}"
|
` data-relationship="${row[mapIndx(COL.devParentRelType)]}"` +
|
||||||
data-isnew="${rowData[COL_EXTRA.devIsNew] || 0}"
|
` data-status="${row[mapIndx(COL.devStatus)]}"` +
|
||||||
data-icon="${rowData[mapIndx(COL.devIcon)]}">
|
` data-present="${row[mapIndx(COL.devPresentLastScan)]}"` +
|
||||||
${displayedValue}
|
` data-alertdown="${row[mapIndx(COL.devAlertDown)]}"` +
|
||||||
</a>
|
` data-flapping="${row[mapIndx(COL.devFlapping)]}"` +
|
||||||
</b>`
|
` data-sleeping="${row[COL_EXTRA.devIsSleeping] || 0}"` +
|
||||||
|
` data-archived="${row[COL_EXTRA.devIsArchived] || 0}"` +
|
||||||
|
` data-isnew="${row[COL_EXTRA.devIsNew] || 0}"` +
|
||||||
|
` data-icon="${row[mapIndx(COL.devIcon)]}"` +
|
||||||
|
`>${displayedValue}</a>` +
|
||||||
|
`</b>`
|
||||||
);
|
);
|
||||||
} },
|
} },
|
||||||
|
|
||||||
|
|||||||
@@ -167,9 +167,11 @@ function initializeDatatable() {
|
|||||||
{ targets: [0,5,6,7,8,10,11,12,13], visible: false },
|
{ targets: [0,5,6,7,8,10,11,12,13], visible: false },
|
||||||
{ targets: [7], orderData: [8] },
|
{ targets: [7], orderData: [8] },
|
||||||
{ targets: [9], orderData: [10] },
|
{ targets: [9], orderData: [10] },
|
||||||
{ targets: [1], createdCell: (td, cellData, rowData) => {
|
// Use `render` (not `createdCell`) so encodeSpecialChars runs before
|
||||||
// Device column as link
|
// DataTables sets td.innerHTML, preventing devName XSS execution.
|
||||||
$(td).html(`<b><a href="deviceDetails.php?mac=${rowData[13]}">${cellData}</a></b>`);
|
{ targets: [1], render: function (data, type, row) {
|
||||||
|
if (type !== 'display') { return data; }
|
||||||
|
return `<b><a href="deviceDetails.php?mac=${row[13]}">${encodeSpecialChars(data)}</a></b>`;
|
||||||
}},
|
}},
|
||||||
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
|
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
|
||||||
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }
|
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }
|
||||||
|
|||||||
@@ -357,23 +357,67 @@ function isValidJSON(jsonString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// method to sanitize input so that HTML and other things don't break
|
/**
|
||||||
|
* Encode special HTML characters into HTML entities.
|
||||||
|
*
|
||||||
|
* Prevents HTML injection/XSS when displaying untrusted text
|
||||||
|
* inside HTML content contexts.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <script> -> <script>
|
||||||
|
*
|
||||||
|
* Note:
|
||||||
|
* - Intended for HTML text contexts only
|
||||||
|
* - Prefer using textContent or jQuery .text() when possible
|
||||||
|
* - Do NOT use as the sole protection for inline JS, URLs, CSS,
|
||||||
|
* or unsafe innerHTML usage
|
||||||
|
*
|
||||||
|
* @param {*} str - Value to encode
|
||||||
|
* @returns {string} Encoded safe HTML string
|
||||||
|
*/
|
||||||
function encodeSpecialChars(str) {
|
function encodeSpecialChars(str) {
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
if (str === null || str === undefined) {
|
||||||
.replace(/</g, '<')
|
return '';
|
||||||
.replace(/>/g, '>')
|
}
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
str = String(str);
|
||||||
|
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Decode HTML entities back into normal characters.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <script> -> <script>
|
||||||
|
*
|
||||||
|
* Warning:
|
||||||
|
* Decoding untrusted content and later inserting it into
|
||||||
|
* innerHTML or other HTML contexts can reintroduce XSS risks.
|
||||||
|
*
|
||||||
|
* @param {*} str - Value to decode
|
||||||
|
* @returns {string} Decoded string
|
||||||
|
*/
|
||||||
function decodeSpecialChars(str) {
|
function decodeSpecialChars(str) {
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
if (str === null || str === undefined) {
|
||||||
.replace(/</g, '<')
|
return '';
|
||||||
.replace(/>/g, '>')
|
}
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, '\'');
|
str = String(str);
|
||||||
|
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, '\'');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
@@ -473,7 +517,7 @@ function createDeviceLink(input)
|
|||||||
{
|
{
|
||||||
if(checkMacOrInternet(input))
|
if(checkMacOrInternet(input))
|
||||||
{
|
{
|
||||||
return `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${input}" target="_blank">${getDevDataByMac(input, "devName")}</a><span>`
|
return `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${input}" target="_blank">${encodeSpecialChars(getDevDataByMac(input, "devName"))}</a><span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
return input;
|
return input;
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null,
|
|||||||
width: '15%',
|
width: '15%',
|
||||||
render: function (name, type, device) {
|
render: function (name, type, device) {
|
||||||
return `<a href="./deviceDetails.php?mac=${device.devMac}" target="_blank">
|
return `<a href="./deviceDetails.php?mac=${device.devMac}" target="_blank">
|
||||||
<b class="anonymize">${name || '-'}</b>
|
<b class="anonymize">${encodeSpecialChars(name || '-')}</b>
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ function renderNetworkTabs(nodes) {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<li class="networkNodeTabHeaders ${i === 0 ? 'active' : ''}">
|
<li class="networkNodeTabHeaders ${i === 0 ? 'active' : ''}">
|
||||||
<a href="#${id}" data-mytabmac="${node.devMac}" id="${id}_id" data-toggle="tab" title="${node.devName}">
|
<a href="#${id}" data-mytabmac="${node.devMac}" id="${id}_id" data-toggle="tab" title="${encodeSpecialChars(node.devName)}">
|
||||||
<div class="icon ${iconClass}">${icon}</div>
|
<div class="icon ${iconClass}">${icon}</div>
|
||||||
<span class="node-name">${node.devName}</span>${portLabel}
|
<span class="node-name">${encodeSpecialChars(node.devName)}</span>${portLabel}
|
||||||
</a>
|
</a>
|
||||||
</li>`;
|
</li>`;
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ function renderNetworkTabContent(nodes) {
|
|||||||
<div class="mb-3 row">
|
<div class="mb-3 row">
|
||||||
<label class="col-sm-3 col-form-label fw-bold">${getString('DevDetail_Tab_Details')}</label>
|
<label class="col-sm-3 col-form-label fw-bold">${getString('DevDetail_Tab_Details')}</label>
|
||||||
<div class="col-sm-9">
|
<div class="col-sm-9">
|
||||||
<a href="./deviceDetails.php?mac=${node.devMac}" target="_blank" class="anonymize">${node.devName}</a>
|
<a href="./deviceDetails.php?mac=${node.devMac}" target="_blank" class="anonymize">${encodeSpecialChars(node.devName)}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ function renderNetworkTabContent(nodes) {
|
|||||||
<div class="col-sm-9">
|
<div class="col-sm-9">
|
||||||
${isRootNode ? '' : `<a class="anonymize" href="#">`}
|
${isRootNode ? '' : `<a class="anonymize" href="#">`}
|
||||||
<span my-data-mac="${node.devParentMAC}" data-mac="${node.devParentMAC}" data-devIsNetworkNodeDynamic="1" onclick="handleNodeClick(this)">
|
<span my-data-mac="${node.devParentMAC}" data-mac="${node.devParentMAC}" data-devIsNetworkNodeDynamic="1" onclick="handleNodeClick(this)">
|
||||||
${isRootNode ? getString('Network_Root') : getDevDataByMac(node.devParentMAC, "devName")}
|
${isRootNode ? getString('Network_Root') : encodeSpecialChars(getDevDataByMac(node.devParentMAC, "devName"))}
|
||||||
</span>
|
</span>
|
||||||
${isRootNode ? '' : `</a>`}
|
${isRootNode ? '' : `</a>`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ function initTree(myHierarchy)
|
|||||||
onclick="handleNodeClick(this)"
|
onclick="handleNodeClick(this)"
|
||||||
data-mac="${nodeData.data.devMac}"
|
data-mac="${nodeData.data.devMac}"
|
||||||
data-parentMac="${nodeData.data.devParentMAC}"
|
data-parentMac="${nodeData.data.devParentMAC}"
|
||||||
data-name="${nodeData.data.devName}"
|
data-name="${encodeSpecialChars(nodeData.data.devName)}"
|
||||||
data-ip="${nodeData.data.devLastIP}"
|
data-ip="${nodeData.data.devLastIP}"
|
||||||
data-mac="${nodeData.data.devMac}"
|
data-mac="${nodeData.data.devMac}"
|
||||||
data-vendor="${nodeData.data.devVendor}"
|
data-vendor="${nodeData.data.devVendor}"
|
||||||
@@ -298,7 +298,7 @@ function initTree(myHierarchy)
|
|||||||
>
|
>
|
||||||
<div class="netNodeText">
|
<div class="netNodeText">
|
||||||
<strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span>
|
<strong><span>${devicePort} <span class="${badgeConf.cssText}">${deviceIcon}</span></span>
|
||||||
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${nodeData.data.devName}</span>
|
<span class="spanNetworkTree anonymizeDev" style="width:${nodeWidthPx-50}px">${encodeSpecialChars(nodeData.data.devName)}</span>
|
||||||
${networkHardwareIcon}
|
${networkHardwareIcon}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -770,7 +770,7 @@ function reverseTransformers(val, transformers) {
|
|||||||
break;
|
break;
|
||||||
case "deviceChip":
|
case "deviceChip":
|
||||||
mac = val // value is mac
|
mac = val // value is mac
|
||||||
val = `${getDevDataByMac(mac, "devName")}`
|
val = encodeSpecialChars(getDevDataByMac(mac, "devName"))
|
||||||
break;
|
break;
|
||||||
case "deviceRelType":
|
case "deviceRelType":
|
||||||
val = val; // nothing to do
|
val = val; // nothing to do
|
||||||
@@ -961,7 +961,7 @@ function generateOptions(options, valuesArray, targetField, transformers, placeh
|
|||||||
// Always include selected if options are used as a source
|
// Always include selected if options are used as a source
|
||||||
let selected = options.length !== 0 && valuesArray.includes(item.id) ? 'selected' : '';
|
let selected = options.length !== 0 && valuesArray.includes(item.id) ? 'selected' : '';
|
||||||
|
|
||||||
optionsHtml += `<option class="${cssClass}" value="${item.id}" ${selected}>${labelName}</option>`;
|
optionsHtml += `<option class="${cssClass}" value="${encodeSpecialChars(item.id)}" ${selected}>${encodeSpecialChars(labelName)}</option>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ function renderDeviceLink(data, container, useName = false) {
|
|||||||
<a href="${badge.url}" target="_blank">
|
<a href="${badge.url}" target="_blank">
|
||||||
<span class="custom-chip">
|
<span class="custom-chip">
|
||||||
<span class="iconPreview">${atob(device.devIcon)}</span>
|
<span class="iconPreview">${atob(device.devIcon)}</span>
|
||||||
${useName ? device.devName : data.text}
|
${useName ? encodeSpecialChars(device.devName) : data.text}
|
||||||
<span>
|
<span>
|
||||||
(${badge.iconHtml})
|
(${badge.iconHtml})
|
||||||
</span>
|
</span>
|
||||||
@@ -1063,7 +1063,7 @@ function initHoverNodeInfo() {
|
|||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div>
|
<div>
|
||||||
<b> <div class="iconPreview">${atob(icon)}</div> </b><b class="devName"> ${name}</b><br>
|
<b> <div class="iconPreview">${atob(icon)}</div> </b><b class="devName"> ${encodeSpecialChars(name)}</b><br>
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="line">
|
<div class="line">
|
||||||
|
|||||||
@@ -10832,7 +10832,8 @@ if (typeof jQuery === 'undefined') {
|
|||||||
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
|
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
|
||||||
}
|
}
|
||||||
|
|
||||||
var $parent = $(selector === '#' ? [] : selector)
|
selector = selector === '#' ? [] : selector
|
||||||
|
var $parent = $(document).find(selector)
|
||||||
|
|
||||||
if (e) e.preventDefault()
|
if (e) e.preventDefault()
|
||||||
|
|
||||||
@@ -11228,9 +11229,15 @@ if (typeof jQuery === 'undefined') {
|
|||||||
// =================
|
// =================
|
||||||
|
|
||||||
var clickHandler = function (e) {
|
var clickHandler = function (e) {
|
||||||
var href
|
|
||||||
var $this = $(this)
|
var $this = $(this)
|
||||||
var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7
|
var href = $this.attr('href')
|
||||||
|
if (href) {
|
||||||
|
href = href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = $this.attr('data-target') || href
|
||||||
|
var $target = $(document).find(target)
|
||||||
|
|
||||||
if (!$target.hasClass('carousel')) return
|
if (!$target.hasClass('carousel')) return
|
||||||
var options = $.extend({}, $target.data(), $this.data())
|
var options = $.extend({}, $target.data(), $this.data())
|
||||||
var slideIndex = $this.attr('data-slide-to')
|
var slideIndex = $this.attr('data-slide-to')
|
||||||
@@ -11420,7 +11427,7 @@ if (typeof jQuery === 'undefined') {
|
|||||||
var target = $trigger.attr('data-target')
|
var target = $trigger.attr('data-target')
|
||||||
|| (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
|
|| (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7
|
||||||
|
|
||||||
return $(target)
|
return $(document).find(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11502,7 +11509,7 @@ if (typeof jQuery === 'undefined') {
|
|||||||
selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
|
selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
|
||||||
}
|
}
|
||||||
|
|
||||||
var $parent = selector && $(selector)
|
var $parent = selector && $(document).find(selector)
|
||||||
|
|
||||||
return $parent && $parent.length ? $parent : $this.parent()
|
return $parent && $parent.length ? $parent : $this.parent()
|
||||||
}
|
}
|
||||||
@@ -11961,7 +11968,10 @@ if (typeof jQuery === 'undefined') {
|
|||||||
$(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
|
$(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) {
|
||||||
var $this = $(this)
|
var $this = $(this)
|
||||||
var href = $this.attr('href')
|
var href = $this.attr('href')
|
||||||
var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7
|
var target = $this.attr('data-target') ||
|
||||||
|
(href && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7
|
||||||
|
|
||||||
|
var $target = $(document).find(target)
|
||||||
var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
|
var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data())
|
||||||
|
|
||||||
if ($this.is('a')) e.preventDefault()
|
if ($this.is('a')) e.preventDefault()
|
||||||
|
|||||||
@@ -1842,11 +1842,16 @@
|
|||||||
return globalLocale;
|
return globalLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLocaleNameSane(name) {
|
||||||
|
// Prevent names that look like filesystem paths, i.e contain '/' or '\'
|
||||||
|
return name.match('^[^/\\\\]*$') != null;
|
||||||
|
}
|
||||||
|
|
||||||
function loadLocale(name) {
|
function loadLocale(name) {
|
||||||
var oldLocale = null;
|
var oldLocale = null;
|
||||||
// TODO: Find a better way to register and load all the locales in Node
|
// TODO: Find a better way to register and load all the locales in Node
|
||||||
if (!locales[name] && (typeof module !== 'undefined') &&
|
if (!locales[name] && (typeof module !== 'undefined') &&
|
||||||
module && module.exports) {
|
module && module.exports && isLocaleNameSane(name)) {
|
||||||
try {
|
try {
|
||||||
oldLocale = globalLocale._abbr;
|
oldLocale = globalLocale._abbr;
|
||||||
var aliasedRequire = require;
|
var aliasedRequire = require;
|
||||||
@@ -2294,7 +2299,7 @@
|
|||||||
|
|
||||||
function preprocessRFC2822(s) {
|
function preprocessRFC2822(s) {
|
||||||
// Remove comments and folding whitespace and replace multiple-spaces with a single space
|
// Remove comments and folding whitespace and replace multiple-spaces with a single space
|
||||||
return s.replace(/\([^)]*\)|[\n\t]/g, ' ').replace(/(\s\s+)/g, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, '');
|
return s.replace(/\((?:(?!\().)*\)|[\n\t]/gs, ' ').replace(/(\s\s+)/g, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkWeekday(weekdayStr, parsedInput, config) {
|
function checkWeekday(weekdayStr, parsedInput, config) {
|
||||||
|
|||||||
@@ -251,7 +251,7 @@
|
|||||||
$select.append(
|
$select.append(
|
||||||
devicesList
|
devicesList
|
||||||
.filter(d => d.devMac && d.devName)
|
.filter(d => d.devMac && d.devName)
|
||||||
.map(d => `<option value="${d.devMac}">${d.devName}</option>`)
|
.map(d => `<option value="${d.devMac}">${encodeSpecialChars(d.devName)}</option>`)
|
||||||
.join('')
|
.join('')
|
||||||
).trigger('change');
|
).trigger('change');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
"CustProps_cant_remove": "No es pot eliminar, es necessita una propietat mínim.",
|
"CustProps_cant_remove": "No es pot eliminar, es necessita una propietat mínim.",
|
||||||
"DAYS_TO_KEEP_EVENTS_description": "Això és una configuració de manteniment. Especifica el nombre de dies que es conservaran els esdeveniments. Els esdeveniments antics s'esborraran periòdicament. També aplica als esdeveniments dels Connectors (Plugins).",
|
"DAYS_TO_KEEP_EVENTS_description": "Això és una configuració de manteniment. Especifica el nombre de dies que es conservaran els esdeveniments. Els esdeveniments antics s'esborraran periòdicament. També aplica als esdeveniments dels Connectors (Plugins).",
|
||||||
"DAYS_TO_KEEP_EVENTS_name": "Esborrar esdeveniments més vells de",
|
"DAYS_TO_KEEP_EVENTS_name": "Esborrar esdeveniments més vells de",
|
||||||
"DEEP_SLEEP_description": "",
|
"DEEP_SLEEP_description": "Redueix l'ús de la CPU ampliant els temps d'espera inactiu entre els cicles de processament. Quan està activat, les exploracions es poden retardar fins a 1 minut i la interfície d'usuari pot ser menys sensible.",
|
||||||
"DEEP_SLEEP_name": "",
|
"DEEP_SLEEP_name": "Son profund",
|
||||||
"DISCOVER_PLUGINS_description": "Desactiva aquesta opció per accelerar la inicialització i l'estalvi de configuració. Quan està desactivat, els connectors no es descobreixen, i no podeu afegir nous connectors a la configuració <code>LOADED_PLUGINS</code>.",
|
"DISCOVER_PLUGINS_description": "Desactiva aquesta opció per accelerar la inicialització i l'estalvi de configuració. Quan està desactivat, els connectors no es descobreixen, i no podeu afegir nous connectors a la configuració <code>LOADED_PLUGINS</code>.",
|
||||||
"DISCOVER_PLUGINS_name": "Descobreix els plugins",
|
"DISCOVER_PLUGINS_name": "Descobreix els plugins",
|
||||||
"DevDetail_Children_Title": "Relacions filles",
|
"DevDetail_Children_Title": "Relacions filles",
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"DevDetail_SessionTable_Duration": "Durada",
|
"DevDetail_SessionTable_Duration": "Durada",
|
||||||
"DevDetail_SessionTable_IP": "IP",
|
"DevDetail_SessionTable_IP": "IP",
|
||||||
"DevDetail_SessionTable_Order": "Ordre",
|
"DevDetail_SessionTable_Order": "Ordre",
|
||||||
"DevDetail_Shortcut_CurrentStatus": "Estat actual",
|
"DevDetail_Shortcut_CurrentStatus": "Estat",
|
||||||
"DevDetail_Shortcut_DownAlerts": "Aturar alertes",
|
"DevDetail_Shortcut_DownAlerts": "Aturar alertes",
|
||||||
"DevDetail_Shortcut_Presence": "Presència",
|
"DevDetail_Shortcut_Presence": "Presència",
|
||||||
"DevDetail_Shortcut_Sessions": "Sessions",
|
"DevDetail_Shortcut_Sessions": "Sessions",
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
"Device_TableHead_NetworkSite": "Network Site",
|
"Device_TableHead_NetworkSite": "Network Site",
|
||||||
"Device_TableHead_Owner": "Propietari",
|
"Device_TableHead_Owner": "Propietari",
|
||||||
"Device_TableHead_ParentRelType": "Tipus de relació",
|
"Device_TableHead_ParentRelType": "Tipus de relació",
|
||||||
"Device_TableHead_Parent_MAC": "Node pare de xarxa",
|
"Device_TableHead_Parent_MAC": "Node pare",
|
||||||
"Device_TableHead_Port": "Port",
|
"Device_TableHead_Port": "Port",
|
||||||
"Device_TableHead_PresentLastScan": "Presència",
|
"Device_TableHead_PresentLastScan": "Presència",
|
||||||
"Device_TableHead_ReqNicsOnline": "Requereix NICs En línia",
|
"Device_TableHead_ReqNicsOnline": "Requereix NICs En línia",
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
"Gen_LockedDB": "ERROR - DB podria estar bloquejada - Fes servir F12 Eines desenvolupament -> Consola o provar-ho més tard.",
|
"Gen_LockedDB": "ERROR - DB podria estar bloquejada - Fes servir F12 Eines desenvolupament -> Consola o provar-ho més tard.",
|
||||||
"Gen_NetworkMask": "Màscara de xarxa",
|
"Gen_NetworkMask": "Màscara de xarxa",
|
||||||
"Gen_New": "Nou",
|
"Gen_New": "Nou",
|
||||||
"Gen_No_Data": "",
|
"Gen_No_Data": "Sense dades",
|
||||||
"Gen_Offline": "Fora de línia",
|
"Gen_Offline": "Fora de línia",
|
||||||
"Gen_Okay": "Ok",
|
"Gen_Okay": "Ok",
|
||||||
"Gen_Online": "En línia",
|
"Gen_Online": "En línia",
|
||||||
|
|||||||
@@ -382,13 +382,13 @@
|
|||||||
"HRS_TO_KEEP_NEWDEV_name": "Odstranit nová zařízení po",
|
"HRS_TO_KEEP_NEWDEV_name": "Odstranit nová zařízení po",
|
||||||
"HRS_TO_KEEP_OFFDEV_description": "Toto je údržbářské nastavení <b>ODSTRANĚNÍ zařízení</b>. Pokud je povoleno (<code>0</code> zakázáno), zařízení <b>Offline</b> s jejich datumem <b>Posledního připojení</b> starším, než uvedené hodiny v tomto nastavení, budou odstraněna. Použijte toto nastavení, pokud chcete automaticky mazat <b>Offline zařízení</b> po uplynutí <code>X</code> hodin offline.",
|
"HRS_TO_KEEP_OFFDEV_description": "Toto je údržbářské nastavení <b>ODSTRANĚNÍ zařízení</b>. Pokud je povoleno (<code>0</code> zakázáno), zařízení <b>Offline</b> s jejich datumem <b>Posledního připojení</b> starším, než uvedené hodiny v tomto nastavení, budou odstraněna. Použijte toto nastavení, pokud chcete automaticky mazat <b>Offline zařízení</b> po uplynutí <code>X</code> hodin offline.",
|
||||||
"HRS_TO_KEEP_OFFDEV_name": "Odstranit offline zařízení po",
|
"HRS_TO_KEEP_OFFDEV_name": "Odstranit offline zařízení po",
|
||||||
"LOADED_PLUGINS_description": "",
|
"LOADED_PLUGINS_description": "Které pluginy načíst. Přidávání pluginů může aplikaci zpomalit. Přečtěte si více o pluginech, které musí být povoleny, o typech, nebo o scannovacích moźnostech v <a target=\"_blank\" href=\"https://docs.netalertx.com/PLUGINS\">dokumentaci pluginů</a>. Odpojené pluginy ztratí vaše nastavení. Pouze <code>deaktivované</code> pluginy mohou být odpojeny.",
|
||||||
"LOADED_PLUGINS_name": "Načtené pluginy",
|
"LOADED_PLUGINS_name": "Načtené pluginy",
|
||||||
"LOG_LEVEL_description": "Toto nastavení způsobí více detailní logování. To je užitečné pro ladění událostí zapisujících do databáze.",
|
"LOG_LEVEL_description": "Toto nastavení způsobí více detailní logování. To je užitečné pro ladění událostí zapisujících do databáze.",
|
||||||
"LOG_LEVEL_name": "Vypisovat dodatečné logování",
|
"LOG_LEVEL_name": "Vypisovat dodatečné logování",
|
||||||
"Loading": "Načítání…",
|
"Loading": "Načítání…",
|
||||||
"Login_Box": "Zadejte vaše heslo",
|
"Login_Box": "Zadejte vaše heslo",
|
||||||
"Login_Default_PWD": "",
|
"Login_Default_PWD": "Výchozí heslo \"123456\" je stále aktivní.",
|
||||||
"Login_Info": "Hesla jsou nastavována přes Set Password plugin. Zkontrolujte <a target=\"_blank\" href=\"https://github.com/netalertx/NetAlertX/tree/main/front/plugins/set_password\">dokumentaci SETPWD</a>, pokud máte potíže s přihlášením.",
|
"Login_Info": "Hesla jsou nastavována přes Set Password plugin. Zkontrolujte <a target=\"_blank\" href=\"https://github.com/netalertx/NetAlertX/tree/main/front/plugins/set_password\">dokumentaci SETPWD</a>, pokud máte potíže s přihlášením.",
|
||||||
"Login_Psw-box": "Heslo",
|
"Login_Psw-box": "Heslo",
|
||||||
"Login_Psw_alert": "Upozornění na heslo!",
|
"Login_Psw_alert": "Upozornění na heslo!",
|
||||||
@@ -437,32 +437,32 @@
|
|||||||
"Maintenance_Tool_UnlockFields_text": "Tento nástroj odstraní všechny zdrojové hodnoty ze všech sledovaných polí pro všechna zařízení, čímž efektivně odemkne všechna pole pro pluginy a uživatele. Používejte jej opatrně, protože to ovlivní celý inventář vašich zařízení.",
|
"Maintenance_Tool_UnlockFields_text": "Tento nástroj odstraní všechny zdrojové hodnoty ze všech sledovaných polí pro všechna zařízení, čímž efektivně odemkne všechna pole pro pluginy a uživatele. Používejte jej opatrně, protože to ovlivní celý inventář vašich zařízení.",
|
||||||
"Maintenance_Tool_arpscansw": "Přepnout ARP sken (zapnuto/vypnuto)",
|
"Maintenance_Tool_arpscansw": "Přepnout ARP sken (zapnuto/vypnuto)",
|
||||||
"Maintenance_Tool_arpscansw_noti": "Přepne ARP sken na zapnuto nebo vypnuto",
|
"Maintenance_Tool_arpscansw_noti": "Přepne ARP sken na zapnuto nebo vypnuto",
|
||||||
"Maintenance_Tool_arpscansw_noti_text": "Pokud je scan vypnut, zůstává vypnutý do opětovné aktivace.",
|
"Maintenance_Tool_arpscansw_noti_text": "Pokud je sken vypnut, zůstává vypnutý do opětovné aktivace.",
|
||||||
"Maintenance_Tool_arpscansw_text": "",
|
"Maintenance_Tool_arpscansw_text": "Přepíná ARP-SCAN na zapnuto, nebo vypnuto. Jakmile je skenování vypnuto, zůstává vypnuto, dokud není znovu aktivováno. Aktivní skenování nejsou přerušena.",
|
||||||
"Maintenance_Tool_backup": "",
|
"Maintenance_Tool_backup": "Záloha DB",
|
||||||
"Maintenance_Tool_backup_noti": "",
|
"Maintenance_Tool_backup_noti": "Záloha DB",
|
||||||
"Maintenance_Tool_backup_noti_text": "",
|
"Maintenance_Tool_backup_noti_text": "Opravdu chcete spustit zálohu DB? Ujistěte se, že neběží žádný sken.",
|
||||||
"Maintenance_Tool_backup_text": "",
|
"Maintenance_Tool_backup_text": "Databázové zálohy jsou umístěny v databázovém adresáři jako ZIP archiv pojmenovaný podle jeho data vytvoření. Počet backupů není limitován.",
|
||||||
"Maintenance_Tool_check_visible": "",
|
"Maintenance_Tool_check_visible": "Odšrktněte pro skrytí sloupce.",
|
||||||
"Maintenance_Tool_clearSourceFields_selected": "",
|
"Maintenance_Tool_clearSourceFields_selected": "Vymazat zrojové položky",
|
||||||
"Maintenance_Tool_clearSourceFields_selected_noti": "",
|
"Maintenance_Tool_clearSourceFields_selected_noti": "Vymazat zdroje",
|
||||||
"Maintenance_Tool_clearSourceFields_selected_text": "",
|
"Maintenance_Tool_clearSourceFields_selected_text": "Toto vymaže všechny zdrojové položky zvolených zařízení. Tuto akci nelze vrátit zpět.",
|
||||||
"Maintenance_Tool_darkmode": "",
|
"Maintenance_Tool_darkmode": "Přepnutí režimů (Tmavý/Světlý)",
|
||||||
"Maintenance_Tool_darkmode_noti": "",
|
"Maintenance_Tool_darkmode_noti": "Přepnutí režimů",
|
||||||
"Maintenance_Tool_darkmode_noti_text": "",
|
"Maintenance_Tool_darkmode_noti_text": "Po změně tématu se stránka pokusí znovu načíst pro aktivaci změn. Pokud bude potřeba, musí být vymazána cache.",
|
||||||
"Maintenance_Tool_darkmode_text": "",
|
"Maintenance_Tool_darkmode_text": "Přepíná mezi tmavým a světlým režimem. Pokud přepínač nefunguje správně, zkuste vymazat cache prohlížeče. Změny jsou provedeny na straně serveru, takže ovlivňují všechna použitá zařízení.",
|
||||||
"Maintenance_Tool_del_ActHistory": "",
|
"Maintenance_Tool_del_ActHistory": "Odstranění síťové aktivity",
|
||||||
"Maintenance_Tool_del_ActHistory_noti": "",
|
"Maintenance_Tool_del_ActHistory_noti": "Odstranit síťovou aktivitu",
|
||||||
"Maintenance_Tool_del_ActHistory_noti_text": "",
|
"Maintenance_Tool_del_ActHistory_noti_text": "Opravdu chcete resetovat síťovou aktivitu?",
|
||||||
"Maintenance_Tool_del_ActHistory_text": "",
|
"Maintenance_Tool_del_ActHistory_text": "Grafy síťové aktivity byly resetovány. Toto neovlivní události.",
|
||||||
"Maintenance_Tool_del_alldev": "",
|
"Maintenance_Tool_del_alldev": "Odstanit všechna zařízení",
|
||||||
"Maintenance_Tool_del_alldev_noti": "",
|
"Maintenance_Tool_del_alldev_noti": "Odstranit zařízení",
|
||||||
"Maintenance_Tool_del_alldev_noti_text": "",
|
"Maintenance_Tool_del_alldev_noti_text": "Opravdu chcete odstranit všechna zařízení?",
|
||||||
"Maintenance_Tool_del_alldev_text": "",
|
"Maintenance_Tool_del_alldev_text": "Před použitím této funkce prosím proveďte zálohu. Odstranění nelze vrátit zpět. Všechna zařízení budou z databáze odstraněna.",
|
||||||
"Maintenance_Tool_del_allevents": "",
|
"Maintenance_Tool_del_allevents": "Odstranit události (reset přítomnosti)",
|
||||||
"Maintenance_Tool_del_allevents30": "",
|
"Maintenance_Tool_del_allevents30": "Odstranit všechny události staří 30 dní",
|
||||||
"Maintenance_Tool_del_allevents30_noti": "",
|
"Maintenance_Tool_del_allevents30_noti": "Odstranit události",
|
||||||
"Maintenance_Tool_del_allevents30_noti_text": "",
|
"Maintenance_Tool_del_allevents30_noti_text": "Opravdu chcete odstranit všechny události starší 30 dní? Toto vyresetuje přítomnost všech zařízení.",
|
||||||
"Maintenance_Tool_del_allevents30_text": "",
|
"Maintenance_Tool_del_allevents30_text": "",
|
||||||
"Maintenance_Tool_del_allevents_noti": "",
|
"Maintenance_Tool_del_allevents_noti": "",
|
||||||
"Maintenance_Tool_del_allevents_noti_text": "",
|
"Maintenance_Tool_del_allevents_noti_text": "",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"BackDevDetail_Tools_WOL_error": "O comando NÃO foi executado.",
|
"BackDevDetail_Tools_WOL_error": "O comando NÃO foi executado.",
|
||||||
"BackDevDetail_Tools_WOL_okay": "O comando foi executado.",
|
"BackDevDetail_Tools_WOL_okay": "O comando foi executado.",
|
||||||
"BackDevices_Arpscan_disabled": "Análise Arp Desativada",
|
"BackDevices_Arpscan_disabled": "Análise Arp Desativada",
|
||||||
"BackDevices_Arpscan_enabled": "Análise ARP Ativada",
|
"BackDevices_Arpscan_enabled": "Análise Arp Ativada",
|
||||||
"BackDevices_Backup_CopError": "A base da dados original não pode ser gravada.",
|
"BackDevices_Backup_CopError": "A base da dados original não pode ser gravada.",
|
||||||
"BackDevices_Backup_Failed": "A copia de segurança foi parcialmente executada. O arquivo não pode ser criado ou está vazio.",
|
"BackDevices_Backup_Failed": "A copia de segurança foi parcialmente executada. O arquivo não pode ser criado ou está vazio.",
|
||||||
"BackDevices_Backup_okay": "A copia de segurança foi feita executado corretamente com o novo arquivo",
|
"BackDevices_Backup_okay": "A copia de segurança foi feita executado corretamente com o novo arquivo",
|
||||||
@@ -61,14 +61,14 @@
|
|||||||
"BackDevices_Restore_okay": "Restauração executada com sucesso.",
|
"BackDevices_Restore_okay": "Restauração executada com sucesso.",
|
||||||
"BackDevices_darkmode_disabled": "Modo Noturno Desativado",
|
"BackDevices_darkmode_disabled": "Modo Noturno Desativado",
|
||||||
"BackDevices_darkmode_enabled": "Modo Noturno Ativado",
|
"BackDevices_darkmode_enabled": "Modo Noturno Ativado",
|
||||||
"CLEAR_NEW_FLAG_description": "Se ativado (<code>0</code> está desativado), dispositivos marcados como<b>Novo Dispositivo</b> serão desmarcados se o limite (especificado em horas) exceder o tempo da <b>Primeira Sessão </b>.",
|
"CLEAR_NEW_FLAG_description": "Se ativado (<code>0</code> está desativado), dispositivos marcados como <b>Novo Dispositivo</b> serão desmarcados se o limite (especificado em horas) exceder o tempo da <b>Primeira Sessão</b>.",
|
||||||
"CLEAR_NEW_FLAG_name": "Limpar a flag nova",
|
"CLEAR_NEW_FLAG_name": "Limpar a flag nova",
|
||||||
"CustProps_cant_remove": "Não é possível remover, é necessária pelo menos uma propriedade.",
|
"CustProps_cant_remove": "Não é possível remover, é necessária pelo menos uma propriedade.",
|
||||||
"DAYS_TO_KEEP_EVENTS_description": "Esta é uma definição de manutenção. Especifica o número de dias de entradas de eventos que serão mantidas. Todos os eventos mais antigos serão apagados periodicamente. Também se aplica ao Histórico de eventos do plug-in.",
|
"DAYS_TO_KEEP_EVENTS_description": "Esta é uma definição de manutenção. Especifica o número de dias de entradas de eventos que serão mantidas. Todos os eventos mais antigos serão apagados periodicamente. Também se aplica ao Histórico de eventos do plug-in.",
|
||||||
"DAYS_TO_KEEP_EVENTS_name": "Apagar eventos mais antigos que",
|
"DAYS_TO_KEEP_EVENTS_name": "Apagar eventos mais antigos que",
|
||||||
"DEEP_SLEEP_description": "",
|
"DEEP_SLEEP_description": "Diminui a utilização do CPU ao prolongar tempos de espera ociosos entre ciclos de processamento. Quando ativo, análises podem ser atrasadas por até 1 minuto e o UI pode ficar menos responsivo.",
|
||||||
"DEEP_SLEEP_name": "",
|
"DEEP_SLEEP_name": "Sleep profundo",
|
||||||
"DISCOVER_PLUGINS_description": "Desative esta opção para acelerar a inicialização e a gravação de definições. Quando desativada, os plug-ins não são descobertos e não é possível adicionar novos plug-ins à definição<code>LOADED_PLUGINS</code>.",
|
"DISCOVER_PLUGINS_description": "Desative esta opção para acelerar a inicialização e a gravação de definições. Quando desativada, os plug-ins não são descobertos e não é possível adicionar novos plug-ins à definição <code>LOADED_PLUGINS</code>.",
|
||||||
"DISCOVER_PLUGINS_name": "Descobrir plugins",
|
"DISCOVER_PLUGINS_name": "Descobrir plugins",
|
||||||
"DevDetail_Children_Title": "Relacionamentos de crianças",
|
"DevDetail_Children_Title": "Relacionamentos de crianças",
|
||||||
"DevDetail_Copy_Device_Title": "Copiar pormenores do dispositivo",
|
"DevDetail_Copy_Device_Title": "Copiar pormenores do dispositivo",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"DevDetail_MainInfo_Location": "Localização",
|
"DevDetail_MainInfo_Location": "Localização",
|
||||||
"DevDetail_MainInfo_Name": "Nome",
|
"DevDetail_MainInfo_Name": "Nome",
|
||||||
"DevDetail_MainInfo_Network": "<i class=\"fa fa-server\"> </i> Node (MAC)",
|
"DevDetail_MainInfo_Network": "<i class=\"fa fa-server\"> </i> Node (MAC)",
|
||||||
"DevDetail_MainInfo_Network_Port": "<i class=\"fa fa-ethernet\"></i>Porta",
|
"DevDetail_MainInfo_Network_Port": "<i class=\"fa fa-ethernet\"></i> Porta",
|
||||||
"DevDetail_MainInfo_Network_Site": "Site",
|
"DevDetail_MainInfo_Network_Site": "Site",
|
||||||
"DevDetail_MainInfo_Network_Title": "Detalhes de Rede",
|
"DevDetail_MainInfo_Network_Title": "Detalhes de Rede",
|
||||||
"DevDetail_MainInfo_Owner": "Proprietário",
|
"DevDetail_MainInfo_Owner": "Proprietário",
|
||||||
@@ -249,8 +249,8 @@
|
|||||||
"Device_TableHead_Name": "Nome",
|
"Device_TableHead_Name": "Nome",
|
||||||
"Device_TableHead_NetworkSite": "Site da rede",
|
"Device_TableHead_NetworkSite": "Site da rede",
|
||||||
"Device_TableHead_Owner": "Proprietário",
|
"Device_TableHead_Owner": "Proprietário",
|
||||||
"Device_TableHead_ParentRelType": "Tipo de relação",
|
"Device_TableHead_ParentRelType": "Relação",
|
||||||
"Device_TableHead_Parent_MAC": "Node de rede anterior",
|
"Device_TableHead_Parent_MAC": "Nó parente",
|
||||||
"Device_TableHead_Port": "Porta",
|
"Device_TableHead_Port": "Porta",
|
||||||
"Device_TableHead_PresentLastScan": "Presença",
|
"Device_TableHead_PresentLastScan": "Presença",
|
||||||
"Device_TableHead_ReqNicsOnline": "Exigir NICs online",
|
"Device_TableHead_ReqNicsOnline": "Exigir NICs online",
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
"Gen_Filter": "Filtro",
|
"Gen_Filter": "Filtro",
|
||||||
"Gen_Flapping": "Flapping",
|
"Gen_Flapping": "Flapping",
|
||||||
"Gen_Generate": "Gerar",
|
"Gen_Generate": "Gerar",
|
||||||
"Gen_InvalidMac": "Endereço MAC Inválido.",
|
"Gen_InvalidMac": "Endereço Mac inválido.",
|
||||||
"Gen_Invalid_Value": "Um valor inválido foi inserido",
|
"Gen_Invalid_Value": "Um valor inválido foi inserido",
|
||||||
"Gen_LockedDB": "ERRO - A base de dados pode estar bloqueada - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.",
|
"Gen_LockedDB": "ERRO - A base de dados pode estar bloqueada - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.",
|
||||||
"Gen_NetworkMask": "Máscara de Rede",
|
"Gen_NetworkMask": "Máscara de Rede",
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
"Gen_Offline": "Offline",
|
"Gen_Offline": "Offline",
|
||||||
"Gen_Okay": "Ok",
|
"Gen_Okay": "Ok",
|
||||||
"Gen_Online": "Online",
|
"Gen_Online": "Online",
|
||||||
"Gen_Purge": "Purge",
|
"Gen_Purge": "Purgar",
|
||||||
"Gen_ReadDocs": "Leia mais em documentos.",
|
"Gen_ReadDocs": "Leia mais em documentos.",
|
||||||
"Gen_Remove_All": "Remover tudo",
|
"Gen_Remove_All": "Remover tudo",
|
||||||
"Gen_Remove_Last": "Remover o último",
|
"Gen_Remove_Last": "Remover o último",
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
"Login_Toggle_Info_headline": "Informações sobre a palavra-passe",
|
"Login_Toggle_Info_headline": "Informações sobre a palavra-passe",
|
||||||
"Maint_PurgeLog": "Limpar o registo",
|
"Maint_PurgeLog": "Limpar o registo",
|
||||||
"Maint_RestartServer": "Reiniciar o servidor",
|
"Maint_RestartServer": "Reiniciar o servidor",
|
||||||
"Maint_Restart_Server_noti_text": "Tem certeza de que deseja reiniciar o servidor backend? Isto pode causar inconsistência na app. Faça primeiro um backup da sua configuração. <br/> <br/> Nota: Isto pode levar alguns minutos.",
|
"Maint_Restart_Server_noti_text": "Tem a certeza que quer reiniciar o servidor backend? Isto pode causar inconsistências na aplicação. Crie uma cópia de segurança primeiro. <br/><br/> Nota: isto pode demorar alguns minutos.",
|
||||||
"Maintenance_InitCheck": "Verificação inicial",
|
"Maintenance_InitCheck": "Verificação inicial",
|
||||||
"Maintenance_InitCheck_Checking": "A verificar…",
|
"Maintenance_InitCheck_Checking": "A verificar…",
|
||||||
"Maintenance_InitCheck_QuickSetupGuide": "Certifique-se de que seguiu o <a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">guia de configuração rápida</a>.",
|
"Maintenance_InitCheck_QuickSetupGuide": "Certifique-se de que seguiu o <a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">guia de configuração rápida</a>.",
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
"Maintenance_Tool_upgrade_database_noti_text": "Tem certeza de que deseja atualizar a base de dados?<br>(talvez prefira arquivá-la)",
|
"Maintenance_Tool_upgrade_database_noti_text": "Tem certeza de que deseja atualizar a base de dados?<br>(talvez prefira arquivá-la)",
|
||||||
"Maintenance_Tool_upgrade_database_text": "Este botão atualizará a base de dados para ativar o gráfico Atividade de rede nas últimas 12 horas. Faça uma cópia de segurança da sua base de dados em caso de problemas.",
|
"Maintenance_Tool_upgrade_database_text": "Este botão atualizará a base de dados para ativar o gráfico Atividade de rede nas últimas 12 horas. Faça uma cópia de segurança da sua base de dados em caso de problemas.",
|
||||||
"Maintenance_Tools_Tab_BackupRestore": "Backup / Restauração",
|
"Maintenance_Tools_Tab_BackupRestore": "Backup / Restauração",
|
||||||
"Maintenance_Tools_Tab_Logging": "Logs",
|
"Maintenance_Tools_Tab_Logging": "Registos",
|
||||||
"Maintenance_Tools_Tab_Settings": "Configurações",
|
"Maintenance_Tools_Tab_Settings": "Configurações",
|
||||||
"Maintenance_Tools_Tab_Tools": "Ferramentas",
|
"Maintenance_Tools_Tab_Tools": "Ferramentas",
|
||||||
"Maintenance_Tools_Tab_UISettings": "Configurações de interface",
|
"Maintenance_Tools_Tab_UISettings": "Configurações de interface",
|
||||||
@@ -503,7 +503,7 @@
|
|||||||
"Maintenance_arp_status_off": "está atualmente desativado",
|
"Maintenance_arp_status_off": "está atualmente desativado",
|
||||||
"Maintenance_arp_status_on": "Scan em curso",
|
"Maintenance_arp_status_on": "Scan em curso",
|
||||||
"Maintenance_built_on": "Construído em",
|
"Maintenance_built_on": "Construído em",
|
||||||
"Maintenance_current_version": "Você está atualizado. Confira o que <a href=\"https://github.com/netalertx/NetAlertX/issues/138\" target=\"_blank\"> estou a trabalhar em</a>.",
|
"Maintenance_current_version": "Você está atualizado. Confira no que é que <a href=\"https://github.com/netalertx/NetAlertX/issues/138\" target=\"_blank\">estou a trabalhar em</a>.",
|
||||||
"Maintenance_database_backup": "Backups DB",
|
"Maintenance_database_backup": "Backups DB",
|
||||||
"Maintenance_database_backup_found": "foram encontrados backups",
|
"Maintenance_database_backup_found": "foram encontrados backups",
|
||||||
"Maintenance_database_backup_total": "uso total do disco",
|
"Maintenance_database_backup_total": "uso total do disco",
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
"Navigation_Report": "Reports enviados",
|
"Navigation_Report": "Reports enviados",
|
||||||
"Navigation_Settings": "Definições",
|
"Navigation_Settings": "Definições",
|
||||||
"Navigation_SystemInfo": "Informação de sistema",
|
"Navigation_SystemInfo": "Informação de sistema",
|
||||||
"Navigation_Workflows": "Workflows",
|
"Navigation_Workflows": "Fluxos de Trabalho",
|
||||||
"Network_Assign": "Conectar ao nodo de network <i class=\"fa fa-server\"></i> em cima",
|
"Network_Assign": "Conectar ao nodo de network <i class=\"fa fa-server\"></i> em cima",
|
||||||
"Network_Cant_Assign": "Não é possível atribuir o node raiz da Internet como um node folha filho.",
|
"Network_Cant_Assign": "Não é possível atribuir o node raiz da Internet como um node folha filho.",
|
||||||
"Network_Cant_Assign_No_Node_Selected": "Não é possível atribuir, nenhum node pai selecionado.",
|
"Network_Cant_Assign_No_Node_Selected": "Não é possível atribuir, nenhum node pai selecionado.",
|
||||||
@@ -565,13 +565,13 @@
|
|||||||
"Network_ManageEdit_Name": "Novo nome de dispositivo",
|
"Network_ManageEdit_Name": "Novo nome de dispositivo",
|
||||||
"Network_ManageEdit_Name_text": "Nome sem caracteres especiais",
|
"Network_ManageEdit_Name_text": "Nome sem caracteres especiais",
|
||||||
"Network_ManageEdit_Port": " Nova contagem de portas",
|
"Network_ManageEdit_Port": " Nova contagem de portas",
|
||||||
"Network_ManageEdit_Port_text": "Deixe em branco para Wi-Fi e Powerline.",
|
"Network_ManageEdit_Port_text": "Deixe em branco para Wi-Fi e Powerline",
|
||||||
"Network_ManageEdit_Submit": "Guardar Alterações",
|
"Network_ManageEdit_Submit": "Guardar Alterações",
|
||||||
"Network_ManageEdit_Type": "Novo tipo de dispositivo",
|
"Network_ManageEdit_Type": "Novo tipo de dispositivo",
|
||||||
"Network_ManageEdit_Type_text": "-- Selecionar tipo --",
|
"Network_ManageEdit_Type_text": "-- Selecionar tipo --",
|
||||||
"Network_ManageLeaf": "Gerir atribuição",
|
"Network_ManageLeaf": "Gerir atribuição",
|
||||||
"Network_ManageUnassign": "Cancelar Atribuição",
|
"Network_ManageUnassign": "Cancelar Atribuição",
|
||||||
"Network_NoAssignedDevices": "Este nó de rede não tem quaisquer dispositivos atribuídos (nós folha). Atribua um abaixo ou vá ao separador <b><i class=\"fa fa-info-circle\"> Detalhes</b> em qualquer dispositivo em <a href=\"devices.php\"><b><i class=\"fa fa-laptop\"></i> Dispositivos</b></a>, e atribua-o a um <i <b><i class=\"fa fa-server\"></i> Nó de rede (MAC)</b> e <b><i class=\"fa fa-ethernet\"></i> Porta</b> lá.",
|
"Network_NoAssignedDevices": "Este nó de rede não tem quaisquer dispositivos atribuídos (nós folha). Atribua um abaixo ou vá à aba <b><i class=\"fa fa-info-circle\"></i> Detalhes</b> de qualquer dispositivo em <a href=\"devices.php\"><b> <i class=\"fa fa-laptop\"></i> Dispositivos</b></a>, e atribua os mesmos a uma rede <b><i class=\"fa fa-server\"></i> Nó (MAC)</b> e <b><i class=\"fa fa-ethernet\"></i> Porta</b> lá.",
|
||||||
"Network_NoDevices": "Sem dispositivos para configurar",
|
"Network_NoDevices": "Sem dispositivos para configurar",
|
||||||
"Network_Node": "Nó de rede",
|
"Network_Node": "Nó de rede",
|
||||||
"Network_Node_Name": "Nome do nó",
|
"Network_Node_Name": "Nome do nó",
|
||||||
@@ -629,183 +629,183 @@
|
|||||||
"REFRESH_FQDN_description": "Reanalisa todos os dispositivos e atualiza o seu Nome de Domínio Qualificado Completo (FQDN). Se estiver desativado, apenas dispositivos sem um nome conhecido serão analisados para melhorar o desempenho. Neste caso, FQDN é atualizado apenas durante a descoberta de dispositivos inicial.",
|
"REFRESH_FQDN_description": "Reanalisa todos os dispositivos e atualiza o seu Nome de Domínio Qualificado Completo (FQDN). Se estiver desativado, apenas dispositivos sem um nome conhecido serão analisados para melhorar o desempenho. Neste caso, FQDN é atualizado apenas durante a descoberta de dispositivos inicial.",
|
||||||
"REFRESH_FQDN_name": "Atualizar FQDN",
|
"REFRESH_FQDN_name": "Atualizar FQDN",
|
||||||
"REPORT_DASHBOARD_URL_description": "Este URL é usado como base para gerar links nos relatórios HTML (p.ex.: emails). Introduza o URL começado com <code>http://</code> incluindo o número da porta (sem barra final <code>/</code>).",
|
"REPORT_DASHBOARD_URL_description": "Este URL é usado como base para gerar links nos relatórios HTML (p.ex.: emails). Introduza o URL começado com <code>http://</code> incluindo o número da porta (sem barra final <code>/</code>).",
|
||||||
"REPORT_DASHBOARD_URL_name": "",
|
"REPORT_DASHBOARD_URL_name": "URL NetAlertX",
|
||||||
"REPORT_ERROR": "",
|
"REPORT_ERROR": "A página que procura está indisponível temporariamente, por favor tente outra vez após alguns segundos",
|
||||||
"REPORT_MAIL_description": "",
|
"REPORT_MAIL_description": "Se ativo, um email é enviado com uma lista de mudanças às quais subscreveu. Por favor preencha também todas as definições relacionadas com a configuração SMTP abaixo. Se está a encontrar problemas, defina <code>LOG_LEVEL</code> para <code>debug</code> e verifique o <a href=\"/maintenance.php#tab_Logging\">registo de erros</a>.",
|
||||||
"REPORT_MAIL_name": "",
|
"REPORT_MAIL_name": "Ativar email",
|
||||||
"REPORT_TITLE": "",
|
"REPORT_TITLE": "Reportar",
|
||||||
"RandomMAC_hover": "",
|
"RandomMAC_hover": "Este dispositivo tem um endereço MAC aleatório",
|
||||||
"Reports_Sent_Log": "",
|
"Reports_Sent_Log": "Registo de relatórios enviado",
|
||||||
"SCAN_SUBNETS_description": "",
|
"SCAN_SUBNETS_description": "A maior parte dos scanners on-network (ARP-SCAN, NMAP, NSLOOKUP, DIG) baseiam-se em scanear interfaces de rede específicas e subredes. Veja a <a href=\"https://docs.netalertx.com/SUBNETS\" target=\"_blank\">documentação de subredes</a> para ajudar com esta definição, especialmente VLANs, quais VLANs são suportadas, ou como descobrir a máscara de rede e a sua interface. <br/> <br/> Uma alternativa a scanners on-network é ativar outro scanner de dispositivos/importadores que não dependam do NetAlert<sup>X</sup> tenha acesso à rede (UNIFI, dhcp.leases, PiHole, etc.). <br/> <br/> Nota: O tempo de scaneamento em si depende do número de endereços de IP a verificar, por isso configure isto com cuidado com a máscara e interface de rede apropriadas.",
|
||||||
"SCAN_SUBNETS_name": "",
|
"SCAN_SUBNETS_name": "Redes a scanear",
|
||||||
"SYSTEM_TITLE": "",
|
"SYSTEM_TITLE": "Informação de Sistema",
|
||||||
"Setting_Override": "",
|
"Setting_Override": "Sobrescrever valor",
|
||||||
"Setting_Override_Description": "",
|
"Setting_Override_Description": "Ativar esta opção irá sobrescrever o valor predefinido pela App com o valor especificado acima.",
|
||||||
"Settings_Metadata_Toggle": "",
|
"Settings_Metadata_Toggle": "Mostrar/esconder metadados para definição especificada.",
|
||||||
"Settings_Show_Description": "",
|
"Settings_Show_Description": "Mostrar descrição",
|
||||||
"Settings_device_Scanners_desync": "",
|
"Settings_device_Scanners_desync": "⚠ Os horários de procura de dispositivos estão dessincronizados.",
|
||||||
"Settings_device_Scanners_desync_popup": "",
|
"Settings_device_Scanners_desync_popup": "Horários de scanners de dispositivos (<code>*_RUN_SCHD</code>) não são o mesmo. Isto resultará em notificações de dispositivo online/offline inconsistentes. A menos que isto seja intencional, por favor use o mesmo horário para todos os <b>🔍scanners de dispositivos</b> ativos.",
|
||||||
"Speedtest_Results": "",
|
"Speedtest_Results": "Resultados do Teste de Velocidade",
|
||||||
"Systeminfo_AvailableIps": "",
|
"Systeminfo_AvailableIps": "IPs Disponíveis",
|
||||||
"Systeminfo_CPU": "",
|
"Systeminfo_CPU": "CPU",
|
||||||
"Systeminfo_CPU_Cores": "",
|
"Systeminfo_CPU_Cores": "Cores CPU:",
|
||||||
"Systeminfo_CPU_Name": "",
|
"Systeminfo_CPU_Name": "Nome do CPU:",
|
||||||
"Systeminfo_CPU_Speed": "",
|
"Systeminfo_CPU_Speed": "Velocidade do CPU:",
|
||||||
"Systeminfo_CPU_Temp": "",
|
"Systeminfo_CPU_Temp": "Temperatura do CPU:",
|
||||||
"Systeminfo_CPU_Vendor": "",
|
"Systeminfo_CPU_Vendor": "Fornecedor do CPU:",
|
||||||
"Systeminfo_Client_Resolution": "",
|
"Systeminfo_Client_Resolution": "Resolução do Browser:",
|
||||||
"Systeminfo_Client_User_Agent": "",
|
"Systeminfo_Client_User_Agent": "Agente do Utilizador:",
|
||||||
"Systeminfo_General": "",
|
"Systeminfo_General": "Geral",
|
||||||
"Systeminfo_General_Date": "",
|
"Systeminfo_General_Date": "Data:",
|
||||||
"Systeminfo_General_Date2": "",
|
"Systeminfo_General_Date2": "Data2:",
|
||||||
"Systeminfo_General_Full_Date": "",
|
"Systeminfo_General_Full_Date": "Data Completa:",
|
||||||
"Systeminfo_General_TimeZone": "",
|
"Systeminfo_General_TimeZone": "Fuso Horário:",
|
||||||
"Systeminfo_Memory": "",
|
"Systeminfo_Memory": "Memória",
|
||||||
"Systeminfo_Memory_Total_Memory": "",
|
"Systeminfo_Memory_Total_Memory": "Memória total:",
|
||||||
"Systeminfo_Memory_Usage": "",
|
"Systeminfo_Memory_Usage": "Utilização de memória:",
|
||||||
"Systeminfo_Memory_Usage_Percent": "",
|
"Systeminfo_Memory_Usage_Percent": "Memória %:",
|
||||||
"Systeminfo_Motherboard": "",
|
"Systeminfo_Motherboard": "Motherboard",
|
||||||
"Systeminfo_Motherboard_BIOS": "",
|
"Systeminfo_Motherboard_BIOS": "BIOS:",
|
||||||
"Systeminfo_Motherboard_BIOS_Date": "",
|
"Systeminfo_Motherboard_BIOS_Date": "Data da BIOS:",
|
||||||
"Systeminfo_Motherboard_BIOS_Vendor": "",
|
"Systeminfo_Motherboard_BIOS_Vendor": "Fabricante da BIOS:",
|
||||||
"Systeminfo_Motherboard_Manufactured": "",
|
"Systeminfo_Motherboard_Manufactured": "Fabricado por:",
|
||||||
"Systeminfo_Motherboard_Name": "",
|
"Systeminfo_Motherboard_Name": "Nome:",
|
||||||
"Systeminfo_Motherboard_Revision": "",
|
"Systeminfo_Motherboard_Revision": "Revisão:",
|
||||||
"Systeminfo_Network": "",
|
"Systeminfo_Network": "Rede",
|
||||||
"Systeminfo_Network_Accept_Encoding": "",
|
"Systeminfo_Network_Accept_Encoding": "Aceitar codificação:",
|
||||||
"Systeminfo_Network_Accept_Language": "",
|
"Systeminfo_Network_Accept_Language": "Aceitar idioma:",
|
||||||
"Systeminfo_Network_Connection_Port": "",
|
"Systeminfo_Network_Connection_Port": "Porta de conexão:",
|
||||||
"Systeminfo_Network_HTTP_Host": "",
|
"Systeminfo_Network_HTTP_Host": "Anfitrião HTTP:",
|
||||||
"Systeminfo_Network_HTTP_Referer": "",
|
"Systeminfo_Network_HTTP_Referer": "Referenciador HTTP:",
|
||||||
"Systeminfo_Network_HTTP_Referer_String": "",
|
"Systeminfo_Network_HTTP_Referer_String": "Nenhum referenciador HTTP",
|
||||||
"Systeminfo_Network_Hardware": "",
|
"Systeminfo_Network_Hardware": "Hardware de Rede",
|
||||||
"Systeminfo_Network_Hardware_Interface_Mask": "",
|
"Systeminfo_Network_Hardware_Interface_Mask": "Máscara de Rede",
|
||||||
"Systeminfo_Network_Hardware_Interface_Name": "",
|
"Systeminfo_Network_Hardware_Interface_Name": "Nome da Interface",
|
||||||
"Systeminfo_Network_Hardware_Interface_RX": "",
|
"Systeminfo_Network_Hardware_Interface_RX": "Recebido",
|
||||||
"Systeminfo_Network_Hardware_Interface_TX": "",
|
"Systeminfo_Network_Hardware_Interface_TX": "Transmitido",
|
||||||
"Systeminfo_Network_IP": "",
|
"Systeminfo_Network_IP": "Internet IP:",
|
||||||
"Systeminfo_Network_IP_Connection": "",
|
"Systeminfo_Network_IP_Connection": "Conexão IP:",
|
||||||
"Systeminfo_Network_IP_Server": "",
|
"Systeminfo_Network_IP_Server": "IP do Servidor:",
|
||||||
"Systeminfo_Network_MIME": "",
|
"Systeminfo_Network_MIME": "MIME:",
|
||||||
"Systeminfo_Network_Request_Method": "",
|
"Systeminfo_Network_Request_Method": "Método do Pedido:",
|
||||||
"Systeminfo_Network_Request_Time": "",
|
"Systeminfo_Network_Request_Time": "Tempo do pedido:",
|
||||||
"Systeminfo_Network_Request_URI": "",
|
"Systeminfo_Network_Request_URI": "URI do Pedido:",
|
||||||
"Systeminfo_Network_Secure_Connection": "",
|
"Systeminfo_Network_Secure_Connection": "Ligação segura:",
|
||||||
"Systeminfo_Network_Secure_Connection_String": "",
|
"Systeminfo_Network_Secure_Connection_String": "Sem (HTTP)",
|
||||||
"Systeminfo_Network_Server_Name": "",
|
"Systeminfo_Network_Server_Name": "Nome do servidor:",
|
||||||
"Systeminfo_Network_Server_Name_String": "",
|
"Systeminfo_Network_Server_Name_String": "Nome de servidor não encontrado",
|
||||||
"Systeminfo_Network_Server_Query": "",
|
"Systeminfo_Network_Server_Query": "Consulta de servidor:",
|
||||||
"Systeminfo_Network_Server_Query_String": "",
|
"Systeminfo_Network_Server_Query_String": "Nenhuma string de consulta",
|
||||||
"Systeminfo_Network_Server_Version": "",
|
"Systeminfo_Network_Server_Version": "Versão do Servidor:",
|
||||||
"Systeminfo_Services": "",
|
"Systeminfo_Services": "Serviços",
|
||||||
"Systeminfo_Services_Description": "",
|
"Systeminfo_Services_Description": "Descrição do Serviço",
|
||||||
"Systeminfo_Services_Name": "",
|
"Systeminfo_Services_Name": "Nome do Serviço",
|
||||||
"Systeminfo_Storage": "",
|
"Systeminfo_Storage": "Armazenamento",
|
||||||
"Systeminfo_Storage_Device": "",
|
"Systeminfo_Storage_Device": "Dispositivo:",
|
||||||
"Systeminfo_Storage_Mount": "",
|
"Systeminfo_Storage_Mount": "Ponto de montagem:",
|
||||||
"Systeminfo_Storage_Size": "",
|
"Systeminfo_Storage_Size": "Tamanho:",
|
||||||
"Systeminfo_Storage_Type": "",
|
"Systeminfo_Storage_Type": "Tipo:",
|
||||||
"Systeminfo_Storage_Usage": "",
|
"Systeminfo_Storage_Usage": "Utilização de armazenamento",
|
||||||
"Systeminfo_Storage_Usage_Free": "",
|
"Systeminfo_Storage_Usage_Free": "Livre:",
|
||||||
"Systeminfo_Storage_Usage_Mount": "",
|
"Systeminfo_Storage_Usage_Mount": "Ponto de montagem:",
|
||||||
"Systeminfo_Storage_Usage_Total": "",
|
"Systeminfo_Storage_Usage_Total": "Total:",
|
||||||
"Systeminfo_Storage_Usage_Used": "",
|
"Systeminfo_Storage_Usage_Used": "Usado:",
|
||||||
"Systeminfo_System": "",
|
"Systeminfo_System": "Sistema",
|
||||||
"Systeminfo_System_AVG": "",
|
"Systeminfo_System_AVG": "Média de carregamento:",
|
||||||
"Systeminfo_System_Architecture": "",
|
"Systeminfo_System_Architecture": "Arquitetura:",
|
||||||
"Systeminfo_System_Kernel": "",
|
"Systeminfo_System_Kernel": "Kernel:",
|
||||||
"Systeminfo_System_OSVersion": "",
|
"Systeminfo_System_OSVersion": "Sistema Operativo:",
|
||||||
"Systeminfo_System_Running_Processes": "",
|
"Systeminfo_System_Running_Processes": "Processos em execução:",
|
||||||
"Systeminfo_System_System": "",
|
"Systeminfo_System_System": "Sistema:",
|
||||||
"Systeminfo_System_Uname": "",
|
"Systeminfo_System_Uname": "Uname:",
|
||||||
"Systeminfo_System_Uptime": "",
|
"Systeminfo_System_Uptime": "Tempo de atividade:",
|
||||||
"Systeminfo_This_Client": "",
|
"Systeminfo_This_Client": "Este Cliente",
|
||||||
"Systeminfo_USB_Devices": "",
|
"Systeminfo_USB_Devices": "Dispositivos USB",
|
||||||
"TICKER_MIGRATE_TO_NETALERTX": "",
|
"TICKER_MIGRATE_TO_NETALERTX": "⚠ Antigos locais de montagem detetados. Siga <a href=\"https://docs.netalertx.com/MIGRATION\" target=\"_blank\">este guia</a> para migrar as novas pastas <code>/data/config</code> e <code>/data/db</code> e o contentor <code>netalertx</code>.",
|
||||||
"TIMEZONE_description": "",
|
"TIMEZONE_description": "Fuso horário para mostras estatísticas corretamente. Encontre o seu fuso horário <a target=\"_blank\" href=\"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\" rel=\"nofollow\">aqui</a>.",
|
||||||
"TIMEZONE_name": "",
|
"TIMEZONE_name": "Fuso horário",
|
||||||
"UI_DEV_SECTIONS_description": "",
|
"UI_DEV_SECTIONS_description": "Selecione quais elementos UI a esconder na página de dispositivos.",
|
||||||
"UI_DEV_SECTIONS_name": "",
|
"UI_DEV_SECTIONS_name": "Esconder secções de dispositivos",
|
||||||
"UI_ICONS_description": "",
|
"UI_ICONS_description": "Uma lista de ícones pré-definidos. Proceda com cautela, a maneira preferível de adicionar ícones é descrita na <a href=\"https://docs.netalertx.com/ICONS\" target=\"_blank\">Documentação de ícones</a>. Pode adicionar uma etiqueta codificada em base64 SVG HTML ou Font-awesome HTML.",
|
||||||
"UI_ICONS_name": "",
|
"UI_ICONS_name": "Ícones pré-definidos",
|
||||||
"UI_LANG_description": "",
|
"UI_LANG_description": "Selecione o idioma de UI preferido. Ajude a traduzir ou sugira idiomas no portal online do <a href=\"https://hosted.weblate.org/projects/pialert/core/\" target=\"_blank\">Weblate</a>.",
|
||||||
"UI_LANG_name": "",
|
"UI_LANG_name": "Idioma do UI",
|
||||||
"UI_MY_DEVICES_description": "",
|
"UI_MY_DEVICES_description": "Dispositivos cujo estado devem ser mostrados na vista predefinida <b>Os meus dispositivos</b>.",
|
||||||
"UI_MY_DEVICES_name": "",
|
"UI_MY_DEVICES_name": "Mostrar na vista Os meus dispositivos",
|
||||||
"UI_NOT_RANDOM_MAC_description": "",
|
"UI_NOT_RANDOM_MAC_description": "Prefixos mac que não devem ser marcados como dispositivos Aleatórios. Introduza, por exemplo, <code>52</code> para excluir dispositivos que começam com <code>52:xx:xx:xx:xx:xx</code> de serem marcados como dispositivos com um endereço MAC aleatório.",
|
||||||
"UI_NOT_RANDOM_MAC_name": "",
|
"UI_NOT_RANDOM_MAC_name": "Não marcar como Aleatório",
|
||||||
"UI_PRESENCE_description": "",
|
"UI_PRESENCE_description": "Selecione quais estatutos devem ser mostrados na <b>Presença de dispositivo</b> gráfico na página de <a href=\"/devices.php\" target=\"_blank\">Dispositivos</a>.",
|
||||||
"UI_PRESENCE_name": "",
|
"UI_PRESENCE_name": "Mostrar em gráfico de presença",
|
||||||
"UI_REFRESH_description": "",
|
"UI_REFRESH_description": "Introduza o número de segundo após os quais o UI atualiza. Defina para <code>0</code> para desativar.",
|
||||||
"UI_REFRESH_name": "",
|
"UI_REFRESH_name": "Auto-atualizar UI",
|
||||||
"VERSION_description": "",
|
"VERSION_description": "Valor auxiliar de versão ou data e hora para verificar se a aplicação foi atualizada.",
|
||||||
"VERSION_name": "",
|
"VERSION_name": "Versão ou data e hora",
|
||||||
"WF_Action_Add": "",
|
"WF_Action_Add": "Adicionar Ação",
|
||||||
"WF_Action_field": "",
|
"WF_Action_field": "Campo",
|
||||||
"WF_Action_type": "",
|
"WF_Action_type": "Tipo",
|
||||||
"WF_Action_value": "",
|
"WF_Action_value": "Valor",
|
||||||
"WF_Actions": "",
|
"WF_Actions": "Ações",
|
||||||
"WF_Add": "",
|
"WF_Add": "Adicionar Fluxo de Trabalho",
|
||||||
"WF_Add_Condition": "",
|
"WF_Add_Condition": "Adicionar Condição",
|
||||||
"WF_Add_Group": "",
|
"WF_Add_Group": "Adicionar Grupo",
|
||||||
"WF_Condition_field": "",
|
"WF_Condition_field": "Campo",
|
||||||
"WF_Condition_operator": "",
|
"WF_Condition_operator": "Operador",
|
||||||
"WF_Condition_value": "",
|
"WF_Condition_value": "Valor",
|
||||||
"WF_Conditions": "",
|
"WF_Conditions": "Condições",
|
||||||
"WF_Conditions_logic_rules": "",
|
"WF_Conditions_logic_rules": "Regras de lógica",
|
||||||
"WF_Duplicate": "",
|
"WF_Duplicate": "Duplicar Fluxo de Trabalho",
|
||||||
"WF_Enabled": "",
|
"WF_Enabled": "Fluxo de trabalho ativo",
|
||||||
"WF_Export": "",
|
"WF_Export": "Exportar Fluxo de Trabalho",
|
||||||
"WF_Export_Copy": "",
|
"WF_Export_Copy": "Copiar para o fluxo de trabalho abaixo e importar para onde for preciso.",
|
||||||
"WF_Import": "",
|
"WF_Import": "Importar Fluxo de Trabalho",
|
||||||
"WF_Import_Copy": "",
|
"WF_Import_Copy": "Colar no fluxo de trabalho que copiou anteriormente.",
|
||||||
"WF_Name": "",
|
"WF_Name": "Nome do fluxo de trabalho",
|
||||||
"WF_Remove": "",
|
"WF_Remove": "Remover Fluxo de Trabalho",
|
||||||
"WF_Remove_Copy": "",
|
"WF_Remove_Copy": "Quer remover este fluxo de trabalho?",
|
||||||
"WF_Save": "",
|
"WF_Save": "Guardar Fluxos de Trabalho",
|
||||||
"WF_Trigger": "",
|
"WF_Trigger": "Acionador",
|
||||||
"WF_Trigger_event_type": "",
|
"WF_Trigger_event_type": "Tipo de evento",
|
||||||
"WF_Trigger_type": "",
|
"WF_Trigger_type": "Tipo de acionador",
|
||||||
"add_icon_event_tooltip": "",
|
"add_icon_event_tooltip": "Adicionar novo ícone",
|
||||||
"add_option_event_tooltip": "",
|
"add_option_event_tooltip": "Adicionar novo valor",
|
||||||
"copy_icons_event_tooltip": "",
|
"copy_icons_event_tooltip": "Sobrescrever ícones de todos os dispositivos com o mesmo tipo de dispositivo",
|
||||||
"devices_old": "",
|
"devices_old": "A atualizar…",
|
||||||
"general_event_description": "",
|
"general_event_description": "O evento que ativou pode demorar algum tempo até que os processos de planos de fundo terminem. A execução acabou uma vez que a lista de execução abaixo fica vazia (Verifique o <a href='/maintenance.php#tab_Logging'>registo de erros</a> se encontrar problemas). <br/> <br/> Lista de execução:",
|
||||||
"general_event_title": "",
|
"general_event_title": "A executar um evento ad-hoc",
|
||||||
"go_to_device_event_tooltip": "",
|
"go_to_device_event_tooltip": "Navegar para o dispositivo",
|
||||||
"go_to_node_event_tooltip": "",
|
"go_to_node_event_tooltip": "Navegar para a página de Rede do nó em questão",
|
||||||
"new_version_available": "",
|
"new_version_available": "Uma versão nova está disponível.",
|
||||||
"report_guid": "",
|
"report_guid": "Guid de Notificação:",
|
||||||
"report_guid_missing": "",
|
"report_guid_missing": "Notificação associada não foi encontrada. Há um pequeno atraso entre notificações recentemente enviadas e as mesmas estarem disponíveis. Atualize a sua página e cache após alguns segundos. Também é possível que a notificação selecionada tenha sido eliminada durante a manutenção como especificado na definição <code>DBCLNP_NOTIFI_HIST</code>. <br/> <br/>Em vez disso, a última notificação é mostrada. A notificação em falta tem o seguinte GUID:",
|
||||||
"report_select_format": "",
|
"report_select_format": "Selecionar Formato:",
|
||||||
"report_time": "",
|
"report_time": "Tempo de notificação:",
|
||||||
"run_event_tooltip": "",
|
"run_event_tooltip": "Ative a definição e guarde as suas mudanças primeiro antes de corrê-lo.",
|
||||||
"select_icon_event_tooltip": "",
|
"select_icon_event_tooltip": "Selecionar ícone",
|
||||||
"settings_core_icon": "",
|
"settings_core_icon": "fa-solid fa-gem",
|
||||||
"settings_core_label": "",
|
"settings_core_label": "Core",
|
||||||
"settings_device_scanners": "",
|
"settings_device_scanners": "Scaneadores de dispositivos usados para descobrir dispositivos que escrevem para a tabela da base de dados CurrentScan.",
|
||||||
"settings_device_scanners_icon": "",
|
"settings_device_scanners_icon": "fa-solid fa-magnifying-glass-plus",
|
||||||
"settings_device_scanners_info": "",
|
"settings_device_scanners_info": "Carregar mais scaneadores de dispositivos com a definição <a href=\"/settings.php#LOADED_PLUGINS\">LOADED_PLUGINS</a>",
|
||||||
"settings_device_scanners_label": "",
|
"settings_device_scanners_label": "Scaneadores de dispositivos",
|
||||||
"settings_enabled": "",
|
"settings_enabled": "Definições ativas",
|
||||||
"settings_enabled_icon": "",
|
"settings_enabled_icon": "fa-solid fa-toggle-on",
|
||||||
"settings_expand_all": "",
|
"settings_expand_all": "Expandir todos",
|
||||||
"settings_imported": "",
|
"settings_imported": "Na última vez, as definições foram importadas a partir do ficheiro app.conf",
|
||||||
"settings_imported_label": "",
|
"settings_imported_label": "Definições importadas",
|
||||||
"settings_missing": "",
|
"settings_missing": "Nem todas as configurações foram carregadas! Carga elevada na base de dados ou na sequência de começo da aplicação. Clique no botão 🔄 de atualizar no topo.",
|
||||||
"settings_missing_block": "",
|
"settings_missing_block": "Erro: Definições não carregadas corretamente. Clique no botão 🔄 de atualizar no topo ou, alternativamente, verifique o registo do navegador para detalhes (F12).",
|
||||||
"settings_old": "",
|
"settings_old": "A importar definições e a reinicializar…",
|
||||||
"settings_other_scanners": "",
|
"settings_other_scanners": "Outros plugins de scaneadores que não são do dispositivo estão atualmente ativos.",
|
||||||
"settings_other_scanners_icon": "",
|
"settings_other_scanners_icon": "fa-solid fa-recycle",
|
||||||
"settings_other_scanners_label": "",
|
"settings_other_scanners_label": "Outros scaneadores",
|
||||||
"settings_publishers": "",
|
"settings_publishers": "Gateways de notificação ativados - editores que enviarão uma notificação de acordo com as suas definições.",
|
||||||
"settings_publishers_icon": "",
|
"settings_publishers_icon": "fa-solid fa-paper-plane",
|
||||||
"settings_publishers_info": "",
|
"settings_publishers_info": "Carregar mais Editores com a definição <a href=\"/settings.php#LOADED_PLUGINS\">LOADED_PLUGINS</a>",
|
||||||
"settings_publishers_label": "",
|
"settings_publishers_label": "Editores",
|
||||||
"settings_readonly": "",
|
"settings_readonly": "Não foi possível LÊR ou ESCREVER na <code>app.conf</code>. Tente reiniciar o contentoer e ler a <a href=\"https://docs.netalertx.com/FILE_PERMISSIONS\" target=\"_blank\">documentação de permissões de ficheiro</a>",
|
||||||
"settings_saved": "",
|
"settings_saved": "<br/>Definições guardadas.<br/> A recarregar...<br/><i class=\"ion ion-ios-loop-strong fa-spin fa-2x fa-fw\"></i><br/>",
|
||||||
"settings_system_icon": "",
|
"settings_system_icon": "fa-solid fa-gear",
|
||||||
"settings_system_label": "",
|
"settings_system_label": "Sistema",
|
||||||
"settings_update_item_warning": "",
|
"settings_update_item_warning": "Atualize o valor abaixo. Tenha cuidado em seguir o formato anterior. <b>Validação não é efetuada.</b>",
|
||||||
"test_event_tooltip": "Guarde as alterações antes de testar as definições."
|
"test_event_tooltip": "Guarde as alterações antes de testar as definições."
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"AppEvents_ObjectType": "Тип объекта",
|
"AppEvents_ObjectType": "Тип объекта",
|
||||||
"AppEvents_Plugin": "Плагин",
|
"AppEvents_Plugin": "Плагин",
|
||||||
"AppEvents_Type": "Тип",
|
"AppEvents_Type": "Тип",
|
||||||
"BACKEND_API_URL_description": "Используется для обеспечения связи между фронтендом и бэкендом. По умолчанию это значение установлено на <code>/server</code> и, как правило, не должно изменяться.",
|
"BACKEND_API_URL_description": "Используется для обеспечения связи между фронтендом и бэкендом. По умолчанию это значение установлено на <code>/server</code> и, как правило, не должно изменяться.",
|
||||||
"BACKEND_API_URL_name": "URL-адрес серверного API",
|
"BACKEND_API_URL_name": "URL-адрес серверного API",
|
||||||
"BackDevDetail_Actions_Ask_Run": "Вы хотите выполнить действие?",
|
"BackDevDetail_Actions_Ask_Run": "Вы хотите выполнить действие?",
|
||||||
"BackDevDetail_Actions_Not_Registered": "Действие не зарегистрировано:· ",
|
"BackDevDetail_Actions_Not_Registered": "Действие не зарегистрировано:· ",
|
||||||
|
|||||||
@@ -249,8 +249,8 @@
|
|||||||
"Device_TableHead_Name": "名字",
|
"Device_TableHead_Name": "名字",
|
||||||
"Device_TableHead_NetworkSite": "网络站点",
|
"Device_TableHead_NetworkSite": "网络站点",
|
||||||
"Device_TableHead_Owner": "所有者",
|
"Device_TableHead_Owner": "所有者",
|
||||||
"Device_TableHead_ParentRelType": "关系类型",
|
"Device_TableHead_ParentRelType": "关系",
|
||||||
"Device_TableHead_Parent_MAC": "父网络节点",
|
"Device_TableHead_Parent_MAC": "父节点",
|
||||||
"Device_TableHead_Port": "端口",
|
"Device_TableHead_Port": "端口",
|
||||||
"Device_TableHead_PresentLastScan": "检测",
|
"Device_TableHead_PresentLastScan": "检测",
|
||||||
"Device_TableHead_ReqNicsOnline": "需要网卡在线",
|
"Device_TableHead_ReqNicsOnline": "需要网卡在线",
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
"Maintenance_InitCheck": "初步检查",
|
"Maintenance_InitCheck": "初步检查",
|
||||||
"Maintenance_InitCheck_Checking": "查看中…",
|
"Maintenance_InitCheck_Checking": "查看中…",
|
||||||
"Maintenance_InitCheck_QuickSetupGuide": "确保您遵循<a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">快速设置指南</a>。",
|
"Maintenance_InitCheck_QuickSetupGuide": "确保您遵循<a href=\"https://docs.netalertx.com/INITIAL_SETUP/\" target=\"_blank\">快速设置指南</a>。",
|
||||||
"Maintenance_InitCheck_Success": "应用程序启动成功!",
|
"Maintenance_InitCheck_Success": "应用程序初始化成功!",
|
||||||
"Maintenance_ReCheck": "重试检查",
|
"Maintenance_ReCheck": "重试检查",
|
||||||
"Maintenance_Running_Version": "安装版本",
|
"Maintenance_Running_Version": "安装版本",
|
||||||
"Maintenance_Status": "状态",
|
"Maintenance_Status": "状态",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
{ "elementType": "select", "elementOptions": [], "transformers": [] }
|
{ "elementType": "select", "elementOptions": [], "transformers": [] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default_value": "before_name_updates",
|
"default_value": "disabled",
|
||||||
"options": [
|
"options": [
|
||||||
"disabled",
|
"disabled",
|
||||||
"before_name_updates",
|
"before_name_updates",
|
||||||
|
|||||||
@@ -209,53 +209,83 @@ def execute_fping(timeout, args, all_devices, plugin_objects, subnets, interface
|
|||||||
|
|
||||||
def run_fping(targets):
|
def run_fping(targets):
|
||||||
targets = expand_subnets(targets)
|
targets = expand_subnets(targets)
|
||||||
|
|
||||||
if not targets:
|
if not targets:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
is_ipv6 = any(':' in t for t in targets)
|
ipv4_targets = [t for t in targets if ':' not in t]
|
||||||
cmd = ["fping", "-a"] + args.split() + targets
|
ipv6_targets = [t for t in targets if ':' in t]
|
||||||
if is_ipv6:
|
|
||||||
cmd.insert(1, "-6") # insert -6 after "fping"
|
|
||||||
|
|
||||||
if interfaces:
|
def run_family(family_targets, ipv6=False):
|
||||||
cmd += ["-I", ",".join(interfaces)]
|
if not family_targets:
|
||||||
|
return []
|
||||||
|
|
||||||
mylog("verbose", [f"[{pluginName}] fping cmd: {' '.join(cmd)}"])
|
interface_list = interfaces if interfaces else [None]
|
||||||
|
|
||||||
try:
|
all_results = []
|
||||||
output = subprocess.check_output(
|
seen_ips = set()
|
||||||
cmd,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
timeout=timeout,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
output = e.output
|
|
||||||
mylog("none", [f"[{pluginName}] fping returned non-zero exit code, reading alive hosts anyway"])
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
mylog("none", [f"[{pluginName}] fping timeout"])
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = []
|
for interface in interface_list:
|
||||||
for line in output.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip unreachable, timed out, or 100% packet loss
|
cmd = ["fping", "-a"]
|
||||||
if "unreachable" in line.lower() or "timed out" in line.lower() or "100% loss" in line.lower():
|
|
||||||
mylog("debug", [f"[{pluginName}] fping skipping {line}"])
|
|
||||||
continue
|
|
||||||
|
|
||||||
match = ip_regex.search(line)
|
if ipv6:
|
||||||
if match:
|
cmd.append("-6")
|
||||||
ip = match.group(0)
|
|
||||||
mylog("debug", [f"[{pluginName}] adding {ip} from {line}"])
|
|
||||||
results.append((ip, line))
|
|
||||||
else:
|
|
||||||
mylog("verbose", [f"[{pluginName}] fping non-parseable {line}"])
|
|
||||||
|
|
||||||
return results
|
cmd += args.split()
|
||||||
|
|
||||||
|
if interface:
|
||||||
|
cmd += ["-I", interface]
|
||||||
|
|
||||||
|
cmd += family_targets
|
||||||
|
|
||||||
|
mylog("verbose", [f"[{pluginName}] fping cmd: {' '.join(cmd)}"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
cmd,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=timeout,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
output = e.output
|
||||||
|
mylog("none", [
|
||||||
|
f"[{pluginName}] fping returned non-zero exit code "
|
||||||
|
f"on interface {interface}, reading alive hosts anyway"
|
||||||
|
])
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
mylog("none", [
|
||||||
|
f"[{pluginName}] fping timeout on interface {interface}"
|
||||||
|
])
|
||||||
|
continue
|
||||||
|
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lower_line = line.lower()
|
||||||
|
|
||||||
|
if "unreachable" in lower_line or "timed out" in lower_line or "100% loss" in lower_line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = ip_regex.search(line)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
ip = match.group(0)
|
||||||
|
|
||||||
|
if ip in seen_ips:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_ips.add(ip)
|
||||||
|
all_results.append((ip, line))
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
return run_family(ipv4_targets, ipv6=False) + run_family(ipv6_targets, ipv6=True)
|
||||||
|
|
||||||
# Scan subnets
|
# Scan subnets
|
||||||
mylog("verbose", [f"[{pluginName}] run_fping: subnets {subnets}"])
|
mylog("verbose", [f"[{pluginName}] run_fping: subnets {subnets}"])
|
||||||
|
|||||||
99
front/plugins/kea_api/README.md
Executable file
99
front/plugins/kea_api/README.md
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
## Overview
|
||||||
|
|
||||||
|
A plugin allowing for importing devices from the Kea DHCP API.
|
||||||
|
https://www.isc.org/kea/
|
||||||
|
|
||||||
|
And specifically:
|
||||||
|
https://kea.readthedocs.io/en/kea-2.6.3/api.html#lease4-get-all
|
||||||
|
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
To enable the API, first you want to add something like this to your main kea configuration (this is for debian 13):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"control-socket": {
|
||||||
|
"socket-type": "unix",
|
||||||
|
"socket-name": "/run/kea/kea4-ctrl-socket"
|
||||||
|
},
|
||||||
|
|
||||||
|
"hooks-libraries": [
|
||||||
|
{
|
||||||
|
"library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
And you need to install kea-ctrl-agent, with a config that looks something like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Control-agent": {
|
||||||
|
"http-host": "127.0.0.1",
|
||||||
|
"http-port": 8000,
|
||||||
|
|
||||||
|
"authentication": {
|
||||||
|
"type": "basic",
|
||||||
|
"realm": "Kea Control Agent",
|
||||||
|
"directory": "/etc/kea",
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"user": "kea-api",
|
||||||
|
"password-file": "kea-api-password"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"control-sockets": {
|
||||||
|
"dhcp4": {
|
||||||
|
"socket-type": "unix",
|
||||||
|
"socket-name": "/run/kea/kea4-ctrl-socket"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loggers": [
|
||||||
|
{
|
||||||
|
"name": "kea-ctrl-agent",
|
||||||
|
"output-options": [
|
||||||
|
{
|
||||||
|
"output": "stdout",
|
||||||
|
"pattern": "%-5p %m\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"severity": "INFO",
|
||||||
|
"debuglevel": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You will need to configure the plugin with the URL to the API, and the username and password configured above (from kea-api-password file in the example)
|
||||||
|
|
||||||
|
|
||||||
|
#### Required Settings
|
||||||
|
|
||||||
|
These settings are required, besides the common device scanner settings:
|
||||||
|
|
||||||
|
- **Kea Control Agent URL** (`KEALSS_URL`): The full URL, including port number, to the Kea API.
|
||||||
|
- Default: `http://127.0.0.1:8000`
|
||||||
|
- This mirrors what you set up in the kea-ctrl-agent configuration.
|
||||||
|
|
||||||
|
- **Basic Auth Username** (`KEALSS_USER`): The user to use for authenticating with the Kea API.
|
||||||
|
- Default: `kea-api`
|
||||||
|
- This mirrors what you set up in the kea-ctrl-agent configuration.
|
||||||
|
|
||||||
|
- **Basic Auth Password** (`KEALSS_PASS`): The password to use for authenticating with the Kea API.
|
||||||
|
- This mirrors what you set up in the kea-ctrl-agent configuration.
|
||||||
|
- When using a password file, it should be the content of the password file.
|
||||||
|
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- This was tested on a basic Debian 13 install.
|
||||||
|
- When you install kea-ctrl-agent, it should ask you about creating a password.
|
||||||
|
- It's possible to run kea-ctrl-agent without password, but it's not recommended and at the moment we don't support that.
|
||||||
|
- I may provide some minimal support, if you ask nicely :)
|
||||||
|
|
||||||
|
- Version: 1.0.0
|
||||||
|
- Author: `void-spark`
|
||||||
|
- Release Date: `11/05/2026`
|
||||||
455
front/plugins/kea_api/config.json
Normal file
455
front/plugins/kea_api/config.json
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
{
|
||||||
|
"code_name": "kea_api",
|
||||||
|
"unique_prefix": "KEALSS",
|
||||||
|
"plugin_type": "device_scanner",
|
||||||
|
"execution_order" : "Layer_3",
|
||||||
|
"enabled": true,
|
||||||
|
"data_source": "script",
|
||||||
|
"data_filters": [
|
||||||
|
{
|
||||||
|
"compare_column": "objectPrimaryId",
|
||||||
|
"compare_operator": "==",
|
||||||
|
"compare_field_id": "txtMacFilter",
|
||||||
|
"compare_js_template": "'{value}'.toString()",
|
||||||
|
"compare_use_quotes": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"show_ui": true,
|
||||||
|
"localized": ["display_name", "description", "icon"],
|
||||||
|
"mapped_to_table": "CurrentScan",
|
||||||
|
"display_name": [{"language_code": "en_us", "string": "Kea DHCP API"}],
|
||||||
|
"icon": [{"language_code": "en_us", "string": "<i class=\"fa-solid fa-hourglass-half\"></i>"}],
|
||||||
|
"description": [{"language_code": "en_us", "string": "Imports leases via Kea Control Agent REST API"}],
|
||||||
|
"database_column_definitions": [
|
||||||
|
{
|
||||||
|
"column": "index",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "none",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Index"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "objectPrimaryId",
|
||||||
|
"mapped_to_column": "scanMac",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "device_mac",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "MAC address"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "objectSecondaryId",
|
||||||
|
"mapped_to_column": "scanLastIP",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "device_ip",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "IP" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "dateTimeCreated",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Created"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "dateTimeChanged",
|
||||||
|
"mapped_to_column": "scanLastConnection",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Changed"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "watchedValue1",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Is active"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "watchedValue2",
|
||||||
|
"mapped_to_column": "scanName",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Hostname"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "watchedValue4",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": true,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "State"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "userData",
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": false,
|
||||||
|
"type": "textbox_save",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Comments"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "Dummy",
|
||||||
|
"mapped_to_column": "scanSourcePlugin",
|
||||||
|
"mapped_to_column_data": {
|
||||||
|
"value": "KEALSS"
|
||||||
|
},
|
||||||
|
"css_classes": "col-sm-2",
|
||||||
|
"show": false,
|
||||||
|
"type": "label",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Scan method"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column": "status",
|
||||||
|
"css_classes": "col-sm-1",
|
||||||
|
"show": true,
|
||||||
|
"type": "replace",
|
||||||
|
"default_value": "",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"equals": "watched-not-changed",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-square-check'></i><div></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equals": "watched-changed",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-triangle-exclamation'></i></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equals": "new",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-circle-plus'></i></div>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"equals": "missing-in-last-scan",
|
||||||
|
"replacement": "<div style='text-align:center'><i class='fa-solid fa-question'></i></div>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"localized": ["name"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Status"}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"function": "RUN",
|
||||||
|
"events": ["run"],
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [{"elementType": "select", "elementOptions": [], "transformers": []}]
|
||||||
|
},
|
||||||
|
"default_value": "disabled",
|
||||||
|
"options": [
|
||||||
|
"disabled",
|
||||||
|
"once",
|
||||||
|
"schedule",
|
||||||
|
"always_after_scan",
|
||||||
|
"on_new_device"
|
||||||
|
],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "When to run"}],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Enable import of devices from <code>Kea API</code>. If you select <code>schedule</code> the scheduling settings from below are applied. If you select <code>once</code> the scan is run only once on start of the application (container) or after you update your settings. ⚠ Use the same schedule if you have multiple <i class=\"fa-solid fa-magnifying-glass-plus\"></i> Device scanners enabled."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "CMD",
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{ "elementType": "input", "elementOptions": [], "transformers": [] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "python3 /app/front/plugins/kea_api/script.py",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "Command"}],
|
||||||
|
"description": [{"language_code": "en_us", "string": "Command to run"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "URL",
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "API URL"}],
|
||||||
|
"description": [{"language_code": "en_us", "string": "Kea Control Agent URL"}],
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "http://127.0.0.1:8000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "USER",
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "API User"}],
|
||||||
|
"description": [{"language_code": "en_us", "string": "Basic Auth Username"}],
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "kea-api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "PASS",
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [{"language_code": "en_us", "string": "API Password"}],
|
||||||
|
"description": [{"language_code": "en_us", "string": "Basic Auth Password"}],
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{"type": "password"}],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "RUN_SCHD",
|
||||||
|
"type": {
|
||||||
|
"dataType": "string",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "span",
|
||||||
|
"elementOptions": [
|
||||||
|
{
|
||||||
|
"cssClasses": "input-group-addon validityCheck"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"getStringKey": "Gen_ValidIcon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformers": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [
|
||||||
|
{
|
||||||
|
"focusout": "validateRegex(this)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlsxLTldfFsxMl1bMC05XXwzWzAxXXxbMC05XSstWzAtOV0rfFwqL1swLTldKykpXHMrKD86XCp8KD86WzEtOV18MVswLTJdfFswLTldKy1bMC05XSt8XCovWzAtOV0rKSlccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XXxcKi9bMC05XSspKSQ="
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": "0 2 * * *",
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Schedule"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Only enabled if you select <code>schedule</code> in the <a href=\"#KEALSS_RUN\"><code>KEALSS_RUN</code> setting</a>. Make sure you enter the schedule in the correct cron-like format (e.g. validate at <a href=\"https://crontab.guru/\" target=\"_blank\">crontab.guru</a>). For example entering <code>0 4 * * *</code> will run the scan after 4 am in the <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code> you set above</a>. Will be run NEXT time the time passes. <br/> It's recommended to use the same schedule interval for all plugins responsible for discovering new devices."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "RUN_TIMEOUT",
|
||||||
|
"type": {
|
||||||
|
"dataType": "integer",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "input",
|
||||||
|
"elementOptions": [{ "type": "number" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": 10,
|
||||||
|
"options": [],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Run timeout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Maximum time in seconds to wait for the script to finish. If this time is exceeded the script is aborted."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "SET_ALWAYS",
|
||||||
|
"type": {
|
||||||
|
"dataType": "array",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "select",
|
||||||
|
"elementOptions": [{ "multiple": "true", "orderable": "true"}],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": ["devMac", "devLastIP"],
|
||||||
|
"options": [
|
||||||
|
"devMac",
|
||||||
|
"devLastIP"
|
||||||
|
],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Set always columns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "These columns are treated as authoritative and will overwrite existing values, including those set by other plugins, unless the current value was explicitly set by the user (<code>Source = USER</code> or <code>Source = LOCKED</code>)."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "SET_EMPTY",
|
||||||
|
"type": {
|
||||||
|
"dataType": "array",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "select",
|
||||||
|
"elementOptions": [{ "multiple": "true", "orderable": "true" }],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": [],
|
||||||
|
"options": [
|
||||||
|
"devMac",
|
||||||
|
"devLastIP",
|
||||||
|
"devName",
|
||||||
|
"devSourcePlugin"
|
||||||
|
],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Set empty columns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "These columns are only overwritten if they are empty (<code>NULL</code> / empty string) or if their Source is set to <code>NEWDEV</code>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "WATCH",
|
||||||
|
"type": {
|
||||||
|
"dataType": "array",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "select",
|
||||||
|
"elementOptions": [{ "multiple": "true", "orderable": "true"}],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": ["watchedValue1", "watchedValue4"],
|
||||||
|
"options": [
|
||||||
|
"watchedValue1",
|
||||||
|
"watchedValue2",
|
||||||
|
"watchedValue4"
|
||||||
|
],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Watched"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Send a notification if selected values change. Use <code>CTRL + Click</code> to select/deselect. <ul> <li><code>watchedValue1</code> is Active </li><li><code>watchedValue2</code> is Hostname </li><li><code>watchedValue4</code> is State</li></ul>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": "REPORT_ON",
|
||||||
|
"type": {
|
||||||
|
"dataType": "array",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"elementType": "select",
|
||||||
|
"elementOptions": [{ "multiple": "true", "orderable": "true"}],
|
||||||
|
"transformers": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default_value": ["new", "watched-changed"],
|
||||||
|
"options": [
|
||||||
|
"new",
|
||||||
|
"watched-changed",
|
||||||
|
"watched-not-changed",
|
||||||
|
"missing-in-last-scan"
|
||||||
|
],
|
||||||
|
"localized": ["name", "description"],
|
||||||
|
"name": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Report on"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
{
|
||||||
|
"language_code": "en_us",
|
||||||
|
"string": "Send a notification only on these statuses. <code>new</code> means a new unique (unique combination of PrimaryId and SecondaryId) object was discovered. <code>watched-changed</code> means that selected <code>watchedValueN</code> columns changed."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
75
front/plugins/kea_api/script.py
Normal file
75
front/plugins/kea_api/script.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../server'))
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../plugins'))
|
||||||
|
|
||||||
|
from plugin_helper import Plugin_Objects, mylog, handleEmpty, is_mac
|
||||||
|
from helper import get_setting_value
|
||||||
|
from const import logPath
|
||||||
|
|
||||||
|
pluginName = 'KEALSS'
|
||||||
|
LOG_PATH = logPath + '/plugins'
|
||||||
|
LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log')
|
||||||
|
RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
|
||||||
|
|
||||||
|
plugin_objects = Plugin_Objects(RESULT_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
url = get_setting_value(f'{pluginName}_URL')
|
||||||
|
user = get_setting_value(f'{pluginName}_USER')
|
||||||
|
password = get_setting_value(f'{pluginName}_PASS')
|
||||||
|
timeout = get_setting_value(f'{pluginName}_RUN_TIMEOUT')
|
||||||
|
|
||||||
|
mylog('verbose', [f'[{pluginName}] Querying Kea API at {url}'])
|
||||||
|
|
||||||
|
payload = {'command': 'lease4-get-all', 'service': ['dhcp4']}
|
||||||
|
|
||||||
|
response = requests.post(url, json=payload, auth=(user, password), timeout=max(1, timeout - 1))
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for entry in data:
|
||||||
|
text = entry.get('text', '[API provided no text]')
|
||||||
|
# Result: 0 (success), 1 (error), or 3 (empty).
|
||||||
|
if entry['result'] == 0:
|
||||||
|
leases = entry['arguments']['leases']
|
||||||
|
for lease in leases:
|
||||||
|
mac = lease['hw-address']
|
||||||
|
state = lease['state']
|
||||||
|
if is_mac(mac):
|
||||||
|
plugin_objects.add_object(
|
||||||
|
primaryId = mac,
|
||||||
|
secondaryId = lease['ip-address'],
|
||||||
|
# Active or not, similar to watched1 of DHCPLSS plugin
|
||||||
|
watched1 = state == 0,
|
||||||
|
watched2 = lease['hostname'],
|
||||||
|
watched3 = None,
|
||||||
|
# Default (or assigned) (0), declined (1), expired-reclaimed (2), released (3), and registered (4)).
|
||||||
|
watched4 = state,
|
||||||
|
extra = None,
|
||||||
|
foreignKey = mac
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
plugin_objects.write_result_file()
|
||||||
|
|
||||||
|
mylog('verbose', [f'[{pluginName}] Kea API response: {text}'])
|
||||||
|
mylog('verbose', [f'[{pluginName}] Successfully imported {count} devices reported by Kea API'])
|
||||||
|
elif entry['result'] == 1:
|
||||||
|
mylog('none', [f'[{pluginName}] ⚠ ERROR: Kea API indicated error: {text}'])
|
||||||
|
elif entry['result'] == 3:
|
||||||
|
mylog('verbose', [f'[{pluginName}] Kea API indicates no entries found: {text}'])
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
mylog('none', [f'[{pluginName}] ⚠ ERROR: {str(e)}'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -12,6 +12,7 @@ from logger import mylog, Logger # noqa: E402 [flake8 lint suppression]
|
|||||||
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
|
from helper import get_setting_value # noqa: E402 [flake8 lint suppression]
|
||||||
from const import logPath # noqa: E402 [flake8 lint suppression]
|
from const import logPath # noqa: E402 [flake8 lint suppression]
|
||||||
from messaging.in_app import remove_old # noqa: E402 [flake8 lint suppression]
|
from messaging.in_app import remove_old # noqa: E402 [flake8 lint suppression]
|
||||||
|
from utils.datetime_utils import timeNowUTC # noqa: E402 [flake8 lint suppression]
|
||||||
import conf # noqa: E402 [flake8 lint suppression]
|
import conf # noqa: E402 [flake8 lint suppression]
|
||||||
from pytz import timezone # noqa: E402 [flake8 lint suppression]
|
from pytz import timezone # noqa: E402 [flake8 lint suppression]
|
||||||
|
|
||||||
@@ -69,6 +70,21 @@ def main():
|
|||||||
mylog('verbose', [f'[{pluginName}] Cleaning in-app notification history'])
|
mylog('verbose', [f'[{pluginName}] Cleaning in-app notification history'])
|
||||||
remove_old(MAINT_NOTI_LENGTH)
|
remove_old(MAINT_NOTI_LENGTH)
|
||||||
|
|
||||||
|
# Delete processed sync artefact files older than 24 hours.
|
||||||
|
# These are created by the SYNC plugin (Mode 3) when it renames received
|
||||||
|
# device JSON files to processed_*.log after processing. They have no value
|
||||||
|
# once processed and are not cleaned up anywhere else.
|
||||||
|
_PROCESSED_MAX_AGE_SECS = 24 * 3600
|
||||||
|
now = timeNowUTC(as_string=False).timestamp()
|
||||||
|
deleted = 0
|
||||||
|
for fname in os.listdir(LOG_PATH):
|
||||||
|
if fname.startswith('processed_') and fname.endswith('.log'):
|
||||||
|
fpath = os.path.join(LOG_PATH, fname)
|
||||||
|
if os.path.isfile(fpath) and (now - os.path.getmtime(fpath)) > _PROCESSED_MAX_AGE_SECS:
|
||||||
|
os.remove(fpath)
|
||||||
|
deleted += 1
|
||||||
|
mylog('verbose', [f'[{pluginName}] Deleted {deleted} processed sync artefact file(s) from {LOG_PATH}'])
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -613,7 +613,8 @@
|
|||||||
"options": [
|
"options": [
|
||||||
"devMac",
|
"devMac",
|
||||||
"devName",
|
"devName",
|
||||||
"devVendor"
|
"devVendor",
|
||||||
|
"devLastIP"
|
||||||
],
|
],
|
||||||
"localized": ["name", "description"],
|
"localized": ["name", "description"],
|
||||||
"name": [
|
"name": [
|
||||||
@@ -810,7 +811,7 @@
|
|||||||
"column": "Dummy",
|
"column": "Dummy",
|
||||||
"mapped_to_column": "scanSourcePlugin",
|
"mapped_to_column": "scanSourcePlugin",
|
||||||
"mapped_to_column_data": {
|
"mapped_to_column_data": {
|
||||||
"value": "sync"
|
"value": "SYNC"
|
||||||
},
|
},
|
||||||
"css_classes": "col-sm-2",
|
"css_classes": "col-sm-2",
|
||||||
"show": false,
|
"show": false,
|
||||||
|
|||||||
@@ -175,23 +175,35 @@ def main():
|
|||||||
if file_name != 'last_result.log':
|
if file_name != 'last_result.log':
|
||||||
mylog('verbose', [f'[{pluginName}] Processing: "{file_name}"'])
|
mylog('verbose', [f'[{pluginName}] Processing: "{file_name}"'])
|
||||||
|
|
||||||
# make sure the file has the correct name (e.g last_result.encoded.Node_1.1.log) to skip any otehr plugin files
|
# Only process sync artifacts:
|
||||||
if len(file_name.split('.')) > 2:
|
# PUSH mode (decoded): last_result.PLUGIN.decoded.NodeName.N.log (6 parts)
|
||||||
# Extract node name from either last_result.decoded.Node_1.1.log or last_result.Node_1.log
|
# PULL mode: last_result.NodeName.log (3 parts, valid JSON)
|
||||||
parts = file_name.split('.')
|
# Local plugin result files (last_result.ARPSCAN.log) are also 3 parts but
|
||||||
# If decoded/encoded file, node name is at index 2; otherwise at index 1
|
# are pipe-delimited — catch and skip them via the JSONDecodeError guard below.
|
||||||
syncHubNodeName = parts[2] if 'decoded' in file_name or 'encoded' in file_name else parts[1]
|
parts = file_name.split('.')
|
||||||
|
if len(parts) > 2:
|
||||||
|
# Extract node name:
|
||||||
|
# decoded/encoded: last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
|
||||||
|
# pull mode: last_result.NodeName.log → parts[1]
|
||||||
|
if 'decoded' in file_name or 'encoded' in file_name:
|
||||||
|
syncHubNodeName = parts[3]
|
||||||
|
else:
|
||||||
|
syncHubNodeName = parts[1]
|
||||||
|
|
||||||
file_path = f"{LOG_PATH}/{file_name}"
|
file_path = f"{LOG_PATH}/{file_name}"
|
||||||
|
|
||||||
with open(file_path, 'r') as f:
|
try:
|
||||||
data = json.load(f)
|
with open(file_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
for device in data['data']:
|
for device in data['data']:
|
||||||
device['devMac'] = str(device['devMac']).lower()
|
device['devMac'] = str(device['devMac']).lower()
|
||||||
if device['devMac'].lower() not in unique_mac_addresses:
|
if device['devMac'].lower() not in unique_mac_addresses:
|
||||||
device['devSyncHubNode'] = syncHubNodeName
|
device['devSyncHubNode'] = syncHubNodeName
|
||||||
unique_mac_addresses.add(device['devMac'].lower())
|
unique_mac_addresses.add(device['devMac'].lower())
|
||||||
device_data.append(device)
|
device_data.append(device)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
mylog('verbose', [f'[{pluginName}] Skipping "{file_name}" - not a valid sync JSON payload'])
|
||||||
|
continue
|
||||||
|
|
||||||
# Rename the file to "processed_" + current name
|
# Rename the file to "processed_" + current name
|
||||||
new_file_name = f"processed_{file_name}"
|
new_file_name = f"processed_{file_name}"
|
||||||
@@ -298,7 +310,7 @@ def send_data(api_token, file_content, encryption_key, file_path, node_name, pre
|
|||||||
final_endpoint = hub_url + endpoint
|
final_endpoint = hub_url + endpoint
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(final_endpoint, data=data, headers=headers, timeout=5)
|
response = requests.post(final_endpoint, json=data, headers=headers, timeout=5)
|
||||||
mylog('verbose', [f'[{pluginName}] Tried endpoint: {final_endpoint}, status: {response.status_code}'])
|
mylog('verbose', [f'[{pluginName}] Tried endpoint: {final_endpoint}, status: {response.status_code}'])
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ function initializeCalendar () {
|
|||||||
|
|
||||||
resourceRender: function (resourceObj, labelTds, bodyTds) {
|
resourceRender: function (resourceObj, labelTds, bodyTds) {
|
||||||
labelTds.find('span.fc-cell-text').html (
|
labelTds.find('span.fc-cell-text').html (
|
||||||
'<b><a href="deviceDetails.php?mac='+ resourceObj.id+ '" class="">'+ resourceObj.title +'</a></b>');
|
'<b><a href="deviceDetails.php?mac='+ resourceObj.id+ '" class="">'+ encodeSpecialChars(resourceObj.title) +'</a></b>');
|
||||||
|
|
||||||
// Resize heihgt
|
// Resize heihgt
|
||||||
// $(".fc-content table tbody tr .fc-widget-content div").addClass('fc-resized-row');
|
// $(".fc-content table tbody tr .fc-widget-content div").addClass('fc-resized-row');
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ nav:
|
|||||||
- Device Display Settings: DEVICE_DISPLAY_SETTINGS.md
|
- Device Display Settings: DEVICE_DISPLAY_SETTINGS.md
|
||||||
- Session Info: SESSION_INFO.md
|
- Session Info: SESSION_INFO.md
|
||||||
- Field Lock/Unlock: DEVICE_FIELD_LOCK.md
|
- Field Lock/Unlock: DEVICE_FIELD_LOCK.md
|
||||||
|
- Device Source Fields: DEVICE_SOURCE_FIELDS.md
|
||||||
- Icons and Topology:
|
- Icons and Topology:
|
||||||
- Icons: ICONS.md
|
- Icons: ICONS.md
|
||||||
- Network Topology: NETWORK_TREE.md
|
- Network Topology: NETWORK_TREE.md
|
||||||
|
|||||||
@@ -185,9 +185,6 @@ def is_authorized():
|
|||||||
return is_authorized_result
|
return is_authorized_result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/mcp/sse', methods=['GET', 'POST', 'OPTIONS'])
|
@app.route('/mcp/sse', methods=['GET', 'POST', 'OPTIONS'])
|
||||||
def api_mcp_sse():
|
def api_mcp_sse():
|
||||||
if not is_authorized():
|
if not is_authorized():
|
||||||
|
|||||||
@@ -893,9 +893,10 @@ class CreateNotificationRequest(BaseModel):
|
|||||||
|
|
||||||
class SyncPushRequest(BaseModel):
|
class SyncPushRequest(BaseModel):
|
||||||
"""Request to push data to sync."""
|
"""Request to push data to sync."""
|
||||||
data: dict = Field(..., description="Data to sync")
|
data: str = Field(..., description="Encrypted data payload (ciphertext string)")
|
||||||
node_name: str = Field(..., description="Name of the node sending data")
|
node_name: str = Field(..., description="Name of the node sending data")
|
||||||
plugin: str = Field(..., description="Plugin identifier")
|
plugin: str = Field(..., description="Plugin identifier")
|
||||||
|
file_path: Optional[str] = Field(None, description="Source file path on the node")
|
||||||
|
|
||||||
|
|
||||||
class SyncPullResponse(BaseResponse):
|
class SyncPullResponse(BaseResponse):
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from logger import mylog
|
from logger import mylog, Logger
|
||||||
from helper import get_setting_value
|
from helper import get_setting_value
|
||||||
from utils.datetime_utils import timeNowUTC
|
from utils.datetime_utils import timeNowUTC
|
||||||
from messaging.in_app import write_notification
|
from messaging.in_app import write_notification
|
||||||
|
|
||||||
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
||||||
|
|
||||||
|
# Make sure log level is initialized correctly
|
||||||
|
lggr = Logger(get_setting_value('LOG_LEVEL'))
|
||||||
|
|
||||||
|
|
||||||
def handle_sync_get():
|
def handle_sync_get():
|
||||||
"""Handle GET requests for SYNC (NODE → HUB)."""
|
"""Handle GET requests for SYNC (NODE → HUB)."""
|
||||||
@@ -28,7 +31,11 @@ def handle_sync_get():
|
|||||||
|
|
||||||
response_data = base64.b64encode(raw_data).decode("utf-8")
|
response_data = base64.b64encode(raw_data).decode("utf-8")
|
||||||
|
|
||||||
write_notification("[Plugin: SYNC] Data sent", "info", timeNowUTC())
|
message = "[Plugin: SYNC] Data sent"
|
||||||
|
mylog('verbose', [message])
|
||||||
|
if lggr.isAbove('verbose'):
|
||||||
|
write_notification(message, 'info', timeNowUTC())
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"node_name": get_setting_value("SYNC_node_name"),
|
"node_name": get_setting_value("SYNC_node_name"),
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -40,9 +47,10 @@ def handle_sync_get():
|
|||||||
|
|
||||||
def handle_sync_post():
|
def handle_sync_post():
|
||||||
"""Handle POST requests for SYNC (HUB receiving from NODE)."""
|
"""Handle POST requests for SYNC (HUB receiving from NODE)."""
|
||||||
data = request.form.get("data", "")
|
body = request.get_json(silent=True) or {}
|
||||||
node_name = request.form.get("node_name", "")
|
data = body.get("data", "")
|
||||||
plugin = request.form.get("plugin", "")
|
node_name = body.get("node_name", "")
|
||||||
|
plugin = body.get("plugin", "")
|
||||||
|
|
||||||
storage_path = INSTALL_PATH + "/log/plugins"
|
storage_path = INSTALL_PATH + "/log/plugins"
|
||||||
os.makedirs(storage_path, exist_ok=True)
|
os.makedirs(storage_path, exist_ok=True)
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ def importConfigs(pm, db, all_plugins):
|
|||||||
# Plugins START
|
# Plugins START
|
||||||
# -----------------
|
# -----------------
|
||||||
|
|
||||||
# necessary_plugins = ['UI', 'CUSTPROP', 'CLOUD' ,'DBCLNP', 'INTRNT','MAINT','NEWDEV', 'SETPWD', 'SYNC', 'VNDRPDT', 'WORKFLOWS']
|
# necessary_plugins = ['UI', 'CUSTPROP', 'HEARTBEAT' ,'DBCLNP', 'INTRNT','MAINT','NEWDEV', 'SETPWD', 'SYNC', 'VNDRPDT', 'WORKFLOWS']
|
||||||
necessary_plugins = [
|
necessary_plugins = [
|
||||||
"UI",
|
"UI",
|
||||||
"CUSTPROP",
|
"CUSTPROP",
|
||||||
@@ -724,13 +724,13 @@ def importConfigs(pm, db, all_plugins):
|
|||||||
|
|
||||||
write_notification(
|
write_notification(
|
||||||
f"""[Upgrade]: App upgraded from <code>{prev_version}</code> to \
|
f"""[Upgrade]: App upgraded from <code>{prev_version}</code> to \
|
||||||
<code>{new_version}</code> 🚀 Please clear the cache: \
|
<code>{new_version}</code> <i class="fa-solid fa-rocket"></i> Please clear the cache: \
|
||||||
<ol> <li>Click OK below</li> \
|
<ol> <li>Click OK below</li> \
|
||||||
<li>Clear the browser cache (shift + browser refresh button)</li> \
|
<li>Clear the browser cache (shift + browser refresh button)</li> \
|
||||||
<li> Clear app cache with the <i class="fa-solid fa-rotate"></i> (reload) button in the header</li>\
|
<li> Clear app cache with the <i class="fa-solid fa-rotate"></i> (reload) button in the header</li>\
|
||||||
<li>Go to Settings and click Save</li> </ol>\
|
<li>Go to Settings and click Save</li> </ol>\
|
||||||
Check out new features and what has changed in the \
|
Check out new features and what has changed in the \
|
||||||
<a href="https://github.com/netalertx/NetAlertX/releases" target="_blank">📓 release notes</a>.""",
|
<a href="https://github.com/netalertx/NetAlertX/releases" target="_blank"><i class="fa-solid fa-file-pen"></i> release notes</a>.""",
|
||||||
'interrupt',
|
'interrupt',
|
||||||
timeNowUTC()
|
timeNowUTC()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,11 +35,10 @@ from messaging.notification_sections import ( # noqa: E402 [flake8 lint suppres
|
|||||||
)
|
)
|
||||||
import conf # noqa: E402 [flake8 lint suppression]
|
import conf # noqa: E402 [flake8 lint suppression]
|
||||||
|
|
||||||
|
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
# Timezone conversion
|
# Timezone conversion
|
||||||
# ===============================================================================
|
# ===============================================================================
|
||||||
|
|
||||||
|
|
||||||
def get_datetime_fields_from_columns(column_names):
|
def get_datetime_fields_from_columns(column_names):
|
||||||
return [
|
return [
|
||||||
col for col in column_names
|
col for col in column_names
|
||||||
@@ -81,6 +80,7 @@ def apply_timezone(data, fields):
|
|||||||
|
|
||||||
for field in fields:
|
for field in fields:
|
||||||
value = row.get(field)
|
value = row.get(field)
|
||||||
|
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -226,11 +226,24 @@ def get_notifications(db):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
json_obj = db.get_table_as_json(sqlQuery, parameters)
|
json_obj = db.get_table_as_json(sqlQuery, parameters)
|
||||||
|
data = apply_timezone_to_json(json_obj, section)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
mylog("minimal", [f"[Notification] DB error in section {section}: ", e])
|
mylog("none", [f"[Notification] apply_timezone failed for section {section}: ", e])
|
||||||
|
|
||||||
|
# fallback: preserve raw DB payload instead of dropping section
|
||||||
|
try:
|
||||||
|
data = json_obj.json.get("data", [])
|
||||||
|
except Exception:
|
||||||
|
data = []
|
||||||
|
|
||||||
|
final_json[section] = data
|
||||||
|
final_json[f"{section}_meta"] = {
|
||||||
|
"title": SECTION_TITLES.get(section, section),
|
||||||
|
"columnNames": getattr(json_obj, "columnNames", [])
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
final_json[section] = json_obj.json.get("data", [])
|
final_json[section] = data
|
||||||
final_json[f"{section}_meta"] = {
|
final_json[f"{section}_meta"] = {
|
||||||
"title": SECTION_TITLES.get(section, section),
|
"title": SECTION_TITLES.get(section, section),
|
||||||
"columnNames": getattr(json_obj, "columnNames", [])
|
"columnNames": getattr(json_obj, "columnNames", [])
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ class DeviceInstance:
|
|||||||
WHERE devName IN ("(unknown)", "(name not found)", "")
|
WHERE devName IN ("(unknown)", "(name not found)", "")
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
def getResolvable(self):
|
||||||
|
"""Return devices that have a name already set but are not USER/LOCKED protected.
|
||||||
|
Used by SET_ALWAYS name-resolution plugins to re-resolve existing names."""
|
||||||
|
return self._fetchall("""
|
||||||
|
SELECT * FROM Devices
|
||||||
|
WHERE devName NOT IN ("(unknown)", "(name not found)", "")
|
||||||
|
AND COALESCE(devNameSource, '') NOT IN ('USER', 'LOCKED')
|
||||||
|
""")
|
||||||
|
|
||||||
def getValueWithMac(self, column_name, devMac):
|
def getValueWithMac(self, column_name, devMac):
|
||||||
row = self._fetchone(f"""
|
row = self._fetchone(f"""
|
||||||
SELECT {column_name} FROM Devices WHERE devMac = ?
|
SELECT {column_name} FROM Devices WHERE devMac = ?
|
||||||
|
|||||||
@@ -543,10 +543,12 @@ def execute_plugin(db, all_plugins, plugin):
|
|||||||
# Append the final parameters to sqlParams
|
# Append the final parameters to sqlParams
|
||||||
sqlParams.append(tuple(base_params))
|
sqlParams.append(tuple(base_params))
|
||||||
|
|
||||||
# keep current instance log file, delete all from other nodes
|
# Delete only files received from other nodes (identified by .encoded. or .decoded. in the name).
|
||||||
if filename != "last_result.log" and os.path.exists(full_path):
|
# Local result files (e.g. last_result.ARPSCAN.log) are overwritten each cycle and must
|
||||||
os.remove(full_path) # DEBUG:TODO uncomment 🐛
|
# survive so the SYNC plugin can read and forward them to the hub.
|
||||||
mylog("verbose", f"[Plugins] Processed and deleted file: {full_path} ")
|
if (".encoded." in filename or ".decoded." in filename) and os.path.exists(full_path):
|
||||||
|
os.remove(full_path)
|
||||||
|
mylog("verbose", f"[Plugins] Processed and deleted node-sync file: {full_path}")
|
||||||
|
|
||||||
# app-db-query
|
# app-db-query
|
||||||
if plugin["data_source"] == "app-db-query":
|
if plugin["data_source"] == "app-db-query":
|
||||||
|
|||||||
@@ -971,7 +971,7 @@ def update_devices_names(pm):
|
|||||||
(resolver.resolve_nbtlookup, "NBTSCAN"),
|
(resolver.resolve_nbtlookup, "NBTSCAN"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def resolve_devices(devices, resolve_both_name_and_fqdn=True):
|
def resolve_devices(devices, resolve_both_name_and_fqdn=True, active_labels=None):
|
||||||
"""
|
"""
|
||||||
Attempts to resolve device names and/or FQDNs using available strategies.
|
Attempts to resolve device names and/or FQDNs using available strategies.
|
||||||
|
|
||||||
@@ -979,6 +979,10 @@ def update_devices_names(pm):
|
|||||||
devices (list): List of devices to resolve.
|
devices (list): List of devices to resolve.
|
||||||
resolve_both_name_and_fqdn (bool): If True, resolves both name and FQDN.
|
resolve_both_name_and_fqdn (bool): If True, resolves both name and FQDN.
|
||||||
If False, resolves only FQDN.
|
If False, resolves only FQDN.
|
||||||
|
active_labels (set|None): If provided, only strategies whose label is in
|
||||||
|
this set are tried. Used by Step 1b to prevent
|
||||||
|
non-SET_ALWAYS plugins from short-circuiting
|
||||||
|
SET_ALWAYS ones.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
recordsToUpdate (list): List of
|
recordsToUpdate (list): List of
|
||||||
@@ -997,6 +1001,8 @@ def update_devices_names(pm):
|
|||||||
|
|
||||||
# Attempt each resolution strategy in order
|
# Attempt each resolution strategy in order
|
||||||
for resolve_fn, label in strategies:
|
for resolve_fn, label in strategies:
|
||||||
|
if active_labels is not None and label not in active_labels:
|
||||||
|
continue
|
||||||
resolved = resolve_fn(device["devMac"], device["devLastIP"])
|
resolved = resolve_fn(device["devMac"], device["devLastIP"])
|
||||||
|
|
||||||
# Extract values
|
# Extract values
|
||||||
@@ -1090,6 +1096,71 @@ def update_devices_names(pm):
|
|||||||
plugin_records,
|
plugin_records,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Step 1b: Re-resolve already-named devices for SET_ALWAYS plugins ---
|
||||||
|
# If any name-resolution plugin declares devName in SET_ALWAYS, it should be
|
||||||
|
# able to overwrite names set by lower-priority plugins. Step 1 only covers
|
||||||
|
# unknown devices, so we run a second pass here limited to devices that:
|
||||||
|
# - already have a name (not unknown/empty), AND
|
||||||
|
# - are not USER/LOCKED protected
|
||||||
|
# recordsNotFound is intentionally discarded: if resolution fails, the
|
||||||
|
# existing name is kept as-is.
|
||||||
|
name_resolution_plugins = [label for _, label in strategies]
|
||||||
|
set_always_plugins = [
|
||||||
|
p for p in name_resolution_plugins
|
||||||
|
if "devName" in get_plugin_authoritative_settings(p).get("set_always", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
if not set_always_plugins:
|
||||||
|
mylog("debug", "[Update Device Name] SET_ALWAYS re-resolve: skipped (no name-resolution plugin has devName in SET_ALWAYS)")
|
||||||
|
else:
|
||||||
|
resolvableDevices = device_handler.getResolvable()
|
||||||
|
mylog("debug", f"[Update Device Name] SET_ALWAYS re-resolve: active plugins={set_always_plugins}, candidate devices={len(resolvableDevices)}")
|
||||||
|
|
||||||
|
if resolvableDevices:
|
||||||
|
recordsToUpdate, _, fs, notFound = resolve_devices(resolvableDevices, active_labels=set(set_always_plugins))
|
||||||
|
|
||||||
|
res_string = f"{fs['DIGSCAN']}/{fs['AVAHISCAN']}/{fs['NSLOOKUP']}/{fs['NBTSCAN']}"
|
||||||
|
mylog("verbose", f"[Update Device Name] SET_ALWAYS re-resolve - Found (DIG/AVAHI/NSL/NBT): {len(recordsToUpdate)} ({res_string}), Not Found: {notFound}")
|
||||||
|
|
||||||
|
records_by_plugin = {}
|
||||||
|
for entry in recordsToUpdate:
|
||||||
|
records_by_plugin.setdefault(entry[1], []).append(entry)
|
||||||
|
|
||||||
|
total_updated = 0
|
||||||
|
for plugin_label, plugin_records in records_by_plugin.items():
|
||||||
|
plugin_settings = get_plugin_authoritative_settings(plugin_label)
|
||||||
|
name_clause = get_overwrite_sql_clause(
|
||||||
|
"devName", "devNameSource", plugin_settings
|
||||||
|
)
|
||||||
|
fqdn_clause = get_overwrite_sql_clause(
|
||||||
|
"devFQDN", "devFQDNSource", plugin_settings
|
||||||
|
)
|
||||||
|
|
||||||
|
sql.executemany(
|
||||||
|
f"""UPDATE Devices
|
||||||
|
SET devName = CASE
|
||||||
|
WHEN {name_clause} THEN ?
|
||||||
|
ELSE devName
|
||||||
|
END,
|
||||||
|
devNameSource = CASE
|
||||||
|
WHEN {name_clause} THEN ?
|
||||||
|
ELSE devNameSource
|
||||||
|
END,
|
||||||
|
devFQDN = CASE
|
||||||
|
WHEN {fqdn_clause} THEN ?
|
||||||
|
ELSE devFQDN
|
||||||
|
END,
|
||||||
|
devFQDNSource = CASE
|
||||||
|
WHEN {fqdn_clause} THEN ?
|
||||||
|
ELSE devFQDNSource
|
||||||
|
END
|
||||||
|
WHERE devMac = ?""",
|
||||||
|
plugin_records,
|
||||||
|
)
|
||||||
|
total_updated += sql.rowcount
|
||||||
|
|
||||||
|
mylog("verbose", f"[Update Device Name] SET_ALWAYS re-resolve - DB rows updated: {total_updated}")
|
||||||
|
|
||||||
# --- Step 2: Optionally refresh FQDN for all devices ---
|
# --- Step 2: Optionally refresh FQDN for all devices ---
|
||||||
if get_setting_value("REFRESH_FQDN"):
|
if get_setting_value("REFRESH_FQDN"):
|
||||||
allDevices = device_handler.getAll()
|
allDevices = device_handler.getAll()
|
||||||
|
|||||||
@@ -206,17 +206,18 @@ def format_date_iso(date_val: str) -> Optional[str]:
|
|||||||
else:
|
else:
|
||||||
dt = date_val
|
dt = date_val
|
||||||
|
|
||||||
# 2. If it has no timezone, assume it's UTC (our DB storage format)
|
# 2. Normalize to UTC first, then convert to target timezone
|
||||||
# then CONVERT to user's configured timezone
|
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
# Mark as UTC first — critical: localize() would label without converting
|
|
||||||
dt = dt.replace(tzinfo=datetime.UTC)
|
dt = dt.replace(tzinfo=datetime.UTC)
|
||||||
# Resolve target timezone; fall back to UTC if conf.tz is missing/invalid
|
else:
|
||||||
try:
|
dt = dt.astimezone(datetime.UTC)
|
||||||
target_tz = conf.tz if isinstance(conf.tz, datetime.tzinfo) else ZoneInfo(conf.tz)
|
|
||||||
except (ZoneInfoNotFoundError, ValueError, TypeError):
|
try:
|
||||||
target_tz = datetime.UTC
|
target_tz = conf.tz if isinstance(conf.tz, datetime.tzinfo) else ZoneInfo(str(conf.tz))
|
||||||
dt = dt.astimezone(target_tz)
|
except Exception:
|
||||||
|
target_tz = datetime.UTC
|
||||||
|
|
||||||
|
dt = dt.astimezone(target_tz)
|
||||||
|
|
||||||
# 3. Return the string. .isoformat() will now include the +11:00 or +10:00
|
# 3. Return the string. .isoformat() will now include the +11:00 or +10:00
|
||||||
return dt.isoformat()
|
return dt.isoformat()
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ class Action:
|
|||||||
def __init__(self, trigger):
|
def __init__(self, trigger):
|
||||||
self.trigger = trigger
|
self.trigger = trigger
|
||||||
|
|
||||||
def execute(self, obj):
|
def get_object(self):
|
||||||
"""Executes the action on the given object."""
|
"""Safely get and normalize the trigger object."""
|
||||||
|
obj = getattr(self.trigger, "object", None)
|
||||||
|
|
||||||
|
if isinstance(obj, sqlite3.Row):
|
||||||
|
obj = dict(obj)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
raise NotImplementedError("Subclasses must implement execute()")
|
raise NotImplementedError("Subclasses must implement execute()")
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +31,7 @@ class UpdateFieldAction(Action):
|
|||||||
"""Action to update a specific field of an object."""
|
"""Action to update a specific field of an object."""
|
||||||
|
|
||||||
def __init__(self, db, field, value, trigger):
|
def __init__(self, db, field, value, trigger):
|
||||||
super().__init__(trigger) # Call the base class constructor
|
super().__init__(trigger)
|
||||||
self.field = field
|
self.field = field
|
||||||
self.value = value
|
self.value = value
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -31,83 +39,93 @@ class UpdateFieldAction(Action):
|
|||||||
def execute(self):
|
def execute(self):
|
||||||
mylog("verbose", f"[WF] Updating field '{self.field}' to '{self.value}' for event object {self.trigger.object_type}")
|
mylog("verbose", f"[WF] Updating field '{self.field}' to '{self.value}' for event object {self.trigger.object_type}")
|
||||||
|
|
||||||
obj = self.trigger.object
|
obj = self.get_object()
|
||||||
|
|
||||||
# convert to dict for easeir handling
|
if obj is None:
|
||||||
if isinstance(obj, sqlite3.Row):
|
mylog("none", "[WF] Object no longer exists")
|
||||||
obj = dict(obj) # Convert Row object to a standard dictionary
|
return None
|
||||||
|
|
||||||
processed = False
|
|
||||||
|
|
||||||
# currently unused
|
|
||||||
if isinstance(obj, dict) and "objectGuid" in obj:
|
if isinstance(obj, dict) and "objectGuid" in obj:
|
||||||
mylog("debug", f"[WF] Updating Object '{obj}' ")
|
mylog("debug", f"[WF] Updating Object '{obj}'")
|
||||||
plugin_instance = PluginObjectInstance()
|
|
||||||
plugin_instance.updateField(obj["objectGuid"], self.field, self.value)
|
|
||||||
processed = True
|
|
||||||
|
|
||||||
elif isinstance(obj, dict) and "devGUID" in obj:
|
PluginObjectInstance().updateField(
|
||||||
mylog("debug", f"[WF] Updating Device '{obj}' ")
|
obj["objectGuid"],
|
||||||
device_instance = DeviceInstance()
|
self.field,
|
||||||
device_instance.updateField(obj["devGUID"], self.field, self.value)
|
self.value,
|
||||||
processed = True
|
)
|
||||||
|
|
||||||
if not processed:
|
return obj
|
||||||
mylog("none", f"[WF] Could not process action for object: {obj}")
|
|
||||||
|
|
||||||
return obj
|
if isinstance(obj, dict) and "devGUID" in obj:
|
||||||
|
mylog("debug", f"[WF] Updating Device '{obj}'")
|
||||||
|
|
||||||
|
DeviceInstance().updateField(
|
||||||
|
obj["devGUID"],
|
||||||
|
self.field,
|
||||||
|
self.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
mylog("none", f"[WF] Unsupported object format: {obj}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DeleteObjectAction(Action):
|
class DeleteObjectAction(Action):
|
||||||
"""Action to delete an object."""
|
"""Action to delete an object."""
|
||||||
|
|
||||||
def __init__(self, db, trigger):
|
def __init__(self, db, trigger):
|
||||||
super().__init__(trigger) # Call the base class constructor
|
super().__init__(trigger)
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
mylog("verbose", f"[WF] Deleting event object {self.trigger.object_type}")
|
mylog("verbose", f"[WF] Deleting event object {self.trigger.object_type}")
|
||||||
|
|
||||||
obj = self.trigger.object
|
obj = self.get_object()
|
||||||
|
|
||||||
# convert to dict for easeir handling
|
if obj is None:
|
||||||
if isinstance(obj, sqlite3.Row):
|
mylog("none", "[WF] Object no longer exists")
|
||||||
obj = dict(obj) # Convert Row object to a standard dictionary
|
return None
|
||||||
|
|
||||||
processed = False
|
|
||||||
|
|
||||||
# currently unused
|
|
||||||
if isinstance(obj, dict) and "objectGuid" in obj:
|
if isinstance(obj, dict) and "objectGuid" in obj:
|
||||||
mylog("debug", f"[WF] Updating Object '{obj}' ")
|
mylog("debug", f"[WF] Deleting Object '{obj}'")
|
||||||
plugin_instance = PluginObjectInstance()
|
|
||||||
plugin_instance.delete(obj["objectGuid"])
|
|
||||||
processed = True
|
|
||||||
|
|
||||||
elif isinstance(obj, dict) and "devGUID" in obj:
|
PluginObjectInstance().delete(obj["objectGuid"])
|
||||||
mylog("debug", f"[WF] Updating Device '{obj}' ")
|
|
||||||
device_instance = DeviceInstance()
|
|
||||||
device_instance.delete(obj["devGUID"])
|
|
||||||
processed = True
|
|
||||||
|
|
||||||
if not processed:
|
return obj
|
||||||
mylog("none", f"[WF] Could not process action for object: {obj}")
|
|
||||||
|
|
||||||
return obj
|
if isinstance(obj, dict) and "devGUID" in obj:
|
||||||
|
mylog("debug", f"[WF] Deleting Device '{obj}'")
|
||||||
|
|
||||||
|
DeviceInstance().delete(obj["devGUID"])
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
mylog("none", f"[WF] Unsupported object format: {obj}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class RunPluginAction(Action):
|
class RunPluginAction(Action):
|
||||||
"""Action to run a specific plugin."""
|
"""Action to run a specific plugin."""
|
||||||
|
|
||||||
def __init__(self, plugin_name, params, trigger): # Add trigger
|
def __init__(self, plugin_name, params, trigger):
|
||||||
super().__init__(trigger) # Call parent constructor
|
super().__init__(trigger)
|
||||||
self.plugin_name = plugin_name
|
self.plugin_name = plugin_name
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
obj = self.trigger.object
|
obj = self.get_object()
|
||||||
|
|
||||||
|
if obj is None:
|
||||||
|
mylog("none", "[WF] Object no longer exists")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mylog("verbose", f"[WF] Executing plugin '{self.plugin_name}' with parameters {self.params} for object {obj}")
|
||||||
|
|
||||||
|
# PluginManager.run(self.plugin_name, self.params)
|
||||||
|
|
||||||
mylog("verbose", f"Executing plugin '{self.plugin_name}' with parameters {self.params} for object {obj}")
|
|
||||||
# PluginManager.run(self.plugin_name, self.parameters)
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@@ -115,14 +133,21 @@ class SendNotificationAction(Action):
|
|||||||
"""Action to send a notification."""
|
"""Action to send a notification."""
|
||||||
|
|
||||||
def __init__(self, method, message, trigger):
|
def __init__(self, method, message, trigger):
|
||||||
super().__init__(trigger) # Call parent constructor
|
super().__init__(trigger)
|
||||||
self.method = method # Fix attribute name
|
self.method = method
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
def execute(self):
|
def execute(self):
|
||||||
obj = self.trigger.object
|
obj = self.get_object()
|
||||||
mylog("verbose", f"Sending notification via '{self.method}': {self.message} for object {obj}")
|
|
||||||
|
if obj is None:
|
||||||
|
mylog("none", "[WF] Object no longer exists")
|
||||||
|
return None
|
||||||
|
|
||||||
|
mylog("verbose", f"[WF] Sending notification via '{self.method}': {self.message} for object {obj}")
|
||||||
|
|
||||||
# NotificationManager.send(self.method, self.message)
|
# NotificationManager.send(self.method, self.message)
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +157,6 @@ class ActionGroup:
|
|||||||
def __init__(self, actions):
|
def __init__(self, actions):
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
|
|
||||||
def execute(self, obj):
|
def execute(self):
|
||||||
for action in self.actions:
|
for action in self.actions:
|
||||||
action.execute(obj)
|
action.execute()
|
||||||
return obj
|
|
||||||
@@ -48,7 +48,11 @@ class Trigger:
|
|||||||
mylog("debug", [query])
|
mylog("debug", [query])
|
||||||
|
|
||||||
result = db.sql.execute(query).fetchall()
|
result = db.sql.execute(query).fetchall()
|
||||||
self.object = result[0]
|
|
||||||
|
if len(result) > 0:
|
||||||
|
self.object = result[0]
|
||||||
|
else:
|
||||||
|
self.object = None
|
||||||
else:
|
else:
|
||||||
self.object = None
|
self.object = None
|
||||||
|
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ def test_sync_get(mock_handle, client, api_token):
|
|||||||
def test_sync_post(mock_handle, client, api_token):
|
def test_sync_post(mock_handle, client, api_token):
|
||||||
"""Test POST /sync."""
|
"""Test POST /sync."""
|
||||||
mock_handle.return_value = ({"success": True}, 200)
|
mock_handle.return_value = ({"success": True}, 200)
|
||||||
payload = {"data": {}, "node_name": "node1", "plugin": "test"}
|
payload = {"data": "encrypted_payload_string", "node_name": "node1", "plugin": "test"}
|
||||||
response = client.post('/sync',
|
response = client.post('/sync',
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=auth_headers(api_token))
|
headers=auth_headers(api_token))
|
||||||
|
|||||||
124
test/api_endpoints/test_sync_endpoint.py
Normal file
124
test/api_endpoints/test_sync_endpoint.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Tests for the /sync POST and GET endpoints.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Authentication enforcement (403 on missing/invalid token)
|
||||||
|
- Content-type enforcement on POST (regression for data= vs json= bug)
|
||||||
|
- Happy-path POST returns 200
|
||||||
|
- GET auth enforcement
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
|
||||||
|
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
|
||||||
|
|
||||||
|
from helper import get_setting_value # noqa: E402
|
||||||
|
from api_server.api_server_start import app # noqa: E402
|
||||||
|
import api_server.sync_endpoint as sync_endpoint # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def api_token():
|
||||||
|
"""Load API token from system settings."""
|
||||||
|
return get_setting_value("API_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""Flask test client."""
|
||||||
|
with app.test_client() as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers(token):
|
||||||
|
"""Helper to construct Authorization header."""
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# POST /sync - authentication
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def test_sync_post_no_token_is_forbidden(client):
|
||||||
|
resp = client.post("/sync")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_post_invalid_token_is_forbidden(client):
|
||||||
|
resp = client.post("/sync", headers=auth_headers("INVALID-TOKEN"))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# POST /sync - content-type enforcement
|
||||||
|
# Regression: node used to send data= (form-encoded); validation rejects it.
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def test_sync_post_form_encoded_returns_415(client, api_token):
|
||||||
|
"""Form-encoded body must be rejected with 415 Unsupported Media Type.
|
||||||
|
|
||||||
|
Regression test: before the fix sync.py used ``requests.post(data=…)``
|
||||||
|
which sends application/x-www-form-urlencoded. The validate_request
|
||||||
|
middleware requires application/json — this test ensures that contract
|
||||||
|
is enforced so the node can never silently regress to form encoding.
|
||||||
|
"""
|
||||||
|
resp = client.post(
|
||||||
|
"/sync",
|
||||||
|
headers=auth_headers(api_token),
|
||||||
|
data={"data": "payload", "plugin": "ARPSCAN", "node_name": "Node1"},
|
||||||
|
content_type="application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_post_json_body_is_accepted(client, api_token, tmp_path):
|
||||||
|
"""JSON body must pass validation and return 200."""
|
||||||
|
plugins_dir = tmp_path / "log" / "plugins"
|
||||||
|
plugins_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch.object(sync_endpoint, "INSTALL_PATH", str(tmp_path)):
|
||||||
|
resp = client.post(
|
||||||
|
"/sync",
|
||||||
|
headers=auth_headers(api_token),
|
||||||
|
json={"data": "test_payload", "plugin": "TESTPLUGIN", "node_name": "TestNode"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data is not None
|
||||||
|
assert "message" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_post_json_body_writes_encoded_file(client, api_token, tmp_path):
|
||||||
|
"""A successful POST must persist an encoded file in the plugins log dir."""
|
||||||
|
plugins_dir = tmp_path / "log" / "plugins"
|
||||||
|
plugins_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch.object(sync_endpoint, "INSTALL_PATH", str(tmp_path)):
|
||||||
|
client.post(
|
||||||
|
"/sync",
|
||||||
|
headers=auth_headers(api_token),
|
||||||
|
json={"data": "encrypted_blob", "plugin": "ARPSCAN", "node_name": "Node1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
written = list(plugins_dir.glob("last_result.ARPSCAN.encoded.Node1.*.log"))
|
||||||
|
assert len(written) == 1
|
||||||
|
assert written[0].read_text() == "encrypted_blob"
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# GET /sync - authentication
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def test_sync_get_no_token_is_forbidden(client):
|
||||||
|
resp = client.get("/sync")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_get_invalid_token_is_forbidden(client):
|
||||||
|
resp = client.get("/sync", headers=auth_headers("INVALID-TOKEN"))
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -46,7 +46,7 @@ def _send_data(api_token, file_content, encryption_key, file_path, node_name, pr
|
|||||||
}
|
}
|
||||||
headers = {"Authorization": f"Bearer {api_token}"}
|
headers = {"Authorization": f"Bearer {api_token}"}
|
||||||
try:
|
try:
|
||||||
response = requests.post(hub_url + API_ENDPOINT, data=data, headers=headers, timeout=5)
|
response = requests.post(hub_url + API_ENDPOINT, json=data, headers=headers, timeout=5)
|
||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
return False
|
return False
|
||||||
@@ -68,9 +68,24 @@ def _get_data(api_token, node_url):
|
|||||||
|
|
||||||
|
|
||||||
def _node_name_from_filename(file_name: str) -> str:
|
def _node_name_from_filename(file_name: str) -> str:
|
||||||
"""Mirror of the node-name extraction in sync.main()."""
|
"""Mirror of the node-name extraction in sync.main() (Mode 3).
|
||||||
|
|
||||||
|
Real file formats produced by the system:
|
||||||
|
PUSH (post-decode): last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
|
||||||
|
PULL: last_result.NodeName.log → parts[1]
|
||||||
|
"""
|
||||||
parts = file_name.split(".")
|
parts = file_name.split(".")
|
||||||
return parts[2] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
|
return parts[3] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_delete_after_process(filename: str) -> bool:
|
||||||
|
"""Mirror of the delete-after-process condition in execute_plugin() (server/plugin.py).
|
||||||
|
|
||||||
|
Only node-sync intermediary files (.encoded. / .decoded.) are removed after
|
||||||
|
processing. Local plugin result files (last_result.ARPSCAN.log etc.) must
|
||||||
|
survive so SYNC Mode 1 can read and forward them to the hub.
|
||||||
|
"""
|
||||||
|
return ".encoded." in filename or ".decoded." in filename
|
||||||
|
|
||||||
|
|
||||||
def _determine_mode(hub_url: str, send_devices: bool, plugins_to_sync: list, pull_nodes: list):
|
def _determine_mode(hub_url: str, send_devices: bool, plugins_to_sync: list, pull_nodes: list):
|
||||||
@@ -205,7 +220,7 @@ class TestSendData:
|
|||||||
with patch("requests.post", return_value=resp) as mock_post:
|
with patch("requests.post", return_value=resp) as mock_post:
|
||||||
_send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY,
|
_send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY,
|
||||||
"/tmp/file.log", "node1", "SYNC", HUB_URL)
|
"/tmp/file.log", "node1", "SYNC", HUB_URL)
|
||||||
payload = mock_post.call_args[1]["data"]
|
payload = mock_post.call_args[1]["json"]
|
||||||
assert "data" in payload # encrypted blob
|
assert "data" in payload # encrypted blob
|
||||||
assert payload["file_path"] == "/tmp/file.log"
|
assert payload["file_path"] == "/tmp/file.log"
|
||||||
assert payload["plugin"] == "SYNC"
|
assert payload["plugin"] == "SYNC"
|
||||||
@@ -219,7 +234,7 @@ class TestSendData:
|
|||||||
with patch("requests.post", return_value=resp) as mock_post:
|
with patch("requests.post", return_value=resp) as mock_post:
|
||||||
_send_data(API_TOKEN, plaintext, ENCRYPTION_KEY,
|
_send_data(API_TOKEN, plaintext, ENCRYPTION_KEY,
|
||||||
"/tmp/file.log", "node1", "SYNC", HUB_URL)
|
"/tmp/file.log", "node1", "SYNC", HUB_URL)
|
||||||
transmitted = mock_post.call_args[1]["data"]["data"]
|
transmitted = mock_post.call_args[1]["json"]["data"]
|
||||||
assert transmitted != plaintext
|
assert transmitted != plaintext
|
||||||
# Verify it round-trips correctly
|
# Verify it round-trips correctly
|
||||||
assert decrypt_data(transmitted, ENCRYPTION_KEY) == plaintext
|
assert decrypt_data(transmitted, ENCRYPTION_KEY) == plaintext
|
||||||
@@ -296,23 +311,33 @@ class TestGetData:
|
|||||||
|
|
||||||
class TestNodeNameExtraction:
|
class TestNodeNameExtraction:
|
||||||
|
|
||||||
def test_simple_filename(self):
|
def test_pull_mode_filename(self):
|
||||||
# last_result.MyNode.log → "MyNode"
|
# PULL mode: last_result.MyNode.log → "MyNode"
|
||||||
assert _node_name_from_filename("last_result.MyNode.log") == "MyNode"
|
assert _node_name_from_filename("last_result.MyNode.log") == "MyNode"
|
||||||
|
|
||||||
def test_decoded_filename(self):
|
def test_push_decoded_filename(self):
|
||||||
# last_result.decoded.MyNode.1.log → "MyNode"
|
# PUSH mode (post-decode): last_result.ARPSCAN.decoded.MyNode.1.log → "MyNode"
|
||||||
assert _node_name_from_filename("last_result.decoded.MyNode.1.log") == "MyNode"
|
assert _node_name_from_filename("last_result.ARPSCAN.decoded.MyNode.1.log") == "MyNode"
|
||||||
|
|
||||||
def test_encoded_filename(self):
|
def test_push_encoded_filename(self):
|
||||||
# last_result.encoded.MyNode.1.log → "MyNode"
|
# PUSH mode (pre-decode): last_result.ARPSCAN.encoded.MyNode.1.log → "MyNode"
|
||||||
assert _node_name_from_filename("last_result.encoded.MyNode.1.log") == "MyNode"
|
assert _node_name_from_filename("last_result.ARPSCAN.encoded.MyNode.1.log") == "MyNode"
|
||||||
|
|
||||||
def test_node_name_with_underscores(self):
|
def test_pull_node_name_with_underscores(self):
|
||||||
assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site"
|
assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site"
|
||||||
|
|
||||||
def test_decoded_node_name_with_underscores(self):
|
def test_push_decoded_node_name_with_underscores(self):
|
||||||
assert _node_name_from_filename("last_result.decoded.Wladek_Site.1.log") == "Wladek_Site"
|
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Wladek_Site.1.log") == "Wladek_Site"
|
||||||
|
|
||||||
|
def test_push_decoded_node_name_with_counter_gt_1(self):
|
||||||
|
# Counter increments when multiple pushes arrive before SYNC runs
|
||||||
|
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Node_Vlan01.3.log") == "Node_Vlan01"
|
||||||
|
|
||||||
|
def test_push_decoded_different_plugins(self):
|
||||||
|
for plugin in ("NMAP", "PIHOLE", "DHCPLEASES"):
|
||||||
|
fname = f"last_result.{plugin}.decoded.HubNode.1.log"
|
||||||
|
assert _node_name_from_filename(fname) == "HubNode", \
|
||||||
|
f"Expected 'HubNode' from {fname}"
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -409,5 +434,103 @@ class TestReceiveInsert:
|
|||||||
inserted = sync_insert_devices(conn, [device], existing_macs=set())
|
inserted = sync_insert_devices(conn, [device], existing_macs=set())
|
||||||
assert inserted == 1
|
assert inserted == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Plugin result file retention (regression for execute_plugin delete bug)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestPluginFileRetention:
|
||||||
|
"""Regression for the execute_plugin() delete-condition bug (server/plugin.py).
|
||||||
|
|
||||||
|
Before the fix the condition was ``filename != "last_result.log"``. No
|
||||||
|
plugin ever writes to that literal name — all write ``last_result.ARPSCAN.log``
|
||||||
|
etc. — so every local result file was deleted immediately after processing,
|
||||||
|
before SYNC Mode 1 had a chance to read and forward it to the hub.
|
||||||
|
|
||||||
|
The corrected condition deletes ONLY ``.encoded.`` / ``.decoded.``
|
||||||
|
node-sync intermediary files. Local plugin result files must survive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_local_result_file_not_flagged_for_deletion(self):
|
||||||
|
assert _should_delete_after_process("last_result.ARPSCAN.log") is False
|
||||||
|
|
||||||
|
def test_local_result_files_for_common_plugins_not_flagged(self):
|
||||||
|
for plugin in ("NMAP", "PIHOLE", "SYNC", "DHCPLEASES", "ARPSCAN"):
|
||||||
|
fname = f"last_result.{plugin}.log"
|
||||||
|
assert _should_delete_after_process(fname) is False, \
|
||||||
|
f"{fname} must NOT be deleted — SYNC Mode 1 still needs it"
|
||||||
|
|
||||||
|
def test_encoded_node_sync_file_flagged_for_deletion(self):
|
||||||
|
assert _should_delete_after_process("last_result.ARPSCAN.encoded.Node1.1.log") is True
|
||||||
|
|
||||||
|
def test_decoded_node_sync_file_flagged_for_deletion(self):
|
||||||
|
assert _should_delete_after_process("last_result.ARPSCAN.decoded.Node1.1.log") is True
|
||||||
|
|
||||||
|
def test_encoded_files_with_various_node_names_flagged(self):
|
||||||
|
for node in ("Node1", "Home_Hub", "Site_B", "OfficeNode"):
|
||||||
|
fname = f"last_result.ARPSCAN.encoded.{node}.1.log"
|
||||||
|
assert _should_delete_after_process(fname) is True, \
|
||||||
|
f"{fname} should be deleted after processing"
|
||||||
|
|
||||||
|
def test_decoded_files_with_various_node_names_flagged(self):
|
||||||
|
for node in ("Node1", "Home_Hub", "Site_B"):
|
||||||
|
fname = f"last_result.ARPSCAN.decoded.{node}.2.log"
|
||||||
|
assert _should_delete_after_process(fname) is True, \
|
||||||
|
f"{fname} should be deleted after processing"
|
||||||
|
|
||||||
def test_empty_device_list_returns_zero(self, conn):
|
def test_empty_device_list_returns_zero(self, conn):
|
||||||
assert sync_insert_devices(conn, [], existing_macs=set()) == 0
|
assert sync_insert_devices(conn, [], existing_macs=set()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Mode 3 JSON-skip behaviour
|
||||||
|
# Regression: local plugin result files (pipe-delimited) must not crash Mode 3.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def _parse_sync_payload(file_path: str) -> list:
|
||||||
|
"""Mirror of the json.load + data['data'] block in sync.main() Mode 3.
|
||||||
|
|
||||||
|
Returns the list of device dicts on success, or raises nothing on invalid
|
||||||
|
input — callers should catch JSONDecodeError / KeyError and skip the file.
|
||||||
|
"""
|
||||||
|
with open(file_path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMode3JsonSkip:
|
||||||
|
"""Regression for the crash when Mode 3 encountered pipe-delimited plugin files.
|
||||||
|
|
||||||
|
Before the fix, sync.py called json.load() on every last_result.*.log file
|
||||||
|
returned by decode_and_rename_files(), including local plugin result files
|
||||||
|
(e.g. last_result.DIGSCAN.log) which are pipe-delimited and not JSON. The
|
||||||
|
fix wraps the load in try/except(JSONDecodeError, KeyError) and continues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_valid_sync_payload_is_parsed(self, tmp_path):
|
||||||
|
payload = {"data": [{"devMac": "aa:bb:cc:dd:ee:01", "devName": "TestDevice"}]}
|
||||||
|
f = tmp_path / "last_result.ARPSCAN.decoded.Node1.1.log"
|
||||||
|
f.write_text(json.dumps(payload))
|
||||||
|
result = _parse_sync_payload(str(f))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["devMac"] == "aa:bb:cc:dd:ee:01"
|
||||||
|
|
||||||
|
def test_pipe_delimited_file_raises_json_error(self, tmp_path):
|
||||||
|
"""Pipe-delimited plugin file must raise JSONDecodeError so callers can skip it."""
|
||||||
|
f = tmp_path / "last_result.DIGSCAN.log"
|
||||||
|
f.write_text("aa:bb:cc:dd:ee:01|192.168.1.1|2026-01-01 00:00:00|hostname||subnet||DIGSCAN|||||\n")
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
_parse_sync_payload(str(f))
|
||||||
|
|
||||||
|
def test_json_without_data_key_raises_key_error(self, tmp_path):
|
||||||
|
"""JSON that lacks the 'data' key must raise KeyError so callers can skip it."""
|
||||||
|
f = tmp_path / "last_result.UNKNOWN.log"
|
||||||
|
f.write_text(json.dumps({"result": []}))
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
_parse_sync_payload(str(f))
|
||||||
|
|
||||||
|
def test_empty_file_raises_json_error(self, tmp_path):
|
||||||
|
f = tmp_path / "last_result.EMPTY.log"
|
||||||
|
f.write_text("")
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
_parse_sync_payload(str(f))
|
||||||
|
|||||||
335
test/ui/test_ui_xss_devname.py
Normal file
335
test/ui/test_ui_xss_devname.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Stored-XSS regression tests for devName / devFQDN rendering.
|
||||||
|
|
||||||
|
Scenario
|
||||||
|
--------
|
||||||
|
A LAN-controlled scanner (e.g. DHCPLSS / nmap) can supply arbitrary hostnames
|
||||||
|
that end up stored as `devName` or `devFQDN` in the database. If these values
|
||||||
|
are injected into jQuery `.html()` calls or template-literal HTML strings
|
||||||
|
without HTML-entity escaping, a stored XSS payload executes in the
|
||||||
|
authenticated operator's browser.
|
||||||
|
|
||||||
|
These tests verify that no page listed in the "affected surfaces" table renders
|
||||||
|
the raw payload as executable HTML.
|
||||||
|
|
||||||
|
Canary mechanism
|
||||||
|
----------------
|
||||||
|
The XSS payload sets `window.__xss_canary = true` if executed:
|
||||||
|
<img src=x onerror="window.__xss_canary=true">
|
||||||
|
|
||||||
|
Before each page navigation we reset the canary to `false`. After the page
|
||||||
|
fully loads (and after any async device-list fetches have had time to run) we
|
||||||
|
assert that the canary is still `false`.
|
||||||
|
|
||||||
|
Note: these tests require a running NetAlertX backend and frontend (use the
|
||||||
|
devcontainer startup tasks or the Docker compose stack). They are Selenium-
|
||||||
|
based (headless Chromium) and are skipped when a browser is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from selenium.webdriver.common.by import By # noqa: E402
|
||||||
|
|
||||||
|
from .test_helpers import ( # noqa: E402
|
||||||
|
BASE_URL, API_BASE_URL,
|
||||||
|
get_driver, get_api_token,
|
||||||
|
wait_for_page_load,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# XSS payload: sets the window canary if the string is parsed as HTML.
|
||||||
|
# We keep the payload simple and deterministic.
|
||||||
|
XSS_PAYLOAD = '<img src=x onerror="window.__xss_canary=true">'
|
||||||
|
|
||||||
|
# A second, attribute-break variant that escapes unquoted attribute contexts
|
||||||
|
XSS_ATTR_BREAK = '" onmouseover="window.__xss_canary=true" data-x="'
|
||||||
|
|
||||||
|
# The fake MAC used for our synthetic test device (FA:CE prefix = recognised as
|
||||||
|
# "fake" by isFakeMac() in ui_components.js, so it won't affect real scanning).
|
||||||
|
XSS_TEST_MAC = "fa:ce:00:00:00:01"
|
||||||
|
|
||||||
|
# Seconds to wait after page load for async device-list fetches to complete.
|
||||||
|
ASYNC_WAIT_S = 4
|
||||||
|
|
||||||
|
# Pages to exercise (relative URLs; all are authenticated but devcontainer
|
||||||
|
# skips auth by default).
|
||||||
|
PAGES_UNDER_TEST = [
|
||||||
|
("/devices.php", "Device list table (devName / devFQDN columns)"),
|
||||||
|
("/network.php", "Network tabs and tree"),
|
||||||
|
(f"/deviceDetails.php?mac={XSS_TEST_MAC}", "Device detail page title"),
|
||||||
|
("/presence.php", "FullCalendar presence view"),
|
||||||
|
("/multiEditCore.php", "Multi-edit device selector"),
|
||||||
|
("/events.php", "Events table device name column"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _auth_headers(token):
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_xss_device(token: str):
|
||||||
|
"""Create a synthetic device whose devName is the XSS payload."""
|
||||||
|
payload = {
|
||||||
|
"createNew": True,
|
||||||
|
"devName": XSS_PAYLOAD,
|
||||||
|
"devFQDN": XSS_PAYLOAD,
|
||||||
|
"devOwner": "XSS-test",
|
||||||
|
"devType": "Other",
|
||||||
|
"devVendor": "XSS-test",
|
||||||
|
"devLastIP": "192.168.99.99",
|
||||||
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
f"{API_BASE_URL}/device/{XSS_TEST_MAC}",
|
||||||
|
json=payload,
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _create_xss_event(token: str):
|
||||||
|
"""Create an event for the XSS test device so events.php renders its name."""
|
||||||
|
requests.post(
|
||||||
|
f"{API_BASE_URL}/events/create/{XSS_TEST_MAC}",
|
||||||
|
json={"event_type": "Device Down", "ip": "192.168.99.99", "additional_info": "xss-test"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_xss_device(token: str):
|
||||||
|
"""Delete the synthetic XSS test device and its events."""
|
||||||
|
requests.delete(
|
||||||
|
f"{API_BASE_URL}/events/{XSS_TEST_MAC}",
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
requests.delete(
|
||||||
|
f"{API_BASE_URL}/devices",
|
||||||
|
json={"macs": [XSS_TEST_MAC]},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_canary(driver) -> bool:
|
||||||
|
"""Return True if the XSS canary was tripped."""
|
||||||
|
return bool(driver.execute_script("return window.__xss_canary || false;"))
|
||||||
|
|
||||||
|
|
||||||
|
def _navigate_with_canary(driver, url, timeout=15):
|
||||||
|
"""Navigate to *url* with window.__xss_canary pre-initialised to false.
|
||||||
|
|
||||||
|
Uses CDP Page.addScriptToEvaluateOnNewDocument so the canary is set
|
||||||
|
*before* any page script runs. If we reset it after driver.get() instead,
|
||||||
|
a payload that fires during page load would set the canary and we would
|
||||||
|
immediately clear the evidence.
|
||||||
|
"""
|
||||||
|
result = driver.execute_cdp_cmd(
|
||||||
|
"Page.addScriptToEvaluateOnNewDocument",
|
||||||
|
{"source": "window.__xss_canary = false;"},
|
||||||
|
)
|
||||||
|
script_id = result.get("identifier")
|
||||||
|
try:
|
||||||
|
driver.get(url)
|
||||||
|
wait_for_page_load(driver, timeout=timeout)
|
||||||
|
finally:
|
||||||
|
if script_id:
|
||||||
|
driver.execute_cdp_cmd(
|
||||||
|
"Page.removeScriptToEvaluateOnNewDocument",
|
||||||
|
{"identifier": script_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _force_cache_refresh(driver):
|
||||||
|
"""Clear localStorage to force a fresh device-list fetch on next page load."""
|
||||||
|
driver.execute_script("if(window.localStorage){ localStorage.clear(); }")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def xss_token():
|
||||||
|
"""Retrieve the API token; skip the module if unavailable."""
|
||||||
|
token = get_api_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("API token not available – is the backend running?")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def xss_device(xss_token):
|
||||||
|
"""Create the XSS test device (and one event) before the module; clean up after."""
|
||||||
|
resp = _create_xss_device(xss_token)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
pytest.skip(f"Could not create XSS test device (HTTP {resp.status_code}). "
|
||||||
|
"Is the backend running?")
|
||||||
|
# Create an event so events.php actually renders this device's name.
|
||||||
|
_create_xss_event(xss_token)
|
||||||
|
yield XSS_TEST_MAC
|
||||||
|
_delete_xss_device(xss_token)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def xss_driver(xss_device): # depend on xss_device so device exists before browser starts
|
||||||
|
"""Single headless browser instance shared across all XSS tests in this module."""
|
||||||
|
driver = get_driver()
|
||||||
|
if not driver:
|
||||||
|
pytest.skip("Headless browser (Chromium) not available")
|
||||||
|
|
||||||
|
# Warm the localStorage device cache so later pages can render the device.
|
||||||
|
driver.get(f"{BASE_URL}/devices.php")
|
||||||
|
wait_for_page_load(driver, timeout=15)
|
||||||
|
time.sleep(ASYNC_WAIT_S) # let async API fetch populate localStorage
|
||||||
|
|
||||||
|
yield driver
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
|
||||||
|
def test_devname_xss_not_executed(xss_driver, path, description):
|
||||||
|
"""
|
||||||
|
Verify that the XSS canary is NOT tripped when a page renders the XSS
|
||||||
|
device name.
|
||||||
|
|
||||||
|
Pass criterion: window.__xss_canary remains false after the page loads
|
||||||
|
and the async device list fetch has had time to complete.
|
||||||
|
"""
|
||||||
|
driver = xss_driver
|
||||||
|
|
||||||
|
# Pre-initialise canary via CDP so it is false before any page JS runs.
|
||||||
|
# Resetting after driver.get() would erase evidence of early-firing payloads.
|
||||||
|
_navigate_with_canary(driver, f"{BASE_URL}{path}")
|
||||||
|
|
||||||
|
# Give async JS (DataTables, FullCalendar, network tree) time to render
|
||||||
|
time.sleep(ASYNC_WAIT_S)
|
||||||
|
|
||||||
|
# Final canary check
|
||||||
|
fired = _read_canary(driver)
|
||||||
|
assert not fired, (
|
||||||
|
f"XSS canary was tripped on {description} ({path}). "
|
||||||
|
f"The raw payload '{XSS_PAYLOAD}' was executed as HTML. "
|
||||||
|
"Check that encodeSpecialChars() is applied at all render sites."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,description", PAGES_UNDER_TEST)
|
||||||
|
def test_devname_raw_payload_not_in_html_source(xss_driver, path, description):
|
||||||
|
"""
|
||||||
|
Verify that the XSS payload was NOT injected as a real HTML element.
|
||||||
|
|
||||||
|
A properly escaped devName ends up as text content in the DOM, e.g.:
|
||||||
|
<img src=x onerror="...">
|
||||||
|
The browser's DOM serialiser encodes < and > but NOT ", so page_source
|
||||||
|
still contains the literal string onerror="..." — that is expected and
|
||||||
|
safe. What must NOT appear is an actual unescaped opening tag:
|
||||||
|
<img src=x
|
||||||
|
which would indicate the payload was inserted as real HTML.
|
||||||
|
"""
|
||||||
|
driver = xss_driver
|
||||||
|
|
||||||
|
driver.get(f"{BASE_URL}{path}")
|
||||||
|
wait_for_page_load(driver, timeout=15)
|
||||||
|
time.sleep(ASYNC_WAIT_S)
|
||||||
|
|
||||||
|
page_source = driver.page_source
|
||||||
|
|
||||||
|
# An actual injected <img src=x tag would appear as-is in the serialised DOM.
|
||||||
|
# Escaped text content would appear as <img src=x — no literal "<img src=x".
|
||||||
|
assert "<img src=x" not in page_source, (
|
||||||
|
f"XSS payload injected as real HTML on {description} ({path}): "
|
||||||
|
"'<img src=x' found literally in page source. "
|
||||||
|
"Ensure encodeSpecialChars() wraps all devName/devFQDN render sites."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_devname_xss_device_shows_as_escaped_text(xss_driver):
|
||||||
|
"""
|
||||||
|
On devices.php, confirm the XSS payload is displayed as visible escaped text
|
||||||
|
('<img' or just '<img' as textContent) rather than as an injected element.
|
||||||
|
|
||||||
|
We check the page text (what the user reads) includes part of the payload as
|
||||||
|
literal characters, and that NO <img> element with src=x was injected.
|
||||||
|
"""
|
||||||
|
driver = xss_driver
|
||||||
|
|
||||||
|
driver.get(f"{BASE_URL}/devices.php")
|
||||||
|
wait_for_page_load(driver, timeout=15)
|
||||||
|
time.sleep(ASYNC_WAIT_S)
|
||||||
|
|
||||||
|
# The body text should contain the literal payload characters as plain text
|
||||||
|
body_text = driver.find_element(By.TAG_NAME, "body").text
|
||||||
|
if "onerror" in body_text:
|
||||||
|
# Payload is visible as literal text — confirm it was not also executed
|
||||||
|
assert not driver.execute_script("return window.__xss_canary || false"), (
|
||||||
|
"XSS canary fired even though payload appeared as visible text on devices.php"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No <img> with src=x should have been injected by the XSS payload
|
||||||
|
injected_imgs = driver.find_elements(By.CSS_SELECTOR, "img[src='x']")
|
||||||
|
assert len(injected_imgs) == 0, (
|
||||||
|
f"XSS payload created an <img src=x> element — payload was not escaped. "
|
||||||
|
f"Found {len(injected_imgs)} injected image(s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_devname_attribute_break_xss_not_executed(xss_driver, xss_token):
|
||||||
|
"""
|
||||||
|
Verify that the attribute-break variant of XSS payload is also handled.
|
||||||
|
This checks that encodeSpecialChars() encodes double-quotes in devName,
|
||||||
|
preventing an attacker from breaking out of an HTML attribute context.
|
||||||
|
"""
|
||||||
|
attr_break_mac = "fa:ce:00:00:00:02"
|
||||||
|
attr_payload = {
|
||||||
|
"createNew": True,
|
||||||
|
"devName": XSS_ATTR_BREAK,
|
||||||
|
"devOwner": "XSS-attr-test",
|
||||||
|
"devLastIP": "192.168.99.100",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{API_BASE_URL}/device/{attr_break_mac}",
|
||||||
|
json=attr_payload,
|
||||||
|
headers=_auth_headers(xss_token),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
pytest.skip(f"Could not create attribute-break XSS device (HTTP {resp.status_code})")
|
||||||
|
|
||||||
|
# Pre-set canary via CDP, then navigate — same pattern as main tests.
|
||||||
|
_navigate_with_canary(xss_driver, f"{BASE_URL}/devices.php")
|
||||||
|
time.sleep(ASYNC_WAIT_S)
|
||||||
|
|
||||||
|
fired = _read_canary(xss_driver)
|
||||||
|
assert not fired, (
|
||||||
|
f"Attribute-break XSS canary was tripped on devices.php. "
|
||||||
|
f"Payload: {XSS_ATTR_BREAK!r}. "
|
||||||
|
"Ensure double-quotes are encoded by encodeSpecialChars()."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
requests.delete(
|
||||||
|
f"{API_BASE_URL}/devices",
|
||||||
|
json={"macs": [attr_break_mac]},
|
||||||
|
headers=_auth_headers(xss_token),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user