Compare commits

...

47 Commits

Author SHA1 Message Date
jokob-sk
4b0c7f2c01 docs, MQTT, GH Actions 2025-03-01 10:06:32 +11:00
jokob-sk
bf3d497d26 Merge branch 'main' of https://github.com/jokob-sk/NetAlertX 2025-03-01 09:44:15 +11:00
jokob-sk
47d9a9300e docs, MQTT, GH Actions 2025-03-01 09:44:11 +11:00
Jokob @NetAlertX
fd107fe4f7 Merge pull request #1008 from xfilo/main
OMDSDNOPENAPI - Refactored data collection into a class, improved code clarity with comments
2025-02-25 06:59:32 +11:00
xfilo
9513a5a2ae Merge branch 'jokob-sk:main' into main 2025-02-24 20:51:53 +01:00
xfilo
bf151bd69a OMDSDNOPENAPI - Refactored data collection into a class, improved code clarity with comments 2025-02-24 20:51:01 +01:00
Jokob @NetAlertX
3a312fd5ed Merge pull request #1007 from xfilo/main
OMDSDNOPENAPI - Fixed example in README.md, improved logging and code logic
2025-02-25 06:13:06 +11:00
xfilo
03bf4f4050 Merge branch 'main' of https://github.com/xfilo/NetAlertX 2025-02-24 16:03:18 +01:00
xfilo
bf3fdd2766 OMDSDNOPENAPI - Rephrased error messages, improved logging and code logic 2025-02-24 16:03:07 +01:00
xfilo
befb2574e9 Merge branch 'jokob-sk:main' into main 2025-02-24 11:38:35 +01:00
xfilo
52de3ae872 OMDSDNOPENAPI - Updated example image in README.md 2025-02-24 11:36:55 +01:00
Jokob @NetAlertX
9be9728cd6 Merge pull request #1006 from xfilo/main
OMDSDNOPENAPI - Run command change due to plugin folder rename
2025-02-24 20:57:05 +11:00
xfilo
65a5d35801 OMDSDNOPENAPI - Run command change due to plugin folder rename 2025-02-24 10:52:18 +01:00
jokob-sk
1e714005a5 docs 2025-02-24 14:34:26 +11:00
jokob-sk
2a25f38268 OMDSDNOPENAPI cleanup + opensense script 2025-02-24 12:52:12 +11:00
Jokob @NetAlertX
65a0f90bd8 Merge pull request #1005 from xfilo/main
New plugin: Omada SDN import using OpenAPI (OMDSDNOPENAPI) by @xfilo 🙏
2025-02-24 12:19:06 +11:00
xfilo
4d77ff3ff1 New plugin for Omada SDN import using OpenAPI 2025-02-24 00:51:44 +01:00
jokob-sk
500129c440 removal of default dropdown values on device 2025-02-24 10:13:48 +11:00
jokob-sk
a320b2910f docs 2025-02-23 07:41:35 +11:00
jokob-sk
ac7e278a36 docs 2025-02-22 13:19:32 +11:00
jokob-sk
04ab1d1fb3 docs 2025-02-22 12:56:15 +11:00
jokob-sk
268ce870a3 docs 2025-02-22 12:48:25 +11:00
jokob-sk
cda1d8b877 docs 2025-02-22 12:22:02 +11:00
jokob-sk
a68aa0bc57 docs 2025-02-22 12:17:00 +11:00
jokob-sk
7f2a1740cc docs 2025-02-22 09:56:36 +11:00
jokob-sk
3ba5c70045 docs 2025-02-22 09:54:43 +11:00
jokob-sk
fec18daab4 Merge branch 'main' of https://github.com/jokob-sk/NetAlertX 2025-02-22 07:52:04 +11:00
jokob-sk
b71037a129 device_tracker MQTT Attributes 2025-02-22 07:51:50 +11:00
Safeguard
4f4ca0cfcb Translated using Weblate (Russian)
Currently translated at 100.0% (754 of 754 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/
2025-02-21 13:02:04 +01:00
anton garcias
adc761a3df Translated using Weblate (Catalan)
Currently translated at 100.0% (754 of 754 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/ca/
2025-02-20 04:02:33 +01:00
jokob-sk
458577e071 mqtt and newdev name regex 2025-02-20 07:57:28 +11:00
jokob-sk
9d4eafea42 Merge branch 'main' of https://github.com/jokob-sk/NetAlertX 2025-02-20 06:38:43 +11:00
jokob-sk
ac8f48c78e ASUS DHCPLSS guide 2025-02-20 06:37:50 +11:00
Patrick Seidel
2000a4291b Translated using Weblate (German)
Currently translated at 90.4% (682 of 754 strings)

Translation: NetAlertX/core
Translate-URL: https://hosted.weblate.org/projects/pialert/core/de/
2025-02-18 22:42:54 +01:00
jokob-sk
97389b988f sync node in MQTT 2025-02-19 08:05:36 +11:00
jokob-sk
daba38ee0a OMADA #997
Some checks failed
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
2025-02-17 13:57:43 +11:00
jokob-sk
03b110950b NTFY token support 2025-02-17 09:03:34 +11:00
jokob-sk
7d9e84668c docs 2025-02-17 08:05:51 +11:00
Jokob @NetAlertX
e37acba4c4 Update DOCKER_COMPOSE.md 2025-02-17 07:09:49 +11:00
Jokob @NetAlertX
3f00c7fc40 Merge pull request #996 from Peter-Maguire/patch-1
Fix spelling mistake in unifi plugin
2025-02-17 07:05:05 +11:00
Peter Maguire
c687128f68 Fix spelling mistake 2025-02-16 12:33:29 +00:00
jokob-sk
8542d51dcf docs
Some checks are pending
docker / docker_dev (push) Waiting to run
Deploy MkDocs / deploy (push) Waiting to run
2025-02-16 13:19:36 +11:00
jokob-sk
c02e725b04 single quote to apostrophe replacement ’ #995 2025-02-16 10:19:24 +11:00
jokob-sk
a0e117f92e modal loop prevention #992 2025-02-16 10:06:34 +11:00
jokob-sk
ffa0457342 docs 2025-02-16 09:54:10 +11:00
jokob-sk
838352388f arpscan readme #867
Some checks are pending
docker / docker_dev (push) Waiting to run
Deploy MkDocs / deploy (push) Waiting to run
2025-02-15 09:34:01 +11:00
jokob-sk
dd01bebadd Omada readme #989
Some checks failed
docker / docker_dev (push) Has been cancelled
Deploy MkDocs / deploy (push) Has been cancelled
2025-02-12 08:14:11 +11:00
56 changed files with 2696 additions and 513 deletions

View File

@@ -1,4 +1,3 @@
---
name: docker
on:
@@ -37,7 +36,7 @@ jobs:
- name: Get release version
id: get_version
run: echo "::set-output name=version::${{ 'Dev' }}"
run: echo "version=Dev" >> $GITHUB_OUTPUT
- name: Create .VERSION file
run: echo "${{ steps.get_version.outputs.version }}" >> .VERSION
@@ -46,13 +45,11 @@ jobs:
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: |
ghcr.io/jokob-sk/netalertx-dev
jokobsk/netalertx-dev
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
@@ -60,24 +57,20 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Log in to Github Container registry
- name: Log in to Github Container Registry (GHCR)
uses: docker/login-action@v3
with:
registry: ghcr.io
username: jokob-sk
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
- name: Log in to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# # Disable this after use
# - name: Prune Docker Builder
# run: docker builder prune --force
- name: Build and push
uses: docker/build-push-action@v3
with:
@@ -86,6 +79,3 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# # ⚠ disable cache if build is failing to download debian packages
# cache-from: type=registry,ref=ghcr.io/jokob-sk/netalertx:buildcache
# cache-to: type=registry,ref=ghcr.io/jokob-sk/netalertx:buildcache,mode=max

View File

@@ -48,8 +48,8 @@ jobs:
with:
# list of Docker images to use as base name for tags
images: |
jokobsk/pi.alert
jokobsk/netalertx
ghcr.io/jokob-sk/netalertx
jokobsk/netalertx
# generate Docker tags based on the following events/attributes
tags: |
type=semver,pattern={{version}},value=${{ inputs.version }}

View File

@@ -1,8 +1,8 @@
# 💾 Backing things up
# Backing things up
> [!NOTE]
> To backup 99% of your configuration backup at least the `/app/config` folder. Please read the whole page (or at least "Scenario 2: Corrupted database") for details.
> Please also note that database definitions might change over versions. The safest way is to restore your older backups into the **same version** of the app and then gradually upgarde between releases to the latest version.
> Note that database definitions might change over time. The safest way is to restore your older backups into the **same version** of the app they were taken from and then gradually upgarde between releases to the latest version.
There are 3 artifacts that can be used to backup the application:
@@ -12,6 +12,46 @@ There are 3 artifacts that can be used to backup the application:
| `/config/app.conf` | Configuration file | Can be overridden with the [`APP_CONF_OVERRIDE` env variable](https://github.com/jokob-sk/NetAlertX/tree/main/dockerfiles#docker-environment-variables). |
| `/config/devices.csv` | CSV file containing device information | Doesn't contain historical data |
## Backup strategies
The safest approach to backups is to backup everything, by taking regular file system backups (I use [Kopia](https://github.com/kopia/kopia)).
Arguably, the most time is spent setting up the device list, so if only one file is kept I'd recommend to have a latest backup of the `devices_<timestamp>.csv` or `devices.csv` file, followed by the `app.conf` file. You can also download `app.conf` and `devices.csv` file in the Maintenance section:
![Backup and Restore Section in Maintenance](./img/BACKUPS/Maintenance_Backup_Restore.png)
### Scenario 1: Full backup
End-result: Full restore
#### 💾 Source artifacts:
- `/app/db/app.db` (uncorrupted)
- `/app/config/app.conf`
#### 📥 Recovery:
To restore the application map the above files as described in the [Setup documentation](https://github.com/jokob-sk/NetAlertX/blob/main/dockerfiles/README.md#docker-paths).
### Scenario 2: Corrupted database
End-result: Partial restore (historical data and some plugin data will be missing)
#### 💾 Source artifacts:
- `/app/config/app.conf`
- `/app/config/devices_<timestamp>.csv` or `/app/config/devices.csv`
#### 📥 Recovery:
Even with a corrupted database you can recover what I would argue is 99% of the configuration.
- upload the `app.conf` file into the mounted `/app/config/` folder as described in the [Setup documentation](https://github.com/jokob-sk/NetAlertX/blob/main/dockerfiles/README.md#docker-paths).
- rename the `devices_<timestamp>.csv` to `devices.csv` and place it in the `/app/config` folder
- Restore the `devices.csv` backup via the [Maintenance section](./DEVICES_BULK_EDITING.md)
## Data and backup storage
To decide on a backup strategy, check where the data is stored:
@@ -44,43 +84,4 @@ Historical data is stored in the `app.db` database (See [Database overview](./DA
- History of Events, Notifications, Workflow Events
- Presence history
## 🧭 Backup strategies
The safest approach to backups is to backup all of the above, by taking regular file system backups (I use [Kopia](https://github.com/kopia/kopia)).
Arguably, the most time is spent setting up the device list, so if only one file is kept I'd recommend to have a latest backup of the `devices_<timestamp>.csv` or `devices.csv` file, followed by the `app.conf` file. You can also download `app.conf` and `devices.csv` file in the Maintenance section:
![Backup and Restore Section in Maintenance](./img/BACKUPS/Maintenance_Backup_Restore.png)
### Scenario 1: Full backup
End-result: Full restore
#### Source artifacts:
- `/app/db/app.db` (uncorrupted)
- `/app/config/app.conf`
#### Recovery:
To restore the application map the above files as described in the [Setup documentation](https://github.com/jokob-sk/NetAlertX/blob/main/dockerfiles/README.md#docker-paths).
### Scenario 2: Corrupted database
End-result: Partial restore (historical data & configurations from the Maintenance section will be missing)
#### Source artifacts:
- `/app/config/app.conf`
- `/app/config/devices_<timestamp>.csv` or `/app/config/devices.csv`
#### Recovery:
Even with a corrupted database you can recover what I would argue is 99% of the configuration.
- upload the `app.conf` file into the mounted `/app/config/` folder as described in the [Setup documentation](https://github.com/jokob-sk/NetAlertX/blob/main/dockerfiles/README.md#docker-paths).
- rename the `devices_<timestamp>.csv` to `devices.csv` and place it in the `/app/config` folder
- Restore the `devices.csv` backup via the [Maintenance section](./DEVICES_BULK_EDITING.md)

View File

@@ -1,6 +1,6 @@
### Loading...
Often if the application is misconfigured the `Loading...` dialog is continuously displayed. This is most likely caused by the backed failing to start. The **Maintenance -> Logs** section should give you more details on what's happenning. If there is no exception, check the Portainer log, or start the container in the foreground (without the `-d` parameter) to observe any exceptions. It's advisable to enable `trace` or `debug`. Check the [Debug tips](./DEBUG_TIPS.md) on detailed instructions.
Often if the application is misconfigured the `Loading...` dialog is continuously displayed. This is most likely caused by the backed failing to start. The **Maintenance -> Logs** section should give you more details on what's happening. If there is no exception, check the Portainer log, or start the container in the foreground (without the `-d` parameter) to observe any exceptions. It's advisable to enable `trace` or `debug`. Check the [Debug tips](./DEBUG_TIPS.md) on detailed instructions.
### Incorrect SCAN_SUBNETS
@@ -8,7 +8,7 @@ One of the most common issues is not configuring `SCAN_SUBNETS` correctly. If th
### Duplicate devices and notifications
The app uses the MAC address as an unique identifier for devices. If a new MAC is detected a new device is added to the application and corresponding notifications are triggered. This means that if the MAC of an existing device changes, the device will be logged as a new device. You can usually prevent this from happenning by changing the device configuration (in Android, iOS, or Windows) for your network. See the [Random Macs](./RANDOM_MAC.md) guide for details.
The app uses the MAC address as an unique identifier for devices. If a new MAC is detected a new device is added to the application and corresponding notifications are triggered. This means that if the MAC of an existing device changes, the device will be logged as a new device. You can usually prevent this from happening by changing the device configuration (in Android, iOS, or Windows) for your network. See the [Random Macs](./RANDOM_MAC.md) guide for details.
### Permissions
@@ -45,4 +45,13 @@ The link above will probably break in time too. Go to https://packages.debian.or
### Only Router and own device show up
Make sure that the subnet and interface in `SCAN_SUBNETS` are correct. If your device/NAS has multiple ethernet ports, you probably need to change `eth0` to something else.
Make sure that the subnet and interface in `SCAN_SUBNETS` are correct. If your device/NAS has multiple ethernet ports, you probably need to change `eth0` to something else.
### Losing my settings and devices after an update
If you lose your devices and/or settings after an update that means you don't have the `/app/db` and `/app/config` folders mapped to a permanent storage. That means every time you update these folders are re-created. Make sure you have the [volumes specified correctly](./DOCKER_COMPOSE.md) in your `docker-compose.yml` or run command.
### The application is slow
Slowness is usually caused by incorrect settings (the app might restart, so check the `app.log`), too many background processes (disable unnecessary scanners), too long scans (limit the number of scanned devices), too many disk operations, or some maintenance plugins might have failed. See the [Performance tips](./PERFORMANCE.md) docs for details.

View File

@@ -28,7 +28,7 @@ docker run --rm --network=host \
If possible, check if your issue got fixed in the `_dev` image before opening a new issue. The container is:
`jokobsk/netalertx-dev:latest`
`ghcr.io/jokob-sk/netalertx-dev:latest`
> ⚠ Please backup your DB and config beforehand!

View File

@@ -7,7 +7,7 @@ services:
netalertx:
container_name: netalertx
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/netalertx:latest"
network_mode: "host"
restart: unless-stopped
@@ -33,12 +33,13 @@ To run the container execute: `sudo docker-compose up -d`
Example by [SeimuS](https://github.com/SeimusS).
```yaml
services:
netalertx:
container_name: NetAlertX
hostname: NetAlertX
privileged: true
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: jokobsk/netalertx:latest
environment:
- TZ=Europe/Bratislava
@@ -60,7 +61,7 @@ services:
netalertx:
container_name: netalertx
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/netalertx:latest"
network_mode: "host"
restart: unless-stopped

21
docs/HELPER_SCRIPTS.md Executable file
View File

@@ -0,0 +1,21 @@
# NetAlertX Community Helper Scripts Overview
This page provides an overview of community-contributed scripts for NetAlertX. These scripts are not actively maintained and are provided as-is.
## Community Scripts
You can find all scripts in this [scripts GitHub folder](https://github.com/jokob-sk/NetAlertX/tree/main/scripts).
| Script Name | Description | Author | Version | Release Date |
|------------|-------------|--------|---------|--------------|
| **New Devices Checkmk Script** | Checks for new devices in NetAlertX and reports status to Checkmk. | N/A | 1.0 | 08-Jan-2025 |
| **DB Cleanup Script** | Queries and removes old device-related entries from the database. | [laxduke](https://github.com/laxduke) | 1.0 | 23-Dec-2024 |
| **OPNsense DHCP Lease Converter** | Retrieves DHCP lease data from OPNsense and converts it to `dnsmasq` format. | [im-redactd](https://github.com/im-redactd) | 1.0 | 24-Feb-2025 |
## Important Notes
> [!NOTE]
> These scripts are community-supplied and not actively maintained. Use at your own discretion.
For detailed usage instructions, refer to each script's documentation in each [scripts GitHub folder](https://github.com/jokob-sk/NetAlertX/tree/main/scripts).

View File

@@ -32,6 +32,7 @@ NetAlertX comes with MQTT support, allowing you to show all detected devices as
- Enable MQTT
- Fill in the details from above
- Fill in remaining settings as per description
- set MQTT_RUN to schedule or on_notification depending on requirements
![Configuration Example][configuration]

View File

@@ -40,7 +40,7 @@ Copying the HTML code from [Font Awesome](https://fontawesome.com/search?o=r&m=f
- The dropdown contains all icons already used in the app for device icons. You might need to navigate away or refresh the page once you add a new icon.
## 🌟 Pro Font Awesome icons
## Font Awesome Pro icons
If you own the premium package of Font Awesome icons you can mount it in your Docker container the following way:

View File

@@ -55,7 +55,7 @@ services:
pialert:
container_name: pialert
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/pialert:latest"
network_mode: "host"
restart: unless-stopped
@@ -77,7 +77,7 @@ services:
netalertx: # ⚠ This has changed (🟡optional)
container_name: netalertx # ⚠ This has changed (🟡optional)
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/netalertx:latest" # ⚠ This has changed (🟡optional/🔺required in future)
network_mode: "host"
restart: unless-stopped
@@ -105,7 +105,7 @@ services:
pialert:
container_name: pialert
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/pialert:latest"
network_mode: "host"
restart: unless-stopped
@@ -127,7 +127,7 @@ services:
netalertx: # ⚠ This has changed (🟡optional)
container_name: netalertx # ⚠ This has changed (🟡optional)
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/netalertx:latest" # ⚠ This has changed (🟡optional/🔺required in future)
network_mode: "host"
restart: unless-stopped

View File

@@ -1,59 +1,96 @@
# Performance tips
# Performance Optimization Guide
The application runs regular maintenance and DB cleanup tasks. If these tasks fail, you might encounter performance issues.
There are several ways to improve the application's performance. The application has been tested on a range of devices, from a Raspberry Pi 4 to NAS and NUC systems. If you are running the application on a lower-end device, carefully fine-tune the performance settings to ensure an optimal user experience.
Most performance issues are caused by a big database or large log files. Enabling unnecessary plugins will also lead to performance degradation.
## Common Causes of Slowness
You can always check the size of your database and database tables under the Maintenance page.
Performance issues are usually caused by:
![Db size check](./img/PERFORMANCE/db_size_check.png)
- **Incorrect settings** The app may restart unexpectedly. Check `app.log` under **Maintenance → Logs** for details.
- **Too many background processes** Disable unnecessary scanners.
- **Long scan durations** Limit the number of scanned devices.
- **Excessive disk operations** Optimize scanning and logging settings.
- **Failed maintenance plugins** Ensure maintenance tasks are running properly.
The application performs regular maintenance and database cleanup. If these tasks fail, performance may degrade.
### Database and Log File Size
A large database or oversized log files can slow down performance. You can check database and table sizes on the **Maintenance** page.
![DB size check](./img/PERFORMANCE/db_size_check.png)
> [!NOTE]
> For around 100 devices the database should be approximately `50MB` and none of the entries (rows) should exceed the value of `10 000` on a healthy system. These numbers will depend on your network activity and settings.
> - For **~100 devices**, the database should be around **50MB**.
> - No table should exceed **10,000 rows** in a healthy system.
> - These numbers vary based on network activity and settings.
## Maintenance plugins
---
There are 2 plugins responsible for maintaining the overal health of the application. One is responsible for the database cleanup and one for other tasks, such as log cleanup.
## Maintenance Plugins
### DB Cleanup (DBCLNP)
Two plugins help maintain the applications performance:
The database cleanup plugin. Check details and related setting in the [DB Cleanup plugin docs](/front/plugins/db_cleanup/README.md). Make sure the plugin is not failing by checking the logs. Try changing the schedule `DBCLNP_RUN_SCHD` and the timeout `DBCLNP_RUN_TIMEOUT` (increase) if the plugin is failing to execute.
### **1. Database Cleanup (DBCLNP)**
- Responsible for database maintenance.
- Check settings in the [DB Cleanup Plugin Docs](/front/plugins/db_cleanup/README.md).
- Ensure its not failing by checking logs.
- Adjust the schedule (`DBCLNP_RUN_SCHD`) and timeout (`DBCLNP_RUN_TIMEOUT`) if needed.
### Maintenance (MAINT)
### **2. Maintenance (MAINT)**
- Handles log cleanup and other maintenance tasks.
- Check settings in the [Maintenance Plugin Docs](/front/plugins/maintenance/README.md).
- Ensure its running correctly by checking logs.
- Adjust the schedule (`MAINT_RUN_SCHD`) and timeout (`MAINT_RUN_TIMEOUT`) if needed.
The maintenance plugin. Check details and related setting in the [Maintenance plugin docs](/front/plugins/maintenance/README.md). Make sure the plugin is not failing by checking the logs. Try changing the schedule `MAINT_RUN_SCHD` and the timeout `MAINT_RUN_TIMEOUT` (increase) if the plugin is failing to execute.
---
## Scan frequency and coverage
## Scan Frequency and Coverage
The more often you scan the networks the more resources, traffic and DB read/write cycles are executed. Especially on busy networks and lower end hardware, consider increasing scan intervals (`<PLUGIN>_RUN_SCHD`) and timeouts (`<PLUGIN>_RUN_TIMEOUT`).
Frequent scans increase resource usage, network traffic, and database read/write cycles.
Also consider decreasing the scanned subnet, e.g. from `/16` to `/24` if need be.
### **Optimizations**
- **Increase scan intervals** (`<PLUGIN>_RUN_SCHD`) on busy networks or low-end hardware.
- **Extend scan timeouts** (`<PLUGIN>_RUN_TIMEOUT`) to prevent failures.
- **Reduce the subnet size** e.g., from `/16` to `/24` to lower scan loads.
# Store temporary files in memory
Some plugins have additional options to limit the number of scanned devices. If certain plugins take too long to complete, check if you can optimize scan times by selecting a scan range.
For example, the **ICMP plugin** allows you to specify a regular expression to scan only IPs that match a specific pattern.
---
## Storing Temporary Files in Memory
On systems with slower I/O speeds, you can optimize performance by storing temporary files in memory. This primarily applies to the `/app/api` and `/app/log` folders.
Using `tmpfs` reduces disk writes and improves performance. However, it should be **disabled** if persistent logs or API data storage are required.
Below is an optimized `docker-compose.yml` snippet:
You can also store temporary files in application memory (`/app/api` and `/app/log` folders). See highlighted lines `◀` below.
```yaml
version: "3"
services:
netalertx:
container_name: netalertx
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# Uncomment the line below to test the latest dev image
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/netalertx:latest"
network_mode: "host"
restart: unless-stopped
volumes:
- local/path/config:/app/config
- local/path/db:/app/db
# (optional) useful for debugging if you have issues setting up the container
# (Optional) Useful for debugging setup issues
- local/path/logs:/app/log
# (API: OPTION 1) use for performance
- type: tmpfs # ◀
target: /app/api # ◀
# (API: OPTION 2) use when debugging issues
# - local/path/api:/app/api
# (API: OPTION 1) Store temporary files in memory (recommended for performance)
- type: tmpfs # ◀
target: /app/api # ◀
# (API: OPTION 2) Store API data on disk (useful for debugging)
# - local/path/api:/app/api
environment:
- TZ=Europe/Berlin
- PORT=20211
```

View File

@@ -1,9 +1,9 @@
# 🔌 Plugins
NetAlertX supports additional plugins to extend its functionality, each with its own settings and options. Plugins can be loaded via the General -> `LOADED_PLUGINS` setting. For custom plugin development, refer to the [Plugin development guide](/docs/PLUGINS_DEV.md).
NetAlertX supports additional plugins to extend its functionality, each with its own settings and options. Plugins can be loaded via the General -> `LOADED_PLUGINS` setting. For custom plugin development, refer to the [Plugin development guide](./PLUGINS_DEV.md).
>[!NOTE]
> Please check this [Plugins debugging guide](https://github.com/jokob-sk/NetAlertX/blob/main/docs/DEBUG_PLUGINS.md) and the corresponding Plugin documentation in the below table if you are facing issues.
> Please check this [Plugins debugging guide](./DEBUG_PLUGINS.md) and the corresponding Plugin documentation in the below table if you are facing issues.
## ⚡ Quick start
@@ -12,10 +12,10 @@ NetAlertX supports additional plugins to extend its functionality, each with its
1. Pick your `🔍 dev scanner` plugin (e.g. `ARPSCAN` or `NMAPDEV`), or import devices into the application with an `📥 importer` plugin. (See **✅Enabling plugins** below)
2. Pick a `▶️ publisher` plugin, if you want to send notifications. If you don't see a publisher you'd like to use, look at the [📚_publisher_apprise](/front/plugins/_publisher_apprise/) plugin which is a proxy for over 80 notification services.
3. Setup your [Network topology diagram](/docs/NETWORK_TREE.md)
4. Fine-tune [Notifications](/docs/NOTIFICATIONS.md)
5. [Backup your setup](/docs/BACKUPS.md)
6. Contribute and [Create custom plugins](/docs/PLUGINS_DEV.md)
3. Setup your [Network topology diagram](./NETWORK_TREE.md)
4. Fine-tune [Notifications](./NOTIFICATIONS.md)
5. [Backup your setup](./BACKUPS.md)
6. Contribute and [Create custom plugins](./PLUGINS_DEV.md)
## 📑 Available Plugins
@@ -51,6 +51,7 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T
| `NTFPRCS` | ⚙ | Notification processing | | Yes | Template | [notification_processing](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/notification_processing/)|
| `NTFY` | ▶️ | NTFY notifications | | | Script | [_publisher_ntfy](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_ntfy/) |
| `OMDSDN` | 📥/🆎 | OMADA TP-Link import | 🖧 🔄 | | Script | [omada_sdn_imp](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_imp/) |
| `OMDSDNOPENAPI`| 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | | Script | [omada_sdn_openapi](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_openapi/) |
| `PIHOLE` | 🔍/🆎/📥| Pi-hole device import & sync | | | SQLite DB | [pihole_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_scan/) |
| `PUSHSAFER` | ▶️ | Pushsafer notifications | | | Script | [_publisher_pushsafer](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushsafer/) |
| `PUSHOVER` | ▶️ | Pushover notifications | | | Script | [_publisher_pushover](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushover/) |
@@ -74,7 +75,7 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T
## Plugin types
| Plugin type | Icon | Description | When to run | Required | Data source [?](/docs/PLUGINS_DEV.md) |
| Plugin type | Icon | Description | When to run | Required | Data source [?](./PLUGINS_DEV.md) |
| -------------- | ---- | ---------------------------------------------------------------- | ----------------------------------- | -------- | ------------------------------------- |
| publisher | ▶️ | Sending notifications to services. | `on_notification` | ✖ | Script |
| dev scanner | 🔍 | Create devices in the app, manages online/offline device status. | `schedule` | ✖ | Script / SQLite DB |
@@ -109,4 +110,4 @@ Plugins can be enabled via Settings, and can be disabled as needed.
## 🆕 Developing new custom plugins
If you want to develop a custom plugin, please read this [Plugin development guide](/docs/PLUGINS_DEV.md).
If you want to develop a custom plugin, please read this [Plugin development guide](./PLUGINS_DEV.md).

View File

@@ -1,4 +1,4 @@
## 🌟 Create a custom plugin: Overview
# Creating a custom plugin
NetAlertX comes with a plugin system to feed events from third-party scripts into the UI and then send notifications, if desired. The highlighted core functionality this plugin system supports, is:

View File

@@ -8,7 +8,6 @@ If you are running a DNS server, such as **AdGuard**, set up **Private reverse D
> ```
> jokob@Synology-NAS:/$ nslookup 192.168.1.58
> ** server can't find 58.1.168.192.in-addr.arpa: NXDOMAIN
>
> ```
> Example 2: Reverse DNS `enabled`

29
docs/SECURITY.md Executable file
View File

@@ -0,0 +1,29 @@
# Securing your NetAlertX instance
NetAlertX is an execution framework. In order to run scanners and plugins, the application has to have access to privileged system resources. It is not recommended to expose NetAlertX to the internet without taking basic security precautions. It is highly recommended to use a VPN to access the application and to set up a password for the web interface before exposing the UI online.
## VPN
VPNs allow you to securely access your NetAlertX instance from remote locations without exposing it to the internet. A VPN encrypts your connection and prevents unauthorized access.
### Tailscale as an Alternative
If setting up a traditional VPN is not ideal, you can use [Tailscale](https://tailscale.com/) as an easy alternative. Tailscale creates a secure, encrypted connection between your devices without complex configuration. Since NetAlertX is designed to be run on private networks, Tailscale can provide a simple way to securely connect to your instance from anywhere.
## Setting a Password
By default, NetAlertX does not enforce authentication, but it is highly recommended to set a password before exposing the web interface.
Configure `SETPWD_enable_password` to `true` and enter your password in `SETPWD_password`. When enabled, a login dialog is displayed. If facing issues, you can always disable the login by setting `SETPWD_enable_password=false` in your `app.conf` file.
- The default password is `123456`.
- Passwords are stored as SHA256 hashes for security.
## Additional Security Measures
- **Firewall Rules**: Ensure that only trusted IPs can access the NetAlertX instance.
- **Limit Plugin Permissions**: Only enable the plugins necessary for your setup.
- **Keep Software Updated**: Regularly update NetAlertX to receive the latest security patches.
- **Use Read-Only API Keys**: If exposing APIs, limit privileges with read-only keys where applicable.
By following these security recommendations, you can help protect your NetAlertX instance from unauthorized access and potential misuse.

View File

@@ -35,7 +35,7 @@ services:
netalertx:
container_name: netalertx
# use the below line if you want to test the latest dev image
# image: "jokobsk/netalertx-dev:latest"
# image: "ghcr.io/jokob-sk/netalertx-dev:latest"
image: "jokobsk/netalertx:latest"
network_mode: "host"
restart: unless-stopped

View File

@@ -1,6 +1,6 @@
# Docker Update Strategies for NetAlertX
# Docker Update Strategies to upgrade NetAlertX
This guide outlines several approaches for updating Docker containers, specifically using NetAlertX. Each method offers different benefits depending on the situation. Here are the methods:
This guide outlines approaches for updating Docker containers, usually when upgrading to a newer version of NetAlertX. Each method offers different benefits depending on the situation. Here are the methods:
- Manual: Direct commands to stop, remove, and rebuild containers.
- Dockcheck: Semi-automated with more control, suited for bulk updates.
@@ -10,6 +10,9 @@ You can choose any approach that fits your workflow.
> In the examples I assume that the container name is `netalertx` and the image name is `netalertx` as well.
> [!NOTE]
> See also [Backup strategies](./BACKUPS.md) to be on the safe side.
## 1. Manual Updates
Use this method when you need precise control over a single container or when dealing with a broken container that needs immediate attention.

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
docs/img/netalertx_docs.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -7,7 +7,7 @@ Welcome to the official NetAlertX documentation! NetAlertX is a powerful tool de
NetAlertX provides contextual help within the application:
- **Hover over settings, fields, or labels** to see additional tooltips and guidance.
- **Click the blue ❔ (question-mark) icons** next to various elements to view detailed information.
- **Click ❔ (question-mark) icons** next to various elements to view detailed information.
- Access the in-app **Help / FAQ** section for frequently asked questions and quick answers.
---

28
docs/overrides/main.html Executable file
View File

@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block analytics %}
<!-- Google Tag Manager -->
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-KCRSGLP8J2"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-KCRSGLP8J2');
</script>
<!-- End Google Tag Manager -->
{{ super() }}
{% endblock %}
{% block header %}
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-KCRSGLP8J2"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
{{ super() }}
{% endblock %}

View File

@@ -361,14 +361,14 @@
// Update data to server using POST
$.post('php/server/devices.php?action=setDeviceData', {
mac: $('#NEWDEV_devMac').val(),
name: encodeURIComponent($('#NEWDEV_devName').val().replace(/'/g, "")),
owner: encodeURIComponent($('#NEWDEV_devOwner').val().replace(/'/g, "")),
name: encodeURIComponent($('#NEWDEV_devName').val().replace(/'/g, "")),
owner: encodeURIComponent($('#NEWDEV_devOwner').val().replace(/'/g, "")),
type: $('#NEWDEV_devType').val().replace(/'/g, ""),
vendor: encodeURIComponent($('#NEWDEV_devVendor').val().replace(/'/g, "")),
vendor: encodeURIComponent($('#NEWDEV_devVendor').val().replace(/'/g, "")),
icon: encodeURIComponent($('#NEWDEV_devIcon').val()),
favorite: ($('#NEWDEV_devFavorite')[0].checked * 1),
group: encodeURIComponent($('#NEWDEV_devGroup').val().replace(/'/g, "")),
location: encodeURIComponent($('#NEWDEV_devLocation').val().replace(/'/g, "")),
group: encodeURIComponent($('#NEWDEV_devGroup').val().replace(/'/g, "")),
location: encodeURIComponent($('#NEWDEV_devLocation').val().replace(/'/g, "")),
comments: encodeURIComponent(encodeSpecialChars($('#NEWDEV_devComments').val())),
networknode: $('#NEWDEV_devParentMAC').val(),
networknodeport: $('#NEWDEV_devParentPort').val(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
front/img/netalertx_docs.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

377
front/img/svg/netalertx_docs.svg Executable file
View File

@@ -0,0 +1,377 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200"
height="200"
viewBox="0 0 52.916667 52.916668"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (b8e25be833, 2022-02-05)"
sodipodi:docname="netalertx_docs.svg"
inkscape:export-filename="C:\Users\jokob\netalertx_docs.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="2.8284271"
inkscape:cx="72.124892"
inkscape:cy="128.33988"
inkscape:window-width="3378"
inkscape:window-height="1417"
inkscape:window-x="54"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer6"
units="px"
width="50px" />
<defs
id="defs2">
<inkscape:path-effect
effect="powermask"
id="path-effect51283"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect51283"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<inkscape:path-effect
effect="powermask"
id="path-effect51278"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect51278"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<inkscape:path-effect
effect="powermask"
id="path-effect51273"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect51273"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<inkscape:path-effect
effect="powermask"
id="path-effect48754"
is_visible="true"
lpeversion="1"
uri="#mask-powermask-path-effect48754"
invert="false"
hide_mask="false"
background="true"
background_color="#ffffffff" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath48972">
<path
style="fill:#000000;stroke-width:0.280643"
id="path48974"
width="56.128242"
height="56.128246"
x="-18.924671"
y="-56.198174"
transform="rotate(45.438374)"
mask="none"
sodipodi:type="rect" />
</clipPath>
<mask
maskUnits="userSpaceOnUse"
id="mask49405">
<text
xml:space="preserve"
style="font-size:60.8695px;line-height:1.25;font-family:Amiri;-inkscape-font-specification:Amiri;display:inline;stroke-width:1.52174"
x="66.930733"
y="78.642288"
id="text49409"
transform="scale(1.4861626,0.67287388)"><tspan
sodipodi:role="line"
id="tspan49407"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Tw Cen MT';-inkscape-font-specification:'Tw Cen MT';fill:#ffffff;stroke-width:1.52174"
x="66.930733"
y="78.642288">A</tspan></text>
</mask>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath50306">
<circle
style="mix-blend-mode:normal;fill:#d40000;stroke-width:0.176318"
id="circle50308"
cy="26.458334"
cx="26.458334"
r="26.458334"
clip-path="url(#clipPath48972)"
transform="matrix(1.0038771,0,0.00391255,1.0073928,-0.04603368,-0.1228191)" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath48972-7">
<path
style="fill:#000000;stroke-width:0.280643"
id="path48974-5"
width="56.128242"
height="56.128246"
x="-18.924671"
y="-56.198174"
transform="rotate(45.438374)"
mask="none"
sodipodi:type="rect" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath50306-6">
<circle
style="mix-blend-mode:normal;fill:#d40000;stroke-width:0.176318"
id="circle50308-5"
cy="26.458334"
cx="26.458334"
r="26.458334"
clip-path="url(#clipPath48972)"
transform="matrix(1.0038771,0,0.00391255,1.0073928,-0.04603368,-0.1228191)" />
</clipPath>
<mask
maskUnits="userSpaceOnUse"
id="mask-powermask-path-effect51273">
<path
id="mask-powermask-path-effect51273_box"
style="fill:#ffffff;fill-opacity:1"
d="m 71.788348,33.677177 h 2.00083 v 2.173766 h -2.00083 z" />
<path
style="fill:#000000"
id="path51263"
sodipodi:type="arc"
sodipodi:cx="66.211845"
sodipodi:cy="37.490814"
sodipodi:rx="3.9464016"
sodipodi:ry="1.4616301"
sodipodi:start="0"
sodipodi:end="0.031086059"
sodipodi:open="true"
sodipodi:arc-type="arc"
d="m 70.158247,37.490814 a 3.9464016,1.4616301 0 0 1 -0.0019,0.04543" />
</mask>
<mask
maskUnits="userSpaceOnUse"
id="mask-powermask-path-effect51278">
<path
style="fill:#000000"
id="path51267"
sodipodi:type="arc"
sodipodi:cx="66.211845"
sodipodi:cy="37.490814"
sodipodi:rx="3.9464016"
sodipodi:ry="1.4616301"
sodipodi:start="0"
sodipodi:end="0.031086059"
sodipodi:open="true"
sodipodi:arc-type="arc" />
</mask>
<mask
maskUnits="userSpaceOnUse"
id="mask-powermask-path-effect51283">
<path
style="fill:#000000"
id="path51271"
sodipodi:type="arc"
sodipodi:cx="66.211845"
sodipodi:cy="37.490814"
sodipodi:rx="3.9464016"
sodipodi:ry="1.4616301"
sodipodi:start="0"
sodipodi:end="0.031086059"
sodipodi:open="true"
sodipodi:arc-type="arc" />
</mask>
<filter
id="mask-powermask-path-effect51273_inverse"
inkscape:label="filtermask-powermask-path-effect51273"
style="color-interpolation-filters:sRGB"
height="100"
width="100"
x="-50"
y="-50">
<feColorMatrix
id="mask-powermask-path-effect51273_primitive1"
values="1"
type="saturate"
result="fbSourceGraphic" />
<feColorMatrix
id="mask-powermask-path-effect51273_primitive2"
values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0 "
in="fbSourceGraphic" />
</filter>
</defs>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Red 1"
style="display:none">
<circle
style="fill:#ff2a2a;stroke-width:0.176318"
id="path31-8"
cy="26.458334"
cx="26.458334"
r="26.458334" />
</g>
<g
inkscape:label="Black"
inkscape:groupmode="layer"
id="layer1"
style="display:inline">
<ellipse
style="fill:#000000;stroke-width:0.176146"
id="path31"
cy="26.51001"
cx="26.458334"
rx="26.458334"
ry="26.406658" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="A - Layer 2"
style="display:none">
<rect
style="fill:#ffffff;stroke-width:0.328992"
id="rect48998"
width="26.0966"
height="6.0620313"
x="13.255443"
y="41.262722" />
</g>
<g
inkscape:groupmode="layer"
id="g48055"
inkscape:label="Red top"
style="display:none;mix-blend-mode:normal">
<circle
style="mix-blend-mode:normal;fill:#d40000;stroke-width:0.176318"
id="circle48752"
cy="26.458334"
cx="26.458334"
r="26.458334"
clip-path="url(#clipPath48972)"
transform="matrix(1.0038771,0,0.00391255,1.0073928,-0.04603368,-0.1228191)" />
<ellipse
style="display:inline;mix-blend-mode:normal;fill:#000000;stroke-width:0.43638"
id="path50080"
clip-path="url(#clipPath50306)"
ry="13.739323"
rx="16.735666"
cy="22.874514"
cx="26.36149"
transform="translate(0,0.09980904)" />
<path
style="fill:#000000"
id="path51325"
sodipodi:type="arc"
sodipodi:cx="16.772207"
sodipodi:cy="26.090099"
sodipodi:rx="4.1291056"
sodipodi:ry="7.6004772"
sodipodi:start="0"
sodipodi:end="0.031086059"
sodipodi:arc-type="slice"
d="m 20.901313,26.090099 a 4.1291056,7.6004772 0 0 1 -0.002,0.236231 l -4.127111,-0.236231 z" />
<path
style="fill:#d40000"
id="path51717"
sodipodi:type="arc"
sodipodi:cx="26.441042"
sodipodi:cy="-26.531424"
sodipodi:rx="10.418671"
sodipodi:ry="9.5820541"
sodipodi:start="0.82219863"
sodipodi:end="2.3054129"
sodipodi:arc-type="slice"
d="m 33.532115,-19.511189 a 10.418671,9.5820541 0 0 1 -14.074736,0.09049 l 6.983663,-7.110726 z"
transform="matrix(1,0,0.0048047,-0.99998846,0,0)" />
<path
style="fill:#ffffff;stroke-width:0.276214"
d="M 145.28835,50.354872 C 127.01317,34.62734 98.057144,30.012421 73.710372,38.947003 c -6.518003,2.391924 -14.288822,6.834002 -19.265958,11.01311 -1.198654,1.006465 -2.270358,1.829935 -2.381565,1.829935 -0.111206,0 -5.210052,-5.102002 -11.33077,-11.337781 L 29.603503,29.114489 30.822139,27.851613 c 0.670251,-0.69458 2.51592,-2.384634 4.101489,-3.755674 C 50.725112,10.43241 69.462577,2.3767456 90.736164,0.10085492 95.380582,-0.39601422 106.33043,-0.31105699 111.03786,0.25837091 133.04363,2.9202648 151.46536,11.26468 167.83762,25.986722 l 3.30701,2.97369 -2.29392,2.320103 c -1.26165,1.276057 -6.58213,6.517685 -11.82329,11.648065 l -9.52936,9.327957 z"
id="path52311"
transform="scale(0.26458333)" />
<path
style="fill:#ffffff;stroke-width:0.276214"
d="M 86.538548,86.634546 74.145111,73.25799 74.899337,72.758689 c 4.93766,-3.268754 10.138703,-6.508578 16.602198,-7.437693 5.484021,-0.788317 12.228205,-0.984814 16.377135,-0.09119 6.77689,1.459652 11.87156,4.340971 17.02452,7.792011 l 0.97468,0.652765 -1.37124,1.269268 c -0.86863,0.804036 -6.82647,6.676301 -13.34742,13.259175 L 99.423152,99.796276 Z"
id="path52350"
transform="scale(0.26458333)"
inkscape:export-filename="C:\Users\jokob\path52350.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
sodipodi:nodetypes="ccsssscsscc" />
</g>
<g
inkscape:groupmode="layer"
id="layer6"
inkscape:label="Circle"
style="display:inline">
<path
style="fill:#000000"
id="path50026"
sodipodi:type="arc"
sodipodi:cx="71.071762"
sodipodi:cy="34.677177"
sodipodi:rx="1.7174155"
sodipodi:ry="5.5907354"
sodipodi:start="0"
sodipodi:end="0.031086059"
sodipodi:open="true"
sodipodi:arc-type="arc"
mask="url(#mask-powermask-path-effect51273)"
d="m 72.789178,34.677177 a 1.7174155,5.5907354 0 0 1 -8.3e-4,0.173766"
inkscape:path-effect="#path-effect51273" />
<path
style="fill:#d40000;stroke-width:0.276214"
d="M 86.416478,86.793237 C 73.427951,73.815968 73.387119,73.801376 73.387119,73.801376 c 3.874197,-3.341721 11.025508,-6.981646 17.312424,-8.529335 2.339787,-0.576001 4.881362,-1.25628 8.810591,-1.259564 4.438736,-0.0037 8.292516,0.857843 13.253396,2.535104 4.59135,1.552325 7.8315,3.224336 11.49958,5.934101 l 1.61476,1.192897 -2.31005,2.336325 c -1.27053,1.284978 -7.22284,7.16236 -13.22736,13.060849 L 99.423152,99.796276 C 95.128284,95.409033 87.282899,87.658907 86.416478,86.793237 Z"
id="path52465"
transform="scale(0.26458333)"
sodipodi:nodetypes="sssssscsscs" />
<path
style="fill:#d40000;stroke-width:0.074168"
d="M 38.412677,13.39572 C 34.322163,9.945267 28.437517,8.4874766 22.684204,9.4993379 19.419721,10.073478 16.752307,11.410793 13.835187,13.872492 l -0.14691,0.126732 -0.587936,-0.661605 c -0.268568,-0.30222 -1.619514,-1.65761 -2.963235,-3.048642 L 7.7265561,7.8632145 7.9975963,7.5868118 C 9.8344314,5.713635 13.005888,3.476019 15.380049,2.3878744 20.659765,-0.03196726 26.24205,-0.73479764 31.856076,0.42838695 36.599757,1.4112419 40.746004,3.5106537 44.46876,7.1557672 l 0.709881,0.6950753 -0.663694,0.69037 C 44.080041,8.9935983 42.672626,10.391271 41.3963,11.655819 L 39.075708,13.955 Z"
id="path52504"
inkscape:export-filename="C:\Users\jokob\path52504.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
sodipodi:nodetypes="ssscsccsssscsscs" />
<rect
style="fill:#ffffff;stroke-width:0.270734"
id="rect9599"
width="8.0679188"
height="21.176973"
x="22.265251"
y="30.578777" />
<path
style="fill:#ffffff;fill-opacity:1;stroke-width:0.353553"
d="m 86.613132,86.61313 -12.851203,-12.854821 0.86086,-0.706098 c 1.692083,-1.387887 6.387757,-3.998693 9.623614,-5.350752 7.33291,-3.063958 14.480764,-4.12547 20.582177,-3.056613 3.69356,0.647044 9.99695,2.663626 13.06868,4.180934 2.21267,1.092967 7.61419,4.559526 7.61419,4.886591 0,0.102465 -5.8606,5.939388 -13.02356,12.97094 L 99.464335,99.467952 Z"
id="path13796"
transform="scale(0.26458333)" />
<path
style="fill:#ffffff;fill-opacity:1;stroke-width:0.353553"
d="M 40.562149,41.010783 29.328726,29.577286 33.137528,26.202177 C 50.057066,11.209199 68.487351,2.8161465 89.979339,0.31672328 96.591211,-0.45220831 108.48969,-0.19409453 115.05495,0.86068879 135.48174,4.1424805 152.54396,12.522653 167.06663,26.406419 l 3.39168,3.242463 -11.39113,11.395174 -11.39113,11.395178 -2.86219,-2.330889 C 131.23238,39.047901 112.18782,33.324108 93.81593,34.781043 78.86759,35.966481 67.456828,40.362971 55.747418,49.448575 54.209095,50.642196 52.690616,51.804531 52.37302,52.031537 51.87959,52.384228 50.161133,50.780729 40.562149,41.010783 Z"
id="path13835"
transform="scale(0.26458333)"
inkscape:export-filename="C:\Users\jokob\docs.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -4,262 +4,262 @@
var modalCallbackFunction = "";
function showModalOK(title, message, callbackFunction) {
showModalOk(title, message, callbackFunction);
showModalOk(title, message, callbackFunction);
}
function showModalOk(title, message, callbackFunction) {
// set captions
$("#modal-ok-title").html(title);
$("#modal-ok-message").html(message);
// set captions
$("#modal-ok-title").html(title);
$("#modal-ok-message").html(message);
if (callbackFunction != null) {
$("#modal-ok-OK").click(function () {
callbackFunction();
});
}
if (callbackFunction != null) {
$("#modal-ok-OK").click(function () {
callbackFunction();
});
}
// Show modal
$("#modal-ok").modal("show");
// Show modal
$("#modal-ok").modal("show");
}
// -----------------------------------------------------------------------------
function showModalDefault(title, message, btnCancel, btnOK, callbackFunction) {
// set captions
$("#modal-default-title").html(title);
$("#modal-default-message").html(message);
$("#modal-default-cancel").html(btnCancel);
$("#modal-default-OK").html(btnOK);
modalCallbackFunction = callbackFunction;
// set captions
$("#modal-default-title").html(title);
$("#modal-default-message").html(message);
$("#modal-default-cancel").html(btnCancel);
$("#modal-default-OK").html(btnOK);
modalCallbackFunction = callbackFunction;
// Show modal
$("#modal-default").modal("show");
// Show modal
$("#modal-default").modal("show");
}
// -----------------------------------------------------------------------------
function showModalDefaultStrParam(
title,
message,
btnCancel,
btnOK,
callbackFunction,
param = ""
title,
message,
btnCancel,
btnOK,
callbackFunction,
param = ""
) {
// set captions
$("#modal-str-title").html(title);
$("#modal-str-message").html(message);
$("#modal-str-cancel").html(btnCancel);
$("#modal-str-OK").html(btnOK);
$("#modal-str-OK").off("click"); //remove existing handlers
$("#modal-str-OK").on("click", function () {
$("#modal-str").modal("hide");
callbackFunction(param);
});
// set captions
$("#modal-str-title").html(title);
$("#modal-str-message").html(message);
$("#modal-str-cancel").html(btnCancel);
$("#modal-str-OK").html(btnOK);
$("#modal-str-OK").off("click"); //remove existing handlers
$("#modal-str-OK").on("click", function () {
$("#modal-str").modal("hide");
callbackFunction(param);
});
// Show modal
$("#modal-str").modal("show");
// Show modal
$("#modal-str").modal("show");
}
// -----------------------------------------------------------------------------
function showModalWarning(
title,
message,
btnCancel = getString("Gen_Cancel"),
btnOK = getString("Gen_Okay"),
callbackFunction = null,
triggeredBy = null
title,
message,
btnCancel = getString("Gen_Cancel"),
btnOK = getString("Gen_Okay"),
callbackFunction = null,
triggeredBy = null
) {
// set captions
$("#modal-warning-title").html(title);
$("#modal-warning-message").html(message);
$("#modal-warning-cancel").html(btnCancel);
$("#modal-warning-OK").html(btnOK);
// set captions
$("#modal-warning-title").html(title);
$("#modal-warning-message").html(message);
$("#modal-warning-cancel").html(btnCancel);
$("#modal-warning-OK").html(btnOK);
if (callbackFunction != null) {
modalCallbackFunction = callbackFunction;
}
if (callbackFunction != null) {
modalCallbackFunction = callbackFunction;
}
if (triggeredBy != null) {
$('#'+prefix).attr("data-myparam-triggered-by", triggeredBy)
}
if (triggeredBy != null) {
$('#'+prefix).attr("data-myparam-triggered-by", triggeredBy)
}
// Show modal
$("#modal-warning").modal("show");
// Show modal
$("#modal-warning").modal("show");
}
// -----------------------------------------------------------------------------
function showModalInput(
title,
message,
btnCancel = getString("Gen_Cancel"),
btnOK = getString("Gen_Okay"),
callbackFunction = null,
triggeredBy = null
title,
message,
btnCancel = getString("Gen_Cancel"),
btnOK = getString("Gen_Okay"),
callbackFunction = null,
triggeredBy = null
) {
prefix = "modal-input";
prefix = "modal-input";
// set captions
$(`#${prefix}-title`).html(title);
$(`#${prefix}-message`).html(message);
$(`#${prefix}-cancel`).html(btnCancel);
$(`#${prefix}-OK`).html(btnOK);
// set captions
$(`#${prefix}-title`).html(title);
$(`#${prefix}-message`).html(message);
$(`#${prefix}-cancel`).html(btnCancel);
$(`#${prefix}-OK`).html(btnOK);
if (callbackFunction != null) {
modalCallbackFunction = callbackFunction;
}
if (callbackFunction != null) {
modalCallbackFunction = callbackFunction;
}
if (triggeredBy != null) {
$('#'+prefix).attr("data-myparam-triggered-by", triggeredBy)
}
if (triggeredBy != null) {
$('#'+prefix).attr("data-myparam-triggered-by", triggeredBy)
}
// Show modal
$(`#${prefix}`).modal("show");
// Show modal
$(`#${prefix}`).modal("show");
setTimeout(function () {
$(`#${prefix}-textarea`).focus();
}, 500);
setTimeout(function () {
$(`#${prefix}-textarea`).focus();
}, 500);
}
// -----------------------------------------------------------------------------
function showModalFieldInput(
title,
message,
btnCancel = getString("Gen_Cancel"),
btnOK = getString("Gen_Okay"),
curValue = "",
callbackFunction = null,
triggeredBy = null
title,
message,
btnCancel = getString("Gen_Cancel"),
btnOK = getString("Gen_Okay"),
curValue = "",
callbackFunction = null,
triggeredBy = null
) {
// set captions
prefix = "modal-field-input";
// set captions
prefix = "modal-field-input";
$(`#${prefix}-title`).html(title);
$(`#${prefix}-message`).html(message);
$(`#${prefix}-cancel`).html(btnCancel);
$(`#${prefix}-OK`).html(btnOK);
$(`#${prefix}-title`).html(title);
$(`#${prefix}-message`).html(message);
$(`#${prefix}-cancel`).html(btnCancel);
$(`#${prefix}-OK`).html(btnOK);
if (callbackFunction != null) {
modalCallbackFunction = callbackFunction;
}
if (callbackFunction != null) {
modalCallbackFunction = callbackFunction;
}
if (triggeredBy != null) {
$('#'+prefix).attr("data-myparam-triggered-by", triggeredBy)
}
if (triggeredBy != null) {
$('#'+prefix).attr("data-myparam-triggered-by", triggeredBy)
}
$(`#${prefix}-field`).val(curValue);
$(`#${prefix}-field`).val(curValue);
setTimeout(function () {
$(`#${prefix}-field`).focus();
}, 500);
setTimeout(function () {
$(`#${prefix}-field`).focus();
}, 500);
// Show modal
$(`#${prefix}`).modal("show");
// Show modal
$(`#${prefix}`).modal("show");
}
// -----------------------------------------------------------------------------
function modalDefaultOK() {
// Hide modal
$("#modal-default").modal("hide");
// Hide modal
$("#modal-default").modal("hide");
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
}
// -----------------------------------------------------------------------------
function modalDefaultInput() {
// Hide modal
$("#modal-input").modal("hide");
// Hide modal
$("#modal-input").modal("hide");
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
}
// -----------------------------------------------------------------------------
function modalDefaultFieldInput() {
// Hide modal
$("#modal-field-input").modal("hide");
// Hide modal
$("#modal-field-input").modal("hide");
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
}
// -----------------------------------------------------------------------------
function modalWarningOK() {
// Hide modal
$("#modal-warning").modal("hide");
// Hide modal
$("#modal-warning").modal("hide");
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
// timer to execute function
window.setTimeout(function () {
if (typeof modalCallbackFunction === "function") {
modalCallbackFunction(); // Direct call
} else if (typeof modalCallbackFunction === "string" && typeof window[modalCallbackFunction] === "function") {
window[modalCallbackFunction](); // Call via window
} else {
console.error("Invalid callback function");
}
}, 100);
}
// -----------------------------------------------------------------------------
function showMessage(textMessage = "", timeout = 3000, colorClass = "modal_green") {
if (textMessage.toLowerCase().includes("error")) {
// show error
alert(textMessage);
} else {
// show temporary notification
$("#notification_modal").removeClass(); // remove all classes
$("#notification_modal").addClass("alert alert-dimissible notification_modal"); // add default ones
$("#notification_modal").addClass(colorClass); // add color modifiers
if (textMessage.toLowerCase().includes("error")) {
// show error
alert(textMessage);
} else {
// show temporary notification
$("#notification_modal").removeClass(); // remove all classes
$("#notification_modal").addClass("alert alert-dimissible notification_modal"); // add default ones
$("#notification_modal").addClass(colorClass); // add color modifiers
// message
$("#alert-message").html(textMessage);
// message
$("#alert-message").html(textMessage);
// timeout
$("#notification_modal").fadeIn(1, function () {
window.setTimeout(function () {
$("#notification_modal").fadeOut(500);
}, timeout);
});
}
// timeout
$("#notification_modal").fadeIn(1, function () {
window.setTimeout(function () {
$("#notification_modal").fadeOut(500);
}, timeout);
});
}
}
// -----------------------------------------------------------------------------
function showTickerAnnouncement(textMessage = "") {
if (textMessage.toLowerCase().includes("error")) {
// show error
alert(textMessage);
} else {
// show permanent notification
$("#ticker-message").html(textMessage);
$("#tickerAnnouncement").removeClass("myhidden");
// Move the tickerAnnouncement element to ticker_announcement_plc
$("#tickerAnnouncement").appendTo("#ticker_announcement_plc");
}
if (textMessage.toLowerCase().includes("error")) {
// show error
alert(textMessage);
} else {
// show permanent notification
$("#ticker-message").html(textMessage);
$("#tickerAnnouncement").removeClass("myhidden");
// Move the tickerAnnouncement element to ticker_announcement_plc
$("#tickerAnnouncement").appendTo("#ticker_announcement_plc");
}
}
// -----------------------------------------------------------------------------
@@ -267,30 +267,30 @@ function showTickerAnnouncement(textMessage = "") {
// -----------------------------------------------------------------------------
$(document).ready(function () {
$(document).on("keydown", function (event) {
// ESC key is pressed
if (event.keyCode === 27) {
// Trigger modal dismissal
$(".modal").modal("hide");
}
$(document).on("keydown", function (event) {
// ESC key is pressed
if (event.keyCode === 27) {
// Trigger modal dismissal
$(".modal").modal("hide");
}
// Enter key is pressed
if (event.keyCode === 13) {
$(".modal:visible").find(".btn-modal-submit").click(); // Trigger the click event of the OK button in visible modals
}
});
// Enter key is pressed
if (event.keyCode === 13) {
$(".modal:visible").find(".btn-modal-submit").click(); // Trigger the click event of the OK button in visible modals
}
});
});
// -----------------------------------------------------------------------------
// Escape text
function safeDecodeURIComponent(content) {
try {
return decodeURIComponent(content);
} catch (error) {
console.warn('Failed to decode URI component:', error);
return content; // Return the original content if decoding fails
}
try {
return decodeURIComponent(content);
} catch (error) {
console.warn('Failed to decode URI component:', error);
return content; // Return the original content if decoding fails
}
}
@@ -299,82 +299,83 @@ function safeDecodeURIComponent(content) {
// -----------------------------------------------------------------------------
// Function to check for notifications
function checkNotification() {
const notificationEndpoint = 'php/server/utilNotification.php?action=get_unread_notifications';
const phpEndpoint = 'php/server/utilNotification.php';
const notificationEndpoint = 'php/server/utilNotification.php?action=get_unread_notifications';
const phpEndpoint = 'php/server/utilNotification.php';
$.ajax({
url: notificationEndpoint,
type: 'GET',
success: function(response) {
// console.log(response);
$.ajax({
url: notificationEndpoint,
type: 'GET',
success: function(response) {
// console.log(response);
if(response != "[]")
{
if(response != "[]")
{
// Find the oldest unread notification with level "interrupt"
const oldestInterruptNotification = response.find(notification => notification.read === 0 && notification.level === "interrupt");
const allUnreadNotification = response.filter(notification => notification.read === 0 && notification.level === "alert");
// Find the oldest unread notification with level "interrupt"
const oldestInterruptNotification = response.find(notification => notification.read === 0 && notification.level === "interrupt");
const allUnreadNotification = response.filter(notification => notification.read === 0 && notification.level === "alert");
if (oldestInterruptNotification) {
// Show modal dialog with the oldest unread notification
if (oldestInterruptNotification) {
// Show modal dialog with the oldest unread notification
console.log(oldestInterruptNotification.content);
console.log(oldestInterruptNotification.content);
const decodedContent = safeDecodeURIComponent(oldestInterruptNotification.content);
const decodedContent = safeDecodeURIComponent(oldestInterruptNotification.content);
showModalOK("Notification", decodedContent, function() {
// Mark the notification as read
$.ajax({
url: phpEndpoint,
type: 'GET',
data: {
action: 'mark_notification_as_read',
guid: oldestInterruptNotification.guid
},
success: function(response) {
console.log(response);
// After marking the notification as read, check for the next one
checkNotification();
hideSpinner();
},
error: function(xhr, status, error) {
console.error("Error marking notification as read:", status, error);
},
complete:function() {
hideSpinner();
}
});
});
// only check and display modal if no modal currently displayed to prevent looping
if($("#modal-ok").is(":visible") == false)
{
showModalOK("Notification", decodedContent, function() {
// Mark the notification as read
$.ajax({
url: phpEndpoint,
type: 'GET',
data: {
action: 'mark_notification_as_read',
guid: oldestInterruptNotification.guid
},
success: function(response) {
console.log(response);
// After marking the notification as read, check for the next one
checkNotification();
hideSpinner();
},
error: function(xhr, status, error) {
console.error("Error marking notification as read:", status, error);
},
complete:function() {
hideSpinner();
}
handleUnreadNotifications(allUnreadNotification.length)
}
},
error: function() {
console.warn(`🟥 Error checking ${notificationEndpoint}`)
});
});
}
}
});
handleUnreadNotifications(allUnreadNotification.length)
}
},
error: function() {
console.warn(`🟥 Error checking ${notificationEndpoint}`)
}
});
}
// Handling unread notifications favicon + bell floating number bublbe
function handleUnreadNotifications(count) {
$('#unread-notifications-bell-count').html(count);
if (count > 0) {
$('#unread-notifications-bell-count').show();
// Change the favicon to show there are notifications
$('#favicon').attr('href', 'img/NetAlertX_logo_notification.png');
// Update the title to include the count
document.title = `(${count}) ` + originalTitle;
} else {
$('#unread-notifications-bell-count').hide();
// Change the favicon back to the original
$('#favicon').attr('href', 'img/NetAlertX_logo.png');
// Revert the title to the original title
document.title = originalTitle;
}
$('#unread-notifications-bell-count').html(count);
if (count > 0) {
$('#unread-notifications-bell-count').show();
// Change the favicon to show there are notifications
$('#favicon').attr('href', 'img/NetAlertX_logo_notification.png');
// Update the title to include the count
document.title = `(${count}) ` + originalTitle;
} else {
$('#unread-notifications-bell-count').hide();
// Change the favicon back to the original
$('#favicon').attr('href', 'img/NetAlertX_logo.png');
// Revert the title to the original title
document.title = originalTitle;
}
}
// Store the original title of the document
@@ -392,73 +393,73 @@ const phpEndpoint = 'php/server/utilNotification.php';
// --------------------------------------------------
// Write a notification
function write_notification(content, level) {
function write_notification(content, level) {
$.ajax({
url: phpEndpoint, // Change this to the path of your PHP script
type: 'GET',
data: {
action: 'write_notification',
content: content,
level: level
},
success: function(response) {
console.log('Notification written successfully.');
},
error: function(xhr, status, error) {
console.error('Error writing notification:', error);
}
});
$.ajax({
url: phpEndpoint, // Change this to the path of your PHP script
type: 'GET',
data: {
action: 'write_notification',
content: content,
level: level
},
success: function(response) {
console.log('Notification written successfully.');
},
error: function(xhr, status, error) {
console.error('Error writing notification:', error);
}
});
}
// --------------------------------------------------
// Write a notification
function markNotificationAsRead(guid) {
function markNotificationAsRead(guid) {
$.ajax({
url: phpEndpoint,
type: 'GET',
data: {
action: 'mark_notification_as_read',
guid: guid
},
success: function(response) {
console.log(response);
// Perform any further actions after marking the notification as read here
showMessage(getString("Gen_Okay"))
},
error: function(xhr, status, error) {
console.error("Error marking notification as read:", status, error);
},
complete: function() {
// Perform any cleanup tasks here
}
});
$.ajax({
url: phpEndpoint,
type: 'GET',
data: {
action: 'mark_notification_as_read',
guid: guid
},
success: function(response) {
console.log(response);
// Perform any further actions after marking the notification as read here
showMessage(getString("Gen_Okay"))
},
error: function(xhr, status, error) {
console.error("Error marking notification as read:", status, error);
},
complete: function() {
// Perform any cleanup tasks here
}
});
}
// --------------------------------------------------
// Remove a notification
function removeNotification(guid) {
function removeNotification(guid) {
$.ajax({
url: phpEndpoint,
type: 'GET',
data: {
action: 'remove_notification',
guid: guid
},
success: function(response) {
console.log(response);
// Perform any further actions after marking the notification as read here
showMessage(getString("Gen_Okay"))
},
error: function(xhr, status, error) {
console.error("Error removing notification:", status, error);
},
complete: function() {
// Perform any cleanup tasks here
}
});
$.ajax({
url: phpEndpoint,
type: 'GET',
data: {
action: 'remove_notification',
guid: guid
},
success: function(response) {
console.log(response);
// Perform any further actions after marking the notification as read here
showMessage(getString("Gen_Okay"))
},
error: function(xhr, status, error) {
console.error("Error removing notification:", status, error);
},
complete: function() {
// Perform any cleanup tasks here
}
});
}

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Pots especificar una consulta SQL personalitzada que generarà un fitxer JSON i el mostrarà mitjançant <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> file endpoint</a>.",
"API_CUSTOM_SQL_description": "Pots especificar una consulta SQL personalitzada que generarà un fitxer JSON i el mostrarà mitjançant <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> file endpoint</a>.",
"API_CUSTOM_SQL_name": "Punt final personalitzat",
"API_TOKEN_description": "Token API per assegurar les comunicacions, pots generar-ne un o introduir un valor clau. S'enviarà a la capçalera de la petició <code>SYNC</code> plugin, servidor GraphQL i altres endpoints API. Pots fer servir els endpoints API per crear integracions personalitzades tal com es descriu a <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md\" target=\"_blank\">la documentació API</a>.",
"API_TOKEN_name": "Token API",
@@ -48,7 +48,7 @@
"BackDevices_DBTools_ImportCSVError": "El fitxer CSV no s'ha pogut importar. Comprovi que el format és correcte.",
"BackDevices_DBTools_ImportCSVMissing": "No es pot trobar el fitxer CSV a la ubicació <b>/config/devices.csv.</b>",
"BackDevices_DBTools_Purge": "Les còpies de seguretat més antigues s'han esborrat",
"BackDevices_DBTools_UpdDev": "Dispositiu actualitzat correctament",
"BackDevices_DBTools_UpdDev": "Dispositiu actualitzat amb èxit. La llista de dispositius principals pot necessitar un temps per tornar a carregar si una exploració està en curs.",
"BackDevices_DBTools_UpdDevError": "Error actualitzant el dispositiu",
"BackDevices_DBTools_Upgrade": "Base de dades actualitzada correctament",
"BackDevices_DBTools_UpgradeError": "Actualització de la base de dades fallida",
@@ -328,7 +328,7 @@
"Gen_Upd_Fail": "Actualització fallida",
"Gen_Update": "Actualitza",
"Gen_Update_Value": "Actualitzar Valor",
"Gen_ValidIcon": "",
"Gen_ValidIcon": "<i class=\"fa-solid fa-chevron-right \"></i>",
"Gen_Warning": "Advertència",
"Gen_Work_In_Progress": "Work in progress, un bon moment per retroalimentació a https://github.com/jokob-sk/NetAlertX/issues",
"Gen_create_new_device": "Nou dispositiu",
@@ -753,4 +753,4 @@
"settings_update_item_warning": "Actualitza el valor sota. Sigues curós de seguir el format anterior. <b>No hi ha validació.</b>",
"test_event_icon": "fa-vial-circle-check",
"test_event_tooltip": "Deseu els canvis primer abans de comprovar la configuració."
}
}

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Benutzerdefinierte SQL-Abfrage, welche eine JSON-Datei generiert und diese mit dem <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\">Dateiendpunkt <code>table_custom_endpoint.json</code></a> zur Verfügung stellt.",
"API_CUSTOM_SQL_description": "Benutzerdefinierte SQL-Abfrage, welche eine JSON-Datei generiert und diese mit dem <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\">Dateiendpunkt <code>table_custom_endpoint.json</code></a> zur Verfügung stellt.",
"API_CUSTOM_SQL_name": "Benutzerdefinierte SQL-Abfrage",
"API_TOKEN_description": "API-Token zur Absicherung der Kommunikation Sie können einen generieren oder einen beliebigen Wert eingeben. Er wird im Anfrage-Header übermittelt. Wird im <code>SYNC</code>-Plugin und GraphQL-Server verwendet.",
"API_TOKEN_name": "API-Schlüssel",
@@ -75,8 +75,8 @@
"CustProps_cant_remove": "Kann nicht entfernt werden, es wird mindestens eine Eigenschaft benötigt.",
"DAYS_TO_KEEP_EVENTS_description": "Dies ist eine Wartungseinstellung. Spezifiziert wie viele Tage Events gespeichert bleiben. Alle älteren Events werden periodisch gelöscht. Wird auch auf die Plugins History angewendet.",
"DAYS_TO_KEEP_EVENTS_name": "Ereignisse löschen, die älter sind als",
"DISCOVER_PLUGINS_description": "",
"DISCOVER_PLUGINS_name": "",
"DISCOVER_PLUGINS_description": "Deaktiviere diese Option um die Initialisierung und Speicherdauer der Einstellungen zu verringern. Wenn es deaktiviert ist, können keine Plugins gefunden oder neue Plugins zu den vorhandenen hinzugefügt werden.",
"DISCOVER_PLUGINS_name": "Entdecke Erweiterungen",
"DevDetail_Copy_Device_Title": "Details von Gerät kopieren",
"DevDetail_Copy_Device_Tooltip": "Details vom Gerät aus der Dropdown-Liste kopieren. Alles auf dieser Seite wird überschrieben",
"DevDetail_CustomProperties_Title": "Benutzerdefinierte Eigenschaften",
@@ -205,7 +205,7 @@
"DevDetail_button_Save": "Speichern",
"DeviceEdit_ValidMacIp": "Gib eine gültige <b>MAC</b>- und <b>IP</b>-Adresse ein.",
"Device_MultiEdit": "Mehrfach-bearbeiten",
"Device_MultiEdit_Backup": "",
"Device_MultiEdit_Backup": "Achtung! Falsche Eingaben können die Installation beschädigen. Bitte sichere deine Datenbank oder Gerätekonfiguration zuerst: (<a href=\"php/server/devices.php?action=ExportCSV\">Konfiguration herunterladen <i class=\"fa-solid fa-download fa-bounce\"></i></a>). Wie du dein Gerät wiederherstellen kannst findest du in der <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/BACKUPS.md#scenario-2-corrupted-database\" target=\"_blank\">Dokumentation über Backups</a>.",
"Device_MultiEdit_Fields": "Felder bearbeiten:",
"Device_MultiEdit_MassActions": "Massen aktionen:",
"Device_MultiEdit_Tooltip": "Achtung! Beim Drücken werden alle Werte auf die oben ausgewählten Geräte übertragen.",
@@ -219,9 +219,9 @@
"Device_Shortcut_Favorites": "Favoriten",
"Device_Shortcut_NewDevices": "Neue Geräte",
"Device_Shortcut_OnlineChart": "Gerätepräsenz im Laufe der Zeit",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertDown": "Alarm aus",
"Device_TableHead_Connected_Devices": "Verbindungen",
"Device_TableHead_CustomProps": "",
"Device_TableHead_CustomProps": "Eigenschaften / Aktionen",
"Device_TableHead_Favorite": "Favorit",
"Device_TableHead_FirstSession": "Erste Sitzung",
"Device_TableHead_GUID": "GUID",
@@ -242,7 +242,7 @@
"Device_TableHead_RowID": "Zeilen ID",
"Device_TableHead_Rowid": "Zeilennummer",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_SourcePlugin": "Quellerweiterung",
"Device_TableHead_Status": "Status",
"Device_TableHead_SyncHubNodeName": "Synchronisationsknoten",
"Device_TableHead_Type": "Typ",
@@ -254,7 +254,7 @@
"Device_Tablelenght": "Zeige _MENU_ Einträge",
"Device_Tablelenght_all": "Alle",
"Device_Title": "Geräte",
"Devices_Filters": "",
"Devices_Filters": "Filter",
"Donations_Others": "Andere",
"Donations_Platforms": "Sponsor-Platformen",
"Donations_Text": "Hey 👋! </br> Thanks for clicking on this menu item 😅 </br> </br> I'm trying to collect some donations to make you better software. Also, it would help me not to get burned out. Me burning out might mean end of support for this app. Any small (recurring or not) sponsorship makes me want ot put more effort into this app. I don't want to lock features (new plugins) behind paywalls 🔐. </br> Currently, I'm waking up 2h before work so I contribute to the app a bit. If I had some recurring income I could shorten my workweek and in the remaining time fully focus on NetAlertX. You'd get more functionality, a more polished app and less bugs. </br> </br> Thanks for reading - I'm super grateful for any support ❤🙏 </br> </br> TL;DR: By supporting me you get: </br> </br> <ul><li>Regular updates to keep your data and family safe 🔄</li><li>Less bugs 🐛🔫</li><li>Better and more functionality</li><li>I don't get burned out 🔥🤯</li><li>Less rushed releases 💨</li><li>Better docs📚</li><li>Quicker and better support with issues 🆘</li><li>Less grumpy me 😄</li></ul> </br> 📧Email me to <a href='mailto:jokob@duck.com?subject=NetAlertX'>jokob@duck.com</a> if you want to get in touch or if I should add other sponsorship platforms. </br>",
@@ -332,7 +332,7 @@
"Gen_Saved": "Gespeichert",
"Gen_Search": "Suchen",
"Gen_Select": "Auswählen",
"Gen_SelectIcon": "",
"Gen_SelectIcon": "<i class=\"fa-solid fa-chevron-down fa-fade\"></i>",
"Gen_SelectToPreview": "Zur Vorschau auswählen",
"Gen_Selected_Devices": "Ausgewählte Geräte:",
"Gen_Switch": "Umschalten",
@@ -340,7 +340,7 @@
"Gen_Upd_Fail": "Aktualisierung fehlgeschlagen",
"Gen_Update": "Aktualisieren",
"Gen_Update_Value": "Wert aktualisieren",
"Gen_ValidIcon": "",
"Gen_ValidIcon": "<i class=\"fa-solid fa-chevron-right \"></i>",
"Gen_Warning": "Warnung",
"Gen_Work_In_Progress": "Keine Finalversion, feedback bitte unter: https://github.com/jokob-sk/NetAlertX/issues",
"Gen_create_new_device": "Neues Gerät",
@@ -834,4 +834,4 @@
"settings_update_item_warning": "",
"test_event_icon": "",
"test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen."
}
}

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "You can specify a custom SQL query which will generate a JSON file and then expose it via the <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> file endpoint</a>.",
"API_CUSTOM_SQL_description": "You can specify a custom SQL query which will generate a JSON file and then expose it via the <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> file endpoint</a>.",
"API_CUSTOM_SQL_name": "Custom endpoint",
"API_TOKEN_description": "API token for secure communication. Generate one or enter any value. It's sent in the request header and used in the <code>SYNC</code> plugin, GraphQL server and other API endpoints. You can use the API endpoints to create custom integrations as descibed in the <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md\" target=\"_blank\">API documentation</a>.",
"API_TOKEN_name": "API token",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Puede especificar una consulta SQL personalizada que generará un archivo JSON y luego lo expondrá a través del <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\">archivo <code>table_custom_endpoint.json</code ></a>.",
"API_CUSTOM_SQL_description": "Puede especificar una consulta SQL personalizada que generará un archivo JSON y luego lo expondrá a través del <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\">archivo <code>table_custom_endpoint.json</code ></a>.",
"API_CUSTOM_SQL_name": "Endpoint personalizado",
"API_TOKEN_description": "Token de API para asegurar la comunicación, puede generar uno o introducir cualquier valor. Se envía en el encabezado de solicitud. Se utiliza en el plugin <code>SYNC</code> del servidor GraphQL.",
"API_TOKEN_name": "Token de la API",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Vous pouvez spécifier votre propre requête SQL qui retournera un fichier JSON et l'exposer via <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> le point de terminaison de fichier</a>.",
"API_CUSTOM_SQL_description": "Vous pouvez spécifier votre propre requête SQL qui retournera un fichier JSON et l'exposer via <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> le point de terminaison de fichier</a>.",
"API_CUSTOM_SQL_name": "Point de terminaison personnalisé",
"API_TOKEN_description": "Vous pouvez renseigner ou générer un jeton API pour sécuriser les échanges. Il est transmis dans le header de la requête, et utilisé dans le plugin <code>SYNC</code>, le serveur GraphQL et d'autres usages API. Vous pouvez utiliser les points de terminaison API pour créer des intégrations spécifiques, comme décrit dans la <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md\" target=\"_blank\">documentation de l'API</a>.",
"API_TOKEN_name": "Jeton d'API",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Puoi specificare una query SQL personalizzata che genererà un file JSON e quindi lo esporrà tramite l'<a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code>endpoint del file</a>.",
"API_CUSTOM_SQL_description": "Puoi specificare una query SQL personalizzata che genererà un file JSON e quindi lo esporrà tramite l'<a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code>endpoint del file</a>.",
"API_CUSTOM_SQL_name": "Endpoint personalizzato",
"API_TOKEN_description": "Token API per comunicazioni sicure. Generane uno o inserisci un valore qualsiasi. Viene inviato nell'intestazione della richiesta e utilizzato nel plugin <code>SYNC</code>, nel server GraphQL e in altri endpoint API. Puoi utilizzare gli endpoint API per creare integrazioni personalizzate come descritto nella <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md\" target=\"_blank\">documentazione API</a>.",
"API_TOKEN_name": "Token API",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Du kan spesifisere en egendefinert SQL-Spørring som vil generere en JSON-fil og deretter eksponere den via <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> file endpoint</a>.",
"API_CUSTOM_SQL_description": "Du kan spesifisere en egendefinert SQL-Spørring som vil generere en JSON-fil og deretter eksponere den via <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> file endpoint</a>.",
"API_CUSTOM_SQL_name": "Egendefinert endepunkt",
"API_TOKEN_description": "",
"API_TOKEN_name": "",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Możesz określić własne zapytanie SQL które będzie generowało plik JSON i udostępnić je poprzez plik typu endpoint <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> </a>.",
"API_CUSTOM_SQL_description": "Możesz określić własne zapytanie SQL które będzie generowało plik JSON i udostępnić je poprzez plik typu endpoint <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> </a>.",
"API_CUSTOM_SQL_name": "Własny endpoint",
"API_TOKEN_description": "",
"API_TOKEN_name": "",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Você pode especificar uma consulta SQL personalizada que irá gerar um arquivo JSON e, em seguida, expô-lo por meio do <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> endpoint do arquivo</a>.",
"API_CUSTOM_SQL_description": "Você pode especificar uma consulta SQL personalizada que irá gerar um arquivo JSON e, em seguida, expô-lo por meio do <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> endpoint do arquivo</a>.",
"API_CUSTOM_SQL_name": "Endpoint customizado",
"API_TOKEN_description": "API token para comunicação segura, você pode gerar um valor ou inserir qualquer valor. Este é enviado no cabeçalho da requisição. Usado no <code>SYNC</code> plugin, servidor GraphQL .",
"API_TOKEN_name": "API token",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Вы можете указать собственный SQL-запрос, который будет генерировать файл JSON, а затем предоставлять его через конечную точку файла <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code></a>.",
"API_CUSTOM_SQL_description": "Вы можете указать собственный SQL-запрос, который будет генерировать файл JSON, а затем предоставлять его через конечную точку файла <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code></a>.",
"API_CUSTOM_SQL_name": "Пользовательская конечная точка",
"API_TOKEN_description": "API-токен для безопасной связи. Сгенерируйте его или введите любое значение. Он передается в заголовке запроса и используется в плагине <code>SYNC</code>, сервере GraphQL и других конечных точках API. Вы можете использовать конечные точки API для создания пользовательских интеграций, как описано в <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md\" target=\"_blank\">документации по API</a>.",
"API_TOKEN_name": "API token",
@@ -328,7 +328,7 @@
"Gen_Upd_Fail": "Не удалось обновить",
"Gen_Update": "Обновление",
"Gen_Update_Value": "Обновить значение",
"Gen_ValidIcon": "",
"Gen_ValidIcon": "<i class=\"fa-solid fa-chevron-right \"></i>",
"Gen_Warning": "Предупреждение",
"Gen_Work_In_Progress": "Работа продолжается, самое время оставить отзыв на https://github.com/jokob-sk/NetAlertX/issues",
"Gen_create_new_device": "Новое устройство",
@@ -753,4 +753,4 @@
"settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. <b>Проверка не выполняется.</b>",
"test_event_icon": "fa-vial-circle-check",
"test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки."
}
}

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "Ви можете вказати спеціальний SQL-запит, який створить файл JSON, а потім відкриє його через кінцеву точку файлу <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code></a>.",
"API_CUSTOM_SQL_description": "Ви можете вказати спеціальний SQL-запит, який створить файл JSON, а потім відкриє його через кінцеву точку файлу <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code></a>.",
"API_CUSTOM_SQL_name": "Спеціальна кінцева точка",
"API_TOKEN_description": "Маркер API для безпечного зв’язку. Згенеруйте один або введіть будь-яке значення. Він надсилається в заголовку запиту та використовується в плагіні <code>SYNC</code>, сервері GraphQL та інших кінцевих точках API. Ви можете використовувати кінцеві точки API для створення спеціальних інтеграцій, як описано в <a href=\"https://github.com/jokob-sk/NetAlertX/blob/main/docs/API.md\" target=\"_blank\">API документація</a>.",
"API_TOKEN_name": "Маркер API",

View File

@@ -1,5 +1,5 @@
{
"API_CUSTOM_SQL_description": "您可以指定一个自定义 SQL 查询,它将生成一个 JSON 文件,然后通过 <a href=\"/api/table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> 文件端点</a> 公开它。",
"API_CUSTOM_SQL_description": "您可以指定一个自定义 SQL 查询,它将生成一个 JSON 文件,然后通过 <a href=\"/php/server/query_json.php?file=table_custom_endpoint.json\" target=\"_blank\"><code>table_custom_endpoint.json</code> 文件端点</a> 公开它。",
"API_CUSTOM_SQL_name": "自定义终点",
"API_TOKEN_description": "",
"API_TOKEN_name": "",

View File

@@ -332,7 +332,7 @@
}
]
},
"default_value": "python3 /app/front/plugins/_publisher_mqtt/mqtt.py devices={devices}",
"default_value": "python3 /app/front/plugins/_publisher_mqtt/mqtt.py",
"options": [],
"localized": ["name", "description"],
"name": [

View File

@@ -464,7 +464,8 @@ def mqtt_start(db):
sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'sensor', 'first_connection', 'calendar-start', device["devMac"])
sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'sensor', 'last_connection', 'calendar-end', device["devMac"])
# handle device_tracker
# device_tracker attributes
devJson = {
"last_ip": device["devLastIP"],
"is_new": str(device["devIsNew"]),
@@ -472,11 +473,14 @@ def mqtt_start(db):
"mac_address": str(device["devMac"]),
"model": devDisplayName,
"last_connection": prepTimeStamp(str(device["devLastConnection"])),
"first_connection": prepTimeStamp(str(device["devFirstConnection"])) }
"first_connection": prepTimeStamp(str(device["devFirstConnection"])),
"sync_node": device["devSyncHubNode"],
"group": device["devGroup"],
"location": device["devLocation"],
"network_parent_mac": device["devParentMAC"],
"network_parent_name": next((dev["devName"] for dev in devices if dev["devMAC"] == device["devParentMAC"]), "")
}
# bulk update device sensors in home assistant
publish_mqtt(mqtt_client, sensorConfig.state_topic, devJson)
# create and update is_present sensor
sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'binary_sensor', 'is_present', 'wifi', device["devMac"])
publish_mqtt(mqtt_client, sensorConfig.state_topic,

View File

@@ -408,6 +408,34 @@
}
]
},
{
"function": "TOKEN",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [],
"transformers": []
}
]
},
"default_value": "",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "NTFY token"
}
],
"description": [
{
"language_code": "en_us",
"string": "Enter a token if authentication is enabled for hosting an instance. If a token is provided, the username and password will be ignored."
}
]
},
{
"function": "USER",
"type": {

View File

@@ -94,23 +94,29 @@ def send(html, text):
response_text = ''
response_status_code = ''
# settings
token = get_setting_value('NTFY_TOKEN')
user = get_setting_value('NTFY_USER')
pwd = get_setting_value('NTFY_PASSWORD')
# prepare request headers
headers = {
"Title": "NetAlertX Notification",
"Actions": "view, Open Dashboard, "+ get_setting_value('REPORT_DASHBOARD_URL'),
"Priority": get_setting_value('NTFY_PRIORITY'),
"Tags": "warning"
}
# if username and password are set generate hash and update header
if get_setting_value('NTFY_USER') != "" and get_setting_value('NTFY_PASSWORD') != "":
# Generate hash for basic auth
# usernamepassword = "{}:{}".format(get_setting_value('NTFY_USER'),get_setting_value('NTFY_PASSWORD'))
basichash = b64encode(bytes(get_setting_value('NTFY_USER') + ':' + get_setting_value('NTFY_PASSWORD'), "utf-8")).decode("ascii")
# add authorization header with hash
# if token or username and password are set generate hash and update header
if token != '':
headers["Authorization"] = "Bearer {}".format(token)
elif user != "" and pwd != "":
# Generate hash for basic auth
basichash = b64encode(bytes(user + ':' + pwd, "utf-8")).decode("ascii")
# add authorization header with hash
headers["Authorization"] = "Basic {}".format(basichash)
# call NTFY service
try:
response = requests.post("{}/{}".format( get_setting_value('NTFY_HOST'),
get_setting_value('NTFY_TOPIC')),
@@ -119,8 +125,6 @@ def send(html, text):
response_status_code = response.status_code
# Check if the request was successful (status code 200)
if response_status_code == 200:
response_text = response.text # This captures the response body/message

View File

@@ -19,6 +19,12 @@ An alternative to on-network scanners is to enable some other Device scanners/im
- SAVE
- Wait for the next scan to finish
### Common issues
#### IP flipping on Google Nest devices
Some devices might flip IP addresses after each scan triggering false notifications. This is because some devices respond to broadcast calls and thus different IPs after scans are logged. To preven this you can try to use the `--exclude-broadcast` flag in the `ARPSCAN_ARGS` setting or change the `SCAN_SUBNETS` setting from e.g.: `192.168.1.0/24` to `192.168.1.1-192.168.1.254` to exclude the broadcast address `192.168.1.255` from the scanned range.
#### Examples
Settings:

View File

@@ -14,6 +14,7 @@
## Solution: Getting the `dnsmasq.leases` from the Asus router and configuriong the `DHCPLSS` plugin:
1. Enable SSH login on your Asus router
2. Generate a pair of SSH keys and place them inside `/root/.ssh/`
3. In your router's admin-settings, paste the public key and disable "password login" for SSH
@@ -42,6 +43,19 @@ Host ASUS-GT-AXE16000
Port 22
```
6. Try a dry run with the command in step 4. If everything is fine, you should have a `dnsmasq.leases` file at your target location
> [!NOTE]
> You can also use ed25519 keys with passphrases. That makes the rsync command a little bit more complex.
> First, one have to install sshpass: apt-get install sshpass
> 1. create a file with your password that is required for the SSH key: `nano .password`
> 2. Then change the `grabdnsmasq.sh` to: `#!/bin/bash sshpass -P passphrase -f '/root/.password' rsync -avzh -e 'ssh -i /root/.ssh/<yourprivatekey>' <asususer>@192.168.1.1:/var/lib/misc/dnsmasq.leases /mnt/service-data/netalertx_dhcp.leases/`
> 3. replace:
> - `/root/.password` to the path of the `.password` file
> - `/root/.ssh/yourprivatekey` to the path of your private SSH key that is required for the ASUS router
> - `asususer` to the login name of your ASUS router (standard is `admin`, not recommended)
> - IP address of the ASUS router
> - Path where rsync should copy the dhcp file (it should be a mounted path to NetAlertX) - here I use `/mnt/service-data/netalertx_dhcp.leases/`
7. Edit crontab for root:
`crontab -e`

View File

@@ -398,7 +398,8 @@
"XC5sYW4=",
"XC5ob21l",
"LVthLWZBLUYwLTldezMyfQ==",
"Iy4q"
"Iy4q",
"XC5cYg=="
],
"options": [],
"localized": [
@@ -651,7 +652,7 @@
{
"name": "value",
"type": "sql",
"value": "SELECT DISTINCT '' as id, '❌None' as name UNION SELECT devOwner as id, devOwner as name FROM (SELECT devOwner FROM Devices UNION SELECT 'House' ) AS all_devices ORDER BY id;"
"value": "SELECT DISTINCT '' as id, '❌None' as name UNION SELECT devOwner as id, devOwner as name FROM (SELECT devOwner FROM Devices) AS all_devices ORDER BY id;"
}
],
"localized": [
@@ -811,7 +812,7 @@
{
"name": "value",
"type": "sql",
"value": "SELECT DISTINCT '' as id, '❌None' as name UNION SELECT devGroup as id, devGroup as name FROM (SELECT devGroup FROM Devices WHERE devGroup <> '' UNION SELECT 'Personal' UNION SELECT 'Always on' UNION SELECT 'Friends' UNION SELECT 'Others' ) AS all_devices ORDER BY id;"
"value": "SELECT DISTINCT '' as id, '❌None' as name UNION SELECT devGroup as id, devGroup as name FROM (SELECT devGroup FROM Devices WHERE devGroup <> '' ) AS all_devices ORDER BY id;"
}
],
"localized": [
@@ -855,7 +856,7 @@
{
"name": "value",
"type": "sql",
"value": "SELECT DISTINCT '' AS id, '❌None' AS name UNION SELECT devLocation AS id, devLocation AS name FROM Devices WHERE devLocation NOT IN ('', 'null') AND devLocation IS NOT NULL UNION SELECT 'Bathroom' AS id, 'Bathroom' AS name UNION SELECT 'Bedroom', 'Bedroom' UNION SELECT 'Dining room', 'Dining room' UNION SELECT 'Hall', 'Hall' UNION SELECT 'Kitchen', 'Kitchen' UNION SELECT 'Laundry', 'Laundry' UNION SELECT 'Living room', 'Living room' UNION SELECT 'Study', 'Study' UNION SELECT 'Attic', 'Attic' UNION SELECT 'Basement', 'Basement' UNION SELECT 'Garage', 'Garage' UNION SELECT 'Back yard', 'Back yard' UNION SELECT 'Garden', 'Garden' UNION SELECT 'Terrace', 'Terrace' ORDER BY id;"
"value": "SELECT DISTINCT '' AS id, '❌None' AS name UNION SELECT devLocation AS id, devLocation AS name FROM Devices WHERE devLocation NOT IN ('', 'null') AND devLocation IS NOT NULL ORDER BY id;"
}
],
"localized": [

View File

@@ -1,33 +1,31 @@
## Overview
The OMADA SDN plugin aims at synchronizing data between NetAlertX and a TPLINK OMADA SND controler by leveraging a tplink omada python library.
#### features:
#### Features
1. extract list of OMADA Clients from OMADA and sync them up with NetAlertX
2. extract list of OAMDA Devices (switches and access points) and sync them up with NetAlertX
> [!TIP]
> some omada devices are apparently not fully compatible with the API which might lead to partial results.
> Some omada devices are apparently not fully compatible with the API which might lead to partial results.
### Quick setup guide
1. You SHOULD (ie: strongly recommend) set up an account in your OMADA SDN console dedicated to NetAlertX OMADA_SDN plugin.
- you should set USER TYPE = Local USer
- you should set USER ROLE = Administrator (if you use a read-only role you won't be able to sync names from NetAlerX to OMADA SDN)
- you should set USER TYPE = Local User
- you should set USER ROLE = Administrator (if you use a read-only role you won't be able to sync names from NetAlerX to OMADA SDN)
- you can set Site Privileges = All Sites (or limit it to specific sites )
2. populate the variables in NetAlertX as instructed in the config plugin page.
#### Required Settings
- OMDSDN_url
- OMDSDN_sites
- OMDSDN_username
- OMDSDN_password
- OMDSDN_force_overwrite
- `OMDSDN_url`
- `OMDSDN_sites`
- `OMDSDN_username`
- `OMDSDN_password` (if using special characters, make sure they are python-friendly (e.g. `~`))
- `OMDSDN_force_overwrite`
### Usage
@@ -36,7 +34,7 @@ The OMADA SDN plugin aims at synchronizing data between NetAlertX and a TPLINK O
### Notes
#### features not implemented yet:
3. extract list of OAMDA router Devices (er605...) and sync them up with NetAlertX
3. Extract list of OAMDA router Devices (er605...) and sync them up with NetAlertX
(I need to setup my own er605 however due to its limitations I have no use for it, and due to limitations of opensense dhcp servers, I can't deploy it yet without breaking dhcp self registration into opnsense unbound - see below)
#### know limitations:
@@ -63,8 +61,6 @@ can not fix some of tplinks OMADA SDN own limitations/bugs:
- OMADA EAP245 - to be fair to tp-link, this access point works inside OMADA SDN, so it might be an issue with our omada python library but we can't extract data from it.
## Other info
- Version: 1.0

View File

@@ -193,24 +193,46 @@ def add_uplink(
sadevices_linksbymac,
port_byswitchmac_byclientmac,
):
# mylog(OMDLOGLEVEL, [f'[{pluginName}] trying to add uplink="{uplink_mac}" to switch="{switch_mac}"'])
# mylog(OMDLOGLEVEL, [f'[{pluginName}] before adding:"{device_data_bymac[switch_mac]}"'])
if device_data_bymac[switch_mac][SWITCH_AP] == "null":
# Ensure switch_mac exists in device_data_bymac
if switch_mac not in device_data_bymac:
mylog("none", [f"[{pluginName}] switch_mac '{switch_mac}' not found in device_data_bymac"])
return
# Ensure SWITCH_AP key exists in the dictionary
if SWITCH_AP not in device_data_bymac[switch_mac]:
mylog("none", [f"[{pluginName}] Missing key '{SWITCH_AP}' in device_data_bymac[{switch_mac}]"])
return
# Check if uplink should be added
if device_data_bymac[switch_mac][SWITCH_AP] in [None, "null"]:
device_data_bymac[switch_mac][SWITCH_AP] = uplink_mac
# Ensure uplink_mac exists in device_data_bymac
if uplink_mac not in device_data_bymac:
mylog("none", [f"[{pluginName}] uplink_mac '{uplink_mac}' not found in device_data_bymac"])
return
# Determine port to uplink
if (
device_data_bymac[switch_mac][TYPE] == "Switch"
and device_data_bymac[uplink_mac][TYPE] == "Switch"
device_data_bymac[switch_mac].get(TYPE) == "Switch"
and device_data_bymac[uplink_mac].get(TYPE) == "Switch"
):
port_to_uplink = port_byswitchmac_byclientmac[switch_mac][uplink_mac]
# find_port_of_uplink_switch(switch_mac, uplink_mac)
port_to_uplink = port_byswitchmac_byclientmac.get(switch_mac, {}).get(uplink_mac)
if port_to_uplink is None:
mylog("none", [f"[{pluginName}] Missing port info for switch_mac '{switch_mac}' and uplink_mac '{uplink_mac}'"])
return
else:
port_to_uplink = device_data_bymac[uplink_mac][PORT_SSID]
port_to_uplink = device_data_bymac[uplink_mac].get(PORT_SSID)
# Assign port to switch_mac
device_data_bymac[switch_mac][PORT_SSID] = port_to_uplink
# mylog(OMDLOGLEVEL, [f'[{pluginName}] after adding:"{device_data_bymac[switch_mac]}"'])
for link in sadevices_linksbymac[switch_mac]:
# Recursively add uplinks for linked devices
for link in sadevices_linksbymac.get(switch_mac, []):
if (
device_data_bymac[link][SWITCH_AP] == "null"
and device_data_bymac[switch_mac][TYPE] == "Switch"
link in device_data_bymac
and device_data_bymac[link].get(SWITCH_AP) in [None, "null"]
and device_data_bymac[switch_mac].get(TYPE) == "Switch"
):
add_uplink(
switch_mac,
@@ -221,6 +243,7 @@ def add_uplink(
)
# ----------------------------------------------
# Main initialization
def main():

View File

@@ -0,0 +1,75 @@
## 🔍 Overview
- This plugin imports online devices and clients from the Omada SDN (Omada Controller) through the provided OpenAPI.
### ✨ Features
1. Import online devices (gateways, switches, and access points) compatible with Omada SDN and send them to NetAlertX.
2. Import online clients (e.g., computers and smartphones) and send them to NetAlertX.
### 📌 Requirements
- Omada Controller with Open API support.
#### ✅ Officially supported controllers - [Source](https://community.tp-link.com/en/business/forum/topic/590430)
- All Omada Pro versions support Open API
- Omada Software/Hardware Controller support Open API since Controller v5.12
### ⚙️ Setup guide & settings
1. Login to your **Omada Controller**.
2. In the **Global Dashboard**, navigate to **Settings**, select **Platform Integration**, then click on **Open API**.
3. Create new credentials by clicking **Add New App**.
- The `App Name` can be anything.
- Set the `Mode` to `Client`.
- Set the `Role` to `Viewer` or `Administrator`.
- For `Site Privileges`, choose `All (Including all new-created sites)` or select specific site(s).
- Click `Apply` to create the application.
4. From the created application, you will need the following fields.
- `Omada ID` - visible by clicking the **eye** icon next to the **edit** and **delete** buttons.
- `Client ID`
- `Client Secret`
5. Open **NetAlertX's Settings**, head to **Omada SDN using OpenAPI** `(OMDSDNOPENAPI)` and configure the plugin.
- `OMDSDNOPENAPI_RUN` - When the scan should run, good option is `schedule`.
- `OMDSDNOPENAPI_host` - Specify the host URL of your **Omada Controller**, including the protocol, e.g., `https://example.com:1234`.
- `OMDSDNOPENAPI_omada_id` - Enter the **Omada ID** obtained in the previous step.
- `OMDSDNOPENAPI_client_id` - Enter the **Client ID** obtained in the previous step.
- `OMDSDNOPENAPI_client_secret` - Enter the **Client Secret** obtained in the previous step.
- `OMDSDNOPENAPI_sites` (optional) - You can enter either the **site name** or **site ID**. If an invalid value is provided or neither is specified, the plugin will default to the first accessible site using the supplied credentials.
- `OMDSDNOPENAPI_verify_ssl` - Check this option to enable SSL verification for requests to your Omada Controller's OpenAPI. If unchecked, SSL verification will be disabled.
### 📋 Data populated by the plugin
- This table outlines the data fields populated by the plugin, their conditions, descriptions, and where they are visible.
| 🔹 Field | 🔄 Population Condition | 📖 Description | 👀 Visibility |
|---|---|---|---|
| **MAC** | Always populated | The device's unique MAC address | Device details |
| **Last IP** | Always populated | The device's assigned IP address | Device details |
| **Name** | Always populated | The device name retrieved from Omada | Device details |
| **Parent Node** | Only if available | MAC address of the parent device (switch, AP, or gateway) | Device details |
| **Parent Node Port** | Only if available | The port number used to connect to the parent device | Device details |
| **SSID** | Only if available | The SSID through which the device is connected | Device details |
| **Device Type** | Only if available | Detected device type (e.g., iPhone, PC, Android) | Device details |
| **Last Seen** | Always populated | Last recorded time the device was active on the network | Plugin details |
| **Omada Site** | Always populated | Omada site to which the device is assigned | Device details |
| **VLAN ID** | Only if available | VLAN ID assigned to the device | Plugin details |
### ⚠️ Limitations and warnings
- The plugin can fetch up to 1000 devices and 1000 clients from the Omada Controller.
- Using non-Omada SDN compatible devices (e.g., switches, APs) may result in incomplete or inaccurate data.
### 🖼️ Examples
- Settings:
- ![settings_example](/front/plugins/omada_sdn_openapi/omada_sdn_openapi_settings.png)
### Other info
- Version: 1.0
- Author : [xfilo](https://github.com/xfilo)
- Release Date: 24-February-2025
- Omada Open API documentation: https://use1-omada-northbound.tplinkcloud.com/doc.html#/home (may take a moment to load)

View File

@@ -0,0 +1,669 @@
{
"code_name": "omada_sdn_openapi",
"unique_prefix": "OMDSDNOPENAPI",
"plugin_type": "device_scanner",
"execution_order" : "Layer_0",
"enabled": true,
"data_source": "script",
"mapped_to_table": "CurrentScan",
"data_filters": [
{
"compare_column": "Object_PrimaryID",
"compare_operator": "==",
"compare_field_id": "txtMacFilter",
"compare_js_template": "'{value}'.toString()",
"compare_use_quotes": true
}
],
"show_ui": true,
"localized": ["display_name", "description", "icon"],
"display_name": [
{
"language_code": "en_us",
"string": "Omada SDN using OpenAPI"
}
],
"description": [
{
"language_code": "en_us",
"string": "This plugin imports devices and clients from the Omada SDN (Omada Controller) through the provided OpenAPI."
}
],
"icon": [
{
"language_code": "en_us",
"string": "<i class=\"fa fa-search\"></i>"
}
],
"params": [],
"settings": [
{
"function": "RUN",
"events": ["run"],
"type": {
"dataType": "string",
"elements": [
{ "elementType": "select", "elementOptions": [], "transformers": [] }
]
},
"default_value": "disabled",
"options": ["disabled", "once", "schedule", "always_after_scan"],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "When to run"
}
],
"description": [
{
"language_code": "en_us",
"string": "When the scan should run, good option is <code>schedule</code>."
}
]
},
{
"function": "RUN_SCHD",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "span",
"elementOptions": [
{
"cssClasses": "input-group-addon validityCheck"
},
{
"getStringKey": "Gen_ValidIcon"
}
],
"transformers": []
},
{
"elementType": "input",
"elementOptions": [
{
"onChange": "validateRegex(this)"
},
{
"base64Regex": "Xig/OlwqfCg/OlswLTldfFsxLTVdWzAtOV18WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlswLTldfDFbMC05XXwyWzAtM118WzAtOV0rLVswLTldK3xcKi9bMC05XSspKVxzKyg/OlwqfCg/OlsxLTldfFsxMl1bMC05XXwzWzAxXXxbMC05XSstWzAtOV0rfFwqL1swLTldKykpXHMrKD86XCp8KD86WzEtOV18MVswLTJdfFswLTldKy1bMC05XSt8XCovWzAtOV0rKSlccysoPzpcKnwoPzpbMC02XXxbMC02XS1bMC02XXxcKi9bMC05XSspKSQ="
}
],
"transformers": []
}
]
},
"default_value": "*/5 * * * *",
"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=\"#OMDSDNOPENAPI_RUN\"><code>OMDSDNOPENAPI_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 selected <a onclick=\"toggleAllSettings()\" href=\"#TIMEZONE\"><code>TIMEZONE</code></a>. Will be run NEXT time the time passes."
}
]
},
{
"function": "host",
"type": {
"dataType": "string",
"elements": [
{ "elementType": "input", "elementOptions": [], "transformers": [] }
]
},
"maxLength": 100,
"default_value": "",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Host URL"
}
],
"description": [
{
"language_code": "en_us",
"string": "Specify the host URL of your <code>Omada Controller</code>, including the protocol, eg. <code>https://example.com:1234</code>."
}
]
},
{
"function": "omada_id",
"type": {
"dataType": "string",
"elements": [
{ "elementType": "input", "elementOptions": [], "transformers": [] }
]
},
"maxLength": 100,
"default_value": "",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Omada ID"
}
],
"description": [
{
"language_code": "en_us",
"string": "Provide your <code>Omada ID</code>, which can be found in the <code>OpenAPI</code> section of your <code>Omada Controller</code>."
}
]
},
{
"function": "client_id",
"type": {
"dataType": "string",
"elements": [
{ "elementType": "input", "elementOptions": [], "transformers": [] }
]
},
"maxLength": 100,
"default_value": "",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Client ID"
}
],
"description": [
{
"language_code": "en_us",
"string": "Enter the <code>Client ID</code> generated by your <code>Omada Controller</code> in the <code>OpenAPI</code> section."
}
]
},
{
"function": "client_secret",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [{ "type": "password" }],
"transformers": []
}
]
},
"maxLength": 100,
"default_value": "",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Client Secret"
}
],
"description": [
{
"language_code": "en_us",
"string": "Input the <code>Client Secret</code> obtained from the <code>OpenAPI</code> section of your <code>Omada Controller</code>."
}
]
},
{
"function": "sites",
"type": {
"dataType": "array",
"elements": [
{
"elementType": "input",
"elementOptions": [
{ "placeholder": "Enter value" },
{ "suffix": "_in" },
{ "cssClasses": "col-sm-10" },
{ "prefillValue": "null" }
],
"transformers": []
},
{
"elementType": "button",
"elementOptions": [
{ "sourceSuffixes": ["_in"] },
{ "separator": "" },
{ "cssClasses": "col-xs-12" },
{ "onClick": "addList(this, false)" },
{ "getStringKey": "Gen_Add" }
],
"transformers": []
},
{
"elementType": "select",
"elementHasInputValue": 1,
"elementOptions": [
{ "multiple": "true" },
{ "readonly": "true" },
{ "editable": "true" }
],
"transformers": []
},
{
"elementType": "button",
"elementOptions": [
{ "sourceSuffixes": [] },
{ "separator": "" },
{ "cssClasses": "col-xs-6" },
{ "onClick": "removeAllOptions(this)" },
{ "getStringKey": "Gen_Remove_All" }
],
"transformers": []
},
{
"elementType": "button",
"elementOptions": [
{ "sourceSuffixes": [] },
{ "separator": "" },
{ "cssClasses": "col-xs-6" },
{ "onClick": "removeFromList(this)" },
{ "getStringKey": "Gen_Remove_Last" }
],
"transformers": []
}
]
},
"default_value": [],
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Omada Sites"
}
],
"description": [
{
"language_code": "en_us",
"string": "You can enter either the <code>site name</code> or <code>site ID</code>. If an invalid value is provided or neither is specified, the plugin will default to the first accessible site using the supplied credentials."
}
]
},
{
"function": "verify_ssl",
"type": {
"dataType": "boolean",
"elements": [
{
"elementType": "input",
"elementOptions": [{ "type": "checkbox" }],
"transformers": []
}
]
},
"default_value": true,
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Verify SSL"
}
],
"description": [
{
"language_code": "en_us",
"string": "Check this option to enable SSL verification for requests to your Omada Controller's OpenAPI. If unchecked, SSL verification will be disabled."
}
]
},
{
"function": "CMD",
"type": {
"dataType": "string",
"elements": [
{
"elementType": "input",
"elementOptions": [{ "readonly": "true" }],
"transformers": []
}
]
},
"default_value": "python3 /app/front/plugins/omada_sdn_openapi/script.py",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Command"
}
],
"description": [
{
"language_code": "en_us",
"string": "Command to run. This can not be changed."
}
]
},
{
"function": "RUN_TIMEOUT",
"type": {
"dataType": "integer",
"elements": [
{
"elementType": "input",
"elementOptions": [{ "type": "number" }],
"transformers": []
}
]
},
"default_value": 30,
"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."
}
]
},
{
"default_value": [],
"description": [
{
"language_code": "en_us",
"string": "Send a notification if selected values change. Use <code>CTRL + Click</code> to select/deselect. <ul> <li><code>Watched_Value1</code> is Device Name </li><li><code>Watched_Value2</code> is Parent Node MAC</li><li><code>Watched_Value3</code> is Parent Node Port </li><li><code>Watched_Value4</code> is Parent Node SSID </li></ul>"
}
],
"function": "WATCH",
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Watched"
}
],
"options": [
"Watched_Value1",
"Watched_Value2",
"Watched_Value3",
"Watched_Value4"
],
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true" }],
"transformers": []
}
]
}
},
{
"default_value": ["new", "watched-changed"],
"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>Watched_ValueN</code> columns changed."
}
],
"function": "REPORT_ON",
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "Report on"
}
],
"options": [
"new",
"watched-changed",
"watched-not-changed",
"missing-in-last-scan"
],
"type": {
"dataType": "array",
"elements": [
{
"elementType": "select",
"elementOptions": [{ "multiple": "true" }],
"transformers": []
}
]
}
}
],
"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": "Object_PrimaryID",
"mapped_to_column": "cur_MAC",
"css_classes": "col-sm-3",
"show": true,
"type": "device_name_mac",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "MAC Address"
}
]
},
{
"column": "Object_SecondaryID",
"mapped_to_column": "cur_IP",
"css_classes": "col-sm-2",
"show": true,
"type": "device_ip",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "IP Address"
}
]
},
{
"column": "Watched_Value1",
"mapped_to_column": "cur_Name",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Device Name"
}
]
},
{
"column": "Watched_Value2",
"mapped_to_column": "cur_NetworkNodeMAC",
"css_classes": "col-sm-2",
"show": true,
"type": "device_name_mac",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Parent Node MAC"
}
]
},
{
"column": "Watched_Value3",
"mapped_to_column": "cur_PORT",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Parent Node Port"
}
]
},
{
"column": "Watched_Value4",
"mapped_to_column": "cur_SSID",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Parent Node SSID"
}
]
},
{
"column": "Extra",
"mapped_to_column": "cur_Type",
"css_classes": "col-sm-2",
"show": false,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Device Type"
}
]
},
{
"column": "Dummy",
"mapped_to_column": "cur_ScanMethod",
"mapped_to_column_data": {
"value": "OMDSDNOPENAPI"
},
"css_classes": "col-sm-2",
"show": false,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Scan method"
}
]
},
{
"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",
"css_classes": "col-sm-2",
"show": false,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Changed"
}
]
},
{
"column": "HelpVal1",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Last Seen"
}
]
},
{
"column": "HelpVal2",
"mapped_to_column": "cur_NetworkSite",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "Omada Site"
}
]
},
{
"column": "HelpVal3",
"css_classes": "col-sm-2",
"show": true,
"type": "label",
"default_value": "",
"options": [],
"localized": ["name"],
"name": [
{
"language_code": "en_us",
"string": "VLAN ID"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -0,0 +1,522 @@
#!/usr/bin/env python
"""
This plugin imports devices and clients from Omada Controller using their OpenAPI.
It was inspired by the 'omada_sdn_imp/omada_sdn.py' plugin,
which relied on the 'tplink_omada_client' library instead of OpenAPI.
However, I found that approach somewhat unstable, so I decided
to give it a shot and create a new plugin with the goal of providing
same, but more reliable results.
Please note that this is my first plugin, and I'm not a Python developer.
Any comments, bug fixes, or contributions are greatly appreciated.
Author: https://github.com/xfilo
"""
__author__ = "xfilo"
__version__ = 0.1 # Initial version
__version__ = 0.2 # Rephrased error messages, improved logging and code logic
__version__ = 0.3 # Refactored data collection into a class, improved code clarity with comments
import os
import sys
import urllib3
import requests
import time
import datetime
import pytz
from datetime import datetime
from typing import Literal, Any, Dict
# Define the installation path and extend the system path for plugin imports
INSTALL_PATH = "/app"
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from plugin_helper import Plugin_Objects, is_typical_router_ip, is_mac
from logger import mylog, Logger
from const import logPath
from helper import get_setting_value
import conf
# Make sure the TIMEZONE for logging is correct
conf.tz = pytz.timezone(get_setting_value('TIMEZONE'))
# Make sure log level is initialized correctly
Logger(get_setting_value('LOG_LEVEL'))
pluginName = 'OMDSDNOPENAPI'
# Define the current path and log file paths
LOG_PATH = logPath + '/plugins'
LOG_FILE = os.path.join(LOG_PATH, f'script.{pluginName}.log')
RESULT_FILE = os.path.join(LOG_PATH, f'last_result.{pluginName}.log')
# Disable insecure request warning
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class OmadaHelper:
@staticmethod
def log(message: str, level: Literal["minimal", "verbose", "debug", "trace"] = "minimal") -> None:
mylog(level, [f"[{pluginName}] [{level[:1].upper()}] {message}"])
@staticmethod
def debug(message: str) -> None:
return OmadaHelper.log(message, "debug")
@staticmethod
def verbose(message: str) -> None:
return OmadaHelper.log(message, "verbose")
@staticmethod
def minimal(message: str) -> None:
return OmadaHelper.log(message, "minimal")
@staticmethod
def response(response_type: str, response_message: str, response_result: Any = None) -> Dict[str, Any]:
return {"response_type": response_type, "response_message": response_message, "response_result": response_result}
@staticmethod
def timestamp_to_datetime(ms: int, timezone: str) -> Dict[str, Any]:
"""Returns datetime from millisecond timestamp with required timezone."""
try:
if not ms or not isinstance(ms, (str, int)):
raise ValueError(f"Value '{ms}' is not a valid timestamp")
# Convert UTC millisecond timestamp to datetime in NetAlertX's timezone
timestamp = ms / 1000
tz = pytz.timezone("UTC")
utc_datetime = datetime.fromtimestamp(timestamp, tz=tz)
target_timezone = pytz.timezone(timezone)
local_datetime = utc_datetime.astimezone(target_timezone)
result = local_datetime.strftime("%Y-%m-%d %H:%M:%S")
msg = f"Converted timestamp {ms} to datetime {result} with timezone {timezone}"
OmadaHelper.debug(msg)
return OmadaHelper.response("success", msg, result)
except pytz.UnknownTimeZoneError:
msg = f"Failed to convert timestamp - unknown timezone: {timezone}"
OmadaHelper.verbose(msg)
return OmadaHelper.response("error", msg)
except Exception as ex:
msg = f"Failed to convert timestamp - error: {str(ex)}"
OmadaHelper.verbose(msg)
return OmadaHelper.response("error", msg)
@staticmethod
def normalize_mac(mac: str) -> Dict[str, Any]:
"""Returns a normalized version of MAC address."""
try:
if not mac or not isinstance(mac, str) or mac is None:
raise Exception(f"Value '{mac}' is not a valid MAC address")
# Replace - with : in a MAC address and make it lowercase
result = mac.lower().replace("-", ":")
msg = f"Normalized MAC address from {mac} to {result}"
OmadaHelper.debug(msg)
return OmadaHelper.response("success", msg, result)
except Exception as ex:
msg = f"Failed to normalize MAC address '{mac}' - error: {str(ex)}"
OmadaHelper.verbose(msg)
return OmadaHelper.response("error", msg)
@staticmethod
def normalize_data(input_data: list, input_type: str, site_name: str, timezone: str) -> Dict[str, Any]:
"""Returns a normalized dictionary of input data (clients, devices)."""
try:
if not isinstance(input_data, list):
raise Exception(f"Expected a list, but got '{type(input_data)}'.")
OmadaHelper.verbose(f"Starting normalization of {len(input_data)} {input_type}(s) from site: {site_name}")
# The default return structure for one device/client
default_entry = {
"mac_address": "",
"ip_address": "",
"name": "",
"last_seen": "",
"site_name": site_name,
"parent_node_mac_address": "",
"parent_node_port": "",
"parent_node_ssid": "",
"vlan_id": "",
}
result = []
# Loop through each device/client
for data in input_data:
# Normalize and verify MAC address
mac = OmadaHelper.normalize_mac(data.get("mac"))
if not isinstance(mac, dict) or mac.get("response_type") != "success":
continue
mac = mac.get("response_result")
if not is_mac(mac):
OmadaHelper.debug(f"Skipping {input_type}, not a MAC address: {mac}")
continue
# Assigning mandatory return values
entry = default_entry.copy()
entry["mac_address"] = mac
entry["ip_address"] = data.get("ip")
entry["name"] = data.get("name")
# Assign the last datetime the device/client was seen on the network
last_seen = OmadaHelper.timestamp_to_datetime(data.get("lastSeen", 0), timezone)
entry["last_seen"] = last_seen.get("response_result") if isinstance(last_seen, dict) and last_seen.get("response_type") == "success" else ""
# Applicable only for DEVICE
if input_type == "device":
entry["device_type"] = data.get("type")
# If it's not a gateway try to assign parent node MAC
if data.get("type", "") != "gateway":
parent_mac = OmadaHelper.normalize_mac(data.get("uplinkDeviceMac"))
entry["parent_node_mac_address"] = parent_mac.get("response_result") if isinstance(parent_mac, dict) and parent_mac.get("response_type") == "success" else ""
# Applicable only for CLIENT
if input_type == "client":
entry["vlan_id"] = data.get("vid")
entry["device_type"] = data.get("deviceType")
# Try to assign parent node MAC and PORT/SSID to the CLIENT
if data.get("connectDevType", "") == "gateway":
parent_mac = OmadaHelper.normalize_mac(data.get("gatewayMac"))
entry["parent_node_mac_address"] = parent_mac.get("response_result") if isinstance(parent_mac, dict) and parent_mac.get("response_type") == "success" else ""
entry["parent_node_port"] = data.get("port", "")
elif data.get("connectDevType", "") == "switch":
parent_mac = OmadaHelper.normalize_mac(data.get("switchMac"))
entry["parent_node_mac_address"] = parent_mac.get("response_result") if isinstance(parent_mac, dict) and parent_mac.get("response_type") == "success" else ""
entry["parent_node_port"] = data.get("port", "")
elif data.get("connectDevType", "") == "ap":
parent_mac = OmadaHelper.normalize_mac(data.get("apMac"))
entry["parent_node_mac_address"] = parent_mac.get("response_result") if isinstance(parent_mac, dict) and parent_mac.get("response_type") == "success" else ""
entry["parent_node_ssid"] = data.get("ssid", "")
# Add the entry to the result
result.append(entry)
OmadaHelper.debug(f"Processed {input_type} entry: {entry}")
msg = f"Successfully normalized {len(result)} {input_type}(s) from site: {site_name}"
OmadaHelper.minimal(msg)
return OmadaHelper.response("success", msg, result)
except Exception as ex:
msg = f"Failed normalizing {input_type}(s) from site '{site_name}' - error: {str(ex)}"
OmadaHelper.verbose(msg)
return OmadaHelper.response("error", msg)
class OmadaAPI:
def __init__(self, options: dict):
OmadaHelper.debug("Initializing OmadaAPI with provided options")
# Define parameters: required, optional, and default values
params = {
"host": {"type": str, "required": True},
"omada_id": {"type": str, "required": True},
"client_id": {"type": str, "required": True},
"client_secret": {"type": str, "required": True},
"verify_ssl": {"type": bool, "required": False, "default": True},
"page_size": {"type": int, "required": False, "default": 1000},
"sites": {"type": list, "required": False, "default": []}
}
# Validate and set attributes
for param_name, param_info in params.items():
# Get user parameter input, or default value if any
value = options.get(param_name, param_info.get("default"))
# Check if a parameter is required and if it's value is non-empty
if param_info["required"] and (value is None or (param_info["type"] == str and not value)):
raise ValueError(f"{param_name} is required and must be a non-empty {param_info['type'].__name__}")
# Check if a parameter has a correct datatype
if not isinstance(value, param_info["type"]):
raise TypeError(f"{param_name} must be of type {param_info['type'].__name__}")
# Assign the parameter to the class
setattr(self, param_name, value)
OmadaHelper.debug(f"Initialized option '{param_name}' with value: {value}")
# Other parameters
self.available_sites_dict = {}
self.active_sites_dict = {}
self.access_token = None
self.refresh_token = None
OmadaHelper.verbose("OmadaAPI initialized")
def _get_headers(self, include_auth: bool = True) -> dict:
"""Return request headers."""
headers = {"Content-type": "application/json"}
# Add access token to header if requested and available
if include_auth == True:
if not self.access_token:
OmadaHelper.debug("No access token available for headers")
else:
headers["Authorization"] = f"AccessToken={self.access_token}"
OmadaHelper.debug(f"Generated headers: {headers}")
return headers
def _make_request(self, method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]:
"""Make a request to an endpoint."""
time.sleep(1) # Sleep before making any request so it does not rate-limited
OmadaHelper.debug(f"{method} request to endpoint: {endpoint}")
url = f"{getattr(self, 'host')}{endpoint}"
headers = self._get_headers(kwargs.pop('include_auth', True))
try:
# Make the request and get the response
response = requests.request(method, url, headers=headers, verify=getattr(self, 'verify_ssl'), **kwargs)
response.raise_for_status()
data = response.json()
# Check if the response contains an error code and determine the function response type
response_type = "error" if data.get("errorCode", 0) != 0 else "success"
msg = f"{method} request completed: {endpoint}"
OmadaHelper.verbose(msg)
return OmadaHelper.response(response_type, msg, data)
except requests.exceptions.RequestException as ex:
OmadaHelper.minimal(f"{method} request failed: {url}")
OmadaHelper.verbose(f"{method} request error: {str(ex)}")
return OmadaHelper.response("error", f"{method} request failed to endpoint '{endpoint}' with error: {str(ex)}")
def authenticate(self) -> Dict[str, any]:
"""Make an endpoint request to get access token."""
OmadaHelper.verbose("Starting authentication process")
# Endpoint request
endpoint = "/openapi/authorize/token?grant_type=client_credentials"
payload = {
"omadacId": getattr(self, 'omada_id'),
"client_id": getattr(self, 'client_id'),
"client_secret": getattr(self, 'client_secret')
}
response = self._make_request("POST", endpoint, json=payload, include_auth=False)
# Successful endpoint response
if response.get("response_type") == "success":
response_result = response.get("response_result")
error_code = response_result.get("errorCode")
access_token = response_result.get("result").get("accessToken")
refresh_token = response_result.get("result").get("refreshToken")
# Authentication is successful if there isn't a response error, and access_token and refresh_token are set
if error_code == 0 and access_token and refresh_token:
self.access_token = access_token
self.refresh_token = refresh_token
msg = "Successfully authenticated"
OmadaHelper.minimal(msg)
return OmadaHelper.response("success", msg)
# Failed authentication
OmadaHelper.debug(f"Authentication response: {response}")
return OmadaHelper.response("error", f"Authentication failed - error: {response.get('response_message', 'Not provided')}")
def get_clients(self, site_id: str) -> Dict[str, Any]:
"""Make an endpoint request to get all online clients on a site."""
OmadaHelper.verbose(f"Retrieving clients for site: {site_id}")
endpoint = f"/openapi/v1/{getattr(self, 'omada_id')}/sites/{site_id}/clients?page=1&pageSize={getattr(self, 'page_size')}"
return self._make_request("GET", endpoint)
def get_devices(self, site_id: str) -> Dict[str, Any]:
"""Make an endpoint request to get all online devices on a site."""
OmadaHelper.verbose(f"Retrieving devices for site: {site_id}")
endpoint = f"/openapi/v1/{getattr(self, 'omada_id')}/sites/{site_id}/devices?page=1&pageSize={getattr(self, 'page_size')}"
return self._make_request("GET", endpoint)
def populate_sites(self) -> Dict[str, Any]:
"""Make an endpoint request to populate all accessible sites."""
OmadaHelper.verbose("Starting site population process")
# Endpoint request
endpoint = f"/openapi/v1/{getattr(self, 'omada_id')}/sites?page=1&pageSize={getattr(self, 'page_size')}"
response = self._make_request("GET", endpoint)
# Successful endpoint response
if response.get("response_type") == "success":
response_result = response.get("response_result")
if response_result.get("errorCode") == 0:
# All allowed sites for credentials
all_sites = response_result.get("result", "").get("data", [])
OmadaHelper.debug(f"Retrieved {len(all_sites)} sites in total")
# All available sites
self.available_sites_dict = {site["siteId"]: site["name"] for site in all_sites}
OmadaHelper.debug(f"Available sites: {self.available_sites_dict}")
# All valid sites from input
active_sites_by_id = {site["siteId"]: site["name"] for site in all_sites if site["siteId"] in self.requested_sites()}
active_sites_by_name = {site["siteId"]: site["name"] for site in all_sites if site["name"] in self.requested_sites()}
self.active_sites_dict = active_sites_by_id | active_sites_by_name
OmadaHelper.debug(f"Active sites after filtering: {self.active_sites_dict}")
# If none of the input sites is valid/accessible, default to the first available site
if not self.active_sites_dict:
OmadaHelper.verbose("No valid site requested by configuration options, defaulting to first available site")
first_available_site = next(iter(self.available_sites_dict.items()), (None, None))
if first_available_site[0]: # Check if there's an available site
self.active_sites_dict = {first_available_site[0]: first_available_site[1]}
OmadaHelper.debug(f"Using first available site: {first_available_site}")
# Successful site population
msg = f"Successfully populated {len(self.active_sites_dict)} site(s)"
OmadaHelper.minimal(msg)
return OmadaHelper.response("success", msg)
# Failed site population
OmadaHelper.debug(f"Site population response: {response}")
return OmadaHelper.response("error", f"Site population failed - error: {response.get('response_message', 'Not provided')}")
def requested_sites(self) -> list:
"""Returns sites requested by user."""
return getattr(self, 'sites')
def available_sites(self) -> dict:
"""Returns all available sites."""
return self.available_sites_dict
def active_sites(self) -> dict:
"""Returns the sites the code will use."""
return self.active_sites_dict
class OmadaData:
@staticmethod
def create_data(plugin_objects: Plugin_Objects, normalized_input_data: dict) -> None:
"""Creates plugin object from normalized input data."""
if normalized_input_data.get("response_type", "error") != "success":
OmadaHelper.minimal(f"Unable to make entries - error: {normalized_input_data.get('response_message', 'Not provided')}")
return
# Loop through every device/client and make an plugin entry
response_result = normalized_input_data.get("response_result", {})
for entry in response_result:
if len(entry) == 0:
OmadaHelper.minimal(f"Skipping entry, missing data.")
continue
OmadaHelper.verbose(f"Making entry for: {entry['mac_address']}")
# If the device_type is gateway, set the parent_node to Internet
device_type = entry["device_type"].lower()
parent_node = entry["parent_node_mac_address"]
if len(parent_node) == 0 and entry["device_type"] == "gateway" and is_typical_router_ip(entry["ip_address"]):
parent_node = "Internet"
# Some device type naming exceptions
if device_type == "iphone":
device_type = "iPhone"
elif device_type == "pc":
device_type = "PC"
else:
device_type = device_type.capitalize()
# Add the plugin object
plugin_objects.add_object(
primaryId=entry["mac_address"],
secondaryId=entry["ip_address"],
watched1=entry["name"],
watched2=parent_node,
watched3=entry["parent_node_port"],
watched4=entry["parent_node_ssid"],
extra=device_type,
foreignKey=entry["mac_address"],
helpVal1=entry["last_seen"],
helpVal2=entry["site_name"],
helpVal3=entry["vlan_id"],
helpVal4="null"
)
@staticmethod
def collect_data(plugin_objects: Plugin_Objects) -> Plugin_Objects:
"""Collects device and client data from Omada Controller."""
omada_api = OmadaAPI(OPTIONS)
# Authenticate
auth_result = omada_api.authenticate()
if auth_result["response_type"] == "error":
OmadaHelper.minimal("Authentication failed, aborting data collection")
OmadaHelper.debug(f"{auth_result['response_message']}")
return plugin_objects
# Populate sites
sites_result = omada_api.populate_sites()
if sites_result["response_type"] == "error":
OmadaHelper.minimal("Site population failed, aborting data collection")
OmadaHelper.debug(f"{sites_result['response_message']}")
return plugin_objects
requested_sites = omada_api.requested_sites()
available_sites = omada_api.available_sites()
active_sites = omada_api.active_sites()
OmadaHelper.verbose(f"Requested sites: {requested_sites}")
OmadaHelper.verbose(f"Available sites: {available_sites}")
OmadaHelper.verbose(f"Active sites: {active_sites}")
OmadaHelper.minimal("Starting data collection process")
# Loop through sites and collect data
for site_id, site_name in active_sites.items():
OmadaHelper.verbose(f"Processing site: {site_name} ({site_id})")
# Collect device data
devices_response = omada_api.get_devices(site_id)
if devices_response["response_type"] != "success":
OmadaHelper.minimal(f"Failed to retrieve devices for site: {site_name}")
else:
devices = devices_response["response_result"].get("result").get("data", [])
OmadaHelper.debug(f"Retrieved {len(devices)} device(s) from site: {site_name}")
devices = OmadaHelper.normalize_data(devices, "device", site_name, TIMEZONE)
OmadaData.create_data(plugin_objects, devices)
# Collect client data
clients_response = omada_api.get_clients(site_id)
if clients_response["response_type"] != "success":
OmadaHelper.minimal(f"Failed to retrieve clients for site {site_name}")
else:
clients = clients_response["response_result"].get("result").get("data", [])
OmadaHelper.debug(f"Retrieved {len(clients)} client(s) from site: {site_name}")
clients = OmadaHelper.normalize_data(clients, "client", site_name, TIMEZONE)
OmadaData.create_data(plugin_objects, clients)
OmadaHelper.verbose(f"Site complete: {site_name} ({site_id})")
# Complete collection and return plugin object
OmadaHelper.minimal("Completed data collection process")
return plugin_objects
def main():
start_time = time.time()
OmadaHelper.minimal(f"Starting execution, version {__version__}")
# Initialize the Plugin object output file
plugin_objects = Plugin_Objects(RESULT_FILE)
# Retrieve options
global OPTIONS, TIMEZONE
TIMEZONE = get_setting_value("TIMEZONE")
OPTIONS = {
"host": get_setting_value(f"{pluginName}_host").strip(),
"client_id": get_setting_value(f"{pluginName}_client_id").strip(),
"client_secret": get_setting_value(f"{pluginName}_client_secret").strip(),
"omada_id": get_setting_value(f"{pluginName}_omada_id").strip(),
"sites": get_setting_value(f"{pluginName}_sites"),
"verify_ssl": get_setting_value(f"{pluginName}_verify_ssl")
}
OmadaHelper.verbose("Configuration options loaded")
# Retrieve entries and write result
plugin_objects = OmadaData.collect_data(plugin_objects)
plugin_objects.write_result_file()
# Finish
OmadaHelper.minimal(f"Execution completed in {time.time() - start_time:.2f}s, found {len(plugin_objects)} devices and clients")
if __name__ == '__main__':
main()

View File

@@ -633,7 +633,7 @@
"description": [
{
"language_code": "en_us",
"string": "The host (IP) where the UNIFI controller is runnig. Do NOT include the protocol (e.g. <code>https://</code>)"
"string": "The host (IP) where the UNIFI controller is running. Do NOT include the protocol (e.g. <code>https://</code>)"
},
{
"language_code": "es_es",
@@ -665,7 +665,7 @@
"description": [
{
"language_code": "en_us",
"string": "The port number where the UNIFI controller is runnig. Usually it is <code>8443</code>, for UDM(P) devices its 443."
"string": "The port number where the UNIFI controller is running. Usually it is <code>8443</code>, for UDM(P) devices its 443."
},
{
"language_code": "es_es",

View File

@@ -32,6 +32,7 @@ nav:
- Home Assistant: HOME_ASSISTANT.md
- Emails: SMTP.md
- Backups: BACKUPS.md
- Security: SECURITY.md
- Advanced guides:
- Remote Networks: REMOTE_NETWORKS.md
- Notifications Guide: NOTIFICATIONS.md
@@ -42,7 +43,7 @@ nav:
- Webhooks (n8n): WEBHOOK_N8N.md
- Help:
- Common issues: COMMON_ISSUES.md
- Random MAC: RANDOM_MAC.md
- Random MAC: RANDOM_MAC.md
- Device guides:
- Editing Devices:
@@ -70,17 +71,22 @@ nav:
- Integrations:
- Webhook Secret: WEBHOOK_SECRET.md
- API: API.md
- Helper scripts: HELPER_SCRIPTS.md
theme:
name: material
logo: img/NetAlertX_logo.png # Reference the favicon here
favicon: img/NetAlertX_logo.png
logo: img/netalertx_docs.png # Reference the favicon here
favicon: img/netalertx_docs.png
custom_dir: docs/overrides
metadata:
description: "NetAlertX Documentation - The go-to resource for all things related to NetAlertX."
image: "https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/front/img/NetAlertX_logo.png"
image: "https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/front/img/netalertx_docs.png"
extra:
home_hide_sidebar: true
analytics:
provider: google
property: G-KCRSGLP8J2
social:
- icon: fontawesome/brands/github
link: https://github.com/jokob-sk
@@ -135,4 +141,4 @@ plugins:
- gh-admonitions
- search
favicon: /img/NetAlertX_logo.png
favicon: /img/netalertx_docs.png

View File

@@ -0,0 +1,78 @@
# NetAlertX OPNsense DHCP Lease Converter
## Overview
This script retrieves DHCP lease data from an OPNsense firewall over SSH and converts it into the `dnsmasq` lease file format. You can combine it with the `DHCPLLSS` plugin to ingest devices from OPNsense.
## Features
- Connects to OPNsense via SSH to retrieve DHCP lease data.
- Parses active DHCP leases.
- Converts lease data to `dnsmasq` lease format.
- Saves the converted lease file to a specified output location.
- Supports password and key-based SSH authentication.
- Includes a debug mode for troubleshooting.
## Requirements
- Python 3
- `paramiko` library (for SSH connection)
- An OPNsense firewall with SSH access enabled
## Usage
Run the script with the required parameters:
```sh
./script.py --host <OPNsense_IP> --username <SSH_User> --output <Output_File>
```
### Available Options
| Option | Description |
|--------------|-------------|
| `--host` | OPNsense hostname or IP address (Required) |
| `--username` | SSH username (Required) |
| `--password` | SSH password (Optional if using key-based authentication) |
| `--key-file` | Path to SSH private key file (Optional) |
| `--port` | SSH port (Default: 22) |
| `--output` | Output file path for converted lease file (Required) |
| `--debug` | Enable debug logging (Optional) |
### Example Commands
#### Using Password Authentication
```sh
./script.py --host 192.168.1.1 --username admin --password mypassword --output /tmp/dnsmasq.leases
```
#### Using SSH Key Authentication
```sh
./script.py --host 192.168.1.1 --username admin --key-file ~/.ssh/id_rsa --output /tmp/dnsmasq.leases
```
## Output Format
The script generates a `dnsmasq`-formatted lease file with lines structured as:
```
[epoch timestamp] [MAC address] [IP address] [hostname] [client ID]
```
Example:
```sh
1708212000 00:11:22:33:44:55 192.168.1.100 my-device 01:00:11:22:33:44:55
```
## Troubleshooting
- **Connection issues?** Ensure SSH is enabled on the OPNsense device and the correct credentials are used.
- **No lease data?** Verify the DHCP lease file exists at `/var/dhcpd/var/db/dhcpd.leases`.
- **Permission denied?** Ensure your SSH user has the required permissions to access the lease file.
- **Debugging:** Run the script with the `--debug` flag to see more details.
### Other info
- Version: 1.0
- Author: [im-redactd](https://github.com/im-redactd)
- Release Date: 24-Feb-2025
> [!NOTE]
> This is a community supplied script and not maintained.

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
import paramiko
import re
from datetime import datetime
import argparse
import sys
from pathlib import Path
import time
import logging
def setup_logging(debug=False):
"""Configure logging based on debug flag."""
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
return logging.getLogger(__name__)
def parse_timestamp(date_str):
"""Convert OPNsense timestamp to Unix epoch time."""
try:
# Format from OPNsense: "1 2025/02/17 20:08:29"
# Remove the leading number and convert
clean_date = ' '.join(date_str.split()[1:])
dt = datetime.strptime(clean_date, '%Y/%m/%d %H:%M:%S')
return int(dt.timestamp())
except Exception as e:
logger.error(f"Failed to parse timestamp: {date_str}")
return None
def get_lease_file(hostname, username, password=None, key_filename=None, port=22, debug=False):
"""Retrieve the lease file content from OPNsense via SSH."""
logger = logging.getLogger(__name__)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
logger.debug(f"Attempting to connect to {hostname}:{port} as {username}")
ssh.connect(hostname, port=port, username=username,
password=password, key_filename=key_filename)
# Get an interactive shell session
logger.debug("Opening interactive SSH channel")
channel = ssh.invoke_shell()
time.sleep(2) # Wait for the menu to load
if debug:
# Read and log the initial menu
while channel.recv_ready():
initial_output = channel.recv(4096).decode('utf-8')
logger.debug(f"Initial menu output:\n{initial_output}")
# Send '8' to access the shell
logger.debug("Sending option 8 to access shell")
channel.send('8\n')
time.sleep(2) # Wait for shell access
# Send the command to read the lease file
command = 'cat /var/dhcpd/var/db/dhcpd.leases\n'
logger.debug(f"Sending command: {command}")
channel.send(command)
time.sleep(2) # Wait for command execution
# Receive the output
output = ""
while channel.recv_ready():
chunk = channel.recv(4096).decode('utf-8')
output += chunk
if debug:
logger.debug(f"Received chunk:\n{chunk}")
# Clean up the output by removing the command echo and shell prompts
lines = output.split('\n')
# Remove first line (command echo) and any lines containing shell prompts
cleaned_lines = [line for line in lines
if not line.strip().startswith(command.strip())
and not line.strip().endswith('> ')
and not line.strip().endswith('# ')]
cleaned_output = '\n'.join(cleaned_lines)
logger.debug(f"Final cleaned output length: {len(cleaned_output)} characters")
# Exit the shell properly
channel.send('exit\n')
ssh.close()
return cleaned_output
except Exception as e:
logger.error(f"Error during SSH operation: {str(e)}")
raise
def parse_lease_file(lease_content):
"""Parse the DHCP lease file content and return a list of valid leases."""
logger = logging.getLogger(__name__)
leases = []
current_lease = None
for line in lease_content.split('\n'):
line = line.strip()
if not line or line.startswith('root@') or line.startswith('#'):
continue
logger.debug(f"Processing line: {line}")
# Start of a lease block
if line.startswith('lease'):
if current_lease:
leases.append(current_lease)
logger.debug(f"Added lease: {current_lease}")
current_lease = {}
ip = line.split()[1]
current_lease['ip'] = ip
# MAC address
elif 'hardware ethernet' in line:
mac = line.split()[2].rstrip(';')
current_lease['mac'] = mac
# Hostname
elif 'client-hostname' in line:
hostname = line.split('"')[1] if '"' in line else line.split()[1].rstrip(';')
current_lease['hostname'] = hostname
# Lease state
elif line.startswith('binding state '):
state = line.split('binding state')[1].strip().rstrip(';')
current_lease['state'] = state
# End time
elif line.startswith('ends'):
date_str = ' '.join(line.split()[1:]).rstrip(';')
current_lease['ends'] = date_str
# Client ID
elif line.startswith('uid'):
uid = line.split('"')[1] if '"' in line else line.split()[1].rstrip(';')
current_lease['uid'] = uid
# End of lease block
elif line.strip() == '}':
if current_lease:
leases.append(current_lease)
logger.debug(f"Added lease at block end: {current_lease}")
current_lease = None
# Add the last lease if exists
if current_lease:
leases.append(current_lease)
logger.debug(f"Added final lease: {current_lease}")
# Filter only active leases
active_leases = [lease for lease in leases
if lease.get('state') == 'active'
and 'mac' in lease
and 'ip' in lease]
logger.debug(f"Found {len(active_leases)} active leases out of {len(leases)} total leases")
logger.debug("Active leases:")
for lease in active_leases:
logger.debug(f" {lease}")
return active_leases
def convert_to_dnsmasq(leases):
"""Convert leases to dnsmasq lease file format."""
logger = logging.getLogger(__name__)
dnsmasq_lines = []
for lease in leases:
logger.debug(f"Converting lease: {lease}")
if 'mac' in lease and 'ip' in lease:
# Get expiry time as Unix timestamp
expiry = lease.get('ends', '')
if expiry:
expiry_epoch = parse_timestamp(expiry)
if not expiry_epoch:
logger.error(f"Skipping lease due to invalid timestamp: {lease}")
continue
else:
logger.error(f"Skipping lease due to missing expiry time: {lease}")
continue
# Get required fields
mac = lease['mac']
ip = lease['ip']
hostname = lease.get('hostname', '*')
# Format client ID - if not available, use MAC address with '01:' prefix
client_id = lease.get('uid', f"01:{mac}")
# Clean up client ID - remove escape sequences and quotes
client_id = client_id.replace('\\', '').replace('"', '')
if not client_id.startswith('01:'):
client_id = f"01:{mac}"
# Format: [epoch timestamp] [MAC address] [IP address] [hostname] [client ID]
line = f"{expiry_epoch} {mac} {ip} {hostname} {client_id}"
dnsmasq_lines.append(line)
logger.debug(f"Added dnsmasq lease line: {line}")
return dnsmasq_lines
def main():
parser = argparse.ArgumentParser(description='Convert OPNsense DHCP leases to dnsmasq format')
parser.add_argument('--host', required=True, help='OPNsense hostname or IP')
parser.add_argument('--username', required=True, help='SSH username')
parser.add_argument('--password', help='SSH password (if not using key-based auth)')
parser.add_argument('--key-file', help='SSH private key file path')
parser.add_argument('--port', type=int, default=22, help='SSH port (default: 22)')
parser.add_argument('--output', required=True, help='Output file path')
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
args = parser.parse_args()
# Setup logging
logger = setup_logging(args.debug)
try:
# Get lease file content
logger.info("Retrieving lease file from OPNsense")
lease_content = get_lease_file(
args.host,
args.username,
password=args.password,
key_filename=args.key_file,
port=args.port,
debug=args.debug
)
# Parse leases
logger.info("Parsing lease file content")
leases = parse_lease_file(lease_content)
# Convert to dnsmasq format
logger.info("Converting to dnsmasq format")
dnsmasq_lines = convert_to_dnsmasq(leases)
# Write output file
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"Writing output to {args.output}")
with open(output_path, 'w') as f:
f.write('\n'.join(dnsmasq_lines) + '\n')
logger.info(f"Successfully wrote {len(dnsmasq_lines)} entries to {args.output}")
except Exception as e:
logger.error(f"Error: {str(e)}")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -624,7 +624,7 @@ def cleanDeviceName(str, match_IP):
str = re.sub(rgx, "", str)
mylog('trace', ["[cleanDeviceName] name after regex : " + str])
str = re.sub(r'\.\b', '', str) # trailing dot after words
# str = re.sub(r'\.\b', '', str) # trailing dot after words
str = re.sub(r'\.$', '', str) # trailing dot at the end of the string
str = str.replace(". (IP match)", " (IP match)") # Remove dot if (IP match) is added