From 32c21b01bb541825df1dd9f9ca0014824993ab15 Mon Sep 17 00:00:00 2001 From: PlanBot Date: Wed, 18 Feb 2026 16:42:17 -0500 Subject: [PATCH 001/122] feat(docs): Update Docker install guide and templates - Add --force-recreate to install commands for easier version switching - Remove debug flags (ALWAYS_FRESH_INSTALL, NETALERTX_DEBUG) from templates - Link to official DOCKER_COMPOSE environment variable docs --- install/docker/README.md | 49 +++++++++++++++++++++++++++ install/docker/docker-compose.dev.yml | 44 ++++++++++++++++++++++++ install/docker/docker-compose.yml | 44 ++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 install/docker/README.md create mode 100644 install/docker/docker-compose.dev.yml create mode 100644 install/docker/docker-compose.yml diff --git a/install/docker/README.md b/install/docker/README.md new file mode 100644 index 000000000..a0adc6388 --- /dev/null +++ b/install/docker/README.md @@ -0,0 +1,49 @@ +# 🐳 Docker Compose Installation + +This folder provides standard Docker Compose configurations to get **NetAlertX** up and running quickly. This method is ideal for users on **Proxmox**, **TrueNAS Scale**, **Portainer**, or standard Linux hosts who prefer a simple, declarative setup. + +## 🚀 Getting Started + +### 1. Choose your flavor + +* **Stable (Recommended):** Use `docker-compose.yml`. This tracks the latest stable release. +* **Development:** Use `docker-compose.dev.yml`. This tracks the `dev` branch and contains the latest features (and potential bugs). + +### 2. Deploy + +Download the chosen file to a directory on your server (e.g., `netalertx/`). You can switch between Stable and Dev versions easily by pointing to the specific file. + +**For Stable:** +```bash +docker compose -f docker-compose.yml up -d --force-recreate +``` + +**For Development:** +```bash +docker compose -f docker-compose.dev.yml up -d --force-recreate +``` + +> [!NOTE] +> The `--force-recreate` flag ensures that your container is rebuilt with the latest configuration, making it seamless to switch between versions. Initial startup might take a few minutes. + +## ⚙️ Configuration + +### Storage +By default, these files use a **Docker Named Volume** (`netalertx_data`) for persistent storage. This is the easiest way to get started and ensures data persists across upgrades. + +> [!TIP] +> If you prefer to map a specific folder on your host (e.g., `/mnt/data/netalertx` on Proxmox or TrueNAS), edit the `volumes` section in the compose file to use a **bind mount** instead. + +### Networking +The container uses `network_mode: host` by default. This is **required** for core features like ARP scanning (`arp-scan`) to work correctly, as the container needs direct access to the network interface to discover devices. + +### Environment Variables +You can customize the application by editing the `environment` section in the compose file. Common overrides include: + +* `TZ`: Timezone (mapped via `/etc/localtime`). +* `SCAN_SUBNETS`: Define specific subnets to scan if auto-detection fails (e.g., `192.168.1.0/24`). + +For a full list of environment variables and configuration options, see the [Customize with Environment Variables](https://docs.netalertx.com/DOCKER_COMPOSE/?h=environmental+variables#customize-with-environmental-variables) section in the documentation. + +--- +[⬅️ Back to Main Repo](../../README.md) diff --git a/install/docker/docker-compose.dev.yml b/install/docker/docker-compose.dev.yml new file mode 100644 index 000000000..dae1069f2 --- /dev/null +++ b/install/docker/docker-compose.dev.yml @@ -0,0 +1,44 @@ +services: + netalertx: + network_mode: host # Use host networking for ARP scanning and other services + image: ghcr.io/netalertx/netalertx-dev:latest + container_name: netalertx + read_only: true + cap_drop: + - ALL + cap_add: + - NET_ADMIN + - NET_RAW + - NET_BIND_SERVICE + - CHOWN + - SETUID + - SETGID + volumes: + - type: volume + source: netalertx_data + target: /data + read_only: false + - type: bind + source: /etc/localtime + target: /etc/localtime + read_only: true + tmpfs: + - "/tmp:mode=1700,uid=0,gid=0,rw,noexec,nosuid,nodev,async,noatime,nodiratime" + environment: + PUID: ${NETALERTX_UID:-20211} + PGID: ${NETALERTX_GID:-20211} + LISTEN_ADDR: ${LISTEN_ADDR:-0.0.0.0} + PORT: ${PORT:-20211} + GRAPHQL_PORT: ${GRAPHQL_PORT:-20212} + mem_limit: 2048m + mem_reservation: 1024m + cpu_shares: 512 + pids_limit: 512 + logging: + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped + +volumes: + netalertx_data: diff --git a/install/docker/docker-compose.yml b/install/docker/docker-compose.yml new file mode 100644 index 000000000..6622efcc0 --- /dev/null +++ b/install/docker/docker-compose.yml @@ -0,0 +1,44 @@ +services: + netalertx: + network_mode: host # Use host networking for ARP scanning and other services + image: ghcr.io/netalertx/netalertx:latest + container_name: netalertx + read_only: true + cap_drop: + - ALL + cap_add: + - NET_ADMIN + - NET_RAW + - NET_BIND_SERVICE + - CHOWN + - SETUID + - SETGID + volumes: + - type: volume + source: netalertx_data + target: /data + read_only: false + - type: bind + source: /etc/localtime + target: /etc/localtime + read_only: true + tmpfs: + - "/tmp:mode=1700,uid=0,gid=0,rw,noexec,nosuid,nodev,async,noatime,nodiratime" + environment: + PUID: ${NETALERTX_UID:-20211} + PGID: ${NETALERTX_GID:-20211} + LISTEN_ADDR: ${LISTEN_ADDR:-0.0.0.0} + PORT: ${PORT:-20211} + GRAPHQL_PORT: ${GRAPHQL_PORT:-20212} + mem_limit: 2048m + mem_reservation: 1024m + cpu_shares: 512 + pids_limit: 512 + logging: + options: + max-size: "10m" + max-file: "3" + restart: unless-stopped + +volumes: + netalertx_data: From 2f1e5068e32178fc4b47c80c1e9bd0d6c51fa9ab Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sun, 22 Feb 2026 23:12:46 +1100 Subject: [PATCH 002/122] BE+FE: Unstable devices list (3 status changes in 1h) Signed-off-by: jokob-sk --- front/php/templates/header.php | 3 + front/php/templates/language/ar_ar.json | 3 +- front/php/templates/language/ca_ca.json | 1 + front/php/templates/language/cs_cz.json | 1 + front/php/templates/language/de_de.json | 1 + front/php/templates/language/en_us.json | 1 + front/php/templates/language/es_es.json | 1 + front/php/templates/language/fa_fa.json | 1 + front/php/templates/language/fr_fr.json | 3 +- front/php/templates/language/it_it.json | 3 +- front/php/templates/language/ja_jp.json | 3 +- front/php/templates/language/nb_no.json | 1 + front/php/templates/language/pl_pl.json | 1 + front/php/templates/language/pt_br.json | 1 + front/php/templates/language/pt_pt.json | 1 + front/php/templates/language/ru_ru.json | 3 +- front/php/templates/language/sv_sv.json | 1 + front/php/templates/language/tr_tr.json | 1 + front/php/templates/language/uk_ua.json | 3 +- front/php/templates/language/vi_vn.json | 1 + front/php/templates/language/zh_cn.json | 3 +- server/api_server/graphql_endpoint.py | 23 ++++++- server/const.py | 65 ++------------------ server/db/db_helper.py | 32 ++++++---- server/db/db_upgrade.py | 81 +++++++++++++++++++++++++ server/models/device_instance.py | 7 ++- server/models/event_instance.py | 47 ++++++++++++++ server/models/plugin_object_instance.py | 2 +- 28 files changed, 207 insertions(+), 87 deletions(-) diff --git a/front/php/templates/header.php b/front/php/templates/header.php index c7d15f0e8..e32b0efb8 100755 --- a/front/php/templates/header.php +++ b/front/php/templates/header.php @@ -314,6 +314,9 @@ function update_servertime() {
  • +
  • + +
  • diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 3076815ef..1391389a6 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "المفضلة", "Device_Shortcut_NewDevices": "أجهزة جديدة", "Device_Shortcut_OnlineChart": "مخطط الاتصال", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "تنبيه عدم الاتصال", "Device_TableHead_Connected_Devices": "الأجهزة المتصلة", "Device_TableHead_CustomProps": "خصائص مخصصة", @@ -789,4 +790,4 @@ "settings_system_label": "نظام", "settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. لم يتم إجراء التحقق.", "test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 943f239ed..2e26de41e 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favorits", "Device_Shortcut_NewDevices": "Nous dispositius", "Device_Shortcut_OnlineChart": "Dispositius detectats", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Cancel·lar alerta", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Props / Accions", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index 61324a978..06dd9a15f 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "", "Device_Shortcut_NewDevices": "", "Device_Shortcut_OnlineChart": "", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 57f55df03..19e1d12f1 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -222,6 +222,7 @@ "Device_Shortcut_Favorites": "Favoriten", "Device_Shortcut_NewDevices": "Neue Geräte", "Device_Shortcut_OnlineChart": "Gerätepräsenz im Laufe der Zeit", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alarm aus", "Device_TableHead_Connected_Devices": "Verbindungen", "Device_TableHead_CustomProps": "Eigenschaften / Aktionen", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index a52725e22..8129a5fcd 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favorites", "Device_Shortcut_NewDevices": "New devices", "Device_Shortcut_OnlineChart": "Device presence", + "Device_Shortcut_Unstable": "Unstable", "Device_TableHead_AlertDown": "Alert Down", "Device_TableHead_Connected_Devices": "Connections", "Device_TableHead_CustomProps": "Props / Actions", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index aff4832cd..aa4a4e393 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -220,6 +220,7 @@ "Device_Shortcut_Favorites": "Favorito(s)", "Device_Shortcut_NewDevices": "Nuevos dispositivos", "Device_Shortcut_OnlineChart": "Presencia del dispositivo a lo largo del tiempo", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alerta desactivada", "Device_TableHead_Connected_Devices": "Conexiones", "Device_TableHead_CustomProps": "Propiedades / Acciones", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 772ef8dfb..e9e9fc846 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "", "Device_Shortcut_NewDevices": "", "Device_Shortcut_OnlineChart": "", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 66ea2d844..9c10212c7 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favoris", "Device_Shortcut_NewDevices": "Nouveaux appareils", "Device_Shortcut_OnlineChart": "Présence de l'appareil", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alerter si En panne", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Champs / Actions", @@ -789,4 +790,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} +} \ No newline at end of file diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index b88e6e4e0..ee9817315 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Preferiti", "Device_Shortcut_NewDevices": "Nuovi dispositivi", "Device_Shortcut_OnlineChart": "Presenza dispositivo", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Avviso disconnessione", "Device_TableHead_Connected_Devices": "Connessioni", "Device_TableHead_CustomProps": "Proprietà/Azioni", @@ -789,4 +790,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index a4aa0b1fe..7262ca5a1 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "お気に入り", "Device_Shortcut_NewDevices": "新規デバイス", "Device_Shortcut_OnlineChart": "デバイス検出", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "ダウンアラート", "Device_TableHead_Connected_Devices": "接続", "Device_TableHead_CustomProps": "属性 / アクション", @@ -789,4 +790,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} +} \ No newline at end of file diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index e86b92003..21b66914c 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favoritter", "Device_Shortcut_NewDevices": "Nye Enheter", "Device_Shortcut_OnlineChart": "Enhetens tilstedeværelse", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", "Device_TableHead_Connected_Devices": "Tilkoblinger", "Device_TableHead_CustomProps": "", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 877376bd6..a208d6a2c 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Ulubione", "Device_Shortcut_NewDevices": "Nowe urządzenia", "Device_Shortcut_OnlineChart": "Obecność urządzenia", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alert niedostępny", "Device_TableHead_Connected_Devices": "Połączenia", "Device_TableHead_CustomProps": "Właściwości / Akcje", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 5adbb5cda..a6ccad5b3 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favoritos", "Device_Shortcut_NewDevices": "Novos dispositivos", "Device_Shortcut_OnlineChart": "Presença do dispositivo", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alerta em baixo", "Device_TableHead_Connected_Devices": "Conexões", "Device_TableHead_CustomProps": "", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index c44b05459..d311d2c9d 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favoritos", "Device_Shortcut_NewDevices": "Novo dispostivo", "Device_Shortcut_OnlineChart": "Presença do dispositivo", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alerta em baixo", "Device_TableHead_Connected_Devices": "Conexões", "Device_TableHead_CustomProps": "Propriedades / Ações", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 838e487fc..670348185 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Избранные", "Device_Shortcut_NewDevices": "Новые устройства", "Device_Shortcut_OnlineChart": "Присутствие устройств", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Оповещение о сост. ВЫКЛ", "Device_TableHead_Connected_Devices": "Соединения", "Device_TableHead_CustomProps": "Свойства / Действия", @@ -789,4 +790,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} +} \ No newline at end of file diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 74024c1d9..f38ce99ed 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "", "Device_Shortcut_NewDevices": "", "Device_Shortcut_OnlineChart": "", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 3d54e46ca..8f50a7fc3 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Favoriler", "Device_Shortcut_NewDevices": "Yeni Cİhazlar", "Device_Shortcut_OnlineChart": "Cihaz Durumu", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Çalışmama Alarmı", "Device_TableHead_Connected_Devices": "Bağlantılar", "Device_TableHead_CustomProps": "Özellikler / Eylemler", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index fffbddc81..7fe0124e6 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "Вибране", "Device_Shortcut_NewDevices": "Нові пристрої", "Device_Shortcut_OnlineChart": "Наявність пристрою", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Агент Вниз", "Device_TableHead_Connected_Devices": "Зв'язки", "Device_TableHead_CustomProps": "Реквізит / дії", @@ -789,4 +790,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Оновіть значення нижче. Слідкуйте за попереднім форматом. Перевірка не виконана.", "test_event_tooltip": "Перш ніж перевіряти налаштування, збережіть зміни." -} +} \ No newline at end of file diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 74024c1d9..f38ce99ed 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "", "Device_Shortcut_NewDevices": "", "Device_Shortcut_OnlineChart": "", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 4d3b2683f..7ff549021 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -218,6 +218,7 @@ "Device_Shortcut_Favorites": "收藏", "Device_Shortcut_NewDevices": "新设备", "Device_Shortcut_OnlineChart": "设备统计", + "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "提醒宕机", "Device_TableHead_Connected_Devices": "链接", "Device_TableHead_CustomProps": "属性", @@ -789,4 +790,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} +} \ No newline at end of file diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index c7edb4fb3..eab237911 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -100,6 +100,7 @@ class Device(ObjectType): devParentPortSource = String(description="Source tracking for devParentPort (USER, LOCKED, NEWDEV, or plugin prefix)") devParentRelTypeSource = String(description="Source tracking for devParentRelType (USER, LOCKED, NEWDEV, or plugin prefix)") devVlanSource = String(description="Source tracking for devVlan") + devFlapping = String(description="ndicates flapping device (device changing between online/offline states frequently)") class DeviceResult(ObjectType): @@ -266,7 +267,7 @@ def resolve_devices(self, info, options=None): filtered.append(device) devices_data = filtered - # 🔻 START If you change anything here, also update get_device_condition_by_status + # 🔻 START If you change anything here, also update get_device_conditions elif status == "connected": devices_data = [ device @@ -323,7 +324,25 @@ def resolve_devices(self, info, options=None): for device in devices_data if device["devType"] in network_dev_types and device["devPresentLastScan"] == 0 and device["devIsArchived"] == 0 ] - # 🔺 END If you change anything here, also update get_device_condition_by_status + elif status == "unstable_devices": + devices_data = [ + device + for device in devices_data + if device["devIsArchived"] == 0 and device["devFlapping"] == 1 + ] + elif status == "unstable_favorites": + devices_data = [ + device + for device in devices_data + if device["devIsArchived"] == 0 and device["devFavorite"] == 1 and device["devFlapping"] == 1 + ] + elif status == "unstable_network_devices": + devices_data = [ + device + for device in devices_data + if device["devIsArchived"] == 0 and device["devType"] in network_dev_types and device["devFlapping"] == 1 + ] + # 🔺 END If you change anything here, also update get_device_conditions elif status == "all_devices": devices_data = devices_data # keep all diff --git a/server/const.py b/server/const.py index 0ea4ca133..f8ea47827 100755 --- a/server/const.py +++ b/server/const.py @@ -58,70 +58,13 @@ # Convert list to SQL string: wrap each value in single quotes and escape single quotes if needed NULL_EQUIVALENTS_SQL = ",".join("'" + v.replace("'", "''") + "'" for v in NULL_EQUIVALENTS) - # =============================================================================== # SQL queries # =============================================================================== -sql_devices_all = """ - SELECT - rowid, - IFNULL(devMac, '') AS devMac, - IFNULL(devName, '') AS devName, - IFNULL(devOwner, '') AS devOwner, - IFNULL(devType, '') AS devType, - IFNULL(devVendor, '') AS devVendor, - IFNULL(devFavorite, '') AS devFavorite, - IFNULL(devGroup, '') AS devGroup, - IFNULL(devComments, '') AS devComments, - IFNULL(devFirstConnection, '') AS devFirstConnection, - IFNULL(devLastConnection, '') AS devLastConnection, - IFNULL(devLastIP, '') AS devLastIP, - IFNULL(devPrimaryIPv4, '') AS devPrimaryIPv4, - IFNULL(devPrimaryIPv6, '') AS devPrimaryIPv6, - IFNULL(devVlan, '') AS devVlan, - IFNULL(devForceStatus, '') AS devForceStatus, - IFNULL(devStaticIP, '') AS devStaticIP, - IFNULL(devScan, '') AS devScan, - IFNULL(devLogEvents, '') AS devLogEvents, - IFNULL(devAlertEvents, '') AS devAlertEvents, - IFNULL(devAlertDown, '') AS devAlertDown, - IFNULL(devSkipRepeated, '') AS devSkipRepeated, - IFNULL(devLastNotification, '') AS devLastNotification, - IFNULL(devPresentLastScan, 0) AS devPresentLastScan, - IFNULL(devIsNew, '') AS devIsNew, - IFNULL(devLocation, '') AS devLocation, - IFNULL(devIsArchived, '') AS devIsArchived, - IFNULL(devParentMAC, '') AS devParentMAC, - IFNULL(devParentPort, '') AS devParentPort, - IFNULL(devIcon, '') AS devIcon, - IFNULL(devGUID, '') AS devGUID, - IFNULL(devSite, '') AS devSite, - IFNULL(devSSID, '') AS devSSID, - IFNULL(devSyncHubNode, '') AS devSyncHubNode, - IFNULL(devSourcePlugin, '') AS devSourcePlugin, - IFNULL(devCustomProps, '') AS devCustomProps, - IFNULL(devFQDN, '') AS devFQDN, - IFNULL(devParentRelType, '') AS devParentRelType, - IFNULL(devReqNicsOnline, '') AS devReqNicsOnline, - IFNULL(devMacSource, '') AS devMacSource, - IFNULL(devNameSource, '') AS devNameSource, - IFNULL(devFQDNSource, '') AS devFQDNSource, - IFNULL(devLastIPSource, '') AS devLastIPSource, - IFNULL(devVendorSource, '') AS devVendorSource, - IFNULL(devSSIDSource, '') AS devSSIDSource, - IFNULL(devParentMACSource, '') AS devParentMACSource, - IFNULL(devParentPortSource, '') AS devParentPortSource, - IFNULL(devParentRelTypeSource, '') AS devParentRelTypeSource, - IFNULL(devVlanSource, '') AS devVlanSource, - CASE - WHEN devIsNew = 1 THEN 'New' - WHEN devPresentLastScan = 1 THEN 'On-line' - WHEN devPresentLastScan = 0 AND devAlertDown != 0 THEN 'Down' - WHEN devIsArchived = 1 THEN 'Archived' - WHEN devPresentLastScan = 0 THEN 'Off-line' - ELSE 'Unknown status' - END AS devStatus - FROM Devices +sql_devices_all = """ + SELECT + * + FROM DevicesView """ sql_appevents = """select * from AppEvents order by DateTimeCreated desc""" diff --git a/server/db/db_helper.py b/server/db/db_helper.py index 8ceb534ba..54ae5a59b 100755 --- a/server/db/db_helper.py +++ b/server/db/db_helper.py @@ -14,22 +14,28 @@ def get_device_conditions(): network_dev_types = ",".join("'" + v.replace("'", "''") + "'" for v in get_setting_value("NETWORK_DEVICE_TYPES")) - # DO NOT CHANGE ORDER + # Base archived condition + base_active = "devIsArchived=0" + + # DO NOT CHANGE ORDER - if you add or change something update graphql endpoint as well conditions = { - "all": "WHERE devIsArchived=0", - "my": "WHERE devIsArchived=0", + "all": f"WHERE {base_active}", + "my": f"WHERE {base_active}", "connected": "WHERE devPresentLastScan=1", - "favorites": "WHERE devIsArchived=0 AND devFavorite=1", - "new": "WHERE devIsArchived=0 AND devIsNew=1", - "down": "WHERE devIsArchived=0 AND devAlertDown != 0 AND devPresentLastScan=0", - "offline": "WHERE devIsArchived=0 AND devPresentLastScan=0", + "favorites": f"WHERE {base_active} AND devFavorite=1", + "new": f"WHERE {base_active} AND devIsNew=1", + "down": f"WHERE {base_active} AND devAlertDown != 0 AND devPresentLastScan=0", + "offline": f"WHERE {base_active} AND devPresentLastScan=0", "archived": "WHERE devIsArchived=1", - "network_devices": f"WHERE devIsArchived=0 AND devType in ({network_dev_types})", - "network_devices_down": f"WHERE devIsArchived=0 AND devType in ({network_dev_types}) AND devPresentLastScan=0", - "unknown": f"WHERE devIsArchived=0 AND devName in ({NULL_EQUIVALENTS_SQL})", - "known": f"WHERE devIsArchived=0 AND devName not in ({NULL_EQUIVALENTS_SQL})", - "favorites_offline": "WHERE devIsArchived=0 AND devFavorite=1 AND devPresentLastScan=0", - "new_online": "WHERE devIsArchived=0 AND devIsNew=1 AND devPresentLastScan=0", + "network_devices": f"WHERE {base_active} AND devType IN ({network_dev_types})", + "network_devices_down": f"WHERE {base_active} AND devType IN ({network_dev_types}) AND devPresentLastScan=0", + "unknown": f"WHERE {base_active} AND devName IN ({NULL_EQUIVALENTS_SQL})", + "known": f"WHERE {base_active} AND devName NOT IN ({NULL_EQUIVALENTS_SQL})", + "favorites_offline": f"WHERE {base_active} AND devFavorite=1 AND devPresentLastScan=0", + "new_online": f"WHERE {base_active} AND devIsNew=1 AND devPresentLastScan=0", + "unstable_devices": f"WHERE {base_active} AND devFlapping=1", + "unstable_favorites": f"WHERE {base_active} AND devFavorite=1 AND devFlapping=1", + "unstable_network_devices": f"WHERE {base_active} AND devType IN ({network_dev_types}) AND devFlapping=1", } return conditions diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index f2f640acd..d3f18de27 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -232,6 +232,87 @@ def ensure_views(sql) -> bool: """) + FLAP_THRESHOLD = 3 + FLAP_WINDOW_HOURS = 1 + + sql.execute(""" DROP VIEW IF EXISTS DevicesView;""") + sql.execute(f""" CREATE VIEW DevicesView AS + SELECT + rowid, + IFNULL(devMac, '') AS devMac, + IFNULL(devName, '') AS devName, + IFNULL(devOwner, '') AS devOwner, + IFNULL(devType, '') AS devType, + IFNULL(devVendor, '') AS devVendor, + IFNULL(devFavorite, '') AS devFavorite, + IFNULL(devGroup, '') AS devGroup, + IFNULL(devComments, '') AS devComments, + IFNULL(devFirstConnection, '') AS devFirstConnection, + IFNULL(devLastConnection, '') AS devLastConnection, + IFNULL(devLastIP, '') AS devLastIP, + IFNULL(devPrimaryIPv4, '') AS devPrimaryIPv4, + IFNULL(devPrimaryIPv6, '') AS devPrimaryIPv6, + IFNULL(devVlan, '') AS devVlan, + IFNULL(devForceStatus, '') AS devForceStatus, + IFNULL(devStaticIP, '') AS devStaticIP, + IFNULL(devScan, '') AS devScan, + IFNULL(devLogEvents, '') AS devLogEvents, + IFNULL(devAlertEvents, '') AS devAlertEvents, + IFNULL(devAlertDown, '') AS devAlertDown, + IFNULL(devSkipRepeated, '') AS devSkipRepeated, + IFNULL(devLastNotification, '') AS devLastNotification, + IFNULL(devPresentLastScan, 0) AS devPresentLastScan, + IFNULL(devIsNew, '') AS devIsNew, + IFNULL(devLocation, '') AS devLocation, + IFNULL(devIsArchived, '') AS devIsArchived, + IFNULL(devParentMAC, '') AS devParentMAC, + IFNULL(devParentPort, '') AS devParentPort, + IFNULL(devIcon, '') AS devIcon, + IFNULL(devGUID, '') AS devGUID, + IFNULL(devSite, '') AS devSite, + IFNULL(devSSID, '') AS devSSID, + IFNULL(devSyncHubNode, '') AS devSyncHubNode, + IFNULL(devSourcePlugin, '') AS devSourcePlugin, + IFNULL(devCustomProps, '') AS devCustomProps, + IFNULL(devFQDN, '') AS devFQDN, + IFNULL(devParentRelType, '') AS devParentRelType, + IFNULL(devReqNicsOnline, '') AS devReqNicsOnline, + IFNULL(devMacSource, '') AS devMacSource, + IFNULL(devNameSource, '') AS devNameSource, + IFNULL(devFQDNSource, '') AS devFQDNSource, + IFNULL(devLastIPSource, '') AS devLastIPSource, + IFNULL(devVendorSource, '') AS devVendorSource, + IFNULL(devSSIDSource, '') AS devSSIDSource, + IFNULL(devParentMACSource, '') AS devParentMACSource, + IFNULL(devParentPortSource, '') AS devParentPortSource, + IFNULL(devParentRelTypeSource, '') AS devParentRelTypeSource, + IFNULL(devVlanSource, '') AS devVlanSource, + CASE + WHEN devIsNew = 1 THEN 'New' + WHEN devPresentLastScan = 1 THEN 'On-line' + WHEN devPresentLastScan = 0 AND devAlertDown != 0 THEN 'Down' + WHEN devIsArchived = 1 THEN 'Archived' + WHEN devPresentLastScan = 0 THEN 'Off-line' + ELSE 'Unknown status' + END AS devStatus, + CASE + WHEN EXISTS ( + SELECT 1 + FROM Events e + WHERE e.eve_MAC = Devices.devMac + AND e.eve_EventType IN ('Connected','Disconnected','Device Down','Down Reconnected') + AND e.eve_DateTime >= datetime('now', '-{FLAP_WINDOW_HOURS} hours') + GROUP BY e.eve_MAC + HAVING COUNT(*) >= {FLAP_THRESHOLD} + ) + THEN 1 + ELSE 0 + END AS devFlapping + + FROM Devices + + """) + return True diff --git a/server/models/device_instance.py b/server/models/device_instance.py index a2f90bd37..d51fdf0a7 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -338,7 +338,7 @@ def getTotals(self): for key, condition in conditions.items(): # Make sure the alias is SQL-safe (no spaces or special chars) alias = key.replace(" ", "_").lower() - sub_queries.append(f'(SELECT COUNT(*) FROM Devices {condition}) AS "{alias}"') + sub_queries.append(f'(SELECT COUNT(*) FROM DevicesView {condition}) AS "{alias}"') # Join all sub-selects with commas query = "SELECT\n " + ",\n ".join(sub_queries) @@ -360,7 +360,7 @@ def getNamedTotals(self): for key, condition in conditions.items(): # Make sure the alias is SQL-safe (no spaces or special chars) alias = key.replace(" ", "_").lower() - sub_queries.append(f'(SELECT COUNT(*) FROM Devices {condition}) AS "{alias}"') + sub_queries.append(f'(SELECT COUNT(*) FROM DevicesView {condition}) AS "{alias}"') # Join all sub-selects with commas query = "SELECT\n " + ",\n ".join(sub_queries) @@ -381,7 +381,8 @@ def getByStatus(self, status=None): # Build condition for SQL condition = get_device_condition_by_status(status) if status else "" - query = f"SELECT * FROM Devices {condition}" + # Only DevicesView has devFlapping + query = f"SELECT * FROM DevicesView {condition}" sql.execute(query) table_data = [] diff --git a/server/models/event_instance.py b/server/models/event_instance.py index d0dfba843..0b166ad61 100644 --- a/server/models/event_instance.py +++ b/server/models/event_instance.py @@ -218,3 +218,50 @@ def getEventsTotals(self, period: str = "7 days"): # Return as list return [row[0], row[1], row[2], row[3], row[4], row[5]] + + def get_unstable_devices(self, hours: int = 1, threshold: int = 3, macs_only: bool = True): + """ + Return unstable devices based on flap detection. + + A device is considered unstable if it has >= threshold events within the last `hours`. + + Events considered: + - Connected + - Disconnected + - Device Down + - Down Reconnected + + Args: + hours (int): Time window in hours (default: 1) + threshold (int): Minimum number of events to be considered unstable (default: 3) + macs_only (bool): If True, return only MAC addresses (set). Otherwise return full rows. + + Returns: + set[str] OR list[dict] + """ + + if hours <= 0 or threshold <= 0: + mylog("warn", f"[Events] get_unstable_devices invalid params: hours={hours}, threshold={threshold}") + return set() if macs_only else [] + + conn = self._conn() + + sql = """ + SELECT eve_MAC, COUNT(*) as event_count + FROM Events + WHERE eve_EventType IN ('Connected','Disconnected','Device Down','Down Reconnected') + AND eve_DateTime >= datetime('now', ?) + GROUP BY eve_MAC + HAVING COUNT(*) >= ? + """ + + # SQLite expects "-1 hours" format + window = f"-{hours} hours" + + rows = conn.execute(sql, (window, threshold)).fetchall() + conn.close() + + if macs_only: + return {row["eve_MAC"] for row in rows} + + return [dict(row) for row in rows] diff --git a/server/models/plugin_object_instance.py b/server/models/plugin_object_instance.py index 1f13d69c2..30b2bf453 100755 --- a/server/models/plugin_object_instance.py +++ b/server/models/plugin_object_instance.py @@ -49,7 +49,7 @@ def getByPlugin(self, plugin): "SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,) ) - def getLastNCreatedPerPLugin(self, plugin, entries=1): + def getLastNCreatedPerPlugin(self, plugin, entries=1): return self._fetchall( """ SELECT * From 53b2596902c6c844bd7dcb570d937fffbdb39c59 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 23 Feb 2026 03:04:43 +0000 Subject: [PATCH 003/122] Add LLMs.txt --- front/llms.txt | 48 ++++++++++++++++++++++++++++++++++ front/php/templates/footer.php | 3 +-- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 front/llms.txt diff --git a/front/llms.txt b/front/llms.txt new file mode 100644 index 000000000..4c93ef1c0 --- /dev/null +++ b/front/llms.txt @@ -0,0 +1,48 @@ +# For Humans +To set up MCP access for your AI Agent: + +1. Tell it to "Please go here and set up MCP using HOST:BACKEND_PORT " +-or- +2. Copy and paste the contents of this page and let it know your + + + +--- + +# For AI Agents: NetAlertX MCP Setup + +NetAlertX is a network monitoring system. Connect to its **MCP (Model Context Protocol) server** for live access to devices, scans, and events. + +## 1. Gather Connection Details +Ask the user for the following if not provided: +1. **API Token:** Found in **`/settings.php`** under **System / API**. If you were directed to this server, try to browse to /settings.php on your own. +2. **MCP URL:** Typically `http://:20212/mcp/sse`. (The UI is usually on port `20211`, and the MCP/GraphQL backend is on `20212`). + +*Note: Test HTTP connectivity. If port `20212` fails, ask the user if the `GRAPHQL_PORT` was changed in settings or if a reverse proxy is used.* + +## 2. Configure MCP Client +Use the gathered details to configure your MCP connection: + +```json +{ + "mcpServers": { + "netalertx": { + "url": "http://:/mcp/sse", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +## 3. Capabilities +Once connected, you can: +- Query and manage devices (search, filter, MAC details, Wake-on-LAN). +- Trigger network scans (ARP, NMAP, Avahi). +- Manage events, sessions, and notifications. + +## 4. Important Notes +- **UI Refresh:** When you modify data, tell the user to click the in-app refresh button (🔄) to see changes. +- **REST API Fallback:** If MCP is unavailable, use the REST API at `http://:/openapi.json`. All calls must be made with `Authorization: Bearer `. This page API is manual, relies on your abilities, and context-heavy so only use it as a last resort. +- **Authentication:** The API token is distinct from the UI login password and must be obtained/changed in the frontend /settings.php diff --git a/front/php/templates/footer.php b/front/php/templates/footer.php index 5b4058e9d..02caeaff5 100755 --- a/front/php/templates/footer.php +++ b/front/php/templates/footer.php @@ -24,11 +24,10 @@ NetAlertx - -
    | + | | | | From cb0b3b607d76dd43ff6dff0a9fdb2fc94108af8a Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Sun, 22 Feb 2026 22:11:40 -0500 Subject: [PATCH 004/122] Update front/llms.txt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- front/llms.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/llms.txt b/front/llms.txt index 4c93ef1c0..0242ddd40 100644 --- a/front/llms.txt +++ b/front/llms.txt @@ -45,4 +45,4 @@ Once connected, you can: ## 4. Important Notes - **UI Refresh:** When you modify data, tell the user to click the in-app refresh button (🔄) to see changes. - **REST API Fallback:** If MCP is unavailable, use the REST API at `http://:/openapi.json`. All calls must be made with `Authorization: Bearer `. This page API is manual, relies on your abilities, and context-heavy so only use it as a last resort. -- **Authentication:** The API token is distinct from the UI login password and must be obtained/changed in the frontend /settings.php +- **Authentication:** The API token is distinct from the UI login password and must be obtained/changed in the frontend /settings.php. From fe226597944b1db1320ede039b94de835a22eb51 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 23 Feb 2026 03:15:21 +0000 Subject: [PATCH 005/122] coderabbit suggested changes --- front/llms.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/llms.txt b/front/llms.txt index 0242ddd40..d7cfd6703 100644 --- a/front/llms.txt +++ b/front/llms.txt @@ -3,7 +3,7 @@ To set up MCP access for your AI Agent: 1. Tell it to "Please go here and set up MCP using HOST:BACKEND_PORT " -or- -2. Copy and paste the contents of this page and let it know your +2. Copy and paste the contents of this page and let it know your HOST and API token. @@ -44,5 +44,5 @@ Once connected, you can: ## 4. Important Notes - **UI Refresh:** When you modify data, tell the user to click the in-app refresh button (🔄) to see changes. -- **REST API Fallback:** If MCP is unavailable, use the REST API at `http://:/openapi.json`. All calls must be made with `Authorization: Bearer `. This page API is manual, relies on your abilities, and context-heavy so only use it as a last resort. -- **Authentication:** The API token is distinct from the UI login password and must be obtained/changed in the frontend /settings.php. +- **REST API Fallback:** If MCP is unavailable, retrieve the OpenAPI spec from `http://:/openapi.json` to discover available endpoints, then call those endpoints with `Authorization: Bearer `. This approach is context-heavy and manual, so use it as a last resort. +- **Authentication:** The API token is distinct from the UI login password and must be obtained/changed in the frontend /settings.php From 4316a436ebd0024cfb827f3f5eff041932cd509c Mon Sep 17 00:00:00 2001 From: MrMeatikins Date: Sun, 22 Feb 2026 22:20:28 -0500 Subject: [PATCH 006/122] Apply CodeRabbit suggestions --- .env | 2 +- install/docker/README.md | 4 ++-- install/docker/docker-compose.dev.yml | 8 ++++---- install/docker/docker-compose.yml | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.env b/.env index 9344583d7..128ddcbcd 100755 --- a/.env +++ b/.env @@ -7,7 +7,7 @@ LOGS_LOCATION=/path/to/docker_logs #ENVIRONMENT VARIABLES TZ=Europe/Paris -PORT=20211 +NETALERTX_PORT=20211 #DEVELOPMENT VARIABLES diff --git a/install/docker/README.md b/install/docker/README.md index a0adc6388..d6267ba18 100644 --- a/install/docker/README.md +++ b/install/docker/README.md @@ -40,8 +40,8 @@ The container uses `network_mode: host` by default. This is **required** for cor ### Environment Variables You can customize the application by editing the `environment` section in the compose file. Common overrides include: -* `TZ`: Timezone (mapped via `/etc/localtime`). -* `SCAN_SUBNETS`: Define specific subnets to scan if auto-detection fails (e.g., `192.168.1.0/24`). +* Timezone is controlled by the read-only `/etc/localtime` bind mount (do not use a `TZ` variable). +* `SCAN_SUBNETS`: Not present by default in the compose `environment` blocks. You must manually add it if you need to override subnet scanning (e.g., `192.168.1.0/24`). For a full list of environment variables and configuration options, see the [Customize with Environment Variables](https://docs.netalertx.com/DOCKER_COMPOSE/?h=environmental+variables#customize-with-environmental-variables) section in the documentation. diff --git a/install/docker/docker-compose.dev.yml b/install/docker/docker-compose.dev.yml index dae1069f2..a2c12b245 100644 --- a/install/docker/docker-compose.dev.yml +++ b/install/docker/docker-compose.dev.yml @@ -23,13 +23,13 @@ services: target: /etc/localtime read_only: true tmpfs: - - "/tmp:mode=1700,uid=0,gid=0,rw,noexec,nosuid,nodev,async,noatime,nodiratime" + - "/tmp:mode=1777,rw,noexec,nosuid,nodev,async,noatime,nodiratime" environment: PUID: ${NETALERTX_UID:-20211} PGID: ${NETALERTX_GID:-20211} - LISTEN_ADDR: ${LISTEN_ADDR:-0.0.0.0} - PORT: ${PORT:-20211} - GRAPHQL_PORT: ${GRAPHQL_PORT:-20212} + LISTEN_ADDR: ${NETALERTX_LISTEN_ADDR:-0.0.0.0} + PORT: ${NETALERTX_PORT:-20211} + GRAPHQL_PORT: ${NETALERTX_GRAPHQL_PORT:-20212} mem_limit: 2048m mem_reservation: 1024m cpu_shares: 512 diff --git a/install/docker/docker-compose.yml b/install/docker/docker-compose.yml index 6622efcc0..5dea86b07 100644 --- a/install/docker/docker-compose.yml +++ b/install/docker/docker-compose.yml @@ -23,13 +23,13 @@ services: target: /etc/localtime read_only: true tmpfs: - - "/tmp:mode=1700,uid=0,gid=0,rw,noexec,nosuid,nodev,async,noatime,nodiratime" + - "/tmp:mode=1777,rw,noexec,nosuid,nodev,async,noatime,nodiratime" environment: PUID: ${NETALERTX_UID:-20211} PGID: ${NETALERTX_GID:-20211} - LISTEN_ADDR: ${LISTEN_ADDR:-0.0.0.0} - PORT: ${PORT:-20211} - GRAPHQL_PORT: ${GRAPHQL_PORT:-20212} + LISTEN_ADDR: ${NETALERTX_LISTEN_ADDR:-0.0.0.0} + PORT: ${NETALERTX_PORT:-20211} + GRAPHQL_PORT: ${NETALERTX_GRAPHQL_PORT:-20212} mem_limit: 2048m mem_reservation: 1024m cpu_shares: 512 From c7e754966ee7483a7e8cade8e1bb0898fedf0ac6 Mon Sep 17 00:00:00 2001 From: MrMeatikins Date: Sun, 22 Feb 2026 22:22:21 -0500 Subject: [PATCH 007/122] Simplify README to link official docs --- install/docker/README.md | 45 ++-------------------------------------- 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/install/docker/README.md b/install/docker/README.md index d6267ba18..b07768b3d 100644 --- a/install/docker/README.md +++ b/install/docker/README.md @@ -1,49 +1,8 @@ # 🐳 Docker Compose Installation -This folder provides standard Docker Compose configurations to get **NetAlertX** up and running quickly. This method is ideal for users on **Proxmox**, **TrueNAS Scale**, **Portainer**, or standard Linux hosts who prefer a simple, declarative setup. +For complete and up-to-date instructions on how to install NetAlertX using Docker, including volumes, networking, and environment variables, please refer to the official documentation: -## 🚀 Getting Started - -### 1. Choose your flavor - -* **Stable (Recommended):** Use `docker-compose.yml`. This tracks the latest stable release. -* **Development:** Use `docker-compose.dev.yml`. This tracks the `dev` branch and contains the latest features (and potential bugs). - -### 2. Deploy - -Download the chosen file to a directory on your server (e.g., `netalertx/`). You can switch between Stable and Dev versions easily by pointing to the specific file. - -**For Stable:** -```bash -docker compose -f docker-compose.yml up -d --force-recreate -``` - -**For Development:** -```bash -docker compose -f docker-compose.dev.yml up -d --force-recreate -``` - -> [!NOTE] -> The `--force-recreate` flag ensures that your container is rebuilt with the latest configuration, making it seamless to switch between versions. Initial startup might take a few minutes. - -## ⚙️ Configuration - -### Storage -By default, these files use a **Docker Named Volume** (`netalertx_data`) for persistent storage. This is the easiest way to get started and ensures data persists across upgrades. - -> [!TIP] -> If you prefer to map a specific folder on your host (e.g., `/mnt/data/netalertx` on Proxmox or TrueNAS), edit the `volumes` section in the compose file to use a **bind mount** instead. - -### Networking -The container uses `network_mode: host` by default. This is **required** for core features like ARP scanning (`arp-scan`) to work correctly, as the container needs direct access to the network interface to discover devices. - -### Environment Variables -You can customize the application by editing the `environment` section in the compose file. Common overrides include: - -* Timezone is controlled by the read-only `/etc/localtime` bind mount (do not use a `TZ` variable). -* `SCAN_SUBNETS`: Not present by default in the compose `environment` blocks. You must manually add it if you need to override subnet scanning (e.g., `192.168.1.0/24`). - -For a full list of environment variables and configuration options, see the [Customize with Environment Variables](https://docs.netalertx.com/DOCKER_COMPOSE/?h=environmental+variables#customize-with-environmental-variables) section in the documentation. +👉 **[NetAlertX Docker Installation Guide](https://docs.netalertx.com/DOCKER_INSTALLATION/)** --- [⬅️ Back to Main Repo](../../README.md) From 14625926f95861d80ebcbaa18c0b61c8f42d3fd8 Mon Sep 17 00:00:00 2001 From: MrMeatikins Date: Sun, 22 Feb 2026 22:37:24 -0500 Subject: [PATCH 008/122] Revert env variables per jokob-sk review Co-authored-by: jokob-sk --- .env | 3 +-- install/docker/docker-compose.dev.yml | 6 +++--- install/docker/docker-compose.yml | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.env b/.env index 128ddcbcd..7ab4755d0 100755 --- a/.env +++ b/.env @@ -6,8 +6,7 @@ LOGS_LOCATION=/path/to/docker_logs #ENVIRONMENT VARIABLES -TZ=Europe/Paris -NETALERTX_PORT=20211 +PORT=20211 #DEVELOPMENT VARIABLES diff --git a/install/docker/docker-compose.dev.yml b/install/docker/docker-compose.dev.yml index a2c12b245..6854934b2 100644 --- a/install/docker/docker-compose.dev.yml +++ b/install/docker/docker-compose.dev.yml @@ -27,9 +27,9 @@ services: environment: PUID: ${NETALERTX_UID:-20211} PGID: ${NETALERTX_GID:-20211} - LISTEN_ADDR: ${NETALERTX_LISTEN_ADDR:-0.0.0.0} - PORT: ${NETALERTX_PORT:-20211} - GRAPHQL_PORT: ${NETALERTX_GRAPHQL_PORT:-20212} + LISTEN_ADDR: ${LISTEN_ADDR:-0.0.0.0} + PORT: ${PORT:-20211} + GRAPHQL_PORT: ${GRAPHQL_PORT:-20212} mem_limit: 2048m mem_reservation: 1024m cpu_shares: 512 diff --git a/install/docker/docker-compose.yml b/install/docker/docker-compose.yml index 5dea86b07..3f842a62a 100644 --- a/install/docker/docker-compose.yml +++ b/install/docker/docker-compose.yml @@ -27,9 +27,9 @@ services: environment: PUID: ${NETALERTX_UID:-20211} PGID: ${NETALERTX_GID:-20211} - LISTEN_ADDR: ${NETALERTX_LISTEN_ADDR:-0.0.0.0} - PORT: ${NETALERTX_PORT:-20211} - GRAPHQL_PORT: ${NETALERTX_GRAPHQL_PORT:-20212} + LISTEN_ADDR: ${LISTEN_ADDR:-0.0.0.0} + PORT: ${PORT:-20211} + GRAPHQL_PORT: ${GRAPHQL_PORT:-20212} mem_limit: 2048m mem_reservation: 1024m cpu_shares: 512 From 8fc78f02e96e3d302af1f06fd953e79773445308 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 24 Feb 2026 07:07:55 +1100 Subject: [PATCH 009/122] BE: Better arpo-scan accuracy w/ system optimization Signed-off-by: jokob-sk --- ....sh => 36-override-individual-settings.sh} | 2 +- .../entrypoint.d/37-host-optimization.sh | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) rename install/production-filesystem/entrypoint.d/{36-override-loaded-plugins.sh => 36-override-individual-settings.sh} (91%) create mode 100644 install/production-filesystem/entrypoint.d/37-host-optimization.sh diff --git a/install/production-filesystem/entrypoint.d/36-override-loaded-plugins.sh b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh similarity index 91% rename from install/production-filesystem/entrypoint.d/36-override-loaded-plugins.sh rename to install/production-filesystem/entrypoint.d/36-override-individual-settings.sh index 829a29282..0ed4fc185 100644 --- a/install/production-filesystem/entrypoint.d/36-override-loaded-plugins.sh +++ b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh @@ -1,5 +1,5 @@ #!/bin/bash -# 36-override-loaded-plugins.sh - Applies environment variable overrides to app.conf +# 36-override-individual-settings.sh - Applies environment variable overrides to app.conf set -eu diff --git a/install/production-filesystem/entrypoint.d/37-host-optimization.sh b/install/production-filesystem/entrypoint.d/37-host-optimization.sh new file mode 100644 index 000000000..6cfe9b9f5 --- /dev/null +++ b/install/production-filesystem/entrypoint.d/37-host-optimization.sh @@ -0,0 +1,93 @@ +#!/bin/sh + +# 37-host-optimization.sh: Apply and validate network optimizations (ARP flux fix) +# +# This script improves detection accuracy by ensuring proper ARP behavior. +# It attempts to apply sysctl settings and warns if not possible. + +# --- Color Codes --- +RED=$(printf '\033[1;31m') +YELLOW=$(printf '\033[1;33m') +RESET=$(printf '\033[0m') + +# --- Skip flag --- +if [ -n "${SKIP_OPTIMIZATIONS:-}" ]; then + exit 0 +fi + +# --- Helpers --- + +get_sysctl() { + sysctl -n "$1" 2>/dev/null || echo "unknown" +} + +set_sysctl_if_needed() { + key="$1" + expected="$2" + + current="$(get_sysctl "$key")" + + # Already correct + if [ "$current" = "$expected" ]; then + return 0 + fi + + # Try to apply + if sysctl -w "$key=$expected" >/dev/null 2>&1; then + return 0 + fi + + # Failed + return 1 +} + +# --- Apply Settings (best effort) --- + +failed=0 + +set_sysctl_if_needed net.ipv4.conf.all.arp_ignore 1 || failed=1 +set_sysctl_if_needed net.ipv4.conf.all.arp_announce 2 || failed=1 +set_sysctl_if_needed net.ipv4.conf.default.arp_ignore 1 || failed=1 +set_sysctl_if_needed net.ipv4.conf.default.arp_announce 2 || failed=1 + +# --- Validate final state --- + +all_ignore="$(get_sysctl net.ipv4.conf.all.arp_ignore)" +all_announce="$(get_sysctl net.ipv4.conf.all.arp_announce)" + +# --- Warning Output --- + +if [ "$all_ignore" != "1" ] || [ "$all_announce" != "2" ]; then + >&2 printf "%s" "${YELLOW}" + >&2 cat <&2 printf "%s" "${RESET}" +fi From 54e9d521263a11083bc072fa9caf0c19523be6ba Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 24 Feb 2026 07:08:29 +1100 Subject: [PATCH 010/122] DOCS: readme Signed-off-by: jokob-sk --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1bb1af9e5..5f893dda6 100755 --- a/README.md +++ b/README.md @@ -168,9 +168,9 @@ Get notified about a new release, what new functionality you can use and about b ### 🔀 Other Alternative Apps - [Fing](https://www.fing.com/) - Network scanner app for your Internet security (Commercial, Phone App, Proprietary hardware) -- [NetBox](https://netboxlabs.com/) - Network management software (Commercial) +- [NetBox](https://netboxlabs.com/) - The gold standard for Network Source of Truth (NSoT) and IPAM. - [Zabbix](https://www.zabbix.com/) or [Nagios](https://www.nagios.org/) - Strong focus on infrastructure monitoring. -- [NetAlertX](https://netalertx.com) - The streamlined, discovery-focused alternative for real-time asset intelligence. +- [NetAlertX](https://netalertx.com) - The streamlined, discovery-focused choice for real-time asset intelligence and noise-free alerting. ### 💙 Donations @@ -208,6 +208,9 @@ Proudly using [Weblate](https://hosted.weblate.org/projects/pialert/). Help out > GPL 3.0 | [Read more here](LICENSE.txt) | Source of the [animated GIF (Loading Animation)](https://commons.wikimedia.org/wiki/File:Loading_Animation.gif) | Source of the [selfhosted Fonts](https://github.com/adobe-fonts/source-sans) +_All product names, logos, and brands are property of their respective owners. All company, product and service names used in this website are for identification purposes only. Use of these names, logos, and brands does not imply endorsement._ + + [main]: ./docs/img/devices_split.png "Main screen" [device_details]: ./docs/img/device_details.png "Screen 1" From c192f2c0321560c0018451b63e4a27169f65eee6 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 24 Feb 2026 07:27:30 +1100 Subject: [PATCH 011/122] FE: mixed case on MACs work Signed-off-by: jokob-sk --- front/deviceDetailsEdit.php | 2 +- front/deviceDetailsTools.php | 12 ++++++------ front/index.php | 2 +- front/network.php | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/front/deviceDetailsEdit.php b/front/deviceDetailsEdit.php index d121e8c80..2ff373969 100755 --- a/front/deviceDetailsEdit.php +++ b/front/deviceDetailsEdit.php @@ -363,7 +363,7 @@ class="fa fa-circle-info pointer helpIconSmallTopRight" generateSimpleForm(relevantSettings); - toggleNetworkConfiguration(mac == 'Internet') + toggleNetworkConfiguration(mac.toLowerCase() == 'internet') initSelect2(); initHoverNodeInfo(); diff --git a/front/deviceDetailsTools.php b/front/deviceDetailsTools.php index 0c0756ac1..404c8349a 100755 --- a/front/deviceDetailsTools.php +++ b/front/deviceDetailsTools.php @@ -5,7 +5,7 @@ ?> - +

    @@ -24,7 +24,7 @@ - +

    @@ -47,7 +47,7 @@ - +

    @@ -108,7 +108,7 @@ class="btn btn-default pa-btn pa-btn-delete" - +

    @@ -126,7 +126,7 @@ class="btn btn-default pa-btn pa-btn-delete" - +

    @@ -144,7 +144,7 @@ class="btn btn-default pa-btn pa-btn-delete" - +

    diff --git a/front/index.php b/front/index.php index ece4e8837..ff57102ed 100755 --- a/front/index.php +++ b/front/index.php @@ -11,7 +11,7 @@ // session_start(); // } -session_start(); +// session_start(); const DEFAULT_REDIRECT = '/devices.php'; diff --git a/front/network.php b/front/network.php index 671628fcf..4ea6070d3 100755 --- a/front/network.php +++ b/front/network.php @@ -630,7 +630,7 @@ function getHierarchy() for(i in deviceListGlobal) { - if(deviceListGlobal[i].devMac == 'Internet') + if(deviceListGlobal[i].devMac.toLowerCase() == 'internet') { internetNode = deviceListGlobal[i]; From f1fc9f24b13324d2bee1c8e6af08d9b804bc3c21 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Tue, 24 Feb 2026 07:53:58 +1100 Subject: [PATCH 012/122] FE: README Signed-off-by: jokob-sk --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 5f893dda6..38e48d1b4 100755 --- a/README.md +++ b/README.md @@ -207,10 +207,8 @@ Proudly using [Weblate](https://hosted.weblate.org/projects/pialert/). Help out ### License > GPL 3.0 | [Read more here](LICENSE.txt) | Source of the [animated GIF (Loading Animation)](https://commons.wikimedia.org/wiki/File:Loading_Animation.gif) | Source of the [selfhosted Fonts](https://github.com/adobe-fonts/source-sans) - _All product names, logos, and brands are property of their respective owners. All company, product and service names used in this website are for identification purposes only. Use of these names, logos, and brands does not imply endorsement._ - [main]: ./docs/img/devices_split.png "Main screen" [device_details]: ./docs/img/device_details.png "Screen 1" From 0172ab4311bfd075cb4d3ca24eca66f0b4abda2e Mon Sep 17 00:00:00 2001 From: mid Date: Mon, 23 Feb 2026 07:40:28 +0100 Subject: [PATCH 013/122] Translated using Weblate (Japanese) Currently translated at 100.0% (791 of 791 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/ --- front/php/templates/language/ja_jp.json | 68 ++++++++++++------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 7262ca5a1..513399c57 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -27,7 +27,7 @@ "AppEvents_ObjectType": "オブジェクトタイプ", "AppEvents_Plugin": "プラグイン", "AppEvents_Type": "種別", - "BACKEND_API_URL_description": "バックエンドAPIのURLを生成するために使用します。リバースプロキシを使用してGRAPHQL_PORTにマッピングする場合は指定してください。ポート番号を含むhttp://で始まる完全なURLを入力してください(末尾のスラッシュ/は不要です)。", + "BACKEND_API_URL_description": "フロントエンドからバックエンドに通信するために使用します。 デフォルトでは/serverに設定されており、通常変更する必要はありません。", "BACKEND_API_URL_name": "バックエンド API URL", "BackDevDetail_Actions_Ask_Run": "このアクションを実行してよろしいですか?", "BackDevDetail_Actions_Not_Registered": "登録されていないアクション: ", @@ -98,10 +98,10 @@ "DevDetail_MainInfo_Network": "ノード(MAC)", "DevDetail_MainInfo_Network_Port": "ポート", "DevDetail_MainInfo_Network_Site": "サイト", - "DevDetail_MainInfo_Network_Title": "ネットワーク", + "DevDetail_MainInfo_Network_Title": "ネットワーク詳細", "DevDetail_MainInfo_Owner": "所有者", "DevDetail_MainInfo_SSID": "SSID", - "DevDetail_MainInfo_Title": "メイン情報", + "DevDetail_MainInfo_Title": "デバイス情報", "DevDetail_MainInfo_Type": "種別", "DevDetail_MainInfo_Vendor": "ベンダー", "DevDetail_MainInfo_mac": "MAC", @@ -203,10 +203,10 @@ "Device_MultiEdit_MassActions": "大量のアクション:", "Device_MultiEdit_No_Devices": "デバイスが選択されていません。", "Device_MultiEdit_Tooltip": "注意。これをクリックすると、左側の値が上記で選択したすべてのデバイスに適用されます。", - "Device_Save_Failed": "", - "Device_Save_Unauthorized": "", - "Device_Saved_Success": "", - "Device_Saved_Unexpected": "", + "Device_Save_Failed": "デバイスの保存に失敗しました", + "Device_Save_Unauthorized": "許可されていない - 無効なAPIトークン", + "Device_Saved_Success": "デバイスが正常に保存されました", + "Device_Saved_Unexpected": "デバイスの更新で予期せぬ応答がありました", "Device_Searchbox": "検索", "Device_Shortcut_AllDevices": "自分のデバイス", "Device_Shortcut_AllNodes": "全ノード", @@ -218,7 +218,7 @@ "Device_Shortcut_Favorites": "お気に入り", "Device_Shortcut_NewDevices": "新規デバイス", "Device_Shortcut_OnlineChart": "デバイス検出", - "Device_Shortcut_Unstable": "", + "Device_Shortcut_Unstable": "不安定", "Device_TableHead_AlertDown": "ダウンアラート", "Device_TableHead_Connected_Devices": "接続", "Device_TableHead_CustomProps": "属性 / アクション", @@ -227,8 +227,8 @@ "Device_TableHead_FirstSession": "初回セッション", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "グループ", - "Device_TableHead_IPv4": "", - "Device_TableHead_IPv6": "", + "Device_TableHead_IPv4": "IPv4", + "Device_TableHead_IPv6": "IPv6", "Device_TableHead_Icon": "アイコン", "Device_TableHead_LastIP": "直近のIP", "Device_TableHead_LastIPOrder": "直近のIP順", @@ -252,7 +252,7 @@ "Device_TableHead_SyncHubNodeName": "同期ノード", "Device_TableHead_Type": "種別", "Device_TableHead_Vendor": "ベンダー", - "Device_TableHead_Vlan": "", + "Device_TableHead_Vlan": "VLAN", "Device_Table_Not_Network_Device": "ネットワーク機器として構成されていない", "Device_Table_info": "_START_~_END_を表示 / _TOTAL_ 件中", "Device_Table_nav_next": "次", @@ -300,14 +300,14 @@ "Events_Tablelenght": "_MENU_件ずつ表示", "Events_Tablelenght_all": "全件", "Events_Title": "イベント", - "FakeMAC_hover": "自動検出 - デバイスがFAKE MACアドレス(FA:CEまたは00:1Aで始まる)を使用しているかどうかを示します。これは通常、本来のMACアドレスを検出できないプラグインによる生成か、ダミーデバイスの作成によって使用されます。", - "FieldLock_Error": "", - "FieldLock_Lock_Tooltip": "", - "FieldLock_Locked": "", - "FieldLock_SaveBeforeLocking": "", - "FieldLock_Source_Label": "", - "FieldLock_Unlock_Tooltip": "", - "FieldLock_Unlocked": "", + "FakeMAC_hover": "このデバイスは偽装MACアドレスを使用しています", + "FieldLock_Error": "フィールドロック状態の更新エラー", + "FieldLock_Lock_Tooltip": "フィールドをロック(プラグインによる上書きを防止)", + "FieldLock_Locked": "フィールドロック", + "FieldLock_SaveBeforeLocking": "変更を保存してからロックしてください", + "FieldLock_Source_Label": "Source: ", + "FieldLock_Unlock_Tooltip": "フィールドのロック解除(プラグインによる上書きを許可)", + "FieldLock_Unlocked": "フィールドロック解除", "GRAPHQL_PORT_description": "GraphQLサーバーのポート番号。このホスト上のすべてのアプリケーションおよびNetAlertXインスタンスにおいて、ポートが一意であることを確認してください。", "GRAPHQL_PORT_name": "GraphQLポート", "Gen_Action": "アクション", @@ -415,10 +415,10 @@ "Maintenance_Tool_ImportPastedConfig": "設定のインポート(貼り付け)", "Maintenance_Tool_ImportPastedConfig_noti_text": "貼り付けた設定を本当にインポートしますか?これによりapp.confファイルが完全に上書きされます。", "Maintenance_Tool_ImportPastedConfig_text": "アプリケーション設定をすべて含むapp.confファイルをインポートします。まず設定のエクスポートで現在のapp.confファイルをダウンロードすることをお勧めします。", - "Maintenance_Tool_UnlockFields": "", - "Maintenance_Tool_UnlockFields_noti": "", - "Maintenance_Tool_UnlockFields_noti_text": "", - "Maintenance_Tool_UnlockFields_text": "", + "Maintenance_Tool_UnlockFields": "デバイスフィールドのロック解除", + "Maintenance_Tool_UnlockFields_noti": "デバイスフィールドのロック解除", + "Maintenance_Tool_UnlockFields_noti_text": "全デバイスのデバイスフィールドのソース値(LOCKED/USER)をすべてクリアしてもよろしいですか? この操作は元に戻せません。", + "Maintenance_Tool_UnlockFields_text": "このツールは、すべてのデバイスにおいて追跡対象フィールドからすべてのソース値を削除し、プラグインおよびユーザー向けに全フィールドを解放します。登録されたデバイス全体に影響を与えるため、慎重に使用してください。", "Maintenance_Tool_arpscansw": "arpスキャンの切り替え(オン/オフ)", "Maintenance_Tool_arpscansw_noti": "arpスキャンをオンまたはオフにする", "Maintenance_Tool_arpscansw_noti_text": "スキャンをオフにした場合、再度有効化されるまでオフのままとなります。", @@ -428,9 +428,9 @@ "Maintenance_Tool_backup_noti_text": "データベースのバックアップを実行してもよろしいですか? 現在スキャンが実行されていないことを確認してください。", "Maintenance_Tool_backup_text": "データベースのバックアップは、作成日をファイル名としたzipアーカイブとしてデータベースディレクトリ内に配置されます。バックアップの最大数は存在しません。", "Maintenance_Tool_check_visible": "チェックを外すと列を非表示にします。", - "Maintenance_Tool_clearSourceFields_selected": "", - "Maintenance_Tool_clearSourceFields_selected_noti": "", - "Maintenance_Tool_clearSourceFields_selected_text": "", + "Maintenance_Tool_clearSourceFields_selected": "ソースフィールドをクリア", + "Maintenance_Tool_clearSourceFields_selected_noti": "ソースをクリア", + "Maintenance_Tool_clearSourceFields_selected_text": "選択したデバイスのすべてのソースフィールドをクリアします。この操作は取り消せません。", "Maintenance_Tool_darkmode": "モード切替(ダーク/ライト)", "Maintenance_Tool_darkmode_noti": "モード切替", "Maintenance_Tool_darkmode_noti_text": "テーマ変更後、変更を有効化するためにページを再読み込みします。必要に応じて、キャッシュをクリアする必要があります。", @@ -461,7 +461,7 @@ "Maintenance_Tool_del_unknowndev_noti": "(Unknown)のデバイスを削除", "Maintenance_Tool_del_unknowndev_noti_text": "すべての(Unknown)のデバイスと(name not found)のデバイスを削除してもよろしいですか?", "Maintenance_Tool_del_unknowndev_text": "この機能を使用する前に、必ずバックアップを作成してください。削除操作は元に戻せません。データベースから(Unknown)という名前のデバイスをすべて削除します。", - "Maintenance_Tool_del_unlockFields_selecteddev_text": "", + "Maintenance_Tool_del_unlockFields_selecteddev_text": "これにより、選択したデバイスの「LOCKED/USER」フィールドのロックが解除されます。この操作は取り消せません。", "Maintenance_Tool_displayed_columns_text": "デバイスページの列の表示状態と順序を変更します。", "Maintenance_Tool_drag_me": "ドラッグして列を並べ替え。", "Maintenance_Tool_order_columns_text": "Maintenance_Tool_order_columns_text", @@ -473,8 +473,8 @@ "Maintenance_Tool_restore_noti": "DB復元", "Maintenance_Tool_restore_noti_text": "データベースの復元を実行してもよろしいですか? 現在スキャンが実行されていないことを確認してください。", "Maintenance_Tool_restore_text": "最新のバックアップはボタンから復元できますが、それ以前のバックアップは手動でのみ復元可能です。復元後は、バックアップ作成時にデータベースが書き込まれていた場合に備え、安全のためデータベースの整合性チェックを実施してください。", - "Maintenance_Tool_unlockFields_selecteddev": "", - "Maintenance_Tool_unlockFields_selecteddev_noti": "", + "Maintenance_Tool_unlockFields_selecteddev": "デバイスフィールドのロック解除", + "Maintenance_Tool_unlockFields_selecteddev_noti": "フィールドのロック解除", "Maintenance_Tool_upgrade_database_noti": "データベースアップグレード", "Maintenance_Tool_upgrade_database_noti_text": "データベースをアップグレードしてもよろしいですか?
    (アーカイブすることをお勧めします)", "Maintenance_Tool_upgrade_database_text": "このボタンをクリックすると、データベースがアップグレードされ、過去12時間のネットワーク活動チャートが表示可能になります。問題発生に備え、データベースのバックアップを必ず行ってください。", @@ -512,7 +512,7 @@ "Navigation_Devices": "デバイス", "Navigation_Donations": "寄付", "Navigation_Events": "イベント", - "Navigation_Integrations": "統合", + "Navigation_Integrations": "連携", "Navigation_Maintenance": "メンテナンス", "Navigation_Monitoring": "監視", "Navigation_Network": "ネットワーク", @@ -593,7 +593,7 @@ "Presence_CalHead_week": "週", "Presence_CalHead_year": "年", "Presence_CallHead_Devices": "デバイス", - "Presence_Key_OnlineNow": "現在までオンライン", + "Presence_Key_OnlineNow": "オンライン継続中", "Presence_Key_OnlineNow_desc": "前回のスキャンでオンライン状態と検出されたデバイス。", "Presence_Key_OnlinePast": "オンラインだった時間", "Presence_Key_OnlinePastMiss": "オンラインだった時間(不一致)", @@ -616,7 +616,7 @@ "REPORT_MAIL_description": "有効化すると、購読した変更点のリストが記載されたメールが送信されます。以下のSMTP設定に関連する残りの設定もすべて入力してください。問題が発生した場合は、LOG_LEVELdebugに設定し、エラーログを確認してください。", "REPORT_MAIL_name": "メールを有効化", "REPORT_TITLE": "レポート", - "RandomMAC_hover": "自動検出 - デバイスがMACアドレスをランダム化しているかどうかを示します。UI_NOT_RANDOM_MAC設定で特定のMACアドレスを除外できます。詳細はこちらをクリックしてください。", + "RandomMAC_hover": "このデバイスはランダムなMACアドレスを使用しています", "Reports_Sent_Log": "送信レポートログ", "SCAN_SUBNETS_description": "ほとんどのネットワーク内スキャナー(ARP-SCAN、NMAP、NSLOOKUP、DIG)は、特定のネットワークインターフェースとサブネットをスキャンすることに依存しています。この設定に関するヘルプについては、サブネットのドキュメントを確認してください。特にVLAN、サポートされているVLANの種類、ネットワークマスクとインターフェースの確認方法についてです。

    ネットワーク内スキャナーの代替手段として、NetAlertXがネットワークにアクセスする必要のない他のデバイススキャナー/インポーター(UNIFI、dhcp.leases、PiHoleなど)を有効化できます。

    注:スキャン時間自体は確認するIPアドレス数に依存するため、適切なネットワークマスクとインターフェースで慎重に設定してください。", "SCAN_SUBNETS_name": "スキャン対象ネットワーク", @@ -624,7 +624,7 @@ "Setting_Override": "上書き値", "Setting_Override_Description": "このオプションを有効にすると、アプリが提供するデフォルト値が上記で指定された値で上書きされます。", "Settings_Metadata_Toggle": "指定された設定のメタデータを表示/非表示にする。", - "Settings_Show_Description": "設定の説明を表示する。", + "Settings_Show_Description": "説明を表示", "Settings_device_Scanners_desync": "⚠デバイススキャナーのスケジュールが同期されていません。", "Settings_device_Scanners_desync_popup": "デバイススキャナーのスケジュール(*_RUN_SCHD)は同一ではありません。これにより、デバイスのオンライン/オフライン通知に一貫性が生じます。意図的な場合を除き、有効化されているすべての🔍デバイススキャナーで同一のスケジュールを使用してください。", "Speedtest_Results": "スピードテスト結果", @@ -790,4 +790,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} \ No newline at end of file +} From f4bc9c93c34a56598ed43a27bd956e4eca7434cd Mon Sep 17 00:00:00 2001 From: Marco Rios Date: Mon, 23 Feb 2026 12:30:28 +0100 Subject: [PATCH 014/122] Translated using Weblate (Spanish) Currently translated at 99.3% (786 of 791 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/es/ --- front/php/templates/language/es_es.json | 74 ++++++++++++------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index aa4a4e393..7c1add8f8 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -29,8 +29,8 @@ "AppEvents_Type": "Tipo", "Apprise_display_name": "Apprise", "Apprise_icon": "", - "BACKEND_API_URL_description": "", - "BACKEND_API_URL_name": "", + "BACKEND_API_URL_description": "Usado para permitir al front comunicarse con el backend. Por defecto está definido en /server y generalmente no debiera cambiarse.", + "BACKEND_API_URL_name": "URL de la API de backend", "BackDevDetail_Actions_Ask_Run": "¿Desea ejecutar la acción?", "BackDevDetail_Actions_Not_Registered": "Acción no registrada: ", "BackDevDetail_Actions_Title_Run": "Ejecutar acción", @@ -203,12 +203,12 @@ "Device_MultiEdit_Backup": "Tenga cuidado, ingresar valores incorrectos o romperá su configuración. Por favor, haga una copia de seguridad de su base de datos o de la configuración de los dispositivos primero (haga clic para descargar ). Lea cómo recuperar dispositivos de este archivo en la documentación de Copia de seguridad. Para aplicar sus cambios haga click en el ícono de Guardar en cada campo que quiera actualizar.", "Device_MultiEdit_Fields": "Editar campos:", "Device_MultiEdit_MassActions": "Acciones masivas:", - "Device_MultiEdit_No_Devices": "", + "Device_MultiEdit_No_Devices": "Sin dispositivo seleccionado.", "Device_MultiEdit_Tooltip": "Cuidado. Al hacer clic se aplicará el valor de la izquierda a todos los dispositivos seleccionados anteriormente.", - "Device_Save_Failed": "", - "Device_Save_Unauthorized": "", - "Device_Saved_Success": "", - "Device_Saved_Unexpected": "", + "Device_Save_Failed": "Fallo al guardar el dispositivo", + "Device_Save_Unauthorized": "No autorizado - Token de API inválido", + "Device_Saved_Success": "Dispositivo guardado exitósamente", + "Device_Saved_Unexpected": "La actualización del dispositivo retornó una respuesta inesperada", "Device_Searchbox": "Búsqueda", "Device_Shortcut_AllDevices": "Mis dispositivos", "Device_Shortcut_AllNodes": "Todos los nodos", @@ -220,7 +220,7 @@ "Device_Shortcut_Favorites": "Favorito(s)", "Device_Shortcut_NewDevices": "Nuevos dispositivos", "Device_Shortcut_OnlineChart": "Presencia del dispositivo a lo largo del tiempo", - "Device_Shortcut_Unstable": "", + "Device_Shortcut_Unstable": "Inestable", "Device_TableHead_AlertDown": "Alerta desactivada", "Device_TableHead_Connected_Devices": "Conexiones", "Device_TableHead_CustomProps": "Propiedades / Acciones", @@ -229,9 +229,9 @@ "Device_TableHead_FirstSession": "1ra. sesión", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grupo", - "Device_TableHead_IPv4": "", - "Device_TableHead_IPv6": "", - "Device_TableHead_Icon": "Icon", + "Device_TableHead_IPv4": "IPv4", + "Device_TableHead_IPv6": "IPv6", + "Device_TableHead_Icon": "Ícono", "Device_TableHead_LastIP": "Última IP", "Device_TableHead_LastIPOrder": "Última orden de IP", "Device_TableHead_LastSession": "Última conexión", @@ -254,7 +254,7 @@ "Device_TableHead_SyncHubNodeName": "Nodo de sincronización", "Device_TableHead_Type": "Tipo", "Device_TableHead_Vendor": "Fabricante", - "Device_TableHead_Vlan": "", + "Device_TableHead_Vlan": "VLAN", "Device_Table_Not_Network_Device": "No está configurado como dispositivo de red", "Device_Table_info": "Mostrando el INICIO y el FINAL de TODAS las entradas", "Device_Table_nav_next": "Siguiente", @@ -302,14 +302,14 @@ "Events_Tablelenght": "Mostrando entradas del MENÚ", "Events_Tablelenght_all": "Todos", "Events_Title": "Eventos", - "FakeMAC_hover": "", - "FieldLock_Error": "", - "FieldLock_Lock_Tooltip": "", - "FieldLock_Locked": "", - "FieldLock_SaveBeforeLocking": "", - "FieldLock_Source_Label": "", - "FieldLock_Unlock_Tooltip": "", - "FieldLock_Unlocked": "", + "FakeMAC_hover": "Este dispositivo tiene una dirección MAC falsa/alterada", + "FieldLock_Error": "Error actualizando el estado de bloqueo del campo", + "FieldLock_Lock_Tooltip": "Campo bloqueado (impide sobreescrituras del plugin)", + "FieldLock_Locked": "Campo bloqueado", + "FieldLock_SaveBeforeLocking": "Guarda tus cambios antes de bloquear", + "FieldLock_Source_Label": "Fuente: ", + "FieldLock_Unlock_Tooltip": "Campo desbloqueado (permite sobreescritura del plugin)", + "FieldLock_Unlocked": "Campo desbloqueado", "GRAPHQL_PORT_description": "El número de puerto del servidor GraphQL. Asegúrese de que el puerto sea único en todas sus aplicaciones en este host y en las instancias de NetAlertX.", "GRAPHQL_PORT_name": "Puerto GraphQL", "Gen_Action": "Acción", @@ -322,7 +322,7 @@ "Gen_Cancel": "Cancelar", "Gen_Change": "Cambiar", "Gen_Copy": "Ejecutar", - "Gen_CopyToClipboard": "", + "Gen_CopyToClipboard": "Copiar al portapapeles", "Gen_DataUpdatedUITakesTime": "Correcto - La interfaz puede tardar en actualizarse si se está ejecutando un escaneo.", "Gen_Delete": "Eliminar", "Gen_DeleteAll": "Eliminar todo", @@ -330,10 +330,10 @@ "Gen_Error": "Error", "Gen_Filter": "Filtro", "Gen_Generate": "Generar", - "Gen_InvalidMac": "", - "Gen_Invalid_Value": "", + "Gen_InvalidMac": "Dirección MAC inválida.", + "Gen_Invalid_Value": "Un valor inválido fue ingresado", "Gen_LockedDB": "Fallo - La base de datos puede estar bloqueada - Pulsa F1 -> Ajustes de desarrolladores -> Consola o prueba más tarde.", - "Gen_NetworkMask": "", + "Gen_NetworkMask": "Máscara de red", "Gen_Offline": "Desconectado", "Gen_Okay": "Aceptar", "Gen_Online": "En linea", @@ -351,7 +351,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Seleccionar para previsualizar", "Gen_Selected_Devices": "Dispositivos seleccionados:", - "Gen_Subnet": "", + "Gen_Subnet": "Subred", "Gen_Switch": "Cambiar", "Gen_Upd": "Actualizado correctamente", "Gen_Upd_Fail": "Fallo al actualizar", @@ -431,10 +431,10 @@ "Maintenance_Tool_ImportPastedConfig": "Importar ajustes (pegar)", "Maintenance_Tool_ImportPastedConfig_noti_text": "¿Seguro que quieres importar la configuración pegada? Esto sobrescribirá por completo el archivo app.conf.", "Maintenance_Tool_ImportPastedConfig_text": "Importa el archivo app.conf que contiene toda la configuración de la aplicación. Es recomendable descargar primero el archivo app.conf actual con la Exportación de configuración.", - "Maintenance_Tool_UnlockFields": "", - "Maintenance_Tool_UnlockFields_noti": "", - "Maintenance_Tool_UnlockFields_noti_text": "", - "Maintenance_Tool_UnlockFields_text": "", + "Maintenance_Tool_UnlockFields": "Campos Desbloqueo del Dispositivo", + "Maintenance_Tool_UnlockFields_noti": "Campos Desbloqueo del Dispositivo", + "Maintenance_Tool_UnlockFields_noti_text": "¿Está seguro que desea limpiar los valores fuente (BLOQUEADO/USUARIO) para todos los campos dispositivo en todos los dispositivos? Esta acción no puede deshacerse.", + "Maintenance_Tool_UnlockFields_text": "Esta herramienta removerá todos los valores fuente de cada campo de traza para todos los dispositivos, desbloqueando efectivamente todos los campos para plugins y usuarios. Use esto con precaución, dado que afectará todo su inventario de dispositivos.", "Maintenance_Tool_arpscansw": "Activar arp-scan (on/off)", "Maintenance_Tool_arpscansw_noti": "Activar arp-scan on or off", "Maintenance_Tool_arpscansw_noti_text": "Cuando el escaneo se ha apagado, permanece apagado hasta que se active nuevamente.", @@ -444,9 +444,9 @@ "Maintenance_Tool_backup_noti_text": "¿Estás seguro de que quieres exactos la copia de seguridad de DB? Asegúrese de que ningún escaneo se esté ejecutando actualmente.", "Maintenance_Tool_backup_text": "Las copias de seguridad de la base de datos se encuentran en el directorio de la base de datos como una Zip-Archive, nombrada con la fecha de creación. No hay un número máximo de copias de seguridad.", "Maintenance_Tool_check_visible": "Desactivar para ocultar columna.", - "Maintenance_Tool_clearSourceFields_selected": "", - "Maintenance_Tool_clearSourceFields_selected_noti": "", - "Maintenance_Tool_clearSourceFields_selected_text": "", + "Maintenance_Tool_clearSourceFields_selected": "Limpiar campos fuente", + "Maintenance_Tool_clearSourceFields_selected_noti": "Limpiar fuentes", + "Maintenance_Tool_clearSourceFields_selected_text": "Esto limpiará todos los campos fuente de los dispositivos seleccionados. Esta acción no puede deshacerse.", "Maintenance_Tool_darkmode": "Cambiar Modo (Dark/Light)", "Maintenance_Tool_darkmode_noti": "Cambiar Modo", "Maintenance_Tool_darkmode_noti_text": "Después del cambio de tema, la página intenta volver a cargar para activar el cambio. Si es necesario, el caché debe ser eliminado.", @@ -477,7 +477,7 @@ "Maintenance_Tool_del_unknowndev_noti": "Eliminar dispositivos (desconocidos)", "Maintenance_Tool_del_unknowndev_noti_text": "¿Estás seguro de que quieres eliminar todos los dispositivos (desconocidos)?", "Maintenance_Tool_del_unknowndev_text": "Antes de usar esta función, haga una copia de seguridad. La eliminación no se puede deshacer. Todos los dispositivos nombrados (desconocidos) se eliminarán de la base de datos.", - "Maintenance_Tool_del_unlockFields_selecteddev_text": "", + "Maintenance_Tool_del_unlockFields_selecteddev_text": "Esto desbloqueará los campos BLOQUEADO/USUARIO de los dispositivos seleccionados. Esta acción no puede deshacerse.", "Maintenance_Tool_displayed_columns_text": "Cambia la visibilidad y el orden de las columnas en la página Dispositivos.", "Maintenance_Tool_drag_me": "Coger para rearrastrar columnas.", "Maintenance_Tool_order_columns_text": "Maintenance_Tool_order_columns_text", @@ -489,8 +489,8 @@ "Maintenance_Tool_restore_noti": "Restaurar DB", "Maintenance_Tool_restore_noti_text": "¿Estás seguro de que quieres hacer exactos la restauración de DB? Asegúrese de que ningún escaneo se esté ejecutando actualmente.", "Maintenance_Tool_restore_text": "La última copia de seguridad se puede restaurar a través del botón, pero las copias de seguridad anteriores solo se pueden restaurar manualmente. Después de la restauración, realice una verificación de integridad en la base de datos por seguridad, en caso de que el DB estuviera actualmente en acceso de escritura cuando se creó la copia de seguridad.", - "Maintenance_Tool_unlockFields_selecteddev": "", - "Maintenance_Tool_unlockFields_selecteddev_noti": "", + "Maintenance_Tool_unlockFields_selecteddev": "Campos dispositivo desbloqueado", + "Maintenance_Tool_unlockFields_selecteddev_noti": "Campos desbloqueo", "Maintenance_Tool_upgrade_database_noti": "Actualizar la base de datos", "Maintenance_Tool_upgrade_database_noti_text": "¿Estás seguro de que quieres actualizar la base de datos?
    (tal vez prefieras archivarla)", "Maintenance_Tool_upgrade_database_text": "Este botón actualizará la base de datos para habilitar la actividad de la red en las últimas 12 horas. Haga una copia de seguridad de su base de datos en caso de problemas.", @@ -688,7 +688,7 @@ "Settings_device_Scanners_desync": "⚠ Los horarios del escáner de los dispositivos no están sincronizados.", "Settings_device_Scanners_desync_popup": "Los horarios de escáneres de dispositivos ( *_RUN_SCHD ) no son lo mismo. Esto resultará en notificaciones inconsistentes del dispositivo en línea/fuera de línea. A menos que sea así, utilice el mismo horario para todos los habilitados. 🔍Escáneres de dispositivos .", "Speedtest_Results": "Resultados de la prueba de velocidad", - "Systeminfo_AvailableIps": "", + "Systeminfo_AvailableIps": "IPs disponibles", "Systeminfo_CPU": "CPU", "Systeminfo_CPU_Cores": "Núcleos de CPU:", "Systeminfo_CPU_Name": "Nombre de la CPU:", @@ -861,4 +861,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualice el valor a continuación. Tenga cuidado de seguir el formato anterior. O la validación no se realiza.", "test_event_tooltip": "Guarda tus cambios antes de probar nuevos ajustes." -} \ No newline at end of file +} From d06301ac80006eabc12acfbc694f565d80801062 Mon Sep 17 00:00:00 2001 From: Sylvain Pichon Date: Mon, 23 Feb 2026 05:48:33 +0100 Subject: [PATCH 015/122] Translated using Weblate (French) Currently translated at 100.0% (791 of 791 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/fr/ --- front/php/templates/language/fr_fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 9c10212c7..15070f2bc 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -218,7 +218,7 @@ "Device_Shortcut_Favorites": "Favoris", "Device_Shortcut_NewDevices": "Nouveaux appareils", "Device_Shortcut_OnlineChart": "Présence de l'appareil", - "Device_Shortcut_Unstable": "", + "Device_Shortcut_Unstable": "Instable", "Device_TableHead_AlertDown": "Alerter si En panne", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Champs / Actions", @@ -790,4 +790,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} \ No newline at end of file +} From fae61174a7a1da7dc2b34f13ca65e64a4694dc72 Mon Sep 17 00:00:00 2001 From: Massimo Pissarello Date: Mon, 23 Feb 2026 03:48:12 +0100 Subject: [PATCH 016/122] Translated using Weblate (Italian) Currently translated at 100.0% (791 of 791 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/it/ --- front/php/templates/language/it_it.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index ee9817315..b7ce9221a 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -218,7 +218,7 @@ "Device_Shortcut_Favorites": "Preferiti", "Device_Shortcut_NewDevices": "Nuovi dispositivi", "Device_Shortcut_OnlineChart": "Presenza dispositivo", - "Device_Shortcut_Unstable": "", + "Device_Shortcut_Unstable": "Instabile", "Device_TableHead_AlertDown": "Avviso disconnessione", "Device_TableHead_Connected_Devices": "Connessioni", "Device_TableHead_CustomProps": "Proprietà/Azioni", @@ -790,4 +790,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} \ No newline at end of file +} From 1fa381429deb236afb10bc99114093f1f1a956fe Mon Sep 17 00:00:00 2001 From: anton garcias Date: Mon, 23 Feb 2026 19:36:45 +0100 Subject: [PATCH 017/122] Translated using Weblate (Catalan) Currently translated at 100.0% (791 of 791 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ca/ --- front/php/templates/language/ca_ca.json | 78 ++++++++++++------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 2e26de41e..c6d5fe9ff 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -27,8 +27,8 @@ "AppEvents_ObjectType": "Tipus d'objecte", "AppEvents_Plugin": "Plugin", "AppEvents_Type": "Tipus", - "BACKEND_API_URL_description": "", - "BACKEND_API_URL_name": "", + "BACKEND_API_URL_description": "S'utilitza per permetre la comunicació del frontend al backend. Per defecte establert en /server i no cal canviar-ho.", + "BACKEND_API_URL_name": "URL del API Backend", "BackDevDetail_Actions_Ask_Run": "Vol executar aquesta comanda?", "BackDevDetail_Actions_Not_Registered": "Comanda no registrada: ", "BackDevDetail_Actions_Title_Run": "Executar la comanda", @@ -98,10 +98,10 @@ "DevDetail_MainInfo_Network": " Node (MAC)", "DevDetail_MainInfo_Network_Port": " Port", "DevDetail_MainInfo_Network_Site": "Lloc web", - "DevDetail_MainInfo_Network_Title": "Xarxa", + "DevDetail_MainInfo_Network_Title": "Detalls de Xarxa", "DevDetail_MainInfo_Owner": "Propietari", "DevDetail_MainInfo_SSID": "SSID", - "DevDetail_MainInfo_Title": "Informació principal", + "DevDetail_MainInfo_Title": "Informació del dispositiu", "DevDetail_MainInfo_Type": "Tipus", "DevDetail_MainInfo_Vendor": "Venedor", "DevDetail_MainInfo_mac": "MAC", @@ -203,10 +203,10 @@ "Device_MultiEdit_MassActions": "Accions massives:", "Device_MultiEdit_No_Devices": "Cap dispositiu seleccionat.", "Device_MultiEdit_Tooltip": "Atenció. Si feu clic a això s'aplicarà el valor de l'esquerra a tots els dispositius seleccionats a dalt.", - "Device_Save_Failed": "", - "Device_Save_Unauthorized": "", - "Device_Saved_Success": "", - "Device_Saved_Unexpected": "", + "Device_Save_Failed": "Problemes guardant el dispositiu", + "Device_Save_Unauthorized": "Token invàlid - No autoritzat", + "Device_Saved_Success": "S'ha guardat el dispositiu", + "Device_Saved_Unexpected": "Actualització de dispositiu ha retornat una resposta no esperada", "Device_Searchbox": "Cerca", "Device_Shortcut_AllDevices": "Els meus dispositius", "Device_Shortcut_AllNodes": "Tots els nodes", @@ -218,7 +218,7 @@ "Device_Shortcut_Favorites": "Favorits", "Device_Shortcut_NewDevices": "Nous dispositius", "Device_Shortcut_OnlineChart": "Dispositius detectats", - "Device_Shortcut_Unstable": "", + "Device_Shortcut_Unstable": "Inestable", "Device_TableHead_AlertDown": "Cancel·lar alerta", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Props / Accions", @@ -227,8 +227,8 @@ "Device_TableHead_FirstSession": "Primera Sessió", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grup", - "Device_TableHead_IPv4": "", - "Device_TableHead_IPv6": "", + "Device_TableHead_IPv4": "IPv4", + "Device_TableHead_IPv6": "IPv6", "Device_TableHead_Icon": "Icona", "Device_TableHead_LastIP": "Darrera IP", "Device_TableHead_LastIPOrder": "Últim Ordre d'IP", @@ -252,7 +252,7 @@ "Device_TableHead_SyncHubNodeName": "Node Sync", "Device_TableHead_Type": "Tipus", "Device_TableHead_Vendor": "Venedor", - "Device_TableHead_Vlan": "", + "Device_TableHead_Vlan": "VLAN", "Device_Table_Not_Network_Device": "No configurat com a dispositiu de xarxa", "Device_Table_info": "Mostrant _INICI_ a_FINAL_ d'entrades_ TOTALS", "Device_Table_nav_next": "Següent", @@ -300,14 +300,14 @@ "Events_Tablelenght": "Veure_entrades_MENU", "Events_Tablelenght_all": "Tot", "Events_Title": "Esdeveniments", - "FakeMAC_hover": "Autodetecció - indica si el dispositiu fa servir una adreça MAC falsa (comença amb FA:CE o 00:1A), típicament generada per un plugin que no pot detectar la MAC real o quan es crea un dispositiu amagat (dummy).", - "FieldLock_Error": "", - "FieldLock_Lock_Tooltip": "", - "FieldLock_Locked": "", - "FieldLock_SaveBeforeLocking": "", - "FieldLock_Source_Label": "", - "FieldLock_Unlock_Tooltip": "", - "FieldLock_Unlocked": "", + "FakeMAC_hover": "Aquest dispositiu fa servir una adreça MAC falsa", + "FieldLock_Error": "Error actualitzant l'estat de bloqueig de camp", + "FieldLock_Lock_Tooltip": "Bloquejar el camp (per previndre modificacions)", + "FieldLock_Locked": "Camp bloquejat", + "FieldLock_SaveBeforeLocking": "Guardar canvis abans de bloquejar", + "FieldLock_Source_Label": "Font: ", + "FieldLock_Unlock_Tooltip": "Desbloquejar camp (permet modificacions)", + "FieldLock_Unlocked": "Camp desbloquejat", "GRAPHQL_PORT_description": "El número de port del servidor GraphQL. Comprova que el port és únic en totes les aplicacions d'aquest servidor i en totes les instàncies de NetAlertX.", "GRAPHQL_PORT_name": "Port GraphQL", "Gen_Action": "Acció", @@ -400,25 +400,25 @@ "Maintenance_Tool_DownloadConfig_text": "Descarregueu una còpia de seguretat completa de la vostra configuració de configuració emmagatzemada al fitxer app.conf.", "Maintenance_Tool_DownloadWorkflows": "Exportació de fluxos de treball", "Maintenance_Tool_DownloadWorkflows_text": "Descarregueu una còpia de seguretat completa dels vostres fluxos de treball emmagatzemats en el fitxer workflows.json.", - "Maintenance_Tool_ExportCSV": "CSV Exportació de dispositius", - "Maintenance_Tool_ExportCSV_noti": "CSV Exportació", + "Maintenance_Tool_ExportCSV": "Exportació de dispositius (csv)", + "Maintenance_Tool_ExportCSV_noti": "Exportació de dispositius (csv)", "Maintenance_Tool_ExportCSV_noti_text": "Estàs segur que vols generar un fitxer CSV?", - "Maintenance_Tool_ExportCSV_text": "Genera un fitxer CSV (comma separated value) que conté la llista dels dispositius incloent les relacions de Xarxa entre Nodes i dispositius connectats. També pots disparar-ho accedint a la URL el_vostre_NetAlertX_ url/php/server/devices.php?acció=ExportCSV o activant el connector CSV Còpia de seguretat.", - "Maintenance_Tool_ImportCSV": "CSV Importació de dispositius", - "Maintenance_Tool_ImportCSV_noti": "CSV Importació", + "Maintenance_Tool_ExportCSV_text": "Genera un fitxer CSV (comma separated value) que conté la llista dels dispositius incloent les relacions de Xarxa entre Nodes i dispositius connectats. També pots disparar-ho activant la CSV Còpia de seguretat.", + "Maintenance_Tool_ImportCSV": "Importació de dispositius (csv)", + "Maintenance_Tool_ImportCSV_noti": "Importar dispositius (csv)", "Maintenance_Tool_ImportCSV_noti_text": "Estàs segur que vols importar el fitxer CSV? Això sobreescriurà completament els dispositius de la seva base de dades.", "Maintenance_Tool_ImportCSV_text": "Abans d'utilitzar aquesta funció, fes una còpia de seguretat, si us plau. Importa un CSV (comma separated value) el fitxer que conté la llista dels dispositius que inclouen les relacions de Xarxa entre Nodes i dispositius connectats. Per fer-ho col·loca el CSV el fitxer anomenat devices.csv a la vostra /config carpeta.", "Maintenance_Tool_ImportConfig_noti": "Importació de la configuració (app.conf)", - "Maintenance_Tool_ImportPastedCSV": "CSV Importació de dispositius (Paste)", + "Maintenance_Tool_ImportPastedCSV": "Importar de dispositius (csv) (Paste)", "Maintenance_Tool_ImportPastedCSV_noti_text": "Estàs segur que vols importar el CSV copiat? Això sobreescriurà completament els dispositius de la base de dades.", "Maintenance_Tool_ImportPastedCSV_text": "Abans d'utilitzar aquesta funció, feu una còpia de seguretat. Importar un fitxer CSV (comma separated value) que contingui la llista de dispositius, incloent les relacions de xarxa entre els nodes i els dispositius connectats.", "Maintenance_Tool_ImportPastedConfig": "Importació de la configuració (paste)", "Maintenance_Tool_ImportPastedConfig_noti_text": "Estàs segur que vols importar la configuració config enganxada? Això sobreescriurà completament el fitxer app.conf.", "Maintenance_Tool_ImportPastedConfig_text": "Importa el fitxer app.conf que conté tota l'aplicació Configuració. És possible que vulgueu descarregar el fitxer actual app.conf primer amb el Settings Export.", - "Maintenance_Tool_UnlockFields": "", - "Maintenance_Tool_UnlockFields_noti": "", - "Maintenance_Tool_UnlockFields_noti_text": "", - "Maintenance_Tool_UnlockFields_text": "", + "Maintenance_Tool_UnlockFields": "Desbloquejar camps del dispositiu", + "Maintenance_Tool_UnlockFields_noti": "Desbloquejar camps del dispositiu", + "Maintenance_Tool_UnlockFields_noti_text": "Esta segur que vol netejar tots els valors de les fonts (BLOQUEIG/USUARI) de tots els camps de tots els dispositius? L'acció no es pot desfer.", + "Maintenance_Tool_UnlockFields_text": "Aquesta eina eliminarà tots els valors d'origen de tots els camps seguits per a tots els dispositius, desbloquejant tots els camps per als connectors i usuaris. Utilitzeu-ho amb precaució, ja que afectarà tot l'inventari del vostre dispositiu.", "Maintenance_Tool_arpscansw": "Conmuta arp-Scan (on/off)", "Maintenance_Tool_arpscansw_noti": "Conmuta arp-Scan on or off", "Maintenance_Tool_arpscansw_noti_text": "Quan l'escàner ha estat canviat a off es queda off fins que és activat de bell nou.", @@ -428,9 +428,9 @@ "Maintenance_Tool_backup_noti_text": "Estàs segur que vols executar el Backup DB? Assegura't que no hi ha exploració en funcionament.", "Maintenance_Tool_backup_text": "Les còpies de seguretat de la base de dades es troben al directori de bases de dades com a arxiu zip, anomenat amb la data de creació. No hi ha un nombre màxim de còpies de seguretat.", "Maintenance_Tool_check_visible": "Desmarqueu-ho per amagar la columna.", - "Maintenance_Tool_clearSourceFields_selected": "", - "Maintenance_Tool_clearSourceFields_selected_noti": "", - "Maintenance_Tool_clearSourceFields_selected_text": "", + "Maintenance_Tool_clearSourceFields_selected": "Neteja els camps font", + "Maintenance_Tool_clearSourceFields_selected_noti": "Neteja les fonts", + "Maintenance_Tool_clearSourceFields_selected_text": "Això netejarà tots els camps d'origen dels dispositius seleccionats. No es pot desfer.", "Maintenance_Tool_darkmode": "Canvia Modes (Fosc/Clar)", "Maintenance_Tool_darkmode_noti": "Canvia Modes", "Maintenance_Tool_darkmode_noti_text": "Després del canvi de tema, la pàgina intenta recarregar-se per activar el canvi. Si és necessari, s'ha de netejar la memòria cau.", @@ -461,7 +461,7 @@ "Maintenance_Tool_del_unknowndev_noti": "Elimina dispositius desconeguts", "Maintenance_Tool_del_unknowndev_noti_text": "Estàs segur que vols eliminar tots els dispositius (no coneguts) o amb nom (no trobat)?", "Maintenance_Tool_del_unknowndev_text": "Abans d'utilitzar aquesta funció, feu una còpia de seguretat. La supressió no es pot desfer. Tots els dispositius anomenats (no coneguts) s'eliminaran de la base de dades.", - "Maintenance_Tool_del_unlockFields_selecteddev_text": "", + "Maintenance_Tool_del_unlockFields_selecteddev_text": "Això desbloquejarà els camps BLOQUEJAT/USUARI dels dispositius seleccionats. No es pot desfer.e desfer.", "Maintenance_Tool_displayed_columns_text": "Canvieu la visibilitat i l'ordre de les columnes a la pàgina Dispositius.", "Maintenance_Tool_drag_me": "Arrossega'm a reorder columnes.", "Maintenance_Tool_order_columns_text": "Manteniment_Eina_ordre_columnes_text", @@ -473,8 +473,8 @@ "Maintenance_Tool_restore_noti": "Restaura base de dades", "Maintenance_Tool_restore_noti_text": "Estàs segur que vols executar la Restauració de Base de Dades? Comprova que no hi ha exploració en funcionament.", "Maintenance_Tool_restore_text": "L'última còpia de seguretat es pot restaurar mitjançant el botó, però les còpies de seguretat antigues només es poden restaurar manualment. Després de la restauració, feu una comprovació d'integritat a la base de dades per seguretat, per si de cas la Base de dades estigués en mode escriptura quan es va crear la còpia de seguretat.", - "Maintenance_Tool_unlockFields_selecteddev": "", - "Maintenance_Tool_unlockFields_selecteddev_noti": "", + "Maintenance_Tool_unlockFields_selecteddev": "Desbloquejar els camps del dispositiu", + "Maintenance_Tool_unlockFields_selecteddev_noti": "Desbloqueja camps", "Maintenance_Tool_upgrade_database_noti": "Actualitza base de dades", "Maintenance_Tool_upgrade_database_noti_text": "T'és segur vols actualitzar la base de dades?
    (potser prefereixes arxivar-la)", "Maintenance_Tool_upgrade_database_text": "Aquest botó actualitzarà la base de dades per activar l'activitat de Xarxa dins les darreres 12 hores. Si us plau, feu còpia de la vostra base de dades per si de cas.", @@ -616,7 +616,7 @@ "REPORT_MAIL_description": "Si està activat s'envia un correu electrònic amb una llista de canvis als quals s'ha subscrit. Si us plau, ompli tots els paràmetres restants relacionats amb la configuració SMTP. Si té problemes, configuri LOG_LEVEL i debug comprovi el registre d'errors.", "REPORT_MAIL_name": "Activa el correu electrònic", "REPORT_TITLE": "Informe", - "RandomMAC_hover": "Auto detectat - indica que el dispositiu aleatoritza l'adreça MAC. Pots excloure MACs específiques amb la configuració UI_NOT_RANDOM_MAC. Fes click per saber més.", + "RandomMAC_hover": "Aquest dispositiu té una l'adreça MAC aleatòria", "Reports_Sent_Log": "Registre d'informes enviats", "SCAN_SUBNETS_description": "La majoria dels escàners en xarxa (ARP-SCAN, NMAP, NSLOOKUP, DIG) es basen en l'exploració d'interfícies de xarxa específiques i subxarxes. Comproveu la documentació de subxarxes per ajudar en aquesta configuració, especialment VLANs, i quines VLANs són compatibles, o com esbrinar la màscara de xarxa i la seva interfície.

    Una alternativa als escàners en xarxa és activar alguns altres escàners / importadors de dispositius que no requereixin NetAlertX per tenir accés a la xarxa (UNIFI, dhcp. leases, PiHole, etc.).

    Nota: El temps d'exploració en si mateix depèn del nombre d'adreces IP per verificar, així que s'ha establir amb cura amb la màscara i la interfície de xarxa adequats.", "SCAN_SUBNETS_name": "Xarxes per escanejar", @@ -624,7 +624,7 @@ "Setting_Override": "Valor de sobreescriptura", "Setting_Override_Description": "Activant aquesta opció anul·larà un valor predeterminat de l'aplicació amb el valor especificat.", "Settings_Metadata_Toggle": "Mostrar/amagar metadades per a la configuració donada.", - "Settings_Show_Description": "Mostra la descripció de la configuració.", + "Settings_Show_Description": "Mostra la descripció", "Settings_device_Scanners_desync": "⚠ Els horaris d'escàner de dispositius no estan en sincronia.", "Settings_device_Scanners_desync_popup": "Els horaris dels escàners de dispositius (*_RUN_SCHD) no són iguals. Això donarà lloc a notificacions inconsistents del dispositiu en línia / fora de línia. Si no és intencionat, utilitzeu el mateix horari per a tots els 🔍 escàners de dispositius.", "Speedtest_Results": "Speedtest Resultats", @@ -790,4 +790,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualitza el valor sota. Sigues curós de seguir el format anterior. No hi ha validació.", "test_event_tooltip": "Deseu els canvis primer abans de comprovar la configuració." -} \ No newline at end of file +} From 0b32a06178459d8359e3b799a0bd7c0258a8536f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8E=8B=E5=8F=AB=E6=88=91=E6=9D=A5=E5=B7=A1?= =?UTF-8?q?=E5=B1=B1?= Date: Mon, 23 Feb 2026 07:47:53 +0100 Subject: [PATCH 018/122] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.4% (787 of 791 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/zh_Hans/ --- front/php/templates/language/zh_cn.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 7ff549021..91f28b9d0 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -27,7 +27,7 @@ "AppEvents_ObjectType": "对象类型", "AppEvents_Plugin": "插件", "AppEvents_Type": "类型", - "BACKEND_API_URL_description": "用于生成后端API URL。如果您使用反向代理映射到您的GRAPHQL_PORT,请指定。请输入以http://开头的完整URL,包括端口号(末尾不带斜杠/)。", + "BACKEND_API_URL_description": "用来允许前端和后端通信。 默认情况下,这被设为 服务器,通常不应更改。", "BACKEND_API_URL_name": "后端 API 网址", "BackDevDetail_Actions_Ask_Run": "您要执行此操作吗?", "BackDevDetail_Actions_Not_Registered": "未注册的操作: ", @@ -415,8 +415,8 @@ "Maintenance_Tool_ImportPastedConfig": "设置导入(粘贴)", "Maintenance_Tool_ImportPastedConfig_noti_text": "您确认要导入粘贴的设置吗?这会完全覆盖app.conf的内容。", "Maintenance_Tool_ImportPastedConfig_text": "导入包含所有应用程序设置的app.conf文件。建议先通过设置导出功能下载当前的app.conf文件。", - "Maintenance_Tool_UnlockFields": "清除所有设备来源", - "Maintenance_Tool_UnlockFields_noti": "清除所有设备来源", + "Maintenance_Tool_UnlockFields": "解锁设备字段", + "Maintenance_Tool_UnlockFields_noti": "解锁设备字段", "Maintenance_Tool_UnlockFields_noti_text": "您确定要清除所有设备字段(锁定/用户)的所有源值吗?(请注意:此操作将永久删除这些值,无法恢复)这一行为无法挽回。", "Maintenance_Tool_UnlockFields_text": "此工具将从所有设备的每个跟踪字段中删除所有原始数据,从而为插件和用户完全解锁所有字段。请谨慎使用,因为这将影响您整个设备的库存情况。", "Maintenance_Tool_arpscansw": "切换 arp 扫描(开/关)", @@ -790,4 +790,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} \ No newline at end of file +} From 786cc5ee33c165aa8bd6e8b8d31b6c08ee76d30d Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:59:40 +0000 Subject: [PATCH 019/122] feat: Implement network topology management with API integration - Added network-api.js for handling API calls related to network devices and nodes. - Introduced network-events.js to manage event handlers for node interactions and window resizing. - Created network-init.js for initializing network topology on page load and fetching device data. - Developed network-tabs.js for rendering network tabs and managing tab content. - Implemented network-tree.js for constructing and rendering the tree hierarchy of network devices. - Enhanced error handling and user feedback for API calls and data loading processes. - Included caching mechanisms for user preferences regarding device visibility. --- front/js/network-api.js | 283 ++++++++++ front/js/network-events.js | 133 +++++ front/js/network-init.js | 149 +++++ front/js/network-tabs.js | 259 +++++++++ front/js/network-tree.js | 346 ++++++++++++ front/network.php | 1063 +----------------------------------- 6 files changed, 1176 insertions(+), 1057 deletions(-) create mode 100644 front/js/network-api.js create mode 100644 front/js/network-events.js create mode 100644 front/js/network-init.js create mode 100644 front/js/network-tabs.js create mode 100644 front/js/network-tree.js diff --git a/front/js/network-api.js b/front/js/network-api.js new file mode 100644 index 000000000..86e93fdbf --- /dev/null +++ b/front/js/network-api.js @@ -0,0 +1,283 @@ +// network-api.js +// API calls and data loading functions for network topology + +/** + * Get API token, waiting if necessary for settings to load + * @returns {string} The API token + */ +function getApiToken() { + let token = getSetting("API_TOKEN"); + + // If token is not yet available, log warning + if (!token || token.trim() === '') { + console.warn("API_TOKEN not yet loaded from settings"); + } + + return token; +} + +/** + * Load network nodes (network device types) + * Creates top-level tabs for each network device + */ +function loadNetworkNodes() { + // Create Top level tabs (List of network devices), explanation of the terminology below: + // + // Switch 1 (node) + // /(p1) \ (p2) <----- port numbers + // / \ + // Smart TV (leaf) Switch 2 (node (for the PC) and leaf (for Switch 1)) + // \ + // PC (leaf) <------- leafs are not included in this SQL query + const rawSql = ` + SELECT + parent.devName AS node_name, + parent.devMac AS node_mac, + parent.devPresentLastScan AS online, + parent.devType AS node_type, + parent.devParentMAC AS parent_mac, + parent.devIcon AS node_icon, + parent.devAlertDown AS node_alert, + COUNT(child.devMac) AS node_ports_count + FROM Devices AS parent + LEFT JOIN Devices AS child + /* CRITICAL FIX: COLLATE NOCASE ensures the join works + even if devParentMAC is uppercase and devMac is lowercase + */ + ON child.devParentMAC = parent.devMac COLLATE NOCASE + WHERE parent.devType IN (${networkDeviceTypes}) + AND parent.devIsArchived = 0 + GROUP BY parent.devMac, parent.devName, parent.devPresentLastScan, + parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown + ORDER BY parent.devName; + `; + + const apiBase = getApiBase(); + const apiToken = getApiToken(); + + // Verify token is available + if (!apiToken || apiToken.trim() === '') { + console.error("API_TOKEN not available. Settings may not be loaded yet."); + return; + } + + const url = `${apiBase}/dbquery/read`; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(rawSql))) }), + contentType: "application/json", + success: function(data) { + const nodes = data.results || []; + renderNetworkTabs(nodes); + loadUnassignedDevices(); + checkTabsOverflow(); + }, + error: function(xhr, status, error) { + console.error("Error loading network nodes:", status, error); + // Check if it's an auth error + if (xhr.status === 401) { + console.error("Authorization failed. API_TOKEN may be invalid or not yet loaded."); + } + } + }); +} + +/** + * Load device table with configurable SQL and rendering + * @param {Object} options - Configuration object + * @param {string} options.sql - SQL query to fetch devices + * @param {string} options.containerSelector - jQuery selector for container + * @param {string} options.tableId - ID for DataTable instance + * @param {string} options.wrapperHtml - HTML wrapper for table + * @param {boolean} options.assignMode - Whether to show assign/unassign buttons + */ +function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, assignMode = true }) { + const apiBase = getApiBase(); + const apiToken = getApiToken(); + + // Verify token is available + if (!apiToken || apiToken.trim() === '') { + console.error("API_TOKEN not available. Settings may not be loaded yet."); + return; + } + + const url = `${apiBase}/dbquery/read`; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(sql))) }), + contentType: "application/json", + success: function(data) { + const devices = data.results || []; + const $container = $(containerSelector); + + // end if nothing to show + if(devices.length == 0) + { + return; + } + + $container.html(wrapperHtml); + + const $table = $(`#${tableId}`); + + const columns = [ + { + title: assignMode ? getString('Network_ManageAssign') : getString('Network_ManageUnassign'), + data: 'devMac', + orderable: false, + width: '5%', + render: function (mac) { + const label = assignMode ? 'assign' : 'unassign'; + const btnClass = assignMode ? 'btn-primary' : 'btn-primary bg-red'; + const btnText = assignMode ? getString('Network_ManageAssign') : getString('Network_ManageUnassign'); + return ``; + } + }, + { + title: getString('Device_TableHead_Name'), + data: 'devName', + width: '15%', + render: function (name, type, device) { + return ` + ${name || '-'} + `; + } + }, + { + title: getString('Device_TableHead_Status'), + data: 'devStatus', + width: '15%', + render: function (_, type, device) { + const badge = getStatusBadgeParts( + device.devPresentLastScan, + device.devAlertDown, + device.devMac, + device.devStatus + ); + return `${badge.iconHtml} ${badge.text}`; + } + }, + { + title: 'MAC', + data: 'devMac', + width: '5%', + render: (data) => `${data}` + }, + { + title: getString('Network_Table_IP'), + data: 'devLastIP', + width: '5%' + }, + { + title: getString('Device_TableHead_Port'), + data: 'devParentPort', + width: '5%' + }, + { + title: getString('Device_TableHead_Vendor'), + data: 'devVendor', + width: '20%' + } + ].filter(Boolean); + + tableConfig = { + data: devices, + columns: columns, + pageLength: 10, + order: assignMode ? [[2, 'asc']] : [], + responsive: true, + autoWidth: false, + searching: true, + createdRow: function (row, data) { + $(row).attr('data-mac', data.devMac); + } + }; + + if ($.fn.DataTable.isDataTable($table)) { + $table.DataTable(tableConfig).clear().rows.add(devices).draw(); + } else { + $table.DataTable(tableConfig); + } + }, + error: function(xhr, status, error) { + console.error("Error loading device table:", status, error); + } + }); +} + +/** + * Load unassigned devices (devices without parent) + */ +function loadUnassignedDevices() { + const sql = ` + SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort + FROM Devices + WHERE (devParentMAC IS NULL OR devParentMAC IN ("", " ", "undefined", "null")) + AND devMac NOT LIKE "%internet%" + AND devIsArchived = 0 + ORDER BY devName ASC`; + + const wrapperHtml = ` +
    +
    +
    +
    ${getString('Network_UnassignedDevices')}
    +
    +
    +
    +
    `; + + loadDeviceTable({ + sql, + containerSelector: '#unassigned-devices-wrapper', + tableId: 'unassignedDevicesTable', + wrapperHtml, + assignMode: true + }); +} + +/** + * Load devices connected to a specific node + * @param {string} node_mac - MAC address of the parent node + */ +function loadConnectedDevices(node_mac) { + // Standardize the input just in case + const normalized_mac = node_mac.toLowerCase(); + + const sql = ` + SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, + CASE + WHEN devIsNew = 1 THEN 'New' + WHEN devPresentLastScan = 1 THEN 'On-line' + WHEN devPresentLastScan = 0 AND devAlertDown != 0 THEN 'Down' + WHEN devIsArchived = 1 THEN 'Archived' + WHEN devPresentLastScan = 0 THEN 'Off-line' + ELSE 'Unknown status' + END AS devStatus + FROM Devices + /* Using COLLATE NOCASE here solves the 'TEXT' vs 'NOCASE' mismatch */ + WHERE devParentMac = '${normalized_mac}' COLLATE NOCASE`; + + // Keep the ID generation consistent + const id = normalized_mac.replace(/:/g, '_'); + + const wrapperHtml = ` + +
    `; + + loadDeviceTable({ + sql, + containerSelector: `#leafs_${id}`, + tableId: `table_leafs_${id}`, + wrapperHtml, + assignMode: false + }); +} diff --git a/front/js/network-events.js b/front/js/network-events.js new file mode 100644 index 000000000..4241fc106 --- /dev/null +++ b/front/js/network-events.js @@ -0,0 +1,133 @@ +// network-events.js +// Event handlers and tree node click interactions + +/** + * Handle network node click - select correct tab and scroll to appropriate content + * @param {HTMLElement} el - The clicked element + */ +function handleNodeClick(el) +{ + + isNetworkDevice = $(el).data("devisnetworknodedynamic") == 1; + targetTabMAC = "" + thisDevMac= $(el).data("mac"); + + if (isNetworkDevice == false) + { + targetTabMAC = $(el).data("parentmac"); + } else + { + targetTabMAC = thisDevMac; + } + + var targetTab = $(`a[data-mytabmac="${targetTabMAC}"]`); + + if (targetTab.length) { + // Simulate a click event on the target tab + targetTab.click(); + + + } + + if (isNetworkDevice) { + // Smooth scroll to the tab content + $('html, body').animate({ + scrollTop: targetTab.offset().top - 50 + }, 500); // Adjust the duration as needed + } else { + $("tr.selected").removeClass("selected"); + $(`tr[data-mac="${thisDevMac}"]`).addClass("selected"); + + const tableId = "table_leafs_" + targetTabMAC.replace(/:/g, '_'); + const $table = $(`#${tableId}`).DataTable(); + + // Find the row index (in the full data set) that matches + const rowIndex = $table + .rows() + .eq(0) + .filter(function(idx) { + return $table.row(idx).node().getAttribute("data-mac") === thisDevMac; + }); + + if (rowIndex.length > 0) { + // Change to the page where this row is + $table.page(Math.floor(rowIndex[0] / $table.page.len())).draw(false); + + // Delay needed so the row is in the DOM after page draw + setTimeout(() => { + const rowNode = $table.row(rowIndex[0]).node(); + $(rowNode).addClass("selected"); + + // Smooth scroll to the row + $('html, body').animate({ + scrollTop: $(rowNode).offset().top - 50 + }, 500); + }, 0); + } + } +} + +/** + * Handle window resize events to recheck tab overflow + */ +let resizeTimeout; +$(window).on('resize', function () { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + checkTabsOverflow(); + }, 100); +}); + +/** + * Initialize page on document ready + * Sets up toggle filters and event handlers + */ +$(document).ready(function () { + // Restore cached values on load + const cachedOffline = getCache('showOffline'); + if (cachedOffline !== null) { + $('input[name="showOffline"]').prop('checked', cachedOffline === 'true'); + } + + const cachedArchived = getCache('showArchived'); + if (cachedArchived !== null) { + $('input[name="showArchived"]').prop('checked', cachedArchived === 'true'); + } + + // Function to enable/disable showArchived based on showOffline + function updateArchivedToggle() { + const isOfflineChecked = $('input[name="showOffline"]').is(':checked'); + const archivedToggle = $('input[name="showArchived"]'); + + if (!isOfflineChecked) { + archivedToggle.prop('checked', false); + archivedToggle.prop('disabled', true); + setCache('showArchived', false); + } else { + archivedToggle.prop('disabled', false); + } + } + + // Initial state on load + updateArchivedToggle(); + + // Bind change event for both toggles + $('input[name="showOffline"], input[name="showArchived"]').on('change', function () { + const name = $(this).attr('name'); + const value = $(this).is(':checked'); + setCache(name, value); + + // Update state of showArchived if showOffline changed + if (name === 'showOffline') { + updateArchivedToggle(); + } + + // Refresh page after a brief delay to ensure cache is written + setTimeout(() => { + location.reload(); + }, 100); + }); + + // init pop up hover boxes for device details + initHoverNodeInfo(); +}); diff --git a/front/js/network-init.js b/front/js/network-init.js new file mode 100644 index 000000000..a5e15d004 --- /dev/null +++ b/front/js/network-init.js @@ -0,0 +1,149 @@ +// network-init.js +// Main initialization and data loading logic for network topology + +// Global variables needed by other modules +var networkDeviceTypes = ""; +var showArchived = false; +var showOffline = false; + +/** + * Initialize network topology on page load + * Fetches all devices and sets up the tree visualization + */ +function initNetworkTopology() { + networkDeviceTypes = getSetting("NETWORK_DEVICE_TYPES").replace("[", "").replace("]", ""); + showArchived = getCache('showArchived') === "true"; + showOffline = getCache('showOffline') === "true"; + + console.log('showArchived:', showArchived); + console.log('showOffline:', showOffline); + + // Always get all devices + const rawSql = ` + SELECT *, + CASE + WHEN devAlertDown != 0 AND devPresentLastScan = 0 THEN "Down" + WHEN devPresentLastScan = 1 THEN "On-line" + ELSE "Off-line" + END AS devStatus, + CASE + WHEN devType IN (${networkDeviceTypes}) THEN 1 + ELSE 0 + END AS devIsNetworkNodeDynamic + FROM Devices a + `; + + const apiBase = getApiBase(); + const apiToken = getApiToken(); + + // Verify token is available before making API call + if (!apiToken || apiToken.trim() === '') { + console.error("API_TOKEN not available. Settings may not be loaded yet. Retrying in 500ms..."); + // Retry after a short delay to allow settings to load + setTimeout(() => { + initNetworkTopology(); + }, 500); + return; + } + + const url = `${apiBase}/dbquery/read`; + + $.ajax({ + url, + method: "POST", + headers: { "Authorization": `Bearer ${apiToken}` }, + data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(rawSql))) }), + contentType: "application/json", + success: function(data) { + console.log(data); + + const allDevices = data.results || []; + + console.log(allDevices); + + if (!allDevices || allDevices.length === 0) { + showModalOK(getString('Gen_Warning'), getString('Network_NoDevices')); + return; + } + + // Count totals for UI + let archivedCount = 0; + let offlineCount = 0; + + allDevices.forEach(device => { + if (parseInt(device.devIsArchived) === 1) archivedCount++; + if (parseInt(device.devPresentLastScan) === 0 && parseInt(device.devIsArchived) === 0) offlineCount++; + }); + + if(archivedCount > 0) + { + $('#showArchivedNumber').text(`(${archivedCount})`); + } + + if(offlineCount > 0) + { + $('#showOfflineNumber').text(`(${offlineCount})`); + } + + // Now apply UI filter based on toggles (always keep root) + const filteredDevices = allDevices.filter(device => { + const isRoot = (device.devMac || '').toLowerCase() === 'internet'; + + if (isRoot) return true; + if (!showArchived && parseInt(device.devIsArchived) === 1) return false; + if (!showOffline && parseInt(device.devPresentLastScan) === 0) return false; + return true; + }); + + // Sort filtered devices + const orderTopologyBy = createArray(getSetting("UI_TOPOLOGY_ORDER")); + const devicesSorted = filteredDevices.sort((a, b) => { + const parsePort = (port) => { + const parsed = parseInt(port, 10); + return isNaN(parsed) ? Infinity : parsed; + }; + + switch (orderTopologyBy[0]) { + case "Name": + // ensuring string + const nameA = (a.devName ?? "").toString(); + const nameB = (b.devName ?? "").toString(); + const nameCompare = nameA.localeCompare(nameB); + return nameCompare !== 0 + ? nameCompare + : parsePort(a.devParentPort) - parsePort(b.devParentPort); + + case "Port": + return parsePort(a.devParentPort) - parsePort(b.devParentPort); + + default: + return a.rowid - b.rowid; + } + }); + + setCache('devicesListNew', JSON.stringify(devicesSorted)); + deviceListGlobal = devicesSorted; + + // Render filtered result + initTree(getHierarchy()); + loadNetworkNodes(); + attachTreeEvents(); + }, + error: function(xhr, status, error) { + console.error("Error loading topology data:", status, error); + if (xhr.status === 401) { + console.error("Authorization failed! API_TOKEN may be invalid. Check that API_TOKEN setting is correct and not empty."); + showMessage("Authorization Failed: API_TOKEN setting may be invalid or not loaded. Please refresh the page."); + } + } + }); +} + +// Initialize on page load +$(document).ready(function () { + // show spinning icon + showSpinner(); + + // Start loading the network topology + initNetworkTopology(); +}); diff --git a/front/js/network-tabs.js b/front/js/network-tabs.js new file mode 100644 index 000000000..060965328 --- /dev/null +++ b/front/js/network-tabs.js @@ -0,0 +1,259 @@ +// network-tabs.js +// Tab management and tab content rendering functions + +/** + * Render network tabs from nodes + * @param {Array} nodes - Array of network node objects + */ +function renderNetworkTabs(nodes) { + let html = ''; + nodes.forEach((node, i) => { + const iconClass = node.online == 1 ? "text-green" : + (node.node_alert == 1 ? "text-red" : "text-gray50"); + + const portLabel = node.node_ports_count ? ` (${node.node_ports_count})` : ''; + const icon = atob(node.node_icon); + const id = node.node_mac.replace(/:/g, '_'); + + html += ` +
  • + +
    ${icon}
    + ${node.node_name}${portLabel} +
    +
  • `; + }); + + $('.nav-tabs').html(html); + + // populate tabs + renderNetworkTabContent(nodes); + + // init selected (first) tab + initTab(); + + // init selected node highlighting + initSelectedNodeHighlighting() + + // Register events on tab change + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + initSelectedNodeHighlighting() + }); +} + +/** + * Render content for each network tab + * @param {Array} nodes - Array of network node objects + */ +function renderNetworkTabContent(nodes) { + $('.tab-content').empty(); + + nodes.forEach((node, i) => { + const id = node.node_mac.replace(/:/g, '_'); + + const badge = getStatusBadgeParts( + node.online, + node.node_alert, + node.node_mac + ); + + const badgeHtml = `${badge.iconHtml} ${badge.status}`; + const parentId = node.parent_mac.replace(/:/g, '_'); + + isRootNode = node.parent_mac == ""; + + const paneHtml = ` +
    +
    ${getString('Network_Node')}
    + +
    + + +
    + +
    + +
    ${node.node_mac}
    +
    + +
    + +
    ${node.node_type}
    +
    + +
    + +
    ${badgeHtml}
    +
    + + +
    +
    +
    + + ${getString('Network_Connected')} +
    + +
    +
    +
    + `; + + $('.tab-content').append(paneHtml); + loadConnectedDevices(node.node_mac); + }); +} + +/** + * Initialize the active tab based on cache or query parameter + */ +function initTab() +{ + key = "activeNetworkTab" + + // default selection + selectedTab = "Internet_id" + + // the #target from the url + target = getQueryString('mac') + + // update cookie if target specified + if(target != "") + { + setCache(key, target.replaceAll(":","_")+'_id') // _id is added so it doesn't conflict with AdminLTE tab behavior + } + + // get the tab id from the cookie (already overridden by the target) + if(!emptyArr.includes(getCache(key))) + { + selectedTab = getCache(key); + } + + // Activate panel + $('.nav-tabs a[id='+ selectedTab +']').tab('show'); + + // When changed save new current tab + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + setCache(key, $(e.target).attr('id')) + }); + +} + +/** + * Highlight the currently selected node in the tree + */ +function initSelectedNodeHighlighting() +{ + + var currentNodeMac = $(".networkNodeTabHeaders.active a").data("mytabmac"); + + // change highlighted node in the tree + selNode = $("#networkTree .highlightedNode")[0] + + console.log(selNode) + + if(selNode) + { + $(selNode).attr('class', $(selNode).attr('class').replace('highlightedNode')) + } + + newSelNode = $("#networkTree div[data-mac='"+currentNodeMac+"']")[0] + + console.log(newSelNode) + + $(newSelNode).attr('class', $(newSelNode).attr('class') + ' highlightedNode') +} + +/** + * Update a device's network assignment + * @param {string} leafMac - MAC address of device to update + * @param {string} action - 'assign' or 'unassign' + */ +function updateLeaf(leafMac, action) { + console.log(leafMac); // child + console.log(action); // action + + const nodeMac = $(".networkNodeTabHeaders.active a").data("mytabmac") || ""; + + if (action === "assign") { + if (!nodeMac) { + showMessage(getString("Network_Cant_Assign_No_Node_Selected")); + } else if (leafMac.toLowerCase().includes("internet")) { + showMessage(getString("Network_Cant_Assign")); + } else { + saveData("updateNetworkLeaf", leafMac, nodeMac); + setTimeout(() => location.reload(), 500); + } + + } else if (action === "unassign") { + saveData("updateNetworkLeaf", leafMac, ""); + setTimeout(() => location.reload(), 500); + + } else { + console.warn("Unknown action:", action); + } +} + +/** + * Dynamically show/hide tab names based on available space + * Hides tab names when tabs overflow, shows them again when space is available + */ +function checkTabsOverflow() { + const $ul = $('.nav-tabs'); + const $lis = $ul.find('li'); + + // First measure widths with current state + let totalWidth = 0; + $lis.each(function () { + totalWidth += $(this).outerWidth(true); + }); + + const ulWidth = $ul.width(); + const isOverflowing = totalWidth > ulWidth; + + if (isOverflowing) { + if (!$ul.hasClass('hide-node-names')) { + $ul.addClass('hide-node-names'); + + // Re-check: did hiding fix it? + requestAnimationFrame(() => { + let newTotal = 0; + $lis.each(function () { + newTotal += $(this).outerWidth(true); + }); + + if (newTotal > $ul.width()) { + // Still overflowing — do nothing, keep class + } + }); + } + } else { + if ($ul.hasClass('hide-node-names')) { + $ul.removeClass('hide-node-names'); + + // Re-check: did un-hiding break it? + requestAnimationFrame(() => { + let newTotal = 0; + $lis.each(function () { + newTotal += $(this).outerWidth(true); + }); + + if (newTotal > $ul.width()) { + // Oops, that broke it — re-hide + $ul.addClass('hide-node-names'); + } + }); + } + } +} diff --git a/front/js/network-tree.js b/front/js/network-tree.js new file mode 100644 index 000000000..256fa85dc --- /dev/null +++ b/front/js/network-tree.js @@ -0,0 +1,346 @@ +// network-tree.js +// Tree hierarchy construction and rendering functions + +// Global state variables +var leafNodesCount = 0; +var visibleNodesCount = 0; +var parentNodesCount = 0; +var hiddenMacs = []; // hidden children +var hiddenChildren = []; +var deviceListGlobal = null; +var myTree; + +/** + * Recursively get children nodes and build a tree + * @param {Object} node - Current node + * @param {Array} list - Full device list + * @param {string} path - Path to current node + * @param {Array} visited - Visited nodes (for cycle detection) + * @returns {Object} Tree node with children + */ +function getChildren(node, list, path, visited = []) +{ + var children = []; + + // Check for infinite recursion by seeing if the node has been visited before + if (visited.includes(node.devMac.toLowerCase())) { + console.error("Infinite recursion detected at node:", node.devMac); + write_notification("[ERROR] ⚠ Infinite recursion detected. You probably have assigned the Internet node to another children node or to itself. Please open a new issue on GitHub and describe how you did it.", 'interrupt') + return { error: "Infinite recursion detected", node: node.devMac }; + } + + // Add current node to visited list + visited.push(node.devMac.toLowerCase()); + + // Loop through all items to find children of the current node + for (var i in list) { + const item = list[i]; + const parentMac = item.devParentMAC?.toLowerCase() || ""; // null-safe + const nodeMac = node.devMac?.toLowerCase() || ""; // null-safe + + if (parentMac != "" && parentMac == nodeMac && !hiddenMacs.includes(parentMac)) { + + visibleNodesCount++; + + // Process children recursively, passing a copy of the visited list + children.push(getChildren(list[i], list, path + ((path == "") ? "" : '|') + parentMac, visited)); + } + } + + // Track leaf and parent node counts + if (children.length == 0) { + leafNodesCount++; + } else { + parentNodesCount++; + } + + // console.log(node); + + return { + name: node.devName, + path: path, + mac: node.devMac, + port: node.devParentPort, + id: node.devMac, + parentMac: node.devParentMAC, + icon: node.devIcon, + type: node.devType, + devIsNetworkNodeDynamic: node.devIsNetworkNodeDynamic, + vendor: node.devVendor, + lastseen: node.devLastConnection, + firstseen: node.devFirstConnection, + ip: node.devLastIP, + status: node.devStatus, + presentLastScan: node.devPresentLastScan, + alertDown: node.devAlertDown, + hasChildren: children.length > 0 || hiddenMacs.includes(node.devMac), + relType: node.devParentRelType, + devVlan: node.devVlan, + devSSID: node.devSSID, + hiddenChildren: hiddenMacs.includes(node.devMac), + qty: children.length, + children: children + }; +} + +/** + * Build complete hierarchy starting from the Internet node + * @returns {Object} Root hierarchy object + */ +function getHierarchy() +{ + // reset counters before rebuilding the hierarchy + leafNodesCount = 0; + visibleNodesCount = 0; + parentNodesCount = 0; + + let internetNode = null; + + for(i in deviceListGlobal) + { + if(deviceListGlobal[i].devMac.toLowerCase() == 'internet') + { + internetNode = deviceListGlobal[i]; + + return (getChildren(internetNode, deviceListGlobal, '')) + break; + } + } + + if (!internetNode) { + showModalOk( + getString('Network_Configuration_Error'), + getString('Network_Root_Not_Configured') + ); + console.error("getHierarchy(): Internet node not found"); + return null; + } +} + +/** + * Toggle collapse/expand state of a subtree + * @param {string} parentMac - MAC address of parent node to toggle + * @param {string} treePath - Path in tree (colon-separated) + */ +function toggleSubTree(parentMac, treePath) +{ + treePath = treePath.split('|') + + parentMac = parentMac.toLowerCase() + + if(!hiddenMacs.includes(parentMac)) + { + hiddenMacs.push(parentMac) + } + else + { + removeItemFromArray(hiddenMacs, parentMac) + } + + updatedTree = getHierarchy() + myTree.refresh(updatedTree); + + // re-attach any onclick events + attachTreeEvents(); +} + +/** + * Attach click events to tree collapse/expand controls + */ +function attachTreeEvents() +{ + // toggle subtree functionality + $("div[data-mytreemac]").each(function(){ + $(this).attr('onclick', 'toggleSubTree("'+$(this).attr('data-mytreemac')+'","'+ $(this).attr('data-mytreepath')+'")') + }); +} + +/** + * Convert pixels to em units + * @param {number} px - Pixel value + * @param {HTMLElement} element - Reference element for font-size + * @returns {number} Value in em units + */ +function pxToEm(px, element) { + var baseFontSize = parseFloat($(element || "body").css("font-size")); + return px / baseFontSize; +} + +/** + * Convert em units to pixels + * @param {number} em - Value in em units + * @param {HTMLElement} element - Reference element for font-size + * @returns {number} Value in pixels (rounded) + */ +function emToPx(em, element) { + var baseFontSize = parseFloat($(element || "body").css("font-size")); + return Math.round(em * baseFontSize); +} + +/** + * Initialize tree visualization + * @param {Object} myHierarchy - Hierarchy object to render + */ +function initTree(myHierarchy) +{ + if(myHierarchy && myHierarchy.type !== "") + { + // calculate the drawing area based on the tree width and available screen size + let baseFontSize = parseFloat($('html').css('font-size')); + let treeAreaHeight = ($(window).height() - 155); ; + let minNodeWidth = 60 // min safe node width not breaking the tree + + // calculate the font size of the leaf nodes to fit everything into the tree area + leafNodesCount == 0 ? 1 : leafNodesCount; + + emSize = pxToEm((treeAreaHeight/(leafNodesCount)).toFixed(2)); + + // let screenWidthEm = pxToEm($('.networkTable').width()-15); + let minTreeWidthPx = parentNodesCount * minNodeWidth; + let actualWidthPx = $('.networkTable').width() - 15; + + let finalWidthPx = Math.max(actualWidthPx, minTreeWidthPx); + + // override original value + let screenWidthEm = pxToEm(finalWidthPx); + + // handle canvas and node size if only a few nodes + emSize > 1 ? emSize = 1 : emSize = emSize; + + let nodeHeightPx = emToPx(emSize*1); + let nodeWidthPx = emToPx(screenWidthEm / (parentNodesCount)); + + // handle if only a few nodes + nodeWidthPx > 160 ? nodeWidthPx = 160 : nodeWidthPx = nodeWidthPx; + if (nodeWidthPx < minNodeWidth) nodeWidthPx = minNodeWidth; // minimum safe width + + console.log("Calculated nodeWidthPx =", nodeWidthPx, "emSize =", emSize , " screenWidthEm:", screenWidthEm, " emToPx(screenWidthEm):" , emToPx(screenWidthEm)); + + // init the drawing area size + $("#networkTree").attr('style', `height:${treeAreaHeight}px; width:${emToPx(screenWidthEm)}px`) + + console.log(Treeviz); + + myTree = Treeviz.create({ + htmlId: "networkTree", + renderNode: nodeData => { + + (!emptyArr.includes(nodeData.data.port )) ? port = nodeData.data.port : port = ""; + + (port == "" || port == 0 || port == 'None' ) ? portBckgIcon = `` : portBckgIcon = ``; + + portHtml = (port == "" || port == 0 || port == 'None' ) ? "   " : port; + + // Build HTML for individual nodes in the network diagram + deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ? + `
    + ${atob(nodeData.data.icon)} +
    ` : ""; + devicePort = `
    + ${portHtml}
    +
    + ${portBckgIcon} +
    `; + collapseExpandIcon = nodeData.data.hiddenChildren ? + "square-plus" : "square-minus"; + + // generate +/- icon if node has children nodes + collapseExpandHtml = nodeData.data.hasChildren ? + `
    + +
    ` : ""; + + selectedNodeMac = $(".nav-tabs-custom .active a").attr('data-mytabmac') + + highlightedCss = nodeData.data.mac == selectedNodeMac ? + " highlightedNode " : ""; + cssNodeType = nodeData.data.devIsNetworkNodeDynamic ? + " node-network-device " : " node-standard-device "; + + networkHardwareIcon = nodeData.data.devIsNetworkNodeDynamic ? ` + + ` : ""; + + const badgeConf = getStatusBadgeParts(nodeData.data.presentLastScan, nodeData.data.alertDown, nodeData.data.mac, statusText = '') + + return result = `
    +
    + ${devicePort} ${deviceIcon} + ${nodeData.data.name} + ${networkHardwareIcon} + +
    +
    + ${collapseExpandHtml}`; + }, + mainAxisNodeSpacing: 'auto', + // secondaryAxisNodeSpacing: 0.3, + nodeHeight: nodeHeightPx, + nodeWidth: nodeWidthPx, + marginTop: '5', + isHorizontal : true, + hasZoom: true, + hasPan: true, + marginLeft: '10', + marginRight: '10', + idKey: "mac", + hasFlatData: false, + relationnalField: "children", + linkLabel: { + render: (parent, child) => { + // Return text or HTML to display on the connection line + connectionLabel = (child?.data.devVlan ?? "") + "/" + (child?.data.devSSID ?? ""); + if(connectionLabel == "/") + { + connectionLabel = ""; + } + + return connectionLabel; + // or with HTML: + // return "reports to"; + }, + color: "#336c87ff", // Label text color (optional) + fontSize: nodeHeightPx - 5 // Label font size in px (optional) + }, + linkWidth: (nodeData) => 2, + linkColor: (nodeData) => { + relConf = getRelationshipConf(nodeData.data.relType) + return relConf.color; + } + // onNodeClick: (nodeData) => handleNodeClick(nodeData), + }); + + console.log(deviceListGlobal); + myTree.refresh(myHierarchy); + + // hide spinning icon + hideSpinner() + } else + { + console.error("getHierarchy() not returning expected result"); + } +} diff --git a/front/network.php b/front/network.php index 4ea6070d3..eb57afd59 100755 --- a/front/network.php +++ b/front/network.php @@ -3,11 +3,6 @@ require 'php/templates/modals.php'; ?> - -
    @@ -72,1058 +67,12 @@ - - - - + + + + + + From 00042ab594334b699035c68e54d861585b2fcc22 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Wed, 25 Feb 2026 21:59:51 +0000 Subject: [PATCH 020/122] refactor: Clean up whitespace and improve API token verification in network initialization --- front/js/network-api.js | 12 ++++++------ front/js/network-init.js | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/front/js/network-api.js b/front/js/network-api.js index 86e93fdbf..df6135b9e 100644 --- a/front/js/network-api.js +++ b/front/js/network-api.js @@ -7,12 +7,12 @@ */ function getApiToken() { let token = getSetting("API_TOKEN"); - + // If token is not yet available, log warning if (!token || token.trim() === '') { console.warn("API_TOKEN not yet loaded from settings"); } - + return token; } @@ -54,13 +54,13 @@ function loadNetworkNodes() { const apiBase = getApiBase(); const apiToken = getApiToken(); - + // Verify token is available if (!apiToken || apiToken.trim() === '') { console.error("API_TOKEN not available. Settings may not be loaded yet."); return; } - + const url = `${apiBase}/dbquery/read`; $.ajax({ @@ -97,13 +97,13 @@ function loadNetworkNodes() { function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, assignMode = true }) { const apiBase = getApiBase(); const apiToken = getApiToken(); - + // Verify token is available if (!apiToken || apiToken.trim() === '') { console.error("API_TOKEN not available. Settings may not be loaded yet."); return; } - + const url = `${apiBase}/dbquery/read`; $.ajax({ diff --git a/front/js/network-init.js b/front/js/network-init.js index a5e15d004..bfca39e55 100644 --- a/front/js/network-init.js +++ b/front/js/network-init.js @@ -35,7 +35,7 @@ function initNetworkTopology() { const apiBase = getApiBase(); const apiToken = getApiToken(); - + // Verify token is available before making API call if (!apiToken || apiToken.trim() === '') { console.error("API_TOKEN not available. Settings may not be loaded yet. Retrying in 500ms..."); @@ -45,7 +45,7 @@ function initNetworkTopology() { }, 500); return; } - + const url = `${apiBase}/dbquery/read`; $.ajax({ @@ -143,7 +143,7 @@ function initNetworkTopology() { $(document).ready(function () { // show spinning icon showSpinner(); - + // Start loading the network topology initNetworkTopology(); }); From 63cef590d6d3b5100168a20c1bacdfb765e5a611 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:21:29 +0000 Subject: [PATCH 021/122] Refactor network API calls to use centralized authentication context and improve cache handling - Removed redundant getApiToken function and replaced its usage with getAuthContext in network-api.js, network-events.js, and network-init.js. - Updated cache handling in network-events.js and network-init.js to use CACHE_KEYS constants for better maintainability. - Introduced cache.js for centralized cache management functions and constants, including cache initialization and retrieval. - Added app-init.js for application lifecycle management, including cache orchestration and initialization checks. - Created app_config.php to securely fetch API token and GraphQL port from configuration. - Improved error handling and logging throughout the codebase for better debugging and maintenance. --- front/deviceDetails.php | 33 +- front/deviceDetailsTools.php | 8 +- front/js/app-init.js | 281 ++++++++++++++ front/js/cache.js | 538 ++++++++++++++++++++++++++ front/js/common.js | 644 +------------------------------- front/js/network-api.js | 25 +- front/js/network-events.js | 8 +- front/js/network-init.js | 11 +- front/multiEditCore.php | 4 +- front/php/server/app_config.php | 38 ++ front/php/templates/header.php | 3 + front/php/templates/version.php | 2 +- 12 files changed, 914 insertions(+), 681 deletions(-) create mode 100644 front/js/app-init.js create mode 100644 front/js/cache.js create mode 100644 front/php/server/app_config.php diff --git a/front/deviceDetails.php b/front/deviceDetails.php index 7c9f3e2d5..aa89349b5 100755 --- a/front/deviceDetails.php +++ b/front/deviceDetails.php @@ -419,7 +419,12 @@ function initializeTabs () { const apiToken = getSetting("API_TOKEN"); const apiBaseUrl = getApiBase(); - const url = `${apiBaseUrl}/device/${getMac()}?period=${encodeURIComponent(period)}`; + // Ensure period is a string, not an element + let periodValue = period; + if (typeof period === 'object' && period !== null && 'value' in period) { + periodValue = period.value; + } + const url = `${apiBaseUrl}/device/${getMac()}?period=${encodeURIComponent(periodValue)}`; const response = await fetch(url, { method: "GET", @@ -553,20 +558,24 @@ function updateDevicePageName(mac) { //----------------------------------------------------------------------------------- -// Call renderSmallBoxes, then main -(async () => { - await renderSmallBoxes(); - main(); - })(); -window.onload = function async() -{ - mac = getMac() - // initializeTabs(); - updateChevrons(mac); - updateDevicePageName(mac); +window.onload = function() { + // Always trigger app-init bootstrap + if (typeof executeOnce === 'function') { + executeOnce(); + } + + mac = getMac(); + + // Wait for app initialization (cache populated) before using cached data + callAfterAppInitialized(async () => { + updateDevicePageName(mac); + updateChevrons(mac); + await renderSmallBoxes(); + main(); + }); } diff --git a/front/deviceDetailsTools.php b/front/deviceDetailsTools.php index 404c8349a..cbe6ebf2d 100755 --- a/front/deviceDetailsTools.php +++ b/front/deviceDetailsTools.php @@ -526,13 +526,7 @@ function copyFromDevice() { function getVisibleDevicesList() { // Read cache (skip cookie expiry check) - devicesList = getCache('devicesListAll_JSON', true); - - if (devicesList != '') { - devicesList = JSON.parse (devicesList); - } else { - devicesList = []; - } + devicesList = parseDeviceCache(getCache('devicesListAll_JSON', true)); // only loop thru the filtered down list visibleDevices = getCache("ntx_visible_macs") diff --git a/front/js/app-init.js b/front/js/app-init.js new file mode 100644 index 000000000..5da095609 --- /dev/null +++ b/front/js/app-init.js @@ -0,0 +1,281 @@ +/* ----------------------------------------------------------------------------- + * NetAlertX + * Open Source Network Guard / WIFI & LAN intrusion detector + * + * app-init.js - Front module. Application lifecycle: initialization, + * cache orchestration, and startup sequencing. + * Loaded AFTER common.js — depends on showSpinner(), isEmpty(), + * mergeUniqueArrays(), getSetting(), getString(), getCache(), + * setCache(), and all cache* functions from cache.js. + *------------------------------------------------------------------------------- + # jokob@duck.com GNU GPLv3 + ----------------------------------------------------------------------------- */ + +// ----------------------------------------------------------------------------- +// initialize +// ----------------------------------------------------------------------------- + +var completedCalls = [] +var completedCalls_final = ['cacheApiConfig', 'cacheSettings', 'cacheStrings', 'cacheDevices']; +var lang_completedCalls = 0; + + +// ----------------------------------------------------------------------------- +// Clearing all the caches +function clearCache() { + showSpinner(); + sessionStorage.clear(); + localStorage.clear(); + // Wait for spinner to show and cache to clear, then reload + setTimeout(() => { + console.warn("clearCache called"); + window.location.reload(); + }, 100); +} + +// =================================================================== +// DEPRECATED: checkSettingChanges() - Replaced by SSE-based manager +// Settings changes are now handled via SSE events +// Kept for backward compatibility, will be removed in future version +// =================================================================== +function checkSettingChanges() { + // SSE manager handles settings_changed events now + if (typeof netAlertXStateManager !== 'undefined' && netAlertXStateManager.initialized) { + return; // SSE handles this now + } + + // Fallback for backward compatibility + $.get('php/server/query_json.php', { file: 'app_state.json', nocache: Date.now() }, function(appState) { + const importedMilliseconds = parseInt(appState["settingsImported"] * 1000); + const lastReloaded = parseInt(getCache(CACHE_KEYS.INIT_TIMESTAMP)); + + if (importedMilliseconds > lastReloaded) { + console.log("Cache needs to be refreshed because of setting changes"); + setTimeout(() => { + clearCache(); + }, 500); + } + }); +} + +// =================================================================== +// Display spinner and reload page if not yet initialized +async function handleFirstLoad(callback) { + if (!isAppInitialized()) { + await new Promise(resolve => setTimeout(resolve, 1000)); + callback(); + } +} + +// =================================================================== +// Execute callback once the app is initialized and GraphQL server is running +async function callAfterAppInitialized(callback) { + if (!isAppInitialized() || !(await isGraphQLServerRunning())) { + setTimeout(() => { + callAfterAppInitialized(callback); + }, 500); + } else { + callback(); + } +} + +// =================================================================== +// Polling function to repeatedly check if the server is running +async function waitForGraphQLServer() { + const pollInterval = 2000; // 2 seconds between each check + let serverRunning = false; + + while (!serverRunning) { + serverRunning = await isGraphQLServerRunning(); + if (!serverRunning) { + console.log("GraphQL server not running, retrying in 2 seconds..."); + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + console.log("GraphQL server is now running."); +} + +// ----------------------------------------------------------------------------- +// Returns 1 if running, 0 otherwise +async function isGraphQLServerRunning() { + try { + const response = await $.get('php/server/query_json.php', { file: 'app_state.json', nocache: Date.now()}); + console.log("graphQLServerStarted: " + response["graphQLServerStarted"]); + setCache(CACHE_KEYS.GRAPHQL_STARTED, response["graphQLServerStarted"]); + return response["graphQLServerStarted"]; + } catch (error) { + console.error("Failed to check GraphQL server status:", error); + return false; + } +} + +// Throttle isAppInitialized logging so the console isn't spammed on every poll. +let _isAppInit_lastLogTime = 0; +function _isAppInitLog(msg) { + const now = Date.now(); + if (now - _isAppInit_lastLogTime > 5000) { // log at most once per 5s + console.log(msg); + _isAppInit_lastLogTime = now; + } +} + +// ----------------------------------------------------------------------------- +// Check if the code has been executed before by checking localStorage +function isAppInitialized() { + + lang_shouldBeCompletedCalls = getLangCode() == 'en_us' ? 1 : 2; + + // check if each ajax call completed succesfully + for (const call_name of completedCalls_final) { + if (getCache(CACHE_KEYS.initFlag(call_name)) != "true") { + _isAppInitLog(`[isAppInitialized] waiting on ${call_name} (value: ${getCache(CACHE_KEYS.initFlag(call_name))})`); + return false; + } + } + + // check if all required languages chached + if(parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) != lang_shouldBeCompletedCalls) + { + _isAppInitLog(`[isAppInitialized] waiting on cacheStrings: ${getCache(CACHE_KEYS.STRINGS_COUNT)} of ${lang_shouldBeCompletedCalls}`); + return false; + } + + return true; +} + +// Retry a single async init step up to maxAttempts times with a delay. +async function retryStep(name, fn, maxAttempts = 3, delayMs = 1500) { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await fn(); + return; // success + } catch (err) { + console.warn(`[executeOnce] ${name} failed (attempt ${attempt}/${maxAttempts}):`, err); + if (attempt < maxAttempts) { + await new Promise(r => setTimeout(r, delayMs)); + } else { + console.error(`[executeOnce] ${name} permanently failed after ${maxAttempts} attempts.`); + } + } + } +} + +// ----------------------------------------------------------------------------- +// Main execution logic +let _executeOnceRunning = false; +async function executeOnce() { + if (_executeOnceRunning) { + console.log('[executeOnce] Already running — skipping duplicate call.'); + return; + } + _executeOnceRunning = true; + showSpinner(); + + // Auto-bust stale cache if code version has changed since last init. + // Clears localStorage in-place so the subsequent init runs fresh without + // requiring a page reload. + if (getCache(CACHE_KEYS.CACHE_VERSION) !== NAX_CACHE_VERSION) { + console.log(`[executeOnce] Cache version mismatch (stored: "${getCache(CACHE_KEYS.CACHE_VERSION)}", expected: "${NAX_CACHE_VERSION}"). Clearing cache.`); + localStorage.clear(); + sessionStorage.clear(); + } + + if (!isAppInitialized()) { + try { + await waitForGraphQLServer(); // Wait for the server to start + + await retryStep('cacheApiConfig', cacheApiConfig); // Bootstrap: API_TOKEN + GRAPHQL_PORT from app.conf + await retryStep('cacheDevices', cacheDevices); + await retryStep('cacheSettings', cacheSettings); + await retryStep('cacheStrings', cacheStrings); + + console.log("All AJAX callbacks have completed"); + onAllCallsComplete(); + } finally { + _executeOnceRunning = false; + } + } else { + _executeOnceRunning = false; + } +} + + +// ----------------------------------------------------------------------------- +// Function to handle successful completion of an AJAX call +const handleSuccess = (callName) => { + console.log(`AJAX call successful: ${callName}`); + + if(callName.includes("cacheStrings")) + { + completed_tmp = getCache(CACHE_KEYS.STRINGS_COUNT); + completed_tmp == "" ? completed_tmp = 0 : completed_tmp = completed_tmp; + completed_tmp++; + setCache(CACHE_KEYS.STRINGS_COUNT, completed_tmp); + } + + setCache(CACHE_KEYS.initFlag(callName), true) +}; + +// ----------------------------------------------------------------------------- +// Function to handle failure of an AJAX call +const handleFailure = (callName, callback) => { + msg = `AJAX call ${callName} failed` + console.error(msg); + if (typeof callback === 'function') { + callback(new Error(msg)); + } +}; + +// ----------------------------------------------------------------------------- +// Function to execute when all AJAX calls have completed +const onAllCallsComplete = () => { + completedCalls = mergeUniqueArrays(getCache(CACHE_KEYS.COMPLETED_CALLS).split(','), completedCalls); + setCache(CACHE_KEYS.COMPLETED_CALLS, completedCalls); + + // Check if all necessary strings are initialized + if (areAllStringsInitialized()) { + const millisecondsNow = Date.now(); + setCache(CACHE_KEYS.INIT_TIMESTAMP, millisecondsNow); + setCache(CACHE_KEYS.CACHE_VERSION, NAX_CACHE_VERSION); + + console.log('✔ Cache initialized'); + + } else { + // If not all strings are initialized, retry initialization + console.log('❌ Not all strings are initialized. Retrying...'); + executeOnce(); + return; + } + + // Call any other initialization functions here if needed + +}; + +// Function to check if all necessary strings are initialized +const areAllStringsInitialized = () => { + // Implement logic to check if all necessary strings are initialized + // Return true if all strings are initialized, false otherwise + return getString('UI_LANG_name') != "" +}; + +// Call the function to execute the code +executeOnce(); + +// Set timer for regular UI refresh if enabled +setTimeout(() => { + + // page refresh if configured + const refreshTime = getSetting("UI_REFRESH"); + if (refreshTime && refreshTime !== "0" && refreshTime !== "") { + console.log("Refreshing page becasue UI_REFRESH setting enabled."); + newTimerRefreshData(clearCache, parseInt(refreshTime)*1000); + } + + // Check if page needs to refresh due to setting changes + checkSettingChanges() + +}, 10000); + + +console.log("init app-init.js"); diff --git a/front/js/cache.js b/front/js/cache.js new file mode 100644 index 000000000..84ce9b6ef --- /dev/null +++ b/front/js/cache.js @@ -0,0 +1,538 @@ +/* ----------------------------------------------------------------------------- + * NetAlertX + * Open Source Network Guard / WIFI & LAN intrusion detector + * + * cache.js - Front module. Cache primitives, settings, strings, and device + * data caching. Loaded FIRST — no dependencies on other NAX files. + * All cross-file calls (handleSuccess, showSpinner, etc.) are + * call-time dependencies resolved after page load. + *------------------------------------------------------------------------------- + # jokob@duck.com GNU GPLv3 + ----------------------------------------------------------------------------- */ + +// Cache version stamp — injected by header.php from the app's .VERSION file. +// Changes automatically on every release, busting stale localStorage caches. +// Falls back to a build-time constant so local dev without PHP still works. +const NAX_CACHE_VERSION = (typeof window.NAX_APP_VERSION !== 'undefined') + ? window.NAX_APP_VERSION + : 'dev'; + +// ----------------------------------------------------------------------------- +// Central registry of all localStorage cache keys. +// Use these constants (and the helper functions for dynamic keys) everywhere +// instead of bare string literals to prevent silent typo bugs. +// ----------------------------------------------------------------------------- +const CACHE_KEYS = { + // --- Init flags (dynamic) --- + // Stores "true" when an AJAX init call completes. Use initFlag(name) below. + initFlag: (name) => `${name}_completed`, + + // --- Settings --- + // Stores the value of a setting by its setKey. nax_set_ + setting: (key) => `nax_set_${key}`, + // Stores the resolved options array for a setting. nax_set_opt_ + settingOpts: (key) => `nax_set_opt_${key}`, + + // --- Language strings --- + // Stores a translated string. pia_lang__ + langString: (key, langCode) => `pia_lang_${key}_${langCode}`, + LANG_FALLBACK: 'en_us', // fallback language code + + // --- Devices --- + DEVICES_ALL: 'devicesListAll_JSON', // full device list from table_devices.json + DEVICES_TOPOLOGY: 'devicesListNew', // filtered/sorted list for network topology + + // --- UI state --- + VISIBLE_MACS: 'ntx_visible_macs', // comma-separated MACs visible in current view + SHOW_ARCHIVED: 'showArchived', // topology show-archived toggle (network page) + SHOW_OFFLINE: 'showOffline', // topology show-offline toggle (network page) + + // --- Internal init tracking --- + GRAPHQL_STARTED: 'graphQLServerStarted', // set when GraphQL server responds + STRINGS_COUNT: 'cacheStringsCountCompleted', // count of language packs loaded + COMPLETED_CALLS: 'completedCalls', // comma-joined list of completed init calls + INIT_TIMESTAMP: 'nax_init_timestamp', // ms timestamp of last successful cache init + CACHE_VERSION: 'nax_cache_version', // version stamp for auto-bust on deploy +}; + + +// ----------------------------------------------------------------------------- +// localStorage cache helpers +// ----------------------------------------------------------------------------- +function getCache(key) +{ + // check cache + cachedValue = localStorage.getItem(key) + + if(cachedValue) + { + return cachedValue; + } + + return ""; +} + +// ----------------------------------------------------------------------------- +function setCache(key, data) +{ + localStorage.setItem(key, data); +} + +// ----------------------------------------------------------------------------- +// Fetch data from a server-generated JSON file via query_json.php. +// Returns a Promise resolving with the "data" array from the response. +// ----------------------------------------------------------------------------- +function fetchJson(file) { + return new Promise((resolve, reject) => { + $.get('php/server/query_json.php', { file: file, nocache: Date.now() }) + .done((res) => resolve(res['data'] || [])) + .fail((err) => reject(err)); + }); +} + +// ----------------------------------------------------------------------------- +// Safely parse and normalize device cache data. +// Handles both direct array format and { data: [...] } format. +// Returns an array, or empty array on failure. +function parseDeviceCache(cachedStr) { + if (!cachedStr || cachedStr === "") { + return []; + } + + let parsed; + try { + parsed = JSON.parse(cachedStr); + } catch (err) { + console.error('[parseDeviceCache] Failed to parse:', err); + return []; + } + + // If result is an object with a .data property, extract it (handles legacy format) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.data)) { + console.warn('[parseDeviceCache] Extracting .data property from wrapper object'); + parsed = parsed.data; + } + + // Ensure result is an array + if (!Array.isArray(parsed)) { + console.error('[parseDeviceCache] Result is not an array:', parsed); + return []; + } + + return parsed; +} + +// ----------------------------------------------------------------------------- +// Returns the API token, base URL, and a ready-to-use Authorization header +// object for all backend API calls. Centralises the repeated +// getSetting("API_TOKEN") + getApiBase() pattern. +// ----------------------------------------------------------------------------- +function getAuthContext() { + const token = getSetting('API_TOKEN'); + const apiBase = getApiBase(); + return { + token, + apiBase, + authHeader: { 'Authorization': 'Bearer ' + token }, + }; +} + + +// ----------------------------------------------------------------------------- +// Get settings from the .json file generated by the python backend +// and cache them, if available, with options +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Bootstrap: fetch API_TOKEN and GRAPHQL_PORT directly from app.conf via the +// PHP helper endpoint. Runs before cacheSettings so that API calls made during +// or after init always have a token available — even if table_settings.json +// hasn't been generated yet. Writes values into the setting() namespace so +// getSetting("API_TOKEN") and getSetting("GRAPHQL_PORT") work immediately. +// ----------------------------------------------------------------------------- +function cacheApiConfig() { + return new Promise((resolve, reject) => { + if (getCache(CACHE_KEYS.initFlag('cacheApiConfig')) === 'true') { + resolve(); + return; + } + + $.get('php/server/app_config.php', { nocache: Date.now() }) + .done((res) => { + if (res && res.api_token) { + setCache(CACHE_KEYS.setting('API_TOKEN'), res.api_token); + setCache(CACHE_KEYS.setting('GRAPHQL_PORT'), String(res.graphql_port || 20212)); + handleSuccess('cacheApiConfig'); + resolve(); + } else { + console.warn('[cacheApiConfig] Response missing api_token — will rely on cacheSettings fallback'); + resolve(); // non-fatal: cacheSettings will still populate these + } + }) + .fail((err) => { + console.warn('[cacheApiConfig] Failed to reach app_config.php:', err); + resolve(); // non-fatal fallback + }); + }); +} + +function cacheSettings() +{ + return new Promise((resolve, reject) => { + if(getCache(CACHE_KEYS.initFlag('cacheSettings')) === "true") + { + resolve(); + return; + } + + // plugins.json may not exist on first boot — treat its absence as non-fatal + Promise.all([fetchJson('table_settings.json'), fetchJson('plugins.json').catch(() => [])]) + .then(([settingsArr, pluginsArr]) => { + pluginsData = pluginsArr; + settingsData = settingsArr; + + // Defensive: Accept either array or object with .data property + // for both settings and plugins + if (!Array.isArray(settingsData)) { + if (settingsData && Array.isArray(settingsData.data)) { + settingsData = settingsData.data; + } else { + console.error('[cacheSettings] settingsData is not an array:', settingsData); + reject(new Error('settingsData is not an array')); + return; + } + } + + // Normalize plugins array too (may have { data: [...] } format) + if (!Array.isArray(pluginsData)) { + if (pluginsData && Array.isArray(pluginsData.data)) { + pluginsData = pluginsData.data; + } else { + console.warn('[cacheSettings] pluginsData is not an array, treating as empty'); + pluginsData = []; + } + } + + settingsData.forEach((set) => { + resolvedOptions = createArray(set.setOptions) + resolvedOptionsOld = resolvedOptions + setPlugObj = {}; + options_params = []; + resolved = "" + + // proceed only if first option item contains something to resolve + if( !set.setKey.includes("__metadata") && + resolvedOptions.length != 0 && + resolvedOptions[0].includes("{value}")) + { + // get setting definition from the plugin config if available + setPlugObj = getPluginSettingObject(pluginsData, set.setKey) + + // check if options contains parameters and resolve + if(setPlugObj != {} && setPlugObj["options_params"]) + { + // get option_params for {value} resolution + options_params = setPlugObj["options_params"] + + if(options_params != []) + { + // handles only strings of length == 1 + + resolved = resolveParams(options_params, resolvedOptions[0]) + + if(resolved.includes('"')) // check if list of strings + { + resolvedOptions = `[${resolved}]` + } else // one value only + { + resolvedOptions = `["${resolved}"]` + } + } + } + } + + setCache(CACHE_KEYS.setting(set.setKey), set.setValue) + setCache(CACHE_KEYS.settingOpts(set.setKey), resolvedOptions) + }); + + handleSuccess('cacheSettings'); + resolve(); + }) + .catch((err) => { handleFailure('cacheSettings'); reject(err); }); + }); +} + +// ----------------------------------------------------------------------------- +// Get a setting options value by key +function getSettingOptions (key) { + + result = getCache(CACHE_KEYS.settingOpts(key)); + + if (result == "") + { + result = [] + } + + return result; +} + +// ----------------------------------------------------------------------------- +// Get a setting value by key +function getSetting (key) { + + result = getCache(CACHE_KEYS.setting(key)); + + return result; +} + +// ----------------------------------------------------------------------------- +// Get language string +// ----------------------------------------------------------------------------- +function cacheStrings() { + return new Promise((resolve, reject) => { + if(getCache(CACHE_KEYS.initFlag('cacheStrings')) === "true") + { + resolve(); + return; + } + + // Create a promise for each language (include en_us by default as fallback) + languagesToLoad = ['en_us'] + + additionalLanguage = getLangCode() + + if(additionalLanguage != 'en_us') + { + languagesToLoad.push(additionalLanguage) + } + + console.log(languagesToLoad); + + const languagePromises = languagesToLoad.map((language_code) => { + return new Promise((resolveLang, rejectLang) => { + // Fetch core strings and translations + + $.get(`php/templates/language/${language_code}.json?nocache=${Date.now()}`) + .done((res) => { + // Iterate over each key-value pair and store the translations + Object.entries(res).forEach(([key, value]) => { + setCache(CACHE_KEYS.langString(key, language_code), value); + }); + + // Fetch strings and translations from plugins (non-fatal — file may + // not exist on first boot or immediately after a cache clear) + fetchJson('table_plugins_language_strings.json') + .catch((pluginError) => { + console.warn('[cacheStrings] Plugin language strings unavailable (non-fatal):', pluginError); + return []; // treat as empty list + }) + .then((data) => { + // Defensive: ensure data is an array (fetchJson may return + // an object, undefined, or empty string on edge cases) + if (!Array.isArray(data)) { data = []; } + // Store plugin translations + data.forEach((langString) => { + setCache(CACHE_KEYS.langString(langString.String_Key, langString.Language_Code), langString.String_Value); + }); + + // Handle successful completion of language processing + handleSuccess('cacheStrings'); + resolveLang(); + }); + }) + .fail((error) => { + // Handle failure in core strings fetching + rejectLang(error); + }); + }); + }); + + // Wait for all language promises to complete + Promise.all(languagePromises) + .then(() => { + // All languages processed successfully + resolve(); + }) + .catch((error) => { + // Handle failure in any of the language processing + handleFailure('cacheStrings'); + reject(error); + }); + + }); +} + +// ----------------------------------------------------------------------------- +// Get translated language string +function getString(key) { + + function fetchString(key) { + + lang_code = getLangCode(); + + let result = getCache(CACHE_KEYS.langString(key, lang_code)); + + if (isEmpty(result)) { + result = getCache(CACHE_KEYS.langString(key, CACHE_KEYS.LANG_FALLBACK)); + } + + return result; + } + + if (isAppInitialized()) { + return fetchString(key); + } else { + callAfterAppInitialized(() => fetchString(key)); + } +} + +// ----------------------------------------------------------------------------- +// Get current language ISO code +// below has to match exactly the values in /front/php/templates/language/lang.php & /front/js/common.js +function getLangCode() { + + UI_LANG = getSetting("UI_LANG"); + + let lang_code = 'en_us'; + + switch (UI_LANG) { + case 'English (en_us)': + lang_code = 'en_us'; + break; + case 'Spanish (es_es)': + lang_code = 'es_es'; + break; + case 'German (de_de)': + lang_code = 'de_de'; + break; + case 'Farsi (fa_fa)': + lang_code = 'fa_fa'; + break; + case 'French (fr_fr)': + lang_code = 'fr_fr'; + break; + case 'Norwegian (nb_no)': + lang_code = 'nb_no'; + break; + case 'Polish (pl_pl)': + lang_code = 'pl_pl'; + break; + case 'Portuguese (pt_br)': + lang_code = 'pt_br'; + break; + case 'Portuguese (pt_pt)': + lang_code = 'pt_pt'; + break; + case 'Turkish (tr_tr)': + lang_code = 'tr_tr'; + break; + case 'Swedish (sv_sv)': + lang_code = 'sv_sv'; + break; + case 'Italian (it_it)': + lang_code = 'it_it'; + break; + case 'Japanese (ja_jp)': + lang_code = 'ja_jp'; + break; + case 'Russian (ru_ru)': + lang_code = 'ru_ru'; + break; + case 'Chinese (zh_cn)': + lang_code = 'zh_cn'; + break; + case 'Czech (cs_cz)': + lang_code = 'cs_cz'; + break; + case 'Arabic (ar_ar)': + lang_code = 'ar_ar'; + break; + case 'Catalan (ca_ca)': + lang_code = 'ca_ca'; + break; + case 'Ukrainian (uk_uk)': + lang_code = 'uk_ua'; + break; + case 'Vietnamese (vi_vn)': + lang_code = 'vi_vn'; + break; + } + + return lang_code; +} + +// ----------------------------------------------------------------------------- +// A function to get a device property using the mac address as key and DB column name as parameter +// for the value to be returned +function getDevDataByMac(macAddress, dbColumn) { + + const sessionDataKey = CACHE_KEYS.DEVICES_ALL; + const devicesCache = getCache(sessionDataKey); + + if (!devicesCache || devicesCache == "") { + console.warn(`[getDevDataByMac] Cache key "${sessionDataKey}" is empty — cache may not be initialized yet.`); + return null; + } + + const devices = parseDeviceCache(devicesCache); + + if (devices.length === 0) { + return null; + } + + for (const device of devices) { + if (device["devMac"].toLowerCase() === macAddress.toLowerCase()) { + + if(dbColumn) + { + return device[dbColumn]; + } + else + { + return device + } + } + } + + console.error("⚠ Device with MAC not found:" + macAddress) + return null; // Return a default value if MAC address is not found +} + +// ----------------------------------------------------------------------------- +// Cache the devices as one JSON +function cacheDevices() +{ + return new Promise((resolve, reject) => { + if(getCache(CACHE_KEYS.initFlag('cacheDevices')) === "true") + { + resolve(); + return; + } + + fetchJson('table_devices.json') + .then((arr) => { + + devicesListAll_JSON = arr; + + devicesListAll_JSON_str = JSON.stringify(devicesListAll_JSON) + + if(devicesListAll_JSON_str == "") + { + showSpinner() + + setTimeout(() => { + cacheDevices() + }, 1000); + } + + setCache(CACHE_KEYS.DEVICES_ALL, devicesListAll_JSON_str) + + handleSuccess('cacheDevices'); + resolve(); + }) + .catch((err) => { handleFailure('cacheDevices'); reject(err); }); + } + ); +} + +var devicesListAll_JSON = []; // this will contain a list off all devices diff --git a/front/js/common.js b/front/js/common.js index c5d109dea..205d49865 100755 --- a/front/js/common.js +++ b/front/js/common.js @@ -19,40 +19,10 @@ const allLanguages = ["ar_ar","ca_ca","cs_cz","de_de", "tr_tr","uk_ua","vi_vn","zh_cn"]; // needs to be same as in lang.php var settingsJSON = {} +// NAX_CACHE_VERSION and CACHE_KEYS moved to cache.js -// ----------------------------------------------------------------------------- -// Simple session cache withe expiration managed via cookies -// ----------------------------------------------------------------------------- -function getCache(key, noCookie = false) -{ - // check cache - cachedValue = localStorage.getItem(key) - // console.log(cachedValue); - - if(cachedValue) - { - // // check if not expired - // if(noCookie || getCookie(key + '_session_expiry') != "") - // { - return cachedValue; - // } - } - - return ""; -} - -// ----------------------------------------------------------------------------- -function setCache(key, data, expirationMinutes='') -{ - localStorage.setItem(key, data); - - // // create cookie if expiration set to handle refresh of data - // if (expirationMinutes != '') - // { - // setCookie ('cache_session_expiry', 'OK', 1) - // } -} +// getCache, setCache, fetchJson, getAuthContext moved to cache.js // ----------------------------------------------------------------------------- @@ -93,288 +63,13 @@ function deleteCookie (cookie) { document.cookie = cookie + '=;expires=Thu, 01 Jan 1970 00:00:00 UTC'; } -// ----------------------------------------------------------------------------- -function deleteAllCookies() { - // Array of cookies - var allCookies = document.cookie.split(";"); - - // For each cookie - for (var i = 0; i < allCookies.length; i++) { - var cookie = allCookies[i].trim(); - var eqPos = cookie.indexOf("="); - var name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; - document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 UTC"; - } -} +// cacheApiConfig, cacheSettings, getSettingOptions, getSetting moved to cache.js // ----------------------------------------------------------------------------- -// Get settings from the .json file generated by the python backend -// and cache them, if available, with options -// ----------------------------------------------------------------------------- -function cacheSettings() -{ - return new Promise((resolve, reject) => { - if(!getCache('cacheSettings_completed') === true) - { - $.get('php/server/query_json.php', { file: 'table_settings.json', nocache: Date.now() }, function(resSet) { - - $.get('php/server/query_json.php', { file: 'plugins.json', nocache: Date.now() }, function(resPlug) { - - pluginsData = resPlug["data"]; - settingsData = resSet["data"]; - - settingsData.forEach((set) => { - - resolvedOptions = createArray(set.setOptions) - resolvedOptionsOld = resolvedOptions - setPlugObj = {}; - options_params = []; - resolved = "" - - // proceed only if first option item contains something to resolve - if( !set.setKey.includes("__metadata") && - resolvedOptions.length != 0 && - resolvedOptions[0].includes("{value}")) - { - // get setting definition from the plugin config if available - setPlugObj = getPluginSettingObject(pluginsData, set.setKey) - - // check if options contains parameters and resolve - if(setPlugObj != {} && setPlugObj["options_params"]) - { - // get option_params for {value} resolution - options_params = setPlugObj["options_params"] - - if(options_params != []) - { - // handles only strings of length == 1 - - resolved = resolveParams(options_params, resolvedOptions[0]) - - if(resolved.includes('"')) // check if list of strings - { - resolvedOptions = `[${resolved}]` - } else // one value only - { - resolvedOptions = `["${resolved}"]` - } - } - } - } - - setCache(`nax_set_${set.setKey}`, set.setValue) - setCache(`nax_set_opt_${set.setKey}`, resolvedOptions) - }); - }).then(() => handleSuccess('cacheSettings', resolve())).catch(() => handleFailure('cacheSettings', reject("cacheSettings already completed"))); // handle AJAX synchronization - }) - } - }); -} - -// ----------------------------------------------------------------------------- -// Get a setting options value by key -function getSettingOptions (key) { - - // handle initial load to make sure everything is set-up and cached - // handleFirstLoad() - - result = getCache(`nax_set_opt_${key}`, true); - - if (result == "") - { - // console.log(`Setting options with key "${key}" not found`) - result = [] - } - - return result; -} - -// ----------------------------------------------------------------------------- -// Get a setting value by key -function getSetting (key) { - - // handle initial load to make sure everything is set-up and cached - // handleFirstLoad() - - result = getCache(`nax_set_${key}`, true); - - // if (result == "") - // { - // console.log(`Setting with key "${key}" not found`) - // } - - return result; -} - -// ----------------------------------------------------------------------------- -// Get language string -// ----------------------------------------------------------------------------- -function cacheStrings() { - return new Promise((resolve, reject) => { - - // Create a promise for each language (include en_us by default as fallback) - languagesToLoad = ['en_us'] - - additionalLanguage = getLangCode() - - if(additionalLanguage != 'en_us') - { - languagesToLoad.push(additionalLanguage) - } - - console.log(languagesToLoad); - - const languagePromises = languagesToLoad.map((language_code) => { - return new Promise((resolveLang, rejectLang) => { - // Fetch core strings and translations - - $.get(`php/templates/language/${language_code}.json?nocache=${Date.now()}`) - .done((res) => { - // Iterate over each key-value pair and store the translations - Object.entries(res).forEach(([key, value]) => { - setCache(`pia_lang_${key}_${language_code}`, value); - }); - - // Fetch strings and translations from plugins - $.get('php/server/query_json.php', { file: 'table_plugins_language_strings.json', nocache: Date.now() }) - .done((pluginRes) => { - const data = pluginRes["data"]; - - // Store plugin translations - data.forEach((langString) => { - setCache(`pia_lang_${langString.String_Key}_${langString.Language_Code}`, langString.String_Value); - }); - - // Handle successful completion of language processing - handleSuccess(`cacheStrings`, resolveLang); - }) - .fail((pluginError) => { - // Handle failure in plugin strings fetching - rejectLang(pluginError); - }); - }) - .fail((error) => { - // Handle failure in core strings fetching - rejectLang(error); - }); - }); - }); - - // Wait for all language promises to complete - Promise.all(languagePromises) - .then(() => { - // All languages processed successfully - resolve(); - }) - .catch((error) => { - // Handle failure in any of the language processing - handleFailure('cacheStrings', reject); - }); - - }); -} - -// ----------------------------------------------------------------------------- -// Get translated language string -function getString(key) { - - function fetchString(key) { - - lang_code = getLangCode(); - - let result = getCache(`pia_lang_${key}_${lang_code}`, true); - - if (isEmpty(result)) { - result = getCache(`pia_lang_${key}_en_us`, true); - } - - return result; - } - - if (isAppInitialized()) { - return fetchString(key); - } else { - callAfterAppInitialized(() => fetchString(key)); - } -} - -// ----------------------------------------------------------------------------- -// Get current language ISO code -// below has to match exactly the values in /front/php/templates/language/lang.php & /front/js/common.js -function getLangCode() { - - UI_LANG = getSetting("UI_LANG"); - - let lang_code = 'en_us'; - - switch (UI_LANG) { - case 'English (en_us)': - lang_code = 'en_us'; - break; - case 'Spanish (es_es)': - lang_code = 'es_es'; - break; - case 'German (de_de)': - lang_code = 'de_de'; - break; - case 'Farsi (fa_fa)': - lang_code = 'fa_fa'; - break; - case 'French (fr_fr)': - lang_code = 'fr_fr'; - break; - case 'Norwegian (nb_no)': - lang_code = 'nb_no'; - break; - case 'Polish (pl_pl)': - lang_code = 'pl_pl'; - break; - case 'Portuguese (pt_br)': - lang_code = 'pt_br'; - break; - case 'Portuguese (pt_pt)': - lang_code = 'pt_pt'; - break; - case 'Turkish (tr_tr)': - lang_code = 'tr_tr'; - break; - case 'Swedish (sv_sv)': - lang_code = 'sv_sv'; - break; - case 'Italian (it_it)': - lang_code = 'it_it'; - break; - case 'Japanese (ja_jp)': - lang_code = 'ja_jp'; - break; - case 'Russian (ru_ru)': - lang_code = 'ru_ru'; - break; - case 'Chinese (zh_cn)': - lang_code = 'zh_cn'; - break; - case 'Czech (cs_cz)': - lang_code = 'cs_cz'; - break; - case 'Arabic (ar_ar)': - lang_code = 'ar_ar'; - break; - case 'Catalan (ca_ca)': - lang_code = 'ca_ca'; - break; - case 'Ukrainian (uk_uk)': - lang_code = 'uk_ua'; - break; - case 'Vietnamese (vi_vn)': - lang_code = 'vi_vn'; - break; - } - - return lang_code; -} +// cacheStrings, getString, getLangCode moved to cache.js const tz = getSetting("TIMEZONE") || 'Europe/Berlin'; const LOCALE = getSetting('UI_LOCALE') || 'en-GB'; @@ -718,14 +413,13 @@ function numberArrayFromString(data) // ----------------------------------------------------------------------------- // Update network parent/child relationship (network tree) function updateNetworkLeaf(leafMac, parentMac) { - const apiBase = getApiBase(); - const apiToken = getSetting("API_TOKEN"); + const { apiBase, authHeader } = getAuthContext(); const url = `${apiBase}/device/${leafMac}/update-column`; $.ajax({ method: "POST", url: url, - headers: { "Authorization": `Bearer ${apiToken}` }, + headers: authHeader, data: JSON.stringify({ columnName: "devParentMAC", columnValue: parentMac }), contentType: "application/json", success: function(response) { @@ -1157,72 +851,7 @@ function isRandomMAC(mac) return options; } -// ----------------------------------------------------------------------------- -// A function to get a device property using the mac address as key and DB column nakme as parameter -// for the value to be returned -function getDevDataByMac(macAddress, dbColumn) { - - const sessionDataKey = 'devicesListAll_JSON'; - const devicesCache = getCache(sessionDataKey); - - if (!devicesCache || devicesCache == "") { - console.error(`Session variable "${sessionDataKey}" not found.`); - return null; - } - - const devices = JSON.parse(devicesCache); - - for (const device of devices) { - if (device["devMac"].toLowerCase() === macAddress.toLowerCase()) { - - if(dbColumn) - { - return device[dbColumn]; - } - else - { - return device - } - } - } - - console.error("⚠ Device with MAC not found:" + macAddress) - return null; // Return a default value if MAC address is not found -} - -// ----------------------------------------------------------------------------- -// Cache the devices as one JSON -function cacheDevices() -{ - return new Promise((resolve, reject) => { - - $.get('php/server/query_json.php', { file: 'table_devices.json', nocache: Date.now() }, function(data) { - - // console.log(data) - - devicesListAll_JSON = data["data"] - - devicesListAll_JSON_str = JSON.stringify(devicesListAll_JSON) - - if(devicesListAll_JSON_str == "") - { - showSpinner() - - setTimeout(() => { - cacheDevices() - }, 1000); - } - // console.log(devicesListAll_JSON_str); - - setCache('devicesListAll_JSON', devicesListAll_JSON_str) - - // console.log(getCache('devicesListAll_JSON')) - }).then(() => handleSuccess('cacheDevices', resolve())).catch(() => handleFailure('cacheDevices', reject("cacheDevices already completed"))); // handle AJAX synchronization - } - ); -} - -var devicesListAll_JSON = []; // this will contain a list off all devices +// getDevDataByMac, cacheDevices, devicesListAll_JSON moved to cache.js // ----------------------------------------------------------------------------- function isEmpty(value) @@ -1369,18 +998,13 @@ function updateApi(apiEndpoints) // value has to be in format event|param. e.g. run|ARPSCAN action = `${getGuid()}|update_api|${apiEndpoints}` - // Get data from the server - const apiToken = getSetting("API_TOKEN"); - const apiBaseUrl = getApiBase(); + const { token: apiToken, apiBase: apiBaseUrl, authHeader } = getAuthContext(); const url = `${apiBaseUrl}/logs/add-to-execution-queue`; $.ajax({ method: "POST", url: url, - headers: { - "Authorization": "Bearer " + apiToken, - "Content-Type": "application/json" - }, + headers: { ...authHeader, "Content-Type": "application/json" }, data: JSON.stringify({ action: action }), success: function(data, textStatus) { console.log(data) @@ -1548,16 +1172,11 @@ function hideUIelements(setKey) { function getDevicesList() { // Read cache (skip cookie expiry check) - devicesList = getCache('devicesListAll_JSON', true); - - if (devicesList != '') { - devicesList = JSON.parse (devicesList); - } else { - devicesList = []; - } + const cached = getCache(CACHE_KEYS.DEVICES_ALL); + let devicesList = parseDeviceCache(cached); // only loop thru the filtered down list - visibleDevices = getCache("ntx_visible_macs") + visibleDevices = getCache(CACHE_KEYS.VISIBLE_MACS) if(visibleDevices != "") { visibleDevicesMACs = visibleDevices.split(','); @@ -1616,18 +1235,14 @@ function restartBackend() { modalEventStatusId = 'modal-message-front-event' - const apiToken = getSetting("API_TOKEN"); - const apiBaseUrl = getApiBase(); + const { token: apiToken, apiBase: apiBaseUrl, authHeader } = getAuthContext(); const url = `${apiBaseUrl}/logs/add-to-execution-queue`; // Execute $.ajax({ method: "POST", url: url, - headers: { - "Authorization": "Bearer " + apiToken, - "Content-Type": "application/json" - }, + headers: { ...authHeader, "Content-Type": "application/json" }, data: JSON.stringify({ action: `cron_restart_backend` }), success: function(data, textStatus) { // showModalOk ('Result', data ); @@ -1642,237 +1257,8 @@ function restartBackend() { }) } -// ----------------------------------------------------------------------------- -// initialize -// ----------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- - -// Define a unique key for storing the flag in sessionStorage -const sessionStorageKey = "myScriptExecuted_common_js"; -var completedCalls = [] -var completedCalls_final = ['cacheSettings', 'cacheStrings', 'cacheDevices']; -var lang_completedCalls = 0; - - -// ----------------------------------------------------------------------------- -// Clearing all the caches -function clearCache() { - showSpinner(); - sessionStorage.clear(); - localStorage.clear(); - setTimeout(() => { - console.warn("clearChache called"); - window.location.reload(); - }, 500); -} - -// =================================================================== -// DEPRECATED: checkSettingChanges() - Replaced by SSE-based manager -// Settings changes are now handled via SSE events -// Kept for backward compatibility, will be removed in future version -// =================================================================== -function checkSettingChanges() { - // SSE manager handles settings_changed events now - if (typeof netAlertXStateManager !== 'undefined' && netAlertXStateManager.initialized) { - return; // SSE handles this now - } - - // Fallback for backward compatibility - $.get('php/server/query_json.php', { file: 'app_state.json', nocache: Date.now() }, function(appState) { - const importedMilliseconds = parseInt(appState["settingsImported"] * 1000); - const lastReloaded = parseInt(sessionStorage.getItem(sessionStorageKey + '_time')); - - if (importedMilliseconds > lastReloaded) { - console.log("Cache needs to be refreshed because of setting changes"); - setTimeout(() => { - clearCache(); - }, 500); - } - }); -} - -// =================================================================== -// Display spinner and reload page if not yet initialized -async function handleFirstLoad(callback) { - if (!isAppInitialized()) { - await new Promise(resolve => setTimeout(resolve, 1000)); - callback(); - } -} - -// =================================================================== -// Execute callback once the app is initialized and GraphQL server is running -async function callAfterAppInitialized(callback) { - if (!isAppInitialized() || !(await isGraphQLServerRunning())) { - setTimeout(() => { - callAfterAppInitialized(callback); - }, 500); - } else { - callback(); - } -} - -// =================================================================== -// Polling function to repeatedly check if the server is running -async function waitForGraphQLServer() { - const pollInterval = 2000; // 2 seconds between each check - let serverRunning = false; - - while (!serverRunning) { - serverRunning = await isGraphQLServerRunning(); - if (!serverRunning) { - console.log("GraphQL server not running, retrying in 2 seconds..."); - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - } - - console.log("GraphQL server is now running."); -} - -// ----------------------------------------------------------------------------- -// Returns 1 if running, 0 otherwise -async function isGraphQLServerRunning() { - try { - const response = await $.get('php/server/query_json.php', { file: 'app_state.json', nocache: Date.now()}); - console.log("graphQLServerStarted: " + response["graphQLServerStarted"]); - setCache("graphQLServerStarted", response["graphQLServerStarted"]); - return response["graphQLServerStarted"]; - } catch (error) { - console.error("Failed to check GraphQL server status:", error); - return false; - } -} - -// ----------------------------------------------------------------------------- -// Check if the code has been executed before by checking sessionStorage -function isAppInitialized() { - - lang_shouldBeCompletedCalls = getLangCode() == 'en_us' ? 1 : 2; - - // check if each ajax call completed succesfully - $.each(completedCalls_final, function(index, call_name){ - - if(getCache(call_name + "_completed") != "true") - { - console.log(`[isAppInitialized] AJAX call ${call_name} unsuccesful: ${getCache(call_name + "_completed")}`) - return false; - } - - }); - - // check if all required languages chached - if(parseInt(getCache("cacheStringsCountCompleted")) != lang_shouldBeCompletedCalls) - { - console.log(`[isAppInitialized] AJAX call cacheStrings unsuccesful: ${getCache("cacheStringsCountCompleted")} out of ${lang_shouldBeCompletedCalls}`) - return false; - } - - return true; -} - -// ----------------------------------------------------------------------------- -// Main execution logic -async function executeOnce() { - showSpinner(); - - if (!isAppInitialized()) { - try { - - await waitForGraphQLServer(); // Wait for the server to start - - await cacheDevices(); - await cacheSettings(); - await cacheStrings(); - - console.log("All AJAX callbacks have completed"); - onAllCallsComplete(); - } catch (error) { - console.error("Error:", error); - } - } -} - - -// ----------------------------------------------------------------------------- -// Function to handle successful completion of an AJAX call -const handleSuccess = (callName) => { - console.log(`AJAX call successful: ${callName}`); - - if(callName.includes("cacheStrings")) - { - completed_tmp = getCache("cacheStringsCountCompleted"); - completed_tmp == "" ? completed_tmp = 0 : completed_tmp = completed_tmp; - completed_tmp++; - setCache("cacheStringsCountCompleted", completed_tmp); - } - - setCache(callName + "_completed", true) -}; - -// ----------------------------------------------------------------------------- -// Function to handle failure of an AJAX call -const handleFailure = (callName, callback) => { - msg = `AJAX call ${callName} failed` - console.error(msg); - // Implement retry logic here if needed - // write_notification(msg, 'interrupt') -}; - -// ----------------------------------------------------------------------------- -// Function to execute when all AJAX calls have completed -const onAllCallsComplete = () => { - completedCalls = mergeUniqueArrays(getCache('completedCalls').split(','), completedCalls); - setCache('completedCalls', completedCalls); - - // Check if all necessary strings are initialized - if (areAllStringsInitialized()) { - sessionStorage.setItem(sessionStorageKey, "true"); - const millisecondsNow = Date.now(); - sessionStorage.setItem(sessionStorageKey + '_time', millisecondsNow); - - console.log('✔ Cache initialized'); - // setTimeout(() => { - // location.reload() - // }, 10); - - } else { - // If not all strings are initialized, retry initialization - console.log('❌ Not all strings are initialized. Retrying...'); - executeOnce(); - return; - } - - // Call any other initialization functions here if needed - -}; - -// Function to check if all necessary strings are initialized -const areAllStringsInitialized = () => { - // Implement logic to check if all necessary strings are initialized - // Return true if all strings are initialized, false otherwise - return getString('UI_LANG_name') != "" -}; - -// Call the function to execute the code -executeOnce(); - -// Set timer for regular UI refresh if enabled -setTimeout(() => { - - // page refresh if configured - const refreshTime = getSetting("UI_REFRESH"); - if (refreshTime && refreshTime !== "0" && refreshTime !== "") { - console.log("Refreshing page becasue UI_REFRESH setting enabled."); - newTimerRefreshData(clearCache, parseInt(refreshTime)*1000); - } - - // Check if page needs to refresh due to setting changes - checkSettingChanges() - -}, 10000); - +// App lifecycle (completedCalls, executeOnce, handleSuccess, clearCache, etc.) moved to app-init.js -console.log("init common.js"); diff --git a/front/js/network-api.js b/front/js/network-api.js index df6135b9e..712f10245 100644 --- a/front/js/network-api.js +++ b/front/js/network-api.js @@ -1,21 +1,6 @@ // network-api.js // API calls and data loading functions for network topology -/** - * Get API token, waiting if necessary for settings to load - * @returns {string} The API token - */ -function getApiToken() { - let token = getSetting("API_TOKEN"); - - // If token is not yet available, log warning - if (!token || token.trim() === '') { - console.warn("API_TOKEN not yet loaded from settings"); - } - - return token; -} - /** * Load network nodes (network device types) * Creates top-level tabs for each network device @@ -52,8 +37,7 @@ function loadNetworkNodes() { ORDER BY parent.devName; `; - const apiBase = getApiBase(); - const apiToken = getApiToken(); + const { token: apiToken, apiBase, authHeader } = getAuthContext(); // Verify token is available if (!apiToken || apiToken.trim() === '') { @@ -66,7 +50,7 @@ function loadNetworkNodes() { $.ajax({ url, method: "POST", - headers: { "Authorization": `Bearer ${apiToken}` }, + headers: { ...authHeader, "Content-Type": "application/json" }, data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(rawSql))) }), contentType: "application/json", success: function(data) { @@ -95,8 +79,7 @@ function loadNetworkNodes() { * @param {boolean} options.assignMode - Whether to show assign/unassign buttons */ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, assignMode = true }) { - const apiBase = getApiBase(); - const apiToken = getApiToken(); + const { token: apiToken, apiBase, authHeader } = getAuthContext(); // Verify token is available if (!apiToken || apiToken.trim() === '') { @@ -109,7 +92,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, $.ajax({ url, method: "POST", - headers: { "Authorization": `Bearer ${apiToken}` }, + headers: { ...authHeader, "Content-Type": "application/json" }, data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(sql))) }), contentType: "application/json", success: function(data) { diff --git a/front/js/network-events.js b/front/js/network-events.js index 4241fc106..9b97c8b6c 100644 --- a/front/js/network-events.js +++ b/front/js/network-events.js @@ -84,12 +84,12 @@ $(window).on('resize', function () { */ $(document).ready(function () { // Restore cached values on load - const cachedOffline = getCache('showOffline'); + const cachedOffline = getCache(CACHE_KEYS.SHOW_OFFLINE); if (cachedOffline !== null) { $('input[name="showOffline"]').prop('checked', cachedOffline === 'true'); } - const cachedArchived = getCache('showArchived'); + const cachedArchived = getCache(CACHE_KEYS.SHOW_ARCHIVED); if (cachedArchived !== null) { $('input[name="showArchived"]').prop('checked', cachedArchived === 'true'); } @@ -102,7 +102,7 @@ $(document).ready(function () { if (!isOfflineChecked) { archivedToggle.prop('checked', false); archivedToggle.prop('disabled', true); - setCache('showArchived', false); + setCache(CACHE_KEYS.SHOW_ARCHIVED, false); } else { archivedToggle.prop('disabled', false); } @@ -115,6 +115,8 @@ $(document).ready(function () { $('input[name="showOffline"], input[name="showArchived"]').on('change', function () { const name = $(this).attr('name'); const value = $(this).is(':checked'); + // setCache(name, value) works because CACHE_KEYS.SHOW_OFFLINE === 'showOffline' + // and CACHE_KEYS.SHOW_ARCHIVED === 'showArchived' — matches the DOM input name attr. setCache(name, value); // Update state of showArchived if showOffline changed diff --git a/front/js/network-init.js b/front/js/network-init.js index bfca39e55..b23364e41 100644 --- a/front/js/network-init.js +++ b/front/js/network-init.js @@ -12,8 +12,8 @@ var showOffline = false; */ function initNetworkTopology() { networkDeviceTypes = getSetting("NETWORK_DEVICE_TYPES").replace("[", "").replace("]", ""); - showArchived = getCache('showArchived') === "true"; - showOffline = getCache('showOffline') === "true"; + showArchived = getCache(CACHE_KEYS.SHOW_ARCHIVED) === "true"; + showOffline = getCache(CACHE_KEYS.SHOW_OFFLINE) === "true"; console.log('showArchived:', showArchived); console.log('showOffline:', showOffline); @@ -33,8 +33,7 @@ function initNetworkTopology() { FROM Devices a `; - const apiBase = getApiBase(); - const apiToken = getApiToken(); + const { token: apiToken, apiBase, authHeader } = getAuthContext(); // Verify token is available before making API call if (!apiToken || apiToken.trim() === '') { @@ -51,7 +50,7 @@ function initNetworkTopology() { $.ajax({ url, method: "POST", - headers: { "Authorization": `Bearer ${apiToken}` }, + headers: { ...authHeader, "Content-Type": "application/json" }, data: JSON.stringify({ rawSql: btoa(unescape(encodeURIComponent(rawSql))) }), contentType: "application/json", success: function(data) { @@ -121,7 +120,7 @@ function initNetworkTopology() { } }); - setCache('devicesListNew', JSON.stringify(devicesSorted)); + setCache(CACHE_KEYS.DEVICES_TOPOLOGY, JSON.stringify(devicesSorted)); deviceListGlobal = devicesSorted; // Render filtered result diff --git a/front/multiEditCore.php b/front/multiEditCore.php index 4484ff8aa..accf5e2eb 100755 --- a/front/multiEditCore.php +++ b/front/multiEditCore.php @@ -240,8 +240,8 @@ function getData(){ // Initialize device selectors / pickers fields function initDeviceSelectors() { - // Parse device list - devicesList = JSON.parse(getCache('devicesListAll_JSON')); + // Parse device list using the shared helper + devicesList = parseDeviceCache(getCache('devicesListAll_JSON')); // Check if the device list exists and is an array if (Array.isArray(devicesList)) { diff --git a/front/php/server/app_config.php b/front/php/server/app_config.php new file mode 100644 index 000000000..7de94cc74 --- /dev/null +++ b/front/php/server/app_config.php @@ -0,0 +1,38 @@ + 'Method not allowed']); + exit; +} + +// API_TOKEN: security.php extracts it from app.conf but the value is empty until Python +// initialise.py runs. Fall back to table_settings.json (runtime source of truth). +$resolved_token = !empty($api_token) ? $api_token : getSettingValue('API_TOKEN'); + +// GRAPHQL_PORT: format in app.conf is bare integer — GRAPHQL_PORT=20212 (no quotes) +$graphql_port_raw = getConfigLine('/^GRAPHQL_PORT\s*=/', $configLines); +$graphql_port = isset($graphql_port_raw[1]) ? (int) trim($graphql_port_raw[1]) : 20212; + +// Validate we have something useful before returning +if (empty($resolved_token) || str_starts_with($resolved_token, 'Could not')) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Could not read API_TOKEN from configuration']); + exit; +} + +header('Content-Type: application/json'); +echo json_encode([ + 'api_token' => $resolved_token, + 'graphql_port' => $graphql_port, +]); diff --git a/front/php/templates/header.php b/front/php/templates/header.php index e32b0efb8..8060abe92 100755 --- a/front/php/templates/header.php +++ b/front/php/templates/header.php @@ -43,7 +43,10 @@ + + + diff --git a/front/php/templates/version.php b/front/php/templates/version.php index e58e7aabf..a9cf10e62 100755 --- a/front/php/templates/version.php +++ b/front/php/templates/version.php @@ -18,7 +18,7 @@ if(trim($fileContents) === 'Dev') { echo date('H:i:s') . " - " . $fileContents; } else { - echo $fileContents; + echo trim($fileContents); } } else { From 9d64665599eb8347ab515e0fd79833db257c98df Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:21:57 +0000 Subject: [PATCH 022/122] fix: Remove trailing whitespace and clean up formatting in version.php --- front/php/templates/version.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/php/templates/version.php b/front/php/templates/version.php index a9cf10e62..3fb59466a 100755 --- a/front/php/templates/version.php +++ b/front/php/templates/version.php @@ -1,7 +1,7 @@ From 686a713aa8aeed46a60e2ba4b74d5355ec02ef47 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Fri, 27 Feb 2026 14:35:35 +1100 Subject: [PATCH 023/122] FE: lower case MAC issues #1538 Signed-off-by: jokob-sk --- docs/PLUGINS_DEV.md | 2 +- front/js/network-api.js | 7 +- front/js/network-init.js | 9 ++- front/js/network-tabs.js | 2 +- front/plugins/website_monitor/config.json | 2 +- server/models/device_instance.py | 86 ++++++++++++----------- 6 files changed, 58 insertions(+), 50 deletions(-) diff --git a/docs/PLUGINS_DEV.md b/docs/PLUGINS_DEV.md index 5176131f5..1bfaf8542 100755 --- a/docs/PLUGINS_DEV.md +++ b/docs/PLUGINS_DEV.md @@ -34,7 +34,7 @@ NetAlertX comes with a plugin system to feed events from third-party scripts int ### 🐛 Troubleshooting - **[Debugging Plugins](DEBUG_PLUGINS.md)** - Troubleshoot plugin issues -- **[Plugin Examples](../front/plugins)** - Study existing plugins as reference implementations +- **[Plugin Examples](https://github.com/netalertx/NetAlertX/tree/main/front/plugins)** - Study existing plugins as reference implementations ### 🎥 Video Tutorial diff --git a/front/js/network-api.js b/front/js/network-api.js index 712f10245..ba8f9abec 100644 --- a/front/js/network-api.js +++ b/front/js/network-api.js @@ -17,10 +17,10 @@ function loadNetworkNodes() { const rawSql = ` SELECT parent.devName AS node_name, - parent.devMac AS node_mac, + LOWER(parent.devMac) AS node_mac, parent.devPresentLastScan AS online, parent.devType AS node_type, - parent.devParentMAC AS parent_mac, + LOWER(parent.devParentMAC) AS parent_mac, parent.devIcon AS node_icon, parent.devAlertDown AS node_alert, COUNT(child.devMac) AS node_ports_count @@ -116,6 +116,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, orderable: false, width: '5%', render: function (mac) { + // mac = mac.toLowerCase() const label = assignMode ? 'assign' : 'unassign'; const btnClass = assignMode ? 'btn-primary' : 'btn-primary bg-red'; const btnText = assignMode ? getString('Network_ManageAssign') : getString('Network_ManageUnassign'); @@ -204,7 +205,7 @@ function loadUnassignedDevices() { SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort FROM Devices WHERE (devParentMAC IS NULL OR devParentMAC IN ("", " ", "undefined", "null")) - AND devMac NOT LIKE "%internet%" + AND LOWER(devMac) NOT LIKE "%internet%" AND devIsArchived = 0 ORDER BY devName ASC`; diff --git a/front/js/network-init.js b/front/js/network-init.js index b23364e41..474aba278 100644 --- a/front/js/network-init.js +++ b/front/js/network-init.js @@ -21,10 +21,12 @@ function initNetworkTopology() { // Always get all devices const rawSql = ` SELECT *, + LOWER(devMac) AS devMac, + LOWER(devParentMAC) AS devParentMAC, CASE - WHEN devAlertDown != 0 AND devPresentLastScan = 0 THEN "Down" - WHEN devPresentLastScan = 1 THEN "On-line" - ELSE "Off-line" + WHEN devAlertDown != 0 AND devPresentLastScan = 0 THEN 'Down' + WHEN devPresentLastScan = 1 THEN 'On-line' + ELSE 'Off-line' END AS devStatus, CASE WHEN devType IN (${networkDeviceTypes}) THEN 1 @@ -33,6 +35,7 @@ function initNetworkTopology() { FROM Devices a `; + const { token: apiToken, apiBase, authHeader } = getAuthContext(); // Verify token is available before making API call diff --git a/front/js/network-tabs.js b/front/js/network-tabs.js index 060965328..b8907b777 100644 --- a/front/js/network-tabs.js +++ b/front/js/network-tabs.js @@ -49,7 +49,7 @@ function renderNetworkTabContent(nodes) { $('.tab-content').empty(); nodes.forEach((node, i) => { - const id = node.node_mac.replace(/:/g, '_'); + const id = node.node_mac.replace(/:/g, '_').toLowerCase(); const badge = getStatusBadgeParts( node.online, diff --git a/front/plugins/website_monitor/config.json b/front/plugins/website_monitor/config.json index 49e19405e..9aeb11f0f 100755 --- a/front/plugins/website_monitor/config.json +++ b/front/plugins/website_monitor/config.json @@ -9,7 +9,7 @@ "display_name": [ { "language_code": "en_us", - "string": "Website monitor" + "string": "Services & Web monitor" }, { "language_code": "es_es", diff --git a/server/models/device_instance.py b/server/models/device_instance.py index d51fdf0a7..947267532 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -463,46 +463,49 @@ def getDeviceData(self, mac, period=""): # Fetch device info + computed fields sql = f""" - SELECT - d.*, - CASE - WHEN d.devAlertDown != 0 AND d.devPresentLastScan = 0 THEN 'Down' - WHEN d.devPresentLastScan = 1 THEN 'On-line' - ELSE 'Off-line' - END AS devStatus, - - (SELECT COUNT(*) FROM Sessions - WHERE ses_MAC = d.devMac AND ( - ses_DateTimeConnection >= {period_date_sql} OR - ses_DateTimeDisconnection >= {period_date_sql} OR - ses_StillConnected = 1 - )) AS devSessions, - - (SELECT COUNT(*) FROM Events - WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql} - AND eve_EventType NOT IN ('Connected','Disconnected')) AS devEvents, - - (SELECT COUNT(*) FROM Events - WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql} - AND eve_EventType = 'Device Down') AS devDownAlerts, - - (SELECT CAST(MAX(0, SUM( - julianday(IFNULL(ses_DateTimeDisconnection,'{now}')) - - julianday(CASE WHEN ses_DateTimeConnection < {period_date_sql} - THEN {period_date_sql} ELSE ses_DateTimeConnection END) - ) * 24) AS INT) - FROM Sessions - WHERE ses_MAC = d.devMac - AND ses_DateTimeConnection IS NOT NULL - AND (ses_DateTimeDisconnection IS NOT NULL OR ses_StillConnected = 1) - AND (ses_DateTimeConnection >= {period_date_sql} - OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1) - ) AS devPresenceHours - - FROM Devices d - WHERE d.devMac = ? OR CAST(d.rowid AS TEXT) = ? + SELECT + d.*, + LOWER(d.devMac) AS devMac, + LOWER(d.devParentMAC) AS devParentMAC, + CASE + WHEN d.devAlertDown != 0 AND d.devPresentLastScan = 0 THEN 'Down' + WHEN d.devPresentLastScan = 1 THEN 'On-line' + ELSE 'Off-line' + END AS devStatus, + + (SELECT COUNT(*) FROM Sessions + WHERE LOWER(ses_MAC) = LOWER(d.devMac) AND ( + ses_DateTimeConnection >= {period_date_sql} OR + ses_DateTimeDisconnection >= {period_date_sql} OR + ses_StillConnected = 1 + )) AS devSessions, + + (SELECT COUNT(*) FROM Events + WHERE LOWER(eve_MAC) = LOWER(d.devMac) AND eve_DateTime >= {period_date_sql} + AND eve_EventType NOT IN ('Connected','Disconnected')) AS devEvents, + + (SELECT COUNT(*) FROM Events + WHERE LOWER(eve_MAC) = LOWER(d.devMac) AND eve_DateTime >= {period_date_sql} + AND eve_EventType = 'Device Down') AS devDownAlerts, + + (SELECT CAST(MAX(0, SUM( + julianday(IFNULL(ses_DateTimeDisconnection,'{now}')) - + julianday(CASE WHEN ses_DateTimeConnection < {period_date_sql} + THEN {period_date_sql} ELSE ses_DateTimeConnection END) + ) * 24) AS INT) + FROM Sessions + WHERE LOWER(ses_MAC) = LOWER(d.devMac) + AND ses_DateTimeConnection IS NOT NULL + AND (ses_DateTimeDisconnection IS NOT NULL OR ses_StillConnected = 1) + AND (ses_DateTimeConnection >= {period_date_sql} + OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1) + ) AS devPresenceHours + + FROM Devices d + WHERE LOWER(d.devMac) = LOWER(?) OR CAST(d.rowid AS TEXT) = ? """ + conn = get_temp_db_connection() cur = conn.cursor() cur.execute(sql, (mac, mac)) @@ -818,9 +821,9 @@ def updateDeviceColumn(self, mac, column_name, column_value): conn = get_temp_db_connection() cur = conn.cursor() - # Build safe SQL with column name - sql = f"UPDATE Devices SET {column_name}=? WHERE devMac=?" - cur.execute(sql, (column_value, mac)) + # Convert the MAC to lowercase for comparison + sql = f"UPDATE Devices SET {column_name}=? WHERE LOWER(devMac)=?" + cur.execute(sql, (column_value, mac.lower())) conn.commit() if cur.rowcount > 0: @@ -831,6 +834,7 @@ def updateDeviceColumn(self, mac, column_name, column_value): conn.close() return result + def lockDeviceField(self, mac, field_name): """Lock a device field so it won't be overwritten by plugins.""" if field_name not in FIELD_SOURCE_MAP: From 4c0d5c73762f4f9c8cfe95cc9ae803e9e41de85f Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:07:55 +0000 Subject: [PATCH 024/122] refactor: Consolidate tab initialization logic using shared utility function --- front/deviceDetails.php | 22 ++------- front/js/ui_components.js | 94 +++++++++++++++++++++++++++++++++++++++ front/maintenance.php | 52 ++++------------------ front/pluginsCore.php | 8 +++- front/systeminfo.php | 27 ++--------- 5 files changed, 116 insertions(+), 87 deletions(-) diff --git a/front/deviceDetails.php b/front/deviceDetails.php index aa89349b5..792c32a8a 100755 --- a/front/deviceDetails.php +++ b/front/deviceDetails.php @@ -384,25 +384,9 @@ function performSwitch(direction) // ----------------------------------------------------------------------------- function initializeTabs () { - - key ="activeDevicesTab" - - // Activate panel - if(!emptyArr.includes(getCache(key))) - { - selectedTab = getCache(key); - } - - $('.nav-tabs a[id='+ selectedTab +']').tab('show'); - - // When changed save new current tab - $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { - setCache(key, $(e.target).attr('id')) - }); - - // events on tab change - $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { - var target = $(e.target).attr("href") // activated tab + initializeTabsShared({ + cacheKey: 'activeDevicesTab', + defaultTab: 'tabDetails' }); } diff --git a/front/js/ui_components.js b/front/js/ui_components.js index 80710fe1d..33c343034 100755 --- a/front/js/ui_components.js +++ b/front/js/ui_components.js @@ -8,6 +8,100 @@ ----------------------------------------------------------------------------- */ +// ------------------------------------------------------------------- +// Shared tab initialization utility. +// Resolves the active tab from URL hash, query param, or cache, then activates it. +// +// Options: +// cacheKey (string) - localStorage key for persisting the active tab (required) +// defaultTab (string) - fallback tab ID if nothing is found in URL or cache. Optional, defaults to ''. +// urlParamName (string) - query-string parameter name to read (e.g. 'tab'). Optional. +// useHash (boolean) - if true, reads window.location.hash as a tab target. Optional. +// idSuffix (string) - suffix appended to URL-derived targets to form the tab id (e.g. '_id'). Optional. +// onTabChange (function) - callback(targetHref) invoked when a tab is shown. Optional. +// delay (number) - ms to delay initialization (wraps in setTimeout). Optional. 0 = immediate. +// tabContainer (string) - CSS selector to scope tab lookups and event binding. Optional. null = whole document. +// +// Returns nothing. Activates the resolved tab and binds cache persistence. +// ------------------------------------------------------------------- +function initializeTabsShared(options) { + const { + cacheKey, + defaultTab = '', + urlParamName = null, + useHash = false, + idSuffix = '', + onTabChange = null, + delay = 0, + tabContainer = null // CSS selector to scope tab lookups (e.g. '#tabs-location') + } = options; + + function run() { + let selectedTab = defaultTab; + + // 1. URL hash (e.g. maintenance.php#tab_Logging) + if (useHash) { + let hashTarget = window.location.hash.substring(1); + if (hashTarget.includes('?')) { + hashTarget = hashTarget.split('?')[0]; + } + if (hashTarget) { + selectedTab = hashTarget.endsWith(idSuffix) ? hashTarget : hashTarget + idSuffix; + setCache(cacheKey, selectedTab); + } + } + + // 2. URL query parameter (e.g. ?tab=WEBMON) + if (urlParamName) { + const urlParams = new URLSearchParams(window.location.search); + const paramVal = urlParams.get(urlParamName); + if (paramVal) { + selectedTab = paramVal.endsWith(idSuffix) ? paramVal : paramVal + idSuffix; + setCache(cacheKey, selectedTab); + } + } + + // 3. Cached value (may already have been overridden above) + const cached = getCache(cacheKey); + if (cached && !emptyArr.includes(cached)) { + selectedTab = cached; + } + + // Resolve scoped vs global selectors + const $scope = tabContainer ? $(tabContainer) : $(document); + + // Activate the resolved tab (no-op if selectedTab is empty or not found) + if (selectedTab) { + $scope.find('a[id="' + selectedTab + '"]').tab('show'); + } + + // Fire callback for initial tab + if (onTabChange && selectedTab) { + const initialHref = $scope.find('a[id="' + selectedTab + '"]').attr('href'); + if (initialHref) { + onTabChange(initialHref); + } + } + + // Persist future tab changes to cache and invoke callback + $scope.find('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + const newTabId = $(e.target).attr('id'); + setCache(cacheKey, newTabId); + + if (onTabChange) { + const newHref = $(e.target).attr('href'); + onTabChange(newHref); + } + }); + } + + if (delay > 0) { + setTimeout(run, delay); + } else { + run(); + } +} + // ------------------------------------------------------------------- // Utility function to generate a random API token in the format t_ diff --git a/front/maintenance.php b/front/maintenance.php index 6648a3ee0..b0c0f93ef 100755 --- a/front/maintenance.php +++ b/front/maintenance.php @@ -778,50 +778,14 @@ function scrollDown() { // General initialization // -------------------------------------------------------- function initializeTabs() { - setTimeout(() => { - const key = "activeMaintenanceTab"; - - // default selection - let selectedTab = "tab_DBTools_id"; - - // the #target from the URL - let target = window.location.hash.substr(1); - - console.log(selectedTab); - - // get only the part between #...? - if (target.includes('?')) { - target = target.split('?')[0]; - } - - // update cookie if target specified - if (target) { - selectedTab = target.endsWith("_id") ? target : `${target}_id`; - setCache(key, selectedTab); // _id is added so it doesn't conflict with AdminLTE tab behavior - } - - // get the tab id from the cookie (already overridden by the target) - const cachedTab = getCache(key); - if (cachedTab && !emptyArr.includes(cachedTab)) { - selectedTab = cachedTab; - } - - // Activate panel - $('.nav-tabs a[id='+ selectedTab +']').tab('show'); - - // When changed save new current tab - $('a[data-toggle="tab"]').on('shown.bs.tab', (e) => { - const newTabId = $(e.target).attr('id'); - setCache(key, newTabId); - }); - - // events on tab change - $('a[data-toggle="tab"]').on('shown.bs.tab', (e) => { - const newTarget = $(e.target).attr("href"); // activated tab - }); - - hideSpinner(); - }, 50); + initializeTabsShared({ + cacheKey: 'activeMaintenanceTab', + defaultTab: 'tab_DBTools_id', + useHash: true, + idSuffix: '_id', + delay: 50 + }); + setTimeout(() => hideSpinner(), 50); } //------------------------------------------------------------------------------ diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 636759937..e4bb10808 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -330,7 +330,13 @@ function generateTabs() { } }); - + // Auto-select tab from ?tab= URL param or cache (scoped to plugin nav only) + initializeTabsShared({ + cacheKey: 'activePluginsTab', + urlParamName: 'tab', + idSuffix: '_id', + tabContainer: '#tabs-location' + }); hideSpinner() } diff --git a/front/systeminfo.php b/front/systeminfo.php index 320e56811..b6e2aaf29 100755 --- a/front/systeminfo.php +++ b/front/systeminfo.php @@ -118,29 +118,10 @@ function loadTabContent(target) { } function initializeTabs() { - const key = "activeSysinfoTab"; - let selectedTab = "tabServer"; // fallback default - - const cached = getCache(key); - if (!emptyArr.includes(cached)) { - selectedTab = cached; - } - - // Activate the correct tab - const $tabLink = $('.nav-tabs a[id="' + selectedTab + '"]'); - $tabLink.tab('show'); - - // Get the pane's ID from the tab's href attribute - const targetSelector = $tabLink.attr("href"); - loadTabContent(targetSelector); - - // On tab change - $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { - const newTabId = $(e.target).attr('id'); - setCache(key, newTabId); - - const newTarget = $(e.target).attr("href"); - loadTabContent(newTarget); + initializeTabsShared({ + cacheKey: 'activeSysinfoTab', + defaultTab: 'tabServer', + onTabChange: loadTabContent }); } From 173ffbe3b22875fefc5747e9f002d01d0ab66f0d Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:31:36 +0000 Subject: [PATCH 025/122] feat: Add cache clearing logic for imported settings in state update --- front/js/sse_manager.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/front/js/sse_manager.js b/front/js/sse_manager.js index 2c53d31dd..2c4e3d406 100644 --- a/front/js/sse_manager.js +++ b/front/js/sse_manager.js @@ -165,6 +165,16 @@ class NetAlertXStateManager { .html(displayTime) .attr('data-build-time', buildTime); + // 4. Trigger cache clear if settings were imported after last init + if (appState["settingsImported"]) { + const importedMs = parseInt(appState["settingsImported"] * 1000); + const lastReloaded = parseInt(getCache(CACHE_KEYS.INIT_TIMESTAMP)); + if (importedMs > lastReloaded) { + console.log("[NetAlertX State] Settings changed — clearing cache and reloading"); + setTimeout(() => clearCache(), 500); + } + } + // console.log("[NetAlertX State] UI updated via jQuery"); } catch (e) { console.error("[NetAlertX State] Failed to update state display:", e); From b74b803d6c6ddbc60a00283a8a881c7f736f3c64 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:29:55 +0000 Subject: [PATCH 026/122] feat: Add devFlapping attribute to device management and update related UI components --- docs/DEVICE_DISPLAY_SETTINGS.md | 10 ++++++---- front/devices.php | 15 +++++++++++---- front/js/network-api.js | 16 +++++++++------- front/js/network-init.js | 2 +- front/js/network-tabs.js | 1 + front/js/network-tree.js | 10 +++++++++- front/js/ui_components.js | 20 ++++++++++++++++---- front/php/templates/language/ar_ar.json | 1 + front/php/templates/language/ca_ca.json | 3 ++- front/php/templates/language/cs_cz.json | 1 + front/php/templates/language/de_de.json | 1 + front/php/templates/language/en_us.json | 1 + front/php/templates/language/es_es.json | 3 ++- front/php/templates/language/fa_fa.json | 1 + front/php/templates/language/fr_fr.json | 3 ++- front/php/templates/language/it_it.json | 3 ++- front/php/templates/language/ja_jp.json | 3 ++- front/php/templates/language/nb_no.json | 1 + front/php/templates/language/pl_pl.json | 1 + front/php/templates/language/pt_br.json | 1 + front/php/templates/language/pt_pt.json | 1 + front/php/templates/language/ru_ru.json | 1 + front/php/templates/language/sv_sv.json | 1 + front/php/templates/language/tr_tr.json | 1 + front/php/templates/language/uk_ua.json | 1 + front/php/templates/language/vi_vn.json | 1 + front/php/templates/language/zh_cn.json | 3 ++- front/plugins/ui_settings/config.json | 3 ++- server/api_server/graphql_endpoint.py | 2 +- 29 files changed, 82 insertions(+), 29 deletions(-) diff --git a/docs/DEVICE_DISPLAY_SETTINGS.md b/docs/DEVICE_DISPLAY_SETTINGS.md index 41eeb4db4..e4ae9ba68 100755 --- a/docs/DEVICE_DISPLAY_SETTINGS.md +++ b/docs/DEVICE_DISPLAY_SETTINGS.md @@ -1,6 +1,6 @@ # Device Display Settings -This set of settings allows you to group Devices under different views. The Archived toggle allows you to exclude a Device from most listings and notifications. +This set of settings allows you to group Devices under different views. The Archived toggle allows you to exclude a Device from most listings and notifications. ![Display settings](./img/DEVICE_MANAGEMENT/DeviceDetails_DisplaySettings.png) @@ -12,9 +12,11 @@ This set of settings allows you to group Devices under different views. The Arch 1. 🔌 Online (Green) = A device that is no longer marked as a "New Device". 2. 🔌 New (Green) = A newly discovered device that is online and is still marked as a "New Device". -3. ✖ New (Grey) = Same as No.2 but device is now offline. -4. ✖ Offline (Grey) = A device that was not detected online in the last scan. -5. ⚠ Down (Red) = A device that has "Alert Down" marked and has been offline for the time set in the Setting `NTFPRCS_alert_down_time`. +3. 🔌(❕) Online (Orange) - The device is unstable and flapping (3 status changes in the last hour) +4. ✖ New (Grey) = Same as No.2 but device is now offline. +5. ✖ Offline (Grey) = A device that was not detected online in the last scan. +6. ⚠ Down (Red) = A device that has "Alert Down" marked and has been offline for the time set in the Setting `NTFPRCS_alert_down_time`. + See also [Notification guide](./NOTIFICATIONS.md). \ No newline at end of file diff --git a/front/devices.php b/front/devices.php index 08da92546..2fd16c465 100755 --- a/front/devices.php +++ b/front/devices.php @@ -547,6 +547,7 @@ function mapColumnIndexToFieldName(index, tableColumnVisible) { "devVlan", // 30 "devPrimaryIPv4", // 31 "devPrimaryIPv6", // 32 + "devFlapping", // 33 ]; // console.log("OrderBy: " + columnNames[tableColumnOrder[index]]); @@ -666,6 +667,7 @@ function initializeDatatable (status) { devVlan devPrimaryIPv4 devPrimaryIPv6 + devFlapping } count } @@ -753,6 +755,7 @@ function initializeDatatable (status) { device.devVlan || "", device.devPrimaryIPv4 || "", device.devPrimaryIPv6 || "", + device.devFlapping || 0, ]; const newRow = []; @@ -821,6 +824,7 @@ function initializeDatatable (status) { data-status="${rowData[mapIndx(10)]}" data-present="${rowData[mapIndx(24)]}" data-alert="${rowData[mapIndx(25)]}" + data-flapping="${rowData[mapIndx(33)]}" data-icon="${rowData[mapIndx(3)]}"> ${displayedValue} @@ -964,12 +968,15 @@ function initializeDatatable (status) { tmp_devPresentLastScan = rowData[mapIndx(24)] tmp_devAlertDown = rowData[mapIndx(25)] + tmp_devMac = rowData[mapIndx(11)] + tmp_devFlapping = rowData[mapIndx(33)] const badge = getStatusBadgeParts( - rowData[mapIndx(24)], // tmp_devPresentLastScan - rowData[mapIndx(25)], // tmp_devAlertDown - rowData[mapIndx(11)], // MAC - cellData // optional text + tmp_devPresentLastScan, // tmp_devPresentLastScan + tmp_devAlertDown, // tmp_devAlertDown + tmp_devFlapping, // tmp_devFlapping + tmp_devMac, // MAC + cellData // optional text ); $(td).html (`${badge.iconHtml} ${badge.text}`); diff --git a/front/js/network-api.js b/front/js/network-api.js index ba8f9abec..4ed82e555 100644 --- a/front/js/network-api.js +++ b/front/js/network-api.js @@ -23,9 +23,10 @@ function loadNetworkNodes() { LOWER(parent.devParentMAC) AS parent_mac, parent.devIcon AS node_icon, parent.devAlertDown AS node_alert, + parent.devFlapping AS node_flapping, COUNT(child.devMac) AS node_ports_count - FROM Devices AS parent - LEFT JOIN Devices AS child + FROM DevicesView AS parent + LEFT JOIN DevicesView AS child /* CRITICAL FIX: COLLATE NOCASE ensures the join works even if devParentMAC is uppercase and devMac is lowercase */ @@ -33,7 +34,7 @@ function loadNetworkNodes() { WHERE parent.devType IN (${networkDeviceTypes}) AND parent.devIsArchived = 0 GROUP BY parent.devMac, parent.devName, parent.devPresentLastScan, - parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown + parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown, parent.devFlapping ORDER BY parent.devName; `; @@ -143,6 +144,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, const badge = getStatusBadgeParts( device.devPresentLastScan, device.devAlertDown, + device.devFlapping, device.devMac, device.devStatus ); @@ -202,8 +204,8 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, */ function loadUnassignedDevices() { const sql = ` - SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort - FROM Devices + SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort, devFlapping, devStatus + FROM DevicesView WHERE (devParentMAC IS NULL OR devParentMAC IN ("", " ", "undefined", "null")) AND LOWER(devMac) NOT LIKE "%internet%" AND devIsArchived = 0 @@ -237,7 +239,7 @@ function loadConnectedDevices(node_mac) { const normalized_mac = node_mac.toLowerCase(); const sql = ` - SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, + SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, devFlapping, CASE WHEN devIsNew = 1 THEN 'New' WHEN devPresentLastScan = 1 THEN 'On-line' @@ -246,7 +248,7 @@ function loadConnectedDevices(node_mac) { WHEN devPresentLastScan = 0 THEN 'Off-line' ELSE 'Unknown status' END AS devStatus - FROM Devices + FROM DevicesView /* Using COLLATE NOCASE here solves the 'TEXT' vs 'NOCASE' mismatch */ WHERE devParentMac = '${normalized_mac}' COLLATE NOCASE`; diff --git a/front/js/network-init.js b/front/js/network-init.js index 474aba278..b61ec9fe9 100644 --- a/front/js/network-init.js +++ b/front/js/network-init.js @@ -32,7 +32,7 @@ function initNetworkTopology() { WHEN devType IN (${networkDeviceTypes}) THEN 1 ELSE 0 END AS devIsNetworkNodeDynamic - FROM Devices a + FROM DevicesView a `; diff --git a/front/js/network-tabs.js b/front/js/network-tabs.js index b8907b777..f358a1ced 100644 --- a/front/js/network-tabs.js +++ b/front/js/network-tabs.js @@ -54,6 +54,7 @@ function renderNetworkTabContent(nodes) { const badge = getStatusBadgeParts( node.online, node.node_alert, + node.node_flapping, node.node_mac ); diff --git a/front/js/network-tree.js b/front/js/network-tree.js index 256fa85dc..a20c3a247 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -72,6 +72,7 @@ function getChildren(node, list, path, visited = []) ip: node.devLastIP, status: node.devStatus, presentLastScan: node.devPresentLastScan, + flapping: node.devFlapping, alertDown: node.devAlertDown, hasChildren: children.length > 0 || hiddenMacs.includes(node.devMac), relType: node.devParentRelType, @@ -266,7 +267,13 @@ function initTree(myHierarchy) ` : ""; - const badgeConf = getStatusBadgeParts(nodeData.data.presentLastScan, nodeData.data.alertDown, nodeData.data.mac, statusText = '') + const badgeConf = getStatusBadgeParts( + nodeData.data.presentLastScan, + nodeData.data.alertDown, + nodeData.data.flapping, + nodeData.data.mac, + statusText = '' + ); return result = `
    ${badge.iconHtml} ${badge.status}` const html = ` diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 1391389a6..d465e0769 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "اسم النطاق الكامل", "Device_TableHead_Favorite": "مفضل", "Device_TableHead_FirstSession": "أول جلسة", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "معرف فريد", "Device_TableHead_Group": "المجموعة", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index c6d5fe9ff..2d98f51c7 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Favorit", "Device_TableHead_FirstSession": "Primera Sessió", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grup", "Device_TableHead_IPv4": "IPv4", @@ -790,4 +791,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualitza el valor sota. Sigues curós de seguir el format anterior. No hi ha validació.", "test_event_tooltip": "Deseu els canvis primer abans de comprovar la configuració." -} +} \ No newline at end of file diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index 06dd9a15f..8d782439a 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "", "Device_TableHead_FirstSession": "", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "", "Device_TableHead_Group": "", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 19e1d12f1..a4596ef04 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -229,6 +229,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "Favorit", "Device_TableHead_FirstSession": "Erste Sitzung", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Gruppe", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 8129a5fcd..ce3f8a7f7 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Favorite", "Device_TableHead_FirstSession": "First Session", + "Device_TableHead_Flapping": "Flapping", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Group", "Device_TableHead_IPv4": "IPv4", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 7c1add8f8..a2fd036a5 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -227,6 +227,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Favorito", "Device_TableHead_FirstSession": "1ra. sesión", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grupo", "Device_TableHead_IPv4": "IPv4", @@ -861,4 +862,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualice el valor a continuación. Tenga cuidado de seguir el formato anterior. O la validación no se realiza.", "test_event_tooltip": "Guarda tus cambios antes de probar nuevos ajustes." -} +} \ No newline at end of file diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index e9e9fc846..62cac6f4f 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "", "Device_TableHead_FirstSession": "", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "", "Device_TableHead_Group": "", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 15070f2bc..4aa95a79f 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "Nom de domaine FQDN", "Device_TableHead_Favorite": "Favori", "Device_TableHead_FirstSession": "Première session", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Groupe", "Device_TableHead_IPv4": "IPv4", @@ -790,4 +791,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} +} \ No newline at end of file diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index b7ce9221a..bb9233dd9 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Preferito", "Device_TableHead_FirstSession": "Prima sessione", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Gruppo", "Device_TableHead_IPv4": "IPv4", @@ -790,4 +791,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 513399c57..061bc4d77 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "お気に入り", "Device_TableHead_FirstSession": "初回セッション", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "グループ", "Device_TableHead_IPv4": "IPv4", @@ -790,4 +791,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} +} \ No newline at end of file diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 21b66914c..1bd7b439f 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "Favoritt", "Device_TableHead_FirstSession": "Første Økt", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Gruppe", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index a208d6a2c..c8160f43a 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Ulubione", "Device_TableHead_FirstSession": "Pierwsza sesja", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grupa", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index a6ccad5b3..f61cc2551 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "Favorito", "Device_TableHead_FirstSession": "Primeira sessão", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grupo", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index d311d2c9d..fb0e54bf4 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Favorito", "Device_TableHead_FirstSession": "Primeira sessão", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grupo", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 670348185..2b58b2d19 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Избранное", "Device_TableHead_FirstSession": "Первый сеанс", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Группа", "Device_TableHead_IPv4": "IPv4", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index f38ce99ed..bad46da06 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "", "Device_TableHead_FirstSession": "", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "", "Device_TableHead_Group": "", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 8f50a7fc3..92ed9d7ce 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "Favori", "Device_TableHead_FirstSession": "İlk Oturum", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Grup", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 7fe0124e6..e77981e64 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Улюблений", "Device_TableHead_FirstSession": "Перша сесія", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Група", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index f38ce99ed..bad46da06 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "", "Device_TableHead_Favorite": "", "Device_TableHead_FirstSession": "", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "", "Device_TableHead_Group": "", "Device_TableHead_IPv4": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 91f28b9d0..7e8c43455 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -225,6 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "收藏", "Device_TableHead_FirstSession": "加入", + "Device_TableHead_Flapping": "", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "组", "Device_TableHead_IPv4": "IPv4", @@ -790,4 +791,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} +} \ No newline at end of file diff --git a/front/plugins/ui_settings/config.json b/front/plugins/ui_settings/config.json index 18cee2cb7..3da0bff20 100755 --- a/front/plugins/ui_settings/config.json +++ b/front/plugins/ui_settings/config.json @@ -443,7 +443,8 @@ "Device_TableHead_ReqNicsOnline", "Device_TableHead_Vlan", "Device_TableHead_IPv4", - "Device_TableHead_IPv6" + "Device_TableHead_IPv6", + "Device_TableHead_Flapping" ], "localized": ["name", "description"], "name": [ diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index eab237911..074d601e8 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -100,7 +100,7 @@ class Device(ObjectType): devParentPortSource = String(description="Source tracking for devParentPort (USER, LOCKED, NEWDEV, or plugin prefix)") devParentRelTypeSource = String(description="Source tracking for devParentRelType (USER, LOCKED, NEWDEV, or plugin prefix)") devVlanSource = String(description="Source tracking for devVlan") - devFlapping = String(description="ndicates flapping device (device changing between online/offline states frequently)") + devFlapping = Int(description="Indicates flapping device (device changing between online/offline states frequently)") class DeviceResult(ObjectType): From 24e2036bde935483bd4d1026e53af81766482258 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 28 Feb 2026 10:31:06 +1100 Subject: [PATCH 027/122] DOCS: flappin/usntable status addition Signed-off-by: jokob-sk --- .../device_management_status_colors.png | Bin 40139 -> 32207 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_colors.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_colors.png index 0762fa89db339990d83640d48771644505f176d7..76447cba87259a23f152794bc491a8013c9714d9 100755 GIT binary patch literal 32207 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 literal 40139 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 Date: Sat, 28 Feb 2026 00:06:04 +0000 Subject: [PATCH 028/122] feat: Refactor device column management and integrate new device-columns.js for centralized field definitions --- front/devices.php | 224 +++++++++------------------------ front/js/device-columns.js | 124 ++++++++++++++++++ front/js/ui_components.js | 42 +------ front/php/templates/footer.php | 1 + 4 files changed, 187 insertions(+), 204 deletions(-) create mode 100644 front/js/device-columns.js diff --git a/front/devices.php b/front/devices.php index 2fd16c465..3de3080f8 100755 --- a/front/devices.php +++ b/front/devices.php @@ -143,6 +143,9 @@ headersDefaultOrder = []; missingNumbers = []; + // DEVICE_COLUMN_FIELDS, COL, NUMERIC_DEFAULTS, GRAPHQL_EXTRA_FIELDS, COLUMN_NAME_MAP + // are all defined in js/device-columns.js — edit that file to add new columns. + // Read parameters & Initialize components callAfterAppInitialized(main) showSpinner(); @@ -512,47 +515,8 @@ function collectFilters() { // ----------------------------------------------------------------------------- // Map column index to column name for GraphQL query function mapColumnIndexToFieldName(index, tableColumnVisible) { - // the order is important, don't change it! - const columnNames = [ - "devName", // 0 - "devOwner", // 1 - "devType", // 2 - "devIcon", // 3 - "devFavorite", // 4 - "devGroup", // 5 - "devFirstConnection", // 6 - "devLastConnection", // 7 - "devLastIP", // 8 - "devIsRandomMac", // 9 resolved on the fly - "devStatus", // 10 resolved on the fly - "devMac", // 11 - "devIpLong", // 12 formatIPlong(device.devLastIP) || "", // IP orderable - "rowid", // 13 - "devParentMAC", // 14 - "devParentChildrenCount", // 15 resolved on the fly - "devLocation", // 16 - "devVendor", // 17 - "devParentPort", // 18 - "devGUID", // 19 - "devSyncHubNode", // 20 - "devSite", // 21 - "devSSID", // 22 - "devSourcePlugin", // 23 - "devPresentLastScan", // 24 - "devAlertDown", // 25 - "devCustomProps", // 26 - "devFQDN", // 27 - "devParentRelType", // 28 - "devReqNicsOnline", // 29 - "devVlan", // 30 - "devPrimaryIPv4", // 31 - "devPrimaryIPv6", // 32 - "devFlapping", // 33 - ]; - - // console.log("OrderBy: " + columnNames[tableColumnOrder[index]]); - - return columnNames[tableColumnOrder[index]] || null; + // Derives field name from the authoritative DEVICE_COLUMN_FIELDS constant. + return DEVICE_COLUMN_FIELDS[tableColumnOrder[index]] || null; } @@ -620,54 +584,15 @@ function initializeDatatable (status) { "type": "POST", "contentType": "application/json", "data": function (d) { - // Construct GraphQL query with pagination and sorting options + // GraphQL fields are derived from DEVICE_COLUMN_FIELDS + GRAPHQL_EXTRA_FIELDS + // (both defined in js/device-columns.js). No manual field list to maintain. + const _gqlFields = [...new Set([...DEVICE_COLUMN_FIELDS, ...GRAPHQL_EXTRA_FIELDS])] + .join('\n '); let graphqlQuery = ` query devices($options: PageQueryOptionsInput) { devices(options: $options) { devices { - rowid - devMac - devName - devOwner - devType - devVendor - devFavorite - devGroup - devComments - devFirstConnection - devLastConnection - devLastIP - devStaticIP - devScan - devLogEvents - devAlertEvents - devAlertDown - devSkipRepeated - devLastNotification - devPresentLastScan - devIsNew - devIsRandomMac - devLocation - devIsArchived - devParentMAC - devParentPort - devIcon - devGUID - devSite - devSSID - devSyncHubNode - devSourcePlugin - devStatus - devParentChildrenCount - devIpLong - devCustomProps - devFQDN - devParentRelType - devReqNicsOnline - devVlan - devPrimaryIPv4 - devPrimaryIPv6 - devFlapping + ${_gqlFields} } count } @@ -719,44 +644,13 @@ function initializeDatatable (status) { // Return only the array of rows for the table return json.devices.devices.map(device => { - // Convert each device record into the required DataTable row format - // Order has to be the same as in the UI_device_columns setting options - const originalRow = [ - device.devName || "", - device.devOwner || "", - device.devType || "", - device.devIcon || "", - device.devFavorite || "", - device.devGroup || "", - device.devFirstConnection || "", - device.devLastConnection || "", - device.devLastIP || "", - device.devIsRandomMac || "", - device.devStatus || "", - device.devMac || "", - device.devIpLong || "", - device.rowid || "", - device.devParentMAC || "", - device.devParentChildrenCount || 0, - device.devLocation || "", - device.devVendor || "", - device.devParentPort || "", - device.devGUID || "", - device.devSyncHubNode || "", - device.devSite || "", - device.devSSID || "", - device.devSourcePlugin || "", - device.devPresentLastScan || "", - device.devAlertDown || "", - device.devCustomProps || "", - device.devFQDN || "", - device.devParentRelType || "", - device.devReqNicsOnline || 0, - device.devVlan || "", - device.devPrimaryIPv4 || "", - device.devPrimaryIPv6 || "", - device.devFlapping || 0, - ]; + // Build positional row directly from DEVICE_COLUMN_FIELDS. + // NUMERIC_DEFAULTS controls which fields default to 0 vs "". + // Adding a new column: add to DEVICE_COLUMN_FIELDS (and NUMERIC_DEFAULTS + // if needed) in js/device-columns.js — nothing to change here. + const originalRow = DEVICE_COLUMN_FIELDS.map( + field => device[field] ?? (NUMERIC_DEFAULTS.has(field) ? 0 : "") + ); const newRow = []; // Reorder data based on user-defined columns order @@ -790,15 +684,15 @@ function initializeDatatable (status) { 'columnDefs' : [ {visible: false, targets: tableColumnHide }, - {className: 'text-center', targets: [mapIndx(4), mapIndx(9), mapIndx(10), mapIndx(15), mapIndx(18)] }, - {className: 'iconColumn text-center', targets: [mapIndx(3)]}, - {width: '80px', targets: [mapIndx(6), mapIndx(7), mapIndx(15), mapIndx(27)] }, - {width: '85px', targets: [mapIndx(9)] }, - {width: '30px', targets: [mapIndx(3), mapIndx(10), mapIndx(13), mapIndx(18)] }, - {orderData: [mapIndx(12)], targets: mapIndx(8) }, + {className: 'text-center', targets: [mapIndx(COL.devFavorite), mapIndx(COL.devIsRandomMac), mapIndx(COL.devStatus), mapIndx(COL.devParentChildrenCount), mapIndx(COL.devParentPort)] }, + {className: 'iconColumn text-center', targets: [mapIndx(COL.devIcon)]}, + {width: '80px', targets: [mapIndx(COL.devFirstConnection), mapIndx(COL.devLastConnection), mapIndx(COL.devParentChildrenCount), mapIndx(COL.devFQDN)] }, + {width: '85px', targets: [mapIndx(COL.devIsRandomMac)] }, + {width: '30px', targets: [mapIndx(COL.devIcon), mapIndx(COL.devStatus), mapIndx(COL.rowid), mapIndx(COL.devParentPort)] }, + {orderData: [mapIndx(COL.devIpLong)], targets: mapIndx(COL.devLastIP) }, // Device Name and FQDN - {targets: [mapIndx(0), mapIndx(27)], + {targets: [mapIndx(COL.devName), mapIndx(COL.devFQDN)], 'createdCell': function (td, cellData, rowData, row, col) { // console.log(cellData) @@ -812,20 +706,20 @@ function initializeDatatable (status) { $(td).html ( ` - + data-ip="${rowData[mapIndx(COL.devLastIP)]}" + data-mac="${rowData[mapIndx(COL.devMac)]}" + data-vendor="${rowData[mapIndx(COL.devVendor)]}" + data-type="${rowData[mapIndx(COL.devType)]}" + data-firstseen="${rowData[mapIndx(COL.devFirstConnection)]}" + data-lastseen="${rowData[mapIndx(COL.devLastConnection)]}" + data-relationship="${rowData[mapIndx(COL.devParentRelType)]}" + data-status="${rowData[mapIndx(COL.devStatus)]}" + data-present="${rowData[mapIndx(COL.devPresentLastScan)]}" + data-alert="${rowData[mapIndx(COL.devAlertDown)]}" + data-flapping="${rowData[mapIndx(COL.devFlapping)]}" + data-icon="${rowData[mapIndx(COL.devIcon)]}"> ${displayedValue} ` @@ -833,12 +727,12 @@ function initializeDatatable (status) { } }, // Connected Devices - {targets: [mapIndx(15)], + {targets: [mapIndx(COL.devParentChildrenCount)], 'createdCell': function (td, cellData, rowData, row, col) { // check if this is a network device - if(getSetting("NETWORK_DEVICE_TYPES").includes(`'${rowData[mapIndx(2)]}'`) ) + if(getSetting("NETWORK_DEVICE_TYPES").includes(`'${rowData[mapIndx(COL.devType)]}'`) ) { - $(td).html (''+ cellData +''); + $(td).html (''+ cellData +''); } else { @@ -848,7 +742,7 @@ function initializeDatatable (status) { } }, // Icon - {targets: [mapIndx(3)], + {targets: [mapIndx(COL.devIcon)], 'createdCell': function (td, cellData, rowData, row, col) { if (!emptyArr.includes(cellData)){ @@ -859,7 +753,7 @@ function initializeDatatable (status) { } }, // Full MAC - {targets: [mapIndx(11)], + {targets: [mapIndx(COL.devMac)], 'createdCell': function (td, cellData, rowData, row, col) { if (!emptyArr.includes(cellData)){ $(td).html (''+cellData+''); @@ -869,7 +763,7 @@ function initializeDatatable (status) { } }, // IP address - {targets: [mapIndx(8)], + {targets: [mapIndx(COL.devLastIP)], 'createdCell': function (td, cellData, rowData, row, col) { if (!emptyArr.includes(cellData)){ $(td).html (` @@ -887,8 +781,8 @@ function initializeDatatable (status) { } } }, - // IP address (ordeable) - {targets: [mapIndx(12)], + // IP address (orderable) + {targets: [mapIndx(COL.devIpLong)], 'createdCell': function (td, cellData, rowData, row, col) { if (!emptyArr.includes(cellData)){ $(td).html (`${cellData}`); @@ -899,10 +793,10 @@ function initializeDatatable (status) { }, // Custom Properties - {targets: [mapIndx(26)], + {targets: [mapIndx(COL.devCustomProps)], 'createdCell': function (td, cellData, rowData, row, col) { if (!emptyArr.includes(cellData)){ - $(td).html (`${renderCustomProps(cellData, rowData[mapIndx(11)])}`); + $(td).html (`${renderCustomProps(cellData, rowData[mapIndx(COL.devMac)])}`); } else { $(td).html (''); } @@ -910,7 +804,7 @@ function initializeDatatable (status) { }, // Favorite - {targets: [mapIndx(4)], + {targets: [mapIndx(COL.devFavorite)], 'createdCell': function (td, cellData, rowData, row, col) { if (cellData == 1){ $(td).html (''); @@ -920,7 +814,7 @@ function initializeDatatable (status) { } }, // Dates - {targets: [mapIndx(6), mapIndx(7)], + {targets: [mapIndx(COL.devFirstConnection), mapIndx(COL.devLastConnection)], 'createdCell': function (td, cellData, rowData, row, col) { var result = cellData.toString(); // Convert to string if (result.includes("+")) { // Check if timezone offset is present @@ -930,7 +824,7 @@ function initializeDatatable (status) { } }, // Random MAC - {targets: [mapIndx(9)], + {targets: [mapIndx(COL.devIsRandomMac)], 'createdCell': function (td, cellData, rowData, row, col) { // console.log(cellData) if (cellData == 1){ @@ -941,7 +835,7 @@ function initializeDatatable (status) { } }, // Parent Mac - {targets: [mapIndx(14)], + {targets: [mapIndx(COL.devParentMAC)], 'createdCell': function (td, cellData, rowData, row, col) { if (!isValidMac(cellData)) { $(td).html(''); @@ -963,13 +857,13 @@ function initializeDatatable (status) { } }, // Status color - {targets: [mapIndx(10)], + {targets: [mapIndx(COL.devStatus)], 'createdCell': function (td, cellData, rowData, row, col) { - tmp_devPresentLastScan = rowData[mapIndx(24)] - tmp_devAlertDown = rowData[mapIndx(25)] - tmp_devMac = rowData[mapIndx(11)] - tmp_devFlapping = rowData[mapIndx(33)] + tmp_devPresentLastScan = rowData[mapIndx(COL.devPresentLastScan)] + tmp_devAlertDown = rowData[mapIndx(COL.devAlertDown)] + tmp_devMac = rowData[mapIndx(COL.devMac)] + tmp_devFlapping = rowData[mapIndx(COL.devFlapping)] const badge = getStatusBadgeParts( tmp_devPresentLastScan, // tmp_devPresentLastScan @@ -1044,7 +938,7 @@ function initializeDatatable (status) { }, createdRow: function(row, data, dataIndex) { // add devMac to the table row - $(row).attr('my-devMac', data[mapIndx(11)]); + $(row).attr('my-devMac', data[mapIndx(COL.devMac)]); } @@ -1090,7 +984,7 @@ function multiEditDevices() macs = "" for (var j = 0; j < selectedDevicesDataTableData.length; j++) { - macs += selectedDevicesDataTableData[j][mapIndx(11)] + ","; // [11] == MAC + macs += selectedDevicesDataTableData[j][mapIndx(COL.devMac)] + ","; // MAC } // redirect to the Maintenance section @@ -1111,7 +1005,7 @@ function getMacsOfShownDevices() { allIndexes.each(function(idx) { var rowData = table.row(idx).data(); if (rowData) { - macs.push(rowData[mapIndx(11)]); // mapIndx(11) == MAC column + macs.push(rowData[mapIndx(COL.devMac)]); // MAC column } }); diff --git a/front/js/device-columns.js b/front/js/device-columns.js new file mode 100644 index 000000000..5d68c9ec2 --- /dev/null +++ b/front/js/device-columns.js @@ -0,0 +1,124 @@ +// ============================================================================= +// device-columns.js — Single source of truth for device field definitions. +// +// To add a new device column, update ONLY these places: +// 1. DEVICE_COLUMN_FIELDS — add the field name in the correct position +// 2. COLUMN_NAME_MAP — add Device_TableHead_X → fieldName mapping +// 3. NUMERIC_DEFAULTS — add fieldName if its default value is 0 not "" +// 4. GRAPHQL_EXTRA_FIELDS — add fieldName ONLY if it is NOT a display column +// (i.e. fetched for logic but not shown in table) +// 5. front/plugins/ui_settings/config.json options[] +// 6. front/php/templates/language/en_us.json Device_TableHead_X +// then run merge_translations.py for other languages +// 7. Backend: DB view + GraphQL type +// ============================================================================= + +// Ordered list of all device table column field names. +// Position here determines the positional index used throughout devices.php. +const DEVICE_COLUMN_FIELDS = [ + "devName", // 0 Device_TableHead_Name + "devOwner", // 1 Device_TableHead_Owner + "devType", // 2 Device_TableHead_Type + "devIcon", // 3 Device_TableHead_Icon + "devFavorite", // 4 Device_TableHead_Favorite + "devGroup", // 5 Device_TableHead_Group + "devFirstConnection", // 6 Device_TableHead_FirstSession + "devLastConnection", // 7 Device_TableHead_LastSession + "devLastIP", // 8 Device_TableHead_LastIP + "devIsRandomMac", // 9 Device_TableHead_MAC (random MAC flag column) + "devStatus", // 10 Device_TableHead_Status + "devMac", // 11 Device_TableHead_MAC_full + "devIpLong", // 12 Device_TableHead_LastIPOrder + "rowid", // 13 Device_TableHead_Rowid + "devParentMAC", // 14 Device_TableHead_Parent_MAC + "devParentChildrenCount",// 15 Device_TableHead_Connected_Devices + "devLocation", // 16 Device_TableHead_Location + "devVendor", // 17 Device_TableHead_Vendor + "devParentPort", // 18 Device_TableHead_Port + "devGUID", // 19 Device_TableHead_GUID + "devSyncHubNode", // 20 Device_TableHead_SyncHubNodeName + "devSite", // 21 Device_TableHead_NetworkSite + "devSSID", // 22 Device_TableHead_SSID + "devSourcePlugin", // 23 Device_TableHead_SourcePlugin + "devPresentLastScan", // 24 Device_TableHead_PresentLastScan + "devAlertDown", // 25 Device_TableHead_AlertDown + "devCustomProps", // 26 Device_TableHead_CustomProps + "devFQDN", // 27 Device_TableHead_FQDN + "devParentRelType", // 28 Device_TableHead_ParentRelType + "devReqNicsOnline", // 29 Device_TableHead_ReqNicsOnline + "devVlan", // 30 Device_TableHead_Vlan + "devPrimaryIPv4", // 31 Device_TableHead_IPv4 + "devPrimaryIPv6", // 32 Device_TableHead_IPv6 + "devFlapping", // 33 Device_TableHead_Flapping +]; + +// Named index constants — eliminates all mapIndx(N) magic numbers. +// Access as COL.devFlapping, COL.devMac, etc. +const COL = Object.fromEntries(DEVICE_COLUMN_FIELDS.map((name, i) => [name, i])); + +// Fields whose GraphQL response value should default to 0 instead of "". +const NUMERIC_DEFAULTS = new Set([ + "devParentChildrenCount", + "devReqNicsOnline", + "devFlapping", +]); + +// Fields fetched from GraphQL for internal logic only — not display columns. +// These are merged with DEVICE_COLUMN_FIELDS to build the GraphQL query. +const GRAPHQL_EXTRA_FIELDS = [ + "devComments", + "devStaticIP", + "devScan", + "devLogEvents", + "devAlertEvents", + "devSkipRepeated", + "devLastNotification", + "devIsNew", + "devIsArchived", +]; + +// Maps Device_TableHead_* language keys to their GraphQL/DB field names. +// Used by getColumnNameFromLangString() in ui_components.js and by +// column filter logic in devices.php. +// +// NOTE: Device_TableHead_MAC maps to devMac (display), while position 9 in +// DEVICE_COLUMN_FIELDS uses devIsRandomMac (the random-MAC flag column). +// These are intentionally different; do not collapse them. +const COLUMN_NAME_MAP = { + "Device_TableHead_Name": "devName", + "Device_TableHead_Owner": "devOwner", + "Device_TableHead_Type": "devType", + "Device_TableHead_Icon": "devIcon", + "Device_TableHead_Favorite": "devFavorite", + "Device_TableHead_Group": "devGroup", + "Device_TableHead_FirstSession": "devFirstConnection", + "Device_TableHead_LastSession": "devLastConnection", + "Device_TableHead_LastIP": "devLastIP", + "Device_TableHead_MAC": "devMac", + "Device_TableHead_Status": "devStatus", + "Device_TableHead_MAC_full": "devMac", + "Device_TableHead_LastIPOrder": "devIpLong", + "Device_TableHead_Rowid": "rowid", + "Device_TableHead_Parent_MAC": "devParentMAC", + "Device_TableHead_Connected_Devices": "devParentChildrenCount", + "Device_TableHead_Location": "devLocation", + "Device_TableHead_Vendor": "devVendor", + "Device_TableHead_Port": "devParentPort", + "Device_TableHead_GUID": "devGUID", + "Device_TableHead_SyncHubNodeName": "devSyncHubNode", + "Device_TableHead_NetworkSite": "devSite", + "Device_TableHead_SSID": "devSSID", + "Device_TableHead_SourcePlugin": "devSourcePlugin", + "Device_TableHead_PresentLastScan": "devPresentLastScan", + "Device_TableHead_AlertDown": "devAlertDown", + "Device_TableHead_CustomProps": "devCustomProps", + "Device_TableHead_FQDN": "devFQDN", + "Device_TableHead_ParentRelType": "devParentRelType", + "Device_TableHead_ReqNicsOnline": "devReqNicsOnline", + "Device_TableHead_Vlan": "devVlan", + "Device_TableHead_IPv4": "devPrimaryIPv4", + "Device_TableHead_IPv6": "devPrimaryIPv6", + "Device_TableHead_Flapping": "devFlapping", +}; + +console.log("init device-columns.js"); diff --git a/front/js/ui_components.js b/front/js/ui_components.js index 217d624c0..51425a9c1 100755 --- a/front/js/ui_components.js +++ b/front/js/ui_components.js @@ -730,46 +730,10 @@ function showIconSelection(setKey) { // ----------------------------------------------------------------------------- -// Get the correct db column code name based on table header title string +// Get the correct db column code name based on table header title string. +// COLUMN_NAME_MAP is defined in device-columns.js, loaded before this file. function getColumnNameFromLangString(headStringKey) { - columnNameMap = { - "Device_TableHead_Name": "devName", - "Device_TableHead_Owner": "devOwner", - "Device_TableHead_Type": "devType", - "Device_TableHead_Icon": "devIcon", - "Device_TableHead_Favorite": "devFavorite", - "Device_TableHead_Group": "devGroup", - "Device_TableHead_FirstSession": "devFirstConnection", - "Device_TableHead_LastSession": "devLastConnection", - "Device_TableHead_LastIP": "devLastIP", - "Device_TableHead_MAC": "devMac", - "Device_TableHead_Status": "devStatus", - "Device_TableHead_MAC_full": "devMac", - "Device_TableHead_LastIPOrder": "devIpLong", - "Device_TableHead_Rowid": "rowid", - "Device_TableHead_Parent_MAC": "devParentMAC", - "Device_TableHead_Connected_Devices": "devParentChildrenCount", - "Device_TableHead_Location": "devLocation", - "Device_TableHead_Vendor": "devVendor", - "Device_TableHead_Port": "devParentPort", - "Device_TableHead_GUID": "devGUID", - "Device_TableHead_SyncHubNodeName": "devSyncHubNode", - "Device_TableHead_NetworkSite": "devSite", - "Device_TableHead_SSID": "devSSID", - "Device_TableHead_SourcePlugin": "devSourcePlugin", - "Device_TableHead_PresentLastScan": "devPresentLastScan", - "Device_TableHead_AlertDown": "devAlertDown", - "Device_TableHead_CustomProps": "devCustomProps", - "Device_TableHead_FQDN": "devFQDN", - "Device_TableHead_ParentRelType": "devParentRelType", - "Device_TableHead_ReqNicsOnline": "devReqNicsOnline", - "Device_TableHead_Vlan": "devVlan", - "Device_TableHead_IPv4": "devPrimaryIPv4", - "Device_TableHead_IPv6": "devPrimaryIPv6", - "Device_TableHead_Flapping": "devFlapping" - }; - - return columnNameMap[headStringKey] || ""; + return COLUMN_NAME_MAP[headStringKey] || ""; } //-------------------------------------------------------------- diff --git a/front/php/templates/footer.php b/front/php/templates/footer.php index 02caeaff5..88c020b4a 100755 --- a/front/php/templates/footer.php +++ b/front/php/templates/footer.php @@ -55,6 +55,7 @@ + From d9c263d5062d3076d31d5e09c3d8d6c08870f911 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:13:22 +0000 Subject: [PATCH 029/122] feat: Update cache handling to normalize legacy device data format and improve logging --- front/js/cache.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/front/js/cache.js b/front/js/cache.js index 84ce9b6ef..fabc8c563 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -109,7 +109,7 @@ function parseDeviceCache(cachedStr) { // If result is an object with a .data property, extract it (handles legacy format) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.data)) { - console.warn('[parseDeviceCache] Extracting .data property from wrapper object'); + console.debug('[parseDeviceCache] Extracting .data property from wrapper object'); parsed = parsed.data; } @@ -505,6 +505,18 @@ function cacheDevices() return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheDevices')) === "true") { + // One-time migration: normalize legacy { data: [...] } wrapper to a plain array. + // Old cache entries from prior versions stored the raw API envelope; re-write + // them in the flat format so parseDeviceCache never needs the fallback branch. + const raw = getCache(CACHE_KEYS.DEVICES_ALL); + if (raw) { + try { + const p = JSON.parse(raw); + if (p && typeof p === 'object' && !Array.isArray(p) && Array.isArray(p.data)) { + setCache(CACHE_KEYS.DEVICES_ALL, JSON.stringify(p.data)); + } + } catch (e) { /* ignore malformed cache – will be refreshed next init */ } + } resolve(); return; } From 0e18e34918990e0bc9551bf564b788cfbd011add Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 28 Feb 2026 11:23:56 +1100 Subject: [PATCH 030/122] DOCS: flapping/unstable status addition Signed-off-by: jokob-sk --- docs/DEVICE_DISPLAY_SETTINGS.md | 15 ++++++++------- .../device_management_status_down.png | Bin 0 -> 1874 bytes .../device_management_status_flapping_online.png | Bin 0 -> 1908 bytes .../device_management_status_new_offline.png | Bin 0 -> 1589 bytes .../device_management_status_new_online.png | Bin 0 -> 1646 bytes .../device_management_status_offline.png | Bin 0 -> 1848 bytes .../device_management_status_online.png | Bin 0 -> 1738 bytes 7 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_down.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_new_offline.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_new_online.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_offline.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_online.png diff --git a/docs/DEVICE_DISPLAY_SETTINGS.md b/docs/DEVICE_DISPLAY_SETTINGS.md index e4ae9ba68..0a2467b88 100755 --- a/docs/DEVICE_DISPLAY_SETTINGS.md +++ b/docs/DEVICE_DISPLAY_SETTINGS.md @@ -10,13 +10,14 @@ This set of settings allows you to group Devices under different views. The Arch ![Sattus colors](./img/DEVICE_MANAGEMENT/device_management_status_colors.png) -1. 🔌 Online (Green) = A device that is no longer marked as a "New Device". -2. 🔌 New (Green) = A newly discovered device that is online and is still marked as a "New Device". -3. 🔌(❕) Online (Orange) - The device is unstable and flapping (3 status changes in the last hour) -4. ✖ New (Grey) = Same as No.2 but device is now offline. -5. ✖ Offline (Grey) = A device that was not detected online in the last scan. -6. ⚠ Down (Red) = A device that has "Alert Down" marked and has been offline for the time set in the Setting `NTFPRCS_alert_down_time`. - +| Icon | Status | Image | Description | +|-----------|------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| 🔌 | Online (Green) | ![Status color - online](./img/DEVICE_MANAGEMENT/device_management_status_online.png) | A device that is no longer marked as a "New Device". | +| 🔌 | New (Green) | ![Status color - new online](./img/DEVICE_MANAGEMENT/device_management_status_new_online.png) | A newly discovered device that is online and is still marked as a "New Device". | +| 🔌 (❕) | Online (Orange) | ![Status color - flapping online](./img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png) | The device is online, but unstable and flapping (3 status changes in the last hour). | +| ✖ | New (Grey) | ![Status color - new offline](./img/DEVICE_MANAGEMENT/device_management_status_new_offline.png) | Same as "New (Green)" but the device is now offline. | +| ✖ | Offline (Grey) | ![Status color - offline](./img/DEVICE_MANAGEMENT/device_management_status_offline.png) | A device that was not detected online in the last scan. | +| ⚠ | Down (Red) | ![Status color - down](./img/DEVICE_MANAGEMENT/device_management_status_down.png) | A device marked as "Alert Down" and offline for the duration set in `NTFPRCS_alert_down_time`.| See also [Notification guide](./NOTIFICATIONS.md). \ No newline at end of file diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_down.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_down.png new file mode 100644 index 0000000000000000000000000000000000000000..6a2d304ba8b306287357042299a1510e9d788877 GIT binary patch literal 1874 zcmV-Y2d(&tP)2RT#(r=U&)lWtRnHU3A%?1>KgHAyJG%Q3xp$ zOG>n`CZ=pO)0uKg8fr5A(1huSnzCtPCTp5BsT}jA7cH`cY*ME}0#V+CO&3rWc6Hrd z_MSfM+UT-(_g-Ieb?)!mea<=0eeQpr`*%L8x)p3R?vGC+`_EcOr z$S4O8$=8k5PSA(+WuVUc>5vNCHH|PFQGzql3qyz)B!M$2biFrFXAvE~NsH7KK!74T zY@p77ts(SEfV+?m3%{AFxcZ^ZwKL}!L_YTIa7pBh8{C5?V_e??Q=H0u3k13n`eNF1vOAMUbHPCe8<9C1CdcY>)-q(u^ z&e6hLw!+}eFCAQB)>*kl&&^Xa0KkoSQ&MAia-B}-@Q&j(>ksWZWOGNz#^t2H^x=Y< z@^DDcSa@`BU25UgD~Y=Jnnt5tvD#>00ie5X`(2BUb^rh|l+AcPOPGW6wr#bIYbrh} zd$qlf16Wn@t0hau3lHG>;im)d`>3G-W{1FNEAM_J+qc3yKWs+R?Y_BKK5oIwxmyT!1N zJg{PQbI|ZK)QwA~Jl_0@6}&n%t$JD?!_%h7&3>s&pdTN#NBTK{f=o!u0H7_O`~Ldu zj06_8f%-4nkJ}(iNPKjC*@kizHk;d=AQVh;!o9%#ljD_M7xMV@a-cw?SAk>TYBhi6 zwKIS^Hc{bf{jRh70thQN%+G&iaqw{bbl}xXE>N$WbyJG8LDn2&X0Dh$OOU6FbetOm z2oSH01Cp07OC8665Zm`vpS@VU`_ZblQv_g?h0F5t(b{~Rf&i-c>{4ycM5T8&_dt=B zo19Us)d37>`(Ld-&jf&N@2V}2uiUxu+aUlzR7!So&}ifW^%6$b^i^f?!F@3LNlU7< zSL#1C-TS%L)4OLM0AOG1;T`~hUOO|(w)f~D06=M&W;EUoHw%{wtOvTx)S^^1K!M@* zUoV|%9hPrw026yKC4rJl&U2!XPM-enT!uVvH3fb@iH50s+caKcpIYQ z01BKrbp~k)wbwZ0Xz(?64|+2LQSoVL-e)lb6iAtxl)?gp_E{(Z=o9b0g9S&>Kk)m{ zf^QuFKOJ~Qv4+;GtYBfT|A##1q3}@uS6QK3_*#ar98)dL)i3~or@OfYCmcR;)QWsx zhvYb)gcr`90DkUm>j&Y$=+#PiA~AThX&5YpJT6HEj)DD{Im8+;1Xh81^%LGb4KJx{ zR~W|n8_(`o`f23@J9nJ$Zx}mk>hj{y(WO)rK9a)%z);WLO+Zt3n;8J0PkO*Ry!ehO z-Z#|T-`E78<;XD?7z%kLMp*O@{@QTnEXWAhF`xvVwnKk}zH2c2bl}yimjvB!%&DGM zq90x6)tL*7)90mUj@~0;)g>zna`@bhQ7v3q@aE@FZCR5t7J$dLZ+-nX0BC<_Q+po= zN>wE_6&v1|yR~N0cnWa#`b~{L0RVnDX63vyegs@yeY@%`T~0&*1cYFy^Os;xApk!e zc=a+vW(CXFI%&}yO&~p4<74-|y)nxu56~tvr7bGu@n@J=werr{la-O2tMAC)Z$0xx zRh_R$Tk3W{yZX=O0S{COg$q(As^A(py><2W7ys+9r?2axo6hD|;ACgd#p@o+{wBUh z5P}t~-##@7^1q1*j(pA@ut~M#lf_H(WTIwQ&ffdM+^=d+i5YW)X<(FoJJiAS0#M2g z@@`S2Oi4)+@sa21{pp`B;oWaMrx7uFKPSZg*Fi@45N8d%9PK*X)pbn*3r8q$kWr4d zhj^m~5Lr+7K6Y+p>Pbw%Q;*OUsDsFVBr)_l#1x)T;71bEK>|%#0?*Srh(@|p`Iw*g4t#TdzguCB&zfLt0;&)-)@1Tg;7ZV1Bk;6nVHCN zlj?MJNdM=EL()qel3wDF^b&`pmpCN7#3AV=4oNR@NP3Aw(n}ow0--WQyS=1kWdHyG M07*qoM6N<$g4czMwEzGB literal 0 HcmV?d00001 diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png new file mode 100644 index 0000000000000000000000000000000000000000..e0199ac78c659e8fbca47ceeee48e48d2907c772 GIT binary patch literal 1908 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 Date: Sat, 28 Feb 2026 11:30:13 +1100 Subject: [PATCH 031/122] DOCS: flapping/unstable status addition Signed-off-by: jokob-sk --- docs/DEVICE_DISPLAY_SETTINGS.md | 14 ++++++-------- mkdocs.yml | 1 + 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/DEVICE_DISPLAY_SETTINGS.md b/docs/DEVICE_DISPLAY_SETTINGS.md index 0a2467b88..9bddf6d87 100755 --- a/docs/DEVICE_DISPLAY_SETTINGS.md +++ b/docs/DEVICE_DISPLAY_SETTINGS.md @@ -8,16 +8,14 @@ This set of settings allows you to group Devices under different views. The Arch ## Status Colors -![Sattus colors](./img/DEVICE_MANAGEMENT/device_management_status_colors.png) - | Icon | Status | Image | Description | |-----------|------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| -| 🔌 | Online (Green) | ![Status color - online](./img/DEVICE_MANAGEMENT/device_management_status_online.png) | A device that is no longer marked as a "New Device". | -| 🔌 | New (Green) | ![Status color - new online](./img/DEVICE_MANAGEMENT/device_management_status_new_online.png) | A newly discovered device that is online and is still marked as a "New Device". | -| 🔌 (❕) | Online (Orange) | ![Status color - flapping online](./img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png) | The device is online, but unstable and flapping (3 status changes in the last hour). | -| ✖ | New (Grey) | ![Status color - new offline](./img/DEVICE_MANAGEMENT/device_management_status_new_offline.png) | Same as "New (Green)" but the device is now offline. | -| ✖ | Offline (Grey) | ![Status color - offline](./img/DEVICE_MANAGEMENT/device_management_status_offline.png) | A device that was not detected online in the last scan. | -| ⚠ | Down (Red) | ![Status color - down](./img/DEVICE_MANAGEMENT/device_management_status_down.png) | A device marked as "Alert Down" and offline for the duration set in `NTFPRCS_alert_down_time`.| +| | Online (Green) | ![Status color - online](./img/DEVICE_MANAGEMENT/device_management_status_online.png) | A device that is no longer marked as a "New Device". | +| | New (Green) | ![Status color - new online](./img/DEVICE_MANAGEMENT/device_management_status_new_online.png) | A newly discovered device that is online and is still marked as a "New Device". | +| | Online (Orange) | ![Status color - flapping online](./img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png) | The device is online, but unstable and flapping (3 status changes in the last hour). | +| | New (Grey) | ![Status color - new offline](./img/DEVICE_MANAGEMENT/device_management_status_new_offline.png) | Same as "New (Green)" but the device is now offline. | +| | Offline (Grey) | ![Status color - offline](./img/DEVICE_MANAGEMENT/device_management_status_offline.png) | A device that was not detected online in the last scan. | +| | Down (Red) | ![Status color - down](./img/DEVICE_MANAGEMENT/device_management_status_down.png) | A device marked as "Alert Down" and offline for the duration set in `NTFPRCS_alert_down_time`.| See also [Notification guide](./NOTIFICATIONS.md). \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 3f7690bc0..47eecb3e9 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -221,5 +221,6 @@ plugins: # Custom CSS extra_css: - stylesheets/custom.css + - https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css favicon: /img/netalertx_docs.png From 4dc2a63ebbae3592e5a7038c814efe21daec867e Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 28 Feb 2026 11:37:11 +1100 Subject: [PATCH 032/122] DOCS: flapping/unstable status addition Signed-off-by: jokob-sk --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 47eecb3e9..72ed8402e 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -221,6 +221,6 @@ plugins: # Custom CSS extra_css: - stylesheets/custom.css - - https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css + - https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css favicon: /img/netalertx_docs.png From e57fd2e81e942c62af6f37e51bfd26b8b972623f Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:02:21 +0000 Subject: [PATCH 033/122] fix: update body text color in dark theme and remove unnecessary console log --- front/css/system-dark-patch.css | 2 +- front/js/ui_components.js | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/front/css/system-dark-patch.css b/front/css/system-dark-patch.css index 353c16792..06121b024 100755 --- a/front/css/system-dark-patch.css +++ b/front/css/system-dark-patch.css @@ -37,7 +37,7 @@ body, .bg-yellow, .callout.callout-warning, .alert-warning, .label-warning, .modal-warning .modal-body { background-color: #353c42 !important; - color: #bec5cb !important; + color: #ffffff !important; } h4 { color: #44def1; diff --git a/front/js/ui_components.js b/front/js/ui_components.js index 51425a9c1..671c9ab90 100755 --- a/front/js/ui_components.js +++ b/front/js/ui_components.js @@ -744,9 +744,6 @@ function getStatusBadgeParts(devPresentLastScan, devAlertDown, devFlapping, devM let status = 'unknown'; let cssText = ''; - console.log(devFlapping); - - if (devPresentLastScan == 1 && devFlapping == 0) { css = 'bg-green text-white statusOnline'; cssText = 'text-green'; From 814ba02d1c619dba711be583798d53296ad21224 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:51:12 +0000 Subject: [PATCH 034/122] feat: implement languages endpoint and refactor language handling to use languages.json --- front/js/cache.js | 75 +--------- front/js/common.js | 27 +++- front/php/templates/language/lang.php | 39 +---- front/php/templates/language/languages.json | 25 ++++ .../templates/language/merge_translations.py | 23 ++- server/api_server/api_server_start.py | 30 ++++ server/api_server/graphql_endpoint.py | 2 +- server/api_server/languages_endpoint.py | 43 ++++++ server/api_server/openapi/schemas.py | 35 +++++ server/initialise.py | 29 +++- test/api_endpoints/test_graphq_endpoints.py | 23 +++ test/api_endpoints/test_languages_endpoint.py | 141 ++++++++++++++++++ 12 files changed, 377 insertions(+), 115 deletions(-) create mode 100644 front/php/templates/language/languages.json create mode 100644 server/api_server/languages_endpoint.py create mode 100644 test/api_endpoints/test_languages_endpoint.py diff --git a/front/js/cache.js b/front/js/cache.js index fabc8c563..8d549ef29 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -387,78 +387,15 @@ function getString(key) { } // ----------------------------------------------------------------------------- -// Get current language ISO code -// below has to match exactly the values in /front/php/templates/language/lang.php & /front/js/common.js +// Get current language ISO code. +// The UI_LANG setting value is always in the form "Name (code)", e.g. "English (en_us)". +// Extracting the code with a regex means this function never needs updating when a +// new language is added — the single source of truth is languages.json. function getLangCode() { - UI_LANG = getSetting("UI_LANG"); - let lang_code = 'en_us'; - - switch (UI_LANG) { - case 'English (en_us)': - lang_code = 'en_us'; - break; - case 'Spanish (es_es)': - lang_code = 'es_es'; - break; - case 'German (de_de)': - lang_code = 'de_de'; - break; - case 'Farsi (fa_fa)': - lang_code = 'fa_fa'; - break; - case 'French (fr_fr)': - lang_code = 'fr_fr'; - break; - case 'Norwegian (nb_no)': - lang_code = 'nb_no'; - break; - case 'Polish (pl_pl)': - lang_code = 'pl_pl'; - break; - case 'Portuguese (pt_br)': - lang_code = 'pt_br'; - break; - case 'Portuguese (pt_pt)': - lang_code = 'pt_pt'; - break; - case 'Turkish (tr_tr)': - lang_code = 'tr_tr'; - break; - case 'Swedish (sv_sv)': - lang_code = 'sv_sv'; - break; - case 'Italian (it_it)': - lang_code = 'it_it'; - break; - case 'Japanese (ja_jp)': - lang_code = 'ja_jp'; - break; - case 'Russian (ru_ru)': - lang_code = 'ru_ru'; - break; - case 'Chinese (zh_cn)': - lang_code = 'zh_cn'; - break; - case 'Czech (cs_cz)': - lang_code = 'cs_cz'; - break; - case 'Arabic (ar_ar)': - lang_code = 'ar_ar'; - break; - case 'Catalan (ca_ca)': - lang_code = 'ca_ca'; - break; - case 'Ukrainian (uk_uk)': - lang_code = 'uk_ua'; - break; - case 'Vietnamese (vi_vn)': - lang_code = 'vi_vn'; - break; - } - - return lang_code; + const match = (UI_LANG || '').match(/\(([a-z]{2}_[a-z]{2})\)\s*$/i); + return match ? match[1].toLowerCase() : 'en_us'; } // ----------------------------------------------------------------------------- diff --git a/front/js/common.js b/front/js/common.js index 205d49865..c72afb54a 100755 --- a/front/js/common.js +++ b/front/js/common.js @@ -12,11 +12,9 @@ var timerRefreshData = '' var emptyArr = ['undefined', "", undefined, null, 'null']; var UI_LANG = "English (en_us)"; -const allLanguages = ["ar_ar","ca_ca","cs_cz","de_de", - "en_us","es_es","fa_fa","fr_fr", - "it_it","ja_jp","nb_no","pl_pl", - "pt_br","pt_pt","ru_ru","sv_sv", - "tr_tr","uk_ua","vi_vn","zh_cn"]; // needs to be same as in lang.php +// allLanguages is populated at init via fetchAllLanguages() from GET /languages. +// Do not hardcode this list — add new languages to languages.json instead. +let allLanguages = []; var settingsJSON = {} // NAX_CACHE_VERSION and CACHE_KEYS moved to cache.js @@ -24,6 +22,25 @@ var settingsJSON = {} // getCache, setCache, fetchJson, getAuthContext moved to cache.js +// ----------------------------------------------------------------------------- +// Fetch the canonical language list from GET /languages and populate allLanguages. +// Must be called after the API token is available (e.g. alongside cacheStrings). +// ----------------------------------------------------------------------------- +function fetchAllLanguages(apiToken) { + return fetch('/languages', { + headers: { 'Authorization': 'Bearer ' + apiToken } + }) + .then(function(resp) { return resp.json(); }) + .then(function(data) { + if (data && data.success && Array.isArray(data.languages)) { + allLanguages = data.languages.map(function(l) { return l.code; }); + } + }) + .catch(function(err) { + console.warn('[fetchAllLanguages] Failed to load language list:', err); + }); +} + // ----------------------------------------------------------------------------- function setCookie (cookie, value, expirationMinutes='') { diff --git a/front/php/templates/language/lang.php b/front/php/templates/language/lang.php index 6107135f7..d6b59b278 100755 --- a/front/php/templates/language/lang.php +++ b/front/php/templates/language/lang.php @@ -5,43 +5,20 @@ // ################################### $defaultLang = "en_us"; -$allLanguages = [ "ar_ar", "ca_ca", "cs_cz", "de_de", - "en_us", "es_es", "fa_fa", "fr_fr", - "it_it", "ja_jp", "nb_no", "pl_pl", - "pt_br", "pt_pt", "ru_ru", "sv_sv", - "tr_tr", "uk_ua", "vi_vn", "zh_cn"]; +// Load the canonical language list from languages.json — do not hardcode here. +$_langJsonPath = dirname(__FILE__) . '/languages.json'; +$_langJson = json_decode(file_get_contents($_langJsonPath), true); +$allLanguages = array_column($_langJson['languages'], 'code'); global $db; $result = $db->querySingle("SELECT setValue FROM Settings WHERE setKey = 'UI_LANG'"); -// below has to match exactly the values in /front/php/templates/language/lang.php & /front/js/common.js -switch($result){ - case 'Arabic (ar_ar)': $pia_lang_selected = 'ar_ar'; break; - case 'Catalan (ca_ca)': $pia_lang_selected = 'ca_ca'; break; - case 'Czech (cs_cz)': $pia_lang_selected = 'cs_cz'; break; - case 'German (de_de)': $pia_lang_selected = 'de_de'; break; - case 'English (en_us)': $pia_lang_selected = 'en_us'; break; - case 'Spanish (es_es)': $pia_lang_selected = 'es_es'; break; - case 'Farsi (fa_fa)': $pia_lang_selected = 'fa_fa'; break; - case 'French (fr_fr)': $pia_lang_selected = 'fr_fr'; break; - case 'Italian (it_it)': $pia_lang_selected = 'it_it'; break; - case 'Japanese (ja_jp)': $pia_lang_selected = 'ja_jp'; break; - case 'Norwegian (nb_no)': $pia_lang_selected = 'nb_no'; break; - case 'Polish (pl_pl)': $pia_lang_selected = 'pl_pl'; break; - case 'Portuguese (pt_br)': $pia_lang_selected = 'pt_br'; break; - case 'Portuguese (pt_pt)': $pia_lang_selected = 'pt_pt'; break; - case 'Russian (ru_ru)': $pia_lang_selected = 'ru_ru'; break; - case 'Swedish (sv_sv)': $pia_lang_selected = 'sv_sv'; break; - case 'Turkish (tr_tr)': $pia_lang_selected = 'tr_tr'; break; - case 'Ukrainian (uk_ua)': $pia_lang_selected = 'uk_ua'; break; - case 'Vietnamese (vi_vn)': $pia_lang_selected = 'vi_vn'; break; - case 'Chinese (zh_cn)': $pia_lang_selected = 'zh_cn'; break; - default: $pia_lang_selected = 'en_us'; break; -} - -if (isset($pia_lang_selected) == FALSE or (strlen($pia_lang_selected) == 0)) {$pia_lang_selected = $defaultLang;} +// Extract the language code from the display value, e.g. "English (en_us)" => "en_us". +// This regex means lang.php never needs updating when a new language is added. +preg_match('/\(([a-z]{2}_[a-z]{2})\)\s*$/i', (string) $result, $_langMatch); +$pia_lang_selected = isset($_langMatch[1]) ? strtolower($_langMatch[1]) : $defaultLang; $result = $db->query("SELECT * FROM Plugins_Language_Strings"); $strings = array(); diff --git a/front/php/templates/language/languages.json b/front/php/templates/language/languages.json new file mode 100644 index 000000000..ba018a015 --- /dev/null +++ b/front/php/templates/language/languages.json @@ -0,0 +1,25 @@ +{ + "default": "en_us", + "languages": [ + { "code": "ar_ar", "display": "Arabic (ar_ar)" }, + { "code": "ca_ca", "display": "Catalan (ca_ca)" }, + { "code": "cs_cz", "display": "Czech (cs_cz)" }, + { "code": "de_de", "display": "German (de_de)" }, + { "code": "en_us", "display": "English (en_us)" }, + { "code": "es_es", "display": "Spanish (es_es)" }, + { "code": "fa_fa", "display": "Farsi (fa_fa)" }, + { "code": "fr_fr", "display": "French (fr_fr)" }, + { "code": "it_it", "display": "Italian (it_it)" }, + { "code": "ja_jp", "display": "Japanese (ja_jp)" }, + { "code": "nb_no", "display": "Norwegian (nb_no)" }, + { "code": "pl_pl", "display": "Polish (pl_pl)" }, + { "code": "pt_br", "display": "Portuguese (pt_br)" }, + { "code": "pt_pt", "display": "Portuguese (pt_pt)" }, + { "code": "ru_ru", "display": "Russian (ru_ru)" }, + { "code": "sv_sv", "display": "Swedish (sv_sv)" }, + { "code": "tr_tr", "display": "Turkish (tr_tr)" }, + { "code": "uk_ua", "display": "Ukrainian (uk_ua)" }, + { "code": "vi_vn", "display": "Vietnamese (vi_vn)" }, + { "code": "zh_cn", "display": "Chinese (zh_cn)" } + ] +} diff --git a/front/php/templates/language/merge_translations.py b/front/php/templates/language/merge_translations.py index 16907d07f..bc5fdc2e3 100755 --- a/front/php/templates/language/merge_translations.py +++ b/front/php/templates/language/merge_translations.py @@ -31,13 +31,22 @@ def merge_translations(main_file, other_files): f.truncate() +def load_language_codes(languages_json_path): + """Read language codes from languages.json, guaranteeing en_us is first.""" + with open(languages_json_path, "r", encoding="utf-8") as f: + data = json.load(f) + codes = [entry["code"] for entry in data["languages"]] + # Ensure en_us (the master) is always first + if "en_us" in codes: + codes.remove("en_us") + codes.insert(0, "en_us") + return codes + + if __name__ == "__main__": current_path = os.path.dirname(os.path.abspath(__file__)) - # language codes can be found here: http://www.lingoes.net/en/translator/langcode.htm - # ⚠ "en_us.json" has to be first! - json_files = ["en_us.json", "ar_ar.json", "ca_ca.json", "cs_cz.json", "de_de.json", - "es_es.json", "fa_fa.json", "fr_fr.json", "it_it.json", "ja_jp.json", - "nb_no.json", "pl_pl.json", "pt_br.json", "pt_pt.json", "ru_ru.json", - "sv_sv.json", "tr_tr.json", "vi_vn.json", "uk_ua.json", "zh_cn.json"] - file_paths = [os.path.join(current_path, file) for file in json_files] + # language codes are loaded from languages.json — add a new language there + languages_json = os.path.join(current_path, "languages.json") + codes = load_language_codes(languages_json) + file_paths = [os.path.join(current_path, f"{code}.json") for code in codes] merge_translations(file_paths[0], file_paths[1:]) diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 8a0511b1e..c50d7a35c 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -42,6 +42,7 @@ from .sync_endpoint import handle_sync_post, handle_sync_get # noqa: E402 [flake8 lint suppression] from .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression] from .health_endpoint import get_health_status # noqa: E402 [flake8 lint suppression] +from .languages_endpoint import get_languages # noqa: E402 [flake8 lint suppression] from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression] from models.event_instance import EventInstance # noqa: E402 [flake8 lint suppression] @@ -95,6 +96,7 @@ DbQueryUpdateRequest, DbQueryDeleteRequest, AddToQueueRequest, GetSettingResponse, RecentEventsRequest, SetDeviceAliasRequest, + LanguagesResponse, ) from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression] @@ -1962,6 +1964,34 @@ def check_health(payload=None): }), 500 +@app.route("/languages", methods=["GET"]) +@validate_request( + operation_id="get_languages", + summary="Get Supported Languages", + description="Returns the canonical list of supported UI languages loaded from languages.json.", + response_model=LanguagesResponse, + tags=["system", "languages"], + auth_callable=is_authorized +) +def list_languages(payload=None): + """Return the canonical language registry.""" + try: + data = get_languages() + return jsonify({"success": True, **data}), 200 + except FileNotFoundError: + return jsonify({ + "success": False, + "error": "languages.json not found", + "message": "Language registry file is missing" + }), 500 + except ValueError as e: + return jsonify({ + "success": False, + "error": str(e), + "message": "Language registry file is malformed" + }), 500 + + # -------------------------- # Background Server Start # -------------------------- diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 074d601e8..000f43a96 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -545,7 +545,7 @@ def resolve_langStrings(self, info, langCode=None, langStringKey=None, fallback_ language_folder = '/app/front/php/templates/language/' if os.path.exists(language_folder): for filename in os.listdir(language_folder): - if filename.endswith('.json'): + if filename.endswith('.json') and filename != 'languages.json': file_lang_code = filename.replace('.json', '') # Filter by langCode if provided diff --git a/server/api_server/languages_endpoint.py b/server/api_server/languages_endpoint.py new file mode 100644 index 000000000..5babcdb14 --- /dev/null +++ b/server/api_server/languages_endpoint.py @@ -0,0 +1,43 @@ +"""Languages endpoint — returns the canonical language registry from languages.json.""" + +import json +import os + +from logger import mylog + +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +LANGUAGES_JSON_PATH = os.path.join( + INSTALL_PATH, "front", "php", "templates", "language", "languages.json" +) + + +def get_languages(): + """ + Load and return the canonical language registry. + + Returns a dict with keys: + - default (str): the fallback language code + - languages (list[dict]): each entry has 'code' and 'display' + + Raises: + FileNotFoundError: if languages.json is missing + ValueError: if the JSON is malformed or missing required fields + """ + try: + with open(LANGUAGES_JSON_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + mylog("none", [f"[languages] languages.json not found at {LANGUAGES_JSON_PATH}"]) + raise + except json.JSONDecodeError as e: + mylog("none", [f"[languages] Failed to parse languages.json: {e}"]) + raise ValueError(f"Malformed languages.json: {e}") from e + + if "default" not in data or "languages" not in data: + raise ValueError("languages.json must contain 'default' and 'languages' keys") + + return { + "default": data["default"], + "languages": data["languages"], + "count": len(data["languages"]), + } diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index 251cf2256..0c3b08eaf 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -1031,6 +1031,41 @@ class GetSettingResponse(BaseResponse): value: Any = Field(None, description="The setting value") +# ============================================================================= +# LANGUAGES SCHEMAS +# ============================================================================= + + +class LanguageEntry(BaseModel): + """A single supported language entry.""" + model_config = ConfigDict(extra="allow") + + code: str = Field(..., description="ISO language code (e.g. 'en_us')") + display: str = Field(..., description="Human-readable display name (e.g. 'English (en_us)')") + + +class LanguagesResponse(BaseResponse): + """Response for GET /languages — the canonical language registry.""" + model_config = ConfigDict( + extra="allow", + json_schema_extra={ + "examples": [{ + "success": True, + "default": "en_us", + "count": 20, + "languages": [ + {"code": "en_us", "display": "English (en_us)"}, + {"code": "de_de", "display": "German (de_de)"} + ] + }] + } + ) + + default: str = Field(..., description="Default/fallback language code") + count: int = Field(..., description="Total number of supported languages") + languages: List[LanguageEntry] = Field(..., description="All supported languages") + + # ============================================================================= # GRAPHQL SCHEMAS # ============================================================================= diff --git a/server/initialise.py b/server/initialise.py index 46d0c0734..47fb8c23c 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -10,7 +10,7 @@ # Register NetAlertX libraries import conf -from const import fullConfPath, fullConfFolder, default_tz +from const import fullConfPath, fullConfFolder, default_tz, applicationPath from helper import getBuildTimeStampAndVersion, collect_lang_strings, updateSubnets, generate_random_string from utils.datetime_utils import timeNowUTC from app_state import updateState @@ -21,6 +21,31 @@ from utils.plugin_utils import get_plugins_configs, get_set_value_for_init from messaging.in_app import write_notification +# =============================================================================== +# Language helpers +# =============================================================================== + +_LANGUAGES_JSON = os.path.join( + applicationPath, "front", "php", "templates", "language", "languages.json" +) + + +def _load_language_display_names(): + """Return a JSON-serialised list of display names from languages.json. + + Falls back to a hardcoded English-only list on any error so that + the settings page is never broken by a missing/corrupt file. + """ + try: + with open(_LANGUAGES_JSON, "r", encoding="utf-8") as f: + data = json.load(f) + names = [entry["display"] for entry in data["languages"]] + return json.dumps(names) + except Exception as e: + mylog("none", [f"[languages] Failed to load languages.json, using fallback: {e}"]) + return '["English (en_us)"]' + + # =============================================================================== # Initialise user defined values # =============================================================================== @@ -401,7 +426,7 @@ def importConfigs(pm, db, all_plugins): c_d, "Language Interface", '{"dataType":"string", "elements": [{"elementType" : "select", "elementOptions" : [] ,"transformers": []}]}', - "['English (en_us)', 'Arabic (ar_ar)', 'Catalan (ca_ca)', 'Czech (cs_cz)', 'German (de_de)', 'Spanish (es_es)', 'Farsi (fa_fa)', 'French (fr_fr)', 'Italian (it_it)', 'Japanese (ja_jp)', 'Norwegian (nb_no)', 'Polish (pl_pl)', 'Portuguese (pt_br)', 'Portuguese (pt_pt)', 'Russian (ru_ru)', 'Swedish (sv_sv)', 'Turkish (tr_tr)', 'Ukrainian (uk_ua)', 'Vietnamese (vi_vn)', 'Chinese (zh_cn)']", # noqa: E501 - inline JSON + _load_language_display_names(), # derived from languages.json "UI", ) diff --git a/test/api_endpoints/test_graphq_endpoints.py b/test/api_endpoints/test_graphq_endpoints.py index d552a7dc5..15f799ace 100644 --- a/test/api_endpoints/test_graphq_endpoints.py +++ b/test/api_endpoints/test_graphq_endpoints.py @@ -169,3 +169,26 @@ def test_graphql_post_langstrings_all_languages(client, api_token): assert data["deStrings"]["count"] >= 1 # Ensure langCode matches assert all(e["langCode"] == "en_us" for e in data["enStrings"]["langStrings"]) + + +def test_graphql_langstrings_excludes_languages_json(client, api_token): + """languages.json must never appear as a language string entry (langCode='languages')""" + query = { + "query": """ + { + langStrings { + langStrings { langCode langStringKey langStringText } + count + } + } + """ + } + resp = client.post("/graphql", json=query, headers=auth_headers(api_token)) + assert resp.status_code == 200 + all_strings = resp.json.get("data", {}).get("langStrings", {}).get("langStrings", []) + # No entry should have langCode == "languages" (i.e. from languages.json) + polluted = [s for s in all_strings if s.get("langCode") == "languages"] + assert polluted == [], ( + f"languages.json leaked into langStrings as {len(polluted)} entries; " + "graphql_endpoint.py must exclude it from the directory scan" + ) diff --git a/test/api_endpoints/test_languages_endpoint.py b/test/api_endpoints/test_languages_endpoint.py new file mode 100644 index 000000000..f95be71e5 --- /dev/null +++ b/test/api_endpoints/test_languages_endpoint.py @@ -0,0 +1,141 @@ +"""Tests for GET /languages endpoint.""" + +import sys +import os +import pytest + +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value # noqa: E402 +from api_server.api_server_start import app # noqa: E402 + + +@pytest.fixture(scope="session") +def api_token(): + """Load API token from system settings.""" + return get_setting_value("API_TOKEN") + + +@pytest.fixture +def client(): + """Flask test client.""" + with app.test_client() as client: + yield client + + +def auth_headers(token): + """Helper to construct Authorization header.""" + return {"Authorization": f"Bearer {token}"} + + +# ======================================================================== +# AUTHENTICATION TESTS +# ======================================================================== + +def test_languages_unauthorized(client): + """Missing token should be forbidden.""" + resp = client.get("/languages") + assert resp.status_code == 403 + + data = resp.get_json() + assert data is not None + assert data.get("success") is False + + +def test_languages_invalid_token(client): + """Invalid bearer token should be forbidden.""" + resp = client.get("/languages", headers=auth_headers("INVALID-TOKEN")) + assert resp.status_code == 403 + + data = resp.get_json() + assert data is not None + assert data.get("success") is False + + +def test_languages_valid_token(client, api_token): + """Valid token should return 200 with success=True.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + assert resp.status_code == 200 + + data = resp.get_json() + assert data is not None + assert data.get("success") is True + + +# ======================================================================== +# RESPONSE STRUCTURE TESTS +# ======================================================================== + +def test_languages_response_structure(client, api_token): + """Response must contain required fields with correct types.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + assert resp.status_code == 200 + + data = resp.get_json() + assert data.get("success") is True + assert isinstance(data.get("default"), str) + assert isinstance(data.get("count"), int) + assert isinstance(data.get("languages"), list) + + +def test_languages_default_is_en_us(client, api_token): + """Default language must always be en_us.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + assert data["default"] == "en_us" + + +def test_languages_count_matches_list(client, api_token): + """count must equal len(languages).""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + assert data["count"] == len(data["languages"]) + + +def test_languages_entry_shape(client, api_token): + """Each language entry must have 'code' and 'display' string fields.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + + for entry in data["languages"]: + assert "code" in entry, f"Missing 'code' in {entry}" + assert "display" in entry, f"Missing 'display' in {entry}" + assert isinstance(entry["code"], str) + assert isinstance(entry["display"], str) + # code must match pattern xx_xx + assert len(entry["code"]) == 5 and entry["code"][2] == "_", \ + f"Unexpected code format: {entry['code']}" + + +def test_languages_includes_en_us(client, api_token): + """en_us must always be in the language list.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + codes = [l["code"] for l in data["languages"]] + assert "en_us" in codes + + +def test_languages_display_contains_code(client, api_token): + """Each display name must embed its code in parentheses, e.g. 'English (en_us)'.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + + for entry in data["languages"]: + assert f"({entry['code']})" in entry["display"], \ + f"Display '{entry['display']}' does not contain '({entry['code']})'" + + +def test_languages_minimum_count(client, api_token): + """Must have at least 20 languages (the original set).""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + assert data["count"] >= 20, f"Expected >=20 languages, got {data['count']}" + + +def test_languages_no_duplicate_codes(client, api_token): + """Language codes must be unique.""" + resp = client.get("/languages", headers=auth_headers(api_token)) + data = resp.get_json() + codes = [l["code"] for l in data["languages"]] + assert len(codes) == len(set(codes)), "Duplicate language codes found" From 11499a6890b1bce5aaa648018431c9c17b50bf31 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 28 Feb 2026 15:58:46 +1100 Subject: [PATCH 035/122] chore: jokob-sk->netalertx Signed-off-by: jokob-sk --- README.md | 2 +- docs/DOCKER_INSTALLATION.md | 6 +- docs/FIX_OFFLINE_DETECTION.md | 2 +- docs/FRONTEND_DEVELOPMENT.md | 20 ++-- docs/HELPER_SCRIPTS.md | 4 +- docs/HOME_ASSISTANT.md | 20 ++-- docs/HW_INSTALL.md | 2 +- docs/INITIAL_SETUP.md | 2 +- docs/INSTALLATION.md | 4 +- docs/NOTIFICATIONS.md | 18 +-- docs/PIHOLE_GUIDE.md | 6 +- docs/PLUGINS.md | 110 +++++++++--------- docs/README.md | 2 +- docs/REMOTE_NETWORKS.md | 6 +- docs/SUBNETS.md | 40 +++---- docs/VERSIONS.md | 4 +- docs/WEBHOOK_N8N.md | 8 +- docs/WORKFLOWS.md | 10 +- front/maintenance.php | 4 +- front/php/server/util.php | 2 +- front/pluginsCore.php | 2 +- front/settings.php | 4 +- .../data/config/app.conf | 2 +- install/ubuntu24/install.sh | 4 +- scripts/opnsense_leases/README.md | 6 +- server/initialise.py | 2 +- 26 files changed, 146 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 38e48d1b4..ba0777a24 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Docker Size](https://img.shields.io/docker/image-size/jokobsk/netalertx?label=Size&logo=Docker&color=0aa8d2&logoColor=fff&style=for-the-badge)](https://hub.docker.com/r/jokobsk/netalertx) [![Docker Pulls](https://img.shields.io/docker/pulls/jokobsk/netalertx?label=Pulls&logo=docker&color=0aa8d2&logoColor=fff&style=for-the-badge)](https://hub.docker.com/r/jokobsk/netalertx) -[![GitHub Release](https://img.shields.io/github/v/release/jokob-sk/NetAlertX?color=0aa8d2&logoColor=fff&logo=GitHub&style=for-the-badge)](https://github.com/jokob-sk/NetAlertX/releases) +[![GitHub Release](https://img.shields.io/github/v/release/netalertx/NetAlertX?color=0aa8d2&logoColor=fff&logo=GitHub&style=for-the-badge)](https://github.com/netalertx/NetAlertX/releases) [![Discord](https://img.shields.io/discord/1274490466481602755?color=0aa8d2&logoColor=fff&logo=Discord&style=for-the-badge)](https://discord.gg/NczTUTWyRr) [![Home Assistant](https://img.shields.io/badge/Repo-blue?logo=home-assistant&style=for-the-badge&color=0aa8d2&logoColor=fff&label=Add)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Falexbelgium%2Fhassio-addons) diff --git a/docs/DOCKER_INSTALLATION.md b/docs/DOCKER_INSTALLATION.md index f57bd8d6b..1c03e9985 100644 --- a/docs/DOCKER_INSTALLATION.md +++ b/docs/DOCKER_INSTALLATION.md @@ -1,6 +1,6 @@ [![Docker Size](https://img.shields.io/docker/image-size/jokobsk/netalertx?label=Size&logo=Docker&color=0aa8d2&logoColor=fff&style=for-the-badge)](https://hub.docker.com/r/jokobsk/netalertx) [![Docker Pulls](https://img.shields.io/docker/pulls/jokobsk/netalertx?label=Pulls&logo=docker&color=0aa8d2&logoColor=fff&style=for-the-badge)](https://hub.docker.com/r/jokobsk/netalertx) -[![GitHub Release](https://img.shields.io/github/v/release/jokob-sk/NetAlertX?color=0aa8d2&logoColor=fff&logo=GitHub&style=for-the-badge)](https://github.com/jokob-sk/NetAlertX/releases) +[![GitHub Release](https://img.shields.io/github/v/release/jokob-sk/NetAlertX?color=0aa8d2&logoColor=fff&logo=GitHub&style=for-the-badge)](https://github.com/netalertx/NetAlertX/releases) [![Discord](https://img.shields.io/discord/1274490466481602755?color=0aa8d2&logoColor=fff&logo=Discord&style=for-the-badge)](https://discord.gg/NczTUTWyRr) [![Home Assistant](https://img.shields.io/badge/Repo-blue?logo=home-assistant&style=for-the-badge&color=0aa8d2&logoColor=fff&label=Add)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Falexbelgium%2Fhassio-addons) @@ -96,7 +96,7 @@ sudo chmod -R a+rwx /local_data_dir ### Initial setup - If unavailable, the app generates a default `app.conf` and `app.db` file on the first run. -- The preferred way is to manage the configuration via the Settings section in the UI, if UI is inaccessible you can modify [app.conf](https://github.com/jokob-sk/NetAlertX/tree/main/back) in the `/data/config/` folder directly +- The preferred way is to manage the configuration via the Settings section in the UI, if UI is inaccessible you can modify [app.conf](https://github.com/netalertx/NetAlertX/tree/main/back) in the `/data/config/` folder directly #### Setting up scanners @@ -116,7 +116,7 @@ You can read or watch several [community configuration guides](https://docs.neta #### Common issues -- Before creating a new issue, please check if a similar issue was [already resolved](https://github.com/jokob-sk/NetAlertX/issues?q=is%3Aissue+is%3Aclosed). +- Before creating a new issue, please check if a similar issue was [already resolved](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed). - Check also common issues and [debugging tips](https://docs.netalertx.com/DEBUG_TIPS). ## 💙 Support me diff --git a/docs/FIX_OFFLINE_DETECTION.md b/docs/FIX_OFFLINE_DETECTION.md index 6bac1fd12..cecdead06 100755 --- a/docs/FIX_OFFLINE_DETECTION.md +++ b/docs/FIX_OFFLINE_DETECTION.md @@ -77,6 +77,6 @@ After increasing the ARP timeout and adding ICMP scanning (on select IP ranges), **Tip:** Each environment is unique. Consider fine-tuning scan settings based on your network size, device behavior, and desired detection accuracy. -Let us know in the [NetAlertX Discussions](https://github.com/jokob-sk/NetAlertX/discussions) if you have further feedback or edge cases. +Let us know in the [NetAlertX Discussions](https://github.com/netalertx/NetAlertX/discussions) if you have further feedback or edge cases. See also [Remote Networks](./REMOTE_NETWORKS.md) for more advanced setups. \ No newline at end of file diff --git a/docs/FRONTEND_DEVELOPMENT.md b/docs/FRONTEND_DEVELOPMENT.md index d761bf84a..df536fa7c 100755 --- a/docs/FRONTEND_DEVELOPMENT.md +++ b/docs/FRONTEND_DEVELOPMENT.md @@ -1,4 +1,4 @@ -# Frontend development +# Frontend development This page contains tips for frontend development when extending NetAlertX. Guiding principles are: @@ -7,17 +7,17 @@ This page contains tips for frontend development when extending NetAlertX. Guidi 3. Reusability 4. Placing more functionality into Plugins and enhancing core Plugins functionality -That means that, when writing code, focus on reusing what's available instead of writing quick fixes. Or creating reusable functions, instead of bespoke functionaility. +That means that, when writing code, focus on reusing what's available instead of writing quick fixes. Or creating reusable functions, instead of bespoke functionaility. ## 🔍 Examples Some examples how to apply the above: > Example 1 -> +> > I want to implement a scan fucntion. Options would be: > -> 1. To add a manual scan functionality to the `deviceDetails.php` page. +> 1. To add a manual scan functionality to the `deviceDetails.php` page. > 2. To create a separate page that handles the execution of the scan. > 3. To create a configurable Plugin. > @@ -31,16 +31,16 @@ Some examples how to apply the above: > 2. Implement the changes and add settings to influence the behavior in the `initialize.py` file so the user can adjust these. > 3. Implement the changes and add settings via a setting-only plugin. > 4. Implement the changes in a way so the behavior can be toggled on each plugin so the core capabilities of Plugins get extended. -> +> > From the above, number 4 would be the most appropriate solution. Then followed by number 3. Number 1 or 2 would be approved only in special circumstances. -## 💡 Frontend tips +## 💡 Frontend tips Some useful frontend JavaScript functions: -- `getDevDataByMac(macAddress, devicesColumn)` - method to retrieve any device data (database column) based on MAC address in the frontend -- `getString(string stringKey)` - method to retrieve translated strings in the frontend -- `getSetting(string stringKey)` - method to retrieve settings in the frontend +- `getDevDataByMac(macAddress, devicesColumn)` - method to retrieve any device data (database column) based on MAC address in the frontend +- `getString(string stringKey)` - method to retrieve translated strings in the frontend +- `getSetting(string stringKey)` - method to retrieve settings in the frontend -Check the [common.js](https://github.com/jokob-sk/NetAlertX/blob/main-2023-06-10/front/js/common.js) file for more frontend functions. \ No newline at end of file +Check the [common.js](https://github.com/netalertx/NetAlertX/blob/main-2023-06-10/front/js/common.js) file for more frontend functions. \ No newline at end of file diff --git a/docs/HELPER_SCRIPTS.md b/docs/HELPER_SCRIPTS.md index fa4ea6b32..922ba13b5 100755 --- a/docs/HELPER_SCRIPTS.md +++ b/docs/HELPER_SCRIPTS.md @@ -4,7 +4,7 @@ This page provides an overview of community-contributed scripts for NetAlertX. T ## Community Scripts -You can find all scripts in this [scripts GitHub folder](https://github.com/jokob-sk/NetAlertX/tree/main/scripts). +You can find all scripts in this [scripts GitHub folder](https://github.com/netalertx/NetAlertX/tree/main/scripts). | Script Name | Description | Author | Version | Release Date | |------------|-------------|--------|---------|--------------| @@ -17,5 +17,5 @@ You can find all scripts in this [scripts GitHub folder](https://github.com/joko > [!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). +For detailed usage instructions, refer to each script's documentation in each [scripts GitHub folder](https://github.com/netalertx/NetAlertX/tree/main/scripts). diff --git a/docs/HOME_ASSISTANT.md b/docs/HOME_ASSISTANT.md index abca1ddc1..de66e3b84 100755 --- a/docs/HOME_ASSISTANT.md +++ b/docs/HOME_ASSISTANT.md @@ -5,11 +5,11 @@ NetAlertX comes with MQTT support, allowing you to show all detected devices as > [!TIP] > You can install NetAlertX also as a Home Assistant addon [![Home Assistant](https://img.shields.io/badge/Repo-blue?logo=home-assistant&style=for-the-badge&color=0aa8d2&logoColor=fff&label=Add)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Falexbelgium%2Fhassio-addons) via the [alexbelgium/hassio-addons](https://github.com/alexbelgium/hassio-addons/) repository. This is only possible if you run a supervised instance of Home Assistant. If not, you can still run NetAlertX in a separate Docker container and follow this guide to configure MQTT. -## ⚠ Note +## ⚠ Note - Please note that discovery takes about ~10s per device. -- Deleting of devices is not handled automatically. Please use [MQTT Explorer](https://mqtt-explorer.com/) to delete devices in the broker (Home Assistant), if needed. -- For optimization reasons, the devices are not always fully synchronized. You can delete Plugin objects as described in the [MQTT plugin](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_mqtt#forcing-an-update) docs to force a full synchronization. +- Deleting of devices is not handled automatically. Please use [MQTT Explorer](https://mqtt-explorer.com/) to delete devices in the broker (Home Assistant), if needed. +- For optimization reasons, the devices are not always fully synchronized. You can delete Plugin objects as described in the [MQTT plugin](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_mqtt#forcing-an-update) docs to force a full synchronization. ## 🧭 Guide @@ -34,26 +34,26 @@ NetAlertX comes with MQTT support, allowing you to show all detected devices as - Fill in remaining settings as per description - set MQTT_RUN to schedule or on_notification depending on requirements -![Configuration Example][configuration] +![Configuration Example][configuration] ## 📷 Screenshots - | ![Screen 1][sensors] | ![Screen 2][history] | - |----------------------|----------------------| - | ![Screen 3][list] | ![Screen 4][overview] | - + | ![Screen 1][sensors] | ![Screen 2][history] | + |----------------------|----------------------| + | ![Screen 3][list] | ![Screen 4][overview] | + [configuration]: ./img/HOME_ASISSTANT/HomeAssistant-Configuration.png "configuration" [sensors]: ./img/HOME_ASISSTANT/HomeAssistant-Device-as-Sensors.png "sensors" [history]: ./img/HOME_ASISSTANT/HomeAssistant-Device-Presence-History.png "history" - [list]: ./img/HOME_ASISSTANT/HomeAssistant-Devices-List.png "list" + [list]: ./img/HOME_ASISSTANT/HomeAssistant-Devices-List.png "list" [overview]: ./img/HOME_ASISSTANT/HomeAssistant-Overview-Card.png "overview" ## Troubleshooting If you can't see all devices detected, run `sudo arp-scan --interface=eth0 192.168.1.0/24` (change these based on your setup, read [Subnets](./SUBNETS.md) docs for details). This command has to be executed the NetAlertX container, not in the Home Assistant container. -You can access the NetAlertX container via Portainer on your host or via ssh. The container name will be something like `addon_db21ed7f_netalertx` (you can copy the `db21ed7f_netalertx` part from the browser when accessing the UI of NetAlertX). +You can access the NetAlertX container via Portainer on your host or via ssh. The container name will be something like `addon_db21ed7f_netalertx` (you can copy the `db21ed7f_netalertx` part from the browser when accessing the UI of NetAlertX). ## Accessing the NetAlertX container via SSH diff --git a/docs/HW_INSTALL.md b/docs/HW_INSTALL.md index e34535cf5..addd56cae 100755 --- a/docs/HW_INSTALL.md +++ b/docs/HW_INSTALL.md @@ -40,7 +40,7 @@ Some facts about what and where something will be changed/installed by the HW in - **EXPERIMENTAL** and not recommended way to install NetAlertX. > [!TIP] -> If the below fails try grabbing and installing one of the [previous releases](https://github.com/jokob-sk/NetAlertX/releases) and run the installation from the zip package. +> If the below fails try grabbing and installing one of the [previous releases](https://github.com/netalertx/NetAlertX/releases) and run the installation from the zip package. These commands will download the `install.debian12.sh` script from the GitHub repository, make it executable with `chmod`, and then run it using `./install.debian12.sh`. diff --git a/docs/INITIAL_SETUP.md b/docs/INITIAL_SETUP.md index 10349bd7d..5aa2b3ef6 100755 --- a/docs/INITIAL_SETUP.md +++ b/docs/INITIAL_SETUP.md @@ -102,7 +102,7 @@ Before opening a new issue: * 📘 [Common Issues](./COMMON_ISSUES.md) * 🧰 [Debugging Tips](./DEBUG_TIPS.md) -* ✅ [Browse resolved GitHub issues](https://github.com/jokob-sk/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) +* ✅ [Browse resolved GitHub issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) --- diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 4f5e51db2..2fdb0ef41 100755 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -17,10 +17,10 @@ If facing issues, please spend a few minutes searching. - Check [common issues](./COMMON_ISSUES.md) - Have a look at [Community guides](./COMMUNITY_GUIDES.md) -- [Search closed or open issues or discussions](https://github.com/jokob-sk/NetAlertX/issues?q=is%3Aissue) +- [Search closed or open issues or discussions](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue) - Check [Discord](https://discord.gg/NczTUTWyRr) > [!NOTE] -> If you can't find a solution anywhere, ask in Discord if you think it's a quick question, otherwise open a new [issue](https://github.com/jokob-sk/NetAlertX/issues/new?template=setup-help.yml). Please fill in as much as possible to speed up the help process. +> If you can't find a solution anywhere, ask in Discord if you think it's a quick question, otherwise open a new [issue](https://github.com/netalertx/NetAlertX/issues/new?template=setup-help.yml). Please fill in as much as possible to speed up the help process. > diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index cd067d598..60052a145 100755 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -23,18 +23,18 @@ The following device properties influence notifications. You can: 4. **Require NICs Online** - Indicates whether this device should be considered online only if all associated NICs (devices with the `nic` relationship type) are online. If disabled, the device is considered online if any NIC is online. If a NIC is online it sets the parent (this) device's status to online irrespectivelly of the detected device's status. The Relationship type is set on the childern device. > [!NOTE] -> Please read through the [NTFPRCS plugin](https://github.com/jokob-sk/NetAlertX/blob/main/front/plugins/notification_processing/README.md) documentation to understand how device and global settings influence the notification processing. +> Please read through the [NTFPRCS plugin](https://github.com/netalertx/NetAlertX/blob/main/front/plugins/notification_processing/README.md) documentation to understand how device and global settings influence the notification processing. ## Plugin settings 🔌 ![Plugin notification settings](./img/NOTIFICATIONS/Plugin-notification-settings.png) -On almost all plugins there are 2 core settings, `_WATCH` and `_REPORT_ON`. +On almost all plugins there are 2 core settings, `_WATCH` and `_REPORT_ON`. -1. `_WATCH` specifies the columns which the app should watch. If watched columns change the device state is considered changed. This changed status is then used to decide to send out notifications based on the `_REPORT_ON` setting. -2. `_REPORT_ON` let's you specify on which events the app should notify you. This is related to the `_WATCH` setting. So if you select `watched-changed` and in `_WATCH` you only select `Watched_Value1`, then a notification is triggered if `Watched_Value1` is changed from the previous value, but no notification is send if `Watched_Value2` changes. +1. `_WATCH` specifies the columns which the app should watch. If watched columns change the device state is considered changed. This changed status is then used to decide to send out notifications based on the `_REPORT_ON` setting. +2. `_REPORT_ON` let's you specify on which events the app should notify you. This is related to the `_WATCH` setting. So if you select `watched-changed` and in `_WATCH` you only select `Watched_Value1`, then a notification is triggered if `Watched_Value1` is changed from the previous value, but no notification is send if `Watched_Value2` changes. -Click the **Read more in the docs.** Link at the top of each plugin to get more details on how the given plugin works. +Click the **Read more in the docs.** Link at the top of each plugin to get more details on how the given plugin works. ## Global settings ⚙ @@ -42,10 +42,10 @@ Click the **Read more in the docs.** Link at the top of each plugin to get more In Notification Processing settings, you can specify blanket rules. These allow you to specify exceptions to the Plugin and Device settings and will override those. -1. Notify on (`NTFPRCS_INCLUDED_SECTIONS`) allows you to specify which events trigger notifications. Usual setups will have `new_devices`, `down_devices`, and possibly `down_reconnected` set. Including `plugin` (dependenton the Plugin `_WATCH` and `_REPORT_ON` settings) and `events` (dependent on the on-device **Alert Events** setting) might be too noisy for most setups. More info in the [NTFPRCS plugin](https://github.com/jokob-sk/NetAlertX/blob/main/front/plugins/notification_processing/README.md) on what events these selections include. +1. Notify on (`NTFPRCS_INCLUDED_SECTIONS`) allows you to specify which events trigger notifications. Usual setups will have `new_devices`, `down_devices`, and possibly `down_reconnected` set. Including `plugin` (dependenton the Plugin `_WATCH` and `_REPORT_ON` settings) and `events` (dependent on the on-device **Alert Events** setting) might be too noisy for most setups. More info in the [NTFPRCS plugin](https://github.com/netalertx/NetAlertX/blob/main/front/plugins/notification_processing/README.md) on what events these selections include. 2. Alert down after (`NTFPRCS_alert_down_time`) is useful if you want to wait for some time before the system sends out a down notification for a device. This is related to the on-device **Alert down** setting and only devices with this checked will trigger a down notification. -You can filter out unwanted notifications globally. This could be because of a misbehaving device (GoogleNest/GoogleHub (See also [ARPSAN docs and the `--exclude-broadcast` flag](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/arp_scan#ip-flipping-on-google-nest-devices))) which flips between IP addresses, or because you want to ignore new device notifications of a certain pattern. +You can filter out unwanted notifications globally. This could be because of a misbehaving device (GoogleNest/GoogleHub (See also [ARPSAN docs and the `--exclude-broadcast` flag](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/arp_scan#ip-flipping-on-google-nest-devices))) which flips between IP addresses, or because you want to ignore new device notifications of a certain pattern. 1. Events Filter (`NTFPRCS_event_condition`) - Filter out Events from notifications. 2. New Devices Filter (`NTFPRCS_new_dev_condition`) - Filter out New Devices from notifications, but log and keep a new device in the system. @@ -54,9 +54,9 @@ You can filter out unwanted notifications globally. This could be because of a m ![Ignoring new devices](./img/NOTIFICATIONS/NEWDEV_ignores.png) -You can completely ignore detected devices globally. This could be because your instance detects docker containers, you want to ignore devices from a specific manufacturer via MAC rules or you want to ignore devices on a specific IP range. +You can completely ignore detected devices globally. This could be because your instance detects docker containers, you want to ignore devices from a specific manufacturer via MAC rules or you want to ignore devices on a specific IP range. 1. Ignored MACs (`NEWDEV_ignored_MACs`) - List of MACs to ignore. -2. Ignored IPs (`NEWDEV_ignored_IPs`) - List of IPs to ignore. +2. Ignored IPs (`NEWDEV_ignored_IPs`) - List of IPs to ignore. diff --git a/docs/PIHOLE_GUIDE.md b/docs/PIHOLE_GUIDE.md index bb1b849c2..52d24fbbb 100755 --- a/docs/PIHOLE_GUIDE.md +++ b/docs/PIHOLE_GUIDE.md @@ -17,7 +17,7 @@ To use this approach make sure the Web UI password in **Pi-hole** is set. | `PIHOLEAPI_API_MAXCLIENTS` | Maximum number of devices to request from Pi-hole. Defaults are usually fine. | `500` | | `PIHOLEAPI_FAKE_MAC` | Generate FAKE MAC from IP. | `False` | -Check the [PiHole API plugin readme](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_api_scan/) for details and troubleshooting. +Check the [PiHole API plugin readme](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/pihole_api_scan/) for details and troubleshooting. ### docker-compose changes @@ -35,7 +35,7 @@ No changes needed | `DHCPLSS_RUN_SCHD` | If you run multiple device scanner plugins, align the schedules of all plugins to the same value. | `*/5 * * * *` | | `DHCPLSS_paths_to_check` | You need to map the value in this setting in the `docker-compose.yml` file. The in-container path must contain `pihole` so it's parsed correctly. | `['/etc/pihole/dhcp.leases']` | -Check the [DHCPLSS plugin readme](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/dhcp_leases#overview) for details +Check the [DHCPLSS plugin readme](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/dhcp_leases#overview) for details ### docker-compose changes @@ -54,7 +54,7 @@ Check the [DHCPLSS plugin readme](https://github.com/jokob-sk/NetAlertX/tree/mai | `PIHOLE_RUN_SCHD` | If you run multiple device scanner plugins, align the schedules of all plugins to the same value. | `*/5 * * * *` | | `PIHOLE_DB_PATH` | You need to map the value in this setting in the `docker-compose.yml` file. | `/etc/pihole/pihole-FTL.db` | -Check the [PiHole plugin readme](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_scan) for details +Check the [PiHole plugin readme](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/pihole_scan) for details ### docker-compose changes diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 6191e384f..78d93d741 100755 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1,18 +1,18 @@ # 🔌 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](./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](./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 > [!TIP] -> You can load additional Plugins via the General -> `LOADED_PLUGINS` setting. You need to save the settings for the new plugins to load (cache/page reload may be necessary). +> You can load additional Plugins via the General -> `LOADED_PLUGINS` setting. You need to save the settings for the new plugins to load (cache/page reload may be necessary). > ![Loaded plugins settings](./img/PLUGINS/enable_plugin.gif) 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. +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](./NETWORK_TREE.md) 4. Fine-tune [Notifications](./NOTIFICATIONS.md) 5. Setup [Workflows](./WORKFLOWS.md) @@ -40,56 +40,56 @@ NetAlertX supports additional plugins to extend its functionality, each with its ## Available Plugins - -Device-detecting plugins insert values into the `CurrentScan` database table. The plugins that are not required are safe to ignore, however, it makes sense to have at least some device-detecting plugins enabled, such as `ARPSCAN` or `NMAPDEV`. + +Device-detecting plugins insert values into the `CurrentScan` database table. The plugins that are not required are safe to ignore, however, it makes sense to have at least some device-detecting plugins enabled, such as `ARPSCAN` or `NMAPDEV`. | ID | Plugin docs | Type | Description | Features | Required | | --------------- | ------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------------------------- | -------- | -------- | -| `APPRISE` | [_publisher_apprise](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_apprise/) | ▶️ | Apprise notification proxy | | | -| `ARPSCAN` | [arp_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/arp_scan/) | 🔍 | ARP-scan on current network | | | -| `AVAHISCAN` | [avahi_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/avahi_scan/) | 🆎 | Avahi (mDNS-based) name resolution | | | -| `ASUSWRT` | [asuswrt_import](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/asuswrt_import/) | 🔍 | Import connected devices from AsusWRT | | | -| `CSVBCKP` | [csv_backup](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/csv_backup/) | ⚙ | CSV devices backup | | | -| `CUSTPROP` | [custom_props](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/custom_props/) | ⚙ | Managing custom device properties values | | Yes | -| `DBCLNP` | [db_cleanup](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/db_cleanup/) | ⚙ | Database cleanup | | Yes\* | -| `DDNS` | [ddns_update](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/ddns_update/) | ⚙ | DDNS update | | | -| `DHCPLSS` | [dhcp_leases](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/dhcp_leases/) | 🔍/📥/🆎 | Import devices from DHCP leases | | | -| `DHCPSRVS` | [dhcp_servers](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/dhcp_servers/) | ♻ | DHCP servers | | | -| `DIGSCAN` | [dig_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/dig_scan/) | 🆎 | Dig (DNS) Name resolution | | | -| `FREEBOX` | [freebox](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/freebox/) | 🔍/♻/🆎 | Pull data and names from Freebox/Iliadbox | | | -| `ICMP` | [icmp_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/icmp_scan/) | ♻ | ICMP (ping) status checker | | | -| `INTRNT` | [internet_ip](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/internet_ip/) | 🔍 | Internet IP scanner | | | -| `INTRSPD` | [internet_speedtest](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/internet_speedtest/) | ♻ | Internet speed test | | | -| `IPNEIGH` | [ipneigh](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | | -| `LUCIRPC` | [luci_import](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | | -| `MAINT` | [maintenance](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | | -| `MQTT` | [_publisher_mqtt](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | | -| `MTSCAN` | [mikrotik_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/mikrotik_scan/) | 🔍 | Mikrotik device import & sync | | | -| `NBTSCAN` | [nbtscan_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nbtscan_scan/) | 🆎 | Nbtscan (NetBIOS-based) name resolution | | | -| `NEWDEV` | [newdev_template](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/newdev_template/) | ⚙ | New device template | | Yes | -| `NMAP` | [nmap_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nmap_scan/) | ♻ | Nmap port scanning & discovery | | | -| `NMAPDEV` | [nmap_dev_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nmap_dev_scan/) | 🔍 | Nmap dev scan on current network | | | -| `NSLOOKUP` | [nslookup_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nslookup_scan/) | 🆎 | NSLookup (DNS-based) name resolution | | | -| `NTFPRCS` | [notification_processing](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/notification_processing/) | ⚙ | Notification processing | | Yes | -| `NTFY` | [_publisher_ntfy](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_ntfy/) | ▶️ | NTFY notifications | | | -| `OMDSDN` | [omada_sdn_imp](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_imp/) | 📥/🆎 ❌ | UNMAINTAINED use `OMDSDNOPENAPI` | 🖧 🔄 | | -| `OMDSDNOPENAPI` | [omada_sdn_openapi](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/omada_sdn_openapi/) | 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | | -| `PIHOLE` | [pihole_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync | | | -| `PIHOLEAPI` | [pihole_api_scan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/pihole_api_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync via API v6+ | | | -| `PUSHSAFER` | [_publisher_pushsafer](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushsafer/) | ▶️ | Pushsafer notifications | | | -| `PUSHOVER` | [_publisher_pushover](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_pushover/) | ▶️ | Pushover notifications | | | -| `SETPWD` | [set_password](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/set_password/) | ⚙ | Set password | | Yes | -| `SMTP` | [_publisher_email](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_email/) | ▶️ | Email notifications | | | -| `SNMPDSC` | [snmp_discovery](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/snmp_discovery/) | 🔍/📥 | SNMP device import & sync | | | -| `SYNC` | [sync](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/sync/) | 🔍/⚙/📥 | Sync & import from NetAlertX instances | 🖧 🔄 | Yes | -| `TELEGRAM` | [_publisher_telegram](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_telegram/) | ▶️ | Telegram notifications | | | -| `UI` | [ui_settings](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/ui_settings/) | ♻ | UI specific settings | | Yes | -| `UNFIMP` | [unifi_import](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/unifi_import/) | 🔍/📥/🆎 | UniFi device import & sync | 🖧 | | -| `UNIFIAPI` | [unifi_api_import](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/unifi_api_import/) | 🔍/📥/🆎 | UniFi device import (SM API, multi-site) | | | -| `VNDRPDT` | [vendor_update](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/vendor_update/) | ⚙ | Vendor database update | | | -| `WEBHOOK` | [_publisher_webhook](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/_publisher_webhook/) | ▶️ | Webhook notifications | | | -| `WEBMON` | [website_monitor](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/website_monitor/) | ♻ | Website down monitoring | | | -| `WOL` | [wake_on_lan](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/wake_on_lan/) | ♻ | Automatic wake-on-lan | | | +| `APPRISE` | [_publisher_apprise](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_apprise/) | ▶️ | Apprise notification proxy | | | +| `ARPSCAN` | [arp_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/arp_scan/) | 🔍 | ARP-scan on current network | | | +| `AVAHISCAN` | [avahi_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/avahi_scan/) | 🆎 | Avahi (mDNS-based) name resolution | | | +| `ASUSWRT` | [asuswrt_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/asuswrt_import/) | 🔍 | Import connected devices from AsusWRT | | | +| `CSVBCKP` | [csv_backup](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/csv_backup/) | ⚙ | CSV devices backup | | | +| `CUSTPROP` | [custom_props](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/custom_props/) | ⚙ | Managing custom device properties values | | Yes | +| `DBCLNP` | [db_cleanup](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/db_cleanup/) | ⚙ | Database cleanup | | Yes\* | +| `DDNS` | [ddns_update](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ddns_update/) | ⚙ | DDNS update | | | +| `DHCPLSS` | [dhcp_leases](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/dhcp_leases/) | 🔍/📥/🆎 | Import devices from DHCP leases | | | +| `DHCPSRVS` | [dhcp_servers](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/dhcp_servers/) | ♻ | DHCP servers | | | +| `DIGSCAN` | [dig_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/dig_scan/) | 🆎 | Dig (DNS) Name resolution | | | +| `FREEBOX` | [freebox](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/freebox/) | 🔍/♻/🆎 | Pull data and names from Freebox/Iliadbox | | | +| `ICMP` | [icmp_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/icmp_scan/) | ♻ | ICMP (ping) status checker | | | +| `INTRNT` | [internet_ip](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_ip/) | 🔍 | Internet IP scanner | | | +| `INTRSPD` | [internet_speedtest](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/internet_speedtest/) | ♻ | Internet speed test | | | +| `IPNEIGH` | [ipneigh](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ipneigh/) | 🔍 | Scan ARP (IPv4) and NDP (IPv6) tables | | | +| `LUCIRPC` | [luci_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/luci_import/) | 🔍 | Import connected devices from OpenWRT | | | +| `MAINT` | [maintenance](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/maintenance/) | ⚙ | Maintenance of logs, etc. | | | +| `MQTT` | [_publisher_mqtt](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_mqtt/) | ▶️ | MQTT for synching to Home Assistant | | | +| `MTSCAN` | [mikrotik_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/mikrotik_scan/) | 🔍 | Mikrotik device import & sync | | | +| `NBTSCAN` | [nbtscan_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/nbtscan_scan/) | 🆎 | Nbtscan (NetBIOS-based) name resolution | | | +| `NEWDEV` | [newdev_template](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/newdev_template/) | ⚙ | New device template | | Yes | +| `NMAP` | [nmap_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/nmap_scan/) | ♻ | Nmap port scanning & discovery | | | +| `NMAPDEV` | [nmap_dev_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/nmap_dev_scan/) | 🔍 | Nmap dev scan on current network | | | +| `NSLOOKUP` | [nslookup_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/nslookup_scan/) | 🆎 | NSLookup (DNS-based) name resolution | | | +| `NTFPRCS` | [notification_processing](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/notification_processing/) | ⚙ | Notification processing | | Yes | +| `NTFY` | [_publisher_ntfy](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_ntfy/) | ▶️ | NTFY notifications | | | +| `OMDSDN` | [omada_sdn_imp](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/omada_sdn_imp/) | 📥/🆎 ❌ | UNMAINTAINED use `OMDSDNOPENAPI` | 🖧 🔄 | | +| `OMDSDNOPENAPI` | [omada_sdn_openapi](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/omada_sdn_openapi/) | 📥/🆎 | OMADA TP-Link import via OpenAPI | 🖧 | | +| `PIHOLE` | [pihole_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/pihole_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync | | | +| `PIHOLEAPI` | [pihole_api_scan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/pihole_api_scan/) | 🔍/🆎/📥 | Pi-hole device import & sync via API v6+ | | | +| `PUSHSAFER` | [_publisher_pushsafer](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_pushsafer/) | ▶️ | Pushsafer notifications | | | +| `PUSHOVER` | [_publisher_pushover](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_pushover/) | ▶️ | Pushover notifications | | | +| `SETPWD` | [set_password](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/set_password/) | ⚙ | Set password | | Yes | +| `SMTP` | [_publisher_email](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_email/) | ▶️ | Email notifications | | | +| `SNMPDSC` | [snmp_discovery](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/snmp_discovery/) | 🔍/📥 | SNMP device import & sync | | | +| `SYNC` | [sync](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/sync/) | 🔍/⚙/📥 | Sync & import from NetAlertX instances | 🖧 🔄 | Yes | +| `TELEGRAM` | [_publisher_telegram](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_telegram/) | ▶️ | Telegram notifications | | | +| `UI` | [ui_settings](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/ui_settings/) | ♻ | UI specific settings | | Yes | +| `UNFIMP` | [unifi_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/unifi_import/) | 🔍/📥/🆎 | UniFi device import & sync | 🖧 | | +| `UNIFIAPI` | [unifi_api_import](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/unifi_api_import/) | 🔍/📥/🆎 | UniFi device import (SM API, multi-site) | | | +| `VNDRPDT` | [vendor_update](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/vendor_update/) | ⚙ | Vendor database update | | | +| `WEBHOOK` | [_publisher_webhook](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/_publisher_webhook/) | ▶️ | Webhook notifications | | | +| `WEBMON` | [website_monitor](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/website_monitor/) | ♻ | Website down monitoring | | | +| `WOL` | [wake_on_lan](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/wake_on_lan/) | ♻ | Automatic wake-on-lan | | | > \* The database cleanup plugin (`DBCLNP`) is not _required_ but the app will become unusable after a while if not executed. @@ -100,18 +100,18 @@ Device-detecting plugins insert values into the `CurrentScan` database table. T ## Enabling plugins -Plugins can be enabled via Settings, and can be disabled as needed. +Plugins can be enabled via Settings, and can be disabled as needed. 1. Research which plugin you'd like to use, enable `DISCOVER_PLUGINS` and load the required plugins in Settings via the `LOADED_PLUGINS` setting. -1. Save the changes and review the Settings of the newly loaded plugins. -1. Change the `_RUN` Setting to the recommended or custom value as per the documentation of the given setting +1. Save the changes and review the Settings of the newly loaded plugins. +1. Change the `_RUN` Setting to the recommended or custom value as per the documentation of the given setting - If using `schedule` on a `🔍 dev scanner` plugin, make sure the schedules are the same across all `🔍 dev scanner` plugins ### Disabling, Unloading and Ignoring plugins 1. Change the `_RUN` Setting to `disabled` if you want to disable the plugin, but keep the settings 1. If you want to speed up the application, you can unload the plugin by unselecting it in the `LOADED_PLUGINS` setting. - - Careful, once you save the Settings Unloaded plugin settings will be lost (old `app.conf` files are kept in the `/config` folder) + - Careful, once you save the Settings Unloaded plugin settings will be lost (old `app.conf` files are kept in the `/config` folder) 1. You can completely ignore plugins by placing a `ignore_plugin` file into the plugin directory. Ignored plugins won't show up in the `LOADED_PLUGINS` setting. ## 🆕 Developing new custom plugins diff --git a/docs/README.md b/docs/README.md index a9ae299b0..f87abad31 100755 --- a/docs/README.md +++ b/docs/README.md @@ -137,7 +137,7 @@ Some additional context: Before submitting a new issue please spend a couple of minutes on research: * Check [🛑 Common issues](./DEBUG_TIPS.md#common-issues) -* Check [💡 Closed issues](https://github.com/jokob-sk/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) if a similar issue was solved in the past. +* Check [💡 Closed issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) if a similar issue was solved in the past. * When submitting an issue ❗[enable debug](./DEBUG_TIPS.md)❗ ⚠ Please follow the pre-defined issue template to resolve your issue faster. diff --git a/docs/REMOTE_NETWORKS.md b/docs/REMOTE_NETWORKS.md index 4e0663442..116960874 100755 --- a/docs/REMOTE_NETWORKS.md +++ b/docs/REMOTE_NETWORKS.md @@ -43,11 +43,11 @@ You can use supplementary plugins that employ alternate methods. Protocols used ## Multiple NetAlertX Instances -If you have servers in different networks, you can set up separate NetAlertX instances on those subnets and synchronize the results into one instance using the [`SYNC` plugin](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/sync). +If you have servers in different networks, you can set up separate NetAlertX instances on those subnets and synchronize the results into one instance using the [`SYNC` plugin](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/sync). ## Manual Entry -If you don't need to discover new devices and only need to report on their status (`online`, `offline`, `down`), you can manually enter devices and check their status using the [`ICMP` plugin](https://github.com/jokob-sk/NetAlertX/blob/main/front/plugins/icmp_scan/), which uses the `ping` command internally. +If you don't need to discover new devices and only need to report on their status (`online`, `offline`, `down`), you can manually enter devices and check their status using the [`ICMP` plugin](https://github.com/netalertx/NetAlertX/blob/main/front/plugins/icmp_scan/), which uses the `ping` command internally. For more information on how to add devices manually (or dummy devices), refer to the [Device Management](./DEVICE_MANAGEMENT.md) documentation. @@ -57,4 +57,4 @@ To create truly dummy devices, you can use a loopback IP address (e.g., `0.0.0.0 Scanning remote networks with NMAP is possible (via the `NMAPDEV` plugin), but since it cannot retrieve the MAC address, you need to enable the `NMAPDEV_FAKE_MAC` setting. This will generate a fake MAC address based on the IP address, allowing you to track devices. However, this can lead to inconsistencies, especially if the IP address changes or a previously logged device is rediscovered. If this setting is disabled, only the IP address will be discovered, and devices with missing MAC addresses will be skipped. -Check the [NMAPDEV plugin](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nmap_dev_scan) for details +Check the [NMAPDEV plugin](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/nmap_dev_scan) for details diff --git a/docs/SUBNETS.md b/docs/SUBNETS.md index b8c795f2b..ac8d0305b 100755 --- a/docs/SUBNETS.md +++ b/docs/SUBNETS.md @@ -2,35 +2,35 @@ You need to specify the network interface and the network mask. You can also configure multiple subnets and specify VLANs (see VLAN exceptions below). -`ARPSCAN` can scan multiple networks if the network allows it. To scan networks directly, the subnets must be accessible from the network where NetAlertX is running. This means NetAlertX needs to have access to the interface attached to that subnet. +`ARPSCAN` can scan multiple networks if the network allows it. To scan networks directly, the subnets must be accessible from the network where NetAlertX is running. This means NetAlertX needs to have access to the interface attached to that subnet. -> [!WARNING] +> [!WARNING] > If you don't see all expected devices run the following command in the NetAlertX container (replace the interface and ip mask): > `sudo arp-scan --interface=eth0 192.168.1.0/24` -> -> If this command returns no results, the network is not accessible due to your network or firewall restrictions (Wi-Fi Extenders, VPNs and inaccessible networks). If direct scans are not possible, check the [remote networks documentation](./REMOTE_NETWORKS.md) for workarounds. +> +> If this command returns no results, the network is not accessible due to your network or firewall restrictions (Wi-Fi Extenders, VPNs and inaccessible networks). If direct scans are not possible, check the [remote networks documentation](./REMOTE_NETWORKS.md) for workarounds. ## Example Values -> [!NOTE] -> Please use the UI to configure settings as it ensures the config file is in the correct format. Edit `app.conf` directly only when really necessary. +> [!NOTE] +> Please use the UI to configure settings as it ensures the config file is in the correct format. Edit `app.conf` directly only when really necessary. > ![Settings location](./img/SUBNETS/subnets-setting-location.png) * **Examples for one and two subnets:** * One subnet: `SCAN_SUBNETS = ['192.168.1.0/24 --interface=eth0']` * Two subnets: `SCAN_SUBNETS = ['192.168.1.0/24 --interface=eth0','192.168.1.0/24 --interface=eth1 --vlan=107']` -> [!TIP] -> When adding more subnets, you may need to increase both the scan interval (`ARPSCAN_RUN_SCHD`) and the timeout (`ARPSCAN_RUN_TIMEOUT`)—as well as similar settings for related plugins. -> -> If the timeout is too short, you may see timeout errors in the log. To prevent the application from hanging due to unresponsive plugins, scans are canceled when they exceed the timeout limit. -> -> To fix this: -> - Reduce the subnet size (e.g., change `/16` to `/24`). -> - Increase the timeout (e.g., set `ARPSCAN_RUN_TIMEOUT` to `300` for a 5-minute timeout). -> - Extend the scan interval (e.g., set `ARPSCAN_RUN_SCHD` to `*/10 * * * *` to scan every 10 minutes). -> +> [!TIP] +> When adding more subnets, you may need to increase both the scan interval (`ARPSCAN_RUN_SCHD`) and the timeout (`ARPSCAN_RUN_TIMEOUT`)—as well as similar settings for related plugins. +> +> If the timeout is too short, you may see timeout errors in the log. To prevent the application from hanging due to unresponsive plugins, scans are canceled when they exceed the timeout limit. +> +> To fix this: +> - Reduce the subnet size (e.g., change `/16` to `/24`). +> - Increase the timeout (e.g., set `ARPSCAN_RUN_TIMEOUT` to `300` for a 5-minute timeout). +> - Extend the scan interval (e.g., set `ARPSCAN_RUN_SCHD` to `*/10 * * * *` to scan every 10 minutes). +> > For more troubleshooting tips, see [Debugging Plugins](./DEBUG_PLUGINS.md). --- @@ -43,7 +43,7 @@ You need to specify the network interface and the network mask. You can also con The `arp-scan` time itself depends on the number of IP addresses to check. -> The number of IPs to check depends on the [network mask](https://www.calculator.net/ip-subnet-calculator.html) you set in the `SCAN_SUBNETS` setting. +> The number of IPs to check depends on the [network mask](https://www.calculator.net/ip-subnet-calculator.html) you set in the `SCAN_SUBNETS` setting. > For example, a `/24` mask results in 256 IPs to check, whereas a `/16` mask checks around 65,536 IPs. Each IP takes a couple of seconds, so an incorrect configuration could make `arp-scan` take hours instead of seconds. Specify the network filter, which **significantly** speeds up the scan process. For example, the filter `192.168.1.0/24` covers IP ranges from `192.168.1.0` to `192.168.1.255`. @@ -56,7 +56,7 @@ The adapter will probably be `eth0` or `eth1`. (Check `System Info` > `Network H ![Network hardware](./img/SUBNETS/system_info-network_hardware.png) -> [!TIP] +> [!TIP] > As an alternative to `iwconfig`, run `ip -o link show | awk -F': ' '!/lo|vir|docker/ {print $2}'` in your container to find your interface name(s) (e.g.: `eth0`, `eth1`): > ```bash > Synology-NAS:/# ip -o link show | awk -F': ' '!/lo|vir|docker/ {print $2}' @@ -73,11 +73,11 @@ The adapter will probably be `eth0` or `eth1`. (Check `System Info` > `Network H #### VLANs on a Hyper-V Setup -> Community-sourced content by [mscreations](https://github.com/mscreations) from this [discussion](https://github.com/jokob-sk/NetAlertX/discussions/404). +> Community-sourced content by [mscreations](https://github.com/mscreations) from this [discussion](https://github.com/netalertx/NetAlertX/discussions/404). **Tested Setup:** Bare Metal → Hyper-V on Win Server 2019 → Ubuntu 22.04 VM → Docker → NetAlertX. -**Approach 1 (may cause issues):** +**Approach 1 (may cause issues):** Configure multiple network adapters in Hyper-V with distinct VLANs connected to each one using Hyper-V's network setup. However, this action can potentially lead to the Docker host's inability to handle network traffic correctly. This might interfere with other applications such as Authentik. **Approach 2 (working example):** diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index fa00bb093..8edc8186a 100755 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,6 +1,6 @@ ## Am I running the latest released version? -Since version 23.01.14 NetAlertX uses a simple timestamp-based version check to verify if a new version is available. You can check the [current and past releases here](https://github.com/jokob-sk/NetAlertX/releases), or have a look at what I'm [currently working on](https://github.com/jokob-sk/NetAlertX/issues/138). +Since version 23.01.14 NetAlertX uses a simple timestamp-based version check to verify if a new version is available. You can check the [current and past releases here](https://github.com/netalertx/NetAlertX/releases), or have a look at what I'm [currently working on](https://github.com/netalertx/NetAlertX/issues/138). If you are not on the latest version, the app will notify you, that a new released version is avialable the following way: @@ -22,4 +22,4 @@ For a comparison, this is how the UI looks like if you are on the latest stable ## Implementation details -During build a [/app/front/buildtimestamp.txt](https://github.com/jokob-sk/NetAlertX/blob/092797e75ccfa8359444ad149e727358ac4da05f/Dockerfile#L44) file is created. The app then periodically checks if a new release is available with a newer timestamp in GitHub's rest-based JSON endpoint (check the `def isNewVersion:` method for details). \ No newline at end of file +During build a [/app/front/buildtimestamp.txt](https://github.com/netalertx/NetAlertX/blob/092797e75ccfa8359444ad149e727358ac4da05f/Dockerfile#L44) file is created. The app then periodically checks if a new release is available with a newer timestamp in GitHub's rest-based JSON endpoint (check the `def isNewVersion:` method for details). \ No newline at end of file diff --git a/docs/WEBHOOK_N8N.md b/docs/WEBHOOK_N8N.md index a919c6a31..b337abe27 100755 --- a/docs/WEBHOOK_N8N.md +++ b/docs/WEBHOOK_N8N.md @@ -1,14 +1,14 @@ ### Create a simple n8n workflow > [!NOTE] -> You need to enable the `WEBHOOK` plugin first in order to follow this guide. See the [Plugins guide](./PLUGINS.md) for details. +> You need to enable the `WEBHOOK` plugin first in order to follow this guide. See the [Plugins guide](./PLUGINS.md) for details. -N8N can be used for more advanced conditional notification use cases. For example, you want only to get notified if two out of a specified list of devices is down. Or you can use other plugins to process the notifiations further. The below is a simple example of sending an email on a webhook. +N8N can be used for more advanced conditional notification use cases. For example, you want only to get notified if two out of a specified list of devices is down. Or you can use other plugins to process the notifiations further. The below is a simple example of sending an email on a webhook. ![n8n workflow](./img/WEBHOOK_N8N/n8n_workflow.png) -### Specify your email template -See [sample JSON](https://github.com/jokob-sk/NetAlertX/blob/main/front/report_templates/webhook_json_sample.json) if you want to see the JSON paths used in the email template below +### Specify your email template +See [sample JSON](https://github.com/netalertx/NetAlertX/blob/main/front/report_templates/webhook_json_sample.json) if you want to see the JSON paths used in the email template below ![Email template](./img/WEBHOOK_N8N/n8n_send_email_settings.png) ``` diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index a21996b44..03b0879d4 100755 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -8,7 +8,7 @@ Below are a few examples that demonstrate how this module can be used to simplif ## Updating Workflows -> [!NOTE] +> [!NOTE] > In order to apply a workflow change, you must first **Save** the changes and then reload the application by clicking **Restart server**. ## Workflow components @@ -25,7 +25,7 @@ Triggers define the event that activates a workflow. They monitor changes to obj #### Example Trigger: - **Object Type**: `Devices` - **Event Type**: `update` - + This trigger will activate when a `Device` object is updated. ### Conditions @@ -42,7 +42,7 @@ Conditions determine whether a workflow should proceed based on certain criteria - **Field**: `devVendor` - **Operator**: `contains` (case in-sensitive) - **Value**: `Google` - + This condition checks if the device's vendor is `Google`. The workflow will only proceed if the condition is true. ### Actions @@ -57,7 +57,7 @@ You can include multiple actions that should execute once the conditions are met - **Action Type**: `update_field` - **Field**: `devIsNew` - **Value**: `0` - + This action updates the `devIsNew` field to `0`, marking the device as no longer new. @@ -67,4 +67,4 @@ You can find a couple of configuration examples in [Workflow Examples](WORKFLOW_ > [!TIP] -> Share your workflows in [Discord](https://discord.com/invite/NczTUTWyRr) or [GitHub Discussions](https://github.com/jokob-sk/NetAlertX/discussions). +> Share your workflows in [Discord](https://discord.com/invite/NczTUTWyRr) or [GitHub Discussions](https://github.com/netalertx/NetAlertX/discussions). diff --git a/front/maintenance.php b/front/maintenance.php index b0c0f93ef..f707acf53 100755 --- a/front/maintenance.php +++ b/front/maintenance.php @@ -859,10 +859,10 @@ function renderLogs(customData) { initializeTabs(); try { - $("#lastCommit").append('GitHub last commit'); + $("#lastCommit").append('GitHub last commit'); $("#lastDockerUpdate").append( - 'Docker last pushed'); + 'Docker last pushed'); } catch (error) { console.error('Failed to load GitHub badges:', error); } diff --git a/front/php/server/util.php b/front/php/server/util.php index 96a4091c4..c9b7b38c2 100755 --- a/front/php/server/util.php +++ b/front/php/server/util.php @@ -77,7 +77,7 @@ function saveSettings() $txt = $txt."# Generated: ".$timestamp." #\n"; $txt = $txt."# #\n"; $txt = $txt."# Config file for the LAN intruder detection app: #\n"; - $txt = $txt."# https://github.com/jokob-sk/NetAlertX #\n"; + $txt = $txt."# https://github.com/netalertx/NetAlertX #\n"; $txt = $txt."# #\n"; $txt = $txt."#-----------------AUTOGENERATED FILE-----------------#\n"; diff --git a/front/pluginsCore.php b/front/pluginsCore.php index e4bb10808..325666039 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -390,7 +390,7 @@ function createTabContent(pluginObj, assignActive) {
    ${getString(`${prefix}_description`)} - ${getString('Gen_ReadDocs')} + ${getString('Gen_ReadDocs')}
    `); diff --git a/front/settings.php b/front/settings.php index 2dc298bd7..1e498a20f 100755 --- a/front/settings.php +++ b/front/settings.php @@ -384,7 +384,7 @@ function initSettingsPage(settingsData, pluginsData){
    ${getString(prefix+'_description')} - + ${getString('Gen_ReadDocs')}
    @@ -620,7 +620,7 @@ function: 'savesettings', clearCache() } else{ // something went wrong - write_notification("[Important] Please take a screenshot of the Console tab in the browser (F12) and next error. Submit it (with the nginx and php error logs) as a new issue here: https://github.com/jokob-sk/NetAlertX/issues", 'interrupt') + write_notification("[Important] Please take a screenshot of the Console tab in the browser (F12) and next error. Submit it (with the nginx and php error logs) as a new issue here: https://github.com/netalertx/NetAlertX/issues", 'interrupt') write_notification(data, 'interrupt') console.log("🔽"); diff --git a/install/production-filesystem/data/config/app.conf b/install/production-filesystem/data/config/app.conf index 62c1d4048..60b444173 100755 --- a/install/production-filesystem/data/config/app.conf +++ b/install/production-filesystem/data/config/app.conf @@ -3,7 +3,7 @@ # Generated: 2022-12-30_22-19-40 # # # # Config file for the LAN intruder detection app: # -# https://github.com/jokob-sk/NetAlertX # +# https://github.com/netalertx/NetAlertX # # # #-----------------AUTOGENERATED FILE-----------------# diff --git a/install/ubuntu24/install.sh b/install/ubuntu24/install.sh index 378f2c236..588054d6f 100755 --- a/install/ubuntu24/install.sh +++ b/install/ubuntu24/install.sh @@ -25,7 +25,7 @@ OUI_FILE="/usr/share/arp-scan/ieee-oui.txt" # Define the path to ieee-oui.txt an FILEDB=${INSTALL_DIR}/db/${DB_FILE} PHPVERSION="8.3" VENV_DIR="/opt/netalertx-python" -GITHUB_REPO="https://github.com/jokob-sk/NetAlertX" +GITHUB_REPO="https://github.com/netalertx/NetAlertX" SYSTEMD_UNIT_FILE="/etc/systemd/system/netalertx.service" SYSTEMD_UNIT_DEFAULTS="/etc/default/netalertx" ALWAYS_FRESH_INSTALL=false # Set to true to always reset /config and /db on each install @@ -141,7 +141,7 @@ if [ -d "${INSTALL_DIR}" ]; then exit 1 fi else - git clone https://github.com/jokob-sk/NetAlertX "${INSTALL_DIR}/" + git clone https://github.com/netalertx/NetAlertX "${INSTALL_DIR}/" fi diff --git a/scripts/opnsense_leases/README.md b/scripts/opnsense_leases/README.md index f6ad66d80..25ff5b553 100755 --- a/scripts/opnsense_leases/README.md +++ b/scripts/opnsense_leases/README.md @@ -54,7 +54,7 @@ You could achieve this by mounting a custom cron file to `/etc/crontabs/root`: * * * * * /opt/venv/bin/python3 -c "import paramiko" || (/opt/venv/bin/pip install paramiko >/dev/null 2>&1 && sed -i '/pip install paramiko/d' /etc/crontabs/root) ``` -Please double check the [default cron file](https://github.com/jokob-sk/NetAlertX/blob/main/install/crontab) hasn't changed. +Please double check the [default cron file](https://github.com/netalertx/NetAlertX/blob/main/install/crontab) hasn't changed. #### Using Password Authentication ```sh @@ -90,7 +90,7 @@ Example: - Version: 1.0 - Author: [im-redactd](https://github.com/im-redactd) -- Release Date: 24-Feb-2025 +- Release Date: 24-Feb-2025 > [!NOTE] -> This is a community supplied script and not maintained. \ No newline at end of file +> This is a community supplied script and not maintained. \ No newline at end of file diff --git a/server/initialise.py b/server/initialise.py index 46d0c0734..8d67ff6fa 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -680,7 +680,7 @@ def importConfigs(pm, db, all_plugins):
  • Clear app cache with the (reload) button in the header
  • \
  • Go to Settings and click Save
  • \ Check out new features and what has changed in the \ - 📓 release notes.""", + 📓 release notes.""", 'interrupt', timeNowUTC() ) From f5e411d5d520de77b734464442a68685c5a2ffac Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sat, 28 Feb 2026 16:00:37 +1100 Subject: [PATCH 036/122] chore: jokob-sk->netalertx Signed-off-by: jokob-sk --- CONTRIBUTING.md | 4 ++-- back/app.conf | 2 +- docs/API_OLD.md | 2 +- docs/BACKUPS.md | 4 ++-- docs/DEBUG_TIPS.md | 2 +- docs/DEV_ENV_SETUP.md | 2 +- front/php/templates/language/ca_ca.json | 10 +++++----- front/php/templates/language/de_de.json | 8 ++++---- front/php/templates/language/en_us.json | 10 +++++----- front/php/templates/language/es_es.json | 12 ++++++------ front/php/templates/language/fr_fr.json | 10 +++++----- front/php/templates/language/it_it.json | 10 +++++----- front/php/templates/language/ja_jp.json | 10 +++++----- front/php/templates/language/nb_no.json | 6 +++--- front/php/templates/language/pl_pl.json | 8 ++++---- front/php/templates/language/pt_br.json | 8 ++++---- front/php/templates/language/pt_pt.json | 10 +++++----- front/php/templates/language/ru_ru.json | 10 +++++----- front/php/templates/language/tr_tr.json | 4 ++-- front/php/templates/language/uk_ua.json | 10 +++++----- front/php/templates/language/zh_cn.json | 10 +++++----- front/plugins/_publisher_webhook/config.json | 4 ++-- front/plugins/newdev_template/config.json | 2 +- front/plugins/omada_sdn_imp/config.json | 2 +- front/plugins/snmp_discovery/config.json | 4 ++-- install/debian12/install.debian12.sh | 8 ++++---- 26 files changed, 86 insertions(+), 86 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b47a067b7..28d62003b 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,14 +6,14 @@ First off, **thank you** for taking the time to contribute! NetAlertX is built a ## 📂 Issues, Bugs, and Feature Requests -Please use the [GitHub Issue Tracker](https://github.com/jokob-sk/NetAlertX/issues) for: +Please use the [GitHub Issue Tracker](https://github.com/netalertx/NetAlertX/issues) for: - Bug reports 🐞 - Feature requests 💡 - Documentation feedback 📖 Before opening a new issue: - 🛑 [Check Common Issues & Debug Tips](https://docs.netalertx.com/DEBUG_TIPS#common-issues) -- 🔍 [Search Closed Issues](https://github.com/jokob-sk/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) +- 🔍 [Search Closed Issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) --- diff --git a/back/app.conf b/back/app.conf index 9c1bbfe91..95fcf24f9 100755 --- a/back/app.conf +++ b/back/app.conf @@ -3,7 +3,7 @@ # Generated: 2022-12-30_22-19-40 # # # # Config file for the LAN intruder detection app: # -# https://github.com/jokob-sk/NetAlertX # +# https://github.com/netalertx/NetAlertX # # # #-----------------AUTOGENERATED FILE-----------------# diff --git a/docs/API_OLD.md b/docs/API_OLD.md index 139cc701b..4b6434ebd 100755 --- a/docs/API_OLD.md +++ b/docs/API_OLD.md @@ -149,7 +149,7 @@ You can access the following files: | File name | Description | |----------------------|----------------------| - | `notification_json_final.json` | The json version of the last notification (e.g. used for webhooks - [sample JSON](https://github.com/jokob-sk/NetAlertX/blob/main/front/report_templates/webhook_json_sample.json)). | + | `notification_json_final.json` | The json version of the last notification (e.g. used for webhooks - [sample JSON](https://github.com/netalertx/NetAlertX/blob/main/front/report_templates/webhook_json_sample.json)). | | `table_devices.json` | All of the available Devices detected by the app. | | `table_plugins_events.json` | The list of the unprocessed (pending) notification events (plugins_events DB table). | | `table_plugins_history.json` | The list of notification events history. | diff --git a/docs/BACKUPS.md b/docs/BACKUPS.md index 79a40e0bb..dcdb928ca 100755 --- a/docs/BACKUPS.md +++ b/docs/BACKUPS.md @@ -13,7 +13,7 @@ There are four key artifacts you can use to back up your NetAlertX configuration | File | Description | Limitations | | ------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `/db/app.db` | The application database | Might be in an uncommitted state or corrupted | -| `/config/app.conf` | Configuration file | Can be overridden using the [`APP_CONF_OVERRIDE`](https://github.com/jokob-sk/NetAlertX/tree/main/dockerfiles#docker-environment-variables) variable | +| `/config/app.conf` | Configuration file | Can be overridden using the [`APP_CONF_OVERRIDE`](https://github.com/netalertx/NetAlertX/tree/main/dockerfiles#docker-environment-variables) variable | | `/config/devices.csv` | CSV file containing device data | Does not include historical data | | `/config/workflows.json` | JSON file containing your workflows | N/A | @@ -37,7 +37,7 @@ This includes settings for: ### Device Data -Stored in `/data/config/devices_.csv` or `/data/config/devices.csv`, created by the [CSV Backup `CSVBCKP` Plugin](https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/csv_backup). +Stored in `/data/config/devices_.csv` or `/data/config/devices.csv`, created by the [CSV Backup `CSVBCKP` Plugin](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/csv_backup). Contains: * Device names, icons, and categories diff --git a/docs/DEBUG_TIPS.md b/docs/DEBUG_TIPS.md index 0228ff002..cf7f27bc1 100755 --- a/docs/DEBUG_TIPS.md +++ b/docs/DEBUG_TIPS.md @@ -38,7 +38,7 @@ If possible, check if your issue got fixed in the `_dev` image before opening a > ⚠ Please backup your DB and config beforehand! -Please also search [open issues](https://github.com/jokob-sk/NetAlertX/issues). +Please also search [open issues](https://github.com/netalertx/NetAlertX/issues). ## 4. Disable restart behavior diff --git a/docs/DEV_ENV_SETUP.md b/docs/DEV_ENV_SETUP.md index 587bc6d8c..9547482d7 100755 --- a/docs/DEV_ENV_SETUP.md +++ b/docs/DEV_ENV_SETUP.md @@ -77,7 +77,7 @@ Create a folder `netalertx` in the `APP_DATA_LOCATION` (in this example in `/vol You can then modify the python script without restarting/rebuilding the container every time. Additionally, you can trigger a plugin run via the UI: -![image](https://github.com/jokob-sk/NetAlertX/assets/96159884/3cbf2748-03c8-49e7-b801-f38c7755246b) +![image](https://github.com/netalertx/NetAlertX/assets/96159884/3cbf2748-03c8-49e7-b801-f38c7755246b) ## Tips diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 2d98f51c7..8bd4b4225 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Seleccioneu el dispositiu de xarxa al qual aquest dispositiu està connectat, per poder omplir l'arbre de xarxa.", "DevDetail_Network_Port_hover": "El port on el dispositiu està connectat al dispositiu de xarxa del pare. Si es deixa buit, sortirà una icona wifi a la representació de la Xarxa.", "DevDetail_Nmap_Scans": "Escaneig manual Nmap", - "DevDetail_Nmap_Scans_desc": "Aquí podeu executar les exploracions NMAP manuals. També podeu programar les exploracions NMAP automàtiques a través del connector Serveis i Ports (NMAP). Ves a Docs per saber-ne més", + "DevDetail_Nmap_Scans_desc": "Aquí podeu executar les exploracions NMAP manuals. També podeu programar les exploracions NMAP automàtiques a través del connector Serveis i Ports (NMAP). Ves a Docs per saber-ne més", "DevDetail_Nmap_buttonDefault": "Escaneig predeterminat", "DevDetail_Nmap_buttonDefault_text": "Escaneig predeterminat: Nmap escaneja els 1000 ports superiors per a cada protocol d'exploració sol·licitat. El 93% dels ports TCP i el 49% dels ports UDP. (uns 5 segons)", "DevDetail_Nmap_buttonDetail": "Escaneig Detallat", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Actualitzar Valor", "Gen_ValidIcon": "", "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_Work_In_Progress": "Work in progress, un bon moment per retroalimentació a https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Nou dispositiu", "Gen_create_new_device_info": "Els dispositius són típicament descobert utilitzant plugins. Tanmateix, en certs casos, pots necessitar afegir dispositius a mà. Per explorar els temes concrets comproveu la documentació de Xarxes Remotes.", "General_display_name": "General", @@ -374,7 +374,7 @@ "Loading": "Carregant…", "Login_Box": "Introduïu la vostra contrasenya", "Login_Default_PWD": "Contrasenya per defecte \"123456\" encara és activa.", - "Login_Info": "Les contrasenyes es canvien al connector(plugin) Configurar Contrasenya. Comprova el SETPWD docs si tens dubtes fent logging.", + "Login_Info": "Les contrasenyes es canvien al connector(plugin) Configurar Contrasenya. Comprova el SETPWD docs si tens dubtes fent logging.", "Login_Psw-box": "Contrasenya", "Login_Psw_alert": "Alerta de contrasenya!", "Login_Psw_folder": "a la carpeta config.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "actualment està desactivat", "Maintenance_arp_status_on": "s'està fent un scan", "Maintenance_built_on": "Construït", - "Maintenance_current_version": "Ets actual. Dona un cop d'ull al que estic treballant.", + "Maintenance_current_version": "Ets actual. Dona un cop d'ull al que estic treballant.", "Maintenance_database_backup": "Còpies de seguretat de BBDD", "Maintenance_database_backup_found": "s'han trobat còpies de seguretat", "Maintenance_database_backup_total": "ús total del disc", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Tria idioma", "Maintenance_lang_selector_lable": "Selecció d'idioma", "Maintenance_lang_selector_text": "El canvi té lloc en el cantó del client, així que afecta només el navegador actual.", - "Maintenance_new_version": "Hi ha una nova versió. Comprova release notes.", + "Maintenance_new_version": "Hi ha una nova versió. Comprova release notes.", "Maintenance_themeselector_apply": "Aplica", "Maintenance_themeselector_empty": "Tria una Skin", "Maintenance_themeselector_lable": "Selecciona una Skin", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index a4596ef04..b47c6243b 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -362,7 +362,7 @@ "Gen_Update_Value": "Wert aktualisieren", "Gen_ValidIcon": "", "Gen_Warning": "Warnung", - "Gen_Work_In_Progress": "Keine Finalversion, feedback bitte unter: https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Keine Finalversion, feedback bitte unter: https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Neues Gerät", "Gen_create_new_device_info": "Geräte werden normalerweise über Plugins gefunden. In Ausnahmefällen kann es nötig sein, sie manuell hinzuzufügen. Konkrete Szenarien sind in der Dokumentation über entfernte Netzwerke zu finden.", "General_display_name": "Allgemein", @@ -506,7 +506,7 @@ "Maintenance_arp_status_off": "ist im Moment deaktiviert", "Maintenance_arp_status_on": "Scan(s) sind gerade aktiv", "Maintenance_built_on": "Erstellt am", - "Maintenance_current_version": "Du bist up-to-date. Sieh dir an, woran ich gerade arbeite.", + "Maintenance_current_version": "Du bist up-to-date. Sieh dir an, woran ich gerade arbeite.", "Maintenance_database_backup": "DB Sicherungen", "Maintenance_database_backup_found": "Sicherungen verfügbar", "Maintenance_database_backup_total": "Speicherplatz insgesamt", @@ -518,7 +518,7 @@ "Maintenance_lang_selector_empty": "Sprache wählen", "Maintenance_lang_selector_lable": "Sprachauswahl", "Maintenance_lang_selector_text": "Die Änderung findet clientseitig statt, betrifft also nur den aktuellen Browser.", - "Maintenance_new_version": "Eine neue Version ist vefügbar. Sieh dir die Versionshinweise an.", + "Maintenance_new_version": "Eine neue Version ist vefügbar. Sieh dir die Versionshinweise an.", "Maintenance_themeselector_apply": "Übernehmen", "Maintenance_themeselector_empty": "Skin wählen", "Maintenance_themeselector_lable": "Skin Auswahl", @@ -784,7 +784,7 @@ "UI_REFRESH_name": "Benutzeroberfläche automatisch auffrischen", "VERSION_description": "", "VERSION_name": "Version oder Zeitstempel", - "WEBHOOK_PAYLOAD_description": "The Webhook payload data format for the body > attachments > text attribute in the payload json. See an example of the payload here. (e.g.: for discord use text)", + "WEBHOOK_PAYLOAD_description": "The Webhook payload data format for the body > attachments > text attribute in the payload json. See an example of the payload here. (e.g.: for discord use text)", "WEBHOOK_PAYLOAD_name": "Payload type", "WEBHOOK_REQUEST_METHOD_description": "The HTTP request method to be used for the webhook call.", "WEBHOOK_REQUEST_METHOD_name": "Request method", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index ce3f8a7f7..9f2d6af10 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Select the parent network device the current device is connected to, to populate the Network tree.", "DevDetail_Network_Port_hover": "The port this device is connected to on the parent network device. If left empty a wifi icon is displayed in the Network tree.", "DevDetail_Nmap_Scans": "Manual Nmap Scans", - "DevDetail_Nmap_Scans_desc": "Here you can execute manual NMAP scans. You can also schedule regular automatic NMAP scans via the Services & Ports (NMAP) plugin. Head to Docs to find out more", + "DevDetail_Nmap_Scans_desc": "Here you can execute manual NMAP scans. You can also schedule regular automatic NMAP scans via the Services & Ports (NMAP) plugin. Head to Docs to find out more", "DevDetail_Nmap_buttonDefault": "Default Scan", "DevDetail_Nmap_buttonDefault_text": "Default Scan: Nmap scans the top 1,000 ports for each scan protocol requested. This catches roughly 93% of the TCP ports and 49% of the UDP ports. (about 5 seconds)", "DevDetail_Nmap_buttonDetail": "Detailed Scan", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Update Value", "Gen_ValidIcon": "", "Gen_Warning": "Warning", - "Gen_Work_In_Progress": "Work in progress, good time to feedback on https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Work in progress, good time to feedback on https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "New device", "Gen_create_new_device_info": "Devices are typically discovered using plugins. However, in certain cases, you may need to add devices manually. To explore specific scenarios check the Remote Networks documentation.", "General_display_name": "General", @@ -374,7 +374,7 @@ "Loading": "Loading…", "Login_Box": "Enter your password", "Login_Default_PWD": "Default password \"123456\" is still active.", - "Login_Info": "Passwords are set via the Set Password plugin. Check the SETPWD docs if you have issues logging in.", + "Login_Info": "Passwords are set via the Set Password plugin. Check the SETPWD docs if you have issues logging in.", "Login_Psw-box": "Password", "Login_Psw_alert": "Password Alert!", "Login_Psw_folder": "in the config folder.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "is currently disabled", "Maintenance_arp_status_on": "scanning in progress", "Maintenance_built_on": "Built on", - "Maintenance_current_version": "You are up-to-date. Check out what I am working on.", + "Maintenance_current_version": "You are up-to-date. Check out what I am working on.", "Maintenance_database_backup": "DB Backups", "Maintenance_database_backup_found": "backups were found", "Maintenance_database_backup_total": "total disk usage", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Choose Language", "Maintenance_lang_selector_lable": "Select Language", "Maintenance_lang_selector_text": "The change takes place on the client side, so it affects only the current browser.", - "Maintenance_new_version": "A new version is available. Check out the release notes.", + "Maintenance_new_version": "A new version is available. Check out the release notes.", "Maintenance_themeselector_apply": "Apply", "Maintenance_themeselector_empty": "Choose a Skin", "Maintenance_themeselector_lable": "Select Skin", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index a2fd036a5..997315220 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -111,7 +111,7 @@ "DevDetail_Network_Node_hover": "Seleccione el dispositivo de red principal al que está conectado el dispositivo actual para completar el árbol de Red.", "DevDetail_Network_Port_hover": "El puerto al que está conectado este dispositivo en el dispositivo de red principal. Si se deja vacío, se muestra un icono de wifi en el árbol de Red.", "DevDetail_Nmap_Scans": "Escaneos de Nmap", - "DevDetail_Nmap_Scans_desc": "Aquí puede ejecutar escaneos NMAP manuales. También puede programar escaneos NMAP automáticos regulares a través del complemento Servicios y puertos (NMAP). Dirígete a Documentación para obtener más información", + "DevDetail_Nmap_Scans_desc": "Aquí puede ejecutar escaneos NMAP manuales. También puede programar escaneos NMAP automáticos regulares a través del complemento Servicios y puertos (NMAP). Dirígete a Documentación para obtener más información", "DevDetail_Nmap_buttonDefault": "Escaneado predeterminado", "DevDetail_Nmap_buttonDefault_text": "Escaneo predeterminado: NMAP escanea los 1,000 puertos principales para cada protocolo de escaneo solicitado. Esto atrapa aproximadamente el 93% de los puertos TCP y el 49% de los puertos UDP. (aproximadamente 5 segundos)", "DevDetail_Nmap_buttonDetail": "Escaneo detallado", @@ -360,7 +360,7 @@ "Gen_Update_Value": "Actualizar valor", "Gen_ValidIcon": "", "Gen_Warning": "Advertencia", - "Gen_Work_In_Progress": "Trabajo en curso, un buen momento para hacer comentarios en https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Trabajo en curso, un buen momento para hacer comentarios en https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Nuevo dispositivo", "Gen_create_new_device_info": "Los dispositivos se suelen descubrir utilizando plugins. Sin embargo, en algunos casos, es posible que necesite agregar dispositivos manualmente. Para explorar escenarios específicos, consulte la documentación Redes remotas.", "General_display_name": "General", @@ -376,7 +376,7 @@ "Loading": "Cargando . . .", "Login_Box": "Ingrese su contraseña", "Login_Default_PWD": "La contraseña por defecto \"123456\" sigue activa.", - "Login_Info": "Las contraseñas se establecen a través del plugin Establecer contraseña. Compruebe la documentación SETPWD si tiene problemas para iniciar sesión.", + "Login_Info": "Las contraseñas se establecen a través del plugin Establecer contraseña. Compruebe la documentación SETPWD si tiene problemas para iniciar sesión.", "Login_Psw-box": "Contraseña", "Login_Psw_alert": "¡Alerta de Contraseña!", "Login_Psw_folder": "en la carpeta config.", @@ -504,7 +504,7 @@ "Maintenance_arp_status_off": "está actualmente deshabilitado", "Maintenance_arp_status_on": "escaneo en ejecución", "Maintenance_built_on": "Creada", - "Maintenance_current_version": "No hay actualizaciones disponibles. Comprueba en que se está trabajando.", + "Maintenance_current_version": "No hay actualizaciones disponibles. Comprueba en que se está trabajando.", "Maintenance_database_backup": "Copias de seguridad de BD", "Maintenance_database_backup_found": "copia(s) de seguridad encontrada(s)", "Maintenance_database_backup_total": "Uso total de disco", @@ -516,7 +516,7 @@ "Maintenance_lang_selector_empty": "Elija un idioma", "Maintenance_lang_selector_lable": "Seleccione su idioma", "Maintenance_lang_selector_text": "El cambio se produce en el lado del cliente, por lo que sólo afecta al navegador actual.", - "Maintenance_new_version": "Una nueva versión está disponible. Comprueba las notas de lanzamiento.", + "Maintenance_new_version": "Una nueva versión está disponible. Comprueba las notas de lanzamiento.", "Maintenance_themeselector_apply": "Aplicar", "Maintenance_themeselector_empty": "Elige un tema", "Maintenance_themeselector_lable": "Seleccionar tema", @@ -783,7 +783,7 @@ "UI_REFRESH_name": "Actualización automática de la interfaz de usuario", "VERSION_description": "Valor de ayuda de versión o marca de tiempo para comprobar si la aplicación se ha actualizado.", "VERSION_name": "Versión o marca de tiempo", - "WEBHOOK_PAYLOAD_description": "El formato de datos de carga de Webhook para el atributo body > attachments > text en el json de carga. Vea un ejemplo de la carga aquí. (por ejemplo: para discord use text)", + "WEBHOOK_PAYLOAD_description": "El formato de datos de carga de Webhook para el atributo body > attachments > text en el json de carga. Vea un ejemplo de la carga aquí. (por ejemplo: para discord use text)", "WEBHOOK_PAYLOAD_name": "Tipo de carga", "WEBHOOK_REQUEST_METHOD_description": "El método de solicitud HTTP que se utilizará para la llamada de webhook.", "WEBHOOK_REQUEST_METHOD_name": "Método de solicitud", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 4aa95a79f..cccf206fb 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Sélectionner l'appareil du réseau principal auquel cet appareil est connecté afin de compléter l'arborescence du Réseau.", "DevDetail_Network_Port_hover": "Le port auquel cet appareil est connecté sur l'appareil du réseau principal. Si vide, une icône Wifi est affichée dans l'arborescence du Réseau.", "DevDetail_Nmap_Scans": "Scans manuels via Nmap", - "DevDetail_Nmap_Scans_desc": "Vous pouvez lancer des scans NMAP manuels. Vous pouvez aussi programmer des sans réguliers via le plugin Services & Ports (NMAP). Aller dans les Docs pour plus de details", + "DevDetail_Nmap_Scans_desc": "Vous pouvez lancer des scans NMAP manuels. Vous pouvez aussi programmer des sans réguliers via le plugin Services & Ports (NMAP). Aller dans les Docs pour plus de details", "DevDetail_Nmap_buttonDefault": "Scan par défaut", "DevDetail_Nmap_buttonDefault_text": "Scan par défaut : NMAP scanne les 1 000 premiers ports pour chaque demande de scan de protocole. Cela couvre environ 93% des ports TCP et 49% des ports UDP (environ 5 secondes)", "DevDetail_Nmap_buttonDetail": "Scan détaillé", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Valeur à mettre à jour", "Gen_ValidIcon": "", "Gen_Warning": "Avertissement", - "Gen_Work_In_Progress": "Travaux en cours, c'est le bon moment pour faire un retour via la liste d'anomalies sur Github https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Travaux en cours, c'est le bon moment pour faire un retour via la liste d'anomalies sur Github https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Nouvel appareil", "Gen_create_new_device_info": "Les appareils sont souvent découverts à l'aide d'un plugin. Cependant, dans certains cas, vous pouvez ajouter manuellement les appareils. Pour explorer des scénarios spécifiques, consulter la documentation des réseaux distants.", "General_display_name": "Général", @@ -374,7 +374,7 @@ "Loading": "Chargement…", "Login_Box": "Saisir votre mot de passe", "Login_Default_PWD": "Le mot de passe par défaut \"123456\" est encore actif.", - "Login_Info": "Les mots de passe sont définis via le plugin Set Password. Vérifiez la documentation de SETPWD si vous rencontrez des difficultés à vous identifier.", + "Login_Info": "Les mots de passe sont définis via le plugin Set Password. Vérifiez la documentation de SETPWD si vous rencontrez des difficultés à vous identifier.", "Login_Psw-box": "Mot de passe", "Login_Psw_alert": "Alerte de mot de passe !", "Login_Psw_folder": "dans le dossier de configuration.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "est actuellement désactivé", "Maintenance_arp_status_on": "scan en cours", "Maintenance_built_on": "Construit sur", - "Maintenance_current_version": "Vous êtes à jour. Découvrez sur quoi je travaille.", + "Maintenance_current_version": "Vous êtes à jour. Découvrez sur quoi je travaille.", "Maintenance_database_backup": "Sauvegardes de base de données", "Maintenance_database_backup_found": "des sauvegardes ont été trouvées", "Maintenance_database_backup_total": "utilisation totale du disque", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Choix de la langue", "Maintenance_lang_selector_lable": "Sélectionner une langue", "Maintenance_lang_selector_text": "Le changement est effectué côté client, cela ne concerne donc que le navigateur actuel.", - "Maintenance_new_version": "Une nouvelle version est disponible. Consulter les notes de version.", + "Maintenance_new_version": "Une nouvelle version est disponible. Consulter les notes de version.", "Maintenance_themeselector_apply": "Appliquer", "Maintenance_themeselector_empty": "Choisir un thème", "Maintenance_themeselector_lable": "Sélectionner un thème", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index bb9233dd9..ffc7d8080 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Seleziona il dispositivo di rete principale a cui è connesso il dispositivo corrente per popolare la struttura di rete.", "DevDetail_Network_Port_hover": "La porta a cui è connesso questo dispositivo sul dispositivo di rete principale. Se lasciato vuoto, verrà visualizzata un'icona Wi-Fi nella struttura di rete.", "DevDetail_Nmap_Scans": "Scansioni Nmap manuali", - "DevDetail_Nmap_Scans_desc": "Qui puoi eseguire scansioni manuali NMAP. Puoi anche pianificare scansioni automatiche NMAP attraverso il plugin Servizi e porte (NMAP). Vai alla Documentazione per saperne di più", + "DevDetail_Nmap_Scans_desc": "Qui puoi eseguire scansioni manuali NMAP. Puoi anche pianificare scansioni automatiche NMAP attraverso il plugin Servizi e porte (NMAP). Vai alla Documentazione per saperne di più", "DevDetail_Nmap_buttonDefault": "Scansione predefinita", "DevDetail_Nmap_buttonDefault_text": "Scansione predefinita: Nmap scansiona 1000 porte per ogni protocollo richiesto. Questo dovrebbe coprire circa il 93% delle porte TCP e il 49% delle porte UDP (circa 5 secondi)", "DevDetail_Nmap_buttonDetail": "Scansione dettagliata", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Aggiorna valore", "Gen_ValidIcon": "", "Gen_Warning": "Avviso", - "Gen_Work_In_Progress": "Lavori in corso, è quindi un buon momento per un feedback su https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Lavori in corso, è quindi un buon momento per un feedback su https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Nuovo dispositivo", "Gen_create_new_device_info": "I dispositivi vengono generalmente rilevati utilizzando plugin. Tuttavia, in alcuni casi, potrebbe essere necessario aggiungere manualmente i dispositivi. Per esplorare scenari specifici, consulta la documentazione sulle reti remote.", "General_display_name": "Generale", @@ -374,7 +374,7 @@ "Loading": "Caricamento…", "Login_Box": "Inserisci la tua password", "Login_Default_PWD": "La password predefinita \"123456\" è ancora attiva.", - "Login_Info": "Le password vengono impostate tramite il plugin Set Password. Controlla la documentazione SETPWD se riscontri problemi di accesso.", + "Login_Info": "Le password vengono impostate tramite il plugin Set Password. Controlla la documentazione SETPWD se riscontri problemi di accesso.", "Login_Psw-box": "Password", "Login_Psw_alert": "Avviso password!", "Login_Psw_folder": "nella cartella di configurazione.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "è attualmente disabilitato", "Maintenance_arp_status_on": "scansione in corso", "Maintenance_built_on": "Rilasciato il", - "Maintenance_current_version": "Sei aggiornato. Scopri a cosa sto lavorando.", + "Maintenance_current_version": "Sei aggiornato. Scopri a cosa sto lavorando.", "Maintenance_database_backup": "Backup DB", "Maintenance_database_backup_found": "backup trovati", "Maintenance_database_backup_total": "utilizzo totale disco", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Scegli lingua", "Maintenance_lang_selector_lable": "Seleziona lingua", "Maintenance_lang_selector_text": "Questa modifica avviene lato client, quindi influisce solo sul browser attualmente in uso.", - "Maintenance_new_version": "È disponibile una nuova versione. Controlla le note di rilascio.", + "Maintenance_new_version": "È disponibile una nuova versione. Controlla le note di rilascio.", "Maintenance_themeselector_apply": "Applica", "Maintenance_themeselector_empty": "Scegli una skin", "Maintenance_themeselector_lable": "Seleziona skin", diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 061bc4d77..c9b8681c9 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "現在のデバイスが接続されている上位のネットワーク機器を選択し、ネットワークツリーを構築します。", "DevDetail_Network_Port_hover": "上位のネットワーク機器上で本デバイスが接続されているポート。空欄のままにすると、ネットワークツリーにWi-Fiアイコンが表示されます。", "DevDetail_Nmap_Scans": "手動Nmapスキャン", - "DevDetail_Nmap_Scans_desc": "ここでは手動のNMAPスキャンを実行できます。また、サービスとポート(NMAP)プラグインを通じて定期的な自動NMAPスキャンをスケジュールすることも可能です。詳細はドキュメントをご覧ください", + "DevDetail_Nmap_Scans_desc": "ここでは手動のNMAPスキャンを実行できます。また、サービスとポート(NMAP)プラグインを通じて定期的な自動NMAPスキャンをスケジュールすることも可能です。詳細はドキュメントをご覧ください", "DevDetail_Nmap_buttonDefault": "デフォルトスキャン", "DevDetail_Nmap_buttonDefault_text": "デフォルトスキャン: Nmapは、要求された各スキャンプロトコルに対して上位1,000ポートをスキャンします。これにより、TCPポートの約93%、UDPポートの約49%を捕捉します。(約5秒)", "DevDetail_Nmap_buttonDetail": "詳細スキャン", @@ -358,7 +358,7 @@ "Gen_Update_Value": "更新値", "Gen_ValidIcon": "", "Gen_Warning": "警告", - "Gen_Work_In_Progress": "作業中、https://github.com/jokob-sk/NetAlertX/issues へのフィードバックの好機です", + "Gen_Work_In_Progress": "作業中、https://github.com/netalertx/NetAlertX/issues へのフィードバックの好機です", "Gen_create_new_device": "新規デバイス", "Gen_create_new_device_info": "デバイスは通常、プラグインを使用して検出されます。ただし、特定のケースでは手動でデバイスを追加する必要がある場合があります。具体的なシナリオについては、リモートネットワークドキュメントを参照してください。", "General_display_name": "一般", @@ -374,7 +374,7 @@ "Loading": "読み込み中…", "Login_Box": "パスワードを入力してください", "Login_Default_PWD": "デフォルトパスワード「123456」は有効なままです。", - "Login_Info": "パスワードはSet Passwordプラグインで設定されます。ログインに問題がある場合はSETPWDのドキュメントを確認してください。", + "Login_Info": "パスワードはSet Passwordプラグインで設定されます。ログインに問題がある場合はSETPWDのドキュメントを確認してください。", "Login_Psw-box": "パスワード", "Login_Psw_alert": "パスワードアラート!", "Login_Psw_folder": "config フォルダ内。", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "無効化中", "Maintenance_arp_status_on": "スキャン中", "Maintenance_built_on": "ビルド日", - "Maintenance_current_version": "最新です。現在の取り組みをご覧ください。", + "Maintenance_current_version": "最新です。現在の取り組みをご覧ください。", "Maintenance_database_backup": "DBバックアップ", "Maintenance_database_backup_found": "バックアップが見つかりました", "Maintenance_database_backup_total": "総ディスク使用量", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "言語を選択", "Maintenance_lang_selector_lable": "言語を選択", "Maintenance_lang_selector_text": "変更はクライアント側で行われるため、現在のブラウザにのみ影響します。", - "Maintenance_new_version": "新しいバージョンが利用可能です。リリースノートを確認してください。", + "Maintenance_new_version": "新しいバージョンが利用可能です。リリースノートを確認してください。", "Maintenance_themeselector_apply": "適用", "Maintenance_themeselector_empty": "スキンを選択", "Maintenance_themeselector_lable": "スキンを選択", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 1bd7b439f..568da04b8 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -358,7 +358,7 @@ "Gen_Update_Value": "Oppdater verdi", "Gen_ValidIcon": "", "Gen_Warning": "Advarsel", - "Gen_Work_In_Progress": "Work in progress, gjerne kom med tilbakemeldinger på https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Work in progress, gjerne kom med tilbakemeldinger på https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "", "Gen_create_new_device_info": "", "General_display_name": "Generelt", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "er for øyeblikket deaktivert", "Maintenance_arp_status_on": "skanning(er) som kjører", "Maintenance_built_on": "Bygd på", - "Maintenance_current_version": "Du er oppdatert. Sjekk ut hvajeg jobber med.", + "Maintenance_current_version": "Du er oppdatert. Sjekk ut hvajeg jobber med.", "Maintenance_database_backup": "Database-sikkerhetskopier", "Maintenance_database_backup_found": "Sikkerhetskopier ble funnet", "Maintenance_database_backup_total": "total diskbruk", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Velg språk", "Maintenance_lang_selector_lable": "Velg språk", "Maintenance_lang_selector_text": "Endringen skjer på klientsiden, så den påvirker bare den nåværende nettleseren.", - "Maintenance_new_version": "En ny versjon er tilgjengelig. Sjekk ut utgivelsesnotater.", + "Maintenance_new_version": "En ny versjon er tilgjengelig. Sjekk ut utgivelsesnotater.", "Maintenance_themeselector_apply": "Bruk", "Maintenance_themeselector_empty": "Velg ett skinn", "Maintenance_themeselector_lable": "Velg skinn", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index c8160f43a..1b9449018 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -358,7 +358,7 @@ "Gen_Update_Value": "Aktualizuj wartość", "Gen_ValidIcon": "", "Gen_Warning": "Ostrzeżenie", - "Gen_Work_In_Progress": "Prace w toku, to dobry moment na przesłanie opinii na https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Prace w toku, to dobry moment na przesłanie opinii na https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Nowe urządzenie", "Gen_create_new_device_info": "Urządzenia są zazwyczaj wykrywane za pomocą wtyczek. Jednak w niektórych przypadkach może być konieczne dodanie urządzeń ręcznie. Aby zapoznać się z konkretnymi scenariuszami, sprawdź dokumentację dotyczącą sieci zdalnych.", "General_display_name": "Ogólne", @@ -374,7 +374,7 @@ "Loading": "Ładowanie…", "Login_Box": "Wprowadź swoje hasło", "Login_Default_PWD": "Domyślne hasło „123456” nadal jest aktywne.", - "Login_Info": "Hasła są ustawiane za pomocą wtyczki Set Password. Jeśli masz problemy z logowaniem, sprawdź dokumentację SETPWD.", + "Login_Info": "Hasła są ustawiane za pomocą wtyczki Set Password. Jeśli masz problemy z logowaniem, sprawdź dokumentację SETPWD.", "Login_Psw-box": "Hasło", "Login_Psw_alert": "Alert hasła!", "Login_Psw_folder": "w folderze konfiguracyjnym.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "jest obecnie wyłączony", "Maintenance_arp_status_on": "skanowanie w toku", "Maintenance_built_on": "Zbudowany na", - "Maintenance_current_version": "Jesteś na bieżąco. Zobacz, nad czym aktualnie pracuję.", + "Maintenance_current_version": "Jesteś na bieżąco. Zobacz, nad czym aktualnie pracuję.", "Maintenance_database_backup": "Kopia zapasowa bazy danych", "Maintenance_database_backup_found": "znaleziono kopie zapasowe", "Maintenance_database_backup_total": "całkowite wykorzystanie dysku", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Wybierz język", "Maintenance_lang_selector_lable": "Wybierz język", "Maintenance_lang_selector_text": "Zmiana odbywa się po stronie klienta, więc dotyczy tylko bieżącej przeglądarki.", - "Maintenance_new_version": "Dostępna jest nowa wersja. Sprawdź informacje o wydaniu.", + "Maintenance_new_version": "Dostępna jest nowa wersja. Sprawdź informacje o wydaniu.", "Maintenance_themeselector_apply": "Zastosuj", "Maintenance_themeselector_empty": "Wybierz skórkę", "Maintenance_themeselector_lable": "Zastosuj skórkę", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index f61cc2551..1c76d93cd 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -358,7 +358,7 @@ "Gen_Update_Value": "Atualizar valor", "Gen_ValidIcon": "", "Gen_Warning": "Aviso", - "Gen_Work_In_Progress": "Trabalho em andamento, um bom momento para enviar feedback em https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Trabalho em andamento, um bom momento para enviar feedback em https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Novo dispositivo", "Gen_create_new_device_info": "Os dispositivos são normalmente descobertos usando plugins. No entanto, em certos casos, pode ser necessário adicionar dispositivos manualmente. Para explorar cenários específicos, verifique a documentação de Redes Remotas.", "General_display_name": "Geral", @@ -374,7 +374,7 @@ "Loading": "Carregando...", "Login_Box": "Introduza a sua palavra-passe", "Login_Default_PWD": "A palavra-passe predefinida “123456” ainda está ativa.", - "Login_Info": "As palavra-passes são definidas por meio do plugin Definir palavra-passe. Verifique a documentação do SETPWD se tiver problemas para fazer login.", + "Login_Info": "As palavra-passes são definidas por meio do plugin Definir palavra-passe. Verifique a documentação do SETPWD se tiver problemas para fazer login.", "Login_Psw-box": "Palavra-passe", "Login_Psw_alert": "Alerta de palavra-passe!", "Login_Psw_folder": "na pasta de configuração.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "está actualmente desativado", "Maintenance_arp_status_on": "scan(s) atualmente em execução", "Maintenance_built_on": "Construído em", - "Maintenance_current_version": "Estás actualizado. Confira o que Estou trabalhando em.", + "Maintenance_current_version": "Estás actualizado. Confira o que Estou trabalhando em.", "Maintenance_database_backup": "Backups DB", "Maintenance_database_backup_found": "foram encontrados backups", "Maintenance_database_backup_total": "uso total do disco", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Selecionar a língua", "Maintenance_lang_selector_lable": "Selecionar a língua", "Maintenance_lang_selector_text": "A mudança ocorre no lado do cliente, por isso afeta apenas o navegador atual.", - "Maintenance_new_version": "Uma nova versão está disponível. Confira as notas de lançamento.", + "Maintenance_new_version": "Uma nova versão está disponível. Confira as notas de lançamento.", "Maintenance_themeselector_apply": "Aplicar", "Maintenance_themeselector_empty": "Escolha uma Skin", "Maintenance_themeselector_lable": "Selecionar Skin", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index fb0e54bf4..aa0bacf3d 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Selecione o dispositivo de rede principal ao qual o dispositivo atual está conectado, para preencher a árvore Rede.", "DevDetail_Network_Port_hover": "A porta a que este dispositivo está ligado no dispositivo de rede principal. Se for deixado vazio, é apresentado um ícone wifi na árvore Rede.", "DevDetail_Nmap_Scans": "Varreduras manuais do Nmap", - "DevDetail_Nmap_Scans_desc": "Aqui pode executar análises NMAP manuais. Também pode agendar análises NMAP automáticas regulares através do plugin Serviços & Portos (NMAP). Aceda à https://github.com/jokob-sk/NetAlertX/tree/main/front/plugins/nmap_scan para saber mais", + "DevDetail_Nmap_Scans_desc": "Aqui pode executar análises NMAP manuais. Também pode agendar análises NMAP automáticas regulares através do plugin Serviços & Portos (NMAP). Aceda à https://github.com/netalertx/NetAlertX/tree/main/front/plugins/nmap_scan para saber mais", "DevDetail_Nmap_buttonDefault": "Verificação predefinida", "DevDetail_Nmap_buttonDefault_text": "Scan padrão: Nmap verifica as 1.000 portas superiores para cada protocolo de digitalização solicitado. Isto atinge cerca de 93% das portas TCP e 49% das portas UDP. (cerca de 5 segundos)", "DevDetail_Nmap_buttonDetail": "Verificação Detalhada", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Atualizar valor", "Gen_ValidIcon": "", "Gen_Warning": "Aviso", - "Gen_Work_In_Progress": "Trabalho em andamento, um bom momento para enviar feedback em https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Trabalho em andamento, um bom momento para enviar feedback em https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Novo dispositivo", "Gen_create_new_device_info": "Os dispositivos são normalmente descobertos usando plugins. No entanto, em certos casos, pode ser necessário adicionar dispositivos manualmente. Para explorar cenários específicos, verifique a documentação de Redes Remotas.", "General_display_name": "Geral", @@ -374,7 +374,7 @@ "Loading": "A carregar…", "Login_Box": "Introduza a sua palavra-passe", "Login_Default_PWD": "A palavra-passe predefinida “123456” ainda está ativa.", - "Login_Info": "As palavra-passes são definidas por meio do plugin Definir palavra-passe. Verifique a documentação do SETPWD se tiver problemas para fazer login.", + "Login_Info": "As palavra-passes são definidas por meio do plugin Definir palavra-passe. Verifique a documentação do SETPWD se tiver problemas para fazer login.", "Login_Psw-box": "Palavra-passe", "Login_Psw_alert": "Alerta de palavra-passe!", "Login_Psw_folder": "na pasta de configuração.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "está atualmente desativado", "Maintenance_arp_status_on": "Scan em curso", "Maintenance_built_on": "Construído em", - "Maintenance_current_version": "Você está atualizado. Confira o que estou a trabalhar em.", + "Maintenance_current_version": "Você está atualizado. Confira o que estou a trabalhar em.", "Maintenance_database_backup": "Backups DB", "Maintenance_database_backup_found": "foram encontrados backups", "Maintenance_database_backup_total": "uso total do disco", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Escolha a lingua", "Maintenance_lang_selector_lable": "Escolha a lingua", "Maintenance_lang_selector_text": "A mudança ocorre no lado do cliente, por isso afeta apenas o navegador atual.", - "Maintenance_new_version": "Uma nova versão está disponível. Confira as notas de lançamento.", + "Maintenance_new_version": "Uma nova versão está disponível. Confira as notas de lançamento.", "Maintenance_themeselector_apply": "Aplicar", "Maintenance_themeselector_empty": "Escolha uma Skin", "Maintenance_themeselector_lable": "Selecionar Skin", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 2b58b2d19..0d1c39193 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Выберите родительское сетевое устройство, к которому подключено текущее устройство, чтобы заполнить дерево сети.", "DevDetail_Network_Port_hover": "Порт, к которому подключено это устройство на родительском сетевом устройстве. Если оставить пустым, в дереве сети отобразится значок Wi-Fi.", "DevDetail_Nmap_Scans": "Ручные сканеры Nmap", - "DevDetail_Nmap_Scans_desc": "Здесь вы можете выполнить сканирование NMAP вручную. Вы также можете запланировать регулярное автоматическое сканирование NMAP с помощью плагина «Службы и порты» (NMAP). Чтобы узнать больше, перейдите в Документацию", + "DevDetail_Nmap_Scans_desc": "Здесь вы можете выполнить сканирование NMAP вручную. Вы также можете запланировать регулярное автоматическое сканирование NMAP с помощью плагина «Службы и порты» (NMAP). Чтобы узнать больше, перейдите в Документацию", "DevDetail_Nmap_buttonDefault": "Сканирование по умолчанию", "DevDetail_Nmap_buttonDefault_text": "Сканирование по умолчанию: Nmap сканирует 1000 верхних портов для каждого запрошенного протокола сканирования. Это перехватывает примерно 93% портов TCP и 49% портов UDP. (около 5 секунд)", "DevDetail_Nmap_buttonDetail": "Детальное сканирование", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Обновить значение", "Gen_ValidIcon": "", "Gen_Warning": "Предупреждение", - "Gen_Work_In_Progress": "Работа продолжается, самое время оставить отзыв на https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Работа продолжается, самое время оставить отзыв на https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Новое устройство", "Gen_create_new_device_info": "Устройства обычно обнаруживаются с помощью плагинов. Однако в некоторых случаях вам может потребоваться добавить устройства вручную. Для изучения конкретных сценариев ознакомьтесь с документацией Remote Networks.", "General_display_name": "Главное", @@ -374,7 +374,7 @@ "Loading": "Загрузка…", "Login_Box": "Введите пароль", "Login_Default_PWD": "Пароль по умолчанию «123456» все еще активен.", - "Login_Info": "Пароли устанавливаются через плагин Set Password. Если у вас возникли проблемы со входом в систему, проверьте SEPWD документацию.", + "Login_Info": "Пароли устанавливаются через плагин Set Password. Если у вас возникли проблемы со входом в систему, проверьте SEPWD документацию.", "Login_Psw-box": "Пароль", "Login_Psw_alert": "Предупреждение о пароле!", "Login_Psw_folder": "в папке конфигурации.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "в настоящее время отключен", "Maintenance_arp_status_on": "выполняется сканирование", "Maintenance_built_on": "Сборка от", - "Maintenance_current_version": "Вы в курсе событий. Узнайте, над чем я работаю.", + "Maintenance_current_version": "Вы в курсе событий. Узнайте, над чем я работаю.", "Maintenance_database_backup": "Резервные копии БД", "Maintenance_database_backup_found": "резервные копии были найдены", "Maintenance_database_backup_total": "общее использование диска", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Выберите язык", "Maintenance_lang_selector_lable": "Выбрать язык", "Maintenance_lang_selector_text": "Изменение происходит на стороне клиента, поэтому оно влияет только на текущий браузер.", - "Maintenance_new_version": "Доступна новая версия. Ознакомьтесь с примечаниями к выпуску.", + "Maintenance_new_version": "Доступна новая версия. Ознакомьтесь с примечаниями к выпуску.", "Maintenance_themeselector_apply": "Применить", "Maintenance_themeselector_empty": "Выбрать скин", "Maintenance_themeselector_lable": "Выбрать Скин", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 92ed9d7ce..bfcffcb41 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -358,7 +358,7 @@ "Gen_Update_Value": "Değeri Güncelle", "Gen_ValidIcon": "", "Gen_Warning": "Uyarı", - "Gen_Work_In_Progress": "Çalışma devam ediyor, geri bildirimde bulunmak için iyi bir zaman: https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Çalışma devam ediyor, geri bildirimde bulunmak için iyi bir zaman: https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Yeni cihaz", "Gen_create_new_device_info": "Cihazlar genellikle eklentiler kullanılarak keşfedilir. Ancak, bazı durumlarda cihazları manuel olarak eklemeniz gerekebilir. Belirli senaryoları incelemek için Uzak Ağlar belgelerini kontrol edin.", "General_display_name": "Genel", @@ -374,7 +374,7 @@ "Loading": "Yükleniyor...", "Login_Box": "Şifrenizi giriniz", "Login_Default_PWD": "Varsayılan şifre \"123456\" hâlâ aktif.", - "Login_Info": "Parolalar, Set Password eklentisi aracılığıyla ayarlanır. Giriş yapmakta sorun yaşıyorsanız, SETPWD belgelerini kontrol edin.", + "Login_Info": "Parolalar, Set Password eklentisi aracılığıyla ayarlanır. Giriş yapmakta sorun yaşıyorsanız, SETPWD belgelerini kontrol edin.", "Login_Psw-box": "Şİfre", "Login_Psw_alert": "Parola Uyarısı!", "Login_Psw_folder": "Konfigürasyon klasöründe.", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index e77981e64..7ce116da2 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "Виберіть батьківський мережевий пристрій, до якого підключено поточний пристрій, щоб заповнити дерево мережі.", "DevDetail_Network_Port_hover": "Порт, до якого підключено цей пристрій на батьківському мережевому пристрої. Якщо залишити пустим, у дереві мережі відобразиться значок Wi-Fi.", "DevDetail_Nmap_Scans": "Сканування Nmap вручну", - "DevDetail_Nmap_Scans_desc": "Тут ви можете виконувати ручні сканування NMAP. Ви також можете запланувати регулярні автоматичні сканування NMAP за допомогою плагіна Services & Ports (NMAP). Щоб дізнатися більше, перейдіть до Документації", + "DevDetail_Nmap_Scans_desc": "Тут ви можете виконувати ручні сканування NMAP. Ви також можете запланувати регулярні автоматичні сканування NMAP за допомогою плагіна Services & Ports (NMAP). Щоб дізнатися більше, перейдіть до Документації", "DevDetail_Nmap_buttonDefault": "Сканування за замовчуванням", "DevDetail_Nmap_buttonDefault_text": "Сканування за замовчуванням: Nmap сканує 1000 найпопулярніших портів для кожного запитуваного протоколу сканування. Це перехоплює приблизно 93% портів TCP і 49% портів UDP. (приблизно 5 секунд)", "DevDetail_Nmap_buttonDetail": "Детальне сканування", @@ -358,7 +358,7 @@ "Gen_Update_Value": "Оновити значення", "Gen_ValidIcon": "", "Gen_Warning": "Попередження", - "Gen_Work_In_Progress": "Робота триває, час залишити відгук на https://github.com/jokob-sk/NetAlertX/issues", + "Gen_Work_In_Progress": "Робота триває, час залишити відгук на https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Новий пристрій", "Gen_create_new_device_info": "Пристрої зазвичай виявляють за допомогою плагінів. Однак у деяких випадках може знадобитися додати пристрої вручну. Щоб дослідити конкретні сценарії, перегляньте документацію щодо віддалених мереж.", "General_display_name": "Загальний", @@ -374,7 +374,7 @@ "Loading": "Завантаження…", "Login_Box": "Введіть свій пароль", "Login_Default_PWD": "Стандартний пароль \"123456\" все ще активний.", - "Login_Info": "Паролі встановлюються за допомогою плагіна Set Password. Перегляньте документи SETPWD, якщо у вас виникли проблеми з входом.", + "Login_Info": "Паролі встановлюються за допомогою плагіна Set Password. Перегляньте документи SETPWD, якщо у вас виникли проблеми з входом.", "Login_Psw-box": "Пароль", "Login_Psw_alert": "Захист пароля!", "Login_Psw_folder": "в папці config.", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "наразі вимкнено", "Maintenance_arp_status_on": "триває сканування", "Maintenance_built_on": "Побудований на", - "Maintenance_current_version": "Ви в курсі подій. Перегляньте, над чим я працюю.", + "Maintenance_current_version": "Ви в курсі подій. Перегляньте, над чим я працюю.", "Maintenance_database_backup": "Резервне копіювання DB", "Maintenance_database_backup_found": "резервних копій знайдено", "Maintenance_database_backup_total": "загальне використання диска", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "Виберіть мову", "Maintenance_lang_selector_lable": "Виберіть мови", "Maintenance_lang_selector_text": "Зміна відбувається на стороні клієнта, тому вона впливає лише на поточний браузер.", - "Maintenance_new_version": "Доступна нова версія. Перегляньте примітки до випуску.", + "Maintenance_new_version": "Доступна нова версія. Перегляньте примітки до випуску.", "Maintenance_themeselector_apply": "Застосувати", "Maintenance_themeselector_empty": "Виберіть скін", "Maintenance_themeselector_lable": "Виберіть Скін", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 7e8c43455..07b824469 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -109,7 +109,7 @@ "DevDetail_Network_Node_hover": "选择当前设备连接到的父网络设备,以填充网络树。", "DevDetail_Network_Port_hover": "此设备连接到父网络设备上的端口。如果留空,则网络树中会显示一个 wifi 图标。", "DevDetail_Nmap_Scans": "手动 Nmap 扫描", - "DevDetail_Nmap_Scans_desc": "您可以在此处执行手动 NMAP 扫描。您还可以通过服务和端口 (NMAP) 插件安排定期自动 NMAP 扫描。前往Docs了解更多信息", + "DevDetail_Nmap_Scans_desc": "您可以在此处执行手动 NMAP 扫描。您还可以通过服务和端口 (NMAP) 插件安排定期自动 NMAP 扫描。前往Docs了解更多信息", "DevDetail_Nmap_buttonDefault": "默认扫描", "DevDetail_Nmap_buttonDefault_text": "默认扫描:Nmap 会扫描请求的每个扫描协议的前 1,000 个端口。这将捕获大约 93% 的 TCP 端口和 49% 的 UDP 端口。(大约 5 秒)", "DevDetail_Nmap_buttonDetail": "详细扫描", @@ -358,7 +358,7 @@ "Gen_Update_Value": "更新值", "Gen_ValidIcon": "", "Gen_Warning": "警告", - "Gen_Work_In_Progress": "工作正在进行中,欢迎在 https://github.com/jokob-sk/NetAlertX/issues 上反馈", + "Gen_Work_In_Progress": "工作正在进行中,欢迎在 https://github.com/netalertx/NetAlertX/issues 上反馈", "Gen_create_new_device": "新设备", "Gen_create_new_device_info": "通常使用plugins来发现设备。但是,在某些情况下,您可能需要手动添加设备。要探索特定场景,请查看远程网络文档。", "General_display_name": "通用", @@ -374,7 +374,7 @@ "Loading": "加载中…", "Login_Box": "输入密码", "Login_Default_PWD": "默认密码“123456”仍然有效。", - "Login_Info": "设置密码使用设置密码插件。如果您无法登录,请查看SETPWD文档。", + "Login_Info": "设置密码使用设置密码插件。如果您无法登录,请查看SETPWD文档。", "Login_Psw-box": "密码", "Login_Psw_alert": "密码警报!", "Login_Psw_folder": "在配置文件夹中。", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "当前已禁用", "Maintenance_arp_status_on": "正在运行扫描", "Maintenance_built_on": "建立于", - "Maintenance_current_version": "您已了解最新情况。查看我正在处理的内容。", + "Maintenance_current_version": "您已了解最新情况。查看我正在处理的内容。", "Maintenance_database_backup": "数据库备份", "Maintenance_database_backup_found": "找到备份", "Maintenance_database_backup_total": "总磁盘使用量", @@ -500,7 +500,7 @@ "Maintenance_lang_selector_empty": "选择语言", "Maintenance_lang_selector_lable": "选择语言", "Maintenance_lang_selector_text": "该更改发生在客户端,因此只影响当前浏览器。", - "Maintenance_new_version": "有新版本可用。查看发行说明。", + "Maintenance_new_version": "有新版本可用。查看发行说明。", "Maintenance_themeselector_apply": "应用", "Maintenance_themeselector_empty": "选择皮肤", "Maintenance_themeselector_lable": "选择皮肤", diff --git a/front/plugins/_publisher_webhook/config.json b/front/plugins/_publisher_webhook/config.json index 2d4ab8276..65a3481c5 100755 --- a/front/plugins/_publisher_webhook/config.json +++ b/front/plugins/_publisher_webhook/config.json @@ -400,11 +400,11 @@ "description": [ { "language_code": "en_us", - "string": "The Webhook payload data format for the body > attachments > text attribute in the payload json. See an example of the payload here. (e.g.: for discord use text)" + "string": "The Webhook payload data format for the body > attachments > text attribute in the payload json. See an example of the payload here. (e.g.: for discord use text)" }, { "language_code": "es_es", - "string": "El formato de datos de carga de Webhook para el atributo body > attachments > text en el json de carga. Vea un ejemplo de la carga aquí. (por ejemplo: para discord use text)" + "string": "El formato de datos de carga de Webhook para el atributo body > attachments > text en el json de carga. Vea un ejemplo de la carga aquí. (por ejemplo: para discord use text)" } ] }, diff --git a/front/plugins/newdev_template/config.json b/front/plugins/newdev_template/config.json index 10fb11cb5..1bddbd1ac 100755 --- a/front/plugins/newdev_template/config.json +++ b/front/plugins/newdev_template/config.json @@ -1676,7 +1676,7 @@ "description": [ { "language_code": "en_us", - "string": "The name of the Sync Node. Uneditable - Auto-populated via the Sync plugin if enabled. Database column name: devSyncHubNode." + "string": "The name of the Sync Node. Uneditable - Auto-populated via the Sync plugin if enabled. Database column name: devSyncHubNode." } ] }, diff --git a/front/plugins/omada_sdn_imp/config.json b/front/plugins/omada_sdn_imp/config.json index 5287d147c..394d39406 100755 --- a/front/plugins/omada_sdn_imp/config.json +++ b/front/plugins/omada_sdn_imp/config.json @@ -224,7 +224,7 @@ "description": [ { "language_code": "en_us", - "string": "Omada SDN site IDs. For now, we only process the first site listed since NetAlertX's other probes won't traverse across NAT and routers. But if needed please submit an issue in github with your specific use case for consideration: https://github.com/jokob-sk/NetAlertX/issues " + "string": "Omada SDN site IDs. For now, we only process the first site listed since NetAlertX's other probes won't traverse across NAT and routers. But if needed please submit an issue in github with your specific use case for consideration: https://github.com/netalertx/NetAlertX/issues " } ] }, diff --git a/front/plugins/snmp_discovery/config.json b/front/plugins/snmp_discovery/config.json index 1a155a71b..b89ed4ad8 100755 --- a/front/plugins/snmp_discovery/config.json +++ b/front/plugins/snmp_discovery/config.json @@ -483,11 +483,11 @@ "description": [ { "language_code": "en_us", - "string": "A list of snmpwalk commands to execute against IP addresses of routers/switches with SNMP turned on.

    Example with the router on the IP 192.168.1.1 (the -OXsq is a required parameter):
    snmpwalk -v 2c -c public -OXsq 192.168.1.1 .1.3.6.1.2.1.3.1.1.2

    Only IPv4 supported. Authentication is not supported. More info on the plugin here." + "string": "A list of snmpwalk commands to execute against IP addresses of routers/switches with SNMP turned on.

    Example with the router on the IP 192.168.1.1 (the -OXsq is a required parameter):
    snmpwalk -v 2c -c public -OXsq 192.168.1.1 .1.3.6.1.2.1.3.1.1.2

    Only IPv4 supported. Authentication is not supported. More info on the plugin here." }, { "language_code": "es_es", - "string": "Una lista de comandos snmpwalk para ejecutar en direcciones IP de computadoras/conmutadores con SNMP activado.

    Ejemplo con el enrutador en la IP 192.168.1.1:
    snmpwalk -v 2c -c public -OXsq 192.168.1.1 .1.3.6.1. 2.1.3.1.1.2

    Solo se admite IPv4. No se admite la autenticación. Más información sobre el complemento aquí." + "string": "Una lista de comandos snmpwalk para ejecutar en direcciones IP de computadoras/conmutadores con SNMP activado.

    Ejemplo con el enrutador en la IP 192.168.1.1:
    snmpwalk -v 2c -c public -OXsq 192.168.1.1 .1.3.6.1. 2.1.3.1.1.2

    Solo se admite IPv4. No se admite la autenticación. Más información sobre el complemento aquí." } ] }, diff --git a/install/debian12/install.debian12.sh b/install/debian12/install.debian12.sh index 1ef484e9d..551a9af1f 100755 --- a/install/debian12/install.debian12.sh +++ b/install/debian12/install.debian12.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# 🛑 Important: This is only used for the bare-metal install 🛑 -# Update /install/start.debian12.sh in most cases is preferred +# 🛑 Important: This is only used for the bare-metal install 🛑 +# Update /install/start.debian12.sh in most cases is preferred echo "---------------------------------------------------------" echo "[INSTALL] Run install.debian12.sh" @@ -12,7 +12,7 @@ INSTALL_DIR=/app # Specify the installation directory here # Check if script is run as root if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root. Please use 'sudo'." + echo "This script must be run as root. Please use 'sudo'." exit 1 fi @@ -27,7 +27,7 @@ apt-get install -y git rm -R ${INSTALL_DIR:?}/ # Clone the application repository -git clone https://github.com/jokob-sk/NetAlertX "$INSTALL_DIR/" +git clone https://github.com/netalertx/NetAlertX "$INSTALL_DIR/" # Check for buildtimestamp.txt existence, otherwise create it if [ ! -f $INSTALL_DIR/front/buildtimestamp.txt ]; then From e15c68d1894948b10bdf37138664fd44a601a4bc Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 1 Mar 2026 04:55:35 +0000 Subject: [PATCH 037/122] feat: enhance cacheStrings function to re-fetch plugin language strings on initialization --- front/js/cache.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/front/js/cache.js b/front/js/cache.js index 8d549ef29..9b35ec837 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -292,7 +292,22 @@ function cacheStrings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheStrings')) === "true") { - resolve(); + // Core strings are cached, but plugin strings may have failed silently on + // the first load (non-fatal fetch). Always re-fetch them so that plugin + // keys like "CSVBCKP_overwrite_description" are available without needing + // a full clearCache(). + fetchJson('table_plugins_language_strings.json') + .catch((pluginError) => { + console.warn('[cacheStrings early-return] Plugin language strings unavailable (non-fatal):', pluginError); + return []; + }) + .then((data) => { + if (!Array.isArray(data)) { data = []; } + data.forEach((langString) => { + setCache(CACHE_KEYS.langString(langString.String_Key, langString.Language_Code), langString.String_Value); + }); + resolve(); + }); return; } From 3d4aba4b397584ebf995eebc100b3c0109a498e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8E=8B=E5=8F=AB=E6=88=91=E6=9D=A5=E5=B7=A1?= =?UTF-8?q?=E5=B1=B1?= Date: Sat, 28 Feb 2026 14:21:04 +0100 Subject: [PATCH 038/122] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.3% (787 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/zh_Hans/ --- front/php/templates/language/zh_cn.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 07b824469..9c6adafa1 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -374,7 +374,7 @@ "Loading": "加载中…", "Login_Box": "输入密码", "Login_Default_PWD": "默认密码“123456”仍然有效。", - "Login_Info": "设置密码使用设置密码插件。如果您无法登录,请查看SETPWD文档。", + "Login_Info": "密码通过 Set Password 插件设置。如果有登录问题,请查看 SETPWD 文档 。", "Login_Psw-box": "密码", "Login_Psw_alert": "密码警报!", "Login_Psw_folder": "在配置文件夹中。", @@ -488,7 +488,7 @@ "Maintenance_arp_status_off": "当前已禁用", "Maintenance_arp_status_on": "正在运行扫描", "Maintenance_built_on": "建立于", - "Maintenance_current_version": "您已了解最新情况。查看我正在处理的内容。", + "Maintenance_current_version": "已是最新版。查看 我正在做什么。", "Maintenance_database_backup": "数据库备份", "Maintenance_database_backup_found": "找到备份", "Maintenance_database_backup_total": "总磁盘使用量", @@ -791,4 +791,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} \ No newline at end of file +} From 563cb4ba20e3bba34c84639c128d3f8173842953 Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 28 Feb 2026 06:01:19 +0100 Subject: [PATCH 039/122] Translated using Weblate (Portuguese (Brazil)) Currently translated at 51.3% (407 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/pt_BR/ --- front/php/templates/language/pt_br.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 1c76d93cd..6ca0c4459 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -791,4 +791,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} \ No newline at end of file +} From dbe65b2a275a327d6b8d5047e703f6dcfc0047fc Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 28 Feb 2026 06:01:20 +0100 Subject: [PATCH 040/122] Translated using Weblate (Turkish) Currently translated at 57.0% (452 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/tr/ --- front/php/templates/language/tr_tr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index bfcffcb41..c8d67e66a 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -791,4 +791,4 @@ "settings_system_label": "Sistem", "settings_update_item_warning": "", "test_event_tooltip": "" -} \ No newline at end of file +} From 247a967e9be3ac597d9d36aca3c5feed38a0febb Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 28 Feb 2026 06:01:18 +0100 Subject: [PATCH 041/122] Translated using Weblate (Polish) Currently translated at 85.6% (678 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/pl/ --- front/php/templates/language/pl_pl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 1b9449018..7199aab72 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -791,4 +791,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Zaktualizuj wartość poniżej. Uważaj, aby zachować poprzedni format. Walidacja nie jest wykonywana.", "test_event_tooltip": "Najpierw zapisz swoje zmiany, zanim przetestujesz ustawienia." -} \ No newline at end of file +} From a6ce4174fe4dcefcff1b341d4d85ce0d3e3fb1b0 Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 28 Feb 2026 06:01:18 +0100 Subject: [PATCH 042/122] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 70.5% (559 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/nb_NO/ --- front/php/templates/language/nb_no.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 568da04b8..13f37307e 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -791,4 +791,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Oppdater verdien nedenfor. Pass på å følge forrige format. Validering etterpå utføres ikke.", "test_event_tooltip": "Lagre endringene først, før du tester innstillingene dine." -} \ No newline at end of file +} From 66532c54a1c30c1768fa618909f896f15e713e95 Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 28 Feb 2026 06:01:14 +0100 Subject: [PATCH 043/122] Translated using Weblate (German) Currently translated at 79.6% (631 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/de/ --- front/php/templates/language/de_de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index b47c6243b..0d06f16bb 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -864,4 +864,4 @@ "settings_system_label": "System", "settings_update_item_warning": "", "test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen." -} \ No newline at end of file +} From c1adfd35f3a23c9a9820ae345d22b5d4b7b742ea Mon Sep 17 00:00:00 2001 From: Anonymous Date: Sat, 28 Feb 2026 06:01:12 +0100 Subject: [PATCH 044/122] Translated using Weblate (Arabic) Currently translated at 85.8% (680 of 792 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ar/ --- front/php/templates/language/ar_ar.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index d465e0769..13695c6d7 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -791,4 +791,4 @@ "settings_system_label": "نظام", "settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. لم يتم إجراء التحقق.", "test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات." -} \ No newline at end of file +} From ea5585a8efe94f0b1b6a81da5a110f95e640cc94 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:07:57 +0000 Subject: [PATCH 045/122] Add database cleanup for Sessions and optimize queries - Implemented deletion of Sessions older than DAYS_TO_KEEP_EVENTS. - Added index for Plugins_History to improve query performance. - Introduced unit tests for Sessions trimming and database analysis. --- front/plugins/db_cleanup/script.py | 19 +++ server/db/db_upgrade.py | 8 + test/db/test_db_cleanup.py | 257 +++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 test/db/test_db_cleanup.py diff --git a/front/plugins/db_cleanup/script.py b/front/plugins/db_cleanup/script.py index fcfaf4bc7..4d1c74e2c 100755 --- a/front/plugins/db_cleanup/script.py +++ b/front/plugins/db_cleanup/script.py @@ -96,6 +96,15 @@ def cleanup_database( cursor.execute(sql) mylog("verbose", [f"[{pluginName}] Events deleted rows: {cursor.rowcount}"]) + # ----------------------------------------------------- + # Sessions (derived snapshot — trimmed to the same window as Events so the + # two tables stay in sync without introducing a separate setting) + mylog("verbose", f"[{pluginName}] Sessions: Delete all older than {str(DAYS_TO_KEEP_EVENTS)} days (reuses DAYS_TO_KEEP_EVENTS)") + sql = f"""DELETE FROM Sessions WHERE ses_DateTimeConnection <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')""" + mylog("verbose", [f"[{pluginName}] SQL : {sql}"]) + cursor.execute(sql) + mylog("verbose", [f"[{pluginName}] Sessions deleted rows: {cursor.rowcount}"]) + # ----------------------------------------------------- # Plugins_History mylog("verbose", f"[{pluginName}] Plugins_History: Trim to {str(PLUGINS_KEEP_HIST)} per Plugin") @@ -199,9 +208,19 @@ def cleanup_database( cursor.execute("PRAGMA wal_checkpoint(FULL);") mylog("verbose", [f"[{pluginName}] WAL checkpoint executed to truncate file."]) + # Refresh query-planner statistics after bulk deletes so SQLite chooses + # the right indexes on the next scan cycle (fixes CPU scaling with DB size) + cursor.execute("ANALYZE;") + mylog("verbose", [f"[{pluginName}] ANALYZE completed"]) + mylog("verbose", [f"[{pluginName}] Shrink Database"]) cursor.execute("VACUUM;") + # Lightweight incremental ANALYZE at connection close — near-zero cost, + # only re-analyzes tables whose statistics have drifted >25% + cursor.execute("PRAGMA optimize;") + mylog("verbose", [f"[{pluginName}] PRAGMA optimize completed"]) + conn.close() diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index d3f18de27..8886b959e 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -405,6 +405,14 @@ def ensure_Indexes(sql) -> bool: "idx_plugins_plugin_mac_ip", "CREATE INDEX idx_plugins_plugin_mac_ip ON Plugins_Objects(Plugin, Object_PrimaryID, Object_SecondaryID)", ), # Issue #1251: Optimize name resolution lookup + # Plugins_History: covers both the db_cleanup window function + # (PARTITION BY Plugin ORDER BY DateTimeChanged DESC) and the + # API query (SELECT * … ORDER BY DateTimeChanged DESC). + # Without this, both ops do a full 48k-row table sort on every cycle. + ( + "idx_plugins_history_plugin_dt", + "CREATE INDEX idx_plugins_history_plugin_dt ON Plugins_History(Plugin, DateTimeChanged DESC)", + ), ] for name, create_sql in indexes: diff --git a/test/db/test_db_cleanup.py b/test/db/test_db_cleanup.py new file mode 100644 index 000000000..941161b37 --- /dev/null +++ b/test/db/test_db_cleanup.py @@ -0,0 +1,257 @@ +""" +Unit tests for db_cleanup plugin SQL logic. + +Covers: +- Sessions trim (reuses DAYS_TO_KEEP_EVENTS window) +- ANALYZE refreshes sqlite_stat1 after bulk deletes +- PRAGMA optimize runs without error + +Each test creates an isolated in-memory SQLite database so there is no +dependency on the running application or its config. +""" + +import sqlite3 +import os + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_db(): + """Return an in-memory connection seeded with the tables used by db_cleanup.""" + conn = sqlite3.connect(":memory:") + cur = conn.cursor() + + cur.execute(""" + CREATE TABLE Events ( + eve_MAC TEXT NOT NULL, + eve_IP TEXT NOT NULL, + eve_DateTime DATETIME NOT NULL, + eve_EventType TEXT NOT NULL, + eve_AdditionalInfo TEXT DEFAULT '', + eve_PendingAlertEmail INTEGER NOT NULL DEFAULT 1, + eve_PairEventRowid INTEGER + ) + """) + + cur.execute(""" + CREATE TABLE Sessions ( + ses_MAC TEXT, + ses_IP TEXT, + ses_EventTypeConnection TEXT, + ses_DateTimeConnection DATETIME, + ses_EventTypeDisconnection TEXT, + ses_DateTimeDisconnection DATETIME, + ses_StillConnected INTEGER, + ses_AdditionalInfo TEXT + ) + """) + + conn.commit() + return conn + + +def _seed_sessions(cur, old_count: int, recent_count: int, days: int): + """ + Insert `old_count` rows with connection date older than `days` days and + `recent_count` rows with connection date today. + """ + for i in range(old_count): + cur.execute( + "INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) " + "VALUES (?, date('now', ?))", + (f"AA:BB:CC:DD:EE:{i:02X}", f"-{days + 1} day"), + ) + for i in range(recent_count): + cur.execute( + "INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) " + "VALUES (?, date('now'))", + (f"11:22:33:44:55:{i:02X}",), + ) + + +def _run_sessions_trim(cur, days: int) -> int: + """Execute the exact DELETE used by db_cleanup and return rowcount.""" + cur.execute( + f"DELETE FROM Sessions " + f"WHERE ses_DateTimeConnection <= date('now', '-{days} day')" + ) + return cur.rowcount + + +# --------------------------------------------------------------------------- +# Sessions trim tests +# --------------------------------------------------------------------------- + +class TestSessionsTrim: + + def test_old_rows_are_deleted(self): + """Rows older than DAYS_TO_KEEP_EVENTS window must be removed.""" + conn = _make_db() + cur = conn.cursor() + _seed_sessions(cur, old_count=10, recent_count=5, days=30) + + deleted = _run_sessions_trim(cur, days=30) + + assert deleted == 10, f"Expected 10 old rows deleted, got {deleted}" + cur.execute("SELECT COUNT(*) FROM Sessions") + remaining = cur.fetchone()[0] + assert remaining == 5, f"Expected 5 recent rows to survive, got {remaining}" + + def test_recent_rows_are_preserved(self): + """Rows within the retention window must not be touched.""" + conn = _make_db() + cur = conn.cursor() + _seed_sessions(cur, old_count=0, recent_count=20, days=30) + + deleted = _run_sessions_trim(cur, days=30) + + assert deleted == 0, f"Expected 0 deletions, got {deleted}" + cur.execute("SELECT COUNT(*) FROM Sessions") + assert cur.fetchone()[0] == 20 + + def test_empty_table_is_a_no_op(self): + """Trim against an empty Sessions table must not raise.""" + conn = _make_db() + cur = conn.cursor() + + deleted = _run_sessions_trim(cur, days=30) + + assert deleted == 0 + + def test_trim_is_bounded_by_days_parameter(self): + """Only rows strictly outside the window are removed; boundary row survives.""" + conn = _make_db() + cur = conn.cursor() + # Row exactly AT the boundary (date = 'now' - days exactly) + cur.execute( + "INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) " + "VALUES (?, date('now', ?))", + ("AA:BB:CC:00:00:01", "-30 day"), + ) + # Row just inside the window + cur.execute( + "INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) " + "VALUES (?, date('now', '-29 day'))", + ("AA:BB:CC:00:00:02",), + ) + + _run_sessions_trim(cur, days=30) + + cur.execute("SELECT ses_MAC FROM Sessions") + remaining_macs = {row[0] for row in cur.fetchall()} + # Boundary row (== threshold) is deleted; inside row survives + assert "AA:BB:CC:00:00:02" in remaining_macs, "Row inside window was wrongly deleted" + + def test_sessions_trim_uses_same_value_as_events(self): + """ + Regression: verify that the Sessions DELETE uses an identical day-offset + expression to the Events DELETE so the two tables stay aligned. + """ + + INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") + script_path = os.path.join( + INSTALL_PATH, "front", "plugins", "db_cleanup", "script.py" + ) + with open(script_path) as fh: + source = fh.read() + + events_expr = "DELETE FROM Events WHERE eve_DateTime <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')" + sessions_expr = "DELETE FROM Sessions WHERE ses_DateTimeConnection <= date('now', '-{str(DAYS_TO_KEEP_EVENTS)} day')" + + assert events_expr in source, "Events DELETE expression changed unexpectedly" + assert sessions_expr in source, "Sessions DELETE is not aligned with Events DELETE" + + +# --------------------------------------------------------------------------- +# ANALYZE tests +# --------------------------------------------------------------------------- + +class TestAnalyze: + + def test_analyze_populates_sqlite_stat1(self): + """ + After ANALYZE, sqlite_stat1 must exist and have at least one row + for the Events table (which has an implicit rowid index). + """ + conn = _make_db() + cur = conn.cursor() + + # Seed some rows so ANALYZE has something to measure + for i in range(20): + cur.execute( + "INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType) " + "VALUES (?, '1.2.3.4', date('now'), 'Connected')", + (f"AA:BB:CC:DD:EE:{i:02X}",), + ) + conn.commit() + + cur.execute("ANALYZE;") + conn.commit() + + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_stat1'") + assert cur.fetchone() is not None, "sqlite_stat1 table not created by ANALYZE" + + def test_analyze_does_not_raise_on_empty_tables(self): + """ANALYZE against empty tables must complete without exceptions.""" + conn = _make_db() + cur = conn.cursor() + + # Should not raise + cur.execute("ANALYZE;") + conn.commit() + + def test_analyze_is_idempotent(self): + """Running ANALYZE twice must not raise or corrupt state.""" + conn = _make_db() + cur = conn.cursor() + + cur.execute("ANALYZE;") + cur.execute("ANALYZE;") + conn.commit() + + +# --------------------------------------------------------------------------- +# PRAGMA optimize tests +# --------------------------------------------------------------------------- + +class TestPragmaOptimize: + + def test_pragma_optimize_does_not_raise(self): + """PRAGMA optimize must complete without exceptions.""" + conn = _make_db() + cur = conn.cursor() + + # Run ANALYZE first (as db_cleanup does) then optimize + cur.execute("ANALYZE;") + cur.execute("PRAGMA optimize;") + conn.commit() + + def test_pragma_optimize_after_bulk_delete(self): + """ + PRAGMA optimize after a bulk DELETE (simulating db_cleanup) must + complete without error, validating the full tail sequence. + """ + conn = _make_db() + cur = conn.cursor() + + for i in range(50): + cur.execute( + "INSERT INTO Sessions (ses_MAC, ses_DateTimeConnection) " + "VALUES (?, date('now', '-60 day'))", + (f"AA:BB:CC:DD:EE:{i:02X}",), + ) + conn.commit() + + # Mirror the tail sequence from cleanup_database. + # WAL checkpoints are omitted: they require no open transaction and are + # not supported on :memory: databases (SQLite raises OperationalError). + cur.execute("DELETE FROM Sessions WHERE ses_DateTimeConnection <= date('now', '-30 day')") + conn.commit() + cur.execute("ANALYZE;") + conn.execute("VACUUM;") + cur.execute("PRAGMA optimize;") + + cur.execute("SELECT COUNT(*) FROM Sessions") + assert cur.fetchone()[0] == 0 From 584aba2c7b08ae3db656652bea8eb3a251561929 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:19:39 +0000 Subject: [PATCH 046/122] Set journal size limit to 10 MB for SQLite connections to prevent unbounded WAL growth --- server/database.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/database.py b/server/database.py index 7bded6dc6..477cfc2b4 100755 --- a/server/database.py +++ b/server/database.py @@ -75,6 +75,10 @@ def open(self): # When temp_store is MEMORY (2) temporary tables and indices # are kept as if they were in pure in-memory databases. self.sql_connection.execute("PRAGMA temp_store=MEMORY;") + # WAL size limit: cap at 10 MB. When approached, SQLite auto-checkpoints + # even if other connections are active. Prevents unbounded WAL growth + # on systems with multiple long-lived processes (backend, nginx, PHP-FPM). + self.sql_connection.execute("PRAGMA journal_size_limit=10000000;") self.sql_connection.text_factory = str self.sql_connection.row_factory = sqlite3.Row @@ -330,5 +334,6 @@ def get_temp_db_connection(): conn = sqlite3.connect(fullDbPath, timeout=5, isolation_level=None) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA busy_timeout=5000;") # 5s wait before giving up + conn.execute("PRAGMA journal_size_limit=10000000;") # 10 MB WAL cap with auto-checkpoint conn.row_factory = sqlite3.Row return conn From d9608b47608fbdf46b51ad2896a138b9463af59d Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:43:07 +0000 Subject: [PATCH 047/122] Add database performance tuning guidelines and user-configurable WAL size limit --- docs/PERFORMANCE.md | 30 +++++++++++++++++++++++++ front/php/templates/language/en_us.json | 2 ++ server/database.py | 20 ++++++++++++++--- server/initialise.py | 9 ++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index d7f4f0a74..194c9c6e7 100755 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -48,6 +48,36 @@ Two plugins help maintain the system’s performance: --- +## Database Performance Tuning + +The application automatically maintains database performance as data accumulates. However, you can adjust settings to balance CPU usage, disk usage, and responsiveness. + +### **WAL Size Tuning (Storage vs. CPU Tradeoff)** + +The SQLite Write-Ahead Log (WAL) is a temporary file that grows during normal operation. On systems with constrained resources (NAS, Raspberry Pi), controlling WAL size is important. + +**Setting:** **Settings → General → "WAL size limit (MB)"** (default: **50 MB**) + +| Setting | Effect | Use Case | +|---------|--------|----------| +| **10–20 MB** | Smaller storage footprint; more frequent disk operations | NAS with SD card (storage priority) | +| **50 MB** (default) | Balanced; recommended for most setups | General use | +| **75–100 MB** | Smoother performance; larger WAL on disk | High-speed NAS or servers | + +**Recommendation:** For NAS devices with SD cards, leave at default (50 MB) or increase slightly (75 MB). Avoid very low values (< 10 MB) as they cause frequent disk thrashing and CPU spikes. + +### **Automatic Cleanup** + +The DB cleanup plugin (`DBCLNP`) automatically optimizes query performance and trims old data: + +- **Deletes old events** – Controlled by `DAYS_TO_KEEP_EVENTS` (default: 90 days) +- **Trims plugin history** – Keeps recent entries only (controlled by `PLUGINS_KEEP_HIST`) +- **Optimizes queries** – Updates database statistics so queries remain fast + +**If cleanup fails**, performance degrades quickly. Check **Maintenance → Logs** for errors. If you see frequent failures, increase the timeout (`DBCLNP_RUN_TIMEOUT`). + +--- + ## Scan Frequency and Coverage Frequent scans increase resource usage, network traffic, and database read/write cycles. diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 9f2d6af10..3de491375 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -63,6 +63,8 @@ "BackDevices_darkmode_enabled": "Darkmode Enabled", "CLEAR_NEW_FLAG_description": "If enabled (0 is disabled), devices flagged as New Device will be unflagged if the time limit (specified in hours) exceeds their First Session time.", "CLEAR_NEW_FLAG_name": "Clear new flag", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL (Write-Ahead Log) maximum size in MB before triggering automatic checkpoints. Lower values (10-20 MB) reduce disk/storage usage but increase CPU usage during scans. Higher values (50-100 MB) reduce CPU spikes during operations but may use more RAM and disk space. Default 50 MB balances both. Useful for resource-constrained systems like NAS devices with SD cards.", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "WAL size limit (MB)", "CustProps_cant_remove": "Can't remove, at least one property is needed.", "DAYS_TO_KEEP_EVENTS_description": "This is a maintenance setting. This specifies the number of days worth of event entries that will be kept. All older events will be deleted periodically. Also applies on Plugin Events History.", "DAYS_TO_KEEP_EVENTS_name": "Delete events older than", diff --git a/server/database.py b/server/database.py index 477cfc2b4..405c47a8e 100755 --- a/server/database.py +++ b/server/database.py @@ -75,10 +75,17 @@ def open(self): # When temp_store is MEMORY (2) temporary tables and indices # are kept as if they were in pure in-memory databases. self.sql_connection.execute("PRAGMA temp_store=MEMORY;") - # WAL size limit: cap at 10 MB. When approached, SQLite auto-checkpoints + # WAL size limit: auto-checkpoint when WAL approaches this size, # even if other connections are active. Prevents unbounded WAL growth # on systems with multiple long-lived processes (backend, nginx, PHP-FPM). - self.sql_connection.execute("PRAGMA journal_size_limit=10000000;") + # User-configurable via PRAGMA_JOURNAL_SIZE_LIMIT setting (default 50 MB). + try: + from helper import get_setting_value + wal_limit_mb = int(get_setting_value("PRAGMA_JOURNAL_SIZE_LIMIT", "50")) + wal_limit_bytes = wal_limit_mb * 1000000 + except Exception: + wal_limit_bytes = 50000000 # 50 MB fallback + self.sql_connection.execute(f"PRAGMA journal_size_limit={wal_limit_bytes};") self.sql_connection.text_factory = str self.sql_connection.row_factory = sqlite3.Row @@ -334,6 +341,13 @@ def get_temp_db_connection(): conn = sqlite3.connect(fullDbPath, timeout=5, isolation_level=None) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA busy_timeout=5000;") # 5s wait before giving up - conn.execute("PRAGMA journal_size_limit=10000000;") # 10 MB WAL cap with auto-checkpoint + # Apply user-configured WAL size limit (default 50 MB in initialise.py) + try: + from helper import get_setting_value + wal_limit_mb = int(get_setting_value("PRAGMA_JOURNAL_SIZE_LIMIT", "50")) + wal_limit_bytes = wal_limit_mb * 1000000 + except Exception: + wal_limit_bytes = 50000000 # 50 MB fallback + conn.execute(f"PRAGMA journal_size_limit={wal_limit_bytes};") conn.row_factory = sqlite3.Row return conn diff --git a/server/initialise.py b/server/initialise.py index b11eb40a7..afed6dd8d 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -341,6 +341,15 @@ def importConfigs(pm, db, all_plugins): "[]", "General", ) + conf.PRAGMA_JOURNAL_SIZE_LIMIT = ccd( + "PRAGMA_JOURNAL_SIZE_LIMIT", + 50, + c_d, + "WAL size limit (MB)", + '{"dataType":"integer", "elements": [{"elementType" : "input", "elementOptions" : [{"type": "number"}] ,"transformers": []}]}', + "[]", + "General", + ) conf.REFRESH_FQDN = ccd( "REFRESH_FQDN", False, From a94f3d722277e566ffe4a70e9e8b3125b9340435 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sun, 1 Mar 2026 17:47:15 +1100 Subject: [PATCH 048/122] DOCS: PRAGMA_JOURNAL_SIZE_LIMIT Signed-off-by: jokob-sk --- docs/PERFORMANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 194c9c6e7..922ad923d 100755 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -56,7 +56,7 @@ The application automatically maintains database performance as data accumulates The SQLite Write-Ahead Log (WAL) is a temporary file that grows during normal operation. On systems with constrained resources (NAS, Raspberry Pi), controlling WAL size is important. -**Setting:** **Settings → General → "WAL size limit (MB)"** (default: **50 MB**) +**Setting:** **`PRAGMA_JOURNAL_SIZE_LIMIT`** (default: **50 MB**) | Setting | Effect | Use Case | |---------|--------|----------| From deb0d16c3d80eeb9f5e3459796c15cd2e6cd42a3 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sun, 1 Mar 2026 18:38:38 +1100 Subject: [PATCH 049/122] LNG: PRAGMA_JOURNAL_SIZE_LIMIT Signed-off-by: jokob-sk --- front/php/templates/language/ar_ar.json | 4 +++- front/php/templates/language/ca_ca.json | 2 ++ front/php/templates/language/cs_cz.json | 2 ++ front/php/templates/language/de_de.json | 4 +++- front/php/templates/language/en_us.json | 4 ++-- front/php/templates/language/es_es.json | 2 ++ front/php/templates/language/fa_fa.json | 2 ++ front/php/templates/language/fr_fr.json | 2 ++ front/php/templates/language/it_it.json | 2 ++ front/php/templates/language/ja_jp.json | 2 ++ front/php/templates/language/nb_no.json | 4 +++- front/php/templates/language/pl_pl.json | 4 +++- front/php/templates/language/pt_br.json | 4 +++- front/php/templates/language/pt_pt.json | 2 ++ front/php/templates/language/ru_ru.json | 2 ++ front/php/templates/language/sv_sv.json | 2 ++ front/php/templates/language/tr_tr.json | 4 +++- front/php/templates/language/uk_ua.json | 2 ++ front/php/templates/language/vi_vn.json | 2 ++ front/php/templates/language/zh_cn.json | 4 +++- 20 files changed, 47 insertions(+), 9 deletions(-) diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 13695c6d7..f56b4d5a2 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "حماية الويب", "PLUGINS_KEEP_HIST_description": "الاحتفاظ بسجل المكونات الإضافية", "PLUGINS_KEEP_HIST_name": "سجل المكونات الإضافية", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "حذف الكل", "Plugins_Filters_Mac": "تصفية عنوان MAC", "Plugins_History": "السجل", @@ -791,4 +793,4 @@ "settings_system_label": "نظام", "settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. لم يتم إجراء التحقق.", "test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 8bd4b4225..913751a70 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Activa l'accés", "PLUGINS_KEEP_HIST_description": "Quantes entrades de Plugins s'han de mantenir a la història (per Plugin, no per dispositiu).", "PLUGINS_KEEP_HIST_name": "Història dels Plugins", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Elimina tot (s'ignoraran els filtres)", "Plugins_Filters_Mac": "Filtre de MAC", "Plugins_History": "Historial d'Esdeveniments", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index 8d782439a..6218deaf8 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 0d06f16bb..e389972ec 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -608,6 +608,8 @@ "PIALERT_WEB_PROTECTION_name": "Anmeldung aktivieren", "PLUGINS_KEEP_HIST_description": "Wie viele Plugin Scanresultate behalten werden (pro Plugin, nicht gerätespezifisch).", "PLUGINS_KEEP_HIST_name": "Plugins Verlauf", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "PUSHSAFER_TOKEN_description": "Your secret Pushsafer API key (token).", "PUSHSAFER_TOKEN_name": "Pushsafer token", "PUSHSAFER_display_name": "Pushsafer", @@ -864,4 +866,4 @@ "settings_system_label": "System", "settings_update_item_warning": "", "test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen." -} +} \ No newline at end of file diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 3de491375..56407e163 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -63,8 +63,6 @@ "BackDevices_darkmode_enabled": "Darkmode Enabled", "CLEAR_NEW_FLAG_description": "If enabled (0 is disabled), devices flagged as New Device will be unflagged if the time limit (specified in hours) exceeds their First Session time.", "CLEAR_NEW_FLAG_name": "Clear new flag", - "PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL (Write-Ahead Log) maximum size in MB before triggering automatic checkpoints. Lower values (10-20 MB) reduce disk/storage usage but increase CPU usage during scans. Higher values (50-100 MB) reduce CPU spikes during operations but may use more RAM and disk space. Default 50 MB balances both. Useful for resource-constrained systems like NAS devices with SD cards.", - "PRAGMA_JOURNAL_SIZE_LIMIT_name": "WAL size limit (MB)", "CustProps_cant_remove": "Can't remove, at least one property is needed.", "DAYS_TO_KEEP_EVENTS_description": "This is a maintenance setting. This specifies the number of days worth of event entries that will be kept. All older events will be deleted periodically. Also applies on Plugin Events History.", "DAYS_TO_KEEP_EVENTS_name": "Delete events older than", @@ -581,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Enable login", "PLUGINS_KEEP_HIST_description": "How many entries of Plugins History scan results should be kept (per Plugin, and not device specific).", "PLUGINS_KEEP_HIST_name": "Plugins History", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL (Write-Ahead Log) maximum size in MB before triggering automatic checkpoints. Lower values (10-20 MB) reduce disk/storage usage but increase CPU usage during scans. Higher values (50-100 MB) reduce CPU spikes during operations but may use more RAM and disk space. Default 50 MB balances both. Useful for resource-constrained systems like NAS devices with SD cards. Restart server for changes to take effect after saving the settings.", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "WAL size limit (MB)", "Plugins_DeleteAll": "Delete all (filters are ignored)", "Plugins_Filters_Mac": "Mac Filter", "Plugins_History": "Events History", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 997315220..b0cf34db1 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -606,6 +606,8 @@ "PIALERT_WEB_PROTECTION_name": "Habilitar inicio de sesión", "PLUGINS_KEEP_HIST_description": "¿Cuántas entradas de los resultados del análisis del historial de complementos deben conservarse (globalmente, no específico del dispositivo!).", "PLUGINS_KEEP_HIST_name": "Historial de complementos", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "PUSHSAFER_TOKEN_description": "Su clave secreta de la API de Pushsafer (token).", "PUSHSAFER_TOKEN_name": "Token de Pushsafer", "PUSHSAFER_display_name": "Pushsafer", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 62cac6f4f..977f5ed00 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index cccf206fb..45967273a 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Activer la connexion par login", "PLUGINS_KEEP_HIST_description": "Combien d'entrées de résultats de scan doivent être conservés dans l'historique des plugins (par plugin, pas par appareil).", "PLUGINS_KEEP_HIST_name": "Historique des plugins", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Tout supprimer (ne prend pas en compte les filtres)", "Plugins_Filters_Mac": "Filtrer par MAC", "Plugins_History": "Historique des événements", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index ffc7d8080..537d31bea 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Abilita login", "PLUGINS_KEEP_HIST_description": "Quante voci dei risultati della scansione della cronologia dei plugin devono essere conservate (per plugin e non per dispositivo specifico).", "PLUGINS_KEEP_HIST_name": "Storico plugin", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Elimina tutti (i filtri vengono ignorati)", "Plugins_Filters_Mac": "Filtro MAC", "Plugins_History": "Storico eventi", diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index c9b8681c9..7dfef8c71 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "ログインを有効化", "PLUGINS_KEEP_HIST_description": "プラグイン履歴スキャン結果のエントリをいくつ保持すべきか(デバイス固有ではなく、プラグインごとに)。", "PLUGINS_KEEP_HIST_name": "プラグイン履歴", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "すべて削除(フィルターは無視されます)", "Plugins_Filters_Mac": "Macフィルター", "Plugins_History": "イベント履歴", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 13f37307e..f598d18e3 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Aktiver innlogging", "PLUGINS_KEEP_HIST_description": "Hvor mange oppføringer av plugins historie skanneresultater som skal oppbevares (per plugin, og ikke enhetsspesifikt).", "PLUGINS_KEEP_HIST_name": "Plugins historie", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Slett alle (filtre blir ignorert)", "Plugins_Filters_Mac": "Mac filter", "Plugins_History": "Hendelses historikk", @@ -791,4 +793,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Oppdater verdien nedenfor. Pass på å følge forrige format. Validering etterpå utføres ikke.", "test_event_tooltip": "Lagre endringene først, før du tester innstillingene dine." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 7199aab72..c358bcd42 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Włącz logowanie", "PLUGINS_KEEP_HIST_description": "Ile wpisów wyników skanowania historii wtyczek powinno być przechowywanych (dla każdej wtyczki, a nie specyficznie dla urządzenia).", "PLUGINS_KEEP_HIST_name": "Historia wtyczek", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Usuń wszystkie (filtry są ignorowane)", "Plugins_Filters_Mac": "Filtr MAC", "Plugins_History": "Historia zdarzeń", @@ -791,4 +793,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Zaktualizuj wartość poniżej. Uważaj, aby zachować poprzedni format. Walidacja nie jest wykonywana.", "test_event_tooltip": "Najpierw zapisz swoje zmiany, zanim przetestujesz ustawienia." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 6ca0c4459..dfa61fc9a 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", @@ -791,4 +793,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index aa0bacf3d..eed329f3d 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 0d1c39193..0c85c6ee5 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Включить вход", "PLUGINS_KEEP_HIST_description": "Сколько записей результатов сканирования истории плагинов следует хранить (для каждого плагина, а не для конкретного устройства).", "PLUGINS_KEEP_HIST_name": "История плагинов", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Удалить все (фильтры игнорируются)", "Plugins_Filters_Mac": "Фильтр MAC-адреса", "Plugins_History": "История событий", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index bad46da06..78e910399 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index c8d67e66a..5cae0ca6e 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", @@ -791,4 +793,4 @@ "settings_system_label": "Sistem", "settings_update_item_warning": "", "test_event_tooltip": "" -} +} \ No newline at end of file diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 7ce116da2..448280fcb 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Увімкнути вхід", "PLUGINS_KEEP_HIST_description": "Скільки записів результатів сканування історії плагінів слід зберігати (для кожного плагіна, а не для конкретного пристрою).", "PLUGINS_KEEP_HIST_name": "Історія плагінів", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "Видалити все (фільтри ігноруються)", "Plugins_Filters_Mac": "Фільтр Mac", "Plugins_History": "Історія подій", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index bad46da06..78e910399 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "", "PLUGINS_KEEP_HIST_description": "", "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "", "Plugins_Filters_Mac": "", "Plugins_History": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 9c6adafa1..935217606 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -579,6 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "启用登录", "PLUGINS_KEEP_HIST_description": "应保留多少个插件历史扫描结果条目(每个插件,而不是特定于设备)。", "PLUGINS_KEEP_HIST_name": "插件历史", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", "Plugins_DeleteAll": "全部删除(忽略过滤器)", "Plugins_Filters_Mac": "Mac 过滤器", "Plugins_History": "事件历史", @@ -791,4 +793,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} +} \ No newline at end of file From 8e6efc30089b43e418d0ccd9d2c0dc3ded715882 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:35:07 +0000 Subject: [PATCH 050/122] sleeping devices status #1519 --- docs/DATABASE.md | 7 +++ docs/DEVICE_DISPLAY_SETTINGS.md | 1 + docs/NOTIFICATIONS.md | 6 ++- front/deviceDetailsEdit.php | 3 +- front/js/network-api.js | 11 ++-- front/js/network-init.js | 3 +- front/js/network-tabs.js | 7 ++- front/js/network-tree.js | 5 +- front/js/ui_components.js | 15 ++++-- front/plugins/newdev_template/config.json | 35 +++++++++++++ .../notification_processing/config.json | 28 ++++++++++ front/workflowsCore.php | 2 +- scripts/generate-device-inventory.py | 2 + server/api_server/graphql_endpoint.py | 12 ++++- server/api_server/openapi/schemas.py | 21 ++++++-- server/database.py | 8 +-- server/db/db_helper.py | 3 +- server/db/db_upgrade.py | 51 +++++++++++++++---- server/initialise.py | 7 +++ server/models/device_instance.py | 28 +++++----- server/scan/device_handling.py | 4 +- server/scan/session_events.py | 7 +-- 22 files changed, 211 insertions(+), 55 deletions(-) diff --git a/docs/DATABASE.md b/docs/DATABASE.md index b93dc6ef6..46aac1458 100755 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -23,6 +23,7 @@ | `devLogEvents` | Whether events related to the device should be logged. | `0` | | `devAlertEvents` | Whether alerts should be generated for events. | `1` | | `devAlertDown` | Whether an alert should be sent when the device goes down. | `0` | +| `devCanSleep` | Whether the device can enter a sleep window. When `1`, offline periods within the `NTFPRCS_sleep_time` window are shown as **Sleeping** instead of **Down** and no down alert is fired. | `0` | | `devSkipRepeated` | Whether to skip repeated alerts for this device. | `1` | | `devLastNotification` | Timestamp of the last notification sent for this device. | `2025-03-22 12:07:26+11:00` | | `devPresentLastScan` | Whether the device was present during the last scan. | `1` | @@ -42,6 +43,12 @@ | `devParentRelType` | The type of relationship between the current device and it's parent node. By default, selecting `nic` will hide it from lists. | `nic` | | `devReqNicsOnline` | If all NICs are required to be online to mark teh current device online. | `0` | +> [!NOTE] +> `DevicesView` extends the `Devices` table with two computed fields that are never persisted: +> - `devIsSleeping` (`1` when `devCanSleep=1`, device is offline, and `devLastConnection` is within the `NTFPRCS_sleep_time` window). +> - `devFlapping` (`1` when the device has changed state more than the flap threshold times in the trailing window). +> - `devStatus` — derived string: `On-line`, `Sleeping`, `Down`, or `Off-line`. + To understand how values of these fields influuence application behavior, such as Notifications or Network topology, see also: diff --git a/docs/DEVICE_DISPLAY_SETTINGS.md b/docs/DEVICE_DISPLAY_SETTINGS.md index 9bddf6d87..6a3d5b85c 100755 --- a/docs/DEVICE_DISPLAY_SETTINGS.md +++ b/docs/DEVICE_DISPLAY_SETTINGS.md @@ -15,6 +15,7 @@ This set of settings allows you to group Devices under different views. The Arch | | Online (Orange) | ![Status color - flapping online](./img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png) | The device is online, but unstable and flapping (3 status changes in the last hour). | | | New (Grey) | ![Status color - new offline](./img/DEVICE_MANAGEMENT/device_management_status_new_offline.png) | Same as "New (Green)" but the device is now offline. | | | Offline (Grey) | ![Status color - offline](./img/DEVICE_MANAGEMENT/device_management_status_offline.png) | A device that was not detected online in the last scan. | +| | Sleeping (Aqua) | | A device with **Can Sleep** enabled that has gone offline within the `NTFPRCS_sleep_time` window. No down alert is fired while the device is in this state. See [Notifications](./NOTIFICATIONS.md#device-settings). | | | Down (Red) | ![Status color - down](./img/DEVICE_MANAGEMENT/device_management_status_down.png) | A device marked as "Alert Down" and offline for the duration set in `NTFPRCS_alert_down_time`.| diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 60052a145..c6a90f586 100755 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -19,8 +19,9 @@ The following device properties influence notifications. You can: 1. **Alert Events** - Enables alerts of connections, disconnections, IP changes (down and down reconnected notifications are still sent even if this is disabled). 2. **Alert Down** - Alerts when a device goes down. This setting overrides a disabled **Alert Events** setting, so you will get a notification of a device going down even if you don't have **Alert Events** ticked. Disabling this will disable down and down reconnected notifications on the device. -3. **Skip repeated notifications**, if for example you know there is a temporary issue and want to pause the same notification for this device for a given time. -4. **Require NICs Online** - Indicates whether this device should be considered online only if all associated NICs (devices with the `nic` relationship type) are online. If disabled, the device is considered online if any NIC is online. If a NIC is online it sets the parent (this) device's status to online irrespectivelly of the detected device's status. The Relationship type is set on the childern device. +3. **Can Sleep** - Marks the device as sleep-capable (e.g. a battery-powered sensor that deep-sleeps between readings). When enabled, offline periods within the **Alert down after (sleep)** (`NTFPRCS_sleep_time`) global window are shown as **Sleeping** (aqua badge 🌙) instead of **Down**, and no down alert is fired during that window. Once the window expires the device falls back to normal down-alert logic. ⚠ Requires **Alert Down** to be enabled — sleeping suppresses the alert during the window only. +4. **Skip repeated notifications**, if for example you know there is a temporary issue and want to pause the same notification for this device for a given time. +5. **Require NICs Online** - Indicates whether this device should be considered online only if all associated NICs (devices with the `nic` relationship type) are online. If disabled, the device is considered online if any NIC is online. If a NIC is online it sets the parent (this) device's status to online irrespectivelly of the detected device's status. The Relationship type is set on the childern device. > [!NOTE] > Please read through the [NTFPRCS plugin](https://github.com/netalertx/NetAlertX/blob/main/front/plugins/notification_processing/README.md) documentation to understand how device and global settings influence the notification processing. @@ -44,6 +45,7 @@ In Notification Processing settings, you can specify blanket rules. These allow 1. Notify on (`NTFPRCS_INCLUDED_SECTIONS`) allows you to specify which events trigger notifications. Usual setups will have `new_devices`, `down_devices`, and possibly `down_reconnected` set. Including `plugin` (dependenton the Plugin `_WATCH` and `_REPORT_ON` settings) and `events` (dependent on the on-device **Alert Events** setting) might be too noisy for most setups. More info in the [NTFPRCS plugin](https://github.com/netalertx/NetAlertX/blob/main/front/plugins/notification_processing/README.md) on what events these selections include. 2. Alert down after (`NTFPRCS_alert_down_time`) is useful if you want to wait for some time before the system sends out a down notification for a device. This is related to the on-device **Alert down** setting and only devices with this checked will trigger a down notification. +3. Alert down after (sleep) (`NTFPRCS_sleep_time`) sets the **sleep window** in minutes. If a device has **Can Sleep** enabled and goes offline, it is shown as **Sleeping** (aqua 🌙 badge) for this many minutes before down-alert logic kicks in. Default is `30` minutes. Changing this setting takes effect after saving — no restart required. You can filter out unwanted notifications globally. This could be because of a misbehaving device (GoogleNest/GoogleHub (See also [ARPSAN docs and the `--exclude-broadcast` flag](https://github.com/netalertx/NetAlertX/tree/main/front/plugins/arp_scan#ip-flipping-on-google-nest-devices))) which flips between IP addresses, or because you want to ignore new device notifications of a certain pattern. diff --git a/front/deviceDetailsEdit.php b/front/deviceDetailsEdit.php index 2ff373969..542f2a595 100755 --- a/front/deviceDetailsEdit.php +++ b/front/deviceDetailsEdit.php @@ -138,7 +138,7 @@ function getDeviceData() { }, // Group for event and alert settings DevDetail_EveandAl_Title: { - data: ["devAlertEvents", "devAlertDown", "devSkipRepeated", "devReqNicsOnline", "devChildrenNicsDynamic", "devForceStatus"], + data: ["devAlertEvents", "devAlertDown", "devCanSleep", "devSkipRepeated", "devReqNicsOnline", "devChildrenNicsDynamic", "devForceStatus"], docs: "https://docs.netalertx.com/NOTIFICATIONS", iconClass: "fa fa-bell", inputGroupClasses: "field-group alert-group col-lg-4 col-sm-6 col-xs-12", @@ -447,6 +447,7 @@ function setDeviceData(direction = '', refreshCallback = '') { devAlertEvents: ($('#NEWDEV_devAlertEvents')[0].checked * 1), devAlertDown: ($('#NEWDEV_devAlertDown')[0].checked * 1), + devCanSleep: ($('#NEWDEV_devCanSleep')[0].checked * 1), devSkipRepeated: $('#NEWDEV_devSkipRepeated').val().split(' ')[0], devForceStatus: $('#NEWDEV_devForceStatus').val().replace(/'/g, ""), diff --git a/front/js/network-api.js b/front/js/network-api.js index 4ed82e555..01addaefa 100644 --- a/front/js/network-api.js +++ b/front/js/network-api.js @@ -24,6 +24,7 @@ function loadNetworkNodes() { parent.devIcon AS node_icon, parent.devAlertDown AS node_alert, parent.devFlapping AS node_flapping, + parent.devIsSleeping AS node_sleeping, COUNT(child.devMac) AS node_ports_count FROM DevicesView AS parent LEFT JOIN DevicesView AS child @@ -34,7 +35,7 @@ function loadNetworkNodes() { WHERE parent.devType IN (${networkDeviceTypes}) AND parent.devIsArchived = 0 GROUP BY parent.devMac, parent.devName, parent.devPresentLastScan, - parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown, parent.devFlapping + parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown, parent.devFlapping, parent.devIsSleeping ORDER BY parent.devName; `; @@ -146,7 +147,8 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, device.devAlertDown, device.devFlapping, device.devMac, - device.devStatus + device.devStatus, + device.devIsSleeping || 0 ); return `${badge.iconHtml} ${badge.text}`; } @@ -204,7 +206,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, */ function loadUnassignedDevices() { const sql = ` - SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort, devFlapping, devStatus + SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort, devFlapping, devIsSleeping, devStatus FROM DevicesView WHERE (devParentMAC IS NULL OR devParentMAC IN ("", " ", "undefined", "null")) AND LOWER(devMac) NOT LIKE "%internet%" @@ -239,10 +241,11 @@ function loadConnectedDevices(node_mac) { const normalized_mac = node_mac.toLowerCase(); const sql = ` - SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, devFlapping, + SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, devFlapping, devIsSleeping, CASE WHEN devIsNew = 1 THEN 'New' WHEN devPresentLastScan = 1 THEN 'On-line' + WHEN devIsSleeping = 1 THEN 'Sleeping' WHEN devPresentLastScan = 0 AND devAlertDown != 0 THEN 'Down' WHEN devIsArchived = 1 THEN 'Archived' WHEN devPresentLastScan = 0 THEN 'Off-line' diff --git a/front/js/network-init.js b/front/js/network-init.js index b61ec9fe9..173b0a891 100644 --- a/front/js/network-init.js +++ b/front/js/network-init.js @@ -24,8 +24,9 @@ function initNetworkTopology() { LOWER(devMac) AS devMac, LOWER(devParentMAC) AS devParentMAC, CASE - WHEN devAlertDown != 0 AND devPresentLastScan = 0 THEN 'Down' WHEN devPresentLastScan = 1 THEN 'On-line' + WHEN devIsSleeping = 1 THEN 'Sleeping' + WHEN devAlertDown != 0 AND devPresentLastScan = 0 THEN 'Down' ELSE 'Off-line' END AS devStatus, CASE diff --git a/front/js/network-tabs.js b/front/js/network-tabs.js index f358a1ced..d995da313 100644 --- a/front/js/network-tabs.js +++ b/front/js/network-tabs.js @@ -9,7 +9,8 @@ function renderNetworkTabs(nodes) { let html = ''; nodes.forEach((node, i) => { const iconClass = node.online == 1 ? "text-green" : - (node.node_alert == 1 ? "text-red" : "text-gray50"); + (node.node_sleeping == 1 ? "text-aqua" : + (node.node_alert == 1 ? "text-red" : "text-gray50")); const portLabel = node.node_ports_count ? ` (${node.node_ports_count})` : ''; const icon = atob(node.node_icon); @@ -55,7 +56,9 @@ function renderNetworkTabContent(nodes) { node.online, node.node_alert, node.node_flapping, - node.node_mac + node.node_mac, + '', + node.node_sleeping || 0 ); const badgeHtml = `${badge.iconHtml} ${badge.status}`; diff --git a/front/js/network-tree.js b/front/js/network-tree.js index a20c3a247..9e67cbceb 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -74,6 +74,7 @@ function getChildren(node, list, path, visited = []) presentLastScan: node.devPresentLastScan, flapping: node.devFlapping, alertDown: node.devAlertDown, + sleeping: node.devIsSleeping || 0, hasChildren: children.length > 0 || hiddenMacs.includes(node.devMac), relType: node.devParentRelType, devVlan: node.devVlan, @@ -272,7 +273,8 @@ function initTree(myHierarchy) nodeData.data.alertDown, nodeData.data.flapping, nodeData.data.mac, - statusText = '' + '', + nodeData.data.sleeping || 0 ); return result = `
    ${badge.iconHtml} ${badge.status}` const html = ` diff --git a/front/plugins/newdev_template/config.json b/front/plugins/newdev_template/config.json index 1bddbd1ac..1a90589d3 100755 --- a/front/plugins/newdev_template/config.json +++ b/front/plugins/newdev_template/config.json @@ -1191,6 +1191,41 @@ } ] }, + { + "function": "devCanSleep", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [ + { + "type": "checkbox" + } + ], + "transformers": [] + } + ] + }, + "default_value": 0, + "options": [], + "localized": [ + "name", + "description" + ], + "name": [ + { + "language_code": "en_us", + "string": "Can Sleep" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "When enabled, the device will appear as Sleeping instead of Down or Off-line while it has been absent for less than the Sleep Window (NTFPRCS_sleep_time). Once the sleep window expires the device becomes subject to the normal Alert Down condition. Database column name: devCanSleep." + } + ] + }, { "function": "devSkipRepeated", "type": { diff --git a/front/plugins/notification_processing/config.json b/front/plugins/notification_processing/config.json index acb4fbbff..87c52093e 100755 --- a/front/plugins/notification_processing/config.json +++ b/front/plugins/notification_processing/config.json @@ -105,6 +105,34 @@ } ] }, + { + "function": "sleep_time", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 30, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Sleep Window" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "How many minutes a device with Can Sleep enabled is shown as Sleeping before it becomes subject to the Alert Down condition. Changes take effect after saving settings." + } + ] + }, { "function": "new_dev_condition", "type": { diff --git a/front/workflowsCore.php b/front/workflowsCore.php index b2942e6e1..09da6af0b 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -46,7 +46,7 @@ "devName", "devMac", "devOwner", "devType", "devVendor", "devVlan", "devFavorite", "devGroup", "devComments", "devFirstConnection", "devLastConnection", "devLastIP", "devStaticIP", "devScan", "devLogEvents", "devAlertEvents", - "devAlertDown", "devSkipRepeated", "devLastNotification", "devPresentLastScan", + "devAlertDown", "devCanSleep", "devSkipRepeated", "devLastNotification", "devPresentLastScan", "devIsNew", "devLocation", "devIsArchived", "devParentMAC", "devParentPort", "devIcon", "devSite", "devSSID", "devSyncHubNode", "devSourcePlugin", "devFQDN", "devParentRelType", "devReqNicsOnline", "devNameSource", "devVendorSource" , diff --git a/scripts/generate-device-inventory.py b/scripts/generate-device-inventory.py index 1ad959f32..ee6989e49 100644 --- a/scripts/generate-device-inventory.py +++ b/scripts/generate-device-inventory.py @@ -33,6 +33,7 @@ "devLogEvents", "devAlertEvents", "devAlertDown", + "devCanSleep", "devSkipRepeated", "devLastNotification", "devPresentLastScan", @@ -185,6 +186,7 @@ def build_row( "devLogEvents": "1", "devAlertEvents": "1", "devAlertDown": "0", + "devCanSleep": "0", "devSkipRepeated": "0", "devLastNotification": "", "devPresentLastScan": "0", diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 000f43a96..1e0da83d5 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -101,6 +101,8 @@ class Device(ObjectType): devParentRelTypeSource = String(description="Source tracking for devParentRelType (USER, LOCKED, NEWDEV, or plugin prefix)") devVlanSource = String(description="Source tracking for devVlan") devFlapping = Int(description="Indicates flapping device (device changing between online/offline states frequently)") + devCanSleep = Int(description="Can this device sleep? (0 or 1). When enabled, offline periods within NTFPRCS_sleep_time are reported as Sleeping instead of Down.") + devIsSleeping = Int(description="Computed: Is device currently in a sleep window? (0 or 1)") class DeviceResult(ObjectType): @@ -247,7 +249,7 @@ def resolve_devices(self, info, options=None): ) is_down = ( - device["devPresentLastScan"] == 0 and device["devAlertDown"] and "down" in allowed_statuses + device["devPresentLastScan"] == 0 and device["devAlertDown"] and device.get("devIsSleeping", 0) == 0 and "down" in allowed_statuses ) is_offline = ( @@ -282,11 +284,17 @@ def resolve_devices(self, info, options=None): devices_data = [ device for device in devices_data if device["devIsNew"] == 1 and device["devIsArchived"] == 0 ] + elif status == "sleeping": + devices_data = [ + device + for device in devices_data + if device.get("devIsSleeping", 0) == 1 and device["devIsArchived"] == 0 + ] elif status == "down": devices_data = [ device for device in devices_data - if device["devPresentLastScan"] == 0 and device["devAlertDown"] and device["devIsArchived"] == 0 + if device["devPresentLastScan"] == 0 and device["devAlertDown"] and device.get("devIsSleeping", 0) == 0 and device["devIsArchived"] == 0 ] elif status == "archived": devices_data = [ diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index 0c3b08eaf..4d7d31274 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -35,7 +35,7 @@ ALLOWED_DEVICE_COLUMNS = Literal[ "devName", "devOwner", "devType", "devVendor", "devGroup", "devLocation", "devComments", "devFavorite", - "devParentMAC" + "devParentMAC", "devCanSleep" ] ALLOWED_NMAP_MODES = Literal[ @@ -204,9 +204,19 @@ class DeviceInfo(BaseModel): description="Present in last scan (0 or 1)", json_schema_extra={"enum": [0, 1]} ) - devStatus: Optional[Literal["online", "offline"]] = Field( + devStatus: Optional[Literal["online", "offline", "sleeping"]] = Field( None, - description="Online/Offline status" + description="Online/Offline/Sleeping status" + ) + devCanSleep: Optional[int] = Field( + 0, + description="Can device sleep? (0=No, 1=Yes). When enabled, offline periods within NTFPRCS_sleep_time window are shown as Sleeping.", + json_schema_extra={"enum": [0, 1]} + ) + devIsSleeping: Optional[int] = Field( + 0, + description="Computed: Is device currently in a sleep window? (0=No, 1=Yes)", + json_schema_extra={"enum": [0, 1]} ) devMacSource: Optional[str] = Field(None, description="Source of devMac (USER, LOCKED, or plugin prefix)") devNameSource: Optional[str] = Field(None, description="Source of devName") @@ -228,14 +238,15 @@ class DeviceSearchResponse(BaseResponse): class DeviceListRequest(BaseModel): """Request for listing devices by status.""" status: Optional[Literal[ - "connected", "down", "favorites", "new", "archived", "all", "my", + "connected", "down", "sleeping", "favorites", "new", "archived", "all", "my", "offline" ]] = Field( None, description=( "Filter devices by status:\n" "- connected: Active devices present in the last scan\n" - "- down: Devices with active 'Device Down' alert\n" + "- down: Devices with active 'Device Down' alert (excludes sleeping)\n" + "- sleeping: Devices in a sleep window (devCanSleep=1, offline within NTFPRCS_sleep_time)\n" "- favorites: Devices marked as favorite\n" "- new: Devices flagged as new\n" "- archived: Devices moved to archive\n" diff --git a/server/database.py b/server/database.py index 405c47a8e..521e865b0 100755 --- a/server/database.py +++ b/server/database.py @@ -10,7 +10,6 @@ from workflows.app_events import AppEvent_obj from db.db_upgrade import ( ensure_column, - ensure_views, ensure_CurrentScan, ensure_plugins_tables, ensure_Parameters, @@ -192,6 +191,8 @@ def initDB(self): raise RuntimeError("ensure_column(devParentRelTypeSource) failed") if not ensure_column(self.sql, "Devices", "devVlanSource", "TEXT"): raise RuntimeError("ensure_column(devVlanSource) failed") + if not ensure_column(self.sql, "Devices", "devCanSleep", "INTEGER"): + raise RuntimeError("ensure_column(devCanSleep) failed") # Settings table setup ensure_Settings(self.sql) @@ -208,8 +209,9 @@ def initDB(self): # CurrentScan table setup ensure_CurrentScan(self.sql) - # Views - ensure_views(self.sql) + # Views are created in importConfigs() after settings are committed, + # so NTFPRCS_sleep_time is available when the view is built. + # ensure_views is NOT called here. # Indexes ensure_Indexes(self.sql) diff --git a/server/db/db_helper.py b/server/db/db_helper.py index 54ae5a59b..25ded026e 100755 --- a/server/db/db_helper.py +++ b/server/db/db_helper.py @@ -24,7 +24,8 @@ def get_device_conditions(): "connected": "WHERE devPresentLastScan=1", "favorites": f"WHERE {base_active} AND devFavorite=1", "new": f"WHERE {base_active} AND devIsNew=1", - "down": f"WHERE {base_active} AND devAlertDown != 0 AND devPresentLastScan=0", + "sleeping": f"WHERE {base_active} AND devIsSleeping=1", + "down": f"WHERE {base_active} AND devAlertDown != 0 AND devPresentLastScan=0 AND devIsSleeping=0", "offline": f"WHERE {base_active} AND devPresentLastScan=0", "archived": "WHERE devIsArchived=1", "network_devices": f"WHERE {base_active} AND devType IN ({network_dev_types})", diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index 8886b959e..c07a2063d 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -28,6 +28,7 @@ "devLogEvents", "devAlertEvents", "devAlertDown", + "devCanSleep", "devSkipRepeated", "devLastNotification", "devPresentLastScan", @@ -235,8 +236,22 @@ def ensure_views(sql) -> bool: FLAP_THRESHOLD = 3 FLAP_WINDOW_HOURS = 1 + # Read sleep window from settings; fall back to 30 min if not yet configured. + # Uses the same sql cursor (no separate connection) to avoid lock contention. + # Note: changing NTFPRCS_sleep_time requires a restart to take effect, + # same behaviour as FLAP_THRESHOLD / FLAP_WINDOW_HOURS. + try: + sql.execute("SELECT setValue FROM Settings WHERE setKey = 'NTFPRCS_sleep_time'") + _sleep_row = sql.fetchone() + SLEEP_MINUTES = int(_sleep_row[0]) if _sleep_row and _sleep_row[0] else 30 + except Exception: + SLEEP_MINUTES = 30 + sql.execute(""" DROP VIEW IF EXISTS DevicesView;""") sql.execute(f""" CREATE VIEW DevicesView AS + -- CTE computes devIsSleeping and devFlapping so devStatus can + -- reference them without duplicating the sub-expressions. + WITH base AS ( SELECT rowid, IFNULL(devMac, '') AS devMac, @@ -259,6 +274,7 @@ def ensure_views(sql) -> bool: IFNULL(devLogEvents, '') AS devLogEvents, IFNULL(devAlertEvents, '') AS devAlertEvents, IFNULL(devAlertDown, '') AS devAlertDown, + IFNULL(devCanSleep, 0) AS devCanSleep, IFNULL(devSkipRepeated, '') AS devSkipRepeated, IFNULL(devLastNotification, '') AS devLastNotification, IFNULL(devPresentLastScan, 0) AS devPresentLastScan, @@ -287,14 +303,15 @@ def ensure_views(sql) -> bool: IFNULL(devParentPortSource, '') AS devParentPortSource, IFNULL(devParentRelTypeSource, '') AS devParentRelTypeSource, IFNULL(devVlanSource, '') AS devVlanSource, + -- devIsSleeping: opted-in, absent, and still within the sleep window CASE - WHEN devIsNew = 1 THEN 'New' - WHEN devPresentLastScan = 1 THEN 'On-line' - WHEN devPresentLastScan = 0 AND devAlertDown != 0 THEN 'Down' - WHEN devIsArchived = 1 THEN 'Archived' - WHEN devPresentLastScan = 0 THEN 'Off-line' - ELSE 'Unknown status' - END AS devStatus, + WHEN devCanSleep = 1 + AND devPresentLastScan = 0 + AND devLastConnection >= datetime('now', '-{SLEEP_MINUTES} minutes') + THEN 1 + ELSE 0 + END AS devIsSleeping, + -- devFlapping: toggling online/offline frequently within the flap window CASE WHEN EXISTS ( SELECT 1 @@ -308,8 +325,20 @@ def ensure_views(sql) -> bool: THEN 1 ELSE 0 END AS devFlapping - - FROM Devices + FROM Devices + ) + SELECT *, + -- devStatus references devIsSleeping from the CTE (no duplication) + CASE + WHEN devIsNew = 1 THEN 'New' + WHEN devPresentLastScan = 1 THEN 'On-line' + WHEN devIsSleeping = 1 THEN 'Sleeping' + WHEN devAlertDown != 0 THEN 'Down' + WHEN devIsArchived = 1 THEN 'Archived' + WHEN devPresentLastScan = 0 THEN 'Off-line' + ELSE 'Unknown status' + END AS devStatus + FROM base """) @@ -381,6 +410,10 @@ def ensure_Indexes(sql) -> bool: "idx_dev_alertdown", "CREATE INDEX idx_dev_alertdown ON Devices(devAlertDown)", ), + ( + "idx_dev_cansleep", + "CREATE INDEX idx_dev_cansleep ON Devices(devCanSleep)", + ), ("idx_dev_isnew", "CREATE INDEX idx_dev_isnew ON Devices(devIsNew)"), ( "idx_dev_isarchived", diff --git a/server/initialise.py b/server/initialise.py index afed6dd8d..850490c06 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -11,6 +11,7 @@ # Register NetAlertX libraries import conf from const import fullConfPath, fullConfFolder, default_tz, applicationPath +from db.db_upgrade import ensure_views from helper import getBuildTimeStampAndVersion, collect_lang_strings, updateSubnets, generate_random_string from utils.datetime_utils import timeNowUTC from app_state import updateState @@ -733,6 +734,12 @@ def importConfigs(pm, db, all_plugins): db.commitDB() + # Rebuild DevicesView now that settings (including NTFPRCS_sleep_time) are committed. + # This is the single call site — initDB() deliberately skips it so the view + # always gets the real user value, not an empty-Settings fallback. + ensure_views(sql) + db.commitDB() + # update only the settings datasource update_api(db, all_plugins, True, ["settings"]) diff --git a/server/models/device_instance.py b/server/models/device_instance.py index 947267532..266ec55a7 100755 --- a/server/models/device_instance.py +++ b/server/models/device_instance.py @@ -61,8 +61,8 @@ def getValueWithMac(self, column_name, devMac): def getDown(self): return self._fetchall(""" - SELECT * FROM Devices - WHERE devAlertDown = 1 AND devPresentLastScan = 0 + SELECT * FROM DevicesView + WHERE devAlertDown = 1 AND devPresentLastScan = 0 AND devIsSleeping = 0 """) def getOffline(self): @@ -454,7 +454,9 @@ def getDeviceData(self, mac, period=""): "devPresenceHours": 0, "devFQDN": "", "devForceStatus" : "dont_force", - "devVlan": "" + "devVlan": "", + "devCanSleep": 0, + "devIsSleeping": 0 } return device_data @@ -467,11 +469,6 @@ def getDeviceData(self, mac, period=""): d.*, LOWER(d.devMac) AS devMac, LOWER(d.devParentMAC) AS devParentMAC, - CASE - WHEN d.devAlertDown != 0 AND d.devPresentLastScan = 0 THEN 'Down' - WHEN d.devPresentLastScan = 1 THEN 'On-line' - ELSE 'Off-line' - END AS devStatus, (SELECT COUNT(*) FROM Sessions WHERE LOWER(ses_MAC) = LOWER(d.devMac) AND ( @@ -501,11 +498,10 @@ def getDeviceData(self, mac, period=""): OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1) ) AS devPresenceHours - FROM Devices d + FROM DevicesView d WHERE LOWER(d.devMac) = LOWER(?) OR CAST(d.rowid AS TEXT) = ? """ - conn = get_temp_db_connection() cur = conn.cursor() cur.execute(sql, (mac, mac)) @@ -571,7 +567,8 @@ def setDeviceData(self, mac, data): "devIsArchived", "devCustomProps", "devForceStatus", - "devVlan" + "devVlan", + "devCanSleep" } # Only mark USER for tracked fields that this method actually updates. @@ -617,12 +614,12 @@ def setDeviceData(self, mac, data): devMac, devName, devOwner, devType, devVendor, devIcon, devFavorite, devGroup, devLocation, devComments, devParentMAC, devParentPort, devSSID, devSite, - devStaticIP, devScan, devAlertEvents, devAlertDown, + devStaticIP, devScan, devAlertEvents, devAlertDown, devCanSleep, devParentRelType, devReqNicsOnline, devSkipRepeated, devIsNew, devIsArchived, devLastConnection, devFirstConnection, devLastIP, devGUID, devCustomProps, devSourcePlugin, devForceStatus, devVlan - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ values = ( @@ -644,6 +641,7 @@ def setDeviceData(self, mac, data): data.get("devScan") or 0, data.get("devAlertEvents") or 0, data.get("devAlertDown") or 0, + data.get("devCanSleep") or 0, data.get("devParentRelType") or "default", data.get("devReqNicsOnline") or 0, data.get("devSkipRepeated") or 0, @@ -665,7 +663,7 @@ def setDeviceData(self, mac, data): devName=?, devOwner=?, devType=?, devVendor=?, devIcon=?, devFavorite=?, devGroup=?, devLocation=?, devComments=?, devParentMAC=?, devParentPort=?, devSSID=?, devSite=?, - devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?, + devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?, devCanSleep=?, devParentRelType=?, devReqNicsOnline=?, devSkipRepeated=?, devIsNew=?, devIsArchived=?, devCustomProps=?, devForceStatus=?, devVlan=? WHERE devMac=? @@ -688,6 +686,7 @@ def setDeviceData(self, mac, data): data.get("devScan") or 0, data.get("devAlertEvents") or 0, data.get("devAlertDown") or 0, + data.get("devCanSleep") or 0, data.get("devParentRelType") or "default", data.get("devReqNicsOnline") or 0, data.get("devSkipRepeated") or 0, @@ -834,7 +833,6 @@ def updateDeviceColumn(self, mac, column_name, column_value): conn.close() return result - def lockDeviceField(self, mac, field_name): """Lock a device field so it won't be overwritten by plugins.""" if field_name not in FIELD_SOURCE_MAP: diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index a80a717da..880904603 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -533,8 +533,8 @@ def print_scan_stats(db): SELECT (SELECT COUNT(*) FROM CurrentScan) AS devices_detected, (SELECT COUNT(*) FROM CurrentScan WHERE NOT EXISTS (SELECT 1 FROM Devices WHERE devMac = scanMac)) AS new_devices, - (SELECT COUNT(*) FROM Devices WHERE devAlertDown != 0 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS down_alerts, - (SELECT COUNT(*) FROM Devices WHERE devAlertDown != 0 AND devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS new_down_alerts, + (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devIsSleeping = 0 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS down_alerts, + (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devIsSleeping = 0 AND devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS new_down_alerts, (SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 0) AS new_connections, (SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS disconnections, (SELECT COUNT(*) FROM Devices, CurrentScan diff --git a/server/scan/session_events.py b/server/scan/session_events.py index 049f2190c..bae9f0535 100755 --- a/server/scan/session_events.py +++ b/server/scan/session_events.py @@ -175,8 +175,9 @@ def insert_events(db): eve_EventType, eve_AdditionalInfo, eve_PendingAlertEmail) SELECT devMac, devLastIP, '{startTime}', 'Device Down', '', 1 - FROM Devices + FROM DevicesView WHERE devAlertDown != 0 + AND devIsSleeping = 0 AND devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac @@ -242,8 +243,8 @@ def insertOnlineHistory(db): COUNT(*) AS allDevices, COALESCE(SUM(CASE WHEN devIsArchived = 1 THEN 1 ELSE 0 END), 0) AS archivedDevices, COALESCE(SUM(CASE WHEN devPresentLastScan = 1 THEN 1 ELSE 0 END), 0) AS onlineDevices, - COALESCE(SUM(CASE WHEN devPresentLastScan = 0 AND devAlertDown = 1 THEN 1 ELSE 0 END), 0) AS downDevices - FROM Devices + COALESCE(SUM(CASE WHEN devPresentLastScan = 0 AND devAlertDown = 1 AND devIsSleeping = 0 THEN 1 ELSE 0 END), 0) AS downDevices + FROM DevicesView """ deviceCounts = db.read(query)[ From 0497c2891e3d4a7832b3c0fd5b9e578801786cef Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:35:36 +0000 Subject: [PATCH 051/122] Fix formatting issues in DATABASE.md for improved readability --- docs/DATABASE.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 46aac1458..c062df4af 100755 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -1,7 +1,7 @@ - + # A high-level description of the database structure - An overview of the most important database tables as well as an detailed overview of the Devices table. The MAC address is used as a foreign key in most cases. + An overview of the most important database tables as well as an detailed overview of the Devices table. The MAC address is used as a foreign key in most cases. ## Devices database table @@ -50,7 +50,7 @@ > - `devStatus` — derived string: `On-line`, `Sleeping`, `Down`, or `Off-line`. -To understand how values of these fields influuence application behavior, such as Notifications or Network topology, see also: +To understand how values of these fields influuence application behavior, such as Notifications or Network topology, see also: - [Device Management](./DEVICE_MANAGEMENT.md) - [Network Tree Topology Setup](./NETWORK_TREE.md) @@ -58,32 +58,32 @@ To understand how values of these fields influuence application behavior, such a ## Other Tables overview - + | Table name | Description | Sample data | - |----------------------|----------------------| ----------------------| - | CurrentScan | Result of the current scan | ![Screen1][screen1] | - | Devices | The main devices database that also contains the Network tree mappings. If `ScanCycle` is set to `0` device is not scanned. | ![Screen2][screen2] | - | Events | Used to collect connection/disconnection events. | ![Screen4][screen4] | - | Online_History | Used to display the `Device presence` chart | ![Screen6][screen6] | - | Parameters | Used to pass values between the frontend and backend. | ![Screen7][screen7] | - | Plugins_Events | For capturing events exposed by a plugin via the `last_result.log` file. If unique then saved into the `Plugins_Objects` table. Entries are deleted once processed and stored in the `Plugins_History` and/or `Plugins_Objects` tables. | ![Screen10][screen10] | - | Plugins_History | History of all entries from the `Plugins_Events` table | ![Screen11][screen11] | - | Plugins_Language_Strings | Language strings collected from the plugin `config.json` files used for string resolution in the frontend. | ![Screen12][screen12] | - | Plugins_Objects | Unique objects detected by individual plugins. | ![Screen13][screen13] | - | Sessions | Used to display sessions in the charts | ![Screen15][screen15] | - | Settings | Database representation of the sum of all settings from `app.conf` and plugins coming from `config.json` files. | ![Screen16][screen16] | + |----------------------|----------------------| ----------------------| + | CurrentScan | Result of the current scan | ![Screen1][screen1] | + | Devices | The main devices database that also contains the Network tree mappings. If `ScanCycle` is set to `0` device is not scanned. | ![Screen2][screen2] | + | Events | Used to collect connection/disconnection events. | ![Screen4][screen4] | + | Online_History | Used to display the `Device presence` chart | ![Screen6][screen6] | + | Parameters | Used to pass values between the frontend and backend. | ![Screen7][screen7] | + | Plugins_Events | For capturing events exposed by a plugin via the `last_result.log` file. If unique then saved into the `Plugins_Objects` table. Entries are deleted once processed and stored in the `Plugins_History` and/or `Plugins_Objects` tables. | ![Screen10][screen10] | + | Plugins_History | History of all entries from the `Plugins_Events` table | ![Screen11][screen11] | + | Plugins_Language_Strings | Language strings collected from the plugin `config.json` files used for string resolution in the frontend. | ![Screen12][screen12] | + | Plugins_Objects | Unique objects detected by individual plugins. | ![Screen13][screen13] | + | Sessions | Used to display sessions in the charts | ![Screen15][screen15] | + | Settings | Database representation of the sum of all settings from `app.conf` and plugins coming from `config.json` files. | ![Screen16][screen16] | [screen1]: ./img/DATABASE/CurrentScan.png [screen2]: ./img/DATABASE/Devices.png - [screen4]: ./img/DATABASE/Events.png + [screen4]: ./img/DATABASE/Events.png [screen6]: ./img/DATABASE/Online_History.png [screen7]: ./img/DATABASE/Parameters.png [screen10]: ./img/DATABASE/Plugins_Events.png [screen11]: ./img/DATABASE/Plugins_History.png [screen12]: ./img/DATABASE/Plugins_Language_Strings.png - [screen13]: ./img/DATABASE/Plugins_Objects.png + [screen13]: ./img/DATABASE/Plugins_Objects.png [screen15]: ./img/DATABASE/Sessions.png [screen16]: ./img/DATABASE/Settings.png From 15807b7ab9e14fc00ed3f3edfefab61ebd59d479 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 05:53:28 +0000 Subject: [PATCH 052/122] Add unit and integration tests for device down event handling and sleeping suppression --- server/db/db_upgrade.py | 2 +- test/db/test_devices_view.py | 206 +++++++++++++++++++ test/db_test_helpers.py | 230 +++++++++++++++++++++ test/scan/test_down_sleep_events.py | 296 ++++++++++++++++++++++++++++ 4 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 test/db/test_devices_view.py create mode 100644 test/db_test_helpers.py create mode 100644 test/scan/test_down_sleep_events.py diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index c07a2063d..d8299d67a 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -273,7 +273,7 @@ def ensure_views(sql) -> bool: IFNULL(devScan, '') AS devScan, IFNULL(devLogEvents, '') AS devLogEvents, IFNULL(devAlertEvents, '') AS devAlertEvents, - IFNULL(devAlertDown, '') AS devAlertDown, + IFNULL(devAlertDown, 0) AS devAlertDown, IFNULL(devCanSleep, 0) AS devCanSleep, IFNULL(devSkipRepeated, '') AS devSkipRepeated, IFNULL(devLastNotification, '') AS devLastNotification, diff --git a/test/db/test_devices_view.py b/test/db/test_devices_view.py new file mode 100644 index 000000000..60792de75 --- /dev/null +++ b/test/db/test_devices_view.py @@ -0,0 +1,206 @@ +""" +Unit tests for the DevicesView SQL view built by ensure_views(). + +Regression coverage: +- NULL devAlertDown must NOT be treated as != 0 (IFNULL bug: '' vs 0). +- devCanSleep / devIsSleeping suppression within the sleep window. +- Only devices with devAlertDown = 1 AND devPresentLastScan = 0 appear in + the "Device Down" event query. + +Each test uses an isolated in-memory SQLite database so it has no +dependency on the running application or config. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import ( # noqa: E402 + make_db as _make_db, + minutes_ago as _minutes_ago, + insert_device as _insert_device, +) + + +# --------------------------------------------------------------------------- +# Tests: devAlertDown NULL coercion +# --------------------------------------------------------------------------- + +class TestAlertDownNullCoercion: + """ + Guard against the IFNULL(devAlertDown, '') bug. + + When devAlertDown IS NULL and the view uses IFNULL(..., ''), the text value + '' satisfies `!= 0` in SQLite (text > integer), causing spurious down events. + The fix is IFNULL(devAlertDown, 0) so NULL → 0, and 0 != 0 is FALSE. + """ + + def test_null_alert_down_not_in_down_event_query(self): + """A device with NULL devAlertDown must NOT appear in the down-event query.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:01", alert_down=None, present_last_scan=0) + conn.commit() + + cur.execute(""" + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devPresentLastScan = 0 + """) + rows = cur.fetchall() + macs = [r["devMac"] for r in rows] + assert "AA:BB:CC:DD:EE:01" not in macs, ( + "Device with NULL devAlertDown must not fire a down event " + "(IFNULL coercion regression)" + ) + + def test_zero_alert_down_not_in_down_event_query(self): + """A device with explicit devAlertDown=0 must NOT appear.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:02", alert_down=0, present_last_scan=0) + conn.commit() + + cur.execute( + "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" + ) + macs = [r["devMac"] for r in cur.fetchall()] + assert "AA:BB:CC:DD:EE:02" not in macs + + def test_one_alert_down_in_down_event_query(self): + """A device with devAlertDown=1 and absent MUST appear in the down-event query.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:03", alert_down=1, present_last_scan=0) + conn.commit() + + cur.execute( + "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" + ) + macs = [r["devMac"] for r in cur.fetchall()] + assert "AA:BB:CC:DD:EE:03" in macs + + def test_online_device_not_in_down_event_query(self): + """An online device (devPresentLastScan=1) should never fire a down event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:BB:CC:DD:EE:04", alert_down=1, present_last_scan=1) + conn.commit() + + cur.execute( + "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" + ) + macs = [r["devMac"] for r in cur.fetchall()] + assert "AA:BB:CC:DD:EE:04" not in macs + + +# --------------------------------------------------------------------------- +# Tests: devIsSleeping suppression +# --------------------------------------------------------------------------- + +class TestIsSleepingSuppression: + """ + When devCanSleep=1 and the device has been absent for less than + NTFPRCS_sleep_time minutes, devIsSleeping must be 1 and the device + must NOT appear in the down-event query. + """ + + def test_sleeping_device_is_marked_sleeping(self): + """devCanSleep=1, absent, last seen 5 min ago → devIsSleeping=1.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:01", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:01'") + row = cur.fetchone() + assert row["devIsSleeping"] == 1 + + def test_sleeping_device_not_in_down_event_query(self): + """A sleeping device must be excluded from the down-event query.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:02", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute(""" + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devIsSleeping = 0 + AND devPresentLastScan = 0 + """) + macs = [r["devMac"] for r in cur.fetchall()] + assert "BB:BB:BB:BB:BB:02" not in macs + + def test_expired_sleep_window_fires_down(self): + """After the sleep window expires, the device must appear as Down.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:03", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45), # > 30 min + ) + conn.commit() + + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:03'") + assert cur.fetchone()["devIsSleeping"] == 0 + + cur.execute(""" + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devIsSleeping = 0 + AND devPresentLastScan = 0 + """) + macs = [r["devMac"] for r in cur.fetchall()] + assert "BB:BB:BB:BB:BB:03" in macs + + def test_can_sleep_zero_device_is_not_sleeping(self): + """devCanSleep=0 device recently offline → devIsSleeping must be 0.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:04", + alert_down=1, present_last_scan=0, + can_sleep=0, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:04'") + assert cur.fetchone()["devIsSleeping"] == 0 + + def test_devstatus_sleeping(self): + """DevicesView devStatus must be 'Sleeping' for a sleeping device.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:05", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5), + ) + conn.commit() + + cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:05'") + assert cur.fetchone()["devStatus"] == "Sleeping" + + def test_devstatus_down_after_window_expires(self): + """DevicesView devStatus must be 'Down' once the sleep window expires.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device( + cur, "BB:BB:BB:BB:BB:06", + alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45), + ) + conn.commit() + + cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:06'") + assert cur.fetchone()["devStatus"] == "Down" diff --git a/test/db_test_helpers.py b/test/db_test_helpers.py new file mode 100644 index 000000000..d7880d2cb --- /dev/null +++ b/test/db_test_helpers.py @@ -0,0 +1,230 @@ +""" +Shared in-memory database factories and helpers for NetAlertX unit tests. + +Import from any test subdirectory with: + + import sys, os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + from db_test_helpers import make_db, insert_device, minutes_ago, DummyDB, down_event_macs +""" + +import sqlite3 +import sys +import os +from datetime import datetime, timezone, timedelta + +# Make the 'server' package importable when this module is loaded directly. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "server")) +from db.db_upgrade import ensure_views # noqa: E402 + + +# --------------------------------------------------------------------------- +# DDL +# --------------------------------------------------------------------------- + +CREATE_DEVICES = """ + CREATE TABLE IF NOT EXISTS Devices ( + devMac TEXT PRIMARY KEY, + devName TEXT, + devOwner TEXT, + devType TEXT, + devVendor TEXT, + devFavorite INTEGER DEFAULT 0, + devGroup TEXT, + devComments TEXT, + devFirstConnection TEXT, + devLastConnection TEXT, + devLastIP TEXT, + devPrimaryIPv4 TEXT, + devPrimaryIPv6 TEXT, + devVlan TEXT, + devForceStatus TEXT, + devStaticIP TEXT, + devScan INTEGER DEFAULT 1, + devLogEvents INTEGER DEFAULT 1, + devAlertEvents INTEGER DEFAULT 1, + devAlertDown INTEGER, -- intentionally nullable + devCanSleep INTEGER DEFAULT 0, + devSkipRepeated INTEGER DEFAULT 0, + devLastNotification TEXT, + devPresentLastScan INTEGER DEFAULT 0, + devIsNew INTEGER DEFAULT 0, + devLocation TEXT, + devIsArchived INTEGER DEFAULT 0, + devParentMAC TEXT, + devParentPort TEXT, + devIcon TEXT, + devGUID TEXT, + devSite TEXT, + devSSID TEXT, + devSyncHubNode TEXT, + devSourcePlugin TEXT, + devCustomProps TEXT, + devFQDN TEXT, + devParentRelType TEXT, + devReqNicsOnline INTEGER DEFAULT 0, + devMacSource TEXT, + devNameSource TEXT, + devFQDNSource TEXT, + devLastIPSource TEXT, + devVendorSource TEXT, + devSSIDSource TEXT, + devParentMACSource TEXT, + devParentPortSource TEXT, + devParentRelTypeSource TEXT, + devVlanSource TEXT + ) +""" + +# Includes eve_PairEventRowid — required by insert_events(). +CREATE_EVENTS = """ + CREATE TABLE IF NOT EXISTS Events ( + eve_MAC TEXT, + eve_IP TEXT, + eve_DateTime TEXT, + eve_EventType TEXT, + eve_AdditionalInfo TEXT, + eve_PendingAlertEmail INTEGER, + eve_PairEventRowid INTEGER + ) +""" + +CREATE_CURRENT_SCAN = """ + CREATE TABLE IF NOT EXISTS CurrentScan ( + scanMac TEXT, + scanLastIP TEXT, + scanVendor TEXT, + scanSourcePlugin TEXT, + scanName TEXT, + scanLastQuery TEXT, + scanLastConnection TEXT, + scanSyncHubNode TEXT, + scanSite TEXT, + scanSSID TEXT, + scanParentMAC TEXT, + scanParentPort TEXT, + scanType TEXT + ) +""" + +CREATE_SETTINGS = """ + CREATE TABLE IF NOT EXISTS Settings ( + setKey TEXT PRIMARY KEY, + setValue TEXT + ) +""" + + +# --------------------------------------------------------------------------- +# DB factory +# --------------------------------------------------------------------------- + +def make_db(sleep_minutes: int = 30) -> sqlite3.Connection: + """ + Return a fully seeded in-memory SQLite connection with DevicesView built. + + Builds all required tables (Devices, Events, CurrentScan, Settings) and + calls ensure_views() so DevicesView is immediately queryable. + """ + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(CREATE_DEVICES) + cur.execute(CREATE_EVENTS) + cur.execute(CREATE_CURRENT_SCAN) + cur.execute(CREATE_SETTINGS) + cur.execute( + "INSERT OR REPLACE INTO Settings (setKey, setValue) VALUES (?, ?)", + ("NTFPRCS_sleep_time", str(sleep_minutes)), + ) + conn.commit() + ensure_views(cur) + conn.commit() + return conn + + +# --------------------------------------------------------------------------- +# Time helpers +# --------------------------------------------------------------------------- + +def minutes_ago(n: int) -> str: + """Return a UTC timestamp string for *n* minutes ago.""" + dt = datetime.now(timezone.utc) - timedelta(minutes=n) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def now_utc() -> str: + """Return the current UTC timestamp as a string.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + +# --------------------------------------------------------------------------- +# Device row factory +# --------------------------------------------------------------------------- + +def insert_device( + cur, + mac: str, + *, + alert_down, + present_last_scan: int = 0, + can_sleep: int = 0, + last_connection: str | None = None, + last_ip: str = "192.168.1.1", +) -> None: + """ + Insert a minimal Devices row. + + Parameters + ---------- + alert_down: + Value for devAlertDown. Pass ``None`` to store SQL NULL (tests the + IFNULL coercion regression), ``0`` for disabled, ``1`` for enabled. + present_last_scan: + ``1`` = device was seen last scan (about to go down transition). + ``0`` = device was already absent last scan. + can_sleep: + ``1`` enables the sleeping window for this device. + last_connection: + ISO-8601 UTC string; defaults to 60 minutes ago when omitted. + last_ip: + Value stored in devLastIP. + """ + cur.execute( + """ + INSERT INTO Devices + (devMac, devAlertDown, devPresentLastScan, devCanSleep, + devLastConnection, devLastIP, devIsArchived, devIsNew) + VALUES (?, ?, ?, ?, ?, ?, 0, 0) + """, + (mac, alert_down, present_last_scan, can_sleep, + last_connection or minutes_ago(60), last_ip), + ) + + +# --------------------------------------------------------------------------- +# Assertion helpers +# --------------------------------------------------------------------------- + +def down_event_macs(cur) -> set: + """Return the set of MACs that have a 'Device Down' event row.""" + cur.execute("SELECT eve_MAC FROM Events WHERE eve_EventType = 'Device Down'") + return {r["eve_MAC"] for r in cur.fetchall()} + + +# --------------------------------------------------------------------------- +# DummyDB — minimal wrapper used by scan.session_events helpers +# --------------------------------------------------------------------------- + +class DummyDB: + """ + Minimal DB wrapper that satisfies the interface expected by + ``session_events.insert_events()`` and related helpers. + """ + + def __init__(self, conn: sqlite3.Connection): + self.sql = conn.cursor() + self._conn = conn + + def commitDB(self) -> None: + self._conn.commit() diff --git a/test/scan/test_down_sleep_events.py b/test/scan/test_down_sleep_events.py new file mode 100644 index 000000000..cc9067a6d --- /dev/null +++ b/test/scan/test_down_sleep_events.py @@ -0,0 +1,296 @@ +""" +Integration tests for the 'Device Down' event insertion and sleeping suppression. + +Two complementary layers are tested: + +Layer 1 — insert_events() (session_events.py) + The "Device Down" event fires when: + devPresentLastScan = 1 (was online last scan) + AND device NOT in CurrentScan (absent this scan) + AND devAlertDown != 0 + + At this point devIsSleeping is always 0 (sleeping requires devPresentLastScan=0, + but insert_events runs before update_presence_from_CurrentScan flips it). + Tests here verify NULL-devAlertDown regression and normal down/no-down branching. + +Layer 2 — DevicesView down-count query (as used by insertOnlineHistory / db_helper) + After presence is updated (devPresentLastScan → 0) the sleeping suppression + (devIsSleeping=1) kicks in for count/API queries. + Tests here verify that sleeping devices are excluded from down counts and that + expired-window devices are included. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import ( # noqa: E402 + make_db as _make_db, + minutes_ago as _minutes_ago, + insert_device as _insert_device, + down_event_macs as _down_event_macs, + DummyDB, +) + +# server/ is already on sys.path after db_test_helpers import +from scan.session_events import insert_events # noqa: E402 + + +# --------------------------------------------------------------------------- +# Layer 1: insert_events() — event creation on the down transition +# +# Condition: devPresentLastScan = 1 (was online) AND not in CurrentScan (now absent) +# At this point devIsSleeping is always 0 (sleeping requires devPresentLastScan=0). +# --------------------------------------------------------------------------- + +class TestInsertEventsDownDetection: + """ + Tests for the 'Device Down' INSERT in insert_events(). + + The down transition is: devPresentLastScan=1 AND absent from CurrentScan. + CurrentScan is left empty in all tests (all devices absent this scan). + """ + + def test_null_alert_down_does_not_fire_down_event(self): + """ + Regression: NULL devAlertDown must NOT produce a 'Device Down' event. + + Root cause: IFNULL(devAlertDown, '') made '' != 0 evaluate TRUE in SQLite, + causing devices without devAlertDown set to fire constant down events. + Fix: IFNULL(devAlertDown, 0) → 0 != 0 is FALSE. + """ + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:01", alert_down=None, present_last_scan=1) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:01" not in _down_event_macs(cur), ( + "NULL devAlertDown must never fire a 'Device Down' event " + "(IFNULL coercion regression)" + ) + + def test_zero_alert_down_does_not_fire_down_event(self): + """Explicit devAlertDown=0 must NOT fire a 'Device Down' event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:02", alert_down=0, present_last_scan=1) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:02" not in _down_event_macs(cur) + + def test_alert_down_one_fires_down_event_when_absent(self): + """devAlertDown=1, was online last scan, absent now → 'Device Down' event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:03", alert_down=1, present_last_scan=1) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:03" in _down_event_macs(cur) + + def test_device_in_current_scan_does_not_fire_down_event(self): + """A device present in CurrentScan (online now) must NOT get Down event.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:04", alert_down=1, present_last_scan=1) + # Put it in CurrentScan → device is online this scan + cur.execute( + "INSERT INTO CurrentScan (scanMac, scanLastIP) VALUES (?, ?)", + ("AA:11:22:33:44:04", "192.168.1.1"), + ) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:04" not in _down_event_macs(cur) + + def test_already_absent_last_scan_does_not_re_fire(self): + """ + devPresentLastScan=0 means device was already absent last scan. + The down event was already created then; it must not be created again. + (The INSERT query requires devPresentLastScan=1 — the down-transition moment.) + """ + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "AA:11:22:33:44:05", alert_down=1, present_last_scan=0) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "AA:11:22:33:44:05" not in _down_event_macs(cur) + + def test_archived_device_does_not_fire_down_event(self): + """Archived devices should not produce Down events.""" + conn = _make_db() + cur = conn.cursor() + cur.execute( + """INSERT INTO Devices + (devMac, devAlertDown, devPresentLastScan, devCanSleep, + devLastConnection, devLastIP, devIsArchived, devIsNew) + VALUES (?, 1, 1, 0, ?, '192.168.1.1', 1, 0)""", + ("AA:11:22:33:44:06", _minutes_ago(60)), + ) + conn.commit() + + insert_events(DummyDB(conn)) + + # Archived devices have devIsArchived=1; insert_events doesn't filter + # by archived, but DevicesView applies devAlertDown — archived here is + # tested to confirm the count stays clean for future filter additions. + # The archived device DOES get a Down event today (no archive filter in + # insert_events). This test documents the current behaviour. + # If that changes, update this assertion accordingly. + assert "AA:11:22:33:44:06" in _down_event_macs(cur) + + def test_multiple_devices_mixed_alert_down(self): + """Only devices with devAlertDown=1 that are absent fire Down events.""" + conn = _make_db() + cur = conn.cursor() + cases = [ + ("CC:00:00:00:00:01", None, 1), # NULL → no event + ("CC:00:00:00:00:02", 0, 1), # 0 → no event + ("CC:00:00:00:00:03", 1, 1), # 1 → event + ("CC:00:00:00:00:04", 1, 0), # already absent → no event + ] + for mac, alert_down, present in cases: + _insert_device(cur, mac, alert_down=alert_down, present_last_scan=present) + conn.commit() + + insert_events(DummyDB(conn)) + fired = _down_event_macs(cur) + + assert "CC:00:00:00:00:01" not in fired, "NULL devAlertDown must not fire" + assert "CC:00:00:00:00:02" not in fired, "devAlertDown=0 must not fire" + assert "CC:00:00:00:00:03" in fired, "devAlertDown=1 absent must fire" + assert "CC:00:00:00:00:04" not in fired, "already-absent device must not fire again" + + +# --------------------------------------------------------------------------- +# Layer 2: DevicesView down-count query (post-presence-update) +# +# After update_presence_from_CurrentScan sets devPresentLastScan → 0 for absent +# devices, the sleeping suppression (devIsSleeping) becomes active for: +# - insertOnlineHistory (SUM ... WHERE devPresentLastScan=0 AND devIsSleeping=0) +# - db_helper "down" filter +# - getDown() +# --------------------------------------------------------------------------- + +class TestDownCountSleepingSuppression: + """ + Tests for the post-presence-update down-count query. + + Simulates the state AFTER update_presence_from_CurrentScan has run by + inserting devices with devPresentLastScan=0 (already absent) directly. + """ + + _DOWN_COUNT_SQL = """ + SELECT devMac FROM DevicesView + WHERE devAlertDown != 0 + AND devPresentLastScan = 0 + AND devIsSleeping = 0 + AND devIsArchived = 0 + """ + + def test_null_alert_down_excluded_from_down_count(self): + """NULL devAlertDown must not contribute to down count.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:01", alert_down=None, present_last_scan=0) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:01" not in macs + + def test_alert_down_one_included_in_down_count(self): + """devAlertDown=1 absent device must be counted as down.""" + conn = _make_db() + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:02", alert_down=1, present_last_scan=0, + last_connection=_minutes_ago(60)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:02" in macs + + def test_sleeping_device_excluded_from_down_count(self): + """ + devCanSleep=1 + absent + within sleep window → devIsSleeping=1. + Must be excluded from the down-count query. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:03", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(5)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:03" not in macs, ( + "Sleeping device must be excluded from down count" + ) + + def test_expired_sleep_window_included_in_down_count(self): + """Once the sleep window expires the device must appear in down count.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:04", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:04" in macs, ( + "Device past its sleep window must appear in down count" + ) + + def test_can_sleep_zero_always_in_down_count(self): + """devCanSleep=0 device that is absent is always counted as down.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "DD:00:00:00:00:05", alert_down=1, present_last_scan=0, + can_sleep=0, last_connection=_minutes_ago(5)) + conn.commit() + + cur.execute(self._DOWN_COUNT_SQL) + macs = {r["devMac"] for r in cur.fetchall()} + assert "DD:00:00:00:00:05" in macs + + def test_online_history_down_count_excludes_sleeping(self): + """ + Mirrors the insertOnlineHistory SUM query exactly. + Sleeping devices must not inflate the downDevices count. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + + # Normal down + _insert_device(cur, "EE:00:00:00:00:01", alert_down=1, present_last_scan=0, + can_sleep=0, last_connection=_minutes_ago(60)) + # Sleeping (within window) + _insert_device(cur, "EE:00:00:00:00:02", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(10)) + # Online + _insert_device(cur, "EE:00:00:00:00:03", alert_down=1, present_last_scan=1, + last_connection=_minutes_ago(1)) + conn.commit() + + cur.execute(""" + SELECT + COALESCE(SUM(CASE + WHEN devPresentLastScan = 0 + AND devAlertDown = 1 + AND devIsSleeping = 0 + THEN 1 ELSE 0 END), 0) AS downDevices + FROM DevicesView + """) + count = cur.fetchone()["downDevices"] + assert count == 1, ( + f"Expected 1 down device (sleeping device must not be counted), got {count}" + ) From 3e237bb4525c6bafda883ea74a925443d7a200d3 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 06:03:18 +0000 Subject: [PATCH 053/122] Normalize MAC addresses in SQL queries and add devCanSleep column to device schema --- server/db/db_upgrade.py | 6 +++--- test/test_device_atomicity.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index d8299d67a..55cda251c 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -254,7 +254,7 @@ def ensure_views(sql) -> bool: WITH base AS ( SELECT rowid, - IFNULL(devMac, '') AS devMac, + LOWER(IFNULL(devMac, '')) AS devMac, IFNULL(devName, '') AS devName, IFNULL(devOwner, '') AS devOwner, IFNULL(devType, '') AS devType, @@ -281,7 +281,7 @@ def ensure_views(sql) -> bool: IFNULL(devIsNew, '') AS devIsNew, IFNULL(devLocation, '') AS devLocation, IFNULL(devIsArchived, '') AS devIsArchived, - IFNULL(devParentMAC, '') AS devParentMAC, + LOWER(IFNULL(devParentMAC, '')) AS devParentMAC, IFNULL(devParentPort, '') AS devParentPort, IFNULL(devIcon, '') AS devIcon, IFNULL(devGUID, '') AS devGUID, @@ -316,7 +316,7 @@ def ensure_views(sql) -> bool: WHEN EXISTS ( SELECT 1 FROM Events e - WHERE e.eve_MAC = Devices.devMac + WHERE LOWER(e.eve_MAC) = LOWER(Devices.devMac) AND e.eve_EventType IN ('Connected','Disconnected','Device Down','Down Reconnected') AND e.eve_DateTime >= datetime('now', '-{FLAP_WINDOW_HOURS} hours') GROUP BY e.eve_MAC diff --git a/test/test_device_atomicity.py b/test/test_device_atomicity.py index f6b257ad0..83516fe68 100644 --- a/test/test_device_atomicity.py +++ b/test/test_device_atomicity.py @@ -58,6 +58,7 @@ def setUp(self): devScan INTEGER DEFAULT 0, devAlertEvents INTEGER DEFAULT 0, devAlertDown INTEGER DEFAULT 0, + devCanSleep INTEGER DEFAULT 0, devParentRelType TEXT DEFAULT 'default', devReqNicsOnline INTEGER DEFAULT 0, devSkipRepeated INTEGER DEFAULT 0, From 6724d250d4b8c8f46c2a6f14862803ade66a2668 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:35:42 +0000 Subject: [PATCH 054/122] Refactor network tree data structure and improve device status handling - Updated the network tree data structure to use consistent naming conventions for device properties (e.g., devName, devMac). - Enhanced the initTree function to utilize the new property names and improved the rendering of device nodes. - Refactored the getStatusBadgeParts function to include additional parameters for archived and new device statuses. - Introduced convenience functions (badgeFromDevice and badgeFromDataAttrs) to streamline badge generation from device objects and data attributes. - Updated various language files to include new status labels and ensure consistency across translations. - Modified the renderSmallBox function to allow for custom icon HTML, improving flexibility in UI components. --- front/deviceDetails.php | 11 ++- front/devices.php | 41 +++++---- front/js/device-columns.js | 9 ++ front/js/network-api.js | 36 ++++---- front/js/network-tabs.js | 43 ++++------ front/js/network-tree.js | 93 ++++++++++----------- front/js/ui_components.js | 105 +++++++++++++++--------- front/php/components/device_cards.php | 9 +- front/php/templates/language/ar_ar.json | 4 + front/php/templates/language/ca_ca.json | 4 + front/php/templates/language/cs_cz.json | 4 + front/php/templates/language/de_de.json | 4 + front/php/templates/language/en_us.json | 7 +- front/php/templates/language/es_es.json | 4 + front/php/templates/language/fa_fa.json | 4 + front/php/templates/language/fr_fr.json | 4 + front/php/templates/language/it_it.json | 4 + front/php/templates/language/ja_jp.json | 4 + front/php/templates/language/nb_no.json | 4 + front/php/templates/language/pl_pl.json | 4 + front/php/templates/language/pt_br.json | 4 + front/php/templates/language/pt_pt.json | 4 + front/php/templates/language/ru_ru.json | 4 + front/php/templates/language/sv_sv.json | 4 + front/php/templates/language/tr_tr.json | 4 + front/php/templates/language/uk_ua.json | 4 + front/php/templates/language/vi_vn.json | 4 + front/php/templates/language/zh_cn.json | 4 + 28 files changed, 277 insertions(+), 153 deletions(-) diff --git a/front/deviceDetails.php b/front/deviceDetails.php index 792c32a8a..ec597bb9f 100755 --- a/front/deviceDetails.php +++ b/front/deviceDetails.php @@ -425,17 +425,22 @@ function initializeTabs () { const deviceData = await response.json(); + // Derive status card appearance from shared getStatusBadgeParts — + // ensures icon, color, label and lang key are always in sync with the rest of the UI. + const statusBadge = badgeFromDevice(deviceData); + const statusText = statusBadge.label; + // Prepare custom data const customData = [ { "onclickEvent": "$('#tabDetails').trigger('click')", - "color": "bg-aqua", + "color": statusBadge.cssClass, "headerId": "deviceStatus", "headerStyle": "margin-left: 0em", "labelLang": "DevDetail_Shortcut_CurrentStatus", "iconId": "deviceStatusIcon", - "iconClass": deviceData.devPresentLastScan == 1 ? "fa fa-check text-green" : "fa fa-xmark text-red", - "dataValue": deviceData.devPresentLastScan == 1 ? getString("Gen_Online") : getString("Gen_Offline") + "iconHtml": statusBadge.iconHtml, + "dataValue": statusText }, { "onclickEvent": "$('#tabSessions').trigger('click');", diff --git a/front/devices.php b/front/devices.php index 3de3080f8..a45ba8840 100755 --- a/front/devices.php +++ b/front/devices.php @@ -520,6 +520,22 @@ function mapColumnIndexToFieldName(index, tableColumnVisible) { } +// --------------------------------------------------------- +// Status badge helper for DataTables rowData (positional array). +// Uses mapIndx(COL.*) for reordered display fields and COL_EXTRA.* for extra fields. +function badgeFromRowData(rowData) { + return getStatusBadgeParts( + rowData[mapIndx(COL.devPresentLastScan)], + rowData[mapIndx(COL.devAlertDown)], + rowData[mapIndx(COL.devFlapping)], + rowData[mapIndx(COL.devMac)], + '', + rowData[COL_EXTRA.devIsSleeping] || 0, + rowData[COL_EXTRA.devIsArchived] || 0, + rowData[COL_EXTRA.devIsNew] || 0 + ); +} + // --------------------------------------------------------- // Initializes the main devices list datatable function initializeDatatable (status) { @@ -657,6 +673,11 @@ function initializeDatatable (status) { for (let index = 0; index < tableColumnOrder.length; index++) { newRow.push(originalRow[tableColumnOrder[index]]); } + // Append extra (non-display) fields after the display columns so + // they are accessible in createdCell via COL_EXTRA.* + GRAPHQL_EXTRA_FIELDS.forEach(field => { + newRow.push(device[field] ?? (NUMERIC_DEFAULTS.has(field) ? 0 : "")); + }); return newRow; }); } @@ -717,8 +738,11 @@ function initializeDatatable (status) { data-relationship="${rowData[mapIndx(COL.devParentRelType)]}" data-status="${rowData[mapIndx(COL.devStatus)]}" data-present="${rowData[mapIndx(COL.devPresentLastScan)]}" - data-alert="${rowData[mapIndx(COL.devAlertDown)]}" + data-alertdown="${rowData[mapIndx(COL.devAlertDown)]}" data-flapping="${rowData[mapIndx(COL.devFlapping)]}" + data-sleeping="${rowData[COL_EXTRA.devIsSleeping] || 0}" + data-archived="${rowData[COL_EXTRA.devIsArchived] || 0}" + data-isnew="${rowData[COL_EXTRA.devIsNew] || 0}" data-icon="${rowData[mapIndx(COL.devIcon)]}"> ${displayedValue} @@ -860,20 +884,9 @@ function initializeDatatable (status) { {targets: [mapIndx(COL.devStatus)], 'createdCell': function (td, cellData, rowData, row, col) { - tmp_devPresentLastScan = rowData[mapIndx(COL.devPresentLastScan)] - tmp_devAlertDown = rowData[mapIndx(COL.devAlertDown)] - tmp_devMac = rowData[mapIndx(COL.devMac)] - tmp_devFlapping = rowData[mapIndx(COL.devFlapping)] - - const badge = getStatusBadgeParts( - tmp_devPresentLastScan, // tmp_devPresentLastScan - tmp_devAlertDown, // tmp_devAlertDown - tmp_devFlapping, // tmp_devFlapping - tmp_devMac, // MAC - cellData // optional text - ); + const badge = badgeFromRowData(rowData); - $(td).html (`${badge.iconHtml} ${badge.text}`); + $(td).html(`${badge.iconHtml} ${badge.label}`); } }, ], diff --git a/front/js/device-columns.js b/front/js/device-columns.js index 5d68c9ec2..ea0420c59 100644 --- a/front/js/device-columns.js +++ b/front/js/device-columns.js @@ -75,8 +75,17 @@ const GRAPHQL_EXTRA_FIELDS = [ "devLastNotification", "devIsNew", "devIsArchived", + "devIsSleeping", ]; +// Row positions for extra (non-display) fields. +// In dataSrc, extra fields are appended AFTER the display columns in each row, +// so their position = DEVICE_COLUMN_FIELDS.length + their index in GRAPHQL_EXTRA_FIELDS. +// Use COL_EXTRA.fieldName to access them in createdCell rowData. +const COL_EXTRA = Object.fromEntries( + GRAPHQL_EXTRA_FIELDS.map((name, i) => [name, DEVICE_COLUMN_FIELDS.length + i]) +); + // Maps Device_TableHead_* language keys to their GraphQL/DB field names. // Used by getColumnNameFromLangString() in ui_components.js and by // column filter logic in devices.php. diff --git a/front/js/network-api.js b/front/js/network-api.js index 01addaefa..e1c28ffd9 100644 --- a/front/js/network-api.js +++ b/front/js/network-api.js @@ -16,15 +16,16 @@ function loadNetworkNodes() { // PC (leaf) <------- leafs are not included in this SQL query const rawSql = ` SELECT - parent.devName AS node_name, - LOWER(parent.devMac) AS node_mac, - parent.devPresentLastScan AS online, - parent.devType AS node_type, - LOWER(parent.devParentMAC) AS parent_mac, - parent.devIcon AS node_icon, - parent.devAlertDown AS node_alert, - parent.devFlapping AS node_flapping, - parent.devIsSleeping AS node_sleeping, + parent.devName, + LOWER(parent.devMac) AS devMac, + parent.devPresentLastScan, + parent.devType, + LOWER(parent.devParentMAC) AS devParentMAC, + parent.devIcon, + parent.devAlertDown, + parent.devFlapping, + parent.devIsSleeping, + parent.devIsNew, COUNT(child.devMac) AS node_ports_count FROM DevicesView AS parent LEFT JOIN DevicesView AS child @@ -35,7 +36,7 @@ function loadNetworkNodes() { WHERE parent.devType IN (${networkDeviceTypes}) AND parent.devIsArchived = 0 GROUP BY parent.devMac, parent.devName, parent.devPresentLastScan, - parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown, parent.devFlapping, parent.devIsSleeping + parent.devType, parent.devParentMAC, parent.devIcon, parent.devAlertDown, parent.devFlapping, parent.devIsSleeping, parent.devIsNew ORDER BY parent.devName; `; @@ -142,15 +143,8 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, data: 'devStatus', width: '15%', render: function (_, type, device) { - const badge = getStatusBadgeParts( - device.devPresentLastScan, - device.devAlertDown, - device.devFlapping, - device.devMac, - device.devStatus, - device.devIsSleeping || 0 - ); - return `${badge.iconHtml} ${badge.text}`; + const badge = badgeFromDevice(device); + return `${badge.iconHtml} ${badge.label}`; } }, { @@ -206,7 +200,7 @@ function loadDeviceTable({ sql, containerSelector, tableId, wrapperHtml = null, */ function loadUnassignedDevices() { const sql = ` - SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort, devFlapping, devIsSleeping, devStatus + SELECT devMac, devPresentLastScan, devName, devLastIP, devVendor, devAlertDown, devParentPort, devFlapping, devIsSleeping, devIsNew, devStatus FROM DevicesView WHERE (devParentMAC IS NULL OR devParentMAC IN ("", " ", "undefined", "null")) AND LOWER(devMac) NOT LIKE "%internet%" @@ -241,7 +235,7 @@ function loadConnectedDevices(node_mac) { const normalized_mac = node_mac.toLowerCase(); const sql = ` - SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, devFlapping, devIsSleeping, + SELECT devName, devMac, devLastIP, devVendor, devPresentLastScan, devAlertDown, devParentPort, devVlan, devFlapping, devIsSleeping, devIsNew, devIsArchived, CASE WHEN devIsNew = 1 THEN 'New' WHEN devPresentLastScan = 1 THEN 'On-line' diff --git a/front/js/network-tabs.js b/front/js/network-tabs.js index d995da313..855ba9e08 100644 --- a/front/js/network-tabs.js +++ b/front/js/network-tabs.js @@ -8,19 +8,19 @@ function renderNetworkTabs(nodes) { let html = ''; nodes.forEach((node, i) => { - const iconClass = node.online == 1 ? "text-green" : - (node.node_sleeping == 1 ? "text-aqua" : - (node.node_alert == 1 ? "text-red" : "text-gray50")); + const iconClass = node.devPresentLastScan == 1 ? "text-green" : + (node.devIsSleeping == 1 ? "text-aqua" : + (node.devAlertDown == 1 ? "text-red" : "text-gray50")); const portLabel = node.node_ports_count ? ` (${node.node_ports_count})` : ''; - const icon = atob(node.node_icon); - const id = node.node_mac.replace(/:/g, '_'); + const icon = atob(node.devIcon); + const id = node.devMac.replace(/:/g, '_'); html += `
  • - +
    ${icon}
    - ${node.node_name}${portLabel} + ${node.devName}${portLabel}
  • `; }); @@ -50,21 +50,14 @@ function renderNetworkTabContent(nodes) { $('.tab-content').empty(); nodes.forEach((node, i) => { - const id = node.node_mac.replace(/:/g, '_').toLowerCase(); + const id = node.devMac.replace(/:/g, '_').toLowerCase(); - const badge = getStatusBadgeParts( - node.online, - node.node_alert, - node.node_flapping, - node.node_mac, - '', - node.node_sleeping || 0 - ); + const badge = badgeFromDevice(node); - const badgeHtml = `${badge.iconHtml} ${badge.status}`; - const parentId = node.parent_mac.replace(/:/g, '_'); + const badgeHtml = `${badge.iconHtml} ${badge.label}`; + const parentId = node.devParentMAC.replace(/:/g, '_'); - isRootNode = node.parent_mac == ""; + isRootNode = node.devParentMAC == ""; const paneHtml = `
    @@ -73,18 +66,18 @@ function renderNetworkTabContent(nodes) {
    -
    ${node.node_mac}
    +
    ${node.devMac}
    -
    ${node.node_type}
    +
    ${node.devType}
    @@ -96,8 +89,8 @@ function renderNetworkTabContent(nodes) { @@ -115,7 +108,7 @@ function renderNetworkTabContent(nodes) { `; $('.tab-content').append(paneHtml); - loadConnectedDevices(node.node_mac); + loadConnectedDevices(node.devMac); }); } diff --git a/front/js/network-tree.js b/front/js/network-tree.js index 9e67cbceb..77bb3f392 100644 --- a/front/js/network-tree.js +++ b/front/js/network-tree.js @@ -57,26 +57,28 @@ function getChildren(node, list, path, visited = []) // console.log(node); return { - name: node.devName, + devName: node.devName, path: path, - mac: node.devMac, - port: node.devParentPort, + devMac: node.devMac, + devParentPort: node.devParentPort, id: node.devMac, - parentMac: node.devParentMAC, - icon: node.devIcon, - type: node.devType, + devParentMAC: node.devParentMAC, + devIcon: node.devIcon, + devType: node.devType, devIsNetworkNodeDynamic: node.devIsNetworkNodeDynamic, - vendor: node.devVendor, - lastseen: node.devLastConnection, - firstseen: node.devFirstConnection, - ip: node.devLastIP, - status: node.devStatus, - presentLastScan: node.devPresentLastScan, - flapping: node.devFlapping, - alertDown: node.devAlertDown, - sleeping: node.devIsSleeping || 0, + devVendor: node.devVendor, + devLastConnection: node.devLastConnection, + devFirstConnection: node.devFirstConnection, + devLastIP: node.devLastIP, + devStatus: node.devStatus, + devPresentLastScan: node.devPresentLastScan, + devFlapping: node.devFlapping, + devAlertDown: node.devAlertDown, + devIsSleeping: node.devIsSleeping || 0, + devIsArchived: node.devIsArchived || 0, + devIsNew: node.devIsNew || 0, hasChildren: children.length > 0 || hiddenMacs.includes(node.devMac), - relType: node.devParentRelType, + devParentRelType: node.devParentRelType, devVlan: node.devVlan, devSSID: node.devSSID, hiddenChildren: hiddenMacs.includes(node.devMac), @@ -227,16 +229,16 @@ function initTree(myHierarchy) htmlId: "networkTree", renderNode: nodeData => { - (!emptyArr.includes(nodeData.data.port )) ? port = nodeData.data.port : port = ""; + (!emptyArr.includes(nodeData.data.devParentPort)) ? port = nodeData.data.devParentPort : port = ""; (port == "" || port == 0 || port == 'None' ) ? portBckgIcon = `` : portBckgIcon = ``; portHtml = (port == "" || port == 0 || port == 'None' ) ? "   " : port; // Build HTML for individual nodes in the network diagram - deviceIcon = (!emptyArr.includes(nodeData.data.icon )) ? + deviceIcon = (!emptyArr.includes(nodeData.data.devIcon)) ? `
    - ${atob(nodeData.data.icon)} + ${atob(nodeData.data.devIcon)}
    ` : ""; devicePort = `
    @@ -253,13 +255,13 @@ function initTree(myHierarchy) `
    + data-mytreemac="${nodeData.data.devMac}">
    ` : ""; selectedNodeMac = $(".nav-tabs-custom .active a").attr('data-mytabmac') - highlightedCss = nodeData.data.mac == selectedNodeMac ? + highlightedCss = nodeData.data.devMac == selectedNodeMac ? " highlightedNode " : ""; cssNodeType = nodeData.data.devIsNetworkNodeDynamic ? " node-network-device " : " node-standard-device "; @@ -268,40 +270,35 @@ function initTree(myHierarchy) ` : ""; - const badgeConf = getStatusBadgeParts( - nodeData.data.presentLastScan, - nodeData.data.alertDown, - nodeData.data.flapping, - nodeData.data.mac, - '', - nodeData.data.sleeping || 0 - ); + const badgeConf = badgeFromDevice(nodeData.data); return result = `
    ${devicePort} ${deviceIcon} - ${nodeData.data.name} + ${nodeData.data.devName} ${networkHardwareIcon}
    @@ -318,7 +315,7 @@ function initTree(myHierarchy) hasPan: true, marginLeft: '10', marginRight: '10', - idKey: "mac", + idKey: "devMac", hasFlatData: false, relationnalField: "children", linkLabel: { @@ -339,7 +336,7 @@ function initTree(myHierarchy) }, linkWidth: (nodeData) => 2, linkColor: (nodeData) => { - relConf = getRelationshipConf(nodeData.data.relType) + relConf = getRelationshipConf(nodeData.data.devParentRelType) return relConf.color; } // onNodeClick: (nodeData) => handleNodeClick(nodeData), diff --git a/front/js/ui_components.js b/front/js/ui_components.js index 7a4f64b18..7ac966f15 100755 --- a/front/js/ui_components.js +++ b/front/js/ui_components.js @@ -738,37 +738,54 @@ function getColumnNameFromLangString(headStringKey) { //-------------------------------------------------------------- // Generating the device status chip -function getStatusBadgeParts(devPresentLastScan, devAlertDown, devFlapping, devMac, statusText = '', devIsSleeping = 0) { - let css = 'bg-gray text-white statusUnknown'; - let icon = ''; - let status = 'unknown'; +function getStatusBadgeParts(devPresentLastScan, devAlertDown, devFlapping, devMac, statusText = '', devIsSleeping = 0, devIsArchived = 0, devIsNew = 0) { + let css = 'bg-gray text-white statusUnknown'; + let icon = ''; + let status = 'unknown'; let cssText = ''; + let label = getString('Gen_Offline'); if (devPresentLastScan == 1 && devFlapping == 0) { - css = 'bg-green text-white statusOnline'; + css = 'bg-green text-white statusOnline'; cssText = 'text-green'; - icon = ''; - status = 'online'; + icon = ''; + status = 'online'; + label = getString('Gen_Online'); } else if (devPresentLastScan == 1 && devFlapping == 1) { - css = 'bg-yellow text-white statusFlapping'; + css = 'bg-yellow text-white statusFlapping'; cssText = 'text-yellow'; - icon = ''; - status = 'online'; + icon = ''; + status = 'flapping'; + label = getString('Gen_Flapping'); } else if (devIsSleeping == 1) { - css = 'bg-aqua text-white statusSleeping'; + css = 'bg-aqua text-white statusSleeping'; cssText = 'text-aqua'; - icon = ''; - status = 'sleeping'; + icon = ''; + status = 'sleeping'; + label = getString('Gen_Sleeping'); + } else if (devIsArchived == 1) { + css = 'bg-gray text-white statusArchived'; + cssText = 'text-gray50'; + icon = ''; + status = 'archived'; + label = getString('Gen_Archived'); } else if (devAlertDown == 1) { - css = 'bg-red text-white statusDown'; + css = 'bg-red text-white statusDown'; cssText = 'text-red'; - icon = ''; - status = 'down'; + icon = ''; + status = 'down'; + label = getString('Gen_Down'); } else if (devPresentLastScan != 1) { - css = 'bg-gray text-white statusOffline'; + css = 'bg-gray text-white statusOffline'; cssText = 'text-gray50'; - icon = ''; - status = 'offline'; + icon = ''; + status = 'offline'; + label = getString('Gen_Offline'); + } + + // New devices keep the online/offline color & icon but show "New" as label + if (devIsNew == 1) { + label = getString('Gen_New'); } const cleanedText = statusText.replace(/-/g, ''); @@ -776,15 +793,36 @@ function getStatusBadgeParts(devPresentLastScan, devAlertDown, devFlapping, devM return { cssClass: css, - cssText: cssText, + cssText: cssText, iconHtml: icon, - mac: devMac, - text: cleanedText, - status: status, - url: url + mac: devMac, + text: cleanedText, + status: status, + label: label, + url: url }; } +// Convenience wrappers — call getStatusBadgeParts with the right fields +// for each object shape used across the codebase. + +// Any object with devXxx field names (API response, cache, SQL DevicesView row, +// network-api nodes, network-tree nodeData.data objects) +function badgeFromDevice(d) { + return getStatusBadgeParts( + d.devPresentLastScan, d.devAlertDown, d.devFlapping, d.devMac, + '', d.devIsSleeping || 0, d.devIsArchived || 0, d.devIsNew || 0 + ); +} + +// hover-box: reads status fields from jQuery data-* attributes on an element +function badgeFromDataAttrs($el) { + return getStatusBadgeParts( + $el.data('present'), $el.data('alertdown'), $el.data('flapping') || 0, $el.data('mac'), + '', $el.data('sleeping') || 0, $el.data('archived') || 0, $el.data('isnew') || 0 + ); +} + //-------------------------------------------------------------- // Getting the color and css class for device relationships function getRelationshipConf(relType) { @@ -930,14 +968,7 @@ function renderDeviceLink(data, container, useName = false) { } // Build and return badge parts - const badge = getStatusBadgeParts( - device.devPresentLastScan, - device.devAlertDown, - device.devFlapping, - device.devMac, - '', - device.devIsSleeping || 0 - ); + const badge = badgeFromDevice(device); // badge class and hover-info class to container $(container) @@ -954,8 +985,10 @@ function renderDeviceLink(data, container, useName = false) { 'data-status': device.devStatus, 'data-flapping': device.devFlapping, 'data-present': device.devPresentLastScan, - 'data-alert': device.devAlertDown, + 'data-alertdown': device.devAlertDown, 'data-sleeping': device.devIsSleeping || 0, + 'data-archived': device.devIsArchived || 0, + 'data-isnew': device.devIsNew || 0, 'data-icon': device.devIcon }); @@ -1024,10 +1057,8 @@ function initHoverNodeInfo() { const lastseen = $el.data('lastseen') || 'Unknown'; const firstseen = $el.data('firstseen') || 'Unknown'; const relationship = $el.data('relationship') || 'Unknown'; - const flapping = $el.data('flapping') || 0; - const sleeping = $el.data('sleeping') || 0; - const badge = getStatusBadgeParts( $el.data('present'), $el.data('alert'), flapping, $el.data('mac'), '', sleeping) - const status =`${badge.iconHtml} ${badge.status}` + const badge = badgeFromDataAttrs($el); + const status =`${badge.iconHtml} ${badge.label}` const html = `
    diff --git a/front/php/components/device_cards.php b/front/php/components/device_cards.php index 0ca24e446..5db88f9d6 100755 --- a/front/php/components/device_cards.php +++ b/front/php/components/device_cards.php @@ -14,12 +14,13 @@ function renderSmallBox($params) { $labelLang = isset($params['labelLang']) ? $params['labelLang'] : ''; $iconId = isset($params['iconId']) ? $params['iconId'] : ''; $iconClass = isset($params['iconClass']) ? $params['iconClass'] : ''; + $iconHtml = isset($params['iconHtml']) ? $params['iconHtml'] : ''; $dataValue = isset($params['dataValue']) ? $params['dataValue'] : ''; return '
    - -
    + '; } diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index f56b4d5a2..560449871 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "إضافة جهاز", "Gen_Add_All": "إضافة الكل", "Gen_All_Devices": "جميع الأجهزة", + "Gen_Archived": "", "Gen_AreYouSure": "هل أنت متأكد؟", "Gen_Backup": "نسخة احتياطية", "Gen_Cancel": "إلغاء", @@ -326,8 +327,10 @@ "Gen_Delete": "حذف", "Gen_DeleteAll": "حذف الكل", "Gen_Description": "الوصف", + "Gen_Down": "", "Gen_Error": "خطأ", "Gen_Filter": "تصفية", + "Gen_Flapping": "", "Gen_Generate": "إنشاء", "Gen_InvalidMac": "عنوان MAC غير صالح.", "Gen_Invalid_Value": "تم إدخال قيمة غير صالحة", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "اختيار أيقونة", "Gen_SelectToPreview": "اختر للمعاينة", "Gen_Selected_Devices": "الأجهزة المحددة", + "Gen_Sleeping": "", "Gen_Subnet": "الشبكة الفرعية", "Gen_Switch": "تبديل", "Gen_Upd": "تحديث", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 913751a70..501d719c0 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Afegir dispositiu", "Gen_Add_All": "Afegeix tot", "Gen_All_Devices": "Tots els dispositius", + "Gen_Archived": "", "Gen_AreYouSure": "Estàs segur?", "Gen_Backup": "Executar Backup", "Gen_Cancel": "Cancel·lar", @@ -326,8 +327,10 @@ "Gen_Delete": "Esborrar", "Gen_DeleteAll": "Esborrar tot", "Gen_Description": "Descripció", + "Gen_Down": "", "Gen_Error": "Error", "Gen_Filter": "Filtrar", + "Gen_Flapping": "", "Gen_Generate": "Generar", "Gen_InvalidMac": "Mac address invàlida.", "Gen_Invalid_Value": "S'ha introduït un valor incorrecte", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Seleccioneu la vista prèvia", "Gen_Selected_Devices": "Dispositius seleccionats:", + "Gen_Sleeping": "", "Gen_Subnet": "Subxarxa", "Gen_Switch": "Switch", "Gen_Upd": "Actualitzat correctament", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index 6218deaf8..52d074c57 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "", "Gen_Add_All": "Přidat vše", "Gen_All_Devices": "Všechna zařízení", + "Gen_Archived": "", "Gen_AreYouSure": "Jste si jistý?", "Gen_Backup": "Spustit zálohování", "Gen_Cancel": "Zrušit", @@ -326,8 +327,10 @@ "Gen_Delete": "Smazat", "Gen_DeleteAll": "Smazat vše", "Gen_Description": "Popis", + "Gen_Down": "", "Gen_Error": "Chyba", "Gen_Filter": "Filtr", + "Gen_Flapping": "", "Gen_Generate": "Vygenerovat", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Vybrat na náhled", "Gen_Selected_Devices": "Vybraná zařízení:", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "Přepnout", "Gen_Upd": "Úspěšně aktualizováno", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index e389972ec..64187af06 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -320,6 +320,7 @@ "Gen_AddDevice": "Gerät hinzufügen", "Gen_Add_All": "Alle hinzufügen", "Gen_All_Devices": "Alle Geräte", + "Gen_Archived": "", "Gen_AreYouSure": "Sind Sie sich sicher?", "Gen_Backup": "Sichern", "Gen_Cancel": "Abbrechen", @@ -330,8 +331,10 @@ "Gen_Delete": "Löschen", "Gen_DeleteAll": "Alles löschen", "Gen_Description": "Beschreibung", + "Gen_Down": "", "Gen_Error": "Fehler", "Gen_Filter": "Filter", + "Gen_Flapping": "", "Gen_Generate": "Generieren", "Gen_InvalidMac": "Ungültige MAC-Adresse.", "Gen_Invalid_Value": "", @@ -354,6 +357,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Zur Vorschau auswählen", "Gen_Selected_Devices": "Ausgewählte Geräte:", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "Umschalten", "Gen_Upd": "Aktualisierung erfolgreich", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 56407e163..646d1dac4 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -139,7 +139,7 @@ "DevDetail_SessionTable_Duration": "Duration", "DevDetail_SessionTable_IP": "IP", "DevDetail_SessionTable_Order": "Order", - "DevDetail_Shortcut_CurrentStatus": "Current Status", + "DevDetail_Shortcut_CurrentStatus": "Status", "DevDetail_Shortcut_DownAlerts": "Down Alerts", "DevDetail_Shortcut_Presence": "Presence", "DevDetail_Shortcut_Sessions": "Sessions", @@ -316,6 +316,7 @@ "Gen_AddDevice": "Add device", "Gen_Add_All": "Add all", "Gen_All_Devices": "All devices", + "Gen_Archived": "Archived", "Gen_AreYouSure": "Are you sure?", "Gen_Backup": "Run Backup", "Gen_Cancel": "Cancel", @@ -326,13 +327,16 @@ "Gen_Delete": "Delete", "Gen_DeleteAll": "Delete all", "Gen_Description": "Description", + "Gen_Down": "Down", "Gen_Error": "Error", "Gen_Filter": "Filter", + "Gen_Flapping": "Flapping", "Gen_Generate": "Generate", "Gen_InvalidMac": "Invalid Mac address.", "Gen_Invalid_Value": "An invalid value was entered", "Gen_LockedDB": "ERROR - DB might be locked - Check F12 Dev tools -> Console or try later.", "Gen_NetworkMask": "Network mask", + "Gen_New": "New", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", @@ -350,6 +354,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Select to preview", "Gen_Selected_Devices": "Selected devices:", + "Gen_Sleeping": "Sleeping", "Gen_Subnet": "Subnet", "Gen_Switch": "Switch", "Gen_Upd": "Updated successfully", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index b0cf34db1..7d2cb9b93 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -318,6 +318,7 @@ "Gen_AddDevice": "Añadir dispositivo", "Gen_Add_All": "Añadir todo", "Gen_All_Devices": "Todos los dispositivos", + "Gen_Archived": "", "Gen_AreYouSure": "¿Estás seguro?", "Gen_Backup": "Ejecutar copia de seguridad", "Gen_Cancel": "Cancelar", @@ -328,8 +329,10 @@ "Gen_Delete": "Eliminar", "Gen_DeleteAll": "Eliminar todo", "Gen_Description": "Descripción", + "Gen_Down": "", "Gen_Error": "Error", "Gen_Filter": "Filtro", + "Gen_Flapping": "", "Gen_Generate": "Generar", "Gen_InvalidMac": "Dirección MAC inválida.", "Gen_Invalid_Value": "Un valor inválido fue ingresado", @@ -352,6 +355,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Seleccionar para previsualizar", "Gen_Selected_Devices": "Dispositivos seleccionados:", + "Gen_Sleeping": "", "Gen_Subnet": "Subred", "Gen_Switch": "Cambiar", "Gen_Upd": "Actualizado correctamente", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 977f5ed00..5354403d6 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "", "Gen_Add_All": "", "Gen_All_Devices": "", + "Gen_Archived": "", "Gen_AreYouSure": "", "Gen_Backup": "", "Gen_Cancel": "", @@ -326,8 +327,10 @@ "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", + "Gen_Down": "", "Gen_Error": "", "Gen_Filter": "", + "Gen_Flapping": "", "Gen_Generate": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "", "Gen_Selected_Devices": "", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "", "Gen_Upd": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 45967273a..562af0b07 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Ajouter un appareil", "Gen_Add_All": "Ajouter tous", "Gen_All_Devices": "Tous les appareils", + "Gen_Archived": "", "Gen_AreYouSure": "Êtes-vous sûr ?", "Gen_Backup": "Lancer la sauvegarde", "Gen_Cancel": "Annuler", @@ -326,8 +327,10 @@ "Gen_Delete": "Supprimer", "Gen_DeleteAll": "Supprimer tous", "Gen_Description": "Description", + "Gen_Down": "", "Gen_Error": "Erreur", "Gen_Filter": "Filtrer", + "Gen_Flapping": "", "Gen_Generate": "Générer", "Gen_InvalidMac": "Adresse MAC invalide.", "Gen_Invalid_Value": "Une valeur invalide a été renseignée", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Sélectionnez pour prévisualiser", "Gen_Selected_Devices": "Appareils sélectionnés :", + "Gen_Sleeping": "", "Gen_Subnet": "Sous-réseau", "Gen_Switch": "Basculer", "Gen_Upd": "Mise à jour réussie", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 537d31bea..acc92d454 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Aggiungi dispositivo", "Gen_Add_All": "Aggiungi tutti", "Gen_All_Devices": "Tutti i dispositivi", + "Gen_Archived": "", "Gen_AreYouSure": "Sei sicuro?", "Gen_Backup": "Esegui backup", "Gen_Cancel": "Annulla", @@ -326,8 +327,10 @@ "Gen_Delete": "Elimina", "Gen_DeleteAll": "Elimina tutti", "Gen_Description": "Descrizione", + "Gen_Down": "", "Gen_Error": "Errore", "Gen_Filter": "Filtro", + "Gen_Flapping": "", "Gen_Generate": "Genera", "Gen_InvalidMac": "Indirizzo Mac non valido.", "Gen_Invalid_Value": "È stato inserito un valore non valido", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Seleziona per anteprima", "Gen_Selected_Devices": "Dispositivi selezionati:", + "Gen_Sleeping": "", "Gen_Subnet": "Sottorete", "Gen_Switch": "Cambia", "Gen_Upd": "Aggiornato correttamente", diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 7dfef8c71..083ea9ea2 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "デバイス追加", "Gen_Add_All": "すべて追加", "Gen_All_Devices": "全デバイス", + "Gen_Archived": "", "Gen_AreYouSure": "本当によろしいですか?", "Gen_Backup": "バックアップを実行", "Gen_Cancel": "キャンセル", @@ -326,8 +327,10 @@ "Gen_Delete": "削除", "Gen_DeleteAll": "全削除", "Gen_Description": "説明", + "Gen_Down": "", "Gen_Error": "エラー", "Gen_Filter": "フィルター", + "Gen_Flapping": "", "Gen_Generate": "生成", "Gen_InvalidMac": "無効なMacアドレス。", "Gen_Invalid_Value": "無効な値が入力されました", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "プレビューを選択", "Gen_Selected_Devices": "選択したデバイス:", + "Gen_Sleeping": "", "Gen_Subnet": "サブネット", "Gen_Switch": "スイッチ", "Gen_Upd": "アップデートが正常に完了しました", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index f598d18e3..49ff0ab59 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "", "Gen_Add_All": "Legg til alle", "Gen_All_Devices": "", + "Gen_Archived": "", "Gen_AreYouSure": "Er du sikker?", "Gen_Backup": "Kjør sikkerhetskopiering", "Gen_Cancel": "Avbryt", @@ -326,8 +327,10 @@ "Gen_Delete": "Slett", "Gen_DeleteAll": "Slett alle", "Gen_Description": "", + "Gen_Down": "", "Gen_Error": "Feil", "Gen_Filter": "Filter", + "Gen_Flapping": "", "Gen_Generate": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "", "Gen_Selected_Devices": "Valgte Enheter:", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "Bytt", "Gen_Upd": "Oppdatering vellykket", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index c358bcd42..369216700 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Dodaj urządzenie", "Gen_Add_All": "Dodaj wszystko", "Gen_All_Devices": "Wszystkie urządzenia", + "Gen_Archived": "", "Gen_AreYouSure": "Jesteś pewien?", "Gen_Backup": "Uruchom kopię zapasową", "Gen_Cancel": "Anuluj", @@ -326,8 +327,10 @@ "Gen_Delete": "Usuń", "Gen_DeleteAll": "Usuń wszystko", "Gen_Description": "Opis", + "Gen_Down": "", "Gen_Error": "Błąd", "Gen_Filter": "Filtr", + "Gen_Flapping": "", "Gen_Generate": "Wygeneruj", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Wybierz, aby podglądnąć", "Gen_Selected_Devices": "Wybrane urządzenia:", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "Switch", "Gen_Upd": "Zaktualizowano pomyślnie", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index dfa61fc9a..f6b44cc14 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Adicionar dispositivo", "Gen_Add_All": "Adicionar todos", "Gen_All_Devices": "Todos os Dispositivos", + "Gen_Archived": "", "Gen_AreYouSure": "Tem certeza?", "Gen_Backup": "Executar backup", "Gen_Cancel": "Cancelar", @@ -326,8 +327,10 @@ "Gen_Delete": "Excluir", "Gen_DeleteAll": "Excluir todos", "Gen_Description": "Descrição", + "Gen_Down": "", "Gen_Error": "Erro", "Gen_Filter": "Filtro", + "Gen_Flapping": "", "Gen_Generate": "Gerar", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Selecionar para pré-visualizar", "Gen_Selected_Devices": "Dispositivos selecionados:", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "Trocar", "Gen_Upd": "Atualizado com sucesso", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index eed329f3d..880988629 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Adicionar dispositivo", "Gen_Add_All": "Adicionar todos", "Gen_All_Devices": "Todos os dispostivos", + "Gen_Archived": "", "Gen_AreYouSure": "Tem certeza?", "Gen_Backup": "Executar backup", "Gen_Cancel": "Cancelar", @@ -326,8 +327,10 @@ "Gen_Delete": "Apagar", "Gen_DeleteAll": "Apagar todos", "Gen_Description": "Descrição", + "Gen_Down": "", "Gen_Error": "Erro", "Gen_Filter": "Filtro", + "Gen_Flapping": "", "Gen_Generate": "Gerar", "Gen_InvalidMac": "Endereço MAC Inválido.", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Selecionar para pré-visualizar", "Gen_Selected_Devices": "Seleciona dispostivos:", + "Gen_Sleeping": "", "Gen_Subnet": "Sub-rede", "Gen_Switch": "Trocar", "Gen_Upd": "Atualizado com sucesso", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 0c85c6ee5..ae4349c0a 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Добавить устройство", "Gen_Add_All": "Добавить все", "Gen_All_Devices": "Все устройства", + "Gen_Archived": "", "Gen_AreYouSure": "Вы уверены?", "Gen_Backup": "Запустить резервное копирование", "Gen_Cancel": "Отмена", @@ -326,8 +327,10 @@ "Gen_Delete": "Удалить", "Gen_DeleteAll": "Удалить все", "Gen_Description": "Описание", + "Gen_Down": "", "Gen_Error": "Ошибка", "Gen_Filter": "Фильтр", + "Gen_Flapping": "", "Gen_Generate": "Генерировать", "Gen_InvalidMac": "Неверный Mac-адрес.", "Gen_Invalid_Value": "Введено некорректное значение", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Выберите для предварительного просмотра", "Gen_Selected_Devices": "Выбранные устройства:", + "Gen_Sleeping": "", "Gen_Subnet": "Подсеть", "Gen_Switch": "Переключить", "Gen_Upd": "Успешное обновление", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 78e910399..380b25200 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "", "Gen_Add_All": "", "Gen_All_Devices": "", + "Gen_Archived": "", "Gen_AreYouSure": "", "Gen_Backup": "", "Gen_Cancel": "", @@ -326,8 +327,10 @@ "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", + "Gen_Down": "", "Gen_Error": "", "Gen_Filter": "", + "Gen_Flapping": "", "Gen_Generate": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "", "Gen_Selected_Devices": "", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "", "Gen_Upd": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 5cae0ca6e..489ef0d83 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Cihaz Ekle", "Gen_Add_All": "Tümünü ekle", "Gen_All_Devices": "Tüm Cihazlar", + "Gen_Archived": "", "Gen_AreYouSure": "Emin misiniz?", "Gen_Backup": "Yedeklemeyi Çalıştır", "Gen_Cancel": "İptal", @@ -326,8 +327,10 @@ "Gen_Delete": "Sil", "Gen_DeleteAll": "Tümünü sil", "Gen_Description": "Açıklama", + "Gen_Down": "", "Gen_Error": "Hata", "Gen_Filter": "Filtre", + "Gen_Flapping": "", "Gen_Generate": "Oluştur", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Önizleme yapmak için seçin", "Gen_Selected_Devices": "Seçilmiş Cihazlar:", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "Switch", "Gen_Upd": "Başarılı bir şekilde güncellendi", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 448280fcb..5028a9129 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "Додати пристрій", "Gen_Add_All": "Додати все", "Gen_All_Devices": "Усі пристрої", + "Gen_Archived": "", "Gen_AreYouSure": "Ви впевнені?", "Gen_Backup": "Запустіть резервне копіювання", "Gen_Cancel": "Скасувати", @@ -326,8 +327,10 @@ "Gen_Delete": "Видалити", "Gen_DeleteAll": "Видалити все", "Gen_Description": "Опис", + "Gen_Down": "", "Gen_Error": "Помилка", "Gen_Filter": "Фільтр", + "Gen_Flapping": "", "Gen_Generate": "Генерувати", "Gen_InvalidMac": "Недійсна Mac-адреса.", "Gen_Invalid_Value": "Введено недійсне значення", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Виберіть для попереднього перегляду", "Gen_Selected_Devices": "Вибрані пристрої:", + "Gen_Sleeping": "", "Gen_Subnet": "Підмережа", "Gen_Switch": "Перемикач", "Gen_Upd": "Оновлено успішно", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 78e910399..380b25200 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "", "Gen_Add_All": "", "Gen_All_Devices": "", + "Gen_Archived": "", "Gen_AreYouSure": "", "Gen_Backup": "", "Gen_Cancel": "", @@ -326,8 +327,10 @@ "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", + "Gen_Down": "", "Gen_Error": "", "Gen_Filter": "", + "Gen_Flapping": "", "Gen_Generate": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "", "Gen_Selected_Devices": "", + "Gen_Sleeping": "", "Gen_Subnet": "", "Gen_Switch": "", "Gen_Upd": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 935217606..9b4709f6e 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -316,6 +316,7 @@ "Gen_AddDevice": "添加设备", "Gen_Add_All": "全部添加", "Gen_All_Devices": "所有设备", + "Gen_Archived": "", "Gen_AreYouSure": "你确定吗?", "Gen_Backup": "运行备份", "Gen_Cancel": "取消", @@ -326,8 +327,10 @@ "Gen_Delete": "删除", "Gen_DeleteAll": "全部删除", "Gen_Description": "描述", + "Gen_Down": "", "Gen_Error": "错误", "Gen_Filter": "筛选", + "Gen_Flapping": "", "Gen_Generate": "生成", "Gen_InvalidMac": "无效的 Mac 地址。", "Gen_Invalid_Value": "输入了无效的值", @@ -350,6 +353,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "选择预览", "Gen_Selected_Devices": "选定的设备:", + "Gen_Sleeping": "", "Gen_Subnet": "子网", "Gen_Switch": "交换", "Gen_Upd": "已成功更新", From 50125f07002ff42f449b96f359a30825eea72ccb Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 2 Mar 2026 20:54:09 +1100 Subject: [PATCH 055/122] DOCS: statuses Signed-off-by: jokob-sk --- docs/DEVICE_DISPLAY_SETTINGS.md | 4 +++- .../device_management_status_archived.png | Bin 0 -> 2245 bytes .../device_management_status_archived_new.png | Bin 0 -> 1677 bytes .../device_management_status_sleeping.png | Bin 0 -> 2582 bytes 4 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_archived.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_archived_new.png create mode 100644 docs/img/DEVICE_MANAGEMENT/device_management_status_sleeping.png diff --git a/docs/DEVICE_DISPLAY_SETTINGS.md b/docs/DEVICE_DISPLAY_SETTINGS.md index 6a3d5b85c..428763c7b 100755 --- a/docs/DEVICE_DISPLAY_SETTINGS.md +++ b/docs/DEVICE_DISPLAY_SETTINGS.md @@ -14,8 +14,10 @@ This set of settings allows you to group Devices under different views. The Arch | | New (Green) | ![Status color - new online](./img/DEVICE_MANAGEMENT/device_management_status_new_online.png) | A newly discovered device that is online and is still marked as a "New Device". | | | Online (Orange) | ![Status color - flapping online](./img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png) | The device is online, but unstable and flapping (3 status changes in the last hour). | | | New (Grey) | ![Status color - new offline](./img/DEVICE_MANAGEMENT/device_management_status_new_offline.png) | Same as "New (Green)" but the device is now offline. | +| | New (Grey) | ![Status color - new archived](./img/DEVICE_MANAGEMENT/device_management_status_archived_new.png) | Same as "New (Green)" but the device is now offline and archived. | | | Offline (Grey) | ![Status color - offline](./img/DEVICE_MANAGEMENT/device_management_status_offline.png) | A device that was not detected online in the last scan. | -| | Sleeping (Aqua) | | A device with **Can Sleep** enabled that has gone offline within the `NTFPRCS_sleep_time` window. No down alert is fired while the device is in this state. See [Notifications](./NOTIFICATIONS.md#device-settings). | +| | Archived (Grey) | ![Status color - archived](./img/DEVICE_MANAGEMENT/device_management_status_archived.png) | A device that was not detected online in the last scan. | +| | Sleeping (Aqua) | ![Status color - sleeping](./img/DEVICE_MANAGEMENT/device_management_status_sleeping.png) | A device with **Can Sleep** enabled that has gone offline within the `NTFPRCS_sleep_time` window. No down alert is fired while the device is in this state. See [Notifications](./NOTIFICATIONS.md#device-settings). | | | Down (Red) | ![Status color - down](./img/DEVICE_MANAGEMENT/device_management_status_down.png) | A device marked as "Alert Down" and offline for the duration set in `NTFPRCS_alert_down_time`.| diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_archived.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_archived.png new file mode 100644 index 0000000000000000000000000000000000000000..16d555c051054be2e4cdfc33d7d5364bcf3e12d9 GIT binary patch literal 2245 zcmV;$2s-zPP)l%ljjw!T2TPy{Y?hZ&R%2pHtM-3V|jyT15 zXJWYUkMcqj5@TWvAtrjE;)QWvfVweiO%bJqG8Tl=+Oil5_FObw%AzBV$Mi{>wD0?V zYc>6?@AnOBRra6%o{Ll9k3=pF6!>#cN(1>&QLe{i)MM0_pexp(QmK$hiwfjL9EinY zM1>`Sp$H#?A!g=6(z2Y`l(@F-mDnv+=!%P^sRG}K3{b077|T=`%Sx#EYb#M<5#Q(( z!{Y&Ic}DUQS6`x~-et#FRw7Lm_<`t(i}>4RXM2?i_d6e<$bz&aB{`3|+sx&(Ht#72 z`wKCamC)L}hi&F^X(}Z-iM!2QPGh~ZFrNMf$biOrXHEiVwPk&Ymi_fkX;`GBq=cH9 z8f3D~OCO0u`0(L_G+ZD9`|F)72(L3*bXIZeUG|JU_W1GR)YQ~S!+F!$*~#?uv^4xf z(dl$ptybpd=9rqAlBPuz3I!Dv6&Y*37sbWJ*zI;gp%CNa?F}np(%nlP9@y;b&`=!pFS}=J1Y%~ z$mMd*pFfXAqv6%7S3G?9P+Ias2?PRS5C7e;TdLA7k7N&!44X5v$dTMx!AV3gL7*>FMdoSpU80@9(F-zh4^uEzlJgQBkf>xzUkUT$3?( z`#XB{D7$y>mWFfY+O=zh!(nMy#OZXhyu3_bUmqt=p2T9YFfuYC=FMg^CX)$|$Aea@ zrMkMBrKKf&J|6%ol?t=jj7Fm&5C{;BM#Z)O@pxi;-DEPM*Xx;@np#(rJpSnDC;%3V zCFPjta5yL`Dq>(@fVH(XATi;rtgIv&jWRPcBgVg`tgH;P*-SVb7QahAn2dRfYt&~R zYkwAUxt!YCTKs-LUauEOi2b#=E|-gzmKFp-;Nr!LC=?2MdwcQueC*u0lVit@QB?HB zg`Pco#*-&c#Lq^fk*im)QeK`|Uc_QCy1To@b0m-7-QCT^!~~~Konn4|p6l1Ii^ou_ z)m*r6fp9p?+qZ8~C=?t%e3+)DCb4FAc9!n$ZszCbfy6|ut*woQh6b@FnahWCTBQ|N zSFDkSa%OdPmCnviR##W0DH4qihSQR?W}AdU|>Yhr_sBE|!*-*0pmu9E^>P(bw09!C>IPfdd>la)i##&UJMF zf*|1c`?1+gp0dcXV_B(ACw&ASC0b%#T^$t_70BiC z4HpoA$K%0fvr$`HOK)#40JXKX01ONapw((=Zf<6JdYb$9?-P&585tR&w6v6#mKKag zBZ45%)YOC^2;9DXn^-IcBzo@U%a_G-K;T^wn}jS5Q`%YLo6NlU(W?;fGhm)T%bQxh(iE2T9j`Sj@%tE;OR z3@Qcd`VAFkJ#pGl1`NGFqMj^uzn7a zB<;>aFcgu7ekw^zURYSbKwE)b_%&@Yu!j2s~C@U)yYiu@~ z_&!(CX_dC;ehh}F`D?2*oRV+t6{*@ME&289*U}Vzzn`_WHPM>W@%Zs$s;jGMYiq;l zbP|a~#2$0)+BHdaeypvn@&5gL>gwuHC=@(?{8-GbtgP_p(Id{EKTk(T2R@&VSS-fY zty_u3Vsv(Pvb3~BUtb@st*vx)bl~^pIH##~>QBjd7Rw}~bFgI@8z~k{yT3U+LY9$Z|aOcjQ zlr#B!@pwEa6bj<;ID>vDsGgWSA%ld-Wes;jH9*=&TvVV*vHDvHQq#c|K{50agzwSXMrnBE$&T7j^CZ^|D5MCD^ZT*D=!;!omZOvI+NLchg zFWiNP(tk2P<)QQ>!{Y&l#{-4Gll%aF`QJ&B%*<{6#07plIZdbv{C`pq_YdZOy|;NF TQ?Ow100000NkvXXu0mjf=W0<9 literal 0 HcmV?d00001 diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_archived_new.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_archived_new.png new file mode 100644 index 0000000000000000000000000000000000000000..ba6a6357c8bae4d414211aca9c4b2d9b29c1570b GIT binary patch literal 1677 zcmV;826Fj{P)LpIrKP;I(w;@r#y&`A3g=YO zUI_{O?q{atn&~~y%si@p{rk@%O&Wq)44VA^Xv%IN8ZkHsC(H(VOa@E_J)OFCT3cID ziGz+9MIfKglga)=BAI4Akt7yRio@#SP_mu<7F?2*PTeuFbmUP1+SXP~-L06q^_+Cr z$Yi(q6p9d7`z#Ju#DQcR^c@VHa$)M$i=|(hPTetvPr2x~_TXFkL^8c44yj4qJ-e^h z%*6P}ZzuX&Ox=1W#z*MuHH)R1)Fr#G*UTS714q6e|1VGhe+&)OC0ubEG3YxO9U2hh z3Kok6o6RQ1brFd~h(sb{{8KYJG{9E&Z$&LdvFxE!u9|1`ty{NfYikqZy4cy-;qKkL zV*FF1qU2_N!6%mXps0;F`z^9P`mmy5|9u#ZMg|85vD@up3A5SE;NT!eqfsn13{$rr zXTL>^_n=6&OR|d59+sDv$>nn7^Z5h)@p`>XPfw%M>8ffDhXb$I z``rW4NR&8|Z6hbl21WDe$B!SQ*XvQM6&=4l6bc2>>9iPEq19@+c=007pFbB%Kb%h8 zF;18bHE(=mW%rm2Vze)}Z{Mb?t4oX(vAMa)y?gh>xQg}lbw)-;SX^9WV`D=s??JEE zW3gDsWHQ8JF?M%%0dTwBw6(Rdw6w(6uU`SMTCJGPW+IUY@pv47_V#v8o;*o1nPhEk zt+L4`}S?+o8xl1@Or(>&dw^5E$2#BcBiggj1&_Nhk5$+ zsTc!r?bAG5)20E>gwX`*|V5T zrpk8Zi>lRXMWfO1;ll^qZa1Bson$f@BuQd>dz)A+_RS~kkDM!6*@r=;QgQtFaWOtH zDwT?8G|Ibo?-(2$WO#VE(sRpLEEd#iHN|3)iHV6y-qX_ql%|m%KYk=0k7Kjh&}cN3 zGuZF<<954|B#BTcgwbeZX=$mlkHaD>JD<;^l{sRKW^Qhdfq?-oT)4oiSFfsSkBJNH$oO1EagV35(#QEWCFk|dEzr7CB7#3E|5(QM^knKY07ROP1y~kDZ7C*WjBzf><02* XpJZ_^b)-a$00000NkvXXu0mjfX6#0P literal 0 HcmV?d00001 diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_sleeping.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_sleeping.png new file mode 100644 index 0000000000000000000000000000000000000000..d7309f2201c221e7a2d29dd1fc4dfe555769807d GIT binary patch literal 2582 zcmV+x3hDKUP)F1N700009a7bBm000ie z000ie0hKEb8vp6J;-sm`>S#@I~EOwuG8kHl7up&s!_#bAP1lAtIE$_)W_*%Me; zcKgT5y8C0)H2#NJMky0*?W7S+MlW6*One7nIrmI(=c29`E zgt>9yWJV~_nFcWo4%vMYu`iYDSCSGonXMHy4p-ei-d%Y&TOOE4Pu~C)tzAO*En~<( z5b@9eGM!538l8En*qK6;OZ>&Y?0sYjS&>S+ZNNNC@%F>C>5L9a&#cQ$;ZIBF65=nh z+rDV(^wqObj z!Jv#B)yO*qPp^!2d^;vmu`f^Mr3%gLt+^sJd88zL8a^Ts>pv;ulM9Uiyj@Yl=JIM9 zR61v|3*=G>`LSUPTdh>KcB3;53MFnev!X)Dh*0w3*O!p_i7AK=M{gS9Y+E-2=HXGg z;|*=JXbnWk11U_3;aEc}ty&{#;R=$Jaw=N7=+O7suNxsUSVmrK7?b>^PWI0)x6`E2 zJK0>JS4O)JUSG(yDbOAB;v<+67$7urrPuS15He)30FV`_vFX2Y_b4#3Q?pQ(AkisPUwEC?k=V7Uqzskn_TVZ2ZN(05oe1Jh1H`-*nj4nh{;` z_CvI24P=BXc(3Gcp8NC*o|rw;zI+%5%@l7x#L@azMtE^SHoq^-vln!Zl}ah`bp-?a=4TX^N(Tn<06oV%t)2_-HmOp4+ChZa%SqosKJ zAxd@}q07+EhpX>F5nvnL-e^X6X<-gqPM@QA`yt*qeU7+ELF`<8FFqcs?!Vl3Cz=5h zkMB9bt0&G9C=s)1$sD_XQDQWkmdv55y@&a4?_<@@&(I8*XwvA3-L#EEbg)#p3C)ES?@qb*Gx2A1LGGm9hK8 zKQAl5WFBVed!=+4`T@|JhB*AhavsV^WNSr@SDF!?+INz_U2Sjn*EEu(l(RHBj)e)4 zoVeU>*9kdT*UbGN9AwyH>mSx+O(#22iP*;nv&F*Fc<#;&3gRO8eBFb*a=en4k5vlg z1WLrDhb!nd_Os#6OuKz#JxNlAc%^Yf-9O;;zO1o>rO9z5hfZ=EyxMM!J#YZ+IwOe+ zISQGSE<-<^SMOD{JkYKgdEITa4XEqIDR|-`6K09)ta9XwCf6CUFd>RHSxM~tre5gs zh}mM{@t9FvrOU;OisuO5=_} zvu!Pw`HgRUj|6h$(|3Ja&kGB(`R$_FObHAiS{_Kz^jJQ*&^TrixI#SSd=EenJV~F< ztKil3Xen>*q#!Or=;ECp-Nk@;n7!w{t30J*UrY{F9YASq6Gtw!up%viX$m>&wr(f? zm0mq&i-p>54e76chDh&^i;Q;oM%umd-2Ts|)|9VmP+1ApH zMRO=lO<>q!#WXxjQ?H(nsvErCjS5z^2~EzK7o_SyYV>|P>W$OqSiAeE(>|mt+H^)9 z+;&K4MsFHo?e3$j-F=k(HH|1`{>+LBrMgqis-2(l%JH*y?a1!Nf~k=JTvqEyfBhpq zsH$V?wnxK??T2}%a{R@Ykg9eSZ8{?v z5elBnP2uVMRDM~I!E4J4IQQHtrcMqPUNQ=)Q9G(Z2NzG$Rk6vm&&+o;c_bzzkmu)R zu_7&j_(`@OW3gJPYVTp!xAnaF<$3ys%z~@_JNRQ!zP&G5mFK-p0A))ZnuvsPJw0#L8re`<&W6%*rW zTt@~@a06Ja#8sCO@}2ur11>Xfgplu=Nb1*59R0hEjOXZ|+st!h;?V7_<(3?}6*6($ s{U&gej=Q^^HeC^<>bR~in&9U1A5vfySz6>3jsO4v07*qoM6N<$g0Wuzi~s-t literal 0 HcmV?d00001 From da63acb675108b3adf0fb9bd0e19d164b44e3f4a Mon Sep 17 00:00:00 2001 From: Massimo Pissarello Date: Sun, 1 Mar 2026 11:24:18 +0100 Subject: [PATCH 056/122] Translated using Weblate (Italian) Currently translated at 100.0% (794 of 794 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/it/ --- front/php/templates/language/it_it.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 537d31bea..621c1e612 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -225,7 +225,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Preferito", "Device_TableHead_FirstSession": "Prima sessione", - "Device_TableHead_Flapping": "", + "Device_TableHead_Flapping": "Flapping", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Gruppo", "Device_TableHead_IPv4": "IPv4", @@ -579,8 +579,8 @@ "PIALERT_WEB_PROTECTION_name": "Abilita login", "PLUGINS_KEEP_HIST_description": "Quante voci dei risultati della scansione della cronologia dei plugin devono essere conservate (per plugin e non per dispositivo specifico).", "PLUGINS_KEEP_HIST_name": "Storico plugin", - "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", - "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "Dimensione massima in MB del WAL (Write-Ahead Log) di SQLite prima dell'attivazione dei checkpoint automatici. Valori inferiori (10-20 MB) riducono l'utilizzo di disco/archiviazione, ma aumentano l'utilizzo della CPU durante le scansioni. Valori superiori (50-100 MB) riducono i picchi di CPU durante le operazioni, ma potrebbero richiedere più RAM e spazio su disco. Il valore predefinito di 50 MB bilancia entrambi. Utile per sistemi con risorse limitate, come dispositivi NAS con schede SD. Riavviare il server affinché le modifiche abbiano effetto dopo aver salvato le impostazioni.", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "Limite dimensione WAL (MB)", "Plugins_DeleteAll": "Elimina tutti (i filtri vengono ignorati)", "Plugins_Filters_Mac": "Filtro MAC", "Plugins_History": "Storico eventi", @@ -793,4 +793,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} \ No newline at end of file +} From 3a73817048bd7e803bd378017c8b9aa1d0d8cf1b Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:05:37 +0000 Subject: [PATCH 057/122] Enhance device down event handling for sleeping devices and improve down alerts query --- server/messaging/reporting.py | 6 +- server/scan/device_handling.py | 6 +- server/scan/session_events.py | 25 ++- test/db_test_helpers.py | 4 +- test/scan/test_down_sleep_events.py | 244 ++++++++++++++++++++++------ 5 files changed, 230 insertions(+), 55 deletions(-) diff --git a/server/messaging/reporting.py b/server/messaging/reporting.py index 21c0d19ee..a764fc272 100755 --- a/server/messaging/reporting.py +++ b/server/messaging/reporting.py @@ -126,6 +126,8 @@ def get_notifications(db): AND eve_MAC IN (SELECT devMac FROM Devices WHERE devAlertDown = 0) """) + alert_down_minutes = int(get_setting_value("NTFPRCS_alert_down_time") or 0) + sections = get_setting_value("NTFPRCS_INCLUDED_SECTIONS") or [] mylog("verbose", ["[Notification] Included sections: ", sections]) @@ -172,7 +174,7 @@ def get_section_condition(section): AND eve_EventType = 'New Device' {condition} ORDER BY eve_DateTime """, - "down_devices": """ + "down_devices": f""" SELECT devName, eve_MAC, @@ -183,7 +185,7 @@ def get_section_condition(section): FROM Events_Devices AS down_events WHERE eve_PendingAlertEmail = 1 AND down_events.eve_EventType = 'Device Down' - AND eve_DateTime < datetime('now', '-0 minutes') + AND eve_DateTime < datetime('now', '-{alert_down_minutes} minutes') AND NOT EXISTS ( SELECT 1 FROM Events AS connected_events diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index 880904603..7d60c13d0 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -534,7 +534,11 @@ def print_scan_stats(db): (SELECT COUNT(*) FROM CurrentScan) AS devices_detected, (SELECT COUNT(*) FROM CurrentScan WHERE NOT EXISTS (SELECT 1 FROM Devices WHERE devMac = scanMac)) AS new_devices, (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devIsSleeping = 0 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS down_alerts, - (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devIsSleeping = 0 AND devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS new_down_alerts, + (SELECT COUNT(*) FROM DevicesView + WHERE devAlertDown != 0 AND devCanSleep = 0 + AND devPresentLastScan = 1 + AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac) + ) AS new_down_alerts, (SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 0) AS new_connections, (SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS disconnections, (SELECT COUNT(*) FROM Devices, CurrentScan diff --git a/server/scan/session_events.py b/server/scan/session_events.py index bae9f0535..6fe39ebf8 100755 --- a/server/scan/session_events.py +++ b/server/scan/session_events.py @@ -169,20 +169,39 @@ def insert_events(db): sql = db.sql # TO-DO startTime = timeNowUTC() - # Check device down - mylog("debug", "[Events] - 1 - Devices down") + # Check device down – non-sleeping devices (immediate on first absence) + mylog("debug", "[Events] - 1a - Devices down (non-sleeping)") sql.execute(f"""INSERT OR IGNORE INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType, eve_AdditionalInfo, eve_PendingAlertEmail) SELECT devMac, devLastIP, '{startTime}', 'Device Down', '', 1 FROM DevicesView WHERE devAlertDown != 0 - AND devIsSleeping = 0 + AND devCanSleep = 0 AND devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac ) """) + # Check device down – sleeping devices whose sleep window has expired + mylog("debug", "[Events] - 1b - Devices down (sleep expired)") + sql.execute(f"""INSERT OR IGNORE INTO Events (eve_MAC, eve_IP, eve_DateTime, + eve_EventType, eve_AdditionalInfo, + eve_PendingAlertEmail) + SELECT devMac, devLastIP, '{startTime}', 'Device Down', '', 1 + FROM DevicesView + WHERE devAlertDown != 0 + AND devCanSleep = 1 + AND devIsSleeping = 0 + AND devPresentLastScan = 0 + AND NOT EXISTS (SELECT 1 FROM CurrentScan + WHERE devMac = scanMac) + AND NOT EXISTS (SELECT 1 FROM Events + WHERE eve_MAC = devMac + AND eve_EventType = 'Device Down' + AND eve_DateTime >= devLastConnection + ) """) + # Check new Connections or Down Reconnections mylog("debug", "[Events] - 2 - New Connections") sql.execute(f""" INSERT OR IGNORE INTO Events (eve_MAC, eve_IP, eve_DateTime, diff --git a/test/db_test_helpers.py b/test/db_test_helpers.py index d7880d2cb..f49fd4b51 100644 --- a/test/db_test_helpers.py +++ b/test/db_test_helpers.py @@ -207,9 +207,9 @@ def insert_device( # --------------------------------------------------------------------------- def down_event_macs(cur) -> set: - """Return the set of MACs that have a 'Device Down' event row.""" + """Return the set of MACs that have a 'Device Down' event row (lowercased).""" cur.execute("SELECT eve_MAC FROM Events WHERE eve_EventType = 'Device Down'") - return {r["eve_MAC"] for r in cur.fetchall()} + return {r["eve_MAC"].lower() for r in cur.fetchall()} # --------------------------------------------------------------------------- diff --git a/test/scan/test_down_sleep_events.py b/test/scan/test_down_sleep_events.py index cc9067a6d..295f06b62 100644 --- a/test/scan/test_down_sleep_events.py +++ b/test/scan/test_down_sleep_events.py @@ -4,14 +4,18 @@ Two complementary layers are tested: Layer 1 — insert_events() (session_events.py) - The "Device Down" event fires when: - devPresentLastScan = 1 (was online last scan) - AND device NOT in CurrentScan (absent this scan) - AND devAlertDown != 0 - - At this point devIsSleeping is always 0 (sleeping requires devPresentLastScan=0, - but insert_events runs before update_presence_from_CurrentScan flips it). - Tests here verify NULL-devAlertDown regression and normal down/no-down branching. + Non-sleeping devices (devCanSleep=0): + The "Device Down" event fires when: + devPresentLastScan = 1 (was online last scan) + AND device NOT in CurrentScan (absent this scan) + AND devAlertDown != 0 + + Sleeping devices (devCanSleep=1): + The "Device Down" event is DEFERRED until the sleep window + (NTFPRCS_sleep_time) expires. During the sleep window the device + is shown as "Sleeping" and NO down event is created. After the + window expires, insert_events creates the event via the + sleep-expired query (devPresentLastScan=0, devIsSleeping=0). Layer 2 — DevicesView down-count query (as used by insertOnlineHistory / db_helper) After presence is updated (devPresentLastScan → 0) the sleeping suppression @@ -39,13 +43,15 @@ # --------------------------------------------------------------------------- # Layer 1: insert_events() — event creation on the down transition # -# Condition: devPresentLastScan = 1 (was online) AND not in CurrentScan (now absent) -# At this point devIsSleeping is always 0 (sleeping requires devPresentLastScan=0). +# Non-sleeping (devCanSleep=0): +# Condition: devPresentLastScan = 1 AND not in CurrentScan → immediate event. +# Sleeping (devCanSleep=1): +# No event until sleep window expires (see TestInsertEventsSleepSuppression). # --------------------------------------------------------------------------- class TestInsertEventsDownDetection: """ - Tests for the 'Device Down' INSERT in insert_events(). + Tests for the 'Device Down' INSERT in insert_events() for non-sleeping devices. The down transition is: devPresentLastScan=1 AND absent from CurrentScan. CurrentScan is left empty in all tests (all devices absent this scan). @@ -61,12 +67,12 @@ def test_null_alert_down_does_not_fire_down_event(self): """ conn = _make_db() cur = conn.cursor() - _insert_device(cur, "AA:11:22:33:44:01", alert_down=None, present_last_scan=1) + _insert_device(cur, "aa:11:22:33:44:01", alert_down=None, present_last_scan=1) conn.commit() insert_events(DummyDB(conn)) - assert "AA:11:22:33:44:01" not in _down_event_macs(cur), ( + assert "aa:11:22:33:44:01" not in _down_event_macs(cur), ( "NULL devAlertDown must never fire a 'Device Down' event " "(IFNULL coercion regression)" ) @@ -75,54 +81,54 @@ def test_zero_alert_down_does_not_fire_down_event(self): """Explicit devAlertDown=0 must NOT fire a 'Device Down' event.""" conn = _make_db() cur = conn.cursor() - _insert_device(cur, "AA:11:22:33:44:02", alert_down=0, present_last_scan=1) + _insert_device(cur, "aa:11:22:33:44:02", alert_down=0, present_last_scan=1) conn.commit() insert_events(DummyDB(conn)) - assert "AA:11:22:33:44:02" not in _down_event_macs(cur) + assert "aa:11:22:33:44:02" not in _down_event_macs(cur) def test_alert_down_one_fires_down_event_when_absent(self): """devAlertDown=1, was online last scan, absent now → 'Device Down' event.""" conn = _make_db() cur = conn.cursor() - _insert_device(cur, "AA:11:22:33:44:03", alert_down=1, present_last_scan=1) + _insert_device(cur, "aa:11:22:33:44:03", alert_down=1, present_last_scan=1) conn.commit() insert_events(DummyDB(conn)) - assert "AA:11:22:33:44:03" in _down_event_macs(cur) + assert "aa:11:22:33:44:03" in _down_event_macs(cur) def test_device_in_current_scan_does_not_fire_down_event(self): """A device present in CurrentScan (online now) must NOT get Down event.""" conn = _make_db() cur = conn.cursor() - _insert_device(cur, "AA:11:22:33:44:04", alert_down=1, present_last_scan=1) + _insert_device(cur, "aa:11:22:33:44:04", alert_down=1, present_last_scan=1) # Put it in CurrentScan → device is online this scan cur.execute( "INSERT INTO CurrentScan (scanMac, scanLastIP) VALUES (?, ?)", - ("AA:11:22:33:44:04", "192.168.1.1"), + ("aa:11:22:33:44:04", "192.168.1.1"), ) conn.commit() insert_events(DummyDB(conn)) - assert "AA:11:22:33:44:04" not in _down_event_macs(cur) + assert "aa:11:22:33:44:04" not in _down_event_macs(cur) def test_already_absent_last_scan_does_not_re_fire(self): """ devPresentLastScan=0 means device was already absent last scan. - The down event was already created then; it must not be created again. - (The INSERT query requires devPresentLastScan=1 — the down-transition moment.) + For non-sleeping devices (devCanSleep=0), the down event was already + created then; it must not be created again. """ conn = _make_db() cur = conn.cursor() - _insert_device(cur, "AA:11:22:33:44:05", alert_down=1, present_last_scan=0) + _insert_device(cur, "aa:11:22:33:44:05", alert_down=1, present_last_scan=0) conn.commit() insert_events(DummyDB(conn)) - assert "AA:11:22:33:44:05" not in _down_event_macs(cur) + assert "aa:11:22:33:44:05" not in _down_event_macs(cur) def test_archived_device_does_not_fire_down_event(self): """Archived devices should not produce Down events.""" @@ -133,7 +139,7 @@ def test_archived_device_does_not_fire_down_event(self): (devMac, devAlertDown, devPresentLastScan, devCanSleep, devLastConnection, devLastIP, devIsArchived, devIsNew) VALUES (?, 1, 1, 0, ?, '192.168.1.1', 1, 0)""", - ("AA:11:22:33:44:06", _minutes_ago(60)), + ("aa:11:22:33:44:06", _minutes_ago(60)), ) conn.commit() @@ -145,17 +151,17 @@ def test_archived_device_does_not_fire_down_event(self): # The archived device DOES get a Down event today (no archive filter in # insert_events). This test documents the current behaviour. # If that changes, update this assertion accordingly. - assert "AA:11:22:33:44:06" in _down_event_macs(cur) + assert "aa:11:22:33:44:06" in _down_event_macs(cur) def test_multiple_devices_mixed_alert_down(self): """Only devices with devAlertDown=1 that are absent fire Down events.""" conn = _make_db() cur = conn.cursor() cases = [ - ("CC:00:00:00:00:01", None, 1), # NULL → no event - ("CC:00:00:00:00:02", 0, 1), # 0 → no event - ("CC:00:00:00:00:03", 1, 1), # 1 → event - ("CC:00:00:00:00:04", 1, 0), # already absent → no event + ("cc:00:00:00:00:01", None, 1), # NULL → no event + ("cc:00:00:00:00:02", 0, 1), # 0 → no event + ("cc:00:00:00:00:03", 1, 1), # 1 → event + ("cc:00:00:00:00:04", 1, 0), # already absent → no event ] for mac, alert_down, present in cases: _insert_device(cur, mac, alert_down=alert_down, present_last_scan=present) @@ -164,10 +170,154 @@ def test_multiple_devices_mixed_alert_down(self): insert_events(DummyDB(conn)) fired = _down_event_macs(cur) - assert "CC:00:00:00:00:01" not in fired, "NULL devAlertDown must not fire" - assert "CC:00:00:00:00:02" not in fired, "devAlertDown=0 must not fire" - assert "CC:00:00:00:00:03" in fired, "devAlertDown=1 absent must fire" - assert "CC:00:00:00:00:04" not in fired, "already-absent device must not fire again" + assert "cc:00:00:00:00:01" not in fired, "NULL devAlertDown must not fire" + assert "cc:00:00:00:00:02" not in fired, "devAlertDown=0 must not fire" + assert "cc:00:00:00:00:03" in fired, "devAlertDown=1 absent must fire" + assert "cc:00:00:00:00:04" not in fired, "already-absent device must not fire again" + + +# --------------------------------------------------------------------------- +# Layer 1b: insert_events() — sleeping device suppression +# +# Sleeping devices (devCanSleep=1) must NOT get a 'Device Down' event on the +# first-scan transition. Instead, the event is deferred until the sleep +# window (NTFPRCS_sleep_time) expires. +# --------------------------------------------------------------------------- + +class TestInsertEventsSleepSuppression: + """ + Tests for sleeping device suppression in insert_events(). + + Verifies that devCanSleep=1 devices DO NOT get immediate down events + and only get events after the sleep window expires. + """ + + def test_sleeping_device_no_down_event_on_first_absence(self): + """ + devCanSleep=1, devPresentLastScan=1, absent from CurrentScan. + Sleep window has NOT expired → must NOT fire 'Device Down'. + This is the core bug fix: previously the event fired immediately. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "bb:00:00:00:00:01", alert_down=1, present_last_scan=1, + can_sleep=1, last_connection=_minutes_ago(1)) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "bb:00:00:00:00:01" not in _down_event_macs(cur), ( + "Sleeping device must NOT get 'Device Down' on first absence " + "(sleep window not expired)" + ) + + def test_sleeping_device_still_in_window_no_event(self): + """ + devCanSleep=1, devPresentLastScan=0, devIsSleeping=1 (within window). + Device was already absent last scan and is still sleeping. + Must NOT fire 'Device Down'. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "bb:00:00:00:00:02", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(10)) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "bb:00:00:00:00:02" not in _down_event_macs(cur), ( + "Sleeping device within sleep window must NOT get 'Device Down'" + ) + + def test_sleeping_device_expired_window_fires_event(self): + """ + devCanSleep=1, devPresentLastScan=0, sleep window expired + (devLastConnection > NTFPRCS_sleep_time ago) → must fire 'Device Down'. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "bb:00:00:00:00:03", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45)) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "bb:00:00:00:00:03" in _down_event_macs(cur), ( + "Sleeping device past its sleep window must get 'Device Down'" + ) + + def test_sleeping_device_expired_no_duplicate_event(self): + """ + Once a 'Device Down' event exists for the current absence period, + subsequent scan cycles must NOT create another one. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + last_conn = _minutes_ago(45) + _insert_device(cur, "bb:00:00:00:00:04", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=last_conn) + # Simulate: a Device Down event already exists for this absence + cur.execute( + "INSERT INTO Events (eve_MAC, eve_IP, eve_DateTime, eve_EventType, " + "eve_AdditionalInfo, eve_PendingAlertEmail) " + "VALUES (?, '192.168.1.1', ?, 'Device Down', '', 1)", + ("bb:00:00:00:00:04", _minutes_ago(15)), + ) + conn.commit() + + insert_events(DummyDB(conn)) + + cur.execute( + "SELECT COUNT(*) as cnt FROM Events " + "WHERE eve_MAC = 'bb:00:00:00:00:04' AND eve_EventType = 'Device Down'" + ) + count = cur.fetchone()["cnt"] + assert count == 1, ( + f"Expected exactly 1 Device Down event, got {count} (duplicate prevention)" + ) + + def test_sleeping_device_with_alert_down_zero_no_event(self): + """devCanSleep=1 but devAlertDown=0 → never fires, even after sleep expires.""" + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + _insert_device(cur, "bb:00:00:00:00:05", alert_down=0, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45)) + conn.commit() + + insert_events(DummyDB(conn)) + + assert "bb:00:00:00:00:05" not in _down_event_macs(cur) + + def test_mixed_sleeping_and_non_sleeping(self): + """ + Non-sleeping device fires immediately on first absence. + Sleeping device within window does NOT fire. + Sleeping device past window DOES fire. + """ + conn = _make_db(sleep_minutes=30) + cur = conn.cursor() + + # Non-sleeping, present last scan, absent now → immediate event + _insert_device(cur, "bb:00:00:00:00:10", alert_down=1, present_last_scan=1, + can_sleep=0, last_connection=_minutes_ago(1)) + # Sleeping, present last scan (first absence) → NO event + _insert_device(cur, "bb:00:00:00:00:11", alert_down=1, present_last_scan=1, + can_sleep=1, last_connection=_minutes_ago(1)) + # Sleeping, within window → NO event + _insert_device(cur, "bb:00:00:00:00:12", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(10)) + # Sleeping, past window → event + _insert_device(cur, "bb:00:00:00:00:13", alert_down=1, present_last_scan=0, + can_sleep=1, last_connection=_minutes_ago(45)) + conn.commit() + + insert_events(DummyDB(conn)) + fired = _down_event_macs(cur) + + assert "bb:00:00:00:00:10" in fired, "Non-sleeping absent must fire" + assert "bb:00:00:00:00:11" not in fired, "Sleeping first-absence must NOT fire" + assert "bb:00:00:00:00:12" not in fired, "Sleeping within window must NOT fire" + assert "bb:00:00:00:00:13" in fired, "Sleeping past window must fire" # --------------------------------------------------------------------------- @@ -200,24 +350,24 @@ def test_null_alert_down_excluded_from_down_count(self): """NULL devAlertDown must not contribute to down count.""" conn = _make_db() cur = conn.cursor() - _insert_device(cur, "DD:00:00:00:00:01", alert_down=None, present_last_scan=0) + _insert_device(cur, "dd:00:00:00:00:01", alert_down=None, present_last_scan=0) conn.commit() cur.execute(self._DOWN_COUNT_SQL) macs = {r["devMac"] for r in cur.fetchall()} - assert "DD:00:00:00:00:01" not in macs + assert "dd:00:00:00:00:01" not in macs def test_alert_down_one_included_in_down_count(self): """devAlertDown=1 absent device must be counted as down.""" conn = _make_db() cur = conn.cursor() - _insert_device(cur, "DD:00:00:00:00:02", alert_down=1, present_last_scan=0, + _insert_device(cur, "dd:00:00:00:00:02", alert_down=1, present_last_scan=0, last_connection=_minutes_ago(60)) conn.commit() cur.execute(self._DOWN_COUNT_SQL) macs = {r["devMac"] for r in cur.fetchall()} - assert "DD:00:00:00:00:02" in macs + assert "dd:00:00:00:00:02" in macs def test_sleeping_device_excluded_from_down_count(self): """ @@ -226,13 +376,13 @@ def test_sleeping_device_excluded_from_down_count(self): """ conn = _make_db(sleep_minutes=30) cur = conn.cursor() - _insert_device(cur, "DD:00:00:00:00:03", alert_down=1, present_last_scan=0, + _insert_device(cur, "dd:00:00:00:00:03", alert_down=1, present_last_scan=0, can_sleep=1, last_connection=_minutes_ago(5)) conn.commit() cur.execute(self._DOWN_COUNT_SQL) macs = {r["devMac"] for r in cur.fetchall()} - assert "DD:00:00:00:00:03" not in macs, ( + assert "dd:00:00:00:00:03" not in macs, ( "Sleeping device must be excluded from down count" ) @@ -240,13 +390,13 @@ def test_expired_sleep_window_included_in_down_count(self): """Once the sleep window expires the device must appear in down count.""" conn = _make_db(sleep_minutes=30) cur = conn.cursor() - _insert_device(cur, "DD:00:00:00:00:04", alert_down=1, present_last_scan=0, + _insert_device(cur, "dd:00:00:00:00:04", alert_down=1, present_last_scan=0, can_sleep=1, last_connection=_minutes_ago(45)) conn.commit() cur.execute(self._DOWN_COUNT_SQL) macs = {r["devMac"] for r in cur.fetchall()} - assert "DD:00:00:00:00:04" in macs, ( + assert "dd:00:00:00:00:04" in macs, ( "Device past its sleep window must appear in down count" ) @@ -254,13 +404,13 @@ def test_can_sleep_zero_always_in_down_count(self): """devCanSleep=0 device that is absent is always counted as down.""" conn = _make_db(sleep_minutes=30) cur = conn.cursor() - _insert_device(cur, "DD:00:00:00:00:05", alert_down=1, present_last_scan=0, + _insert_device(cur, "dd:00:00:00:00:05", alert_down=1, present_last_scan=0, can_sleep=0, last_connection=_minutes_ago(5)) conn.commit() cur.execute(self._DOWN_COUNT_SQL) macs = {r["devMac"] for r in cur.fetchall()} - assert "DD:00:00:00:00:05" in macs + assert "dd:00:00:00:00:05" in macs def test_online_history_down_count_excludes_sleeping(self): """ @@ -271,13 +421,13 @@ def test_online_history_down_count_excludes_sleeping(self): cur = conn.cursor() # Normal down - _insert_device(cur, "EE:00:00:00:00:01", alert_down=1, present_last_scan=0, + _insert_device(cur, "ee:00:00:00:00:01", alert_down=1, present_last_scan=0, can_sleep=0, last_connection=_minutes_ago(60)) # Sleeping (within window) - _insert_device(cur, "EE:00:00:00:00:02", alert_down=1, present_last_scan=0, + _insert_device(cur, "ee:00:00:00:00:02", alert_down=1, present_last_scan=0, can_sleep=1, last_connection=_minutes_ago(10)) # Online - _insert_device(cur, "EE:00:00:00:00:03", alert_down=1, present_last_scan=1, + _insert_device(cur, "ee:00:00:00:00:03", alert_down=1, present_last_scan=1, last_connection=_minutes_ago(1)) conn.commit() From bc4f419927efdfd80ba8bef47dc6e65d75d2a8cf Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:42:36 +0000 Subject: [PATCH 058/122] Enhance device MAC address handling in tests to ensure lowercase normalization and skip tests when web protection is disabled --- test/api_endpoints/test_device_endpoints.py | 4 ++-- test/db/test_devices_view.py | 23 ++++++++++++++------- test/ui/test_ui_login.py | 19 ++++++++++++++++- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/test/api_endpoints/test_device_endpoints.py b/test/api_endpoints/test_device_endpoints.py index 007fbf0fd..4314fd2a9 100644 --- a/test/api_endpoints/test_device_endpoints.py +++ b/test/api_endpoints/test_device_endpoints.py @@ -99,9 +99,9 @@ def test_copy_device(client, api_token, test_mac): ) assert resp.status_code == 200 - # Step 2: Generate a target MAC + # Step 2: Generate a target MAC (lowercase to match DB trigger normalisation) target_mac = "aa:bb:cc:" + ":".join( - f"{random.randint(0, 255):02X}" for _ in range(3) + f"{random.randint(0, 255):02x}" for _ in range(3) ) # Step 3: Copy device diff --git a/test/db/test_devices_view.py b/test/db/test_devices_view.py index 60792de75..8ff9e7f43 100644 --- a/test/db/test_devices_view.py +++ b/test/db/test_devices_view.py @@ -78,7 +78,8 @@ def test_one_alert_down_in_down_event_query(self): "SELECT devMac FROM DevicesView WHERE devAlertDown != 0 AND devPresentLastScan = 0" ) macs = [r["devMac"] for r in cur.fetchall()] - assert "AA:BB:CC:DD:EE:03" in macs + # DevicesView returns LOWER(devMac), so compare against lowercase + assert "aa:bb:cc:dd:ee:03" in macs def test_online_device_not_in_down_event_query(self): """An online device (devPresentLastScan=1) should never fire a down event.""" @@ -116,7 +117,8 @@ def test_sleeping_device_is_marked_sleeping(self): ) conn.commit() - cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:01'") + # DevicesView returns LOWER(devMac); query must use lowercase + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'bb:bb:bb:bb:bb:01'") row = cur.fetchone() assert row["devIsSleeping"] == 1 @@ -138,7 +140,8 @@ def test_sleeping_device_not_in_down_event_query(self): AND devPresentLastScan = 0 """) macs = [r["devMac"] for r in cur.fetchall()] - assert "BB:BB:BB:BB:BB:02" not in macs + # DevicesView returns LOWER(devMac) + assert "bb:bb:bb:bb:bb:02" not in macs def test_expired_sleep_window_fires_down(self): """After the sleep window expires, the device must appear as Down.""" @@ -151,7 +154,8 @@ def test_expired_sleep_window_fires_down(self): ) conn.commit() - cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:03'") + # DevicesView returns LOWER(devMac); query must use lowercase + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'bb:bb:bb:bb:bb:03'") assert cur.fetchone()["devIsSleeping"] == 0 cur.execute(""" @@ -161,7 +165,7 @@ def test_expired_sleep_window_fires_down(self): AND devPresentLastScan = 0 """) macs = [r["devMac"] for r in cur.fetchall()] - assert "BB:BB:BB:BB:BB:03" in macs + assert "bb:bb:bb:bb:bb:03" in macs def test_can_sleep_zero_device_is_not_sleeping(self): """devCanSleep=0 device recently offline → devIsSleeping must be 0.""" @@ -174,7 +178,8 @@ def test_can_sleep_zero_device_is_not_sleeping(self): ) conn.commit() - cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:04'") + # DevicesView returns LOWER(devMac); query must use lowercase + cur.execute("SELECT devIsSleeping FROM DevicesView WHERE devMac = 'bb:bb:bb:bb:bb:04'") assert cur.fetchone()["devIsSleeping"] == 0 def test_devstatus_sleeping(self): @@ -188,7 +193,8 @@ def test_devstatus_sleeping(self): ) conn.commit() - cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:05'") + # DevicesView returns LOWER(devMac); query must use lowercase + cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'bb:bb:bb:bb:bb:05'") assert cur.fetchone()["devStatus"] == "Sleeping" def test_devstatus_down_after_window_expires(self): @@ -202,5 +208,6 @@ def test_devstatus_down_after_window_expires(self): ) conn.commit() - cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'BB:BB:BB:BB:BB:06'") + # DevicesView returns LOWER(devMac); query must use lowercase + cur.execute("SELECT devStatus FROM DevicesView WHERE devMac = 'bb:bb:bb:bb:bb:06'") assert cur.fetchone()["devStatus"] == "Down" diff --git a/test/ui/test_ui_login.py b/test/ui/test_ui_login.py index bc79611a2..faccdb587 100644 --- a/test/ui/test_ui_login.py +++ b/test/ui/test_ui_login.py @@ -8,6 +8,7 @@ import os import time +import pytest from selenium.webdriver.common.by import By # Add test directory to path @@ -73,6 +74,16 @@ def get_login_password(): return None +def require_login_page(driver): + """Skip the test if the login form is not present (web protection disabled).""" + fields = driver.find_elements(By.NAME, "loginpassword") + if not fields: + pytest.skip( + "Web protection is disabled (SETPWD_enable_password != true); " + "login page is not shown on this instance" + ) + + def perform_login(driver, password=None): """Helper function to perform login with optional password fallback @@ -83,6 +94,7 @@ def perform_login(driver, password=None): if password is None: password = "123456" # Default test password + require_login_page(driver) password_input = driver.find_element(By.NAME, "loginpassword") password_input.send_keys(password) @@ -100,7 +112,9 @@ def test_login_page_loads(driver): driver.get(f"{BASE_URL}/index.php") wait_for_page_load(driver) - # Check that login form is present + # Skip if web protection is disabled (page redirected away from login form) + require_login_page(driver) + password_field = driver.find_element(By.NAME, "loginpassword") assert password_field, "Password field should be present" @@ -230,6 +244,9 @@ def test_url_hash_hidden_input_present(driver): driver.get(f"{BASE_URL}/index.php") wait_for_page_load(driver) + # Skip if web protection is disabled (login form not shown) + require_login_page(driver) + # Verify the hidden input field exists url_hash_input = driver.find_element(By.ID, "url_hash") assert url_hash_input, "Hidden url_hash input field should be present" From 95f411d92aa17c9940d3f0cbf507089f64dc80c7 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 2 Mar 2026 21:51:54 +1100 Subject: [PATCH 059/122] DOCS: statuses Signed-off-by: jokob-sk --- ...vice_management_status_flapping_online.png | Bin 1908 -> 2539 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png b/docs/img/DEVICE_MANAGEMENT/device_management_status_flapping_online.png index e0199ac78c659e8fbca47ceeee48e48d2907c772..407a9613407bbef9d95ded847da0715e5953038f 100644 GIT binary patch literal 2539 zcmVJNRA@u(n|oA~=N-pCNq`U_Km-KBwQ^C4SW&>Lvqfv|b#zU3a=;yKZ*&Y*uG&>*l@HqG*c>C?F*W0wDxQ zLK2d^*&mURyt$CDLOpFh=WxRF{r%pT%a`YQf4_$(lCp{=P9l7zk&1-~Up=D4LZ}p~ z3>2x1WG9+PRg9vDvLlsp3 zHc0IpO=LN~f)Jdry*ZcD+GKIahY1U>va~_&E}7WijM7_~wd^sFh!Q0dW-WV+jM7`Z z^$7nLQVHB$qMdzBu4@u?!ca`S9KS`84!L5IO^mQ8ESqp$f7ESp*Tj)8v2YEs& zs=P{fUfZER(zx>{IGsY+c|%lr_(N?=cKLjUkyF$Tz?4q$)t%g1hZ(d2>wGUCe^2dPdx+uAlP{TP{LH6-UO03b@9K+08%8NPIewu74iIJ@ho zWR=&FQm~8*hhK5uCm4Zz^J{WASdCij76VbSzR!X&n%?_4Gx8U?4F>r1?Z?QeTunyl zEkwsAQt{v$9C&^a=AkZ+E4}e4yqv*zWNqdl;!^U-S$Ho=+2tf??{DZ>fKVZRqf5Iu)(R{Mi^U|OM^3AWw{pJ|46qKet&tf(7iDdwcz2}ff zWh7?K6^DFK@WiiMBccvdZryDeD?9O%|>28c&MLvqP0z0YHD-5%06v>6q2^77q8pxKu(O^+FI9^}A#-s!UMv9tOm5L9urxcoKGx9F#Qx{2$X-xO z;fkk7NGrtH*Fx+57a27TU^Vn2SEjr3Q;U`p6Q}aH0}S^xdX2-u(9?t}uaeoTp5e^9 z>lwT}1LoE|M|#OB5@r^oOfSYM_!cLMm+Mx&677gwEKi7Fx1y>0EBenr z0YKkT$EAjM0at(O$&t+~Isc#M#|5Vo&9T3XkHbV$_Xbu&FX>m`3_y3o4jOm=42Q!? zT2VD4x^@n}@C~nOdkkY=3tgwSqspr!HZcQD-J2ZVRE@)Co>bms=xyACR2EHEc`f-% z9wC3pBNX4bjzy2YLut)(;&dOhBXYkRArR!yK$TiYO<~|lHyGvjY$2!eemal8#i?y; z0FcUJSiEKzDYKRUpgq5zgtS5wsvH2C-~Sco_x{#>kLf@fCFk~y{S0+AaPWnt;;@@g z^CpWjqlD@o9i#ootDM=f4sh*$C92C<_QYOn<{>`#MequKXKPl#y=%WS8KAB^g^uCy zs>U(K+6B+HL2!(6bjvq6_u+3CHR(tzUXDVQ!|~2%DW`1kP|5F~Lb^j(hPi(_#==IVC$0+AM_%-J~ z_%(9nOaz++o5l6~`8uO(JA&P6;+opcsB$YY4|SnPQj=40A5KAV?~3_DaJqa7Mo^Qx z4RLI5&gJfsiMJfZhz%AKsO9=PV$!wq@hi7dymB46RSyuYNMd9_LvQn5TKByWbS7_a z&JB7h1cE%1J&7FTyYdLx*^57&!{yiI0m z`6AyY6Vh_+k5;{M(qA;f^b)X7j_}nXV)EC3{{etSGIN9o?Z^NC002ovPDHLkV1g2a B<{JP2 literal 1908 zcmV-)2aEWLP)zXF8J$51nbtOw&Fz$+XkVB-2Y8pq);Kz(bleo>Ha_7}I*1 zK#*N9@%Wt1gXI<+&%PaGpw~dy9W#wX!_6tpbJv!r1}W#JrHTmUY#- zGfUgt;jeF-tg&g!Y}<;aTF}}(^RqHP6Xr8(vn&p6?(m`3>Fg6(#g}ber)@1e&1GcM z_rZ%u{|G0#xrR>GRA#B)%ZQsfPur5% zx&k@4b=p&zK4ql~*S*F6$y+o(CWN4Si$AS!K{(P;A6z}F!D0KCj+AyIb~gkU=|GogECf}i=0Y_@W1y;Dq%qZO%k#nm z4>6Xw4V0=g`mLWS*ZSxp4et%R8*&KO=#=xuYc^87k<9)^o&^BZG$%iD?fNtE)t9Mh zpm#&H?n#u}Y+ zGB!>L*W{A(G~;%jwt4aegbD7P3jg)hMYSU>&s&`W06O>UgiP1pic3kHUlUs1m9OPq zpza+_u57|JRDJ9LQ?9Gy|17}cw(y=$0RV<#eM#pUiS?c39($4n!+H)d*IqQ%9T#`) zBLG5^aI7PQ0F=gVsr}!IPDC<0<@cVqIswe87oSk3l9!W4#6@g80YGDh@3qK3{+M{; z+9USu2be^aPDAITy(iRAKD*5lrB+p!sOD*-^?oHXaXWXtYK;9b{LV|7?MZR}ImhSE zIrjh7zO9?dx)tU+SO)+=zM+}DT>h6HzjSO)wj%&>tRt#d9sdX*gx|z(d$ynrc7i%a zFpTMUG{pennfs0jEp7nt92fUI#R@#sLG`0v!(d6pK4H&sHg8{MiBiIeT>jZTLXDg^ z7-K(>yiTF(m$uFAj35$*Cqe^z|98a4U*an=?p|A$2zVls3+J*cG~r3L%sNxC{I>Mx zZzz|45{t*7`~&Qoyp}E?tY14OzxU(FKtBM0VQY2)ZocVJ1{4^Ioj<1ZKPL|jff%su z;Wy>$%Pdh5X(b~vpS?owkM+-6cOB!b@!q`AvoC7#v9YvTKl@DnVO$(#%FqA|&}jVH zO00!9ju@d40FVQTL1IO4k8w2&2!Z0G)ty{@C#&72U?{9i&GrKOO?kbfu?|Zgou5g&gAV4+10sydoj^F%pn!3C4+Ugj<bzg8{6=*AQG0!QQosmaV4X*EH?uNp+JIhD z$rU|(lyw01R*MVKWK8aQgo`g(;hW2wT?o!4MlSe=aL+4+hmC&8lKh==lhI)I{E3N( z-uJBHyOUl_)7H2eW@9^J>Wnbr!W#x+qaOkQ>MSjX?^>{dZ|P(}fv7(E`{LucVvsND z5faBAFP+qaaX&F=_}_ZiWyoeV>nsoLH-FKF^kG^r z)cez@U!nkU94UNKaih&%Y^Cpghc)oE?;Iq5=y+lDt|6LXXRmhD3Ofr46? znK&U|JgG&ZAa1VlDQkT*;cYZJ*gRXHwm5Lj)M! zZ%X|=dQ=6wp1c2HmIIZ&{Q zz%a{U%}kj0N&ME@a5)uBDNbE=zd!BBtjd1MH52B&e!JTds}}x$54wf}!t3v6@wGVF zg#!?Za#toQ!!g!r*F?V9?C$EuC-Hw7T64x*8;a%(PZl}Jk5m2&L?#6ZS0 Date: Mon, 2 Mar 2026 19:39:23 +0000 Subject: [PATCH 060/122] Fix healthcheck for non-0.0.0.0. will pass as long as reachable. --- install/production-filesystem/services/healthcheck.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/install/production-filesystem/services/healthcheck.sh b/install/production-filesystem/services/healthcheck.sh index 166cbe4f1..f45dc1ab9 100755 --- a/install/production-filesystem/services/healthcheck.sh +++ b/install/production-filesystem/services/healthcheck.sh @@ -49,10 +49,11 @@ else fi # 5. Check port 20211 is open and contains "netalertx" -if curl -sf --max-time 10 "http://localhost:${PORT:-20211}" | grep -i "netalertx" > /dev/null; then - log_success "Port ${PORT:-20211} is responding and contains 'netalertx'" +[ "${LISTEN_ADDR}" == "0.0.0.0" ] && CHECK_ADDR="127.0.0.1" || CHECK_ADDR="${LISTEN_ADDR}"; +if timeout 10 bash -c "/dev/null; then + log_success "Port ${PORT:-20211} is responding" else - log_error "Port ${PORT:-20211} is not responding or doesn't contain 'netalertx'" + log_error "Port ${PORT:-20211} is not responding" fi # NOTE: GRAPHQL_PORT might not be set and is initailized as a setting with a default value in the container. It can also be initialized via APP_CONF_OVERRIDE @@ -71,4 +72,4 @@ else echo "[HEALTHCHECK] ❌ One or more health checks failed" fi -exit $EXIT_CODE \ No newline at end of file +exit $EXIT_CODE From b0aa5d0e454f4b092663cbbc05f2fbbcc923f655 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 2 Mar 2026 19:41:06 +0000 Subject: [PATCH 061/122] Fix startup script matching for skips --- install/production-filesystem/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/production-filesystem/entrypoint.sh b/install/production-filesystem/entrypoint.sh index c162ad895..501e67c48 100755 --- a/install/production-filesystem/entrypoint.sh +++ b/install/production-filesystem/entrypoint.sh @@ -86,7 +86,7 @@ for script in "${ENTRYPOINT_CHECKS}"/*; do fi script_name=$(basename "$script" | sed 's/^[0-9]*-//;s/\.(sh|py)$//;s/-/ /g') echo "--> ${script_name} " - if [ -n "${SKIP_STARTUP_CHECKS:-}" ] && echo "${SKIP_STARTUP_CHECKS}" | grep -q "\b${script_name}\b"; then + if [ -n "${SKIP_STARTUP_CHECKS:-}" ] && echo "${SKIP_STARTUP_CHECKS}" | grep -Fq "${script_name}"; then printf "%sskip%s\n" "${GREY}" "${RESET}" continue fi From 055510547325293e98a1ca73c31ebc96dd682265 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 2 Mar 2026 19:42:00 +0000 Subject: [PATCH 062/122] Detect sysctls only, don't modify sysctls; allow user to modify. --- .../entrypoint.d/37-host-optimization.sh | 84 +++---------------- 1 file changed, 11 insertions(+), 73 deletions(-) diff --git a/install/production-filesystem/entrypoint.d/37-host-optimization.sh b/install/production-filesystem/entrypoint.d/37-host-optimization.sh index 6cfe9b9f5..4dc5d0196 100644 --- a/install/production-filesystem/entrypoint.d/37-host-optimization.sh +++ b/install/production-filesystem/entrypoint.d/37-host-optimization.sh @@ -1,92 +1,30 @@ #!/bin/sh -# 37-host-optimization.sh: Apply and validate network optimizations (ARP flux fix) +# 37-host-optimization.sh: Detect ARP flux sysctl configuration. # -# This script improves detection accuracy by ensuring proper ARP behavior. -# It attempts to apply sysctl settings and warns if not possible. +# This script does not change host/kernel settings. -# --- Color Codes --- -RED=$(printf '\033[1;31m') YELLOW=$(printf '\033[1;33m') RESET=$(printf '\033[0m') -# --- Skip flag --- -if [ -n "${SKIP_OPTIMIZATIONS:-}" ]; then - exit 0 -fi - -# --- Helpers --- - -get_sysctl() { - sysctl -n "$1" 2>/dev/null || echo "unknown" -} - -set_sysctl_if_needed() { - key="$1" - expected="$2" - - current="$(get_sysctl "$key")" - - # Already correct - if [ "$current" = "$expected" ]; then - return 0 - fi - - # Try to apply - if sysctl -w "$key=$expected" >/dev/null 2>&1; then - return 0 - fi - - # Failed - return 1 -} - -# --- Apply Settings (best effort) --- - failed=0 -set_sysctl_if_needed net.ipv4.conf.all.arp_ignore 1 || failed=1 -set_sysctl_if_needed net.ipv4.conf.all.arp_announce 2 || failed=1 -set_sysctl_if_needed net.ipv4.conf.default.arp_ignore 1 || failed=1 -set_sysctl_if_needed net.ipv4.conf.default.arp_announce 2 || failed=1 +[ "$(sysctl -n net.ipv4.conf.all.arp_ignore 2>/dev/null || echo unknown)" = "1" ] || failed=1 +[ "$(sysctl -n net.ipv4.conf.all.arp_announce 2>/dev/null || echo unknown)" = "2" ] || failed=1 -# --- Validate final state --- - -all_ignore="$(get_sysctl net.ipv4.conf.all.arp_ignore)" -all_announce="$(get_sysctl net.ipv4.conf.all.arp_announce)" - -# --- Warning Output --- - -if [ "$all_ignore" != "1" ] || [ "$all_announce" != "2" ]; then +if [ "$failed" -eq 1 ]; then >&2 printf "%s" "${YELLOW}" - >&2 cat <&2 cat <<'EOF' ══════════════════════════════════════════════════════════════════════════════ -⚠️ ATTENTION: ARP flux protection not enabled. - - NetAlertX relies on ARP for device detection. Your system currently allows - ARP replies from incorrect interfaces (ARP flux), which may result in: - - • False devices being detected - • IP/MAC mismatches - • Flapping device states - • Incorrect network topology - - This is common when running in Docker or multi-interface environments. - - ────────────────────────────────────────────────────────────────────────── - Recommended fix (Docker Compose): - - sysctls: - net.ipv4.conf.all.arp_ignore: 1 - net.ipv4.conf.all.arp_announce: 2 - - ────────────────────────────────────────────────────────────────────────── - Alternatively, apply on the host: +⚠️ WARNING: ARP flux sysctls are not set. + Expected values: net.ipv4.conf.all.arp_ignore=1 net.ipv4.conf.all.arp_announce=2 - Detection accuracy may be reduced until this is configured. + Detection accuracy may be reduced until configured. + + See: https://docs.netalertx.com/docker-troubleshooting/arp-flux-sysctls/ ══════════════════════════════════════════════════════════════════════════════ EOF >&2 printf "%s" "${RESET}" From a329c5b5410460f7dccb38dd2a729f225b8a2297 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 2 Mar 2026 19:42:29 +0000 Subject: [PATCH 063/122] Tidy up plugin logic --- .../36-override-individual-settings.sh | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh index 0ed4fc185..64b095e30 100644 --- a/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh +++ b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh @@ -9,28 +9,14 @@ if [ ! -f "${NETALERTX_CONFIG}/app.conf" ]; then exit 0 fi -# Helper: set or append config key safely -set_config_value() { - _key="$1" - _value="$2" - - # Remove newlines just in case - _value=$(printf '%s' "$_value" | tr -d '\n\r') - - # Escape sed-sensitive chars - _escaped=$(printf '%s\n' "$_value" | sed 's/[\/&]/\\&/g') +if [ -n "${LOADED_PLUGINS:-}" ]; then + echo "[ENV] Applying LOADED_PLUGINS override" + value=$(printf '%s' "$LOADED_PLUGINS" | tr -d '\n\r') + escaped=$(printf '%s\n' "$value" | sed 's/[\/&]/\\&/g') - if grep -q "^${_key}=" "${NETALERTX_CONFIG}/app.conf"; then - sed -i "s|^${_key}=.*|${_key}=${_escaped}|" "${NETALERTX_CONFIG}/app.conf" + if grep -q '^LOADED_PLUGINS=' "${NETALERTX_CONFIG}/app.conf"; then + sed -i "s|^LOADED_PLUGINS=.*|LOADED_PLUGINS=${escaped}|" "${NETALERTX_CONFIG}/app.conf" else - echo "${_key}=${_value}" >> "${NETALERTX_CONFIG}/app.conf" + echo "LOADED_PLUGINS=${value}" >> "${NETALERTX_CONFIG}/app.conf" fi -} - -# ------------------------------------------------------------ -# LOADED_PLUGINS override -# ------------------------------------------------------------ -if [ -n "${LOADED_PLUGINS:-}" ]; then - echo "[ENV] Applying LOADED_PLUGINS override" - set_config_value "LOADED_PLUGINS" "$LOADED_PLUGINS" fi From c1d53ff93f3e9971274c148ffc262e2c6466b291 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 2 Mar 2026 19:43:28 +0000 Subject: [PATCH 064/122] Update docker compose and unit tests --- docker-compose.yml | 3 + install/docker/docker-compose.dev.yml | 3 + install/docker/docker-compose.yml | 3 + test/api_endpoints/test_devices_endpoints.py | 84 ++++++++++--------- .../api_endpoints/test_mcp_tools_endpoints.py | 45 ++++++---- .../test_docker_compose_scenarios.py | 13 ++- test/docker_tests/test_entrypoint.py | 37 ++++++++ 7 files changed, 131 insertions(+), 57 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3067ca8ba..5d062ba0b 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: - CHOWN # Required for root-entrypoint to chown /data + /tmp before dropping privileges - SETUID # Required for root-entrypoint to switch to non-root user - SETGID # Required for root-entrypoint to switch to non-root group + sysctls: # ARP flux mitigation for host networking accuracy + net.ipv4.conf.all.arp_ignore: 1 + net.ipv4.conf.all.arp_announce: 2 volumes: - type: volume # Persistent Docker-managed Named Volume for storage diff --git a/install/docker/docker-compose.dev.yml b/install/docker/docker-compose.dev.yml index 6854934b2..25a119435 100644 --- a/install/docker/docker-compose.dev.yml +++ b/install/docker/docker-compose.dev.yml @@ -13,6 +13,9 @@ services: - CHOWN - SETUID - SETGID + sysctls: + net.ipv4.conf.all.arp_ignore: 1 + net.ipv4.conf.all.arp_announce: 2 volumes: - type: volume source: netalertx_data diff --git a/install/docker/docker-compose.yml b/install/docker/docker-compose.yml index 3f842a62a..49143a7b8 100644 --- a/install/docker/docker-compose.yml +++ b/install/docker/docker-compose.yml @@ -13,6 +13,9 @@ services: - CHOWN - SETUID - SETGID + sysctls: + net.ipv4.conf.all.arp_ignore: 1 + net.ipv4.conf.all.arp_announce: 2 volumes: - type: volume source: netalertx_data diff --git a/test/api_endpoints/test_devices_endpoints.py b/test/api_endpoints/test_devices_endpoints.py index 58a892a6c..b90f0a32b 100644 --- a/test/api_endpoints/test_devices_endpoints.py +++ b/test/api_endpoints/test_devices_endpoints.py @@ -43,6 +43,10 @@ def create_dummy(client, api_token, test_mac): client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token)) +def delete_dummy(client, api_token, test_mac): + client.delete("/devices", json={"macs": [test_mac]}, headers=auth_headers(api_token)) + + def test_get_all_devices(client, api_token, test_mac): # Ensure there is at least one device create_dummy(client, api_token, test_mac) @@ -149,53 +153,55 @@ def test_export_import_cycle_base64(client, api_token, test_mac): def test_devices_totals(client, api_token, test_mac): - # 1. Create a dummy device create_dummy(client, api_token, test_mac) + try: + # 1. Call the totals endpoint + resp = client.get("/devices/totals", headers=auth_headers(api_token)) + assert resp.status_code == 200 - # 2. Call the totals endpoint - resp = client.get("/devices/totals", headers=auth_headers(api_token)) - assert resp.status_code == 200 - - # 3. Ensure the response is a JSON list - data = resp.json - assert isinstance(data, list) + # 2. Ensure the response is a JSON list + data = resp.json + assert isinstance(data, list) - # 4. Dynamically get expected length - conditions = get_device_conditions() - expected_length = len(conditions) - assert len(data) == expected_length + # 3. Dynamically get expected length + conditions = get_device_conditions() + expected_length = len(conditions) + assert len(data) == expected_length - # 5. Check that at least 1 device exists - assert data[0] >= 1 # 'devices' count includes the dummy device + # 4. Check that at least 1 device exists + assert data[0] >= 1 # 'devices' count includes the dummy device + finally: + delete_dummy(client, api_token, test_mac) def test_devices_by_status(client, api_token, test_mac): - # 1. Create a dummy device create_dummy(client, api_token, test_mac) - - # 2. Request devices by a valid status - resp = client.get("/devices/by-status?status=my", headers=auth_headers(api_token)) - assert resp.status_code == 200 - data = resp.json - assert isinstance(data, list) - assert any(d["id"] == test_mac for d in data) - - # 3. Request devices with an invalid/unknown status - resp_invalid = client.get("/devices/by-status?status=invalid_status", headers=auth_headers(api_token)) - # Strict validation now returns 422 for invalid status enum values - assert resp_invalid.status_code == 422 - - # 4. Check favorite formatting if devFavorite = 1 - # Update dummy device to favorite - client.post( - f"/device/{test_mac}", - json={"devFavorite": 1}, - headers=auth_headers(api_token) - ) - resp_fav = client.get("/devices/by-status?status=my", headers=auth_headers(api_token)) - fav_data = next((d for d in resp_fav.json if d["id"] == test_mac), None) - assert fav_data is not None - assert "★" in fav_data["title"] + try: + # 1. Request devices by a valid status + resp = client.get("/devices/by-status?status=my", headers=auth_headers(api_token)) + assert resp.status_code == 200 + data = resp.json + assert isinstance(data, list) + assert any(d["id"] == test_mac for d in data) + + # 2. Request devices with an invalid/unknown status + resp_invalid = client.get("/devices/by-status?status=invalid_status", headers=auth_headers(api_token)) + # Strict validation now returns 422 for invalid status enum values + assert resp_invalid.status_code == 422 + + # 3. Check favorite formatting if devFavorite = 1 + # Update dummy device to favorite + client.post( + f"/device/{test_mac}", + json={"devFavorite": 1}, + headers=auth_headers(api_token) + ) + resp_fav = client.get("/devices/by-status?status=my", headers=auth_headers(api_token)) + fav_data = next((d for d in resp_fav.json if d["id"] == test_mac), None) + assert fav_data is not None + assert "★" in fav_data["title"] + finally: + delete_dummy(client, api_token, test_mac) def test_delete_test_devices(client, api_token): diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index edb2b6d0d..489c1e3b0 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import patch, MagicMock from datetime import datetime +import random from api_server.api_server_start import app from helper import get_setting_value @@ -21,6 +22,21 @@ def auth_headers(token): return {"Authorization": f"Bearer {token}"} +def create_dummy(client, api_token, test_mac): + payload = { + "createNew": True, + "devName": "Test Device MCP", + "devOwner": "Unit Test", + "devType": "Router", + "devVendor": "TestVendor", + } + client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token)) + + +def delete_dummy(client, api_token, test_mac): + client.delete("/devices", json={"macs": [test_mac]}, headers=auth_headers(api_token)) + + # --- Device Search Tests --- @@ -350,25 +366,22 @@ def test_mcp_devices_import_json(mock_db_conn, client, api_token): # --- MCP Device Totals Tests --- -@patch("database.get_temp_db_connection") -def test_mcp_devices_totals(mock_db_conn, client, api_token): +def test_mcp_devices_totals(client, api_token): """Test MCP devices totals endpoint.""" - mock_conn = MagicMock() - mock_sql = MagicMock() - mock_execute_result = MagicMock() - # Mock the getTotals method to return sample data - mock_execute_result.fetchone.return_value = [10, 8, 2, 0, 1, 3] # devices, connected, favorites, new, down, archived - mock_sql.execute.return_value = mock_execute_result - mock_conn.cursor.return_value = mock_sql - mock_db_conn.return_value = mock_conn + test_mac = "aa:bb:cc:" + ":".join(f"{random.randint(0, 255):02X}" for _ in range(3)).lower() + create_dummy(client, api_token, test_mac) - response = client.get("/devices/totals", headers=auth_headers(api_token)) + try: + response = client.get("/devices/totals", headers=auth_headers(api_token)) - assert response.status_code == 200 - data = response.get_json() - # Should return device counts as array - assert isinstance(data, list) - assert len(data) >= 4 # At least online, offline, etc. + assert response.status_code == 200 + data = response.get_json() + # Should return device counts as array + assert isinstance(data, list) + assert len(data) >= 4 # At least online, offline, etc. + assert data[0] >= 1 + finally: + delete_dummy(client, api_token, test_mac) # --- MCP Traceroute Tests --- diff --git a/test/docker_tests/test_docker_compose_scenarios.py b/test/docker_tests/test_docker_compose_scenarios.py index bc53f5d77..fb5b470bc 100644 --- a/test/docker_tests/test_docker_compose_scenarios.py +++ b/test/docker_tests/test_docker_compose_scenarios.py @@ -344,6 +344,7 @@ def _write_normal_startup_compose( service_env = service.setdefault("environment", {}) service_env.setdefault("NETALERTX_CHECK_ONLY", "1") + service_env.setdefault("SKIP_STARTUP_CHECKS", "host optimization") if env_overrides: service_env.update(env_overrides) @@ -885,9 +886,14 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: f"Unexpected mount row values for /data: {data_parts[2:4]}" ) + allowed_warning = "⚠️ WARNING: ARP flux sysctls are not set." + assert "Write permission denied" not in default_output assert "CRITICAL" not in default_output - assert "⚠️" not in default_output + assert all( + "⚠️" not in line or allowed_warning in line + for line in default_output.splitlines() + ), "Unexpected warning found in default output" custom_http = _select_custom_ports({default_http_port}) custom_graphql = _select_custom_ports({default_http_port, custom_http}) @@ -922,7 +928,10 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: assert "❌" not in custom_output assert "Write permission denied" not in custom_output assert "CRITICAL" not in custom_output - assert "⚠️" not in custom_output + assert all( + "⚠️" not in line or allowed_warning in line + for line in custom_output.splitlines() + ), "Unexpected warning found in custom output" lowered_custom = custom_output.lower() assert "arning" not in lowered_custom assert "rror" not in lowered_custom diff --git a/test/docker_tests/test_entrypoint.py b/test/docker_tests/test_entrypoint.py index 4d457387a..a1b930225 100644 --- a/test/docker_tests/test_entrypoint.py +++ b/test/docker_tests/test_entrypoint.py @@ -90,3 +90,40 @@ def test_skip_startup_checks_env_var(): result = _run_entrypoint(env={"SKIP_STARTUP_CHECKS": "mandatory folders"}, check_only=True) assert "Creating NetAlertX log directory" not in result.stdout assert result.returncode == 0 + + +@pytest.mark.docker +@pytest.mark.feature_complete +def test_host_optimization_warning_matches_sysctl(): + """Validate host-optimization warning matches actual host sysctl values.""" + ignore_proc = subprocess.run( + ["sysctl", "-n", "net.ipv4.conf.all.arp_ignore"], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + announce_proc = subprocess.run( + ["sysctl", "-n", "net.ipv4.conf.all.arp_announce"], + capture_output=True, + text=True, + check=False, + timeout=10, + ) + + if ignore_proc.returncode != 0 or announce_proc.returncode != 0: + pytest.skip("sysctl values unavailable on host; skipping host-optimization warning check") + + arp_ignore = ignore_proc.stdout.strip() + arp_announce = announce_proc.stdout.strip() + expected_warning = not (arp_ignore == "1" and arp_announce == "2") + + result = _run_entrypoint(check_only=True) + combined_output = result.stdout + result.stderr + warning_present = "WARNING: ARP flux sysctls are not set." in combined_output + + assert warning_present == expected_warning, ( + "host-optimization warning mismatch: " + f"arp_ignore={arp_ignore}, arp_announce={arp_announce}, " + f"expected_warning={expected_warning}, warning_present={warning_present}" + ) From 8ab9d9f3951aa1bea9cfae86fdaefb4e03af535c Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Mon, 2 Mar 2026 19:43:38 +0000 Subject: [PATCH 065/122] Update docs --- docs/DOCKER_COMPOSE.md | 3 ++ .../arp-flux-sysctls.md | 47 +++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 51 insertions(+) create mode 100644 docs/docker-troubleshooting/arp-flux-sysctls.md diff --git a/docs/DOCKER_COMPOSE.md b/docs/DOCKER_COMPOSE.md index dd89807b5..e76f08e14 100755 --- a/docs/DOCKER_COMPOSE.md +++ b/docs/DOCKER_COMPOSE.md @@ -30,6 +30,9 @@ services: - CHOWN # Required for root-entrypoint to chown /data + /tmp before dropping privileges - SETUID # Required for root-entrypoint to switch to non-root user - SETGID # Required for root-entrypoint to switch to non-root group + sysctls: # ARP flux mitigation (reduces duplicate/ambiguous ARP behavior on host networking) + net.ipv4.conf.all.arp_ignore: 1 + net.ipv4.conf.all.arp_announce: 2 volumes: - type: volume # Persistent Docker-managed named volume for config + database diff --git a/docs/docker-troubleshooting/arp-flux-sysctls.md b/docs/docker-troubleshooting/arp-flux-sysctls.md new file mode 100644 index 000000000..d73c5cb04 --- /dev/null +++ b/docs/docker-troubleshooting/arp-flux-sysctls.md @@ -0,0 +1,47 @@ +# ARP Flux Sysctls Not Set + +## Issue Description + +NetAlertX detected that ARP flux protection sysctls are not set as expected: + +- `net.ipv4.conf.all.arp_ignore=1` +- `net.ipv4.conf.all.arp_announce=2` + +## Security Ramifications + +This is not a direct container breakout risk, but detection quality can degrade: + +- Incorrect IP/MAC associations +- Device state flapping +- Unreliable topology or presence data + +## Why You're Seeing This Issue + +The running environment does not provide the expected kernel sysctl values. This is common in Docker setups where sysctls were not explicitly configured. + +## How to Correct the Issue + +Set these sysctls at container runtime. + +- In `docker-compose.yml` (preferred): + ```yaml + services: + netalertx: + sysctls: + net.ipv4.conf.all.arp_ignore: 1 + net.ipv4.conf.all.arp_announce: 2 + ``` + +- For `docker run`: + ```bash + docker run \ + --sysctl net.ipv4.conf.all.arp_ignore=1 \ + --sysctl net.ipv4.conf.all.arp_announce=2 \ + jokob-sk/netalertx:latest + ``` + +## Additional Resources + +For broader Docker Compose guidance, see: + +- [DOCKER_COMPOSE.md](https://docs.netalertx.com/DOCKER_COMPOSE) diff --git a/mkdocs.yml b/mkdocs.yml index 3f7690bc0..3aff97cfc 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,6 +20,7 @@ nav: - Docker Updates: UPDATES.md - Docker Maintenance: DOCKER_MAINTENANCE.md - Docker Startup Troubleshooting: + - ARP flux sysctls: docker-troubleshooting/arp-flux-sysctls.md - Aufs capabilities: docker-troubleshooting/aufs-capabilities.md - Excessive capabilities: docker-troubleshooting/excessive-capabilities.md - File permissions: docker-troubleshooting/file-permissions.md From ab74307ed119c2da7d2640c72be09665c1c4b2f4 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:07:22 +0000 Subject: [PATCH 066/122] Add next scan ETA display and update app state with scan timing information --- front/devices.php | 93 ++++++++++++++++++++++++- front/js/sse_manager.js | 10 +++ front/php/templates/language/ar_ar.json | 6 ++ front/php/templates/language/ca_ca.json | 6 ++ front/php/templates/language/cs_cz.json | 6 ++ front/php/templates/language/de_de.json | 6 ++ front/php/templates/language/en_us.json | 5 ++ front/php/templates/language/es_es.json | 6 ++ front/php/templates/language/fa_fa.json | 6 ++ front/php/templates/language/fr_fr.json | 6 ++ front/php/templates/language/it_it.json | 8 ++- front/php/templates/language/ja_jp.json | 6 ++ front/php/templates/language/nb_no.json | 6 ++ front/php/templates/language/pl_pl.json | 6 ++ front/php/templates/language/pt_br.json | 6 ++ front/php/templates/language/pt_pt.json | 6 ++ front/php/templates/language/ru_ru.json | 6 ++ front/php/templates/language/sv_sv.json | 6 ++ front/php/templates/language/tr_tr.json | 6 ++ front/php/templates/language/uk_ua.json | 6 ++ front/php/templates/language/vi_vn.json | 6 ++ front/php/templates/language/zh_cn.json | 6 ++ server/__main__.py | 12 +++- server/app_state.py | 26 +++++-- 24 files changed, 254 insertions(+), 8 deletions(-) diff --git a/front/devices.php b/front/devices.php index a45ba8840..6be7a08d8 100755 --- a/front/devices.php +++ b/front/devices.php @@ -87,6 +87,8 @@

    + +
    @@ -536,6 +538,95 @@ function badgeFromRowData(rowData) { ); } +// --------------------------------------------------------- +// Build the rich empty-table onboarding message (HTML). +// Used as the DataTables 'emptyTable' language option. +function buildEmptyDeviceTableMessage(nextScanLabel) { + var etaLine = nextScanLabel + ? '' + nextScanLabel + '' + : ''; + return '
    ' + + '
    ' + + '' + getString('Device_NoData_Title') + '
    ' + + '' + getString('Device_NoData_Scanning') + '
    ' + + etaLine + + '' + getString('Device_NoData_Help') + '' + + '
    '; +} + +// --------------------------------------------------------- +// Compute a live countdown label from an ISO next_scan_time string. +// next_scan_time is the earliest scheduled run time across enabled device_scanner plugins, +// computed by the backend and broadcast via SSE — no guesswork needed on the frontend. +function computeNextScanLabel(nextScanTime) { + if (!nextScanTime) return getString('Device_NextScan_Imminent'); + // Append Z if no UTC offset marker present — backend may emit naive UTC ISO strings. + var isoStr = /Z$|[+-]\d{2}:?\d{2}$/.test(nextScanTime.trim()) ? nextScanTime : nextScanTime + 'Z'; + var secsLeft = Math.round((new Date(isoStr).getTime() - Date.now()) / 1000); + if (secsLeft <= 0) return getString('Device_NextScan_Imminent'); + if (secsLeft >= 60) { + var m = Math.floor(secsLeft / 60); + var s = secsLeft % 60; + return getString('Device_NextScan_In') + m + 'm ' + s + 's'; + } + return getString('Device_NextScan_In') + secsLeft + 's'; +} + +// Anchor for next scheduled scan time, ticker handle, and plugins data — module-level. +var _nextScanTimeAnchor = null; +var _scanEtaTickerId = null; +var _pluginsData = null; + +// Fetch plugins.json once on page load so we can guard ETA display to device_scanner plugins only. +$.get('php/server/query_json.php', { file: 'plugins.json', nocache: Date.now() }, function(res) { + _pluginsData = res['data'] || []; +}); + +// Returns true only when at least one device_scanner plugin is loaded and not disabled. +function hasEnabledDeviceScanners() { + if (!_pluginsData || !_pluginsData.length) return false; + return getPluginsByType(_pluginsData, 'device_scanner', true).length > 0; +} + +// --------------------------------------------------------- +// Update the title-bar ETA subtitle and the DataTables empty-state message. +// Called on every nax:scanEtaUpdate; the inner ticker keeps the title bar live between events. +function updateScanEtaDisplay(nextScanTime) { + // Prefer the backend-computed next_scan_time; keep previous anchor if not yet received. + _nextScanTimeAnchor = nextScanTime || _nextScanTimeAnchor; + + // Restart the per-second title-bar ticker + if (_scanEtaTickerId !== null) { clearInterval(_scanEtaTickerId); } + + function tickTitleBar() { + var eta = document.getElementById('nextScanEta'); + if (!eta) return; + if (!hasEnabledDeviceScanners()) { + eta.style.display = 'none'; + return; + } + eta.textContent = computeNextScanLabel(_nextScanTimeAnchor); + eta.style.display = ''; + } + + // Update DataTables empty message once per SSE event — avoids AJAX spam on server-side tables. + // Only show the next-scan ETA line when device_scanner plugins are actually enabled. + var label = hasEnabledDeviceScanners() ? computeNextScanLabel(_nextScanTimeAnchor) : ''; + if ($.fn.DataTable.isDataTable('#tableDevices')) { + var dt = $('#tableDevices').DataTable(); + dt.settings()[0].oLanguage.sEmptyTable = buildEmptyDeviceTableMessage(label); + if (dt.page.info().recordsTotal === 0) { dt.draw(false); } + } + + tickTitleBar(); + _scanEtaTickerId = setInterval(tickTitleBar, 1000); +} + +// Listen for scan ETA updates dispatched by sse_manager.js (SSE push or poll fallback) +document.addEventListener('nax:scanEtaUpdate', function(e) { + updateScanEtaDisplay(e.detail.nextScanTime); +}); + // --------------------------------------------------------- // Initializes the main devices list datatable function initializeDatatable (status) { @@ -893,7 +984,7 @@ function initializeDatatable (status) { // Processing 'processing' : true, 'language' : { - emptyTable: 'No data', + emptyTable: buildEmptyDeviceTableMessage(getString('Device_NextScan_Imminent')), "lengthMenu": "", "search": ": ", "paginate": { diff --git a/front/js/sse_manager.js b/front/js/sse_manager.js index 2c4e3d406..a61f99302 100644 --- a/front/js/sse_manager.js +++ b/front/js/sse_manager.js @@ -175,6 +175,16 @@ class NetAlertXStateManager { } } + // 5. Dispatch scan ETA update for pages that display next-scan timing + if (appState["last_scan_run"] !== undefined || appState["next_scan_time"] !== undefined) { + document.dispatchEvent(new CustomEvent('nax:scanEtaUpdate', { + detail: { + lastScanRun: appState["last_scan_run"], + nextScanTime: appState["next_scan_time"] + } + })); + } + // console.log("[NetAlertX State] UI updated via jQuery"); } catch (e) { console.error("[NetAlertX State] Failed to update state display:", e); diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 560449871..3f9f80feb 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "إجراءات جماعية", "Device_MultiEdit_No_Devices": "لم يتم تحديد أي أجهزة.", "Device_MultiEdit_Tooltip": "تعديل الأجهزة المحددة", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "تم إدخال قيمة غير صالحة", "Gen_LockedDB": "قاعدة البيانات مقفلة", "Gen_NetworkMask": "قناع الشبكة", + "Gen_New": "", "Gen_Offline": "غير متصل", "Gen_Okay": "موافق", "Gen_Online": "متصل", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 501d719c0..8c6dec136 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Accions massives:", "Device_MultiEdit_No_Devices": "Cap dispositiu seleccionat.", "Device_MultiEdit_Tooltip": "Atenció. Si feu clic a això s'aplicarà el valor de l'esquerra a tots els dispositius seleccionats a dalt.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "Problemes guardant el dispositiu", "Device_Save_Unauthorized": "Token invàlid - No autoritzat", "Device_Saved_Success": "S'ha guardat el dispositiu", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "S'ha introduït un valor incorrecte", "Gen_LockedDB": "ERROR - DB podria estar bloquejada - Fes servir F12 Eines desenvolupament -> Consola o provar-ho més tard.", "Gen_NetworkMask": "Màscara de xarxa", + "Gen_New": "", "Gen_Offline": "Fora de línia", "Gen_Okay": "Ok", "Gen_Online": "En línia", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index 52d074c57..a04566d0c 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "CHYBA - Databáze je možná zamčená - Zkontrolujte F12 -> Nástroje pro vývojáře -> Konzole. nebo to zkuste později.", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 64187af06..ae01a670d 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -207,6 +207,11 @@ "Device_MultiEdit_MassActions": "Massen aktionen:", "Device_MultiEdit_No_Devices": "Keine Geräte ausgewählt.", "Device_MultiEdit_Tooltip": "Achtung! Beim Drücken werden alle Werte auf die oben ausgewählten Geräte übertragen.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "Gerät erfolgreich gespeichert", @@ -340,6 +345,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "ERROR - DB eventuell gesperrt - Nutze die Konsole in den Entwickler Werkzeugen (F12) zur Überprüfung oder probiere es später erneut.", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 646d1dac4..b1222f342 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Mass actions:", "Device_MultiEdit_No_Devices": "No devices selected.", "Device_MultiEdit_Tooltip": "Careful. Clicking this will apply the value on the left to all devices selected above.", + "Device_NextScan_Imminent": "imminent", + "Device_NextScan_In": "Next scan in ", + "Device_NoData_Help": "If devices don't appear after the scan, check your SCAN_SUBNETS setting and documentation.", + "Device_NoData_Scanning": "Waiting for the first scan - this may take several minutes after the initial setup.", + "Device_NoData_Title": "No devices found yet", "Device_Save_Failed": "Failed to save device", "Device_Save_Unauthorized": "Unauthorized - invalid API token", "Device_Saved_Success": "Device saved successfully", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 7d2cb9b93..464165efa 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -205,6 +205,11 @@ "Device_MultiEdit_MassActions": "Acciones masivas:", "Device_MultiEdit_No_Devices": "Sin dispositivo seleccionado.", "Device_MultiEdit_Tooltip": "Cuidado. Al hacer clic se aplicará el valor de la izquierda a todos los dispositivos seleccionados anteriormente.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "Fallo al guardar el dispositivo", "Device_Save_Unauthorized": "No autorizado - Token de API inválido", "Device_Saved_Success": "Dispositivo guardado exitósamente", @@ -338,6 +343,7 @@ "Gen_Invalid_Value": "Un valor inválido fue ingresado", "Gen_LockedDB": "Fallo - La base de datos puede estar bloqueada - Pulsa F1 -> Ajustes de desarrolladores -> Consola o prueba más tarde.", "Gen_NetworkMask": "Máscara de red", + "Gen_New": "", "Gen_Offline": "Desconectado", "Gen_Okay": "Aceptar", "Gen_Online": "En linea", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 5354403d6..56394aea3 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "", "Gen_Okay": "", "Gen_Online": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 562af0b07..1a404705f 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Actions en masse :", "Device_MultiEdit_No_Devices": "Aucun appareil sélectionné.", "Device_MultiEdit_Tooltip": "Attention. Ceci va appliquer la valeur de gauche à tous les appareils sélectionnés au-dessus.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "Erreur à l'enregistrement de l'appareil", "Device_Save_Unauthorized": "Non autorisé - Jeton d'API invalide", "Device_Saved_Success": "Appareil enregistré avec succès", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "Une valeur invalide a été renseignée", "Gen_LockedDB": "Erreur - La base de données est peut-être verrouillée - Vérifier avec les outils de dév via F12 -> Console ou essayer plus tard.", "Gen_NetworkMask": "Masque réseau", + "Gen_New": "", "Gen_Offline": "Hors ligne", "Gen_Okay": "OK", "Gen_Online": "En ligne", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index f03913c02..a0464200f 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Azioni di massa:", "Device_MultiEdit_No_Devices": "Nessun dispositivo selezionato.", "Device_MultiEdit_Tooltip": "Attento. Facendo clic verrà applicato il valore sulla sinistra a tutti i dispositivi selezionati sopra.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "Impossibile salvare il dispositivo", "Device_Save_Unauthorized": "Non autorizzato: token API non valido", "Device_Saved_Success": "Dispositivo salvato correttamente", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "È stato inserito un valore non valido", "Gen_LockedDB": "ERRORE: il DB potrebbe essere bloccato, controlla F12 Strumenti di sviluppo -> Console o riprova più tardi.", "Gen_NetworkMask": "Maschera di rete", + "Gen_New": "", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", @@ -797,4 +803,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 083ea9ea2..07428b05c 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "大量のアクション:", "Device_MultiEdit_No_Devices": "デバイスが選択されていません。", "Device_MultiEdit_Tooltip": "注意。これをクリックすると、左側の値が上記で選択したすべてのデバイスに適用されます。", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "デバイスの保存に失敗しました", "Device_Save_Unauthorized": "許可されていない - 無効なAPIトークン", "Device_Saved_Success": "デバイスが正常に保存されました", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "無効な値が入力されました", "Gen_LockedDB": "エラー - DBがロックされている可能性があります - F12で開発者ツール→コンソールを確認するか、後で試してください。", "Gen_NetworkMask": "ネットワークマスク", + "Gen_New": "", "Gen_Offline": "オフライン", "Gen_Okay": "Ok", "Gen_Online": "オンライン", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 49ff0ab59..605489b31 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Flerhandlinger:", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "Forsiktig. Ved å klikke på denne vil verdien til venstre brukes på alle enhetene som er valgt ovenfor.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "FEIL - DB kan være låst - Sjekk F12 Dev tools -> Konsoll eller prøv senere.", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "Frakoblet", "Gen_Okay": "Ok", "Gen_Online": "", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 369216700..48eef2825 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Operacje zbiorcze:", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "Uwaga. Kliknięcie tego spowoduje zastosowanie wartości po lewej stronie do wszystkich wybranych powyżej urządzeń.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "Błąd - Baza danych może być zablokowana - Sprawdź narzędzia deweloperskie F12 -> Konsola lub spróbuj później.", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "Niedostępne", "Gen_Okay": "Ok", "Gen_Online": "Dostępne", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index f6b44cc14..6de1d4ef8 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Ações em massa:", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "Cuidadoso. Clicar aqui aplicará o valor à esquerda a todos os dispositivos selecionados acima.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "ERRO - O banco de dados pode estar bloqueado - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index 880988629..b72ee269c 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Ações em massa:", "Device_MultiEdit_No_Devices": "Nenhum dispositivo selecionado.", "Device_MultiEdit_Tooltip": "Cuidadoso. Clicar aqui aplicará o valor à esquerda a todos os dispositivos selecionados acima.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "ERRO - A base de dados pode estar bloqueada - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.", "Gen_NetworkMask": "Máscara de Rede", + "Gen_New": "", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index ae4349c0a..e19861cb7 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Массовые действия:", "Device_MultiEdit_No_Devices": "Устройства не выбраны.", "Device_MultiEdit_Tooltip": "Осторожно. При нажатии на эту кнопку значение слева будет применено ко всем устройствам, выбранным выше.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "Не удалось сохранить устройство", "Device_Save_Unauthorized": "Не авторизован - недействительный токен API", "Device_Saved_Success": "Устройство успешно сохранено", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "Введено некорректное значение", "Gen_LockedDB": "ОШИБКА - Возможно, база данных заблокирована. Проверьте инструменты разработчика F12 -> Консоль или повторите попытку позже.", "Gen_NetworkMask": "Маска сети", + "Gen_New": "", "Gen_Offline": "Оффлайн", "Gen_Okay": "OK", "Gen_Online": "Онлайн", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 380b25200..0f1292fdb 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "", "Gen_Okay": "", "Gen_Online": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 489ef0d83..041891fed 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Toplu komutlar:", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "Dikkat. Buna tıklamak, soldaki değeri yukarıda seçilen tüm cihazlara uygulayacaktır.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "HATA - Veritabanı kilitlenmiş olabilir - F12 Geliştirici araçlarını -> Konsol kısmını kontrol edin veya daha sonra tekrar deneyin.", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "Çevrimdışı", "Gen_Okay": "Tamam", "Gen_Online": "Çevrimiçi", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 5028a9129..40bdea05c 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "Масові акції:", "Device_MultiEdit_No_Devices": "Не вибрано жодного пристрою.", "Device_MultiEdit_Tooltip": "Обережно. Якщо натиснути це, значення зліва буде застосовано до всіх пристроїв, вибраних вище.", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "Не вдалося зберегти пристрій", "Device_Save_Unauthorized": "Неавторизовано – недійсний токен API", "Device_Saved_Success": "Пристрій успішно збережено", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "Введено недійсне значення", "Gen_LockedDB": "ПОМИЛКА – БД може бути заблоковано – перевірте F12 Інструменти розробника -> Консоль або спробуйте пізніше.", "Gen_NetworkMask": "Маска мережі", + "Gen_New": "", "Gen_Offline": "Офлайн", "Gen_Okay": "Гаразд", "Gen_Online": "Онлайн", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 380b25200..0f1292fdb 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "", "Device_MultiEdit_No_Devices": "", "Device_MultiEdit_Tooltip": "", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "", "Gen_LockedDB": "", "Gen_NetworkMask": "", + "Gen_New": "", "Gen_Offline": "", "Gen_Okay": "", "Gen_Online": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 9b4709f6e..36fab9b80 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -203,6 +203,11 @@ "Device_MultiEdit_MassActions": "谨慎操作:", "Device_MultiEdit_No_Devices": "未选择设备。", "Device_MultiEdit_Tooltip": "小心。 单击此按钮会将左侧的值应用到上面选择的所有设备。", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", "Device_Save_Failed": "保存设备失败", "Device_Save_Unauthorized": "未授权 - API 令牌无效", "Device_Saved_Success": "设备保存成功", @@ -336,6 +341,7 @@ "Gen_Invalid_Value": "输入了无效的值", "Gen_LockedDB": "错误 - DB 可能被锁定 - 检查 F12 开发工具 -> 控制台或稍后重试。", "Gen_NetworkMask": "网络掩码", + "Gen_New": "", "Gen_Offline": "离线", "Gen_Okay": "Ok", "Gen_Online": "在线", diff --git a/server/__main__.py b/server/__main__.py index e0900409e..27e373953 100755 --- a/server/__main__.py +++ b/server/__main__.py @@ -124,8 +124,16 @@ def main(): # last time any scan or maintenance/upkeep was run conf.last_scan_run = loop_start_time - # Header - updateState("Process: Start") + # Compute the next scheduled run time across enabled device_scanner plugins + scanner_prefixes = {p["unique_prefix"] for p in all_plugins if p.get("plugin_type") == "device_scanner"} + scanner_next = [s.last_next_schedule for s in conf.mySchedules if s.service in scanner_prefixes] + next_scan_dt = min(scanner_next, default=None) + next_scan_time_iso = next_scan_dt.replace(microsecond=0).isoformat() if next_scan_dt else "" + + # Header (also broadcasts last_scan_run + next_scan_time to frontend via SSE / app_state.json) + updateState("Process: Start", + last_scan_run=loop_start_time.replace(microsecond=0).isoformat(), + next_scan_time=next_scan_time_iso) # Timestamp startTime = loop_start_time diff --git a/server/app_state.py b/server/app_state.py index e444cc2e3..505470e3f 100755 --- a/server/app_state.py +++ b/server/app_state.py @@ -43,7 +43,9 @@ def __init__( processScan=False, pluginsStates=None, appVersion=None, - buildTimestamp=None + buildTimestamp=None, + last_scan_run=None, + next_scan_time=None ): """ Initialize the application state, optionally overwriting previous values. @@ -89,6 +91,8 @@ def __init__( self.pluginsStates = previousState.get("pluginsStates", {}) self.appVersion = previousState.get("appVersion", "") self.buildTimestamp = previousState.get("buildTimestamp", "") + self.last_scan_run = previousState.get("last_scan_run", "") + self.next_scan_time = previousState.get("next_scan_time", "") else: # init first time values self.settingsSaved = 0 self.settingsImported = 0 @@ -101,6 +105,8 @@ def __init__( self.pluginsStates = {} self.appVersion = "" self.buildTimestamp = "" + self.last_scan_run = "" + self.next_scan_time = "" # Overwrite with provided parameters if supplied if settingsSaved is not None: @@ -133,6 +139,10 @@ def __init__( self.appVersion = appVersion if buildTimestamp is not None: self.buildTimestamp = buildTimestamp + if last_scan_run is not None: + self.last_scan_run = last_scan_run + if next_scan_time is not None: + self.next_scan_time = next_scan_time # check for new version every hour and if currently not running new version if self.isNewVersion is False and self.isNewVersionChecked + 3600 < int( timeNowUTC(as_string=False).timestamp() @@ -165,7 +175,9 @@ def __init__( self.settingsImported, timestamp=self.lastUpdated, appVersion=self.appVersion, - buildTimestamp=self.buildTimestamp + buildTimestamp=self.buildTimestamp, + last_scan_run=self.last_scan_run, + next_scan_time=self.next_scan_time ) except Exception as e: mylog("none", [f"[app_state] SSE broadcast: {e}"]) @@ -183,7 +195,9 @@ def updateState(newState = None, processScan = None, pluginsStates=None, appVersion=None, - buildTimestamp=None): + buildTimestamp=None, + last_scan_run=None, + next_scan_time=None): """ Convenience method to create or update the app state. @@ -197,6 +211,8 @@ def updateState(newState = None, pluginsStates (dict, optional): Plugin state updates. appVersion (str, optional): Application version. buildTimestamp (str, optional): Build timestamp. + last_scan_run (str, optional): ISO timestamp of last backend scan run. + next_scan_time (str, optional): ISO timestamp of next scheduled device_scanner run. Returns: app_state_class: Updated state object. @@ -210,7 +226,9 @@ def updateState(newState = None, processScan, pluginsStates, appVersion, - buildTimestamp + buildTimestamp, + last_scan_run, + next_scan_time ) From d2bc8410a75cd72abb6ff38bd90d80383e964998 Mon Sep 17 00:00:00 2001 From: anton garcias Date: Mon, 2 Mar 2026 10:54:23 +0100 Subject: [PATCH 067/122] Translated using Weblate (Catalan) Currently translated at 98.8% (790 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ca/ --- front/php/templates/language/ca_ca.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 501d719c0..a7ea65f7a 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -797,4 +797,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualitza el valor sota. Sigues curós de seguir el format anterior. No hi ha validació.", "test_event_tooltip": "Deseu els canvis primer abans de comprovar la configuració." -} \ No newline at end of file +} From 9f964be0c3f2631c03855bb3623f2732a674d9de Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 2 Mar 2026 10:54:27 +0100 Subject: [PATCH 068/122] Translated using Weblate (German) Currently translated at 78.8% (630 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/de/ --- front/php/templates/language/de_de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 64187af06..049ed1f74 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -870,4 +870,4 @@ "settings_system_label": "System", "settings_update_item_warning": "", "test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen." -} \ No newline at end of file +} From c89b2ded26d3a94ffa67c4ccfe5012f20aa3790d Mon Sep 17 00:00:00 2001 From: Deleted User Date: Mon, 2 Mar 2026 10:54:55 +0100 Subject: [PATCH 069/122] Translated using Weblate (Ukrainian) Currently translated at 97.6% (780 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/uk/ --- front/php/templates/language/uk_ua.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 5028a9129..6c40de99b 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -797,4 +797,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Оновіть значення нижче. Слідкуйте за попереднім форматом. Перевірка не виконана.", "test_event_tooltip": "Перш ніж перевіряти налаштування, збережіть зміни." -} \ No newline at end of file +} From 6e8a3d8a589e6527252f72c6d0f9b66e102f9fcd Mon Sep 17 00:00:00 2001 From: blomusti Date: Mon, 2 Mar 2026 10:54:52 +0100 Subject: [PATCH 070/122] Translated using Weblate (Turkish) Currently translated at 56.4% (451 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/tr/ --- front/php/templates/language/tr_tr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 489ef0d83..59b5b8f21 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -797,4 +797,4 @@ "settings_system_label": "Sistem", "settings_update_item_warning": "", "test_event_tooltip": "" -} \ No newline at end of file +} From 550f59b34f84127d5b8cdd4c8a940e7862296f59 Mon Sep 17 00:00:00 2001 From: GoldBull3t Date: Mon, 2 Mar 2026 10:54:44 +0100 Subject: [PATCH 071/122] Translated using Weblate (Portuguese (Brazil)) Currently translated at 50.8% (406 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/pt_BR/ --- front/php/templates/language/pt_br.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index f6b44cc14..46be2916f 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -797,4 +797,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} \ No newline at end of file +} From 04db68ea6cc909e9ce1c307d7e85576cf9a30c01 Mon Sep 17 00:00:00 2001 From: mid Date: Mon, 2 Mar 2026 10:54:36 +0100 Subject: [PATCH 072/122] Translated using Weblate (Japanese) Currently translated at 98.8% (790 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/ --- front/php/templates/language/ja_jp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 083ea9ea2..e1934576a 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -797,4 +797,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} \ No newline at end of file +} From dd564b235bbb9a8e0d693e1341e6a802f82651ea Mon Sep 17 00:00:00 2001 From: Anonymous Date: Mon, 2 Mar 2026 10:54:29 +0100 Subject: [PATCH 073/122] Translated using Weblate (Spanish) Currently translated at 98.2% (785 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/es/ --- front/php/templates/language/es_es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 7d2cb9b93..4587b9ce8 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -868,4 +868,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualice el valor a continuación. Tenga cuidado de seguir el formato anterior. O la validación no se realiza.", "test_event_tooltip": "Guarda tus cambios antes de probar nuevos ajustes." -} \ No newline at end of file +} From b4510663f77e3c40900ee9797664dd51755ae3ee Mon Sep 17 00:00:00 2001 From: Ptsa Daniel Date: Mon, 2 Mar 2026 10:55:00 +0100 Subject: [PATCH 074/122] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 98.4% (787 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/zh_Hans/ --- front/php/templates/language/zh_cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 9b4709f6e..8182539b1 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -797,4 +797,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} \ No newline at end of file +} From b7e1cb1f9d76c0e0ec75f6ab4e617899b19d92ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8E=8B=E5=8F=AB=E6=88=91=E6=9D=A5=E5=B7=A1?= =?UTF-8?q?=E5=B1=B1?= Date: Tue, 3 Mar 2026 00:33:21 +0100 Subject: [PATCH 075/122] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 98.4% (787 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/zh_Hans/ --- front/php/templates/language/zh_cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 8182539b1..e0cbbd953 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -139,7 +139,7 @@ "DevDetail_SessionTable_Duration": "时长", "DevDetail_SessionTable_IP": "IP", "DevDetail_SessionTable_Order": "排序", - "DevDetail_Shortcut_CurrentStatus": "当前状态", + "DevDetail_Shortcut_CurrentStatus": "状态", "DevDetail_Shortcut_DownAlerts": "下线警报", "DevDetail_Shortcut_Presence": "存在", "DevDetail_Shortcut_Sessions": "会话", From f3bf37bb24f18a1e3d53dd00aa516bf573829bd1 Mon Sep 17 00:00:00 2001 From: ButterflyOfFire Date: Mon, 2 Mar 2026 10:54:33 +0100 Subject: [PATCH 076/122] Translated using Weblate (French) Currently translated at 98.8% (790 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/fr/ --- front/php/templates/language/fr_fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 562af0b07..0032cd626 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -797,4 +797,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} \ No newline at end of file +} From 5a65d807a87c0a612f72cfb82a5603f71ffdde78 Mon Sep 17 00:00:00 2001 From: Marcus Isdahl Date: Mon, 2 Mar 2026 10:54:40 +0100 Subject: [PATCH 077/122] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 69.8% (558 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/nb_NO/ --- front/php/templates/language/nb_no.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 49ff0ab59..2dfe8ed40 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -797,4 +797,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Oppdater verdien nedenfor. Pass på å følge forrige format. Validering etterpå utføres ikke.", "test_event_tooltip": "Lagre endringene først, før du tester innstillingene dine." -} \ No newline at end of file +} From 4f239be8a35742fc52a59227126826a8383ad06f Mon Sep 17 00:00:00 2001 From: ssantos Date: Mon, 2 Mar 2026 10:54:46 +0100 Subject: [PATCH 078/122] Translated using Weblate (Portuguese (Portugal)) Currently translated at 64.5% (516 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/pt_PT/ --- front/php/templates/language/pt_pt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index 880988629..141bc5481 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -797,4 +797,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} \ No newline at end of file +} From 14362d20bd1921263b909b81a0bc15fe251f2d70 Mon Sep 17 00:00:00 2001 From: Safeguard Date: Mon, 2 Mar 2026 10:54:48 +0100 Subject: [PATCH 079/122] Translated using Weblate (Russian) Currently translated at 98.7% (789 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/ --- front/php/templates/language/ru_ru.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index ae4349c0a..76eed5ef3 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -797,4 +797,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} \ No newline at end of file +} From 594c2fe015d97d80f82225df1c4b5a38c2f5fe2d Mon Sep 17 00:00:00 2001 From: HAMAD ABDULLA Date: Mon, 2 Mar 2026 10:54:21 +0100 Subject: [PATCH 080/122] Translated using Weblate (Arabic) Currently translated at 84.9% (679 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ar/ --- front/php/templates/language/ar_ar.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 560449871..810384fb8 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -797,4 +797,4 @@ "settings_system_label": "نظام", "settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. لم يتم إجراء التحقق.", "test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات." -} \ No newline at end of file +} From 27f34963beff065eab6c249a090706092c03999f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Sta=C5=84czyk?= Date: Mon, 2 Mar 2026 10:54:42 +0100 Subject: [PATCH 081/122] Translated using Weblate (Polish) Currently translated at 84.7% (677 of 799 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/pl/ --- front/php/templates/language/pl_pl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 369216700..e15263e77 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -797,4 +797,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Zaktualizuj wartość poniżej. Uważaj, aby zachować poprzedni format. Walidacja nie jest wykonywana.", "test_event_tooltip": "Najpierw zapisz swoje zmiany, zanim przetestujesz ustawienia." -} \ No newline at end of file +} From 5be7bbe07dbcfe8a60ce084db9447f52bb4a1250 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 06:09:56 +0000 Subject: [PATCH 082/122] Enhance scan ETA display with current scanning state and add localization for 'Scanning...' message --- front/devices.php | 32 ++++++++++++++++++------- front/js/sse_manager.js | 3 ++- front/php/templates/language/ar_ar.json | 1 + front/php/templates/language/ca_ca.json | 1 + front/php/templates/language/cs_cz.json | 1 + front/php/templates/language/de_de.json | 1 + front/php/templates/language/en_us.json | 1 + front/php/templates/language/es_es.json | 1 + front/php/templates/language/fa_fa.json | 1 + front/php/templates/language/fr_fr.json | 1 + front/php/templates/language/it_it.json | 1 + front/php/templates/language/ja_jp.json | 1 + front/php/templates/language/nb_no.json | 1 + front/php/templates/language/pl_pl.json | 1 + front/php/templates/language/pt_br.json | 1 + front/php/templates/language/pt_pt.json | 1 + front/php/templates/language/ru_ru.json | 1 + front/php/templates/language/sv_sv.json | 1 + front/php/templates/language/tr_tr.json | 1 + front/php/templates/language/uk_ua.json | 1 + front/php/templates/language/vi_vn.json | 1 + front/php/templates/language/zh_cn.json | 1 + 22 files changed, 46 insertions(+), 9 deletions(-) diff --git a/front/devices.php b/front/devices.php index 6be7a08d8..0c6bab748 100755 --- a/front/devices.php +++ b/front/devices.php @@ -572,10 +572,17 @@ function computeNextScanLabel(nextScanTime) { return getString('Device_NextScan_In') + secsLeft + 's'; } -// Anchor for next scheduled scan time, ticker handle, and plugins data — module-level. +// Anchor for next scheduled scan time, ticker handle, plugins data, and current state — module-level. var _nextScanTimeAnchor = null; +var _currentStateAnchor = null; var _scanEtaTickerId = null; -var _pluginsData = null; +var _pluginsData = null; + +// Returns true when the backend is actively scanning (not idle). +// States that indicate scanning: Process: Start, Check scan, Scan processed. +function isScanningState(state) { + return ['Process: Start', 'Check scan', 'Scan processed'].indexOf(state) !== -1; +} // Fetch plugins.json once on page load so we can guard ETA display to device_scanner plugins only. $.get('php/server/query_json.php', { file: 'plugins.json', nocache: Date.now() }, function(res) { @@ -591,9 +598,10 @@ function hasEnabledDeviceScanners() { // --------------------------------------------------------- // Update the title-bar ETA subtitle and the DataTables empty-state message. // Called on every nax:scanEtaUpdate; the inner ticker keeps the title bar live between events. -function updateScanEtaDisplay(nextScanTime) { - // Prefer the backend-computed next_scan_time; keep previous anchor if not yet received. +function updateScanEtaDisplay(nextScanTime, currentState) { + // Prefer the backend-computed values; keep previous anchors if not yet received. _nextScanTimeAnchor = nextScanTime || _nextScanTimeAnchor; + _currentStateAnchor = currentState || _currentStateAnchor; // Restart the per-second title-bar ticker if (_scanEtaTickerId !== null) { clearInterval(_scanEtaTickerId); } @@ -605,13 +613,21 @@ function tickTitleBar() { eta.style.display = 'none'; return; } - eta.textContent = computeNextScanLabel(_nextScanTimeAnchor); + // Show 'Scanning...' when the backend is actively scanning, countdown otherwise. + eta.textContent = isScanningState(_currentStateAnchor) + ? getString('Device_Scanning') + : computeNextScanLabel(_nextScanTimeAnchor); eta.style.display = ''; } // Update DataTables empty message once per SSE event — avoids AJAX spam on server-side tables. - // Only show the next-scan ETA line when device_scanner plugins are actually enabled. - var label = hasEnabledDeviceScanners() ? computeNextScanLabel(_nextScanTimeAnchor) : ''; + // Show 'Scanning...' when active, countdown when idle, nothing when no device_scanner enabled. + var label = ''; + if (hasEnabledDeviceScanners()) { + label = isScanningState(_currentStateAnchor) + ? getString('Device_Scanning') + : computeNextScanLabel(_nextScanTimeAnchor); + } if ($.fn.DataTable.isDataTable('#tableDevices')) { var dt = $('#tableDevices').DataTable(); dt.settings()[0].oLanguage.sEmptyTable = buildEmptyDeviceTableMessage(label); @@ -624,7 +640,7 @@ function tickTitleBar() { // Listen for scan ETA updates dispatched by sse_manager.js (SSE push or poll fallback) document.addEventListener('nax:scanEtaUpdate', function(e) { - updateScanEtaDisplay(e.detail.nextScanTime); + updateScanEtaDisplay(e.detail.nextScanTime, e.detail.currentState); }); // --------------------------------------------------------- diff --git a/front/js/sse_manager.js b/front/js/sse_manager.js index a61f99302..ae9c69039 100644 --- a/front/js/sse_manager.js +++ b/front/js/sse_manager.js @@ -180,7 +180,8 @@ class NetAlertXStateManager { document.dispatchEvent(new CustomEvent('nax:scanEtaUpdate', { detail: { lastScanRun: appState["last_scan_run"], - nextScanTime: appState["next_scan_time"] + nextScanTime: appState["next_scan_time"], + currentState: appState["currentState"] } })); } diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 3f9f80feb..98ec45a4c 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "بحث", "Device_Shortcut_AllDevices": "جميع الأجهزة", "Device_Shortcut_AllNodes": "جميع العقد", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 8c6dec136..67b4292b8 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "Token invàlid - No autoritzat", "Device_Saved_Success": "S'ha guardat el dispositiu", "Device_Saved_Unexpected": "Actualització de dispositiu ha retornat una resposta no esperada", + "Device_Scanning": "", "Device_Searchbox": "Cerca", "Device_Shortcut_AllDevices": "Els meus dispositius", "Device_Shortcut_AllNodes": "Tots els nodes", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index a04566d0c..d03f57749 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "", "Device_Shortcut_AllDevices": "", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index ae01a670d..77d4ca40c 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -216,6 +216,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "Gerät erfolgreich gespeichert", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "Suche", "Device_Shortcut_AllDevices": "Meine Geräte", "Device_Shortcut_AllNodes": "Alle Knoten", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index b1222f342..c83f074b0 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "Unauthorized - invalid API token", "Device_Saved_Success": "Device saved successfully", "Device_Saved_Unexpected": "Device update returned an unexpected response", + "Device_Scanning": "Scanning...", "Device_Searchbox": "Search", "Device_Shortcut_AllDevices": "My devices", "Device_Shortcut_AllNodes": "All Nodes", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 464165efa..577e1fda8 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -214,6 +214,7 @@ "Device_Save_Unauthorized": "No autorizado - Token de API inválido", "Device_Saved_Success": "Dispositivo guardado exitósamente", "Device_Saved_Unexpected": "La actualización del dispositivo retornó una respuesta inesperada", + "Device_Scanning": "", "Device_Searchbox": "Búsqueda", "Device_Shortcut_AllDevices": "Mis dispositivos", "Device_Shortcut_AllNodes": "Todos los nodos", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 56394aea3..8cac2dd36 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "", "Device_Shortcut_AllDevices": "", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 1a404705f..4fa8ee6ef 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "Non autorisé - Jeton d'API invalide", "Device_Saved_Success": "Appareil enregistré avec succès", "Device_Saved_Unexpected": "La mise à jour de l'appareil a renvoyé une réponse inattendue", + "Device_Scanning": "", "Device_Searchbox": "Rechercher", "Device_Shortcut_AllDevices": "Mes appareils", "Device_Shortcut_AllNodes": "Tous les nœuds", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index a0464200f..d761d4760 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "Non autorizzato: token API non valido", "Device_Saved_Success": "Dispositivo salvato correttamente", "Device_Saved_Unexpected": "L'aggiornamento del dispositivo ha restituito una risposta imprevista", + "Device_Scanning": "", "Device_Searchbox": "Cerca", "Device_Shortcut_AllDevices": "I miei dispositivi", "Device_Shortcut_AllNodes": "Tutti i nodi", diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 07428b05c..a1fe40acd 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "許可されていない - 無効なAPIトークン", "Device_Saved_Success": "デバイスが正常に保存されました", "Device_Saved_Unexpected": "デバイスの更新で予期せぬ応答がありました", + "Device_Scanning": "", "Device_Searchbox": "検索", "Device_Shortcut_AllDevices": "自分のデバイス", "Device_Shortcut_AllNodes": "全ノード", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 605489b31..3fc679663 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "Søk", "Device_Shortcut_AllDevices": "Mine Enheter", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 48eef2825..1e46dc203 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "Szukaj", "Device_Shortcut_AllDevices": "Moje urządzenia", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 6de1d4ef8..2005b90ec 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "Procurar", "Device_Shortcut_AllDevices": "Meus dispositivos", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index b72ee269c..b60aa27d3 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "Procurar", "Device_Shortcut_AllDevices": "Os meus dispositivos", "Device_Shortcut_AllNodes": "Todos os Nodes", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index e19861cb7..6a8cb0df2 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "Не авторизован - недействительный токен API", "Device_Saved_Success": "Устройство успешно сохранено", "Device_Saved_Unexpected": "Обновление устройства дало неожиданный ответ", + "Device_Scanning": "", "Device_Searchbox": "Поиск", "Device_Shortcut_AllDevices": "Мои устройства", "Device_Shortcut_AllNodes": "Все узлы", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 0f1292fdb..c114cb5cb 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "", "Device_Shortcut_AllDevices": "", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 041891fed..e9e3d63cb 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "Arama", "Device_Shortcut_AllDevices": "Cihazlarım", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 40bdea05c..3fe7e7ca5 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "Неавторизовано – недійсний токен API", "Device_Saved_Success": "Пристрій успішно збережено", "Device_Saved_Unexpected": "Оновлення пристрою повернуло неочікувану відповідь", + "Device_Scanning": "", "Device_Searchbox": "Пошук", "Device_Shortcut_AllDevices": "Мої пристрої", "Device_Shortcut_AllNodes": "Усі вузли", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 0f1292fdb..c114cb5cb 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "", "Device_Saved_Success": "", "Device_Saved_Unexpected": "", + "Device_Scanning": "", "Device_Searchbox": "", "Device_Shortcut_AllDevices": "", "Device_Shortcut_AllNodes": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 36fab9b80..36453e064 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -212,6 +212,7 @@ "Device_Save_Unauthorized": "未授权 - API 令牌无效", "Device_Saved_Success": "设备保存成功", "Device_Saved_Unexpected": "设备更新返回了一个意外的响应", + "Device_Scanning": "", "Device_Searchbox": "搜索", "Device_Shortcut_AllDevices": "我的设备", "Device_Shortcut_AllNodes": "全部节点", From 4637ec635010c8641aebaed9c750086e64ab7f25 Mon Sep 17 00:00:00 2001 From: mid Date: Tue, 3 Mar 2026 06:29:57 +0100 Subject: [PATCH 083/122] Translated using Weblate (Japanese) Currently translated at 99.8% (803 of 804 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/ --- front/php/templates/language/ja_jp.json | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 374c948cd..f4e450a8b 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -139,7 +139,7 @@ "DevDetail_SessionTable_Duration": "期間", "DevDetail_SessionTable_IP": "IPアドレス", "DevDetail_SessionTable_Order": "順序", - "DevDetail_Shortcut_CurrentStatus": "現在の状態", + "DevDetail_Shortcut_CurrentStatus": "状態", "DevDetail_Shortcut_DownAlerts": "ダウンアラート", "DevDetail_Shortcut_Presence": "検出", "DevDetail_Shortcut_Sessions": "セッション", @@ -203,11 +203,11 @@ "Device_MultiEdit_MassActions": "大量のアクション:", "Device_MultiEdit_No_Devices": "デバイスが選択されていません。", "Device_MultiEdit_Tooltip": "注意。これをクリックすると、左側の値が上記で選択したすべてのデバイスに適用されます。", - "Device_NextScan_Imminent": "", - "Device_NextScan_In": "", - "Device_NoData_Help": "", - "Device_NoData_Scanning": "", - "Device_NoData_Title": "", + "Device_NextScan_Imminent": "まもなく", + "Device_NextScan_In": "次のスキャン ", + "Device_NoData_Help": "スキャン後にデバイスが表示されない場合は、SCAN_SUBNETS設定とドキュメントを確認してください。", + "Device_NoData_Scanning": "最初のスキャンを待機中 - 初期設定後、数分かかる場合があります。", + "Device_NoData_Title": "デバイスが見つかりません", "Device_Save_Failed": "デバイスの保存に失敗しました", "Device_Save_Unauthorized": "許可されていない - 無効なAPIトークン", "Device_Saved_Success": "デバイスが正常に保存されました", @@ -230,7 +230,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "お気に入り", "Device_TableHead_FirstSession": "初回セッション", - "Device_TableHead_Flapping": "", + "Device_TableHead_Flapping": "フラッピング", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "グループ", "Device_TableHead_IPv4": "IPv4", @@ -321,7 +321,7 @@ "Gen_AddDevice": "デバイス追加", "Gen_Add_All": "すべて追加", "Gen_All_Devices": "全デバイス", - "Gen_Archived": "", + "Gen_Archived": "アーカイブ", "Gen_AreYouSure": "本当によろしいですか?", "Gen_Backup": "バックアップを実行", "Gen_Cancel": "キャンセル", @@ -332,16 +332,16 @@ "Gen_Delete": "削除", "Gen_DeleteAll": "全削除", "Gen_Description": "説明", - "Gen_Down": "", + "Gen_Down": "ダウン中", "Gen_Error": "エラー", "Gen_Filter": "フィルター", - "Gen_Flapping": "", + "Gen_Flapping": "フラッピング", "Gen_Generate": "生成", "Gen_InvalidMac": "無効なMacアドレス。", "Gen_Invalid_Value": "無効な値が入力されました", "Gen_LockedDB": "エラー - DBがロックされている可能性があります - F12で開発者ツール→コンソールを確認するか、後で試してください。", "Gen_NetworkMask": "ネットワークマスク", - "Gen_New": "", + "Gen_New": "New", "Gen_Offline": "オフライン", "Gen_Okay": "Ok", "Gen_Online": "オンライン", @@ -359,7 +359,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "プレビューを選択", "Gen_Selected_Devices": "選択したデバイス:", - "Gen_Sleeping": "", + "Gen_Sleeping": "スリーピング", "Gen_Subnet": "サブネット", "Gen_Switch": "スイッチ", "Gen_Upd": "アップデートが正常に完了しました", @@ -589,8 +589,8 @@ "PIALERT_WEB_PROTECTION_name": "ログインを有効化", "PLUGINS_KEEP_HIST_description": "プラグイン履歴スキャン結果のエントリをいくつ保持すべきか(デバイス固有ではなく、プラグインごとに)。", "PLUGINS_KEEP_HIST_name": "プラグイン履歴", - "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", - "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL(Write-Ahead Log)の自動チェックポイント発生前の最大サイズ(MB単位)。低い値(10~20 MB)ではディスク/ストレージ使用量を削減しますが、スキャン時のCPU使用率が増加します。高い値(50~100 MB)は操作中のCPUスパイクを軽減しますが、RAMとディスク容量をより多く消費する可能性があります。デフォルトの50 MBは両者のバランスを取ります。SDカードを搭載したNASデバイスなどのリソース制約のあるシステムで有用です。設定保存後、変更を有効にするにはサーバーを再起動してください。", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "WALサイズ制限(MB)", "Plugins_DeleteAll": "すべて削除(フィルターは無視されます)", "Plugins_Filters_Mac": "Macフィルター", "Plugins_History": "イベント履歴", From 99de69e30d33a9d58a2b9cb7c2bfeabc1ff8f47c Mon Sep 17 00:00:00 2001 From: Massimo Pissarello Date: Tue, 3 Mar 2026 05:48:31 +0100 Subject: [PATCH 084/122] Translated using Weblate (Italian) Currently translated at 98.7% (794 of 804 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/it/ --- front/php/templates/language/it_it.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index a0464200f..30ada2751 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -139,7 +139,7 @@ "DevDetail_SessionTable_Duration": "Durata", "DevDetail_SessionTable_IP": "IP", "DevDetail_SessionTable_Order": "Ordine", - "DevDetail_Shortcut_CurrentStatus": "Stato attuale", + "DevDetail_Shortcut_CurrentStatus": "Stato", "DevDetail_Shortcut_DownAlerts": "Avvisi disconnessione", "DevDetail_Shortcut_Presence": "Presenza", "DevDetail_Shortcut_Sessions": "Sessioni", @@ -803,4 +803,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} \ No newline at end of file +} From 0f20fb38f0c136a9b40f173c53d0a4d8edff90d7 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 06:36:31 +0000 Subject: [PATCH 085/122] Enhance scanning state handling and localization updates across multiple language files --- front/devices.php | 44 +++++++++++++++---------- front/php/templates/language/ar_ar.json | 2 +- front/php/templates/language/ca_ca.json | 2 +- front/php/templates/language/de_de.json | 2 +- front/php/templates/language/en_us.json | 2 +- front/php/templates/language/es_es.json | 2 +- front/php/templates/language/fr_fr.json | 2 +- front/php/templates/language/ja_jp.json | 2 +- front/php/templates/language/nb_no.json | 2 +- front/php/templates/language/pl_pl.json | 2 +- front/php/templates/language/pt_br.json | 2 +- front/php/templates/language/pt_pt.json | 2 +- front/php/templates/language/ru_ru.json | 2 +- front/php/templates/language/tr_tr.json | 2 +- front/php/templates/language/uk_ua.json | 2 +- front/php/templates/language/zh_cn.json | 2 +- 16 files changed, 42 insertions(+), 32 deletions(-) diff --git a/front/devices.php b/front/devices.php index 0c6bab748..d8855fea7 100755 --- a/front/devices.php +++ b/front/devices.php @@ -577,11 +577,15 @@ function computeNextScanLabel(nextScanTime) { var _currentStateAnchor = null; var _scanEtaTickerId = null; var _pluginsData = null; +var _wasImminent = false; // true once the countdown displayed "imminent"; gates the Scanning... label +var _imminentForTime = null; // the _nextScanTimeAnchor value that last set _wasImminent + // prevents re-arming on the same (already-consumed) timestamp // Returns true when the backend is actively scanning (not idle). -// States that indicate scanning: Process: Start, Check scan, Scan processed. +// Uses an exclusion approach — only "Process: Idle" and an empty/null state are non-scanning. +// This future-proofs against new states added to the scan pipeline (e.g. "Plugin: AVAHISCAN"). function isScanningState(state) { - return ['Process: Start', 'Check scan', 'Scan processed'].indexOf(state) !== -1; + return !!state && state !== 'Process: Idle'; } // Fetch plugins.json once on page load so we can guard ETA display to device_scanner plugins only. @@ -603,31 +607,37 @@ function updateScanEtaDisplay(nextScanTime, currentState) { _nextScanTimeAnchor = nextScanTime || _nextScanTimeAnchor; _currentStateAnchor = currentState || _currentStateAnchor; + // Reset the imminent gate when the scan finishes back to idle so the next cycle starts clean. + if (currentState === 'Process: Idle') { _wasImminent = false; } + // Restart the per-second title-bar ticker if (_scanEtaTickerId !== null) { clearInterval(_scanEtaTickerId); } + function getEtaLabel() { + if (!hasEnabledDeviceScanners()) return ''; + if (isScanningState(_currentStateAnchor) && _wasImminent) return getString('Device_Scanning'); + var label = computeNextScanLabel(_nextScanTimeAnchor); + // Arm _wasImminent only for a NEW next_scan_time anchor — not the already-consumed one. + // This prevents the ticker from re-arming immediately after "Process: Idle" resets the flag + // while _nextScanTimeAnchor still holds the now-past timestamp. + if (label === getString('Device_NextScan_Imminent') && _nextScanTimeAnchor !== _imminentForTime) { + _wasImminent = true; + _imminentForTime = _nextScanTimeAnchor; + } + return label; + } + function tickTitleBar() { var eta = document.getElementById('nextScanEta'); if (!eta) return; - if (!hasEnabledDeviceScanners()) { - eta.style.display = 'none'; - return; - } - // Show 'Scanning...' when the backend is actively scanning, countdown otherwise. - eta.textContent = isScanningState(_currentStateAnchor) - ? getString('Device_Scanning') - : computeNextScanLabel(_nextScanTimeAnchor); + var label = getEtaLabel(); + if (!label) { eta.style.display = 'none'; return; } + eta.textContent = label; eta.style.display = ''; } // Update DataTables empty message once per SSE event — avoids AJAX spam on server-side tables. - // Show 'Scanning...' when active, countdown when idle, nothing when no device_scanner enabled. - var label = ''; - if (hasEnabledDeviceScanners()) { - label = isScanningState(_currentStateAnchor) - ? getString('Device_Scanning') - : computeNextScanLabel(_nextScanTimeAnchor); - } + var label = getEtaLabel(); if ($.fn.DataTable.isDataTable('#tableDevices')) { var dt = $('#tableDevices').DataTable(); dt.settings()[0].oLanguage.sEmptyTable = buildEmptyDeviceTableMessage(label); diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 17d8e876d..98ec45a4c 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -804,4 +804,4 @@ "settings_system_label": "نظام", "settings_update_item_warning": "قم بتحديث القيمة أدناه. احرص على اتباع التنسيق السابق. لم يتم إجراء التحقق.", "test_event_tooltip": "احفظ التغييرات أولاً قبل اختبار الإعدادات." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index d79950bbb..67b4292b8 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -804,4 +804,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualitza el valor sota. Sigues curós de seguir el format anterior. No hi ha validació.", "test_event_tooltip": "Deseu els canvis primer abans de comprovar la configuració." -} +} \ No newline at end of file diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 4988cd778..77d4ca40c 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -877,4 +877,4 @@ "settings_system_label": "System", "settings_update_item_warning": "", "test_event_tooltip": "Speichere die Änderungen, bevor Sie die Einstellungen testen." -} +} \ No newline at end of file diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index c83f074b0..efad99751 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -203,7 +203,7 @@ "Device_MultiEdit_MassActions": "Mass actions:", "Device_MultiEdit_No_Devices": "No devices selected.", "Device_MultiEdit_Tooltip": "Careful. Clicking this will apply the value on the left to all devices selected above.", - "Device_NextScan_Imminent": "imminent", + "Device_NextScan_Imminent": "Imminent...", "Device_NextScan_In": "Next scan in ", "Device_NoData_Help": "If devices don't appear after the scan, check your SCAN_SUBNETS setting and documentation.", "Device_NoData_Scanning": "Waiting for the first scan - this may take several minutes after the initial setup.", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 2a72ab1c7..577e1fda8 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -875,4 +875,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Actualice el valor a continuación. Tenga cuidado de seguir el formato anterior. O la validación no se realiza.", "test_event_tooltip": "Guarda tus cambios antes de probar nuevos ajustes." -} +} \ No newline at end of file diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 4adb756e8..4fa8ee6ef 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -804,4 +804,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index a0da15bed..a1fe40acd 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -804,4 +804,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} +} \ No newline at end of file diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 8bbb9a05f..3fc679663 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -804,4 +804,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Oppdater verdien nedenfor. Pass på å følge forrige format. Validering etterpå utføres ikke.", "test_event_tooltip": "Lagre endringene først, før du tester innstillingene dine." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index fb25d082e..1e46dc203 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -804,4 +804,4 @@ "settings_system_label": "System", "settings_update_item_warning": "Zaktualizuj wartość poniżej. Uważaj, aby zachować poprzedni format. Walidacja nie jest wykonywana.", "test_event_tooltip": "Najpierw zapisz swoje zmiany, zanim przetestujesz ustawienia." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 89cff78b8..2005b90ec 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -804,4 +804,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} +} \ No newline at end of file diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index 045faf1b0..b60aa27d3 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -804,4 +804,4 @@ "settings_system_label": "", "settings_update_item_warning": "", "test_event_tooltip": "Guarde as alterações antes de testar as definições." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index c23f2c7ad..6a8cb0df2 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -804,4 +804,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} +} \ No newline at end of file diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 8621925e0..e9e3d63cb 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -804,4 +804,4 @@ "settings_system_label": "Sistem", "settings_update_item_warning": "", "test_event_tooltip": "" -} +} \ No newline at end of file diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index bd33374cb..3fe7e7ca5 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -804,4 +804,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Оновіть значення нижче. Слідкуйте за попереднім форматом. Перевірка не виконана.", "test_event_tooltip": "Перш ніж перевіряти налаштування, збережіть зміни." -} +} \ No newline at end of file diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index ca715ad64..173219147 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -804,4 +804,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} +} \ No newline at end of file From 3ba1b69c1ebea8d5d0a1e56bbb66f5e2dac27a04 Mon Sep 17 00:00:00 2001 From: Massimo Pissarello Date: Tue, 3 Mar 2026 07:24:08 +0100 Subject: [PATCH 086/122] Translated using Weblate (Italian) Currently translated at 100.0% (805 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/it/ --- front/php/templates/language/it_it.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 323b75e14..ffb860ac5 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -203,16 +203,16 @@ "Device_MultiEdit_MassActions": "Azioni di massa:", "Device_MultiEdit_No_Devices": "Nessun dispositivo selezionato.", "Device_MultiEdit_Tooltip": "Attento. Facendo clic verrà applicato il valore sulla sinistra a tutti i dispositivi selezionati sopra.", - "Device_NextScan_Imminent": "", - "Device_NextScan_In": "", - "Device_NoData_Help": "", - "Device_NoData_Scanning": "", - "Device_NoData_Title": "", + "Device_NextScan_Imminent": "imminente", + "Device_NextScan_In": "Prossima scansione in ", + "Device_NoData_Help": "Se i dispositivi non vengono visualizzati dopo la scansione, controlla l'impostazione SCAN_SUBNETS e la documentazione.", + "Device_NoData_Scanning": "In attesa della prima scansione: potrebbero volerci diversi minuti dopo la configurazione iniziale.", + "Device_NoData_Title": "Ancora nessun dispositivo trovato", "Device_Save_Failed": "Impossibile salvare il dispositivo", "Device_Save_Unauthorized": "Non autorizzato: token API non valido", "Device_Saved_Success": "Dispositivo salvato correttamente", "Device_Saved_Unexpected": "L'aggiornamento del dispositivo ha restituito una risposta imprevista", - "Device_Scanning": "", + "Device_Scanning": "Scansione in corso...", "Device_Searchbox": "Cerca", "Device_Shortcut_AllDevices": "I miei dispositivi", "Device_Shortcut_AllNodes": "Tutti i nodi", @@ -322,7 +322,7 @@ "Gen_AddDevice": "Aggiungi dispositivo", "Gen_Add_All": "Aggiungi tutti", "Gen_All_Devices": "Tutti i dispositivi", - "Gen_Archived": "", + "Gen_Archived": "Archiviato", "Gen_AreYouSure": "Sei sicuro?", "Gen_Backup": "Esegui backup", "Gen_Cancel": "Annulla", @@ -333,16 +333,16 @@ "Gen_Delete": "Elimina", "Gen_DeleteAll": "Elimina tutti", "Gen_Description": "Descrizione", - "Gen_Down": "", + "Gen_Down": "Giù", "Gen_Error": "Errore", "Gen_Filter": "Filtro", - "Gen_Flapping": "", + "Gen_Flapping": "Flapping", "Gen_Generate": "Genera", "Gen_InvalidMac": "Indirizzo Mac non valido.", "Gen_Invalid_Value": "È stato inserito un valore non valido", "Gen_LockedDB": "ERRORE: il DB potrebbe essere bloccato, controlla F12 Strumenti di sviluppo -> Console o riprova più tardi.", "Gen_NetworkMask": "Maschera di rete", - "Gen_New": "", + "Gen_New": "Nuovo", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", @@ -360,7 +360,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Seleziona per anteprima", "Gen_Selected_Devices": "Dispositivi selezionati:", - "Gen_Sleeping": "", + "Gen_Sleeping": "Sleeping", "Gen_Subnet": "Sottorete", "Gen_Switch": "Cambia", "Gen_Upd": "Aggiornato correttamente", From 9fe8090a1b6b4c6018d8c16e12f2ab85531938eb Mon Sep 17 00:00:00 2001 From: mid Date: Tue, 3 Mar 2026 07:13:37 +0100 Subject: [PATCH 087/122] Translated using Weblate (Japanese) Currently translated at 100.0% (805 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/ --- front/php/templates/language/ja_jp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index a63d5a15e..2e895ff11 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -212,7 +212,7 @@ "Device_Save_Unauthorized": "許可されていない - 無効なAPIトークン", "Device_Saved_Success": "デバイスが正常に保存されました", "Device_Saved_Unexpected": "デバイスの更新で予期せぬ応答がありました", - "Device_Scanning": "", + "Device_Scanning": "スキャン中…", "Device_Searchbox": "検索", "Device_Shortcut_AllDevices": "自分のデバイス", "Device_Shortcut_AllNodes": "全ノード", From ac407bd86e017be1274618d006d8ecc4f4b5c5e7 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 06:37:37 +0000 Subject: [PATCH 088/122] Update next scan message for clarity in device management --- front/php/templates/language/en_us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index efad99751..a51bbdfc2 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -204,7 +204,7 @@ "Device_MultiEdit_No_Devices": "No devices selected.", "Device_MultiEdit_Tooltip": "Careful. Clicking this will apply the value on the left to all devices selected above.", "Device_NextScan_Imminent": "Imminent...", - "Device_NextScan_In": "Next scan in ", + "Device_NextScan_In": "Next scan in about ", "Device_NoData_Help": "If devices don't appear after the scan, check your SCAN_SUBNETS setting and documentation.", "Device_NoData_Scanning": "Waiting for the first scan - this may take several minutes after the initial setup.", "Device_NoData_Title": "No devices found yet", From ffbcc2ad25d242cbbb29fcbe2d8c5185e25465c5 Mon Sep 17 00:00:00 2001 From: mid Date: Tue, 3 Mar 2026 07:36:47 +0100 Subject: [PATCH 089/122] Translated using Weblate (Japanese) Currently translated at 99.8% (804 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/ --- front/php/templates/language/ja_jp.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 1f3e3bd06..2e895ff11 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -804,4 +804,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} \ No newline at end of file +} From ea7711231565491351e2e37464faa8d77b20d958 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:31:50 +0000 Subject: [PATCH 090/122] next scan dsiplay DRY --- server/__main__.py | 21 +++++++---- server/app_state.py | 9 ++++- server/initialise.py | 7 +++- server/scheduler.py | 5 ++- server/utils/datetime_utils.py | 68 ++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 14 deletions(-) diff --git a/server/__main__.py b/server/__main__.py index 27e373953..f58e760bd 100755 --- a/server/__main__.py +++ b/server/__main__.py @@ -124,16 +124,10 @@ def main(): # last time any scan or maintenance/upkeep was run conf.last_scan_run = loop_start_time - # Compute the next scheduled run time across enabled device_scanner plugins - scanner_prefixes = {p["unique_prefix"] for p in all_plugins if p.get("plugin_type") == "device_scanner"} - scanner_next = [s.last_next_schedule for s in conf.mySchedules if s.service in scanner_prefixes] - next_scan_dt = min(scanner_next, default=None) - next_scan_time_iso = next_scan_dt.replace(microsecond=0).isoformat() if next_scan_dt else "" - - # Header (also broadcasts last_scan_run + next_scan_time to frontend via SSE / app_state.json) + # Header (also broadcasts last_scan_run to frontend via SSE / app_state.json) updateState("Process: Start", last_scan_run=loop_start_time.replace(microsecond=0).isoformat(), - next_scan_time=next_scan_time_iso) + next_scan_time="") # Timestamp startTime = loop_start_time @@ -142,6 +136,17 @@ def main(): # Check if any plugins need to run on schedule pm.run_plugin_scripts("schedule") + # Compute the next scheduled run time AFTER schedule check (which updates last_next_schedule) + # Only device_scanner plugins have meaningful next_scan times for user display + scanner_prefixes = {p["unique_prefix"] for p in all_plugins if p.get("plugin_type") == "device_scanner"} + scanner_next = [s.last_next_schedule for s in conf.mySchedules if s.service in scanner_prefixes] + + # Get the earliest next scan time across all device scanners and broadcast. + # updateState validates the value is in the future before storing/broadcasting. + if scanner_next: + next_scan_dt = min(scanner_next) + updateState(next_scan_time=next_scan_dt.replace(microsecond=0).isoformat()) + # determine run/scan type based on passed time # -------------------------------------------- diff --git a/server/app_state.py b/server/app_state.py index 505470e3f..aab57cda7 100755 --- a/server/app_state.py +++ b/server/app_state.py @@ -4,7 +4,7 @@ from const import applicationPath, apiPath from logger import mylog from helper import checkNewVersion -from utils.datetime_utils import timeNowUTC +from utils.datetime_utils import timeNowUTC, is_datetime_future, normalizeTimeStamp from api_server.sse_broadcast import broadcast_state_update # Register NetAlertX directories using runtime configuration @@ -142,7 +142,12 @@ def __init__( if last_scan_run is not None: self.last_scan_run = last_scan_run if next_scan_time is not None: - self.next_scan_time = next_scan_time + # Guard against stale/past timestamps — only store if genuinely in the future. + # This enforces correctness regardless of which caller sets next_scan_time. + if next_scan_time == "" or is_datetime_future(normalizeTimeStamp(next_scan_time)): + self.next_scan_time = next_scan_time + else: + self.next_scan_time = "" # check for new version every hour and if currently not running new version if self.isNewVersion is False and self.isNewVersionChecked + 3600 < int( timeNowUTC(as_string=False).timestamp() diff --git a/server/initialise.py b/server/initialise.py index 850490c06..5bc85230e 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -13,7 +13,7 @@ from const import fullConfPath, fullConfFolder, default_tz, applicationPath from db.db_upgrade import ensure_views from helper import getBuildTimeStampAndVersion, collect_lang_strings, updateSubnets, generate_random_string -from utils.datetime_utils import timeNowUTC +from utils.datetime_utils import timeNowUTC, ensure_future_datetime from app_state import updateState from logger import mylog from api import update_api @@ -682,9 +682,12 @@ def importConfigs(pm, db, all_plugins): newSchedule = Cron(run_sch).schedule( start_date=timeNowUTC(as_string=False) ) + # Get initial next schedule time, ensuring it's in the future + next_schedule_time = ensure_future_datetime(newSchedule, timeNowUTC(as_string=False)) + conf.mySchedules.append( schedule_class( - plugin["unique_prefix"], newSchedule, newSchedule.next(), False + plugin["unique_prefix"], newSchedule, next_schedule_time, False ) ) diff --git a/server/scheduler.py b/server/scheduler.py index 33f98f116..c00fc518f 100755 --- a/server/scheduler.py +++ b/server/scheduler.py @@ -3,7 +3,7 @@ import datetime from logger import mylog -from utils.datetime_utils import timeNowTZ +from utils.datetime_utils import timeNowTZ, ensure_future_datetime # ------------------------------------------------------------------------------- @@ -48,6 +48,7 @@ def runScheduleCheck(self): if self.was_last_schedule_used: self.was_last_schedule_used = False - self.last_next_schedule = self.scheduleObject.next() + # Get the next scheduled time, ensuring it's in the future + self.last_next_schedule = ensure_future_datetime(self.scheduleObject, timeNowTZ(as_string=False)) return result diff --git a/server/utils/datetime_utils.py b/server/utils/datetime_utils.py index cc731d782..d29c829b3 100644 --- a/server/utils/datetime_utils.py +++ b/server/utils/datetime_utils.py @@ -88,6 +88,74 @@ def get_timezone_offset(): # Date and time methods # ------------------------------------------------------------------------------- +def is_datetime_future(dt, current_threshold=None): + """ + Check if a datetime is strictly in the future. + + Utility for validating that a datetime hasn't already passed. + Used after retrieving pre-computed schedule times to ensure they're still valid. + + Args: + dt: datetime.datetime object to validate + current_threshold: datetime to compare against. If None, uses timeNowUTC(as_string=False) + + Returns: + bool: True if dt is in the future, False otherwise + + Examples: + if is_datetime_future(next_scan_dt): + broadcast_to_frontend(next_scan_dt) + """ + if dt is None: + return False + + if current_threshold is None: + current_threshold = timeNowUTC(as_string=False) + + return dt > current_threshold + + +def ensure_future_datetime(schedule_obj, current_threshold=None, max_retries=5): + """ + Ensure a schedule's next() call returns a datetime strictly in the future. + + This is a defensive utility for cron/schedule libraries that should always return + future times but may have edge cases. Validates and retries if needed. + + Args: + schedule_obj: A schedule object with a .next() method (e.g., from croniter/APScheduler) + current_threshold: datetime to compare against. If None, uses timeNowTZ(as_string=False) + max_retries: Maximum times to call .next() if result is not in future (default: 5) + + Returns: + datetime.datetime: A guaranteed future datetime from schedule_obj.next() + + Raises: + RuntimeError: If max_retries exceeded without getting a future time + + Examples: + newSchedule = Cron(run_sch).schedule(start_date=timeNowUTC(as_string=False)) + next_time = ensure_future_datetime(newSchedule) + """ + if current_threshold is None: + current_threshold = timeNowTZ(as_string=False) + + next_time = schedule_obj.next() + retries = 0 + + while next_time <= current_threshold and retries < max_retries: + next_time = schedule_obj.next() + retries += 1 + + if next_time <= current_threshold: + raise RuntimeError( + f"[ensure_future_datetime] Failed to get future time after {max_retries} retries. " + f"Last attempt: {next_time}, Current time: {current_threshold}" + ) + + return next_time + + def normalizeTimeStamp(inputTimeStamp): """ Normalize various timestamp formats into a datetime.datetime object. From 01b6b9f04a699032eb38c2fc9e5ee299889e2bb3 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:32:07 +0000 Subject: [PATCH 091/122] whitespace --- server/__main__.py | 2 +- server/utils/datetime_utils.py | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/server/__main__.py b/server/__main__.py index f58e760bd..32240f54e 100755 --- a/server/__main__.py +++ b/server/__main__.py @@ -140,7 +140,7 @@ def main(): # Only device_scanner plugins have meaningful next_scan times for user display scanner_prefixes = {p["unique_prefix"] for p in all_plugins if p.get("plugin_type") == "device_scanner"} scanner_next = [s.last_next_schedule for s in conf.mySchedules if s.service in scanner_prefixes] - + # Get the earliest next scan time across all device scanners and broadcast. # updateState validates the value is in the future before storing/broadcasting. if scanner_next: diff --git a/server/utils/datetime_utils.py b/server/utils/datetime_utils.py index d29c829b3..bbcaab3ff 100644 --- a/server/utils/datetime_utils.py +++ b/server/utils/datetime_utils.py @@ -91,68 +91,68 @@ def get_timezone_offset(): def is_datetime_future(dt, current_threshold=None): """ Check if a datetime is strictly in the future. - + Utility for validating that a datetime hasn't already passed. Used after retrieving pre-computed schedule times to ensure they're still valid. - + Args: dt: datetime.datetime object to validate current_threshold: datetime to compare against. If None, uses timeNowUTC(as_string=False) - + Returns: bool: True if dt is in the future, False otherwise - + Examples: if is_datetime_future(next_scan_dt): broadcast_to_frontend(next_scan_dt) """ if dt is None: return False - + if current_threshold is None: current_threshold = timeNowUTC(as_string=False) - + return dt > current_threshold def ensure_future_datetime(schedule_obj, current_threshold=None, max_retries=5): """ Ensure a schedule's next() call returns a datetime strictly in the future. - + This is a defensive utility for cron/schedule libraries that should always return future times but may have edge cases. Validates and retries if needed. - + Args: schedule_obj: A schedule object with a .next() method (e.g., from croniter/APScheduler) current_threshold: datetime to compare against. If None, uses timeNowTZ(as_string=False) max_retries: Maximum times to call .next() if result is not in future (default: 5) - + Returns: datetime.datetime: A guaranteed future datetime from schedule_obj.next() - + Raises: RuntimeError: If max_retries exceeded without getting a future time - + Examples: newSchedule = Cron(run_sch).schedule(start_date=timeNowUTC(as_string=False)) next_time = ensure_future_datetime(newSchedule) """ if current_threshold is None: current_threshold = timeNowTZ(as_string=False) - + next_time = schedule_obj.next() retries = 0 - + while next_time <= current_threshold and retries < max_retries: next_time = schedule_obj.next() retries += 1 - + if next_time <= current_threshold: raise RuntimeError( f"[ensure_future_datetime] Failed to get future time after {max_retries} retries. " f"Last attempt: {next_time}, Current time: {current_threshold}" ) - + return next_time From 76a259d9e5244f831057bc98367c53d637144daf Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:20:22 +0000 Subject: [PATCH 092/122] Fix DataTable redraw logic and update empty message handling --- front/devices.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/front/devices.php b/front/devices.php index d8855fea7..5c28a75b0 100755 --- a/front/devices.php +++ b/front/devices.php @@ -472,12 +472,9 @@ function renderFilters(customData) { // Collect filters const columnFilters = collectFilters(); - // Update DataTable with the new filters or search value (if applicable) - $('#tableDevices').DataTable().draw(); - - // Optionally, apply column filters (if using filters for individual columns) + // Apply column filters then draw once (previously drew twice — bug fixed). const table = $('#tableDevices').DataTable(); - table.columnFilters = columnFilters; // Apply your column filters logic + table.columnFilters = columnFilters; table.draw(); }); @@ -636,12 +633,19 @@ function tickTitleBar() { eta.style.display = ''; } - // Update DataTables empty message once per SSE event — avoids AJAX spam on server-side tables. + // Update DataTables empty message once per SSE event. + // NOTE: Do NOT call dt.draw() here — on page load the SSE queue replays all + // accumulated events at once, causing a draw() (= GraphQL AJAX call) per event. + // Instead, update the visible empty-state DOM cell directly. var label = getEtaLabel(); if ($.fn.DataTable.isDataTable('#tableDevices')) { var dt = $('#tableDevices').DataTable(); - dt.settings()[0].oLanguage.sEmptyTable = buildEmptyDeviceTableMessage(label); - if (dt.page.info().recordsTotal === 0) { dt.draw(false); } + var newEmptyMsg = buildEmptyDeviceTableMessage(label); + dt.settings()[0].oLanguage.sEmptyTable = newEmptyMsg; + if (dt.page.info().recordsTotal === 0) { + // Patch the visible cell text without triggering a server-side AJAX reload. + $('#tableDevices tbody .dataTables_empty').html(newEmptyMsg); + } } tickTitleBar(); From 37f8a44cb3d0d889c731af454dfd04ca5f9b225c Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:25:09 +0000 Subject: [PATCH 093/122] Update devIpLong field to String and handle empty string coercion for Int fields in devices data --- server/api_server/graphql_endpoint.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 1e0da83d5..0396ed7df 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -85,7 +85,7 @@ class Device(ObjectType): devStatus = String(description="Online/Offline status") devIsRandomMac = Int(description="Calculated: Is MAC address randomized?") devParentChildrenCount = Int(description="Calculated: Number of children attached to this parent") - devIpLong = Int(description="Calculated: IP address in long format") + devIpLong = String(description="Calculated: IP address in long format (returned as string to support the full unsigned 32-bit range)") devFilterStatus = String(description="Calculated: Device status for UI filtering") devFQDN = String(description="Fully Qualified Domain Name") devParentRelType = String(description="Relationship type to parent") @@ -200,13 +200,26 @@ def resolve_devices(self, info, options=None): mylog("none", f"[graphql_schema] Error loading devices data: {e}") return DeviceResult(devices=[], count=0) + # Int fields that may arrive from the DB as empty strings — coerce to None + _INT_FIELDS = [ + "devFavorite", "devStaticIP", "devScan", "devLogEvents", "devAlertEvents", + "devAlertDown", "devSkipRepeated", "devPresentLastScan", "devIsNew", + "devIsArchived", "devReqNicsOnline", "devFlapping", "devCanSleep", "devIsSleeping", + ] + # Add dynamic fields to each device for device in devices_data: device["devIsRandomMac"] = 1 if is_random_mac(device["devMac"]) else 0 device["devParentChildrenCount"] = get_number_of_children( device["devMac"], devices_data ) - device["devIpLong"] = format_ip_long(device.get("devLastIP", "")) + # Return as string — IPv4 long values can exceed Int's signed 32-bit max (2,147,483,647) + device["devIpLong"] = str(format_ip_long(device.get("devLastIP", ""))) + + # Coerce empty strings to None so GraphQL Int serialisation doesn't fail + for _field in _INT_FIELDS: + if device.get(_field) == "": + device[_field] = None mylog("trace", f"[graphql_schema] devices_data: {devices_data}") From ba26f34191bba30eb41a10c4e59c9f7cc2e5f69d Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:40:19 +0000 Subject: [PATCH 094/122] Enhance device section UI with collapsible filters and default collapse on mobile --- front/devices.php | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/front/devices.php b/front/devices.php index 5c28a75b0..cc730aa74 100755 --- a/front/devices.php +++ b/front/devices.php @@ -53,7 +53,12 @@
    -

    +

    +
    + +
    @@ -72,10 +77,15 @@ @@ -148,6 +158,20 @@ // DEVICE_COLUMN_FIELDS, COL, NUMERIC_DEFAULTS, GRAPHQL_EXTRA_FIELDS, COLUMN_NAME_MAP // are all defined in js/device-columns.js — edit that file to add new columns. + // Collapse DevicePresence and Filters sections by default on small/mobile screens + (function collapseOnMobile() { + if (window.innerWidth < 768) { + ['#clients', '#columnFiltersWrap'].forEach(function(sel) { + var $box = $(sel); + if ($box.length) { + $box.addClass('collapsed-box'); + $box.find('.box-body, .box-footer').hide(); + $box.find('[data-widget="collapse"] i').removeClass('fa-minus').addClass('fa-plus'); + } + }); + } + })(); + // Read parameters & Initialize components callAfterAppInitialized(main) showSpinner(); From f1496b483bebfbb1118576a912dc161321128611 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Wed, 4 Mar 2026 01:04:07 +0000 Subject: [PATCH 095/122] Fix docker compose unit test to remove ARP FLUX warning. --- .../test_docker_compose_scenarios.py | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/test/docker_tests/test_docker_compose_scenarios.py b/test/docker_tests/test_docker_compose_scenarios.py index fb5b470bc..da04c285b 100644 --- a/test/docker_tests/test_docker_compose_scenarios.py +++ b/test/docker_tests/test_docker_compose_scenarios.py @@ -317,14 +317,18 @@ def _select_custom_ports(exclude: set[int] | None = None) -> int: raise RuntimeError("Unable to locate a free high port for compose testing") -def _make_port_check_hook(ports: tuple[int, ...]) -> Callable[[], None]: +def _make_port_check_hook( + ports: tuple[int, ...], + settle_wait_seconds: int = COMPOSE_SETTLE_WAIT_SECONDS, + port_wait_timeout: int = COMPOSE_PORT_WAIT_TIMEOUT, +) -> Callable[[], None]: """Return a callback that waits for the provided ports to accept TCP connections.""" def _hook() -> None: for port in ports: LAST_PORT_SUCCESSES.pop(port, None) - time.sleep(COMPOSE_SETTLE_WAIT_SECONDS) - _wait_for_ports(ports, timeout=COMPOSE_PORT_WAIT_TIMEOUT) + time.sleep(settle_wait_seconds) + _wait_for_ports(ports, timeout=port_wait_timeout) return _hook @@ -853,12 +857,18 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: default_project = "netalertx-normal-default" default_compose_file = _write_normal_startup_compose(default_dir, default_project, default_env_overrides) + port_check_timeout = 20 + settle_wait_seconds = 2 default_result = _run_docker_compose( default_compose_file, default_project, timeout=8, detached=True, - post_up=_make_port_check_hook(default_ports), + post_up=_make_port_check_hook( + default_ports, + settle_wait_seconds=settle_wait_seconds, + port_wait_timeout=port_check_timeout, + ), ) # MANDATORY LOGGING - DO NOT REMOVE (see file header for reasoning) print("\n[compose output default]", default_result.output) @@ -919,7 +929,11 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: custom_project, timeout=8, detached=True, - post_up=_make_port_check_hook(custom_ports), + post_up=_make_port_check_hook( + custom_ports, + settle_wait_seconds=settle_wait_seconds, + port_wait_timeout=port_check_timeout, + ), ) print("\n[compose output custom]", custom_result.output) custom_output = _assert_ports_ready(custom_result, custom_project, custom_ports) @@ -932,7 +946,12 @@ def test_normal_startup_no_warnings_compose(tmp_path: pathlib.Path) -> None: "⚠️" not in line or allowed_warning in line for line in custom_output.splitlines() ), "Unexpected warning found in custom output" - lowered_custom = custom_output.lower() + custom_output_without_allowed_warning = "\n".join( + line + for line in custom_output.splitlines() + if allowed_warning.lower() not in line.lower() + ) + lowered_custom = custom_output_without_allowed_warning.lower() assert "arning" not in lowered_custom assert "rror" not in lowered_custom From 5c0f29b97c84f3075b08f6b67553273178a00b1b Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Wed, 4 Mar 2026 01:33:43 +0000 Subject: [PATCH 096/122] coderabbit suggestions --- docs/docker-troubleshooting/arp-flux-sysctls.md | 4 ++++ .../entrypoint.d/36-override-individual-settings.sh | 7 +++++-- install/production-filesystem/entrypoint.sh | 9 +++++---- install/production-filesystem/services/healthcheck.sh | 5 +++-- test/api_endpoints/test_devices_endpoints.py | 8 ++++++-- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/docker-troubleshooting/arp-flux-sysctls.md b/docs/docker-troubleshooting/arp-flux-sysctls.md index d73c5cb04..29a40456d 100644 --- a/docs/docker-troubleshooting/arp-flux-sysctls.md +++ b/docs/docker-troubleshooting/arp-flux-sysctls.md @@ -40,6 +40,10 @@ Set these sysctls at container runtime. jokob-sk/netalertx:latest ``` +> **Note:** Setting `net.ipv4.conf.all.arp_ignore` and `net.ipv4.conf.all.arp_announce` may fail with "operation not permitted" unless the container is run with elevated privileges. To resolve this, you can: +> - Use `--privileged` with `docker run`. +> - Use the more restrictive `--cap-add=NET_ADMIN` (or `cap_add: [NET_ADMIN]` in `docker-compose` service definitions) to allow the sysctls to be applied at runtime. + ## Additional Resources For broader Docker Compose guidance, see: diff --git a/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh index 64b095e30..d51960859 100644 --- a/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh +++ b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh @@ -12,10 +12,13 @@ fi if [ -n "${LOADED_PLUGINS:-}" ]; then echo "[ENV] Applying LOADED_PLUGINS override" value=$(printf '%s' "$LOADED_PLUGINS" | tr -d '\n\r') - escaped=$(printf '%s\n' "$value" | sed 's/[\/&]/\\&/g') + # declare delimiter for sed and escape it along with / and & + delim='|' + escaped=$(printf '%s\n' "$value" | sed "s/[\/${delim}&]/\\&/g") if grep -q '^LOADED_PLUGINS=' "${NETALERTX_CONFIG}/app.conf"; then - sed -i "s|^LOADED_PLUGINS=.*|LOADED_PLUGINS=${escaped}|" "${NETALERTX_CONFIG}/app.conf" + # use same delimiter when substituting + sed -i "s${delim}^LOADED_PLUGINS=.*${delim}LOADED_PLUGINS=${escaped}${delim}" "${NETALERTX_CONFIG}/app.conf" else echo "LOADED_PLUGINS=${value}" >> "${NETALERTX_CONFIG}/app.conf" fi diff --git a/install/production-filesystem/entrypoint.sh b/install/production-filesystem/entrypoint.sh index 501e67c48..927d4dc80 100755 --- a/install/production-filesystem/entrypoint.sh +++ b/install/production-filesystem/entrypoint.sh @@ -86,10 +86,11 @@ for script in "${ENTRYPOINT_CHECKS}"/*; do fi script_name=$(basename "$script" | sed 's/^[0-9]*-//;s/\.(sh|py)$//;s/-/ /g') echo "--> ${script_name} " - if [ -n "${SKIP_STARTUP_CHECKS:-}" ] && echo "${SKIP_STARTUP_CHECKS}" | grep -Fq "${script_name}"; then - printf "%sskip%s\n" "${GREY}" "${RESET}" - continue - fi + if [ -n "${SKIP_STARTUP_CHECKS:-}" ] && + printf '%s' "${SKIP_STARTUP_CHECKS}" | grep -wFq -- "${script_name}"; then + printf "%sskip%s\n" "${GREY}" "${RESET}" + continue + fi "$script" NETALERTX_DOCKER_ERROR_CHECK=$? diff --git a/install/production-filesystem/services/healthcheck.sh b/install/production-filesystem/services/healthcheck.sh index f45dc1ab9..bd19f7bde 100755 --- a/install/production-filesystem/services/healthcheck.sh +++ b/install/production-filesystem/services/healthcheck.sh @@ -48,8 +48,9 @@ else log_error "python /app/server is not running" fi -# 5. Check port 20211 is open and contains "netalertx" -[ "${LISTEN_ADDR}" == "0.0.0.0" ] && CHECK_ADDR="127.0.0.1" || CHECK_ADDR="${LISTEN_ADDR}"; +# 5. Check port 20211 is open +CHECK_ADDR="${LISTEN_ADDR:-127.0.0.1}" +[ "${CHECK_ADDR}" == "0.0.0.0" ] && CHECK_ADDR="127.0.0.1" if timeout 10 bash -c "/dev/null; then log_success "Port ${PORT:-20211} is responding" else diff --git a/test/api_endpoints/test_devices_endpoints.py b/test/api_endpoints/test_devices_endpoints.py index b90f0a32b..4dd2a4ab2 100644 --- a/test/api_endpoints/test_devices_endpoints.py +++ b/test/api_endpoints/test_devices_endpoints.py @@ -168,8 +168,12 @@ def test_devices_totals(client, api_token, test_mac): expected_length = len(conditions) assert len(data) == expected_length - # 4. Check that at least 1 device exists - assert data[0] >= 1 # 'devices' count includes the dummy device + # 4. Check that at least 1 device exists when there are any conditions + if expected_length > 0: + assert data[0] >= 1 # 'devices' count includes the dummy device + else: + # no conditions defined; data should be an empty list + assert data == [] finally: delete_dummy(client, api_token, test_mac) From c73ce839f26455b5d1a48e9ca521a2fb6ee4ecc2 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Wed, 4 Mar 2026 03:07:37 +0000 Subject: [PATCH 097/122] Refactor ensure_future_datetime to simplify logic and remove max_retries parameter --- server/utils/datetime_utils.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/server/utils/datetime_utils.py b/server/utils/datetime_utils.py index bbcaab3ff..03cb1bed5 100644 --- a/server/utils/datetime_utils.py +++ b/server/utils/datetime_utils.py @@ -115,24 +115,19 @@ def is_datetime_future(dt, current_threshold=None): return dt > current_threshold -def ensure_future_datetime(schedule_obj, current_threshold=None, max_retries=5): +def ensure_future_datetime(schedule_obj, current_threshold=None): """ Ensure a schedule's next() call returns a datetime strictly in the future. - This is a defensive utility for cron/schedule libraries that should always return - future times but may have edge cases. Validates and retries if needed. + Keeps calling .next() until a future time is returned — never raises. Args: schedule_obj: A schedule object with a .next() method (e.g., from croniter/APScheduler) current_threshold: datetime to compare against. If None, uses timeNowTZ(as_string=False) - max_retries: Maximum times to call .next() if result is not in future (default: 5) Returns: datetime.datetime: A guaranteed future datetime from schedule_obj.next() - Raises: - RuntimeError: If max_retries exceeded without getting a future time - Examples: newSchedule = Cron(run_sch).schedule(start_date=timeNowUTC(as_string=False)) next_time = ensure_future_datetime(newSchedule) @@ -141,17 +136,9 @@ def ensure_future_datetime(schedule_obj, current_threshold=None, max_retries=5): current_threshold = timeNowTZ(as_string=False) next_time = schedule_obj.next() - retries = 0 - while next_time <= current_threshold and retries < max_retries: + while next_time <= current_threshold: next_time = schedule_obj.next() - retries += 1 - - if next_time <= current_threshold: - raise RuntimeError( - f"[ensure_future_datetime] Failed to get future time after {max_retries} retries. " - f"Last attempt: {next_time}, Current time: {current_threshold}" - ) return next_time From da23880eb1a1af573d1fa3ed563327e76ecd5d15 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Tue, 3 Mar 2026 23:20:40 -0500 Subject: [PATCH 098/122] Update docs/docker-troubleshooting/arp-flux-sysctls.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/docker-troubleshooting/arp-flux-sysctls.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/docker-troubleshooting/arp-flux-sysctls.md b/docs/docker-troubleshooting/arp-flux-sysctls.md index 29a40456d..958118de9 100644 --- a/docs/docker-troubleshooting/arp-flux-sysctls.md +++ b/docs/docker-troubleshooting/arp-flux-sysctls.md @@ -37,8 +37,7 @@ Set these sysctls at container runtime. docker run \ --sysctl net.ipv4.conf.all.arp_ignore=1 \ --sysctl net.ipv4.conf.all.arp_announce=2 \ - jokob-sk/netalertx:latest - ``` + ghcr.io/netalertx/netalertx:latest > **Note:** Setting `net.ipv4.conf.all.arp_ignore` and `net.ipv4.conf.all.arp_announce` may fail with "operation not permitted" unless the container is run with elevated privileges. To resolve this, you can: > - Use `--privileged` with `docker run`. From a532c98115d747b7d07777f45d6db12e3962b274 Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Tue, 3 Mar 2026 23:22:11 -0500 Subject: [PATCH 099/122] Update test/docker_tests/test_entrypoint.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- test/docker_tests/test_entrypoint.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/docker_tests/test_entrypoint.py b/test/docker_tests/test_entrypoint.py index a1b930225..a25337656 100644 --- a/test/docker_tests/test_entrypoint.py +++ b/test/docker_tests/test_entrypoint.py @@ -96,15 +96,26 @@ def test_skip_startup_checks_env_var(): @pytest.mark.feature_complete def test_host_optimization_warning_matches_sysctl(): """Validate host-optimization warning matches actual host sysctl values.""" +def test_host_optimization_warning_matches_sysctl(): + """Validate host-optimization warning matches actual host sysctl values.""" + sysctl_bin = shutil.which("sysctl") + if not sysctl_bin: + pytest.skip("sysctl binary not found on host; skipping host-optimization warning check") + ignore_proc = subprocess.run( - ["sysctl", "-n", "net.ipv4.conf.all.arp_ignore"], + [sysctl_bin, "-n", "net.ipv4.conf.all.arp_ignore"], capture_output=True, text=True, check=False, timeout=10, ) announce_proc = subprocess.run( - ["sysctl", "-n", "net.ipv4.conf.all.arp_announce"], + [sysctl_bin, "-n", "net.ipv4.conf.all.arp_announce"], + capture_output=True, + text=True, + check=False, + timeout=10, + ) capture_output=True, text=True, check=False, From b85420659904852abf14b4d75214848f101b7d9b Mon Sep 17 00:00:00 2001 From: Adam Outler Date: Wed, 4 Mar 2026 14:36:31 +0000 Subject: [PATCH 100/122] Address review comments from PR #1544 --- docs/docker-troubleshooting/arp-flux-sysctls.md | 3 ++- .../36-override-individual-settings.sh | 0 .../entrypoint.d/37-host-optimization.sh | 0 test/api_endpoints/test_devices_endpoints.py | 5 ++++- test/api_endpoints/test_mcp_tools_endpoints.py | 14 ++++++++++++-- test/docker_tests/test_entrypoint.py | 10 ++-------- 6 files changed, 20 insertions(+), 12 deletions(-) mode change 100644 => 100755 install/production-filesystem/entrypoint.d/36-override-individual-settings.sh mode change 100644 => 100755 install/production-filesystem/entrypoint.d/37-host-optimization.sh diff --git a/docs/docker-troubleshooting/arp-flux-sysctls.md b/docs/docker-troubleshooting/arp-flux-sysctls.md index 958118de9..1070659a7 100644 --- a/docs/docker-troubleshooting/arp-flux-sysctls.md +++ b/docs/docker-troubleshooting/arp-flux-sysctls.md @@ -38,7 +38,8 @@ Set these sysctls at container runtime. --sysctl net.ipv4.conf.all.arp_ignore=1 \ --sysctl net.ipv4.conf.all.arp_announce=2 \ ghcr.io/netalertx/netalertx:latest - + ``` + > **Note:** Setting `net.ipv4.conf.all.arp_ignore` and `net.ipv4.conf.all.arp_announce` may fail with "operation not permitted" unless the container is run with elevated privileges. To resolve this, you can: > - Use `--privileged` with `docker run`. > - Use the more restrictive `--cap-add=NET_ADMIN` (or `cap_add: [NET_ADMIN]` in `docker-compose` service definitions) to allow the sysctls to be applied at runtime. diff --git a/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh b/install/production-filesystem/entrypoint.d/36-override-individual-settings.sh old mode 100644 new mode 100755 diff --git a/install/production-filesystem/entrypoint.d/37-host-optimization.sh b/install/production-filesystem/entrypoint.d/37-host-optimization.sh old mode 100644 new mode 100755 diff --git a/test/api_endpoints/test_devices_endpoints.py b/test/api_endpoints/test_devices_endpoints.py index 4dd2a4ab2..62a3725cd 100644 --- a/test/api_endpoints/test_devices_endpoints.py +++ b/test/api_endpoints/test_devices_endpoints.py @@ -195,11 +195,14 @@ def test_devices_by_status(client, api_token, test_mac): # 3. Check favorite formatting if devFavorite = 1 # Update dummy device to favorite - client.post( + update_resp = client.post( f"/device/{test_mac}", json={"devFavorite": 1}, headers=auth_headers(api_token) ) + assert update_resp.status_code == 200 + assert update_resp.json.get("success") is True + resp_fav = client.get("/devices/by-status?status=my", headers=auth_headers(api_token)) fav_data = next((d for d in resp_fav.json if d["id"] == test_mac), None) assert fav_data is not None diff --git a/test/api_endpoints/test_mcp_tools_endpoints.py b/test/api_endpoints/test_mcp_tools_endpoints.py index 489c1e3b0..cdf2a94ae 100644 --- a/test/api_endpoints/test_mcp_tools_endpoints.py +++ b/test/api_endpoints/test_mcp_tools_endpoints.py @@ -30,11 +30,21 @@ def create_dummy(client, api_token, test_mac): "devType": "Router", "devVendor": "TestVendor", } - client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token)) + response = client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token)) + assert response.status_code in [200, 201], ( + f"Expected status 200/201 for device creation, got {response.status_code}. " + f"Response body: {response.get_data(as_text=True)}" + ) + return response def delete_dummy(client, api_token, test_mac): - client.delete("/devices", json={"macs": [test_mac]}, headers=auth_headers(api_token)) + response = client.delete("/devices", json={"macs": [test_mac]}, headers=auth_headers(api_token)) + assert response.status_code == 200, ( + f"Expected status 200 for device deletion, got {response.status_code}. " + f"Response body: {response.get_data(as_text=True)}" + ) + return response # --- Device Search Tests --- diff --git a/test/docker_tests/test_entrypoint.py b/test/docker_tests/test_entrypoint.py index a25337656..94a29a8a5 100644 --- a/test/docker_tests/test_entrypoint.py +++ b/test/docker_tests/test_entrypoint.py @@ -8,6 +8,7 @@ import subprocess import uuid import pytest +import shutil IMAGE = "netalertx-test" @@ -85,7 +86,7 @@ def test_no_app_conf_override_when_no_graphql_port(): def test_skip_startup_checks_env_var(): # If SKIP_STARTUP_CHECKS contains the human-readable name of a check (e.g. "mandatory folders"), - # the entrypoint should skip that specific check. We check that the "Creating NetAlertX log directory." + # the entrypoint should skip that specific check. We check that the "Creating NetAlertX log directory." # message (from the mandatory folders check) is not printed when skipped. result = _run_entrypoint(env={"SKIP_STARTUP_CHECKS": "mandatory folders"}, check_only=True) assert "Creating NetAlertX log directory" not in result.stdout @@ -94,8 +95,6 @@ def test_skip_startup_checks_env_var(): @pytest.mark.docker @pytest.mark.feature_complete -def test_host_optimization_warning_matches_sysctl(): - """Validate host-optimization warning matches actual host sysctl values.""" def test_host_optimization_warning_matches_sysctl(): """Validate host-optimization warning matches actual host sysctl values.""" sysctl_bin = shutil.which("sysctl") @@ -116,11 +115,6 @@ def test_host_optimization_warning_matches_sysctl(): check=False, timeout=10, ) - capture_output=True, - text=True, - check=False, - timeout=10, - ) if ignore_proc.returncode != 0 or announce_proc.returncode != 0: pytest.skip("sysctl values unavailable on host; skipping host-optimization warning check") From a0048980b8bc3aef5f92a1bed6e3a785390d4ec5 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 5 Mar 2026 18:33:36 +1100 Subject: [PATCH 101/122] LNG: Indonesian Signed-off-by: jokob-sk --- front/php/templates/language/id_id.json | 807 ++++++++++++++++++++ front/php/templates/language/it_it.json | 2 +- front/php/templates/language/ja_jp.json | 2 +- front/php/templates/language/lang.php | 1 + front/php/templates/language/languages.json | 1 + 5 files changed, 811 insertions(+), 2 deletions(-) create mode 100644 front/php/templates/language/id_id.json diff --git a/front/php/templates/language/id_id.json b/front/php/templates/language/id_id.json new file mode 100644 index 000000000..c114cb5cb --- /dev/null +++ b/front/php/templates/language/id_id.json @@ -0,0 +1,807 @@ +{ + "API_CUSTOM_SQL_description": "", + "API_CUSTOM_SQL_name": "", + "API_TOKEN_description": "", + "API_TOKEN_name": "", + "API_display_name": "", + "API_icon": "", + "About_Design": "", + "About_Exit": "", + "About_Title": "", + "AppEvents_AppEventProcessed": "", + "AppEvents_DateTimeCreated": "", + "AppEvents_Extra": "", + "AppEvents_GUID": "", + "AppEvents_Helper1": "", + "AppEvents_Helper2": "", + "AppEvents_Helper3": "", + "AppEvents_ObjectForeignKey": "", + "AppEvents_ObjectIndex": "", + "AppEvents_ObjectIsArchived": "", + "AppEvents_ObjectIsNew": "", + "AppEvents_ObjectPlugin": "", + "AppEvents_ObjectPrimaryID": "", + "AppEvents_ObjectSecondaryID": "", + "AppEvents_ObjectStatus": "", + "AppEvents_ObjectStatusColumn": "", + "AppEvents_ObjectType": "", + "AppEvents_Plugin": "", + "AppEvents_Type": "", + "BACKEND_API_URL_description": "", + "BACKEND_API_URL_name": "", + "BackDevDetail_Actions_Ask_Run": "", + "BackDevDetail_Actions_Not_Registered": "", + "BackDevDetail_Actions_Title_Run": "", + "BackDevDetail_Copy_Ask": "", + "BackDevDetail_Copy_Title": "", + "BackDevDetail_Tools_WOL_error": "", + "BackDevDetail_Tools_WOL_okay": "", + "BackDevices_Arpscan_disabled": "", + "BackDevices_Arpscan_enabled": "", + "BackDevices_Backup_CopError": "", + "BackDevices_Backup_Failed": "", + "BackDevices_Backup_okay": "", + "BackDevices_DBTools_DelDevError_a": "", + "BackDevices_DBTools_DelDevError_b": "", + "BackDevices_DBTools_DelDev_a": "", + "BackDevices_DBTools_DelDev_b": "", + "BackDevices_DBTools_DelEvents": "", + "BackDevices_DBTools_DelEventsError": "", + "BackDevices_DBTools_ImportCSV": "", + "BackDevices_DBTools_ImportCSVError": "", + "BackDevices_DBTools_ImportCSVMissing": "", + "BackDevices_DBTools_Purge": "", + "BackDevices_DBTools_UpdDev": "", + "BackDevices_DBTools_UpdDevError": "", + "BackDevices_DBTools_Upgrade": "", + "BackDevices_DBTools_UpgradeError": "", + "BackDevices_Device_UpdDevError": "", + "BackDevices_Restore_CopError": "", + "BackDevices_Restore_Failed": "", + "BackDevices_Restore_okay": "", + "BackDevices_darkmode_disabled": "", + "BackDevices_darkmode_enabled": "", + "CLEAR_NEW_FLAG_description": "", + "CLEAR_NEW_FLAG_name": "", + "CustProps_cant_remove": "", + "DAYS_TO_KEEP_EVENTS_description": "", + "DAYS_TO_KEEP_EVENTS_name": "", + "DISCOVER_PLUGINS_description": "", + "DISCOVER_PLUGINS_name": "", + "DevDetail_Children_Title": "", + "DevDetail_Copy_Device_Title": "", + "DevDetail_Copy_Device_Tooltip": "", + "DevDetail_CustomProperties_Title": "", + "DevDetail_CustomProps_reset_info": "", + "DevDetail_DisplayFields_Title": "", + "DevDetail_EveandAl_AlertAllEvents": "", + "DevDetail_EveandAl_AlertDown": "", + "DevDetail_EveandAl_Archived": "", + "DevDetail_EveandAl_NewDevice": "", + "DevDetail_EveandAl_NewDevice_Tooltip": "", + "DevDetail_EveandAl_RandomMAC": "", + "DevDetail_EveandAl_ScanCycle": "", + "DevDetail_EveandAl_ScanCycle_a": "", + "DevDetail_EveandAl_ScanCycle_z": "", + "DevDetail_EveandAl_Skip": "", + "DevDetail_EveandAl_Title": "", + "DevDetail_Events_CheckBox": "", + "DevDetail_GoToNetworkNode": "", + "DevDetail_Icon": "", + "DevDetail_Icon_Descr": "", + "DevDetail_Loading": "", + "DevDetail_MainInfo_Comments": "", + "DevDetail_MainInfo_Favorite": "", + "DevDetail_MainInfo_Group": "", + "DevDetail_MainInfo_Location": "", + "DevDetail_MainInfo_Name": "", + "DevDetail_MainInfo_Network": "", + "DevDetail_MainInfo_Network_Port": "", + "DevDetail_MainInfo_Network_Site": "", + "DevDetail_MainInfo_Network_Title": "", + "DevDetail_MainInfo_Owner": "", + "DevDetail_MainInfo_SSID": "", + "DevDetail_MainInfo_Title": "", + "DevDetail_MainInfo_Type": "", + "DevDetail_MainInfo_Vendor": "", + "DevDetail_MainInfo_mac": "", + "DevDetail_NavToChildNode": "", + "DevDetail_Network_Node_hover": "", + "DevDetail_Network_Port_hover": "", + "DevDetail_Nmap_Scans": "", + "DevDetail_Nmap_Scans_desc": "", + "DevDetail_Nmap_buttonDefault": "", + "DevDetail_Nmap_buttonDefault_text": "", + "DevDetail_Nmap_buttonDetail": "", + "DevDetail_Nmap_buttonDetail_text": "", + "DevDetail_Nmap_buttonFast": "", + "DevDetail_Nmap_buttonFast_text": "", + "DevDetail_Nmap_buttonSkipDiscovery": "", + "DevDetail_Nmap_buttonSkipDiscovery_text": "", + "DevDetail_Nmap_resultsLink": "", + "DevDetail_Owner_hover": "", + "DevDetail_Periodselect_All": "", + "DevDetail_Periodselect_LastMonth": "", + "DevDetail_Periodselect_LastWeek": "", + "DevDetail_Periodselect_LastYear": "", + "DevDetail_Periodselect_today": "", + "DevDetail_Run_Actions_Title": "", + "DevDetail_Run_Actions_Tooltip": "", + "DevDetail_SessionInfo_FirstSession": "", + "DevDetail_SessionInfo_LastIP": "", + "DevDetail_SessionInfo_LastSession": "", + "DevDetail_SessionInfo_StaticIP": "", + "DevDetail_SessionInfo_Status": "", + "DevDetail_SessionInfo_Title": "", + "DevDetail_SessionTable_Additionalinfo": "", + "DevDetail_SessionTable_Connection": "", + "DevDetail_SessionTable_Disconnection": "", + "DevDetail_SessionTable_Duration": "", + "DevDetail_SessionTable_IP": "", + "DevDetail_SessionTable_Order": "", + "DevDetail_Shortcut_CurrentStatus": "", + "DevDetail_Shortcut_DownAlerts": "", + "DevDetail_Shortcut_Presence": "", + "DevDetail_Shortcut_Sessions": "", + "DevDetail_Tab_Details": "", + "DevDetail_Tab_Events": "", + "DevDetail_Tab_EventsTableDate": "", + "DevDetail_Tab_EventsTableEvent": "", + "DevDetail_Tab_EventsTableIP": "", + "DevDetail_Tab_EventsTableInfo": "", + "DevDetail_Tab_Nmap": "", + "DevDetail_Tab_NmapEmpty": "", + "DevDetail_Tab_NmapTableExtra": "", + "DevDetail_Tab_NmapTableHeader": "", + "DevDetail_Tab_NmapTableIndex": "", + "DevDetail_Tab_NmapTablePort": "", + "DevDetail_Tab_NmapTableService": "", + "DevDetail_Tab_NmapTableState": "", + "DevDetail_Tab_NmapTableText": "", + "DevDetail_Tab_NmapTableTime": "", + "DevDetail_Tab_Plugins": "", + "DevDetail_Tab_Presence": "", + "DevDetail_Tab_Sessions": "", + "DevDetail_Tab_Tools": "", + "DevDetail_Tab_Tools_Internet_Info_Description": "", + "DevDetail_Tab_Tools_Internet_Info_Error": "", + "DevDetail_Tab_Tools_Internet_Info_Start": "", + "DevDetail_Tab_Tools_Internet_Info_Title": "", + "DevDetail_Tab_Tools_Nslookup_Description": "", + "DevDetail_Tab_Tools_Nslookup_Error": "", + "DevDetail_Tab_Tools_Nslookup_Start": "", + "DevDetail_Tab_Tools_Nslookup_Title": "", + "DevDetail_Tab_Tools_Speedtest_Description": "", + "DevDetail_Tab_Tools_Speedtest_Start": "", + "DevDetail_Tab_Tools_Speedtest_Title": "", + "DevDetail_Tab_Tools_Traceroute_Description": "", + "DevDetail_Tab_Tools_Traceroute_Error": "", + "DevDetail_Tab_Tools_Traceroute_Start": "", + "DevDetail_Tab_Tools_Traceroute_Title": "", + "DevDetail_Tools_WOL": "", + "DevDetail_Tools_WOL_noti": "", + "DevDetail_Tools_WOL_noti_text": "", + "DevDetail_Type_hover": "", + "DevDetail_Vendor_hover": "", + "DevDetail_WOL_Title": "", + "DevDetail_button_AddIcon": "", + "DevDetail_button_AddIcon_Help": "", + "DevDetail_button_AddIcon_Tooltip": "", + "DevDetail_button_Delete": "", + "DevDetail_button_DeleteEvents": "", + "DevDetail_button_DeleteEvents_Warning": "", + "DevDetail_button_Delete_ask": "", + "DevDetail_button_OverwriteIcons": "", + "DevDetail_button_OverwriteIcons_Tooltip": "", + "DevDetail_button_OverwriteIcons_Warning": "", + "DevDetail_button_Reset": "", + "DevDetail_button_Save": "", + "DeviceEdit_ValidMacIp": "", + "Device_MultiEdit": "", + "Device_MultiEdit_Backup": "", + "Device_MultiEdit_Fields": "", + "Device_MultiEdit_MassActions": "", + "Device_MultiEdit_No_Devices": "", + "Device_MultiEdit_Tooltip": "", + "Device_NextScan_Imminent": "", + "Device_NextScan_In": "", + "Device_NoData_Help": "", + "Device_NoData_Scanning": "", + "Device_NoData_Title": "", + "Device_Save_Failed": "", + "Device_Save_Unauthorized": "", + "Device_Saved_Success": "", + "Device_Saved_Unexpected": "", + "Device_Scanning": "", + "Device_Searchbox": "", + "Device_Shortcut_AllDevices": "", + "Device_Shortcut_AllNodes": "", + "Device_Shortcut_Archived": "", + "Device_Shortcut_Connected": "", + "Device_Shortcut_Devices": "", + "Device_Shortcut_DownAlerts": "", + "Device_Shortcut_DownOnly": "", + "Device_Shortcut_Favorites": "", + "Device_Shortcut_NewDevices": "", + "Device_Shortcut_OnlineChart": "", + "Device_Shortcut_Unstable": "", + "Device_TableHead_AlertDown": "", + "Device_TableHead_Connected_Devices": "", + "Device_TableHead_CustomProps": "", + "Device_TableHead_FQDN": "", + "Device_TableHead_Favorite": "", + "Device_TableHead_FirstSession": "", + "Device_TableHead_Flapping": "", + "Device_TableHead_GUID": "", + "Device_TableHead_Group": "", + "Device_TableHead_IPv4": "", + "Device_TableHead_IPv6": "", + "Device_TableHead_Icon": "", + "Device_TableHead_LastIP": "", + "Device_TableHead_LastIPOrder": "", + "Device_TableHead_LastSession": "", + "Device_TableHead_Location": "", + "Device_TableHead_MAC": "", + "Device_TableHead_MAC_full": "", + "Device_TableHead_Name": "", + "Device_TableHead_NetworkSite": "", + "Device_TableHead_Owner": "", + "Device_TableHead_ParentRelType": "", + "Device_TableHead_Parent_MAC": "", + "Device_TableHead_Port": "", + "Device_TableHead_PresentLastScan": "", + "Device_TableHead_ReqNicsOnline": "", + "Device_TableHead_RowID": "", + "Device_TableHead_Rowid": "", + "Device_TableHead_SSID": "", + "Device_TableHead_SourcePlugin": "", + "Device_TableHead_Status": "", + "Device_TableHead_SyncHubNodeName": "", + "Device_TableHead_Type": "", + "Device_TableHead_Vendor": "", + "Device_TableHead_Vlan": "", + "Device_Table_Not_Network_Device": "", + "Device_Table_info": "", + "Device_Table_nav_next": "", + "Device_Table_nav_prev": "", + "Device_Tablelenght": "", + "Device_Tablelenght_all": "", + "Device_Title": "", + "Devices_Filters": "", + "ENABLE_PLUGINS_description": "", + "ENABLE_PLUGINS_name": "", + "ENCRYPTION_KEY_description": "", + "ENCRYPTION_KEY_name": "", + "Email_display_name": "", + "Email_icon": "", + "Events_Loading": "", + "Events_Periodselect_All": "", + "Events_Periodselect_LastMonth": "", + "Events_Periodselect_LastWeek": "", + "Events_Periodselect_LastYear": "", + "Events_Periodselect_today": "", + "Events_Searchbox": "", + "Events_Shortcut_AllEvents": "", + "Events_Shortcut_DownAlerts": "", + "Events_Shortcut_Events": "", + "Events_Shortcut_MissSessions": "", + "Events_Shortcut_NewDevices": "", + "Events_Shortcut_Sessions": "", + "Events_Shortcut_VoidSessions": "", + "Events_TableHead_AdditionalInfo": "", + "Events_TableHead_Connection": "", + "Events_TableHead_Date": "", + "Events_TableHead_Device": "", + "Events_TableHead_Disconnection": "", + "Events_TableHead_Duration": "", + "Events_TableHead_DurationOrder": "", + "Events_TableHead_EventType": "", + "Events_TableHead_IP": "", + "Events_TableHead_IPOrder": "", + "Events_TableHead_Order": "", + "Events_TableHead_Owner": "", + "Events_TableHead_PendingAlert": "", + "Events_Table_info": "", + "Events_Table_nav_next": "", + "Events_Table_nav_prev": "", + "Events_Tablelenght": "", + "Events_Tablelenght_all": "", + "Events_Title": "", + "FakeMAC_hover": "", + "FieldLock_Error": "", + "FieldLock_Lock_Tooltip": "", + "FieldLock_Locked": "", + "FieldLock_SaveBeforeLocking": "", + "FieldLock_Source_Label": "", + "FieldLock_Unlock_Tooltip": "", + "FieldLock_Unlocked": "", + "GRAPHQL_PORT_description": "", + "GRAPHQL_PORT_name": "", + "Gen_Action": "", + "Gen_Add": "", + "Gen_AddDevice": "", + "Gen_Add_All": "", + "Gen_All_Devices": "", + "Gen_Archived": "", + "Gen_AreYouSure": "", + "Gen_Backup": "", + "Gen_Cancel": "", + "Gen_Change": "", + "Gen_Copy": "", + "Gen_CopyToClipboard": "", + "Gen_DataUpdatedUITakesTime": "", + "Gen_Delete": "", + "Gen_DeleteAll": "", + "Gen_Description": "", + "Gen_Down": "", + "Gen_Error": "", + "Gen_Filter": "", + "Gen_Flapping": "", + "Gen_Generate": "", + "Gen_InvalidMac": "", + "Gen_Invalid_Value": "", + "Gen_LockedDB": "", + "Gen_NetworkMask": "", + "Gen_New": "", + "Gen_Offline": "", + "Gen_Okay": "", + "Gen_Online": "", + "Gen_Purge": "", + "Gen_ReadDocs": "", + "Gen_Remove_All": "", + "Gen_Remove_Last": "", + "Gen_Reset": "", + "Gen_Restore": "", + "Gen_Run": "", + "Gen_Save": "", + "Gen_Saved": "", + "Gen_Search": "", + "Gen_Select": "", + "Gen_SelectIcon": "", + "Gen_SelectToPreview": "", + "Gen_Selected_Devices": "", + "Gen_Sleeping": "", + "Gen_Subnet": "", + "Gen_Switch": "", + "Gen_Upd": "", + "Gen_Upd_Fail": "", + "Gen_Update": "", + "Gen_Update_Value": "", + "Gen_ValidIcon": "", + "Gen_Warning": "", + "Gen_Work_In_Progress": "", + "Gen_create_new_device": "", + "Gen_create_new_device_info": "", + "General_display_name": "", + "General_icon": "", + "HRS_TO_KEEP_NEWDEV_description": "", + "HRS_TO_KEEP_NEWDEV_name": "", + "HRS_TO_KEEP_OFFDEV_description": "", + "HRS_TO_KEEP_OFFDEV_name": "", + "LOADED_PLUGINS_description": "", + "LOADED_PLUGINS_name": "", + "LOG_LEVEL_description": "", + "LOG_LEVEL_name": "", + "Loading": "", + "Login_Box": "", + "Login_Default_PWD": "", + "Login_Info": "", + "Login_Psw-box": "", + "Login_Psw_alert": "", + "Login_Psw_folder": "", + "Login_Psw_new": "", + "Login_Psw_run": "", + "Login_Remember": "", + "Login_Remember_small": "", + "Login_Submit": "", + "Login_Toggle_Alert_headline": "", + "Login_Toggle_Info": "", + "Login_Toggle_Info_headline": "", + "Maint_PurgeLog": "", + "Maint_RestartServer": "", + "Maint_Restart_Server_noti_text": "", + "Maintenance_InitCheck": "", + "Maintenance_InitCheck_Checking": "", + "Maintenance_InitCheck_QuickSetupGuide": "", + "Maintenance_InitCheck_Success": "", + "Maintenance_ReCheck": "", + "Maintenance_Running_Version": "", + "Maintenance_Status": "", + "Maintenance_Title": "", + "Maintenance_Tool_DownloadConfig": "", + "Maintenance_Tool_DownloadConfig_text": "", + "Maintenance_Tool_DownloadWorkflows": "", + "Maintenance_Tool_DownloadWorkflows_text": "", + "Maintenance_Tool_ExportCSV": "", + "Maintenance_Tool_ExportCSV_noti": "", + "Maintenance_Tool_ExportCSV_noti_text": "", + "Maintenance_Tool_ExportCSV_text": "", + "Maintenance_Tool_ImportCSV": "", + "Maintenance_Tool_ImportCSV_noti": "", + "Maintenance_Tool_ImportCSV_noti_text": "", + "Maintenance_Tool_ImportCSV_text": "", + "Maintenance_Tool_ImportConfig_noti": "", + "Maintenance_Tool_ImportPastedCSV": "", + "Maintenance_Tool_ImportPastedCSV_noti_text": "", + "Maintenance_Tool_ImportPastedCSV_text": "", + "Maintenance_Tool_ImportPastedConfig": "", + "Maintenance_Tool_ImportPastedConfig_noti_text": "", + "Maintenance_Tool_ImportPastedConfig_text": "", + "Maintenance_Tool_UnlockFields": "", + "Maintenance_Tool_UnlockFields_noti": "", + "Maintenance_Tool_UnlockFields_noti_text": "", + "Maintenance_Tool_UnlockFields_text": "", + "Maintenance_Tool_arpscansw": "", + "Maintenance_Tool_arpscansw_noti": "", + "Maintenance_Tool_arpscansw_noti_text": "", + "Maintenance_Tool_arpscansw_text": "", + "Maintenance_Tool_backup": "", + "Maintenance_Tool_backup_noti": "", + "Maintenance_Tool_backup_noti_text": "", + "Maintenance_Tool_backup_text": "", + "Maintenance_Tool_check_visible": "", + "Maintenance_Tool_clearSourceFields_selected": "", + "Maintenance_Tool_clearSourceFields_selected_noti": "", + "Maintenance_Tool_clearSourceFields_selected_text": "", + "Maintenance_Tool_darkmode": "", + "Maintenance_Tool_darkmode_noti": "", + "Maintenance_Tool_darkmode_noti_text": "", + "Maintenance_Tool_darkmode_text": "", + "Maintenance_Tool_del_ActHistory": "", + "Maintenance_Tool_del_ActHistory_noti": "", + "Maintenance_Tool_del_ActHistory_noti_text": "", + "Maintenance_Tool_del_ActHistory_text": "", + "Maintenance_Tool_del_alldev": "", + "Maintenance_Tool_del_alldev_noti": "", + "Maintenance_Tool_del_alldev_noti_text": "", + "Maintenance_Tool_del_alldev_text": "", + "Maintenance_Tool_del_allevents": "", + "Maintenance_Tool_del_allevents30": "", + "Maintenance_Tool_del_allevents30_noti": "", + "Maintenance_Tool_del_allevents30_noti_text": "", + "Maintenance_Tool_del_allevents30_text": "", + "Maintenance_Tool_del_allevents_noti": "", + "Maintenance_Tool_del_allevents_noti_text": "", + "Maintenance_Tool_del_allevents_text": "", + "Maintenance_Tool_del_empty_macs": "", + "Maintenance_Tool_del_empty_macs_noti": "", + "Maintenance_Tool_del_empty_macs_noti_text": "", + "Maintenance_Tool_del_empty_macs_text": "", + "Maintenance_Tool_del_selecteddev": "", + "Maintenance_Tool_del_selecteddev_text": "", + "Maintenance_Tool_del_unknowndev": "", + "Maintenance_Tool_del_unknowndev_noti": "", + "Maintenance_Tool_del_unknowndev_noti_text": "", + "Maintenance_Tool_del_unknowndev_text": "", + "Maintenance_Tool_del_unlockFields_selecteddev_text": "", + "Maintenance_Tool_displayed_columns_text": "", + "Maintenance_Tool_drag_me": "", + "Maintenance_Tool_order_columns_text": "", + "Maintenance_Tool_purgebackup": "", + "Maintenance_Tool_purgebackup_noti": "", + "Maintenance_Tool_purgebackup_noti_text": "", + "Maintenance_Tool_purgebackup_text": "", + "Maintenance_Tool_restore": "", + "Maintenance_Tool_restore_noti": "", + "Maintenance_Tool_restore_noti_text": "", + "Maintenance_Tool_restore_text": "", + "Maintenance_Tool_unlockFields_selecteddev": "", + "Maintenance_Tool_unlockFields_selecteddev_noti": "", + "Maintenance_Tool_upgrade_database_noti": "", + "Maintenance_Tool_upgrade_database_noti_text": "", + "Maintenance_Tool_upgrade_database_text": "", + "Maintenance_Tools_Tab_BackupRestore": "", + "Maintenance_Tools_Tab_Logging": "", + "Maintenance_Tools_Tab_Settings": "", + "Maintenance_Tools_Tab_Tools": "", + "Maintenance_Tools_Tab_UISettings": "", + "Maintenance_arp_status": "", + "Maintenance_arp_status_off": "", + "Maintenance_arp_status_on": "", + "Maintenance_built_on": "", + "Maintenance_current_version": "", + "Maintenance_database_backup": "", + "Maintenance_database_backup_found": "", + "Maintenance_database_backup_total": "", + "Maintenance_database_lastmod": "", + "Maintenance_database_path": "", + "Maintenance_database_rows": "", + "Maintenance_database_size": "", + "Maintenance_lang_selector_apply": "", + "Maintenance_lang_selector_empty": "", + "Maintenance_lang_selector_lable": "", + "Maintenance_lang_selector_text": "", + "Maintenance_new_version": "", + "Maintenance_themeselector_apply": "", + "Maintenance_themeselector_empty": "", + "Maintenance_themeselector_lable": "", + "Maintenance_themeselector_text": "", + "Maintenance_version": "", + "NETWORK_DEVICE_TYPES_description": "", + "NETWORK_DEVICE_TYPES_name": "", + "Navigation_About": "", + "Navigation_AppEvents": "", + "Navigation_Devices": "", + "Navigation_Donations": "", + "Navigation_Events": "", + "Navigation_Integrations": "", + "Navigation_Maintenance": "", + "Navigation_Monitoring": "", + "Navigation_Network": "", + "Navigation_Notifications": "", + "Navigation_Plugins": "", + "Navigation_Presence": "", + "Navigation_Report": "", + "Navigation_Settings": "", + "Navigation_SystemInfo": "", + "Navigation_Workflows": "", + "Network_Assign": "", + "Network_Cant_Assign": "", + "Network_Cant_Assign_No_Node_Selected": "", + "Network_Configuration_Error": "", + "Network_Connected": "", + "Network_Devices": "", + "Network_ManageAdd": "", + "Network_ManageAdd_Name": "", + "Network_ManageAdd_Name_text": "", + "Network_ManageAdd_Port": "", + "Network_ManageAdd_Port_text": "", + "Network_ManageAdd_Submit": "", + "Network_ManageAdd_Type": "", + "Network_ManageAdd_Type_text": "", + "Network_ManageAssign": "", + "Network_ManageDel": "", + "Network_ManageDel_Name": "", + "Network_ManageDel_Name_text": "", + "Network_ManageDel_Submit": "", + "Network_ManageDevices": "", + "Network_ManageEdit": "", + "Network_ManageEdit_ID": "", + "Network_ManageEdit_ID_text": "", + "Network_ManageEdit_Name": "", + "Network_ManageEdit_Name_text": "", + "Network_ManageEdit_Port": "", + "Network_ManageEdit_Port_text": "", + "Network_ManageEdit_Submit": "", + "Network_ManageEdit_Type": "", + "Network_ManageEdit_Type_text": "", + "Network_ManageLeaf": "", + "Network_ManageUnassign": "", + "Network_NoAssignedDevices": "", + "Network_NoDevices": "", + "Network_Node": "", + "Network_Node_Name": "", + "Network_Parent": "", + "Network_Root": "", + "Network_Root_Not_Configured": "", + "Network_Root_Unconfigurable": "", + "Network_ShowArchived": "", + "Network_ShowOffline": "", + "Network_Table_Hostname": "", + "Network_Table_IP": "", + "Network_Table_State": "", + "Network_Title": "", + "Network_UnassignedDevices": "", + "Notifications_All": "", + "Notifications_Mark_All_Read": "", + "PIALERT_WEB_PASSWORD_description": "", + "PIALERT_WEB_PASSWORD_name": "", + "PIALERT_WEB_PROTECTION_description": "", + "PIALERT_WEB_PROTECTION_name": "", + "PLUGINS_KEEP_HIST_description": "", + "PLUGINS_KEEP_HIST_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", + "Plugins_DeleteAll": "", + "Plugins_Filters_Mac": "", + "Plugins_History": "", + "Plugins_Obj_DeleteListed": "", + "Plugins_Objects": "", + "Plugins_Out_of": "", + "Plugins_Unprocessed_Events": "", + "Plugins_no_control": "", + "Presence_CalHead_day": "", + "Presence_CalHead_lang": "", + "Presence_CalHead_month": "", + "Presence_CalHead_quarter": "", + "Presence_CalHead_week": "", + "Presence_CalHead_year": "", + "Presence_CallHead_Devices": "", + "Presence_Key_OnlineNow": "", + "Presence_Key_OnlineNow_desc": "", + "Presence_Key_OnlinePast": "", + "Presence_Key_OnlinePastMiss": "", + "Presence_Key_OnlinePastMiss_desc": "", + "Presence_Key_OnlinePast_desc": "", + "Presence_Loading": "", + "Presence_Shortcut_AllDevices": "", + "Presence_Shortcut_Archived": "", + "Presence_Shortcut_Connected": "", + "Presence_Shortcut_Devices": "", + "Presence_Shortcut_DownAlerts": "", + "Presence_Shortcut_Favorites": "", + "Presence_Shortcut_NewDevices": "", + "Presence_Title": "", + "REFRESH_FQDN_description": "", + "REFRESH_FQDN_name": "", + "REPORT_DASHBOARD_URL_description": "", + "REPORT_DASHBOARD_URL_name": "", + "REPORT_ERROR": "", + "REPORT_MAIL_description": "", + "REPORT_MAIL_name": "", + "REPORT_TITLE": "", + "RandomMAC_hover": "", + "Reports_Sent_Log": "", + "SCAN_SUBNETS_description": "", + "SCAN_SUBNETS_name": "", + "SYSTEM_TITLE": "", + "Setting_Override": "", + "Setting_Override_Description": "", + "Settings_Metadata_Toggle": "", + "Settings_Show_Description": "", + "Settings_device_Scanners_desync": "", + "Settings_device_Scanners_desync_popup": "", + "Speedtest_Results": "", + "Systeminfo_AvailableIps": "", + "Systeminfo_CPU": "", + "Systeminfo_CPU_Cores": "", + "Systeminfo_CPU_Name": "", + "Systeminfo_CPU_Speed": "", + "Systeminfo_CPU_Temp": "", + "Systeminfo_CPU_Vendor": "", + "Systeminfo_Client_Resolution": "", + "Systeminfo_Client_User_Agent": "", + "Systeminfo_General": "", + "Systeminfo_General_Date": "", + "Systeminfo_General_Date2": "", + "Systeminfo_General_Full_Date": "", + "Systeminfo_General_TimeZone": "", + "Systeminfo_Memory": "", + "Systeminfo_Memory_Total_Memory": "", + "Systeminfo_Memory_Usage": "", + "Systeminfo_Memory_Usage_Percent": "", + "Systeminfo_Motherboard": "", + "Systeminfo_Motherboard_BIOS": "", + "Systeminfo_Motherboard_BIOS_Date": "", + "Systeminfo_Motherboard_BIOS_Vendor": "", + "Systeminfo_Motherboard_Manufactured": "", + "Systeminfo_Motherboard_Name": "", + "Systeminfo_Motherboard_Revision": "", + "Systeminfo_Network": "", + "Systeminfo_Network_Accept_Encoding": "", + "Systeminfo_Network_Accept_Language": "", + "Systeminfo_Network_Connection_Port": "", + "Systeminfo_Network_HTTP_Host": "", + "Systeminfo_Network_HTTP_Referer": "", + "Systeminfo_Network_HTTP_Referer_String": "", + "Systeminfo_Network_Hardware": "", + "Systeminfo_Network_Hardware_Interface_Mask": "", + "Systeminfo_Network_Hardware_Interface_Name": "", + "Systeminfo_Network_Hardware_Interface_RX": "", + "Systeminfo_Network_Hardware_Interface_TX": "", + "Systeminfo_Network_IP": "", + "Systeminfo_Network_IP_Connection": "", + "Systeminfo_Network_IP_Server": "", + "Systeminfo_Network_MIME": "", + "Systeminfo_Network_Request_Method": "", + "Systeminfo_Network_Request_Time": "", + "Systeminfo_Network_Request_URI": "", + "Systeminfo_Network_Secure_Connection": "", + "Systeminfo_Network_Secure_Connection_String": "", + "Systeminfo_Network_Server_Name": "", + "Systeminfo_Network_Server_Name_String": "", + "Systeminfo_Network_Server_Query": "", + "Systeminfo_Network_Server_Query_String": "", + "Systeminfo_Network_Server_Version": "", + "Systeminfo_Services": "", + "Systeminfo_Services_Description": "", + "Systeminfo_Services_Name": "", + "Systeminfo_Storage": "", + "Systeminfo_Storage_Device": "", + "Systeminfo_Storage_Mount": "", + "Systeminfo_Storage_Size": "", + "Systeminfo_Storage_Type": "", + "Systeminfo_Storage_Usage": "", + "Systeminfo_Storage_Usage_Free": "", + "Systeminfo_Storage_Usage_Mount": "", + "Systeminfo_Storage_Usage_Total": "", + "Systeminfo_Storage_Usage_Used": "", + "Systeminfo_System": "", + "Systeminfo_System_AVG": "", + "Systeminfo_System_Architecture": "", + "Systeminfo_System_Kernel": "", + "Systeminfo_System_OSVersion": "", + "Systeminfo_System_Running_Processes": "", + "Systeminfo_System_System": "", + "Systeminfo_System_Uname": "", + "Systeminfo_System_Uptime": "", + "Systeminfo_This_Client": "", + "Systeminfo_USB_Devices": "", + "TICKER_MIGRATE_TO_NETALERTX": "", + "TIMEZONE_description": "", + "TIMEZONE_name": "", + "UI_DEV_SECTIONS_description": "", + "UI_DEV_SECTIONS_name": "", + "UI_ICONS_description": "", + "UI_ICONS_name": "", + "UI_LANG_description": "", + "UI_LANG_name": "", + "UI_MY_DEVICES_description": "", + "UI_MY_DEVICES_name": "", + "UI_NOT_RANDOM_MAC_description": "", + "UI_NOT_RANDOM_MAC_name": "", + "UI_PRESENCE_description": "", + "UI_PRESENCE_name": "", + "UI_REFRESH_description": "", + "UI_REFRESH_name": "", + "VERSION_description": "", + "VERSION_name": "", + "WF_Action_Add": "", + "WF_Action_field": "", + "WF_Action_type": "", + "WF_Action_value": "", + "WF_Actions": "", + "WF_Add": "", + "WF_Add_Condition": "", + "WF_Add_Group": "", + "WF_Condition_field": "", + "WF_Condition_operator": "", + "WF_Condition_value": "", + "WF_Conditions": "", + "WF_Conditions_logic_rules": "", + "WF_Duplicate": "", + "WF_Enabled": "", + "WF_Export": "", + "WF_Export_Copy": "", + "WF_Import": "", + "WF_Import_Copy": "", + "WF_Name": "", + "WF_Remove": "", + "WF_Remove_Copy": "", + "WF_Save": "", + "WF_Trigger": "", + "WF_Trigger_event_type": "", + "WF_Trigger_type": "", + "add_icon_event_tooltip": "", + "add_option_event_tooltip": "", + "copy_icons_event_tooltip": "", + "devices_old": "", + "general_event_description": "", + "general_event_title": "", + "go_to_device_event_tooltip": "", + "go_to_node_event_tooltip": "", + "new_version_available": "", + "report_guid": "", + "report_guid_missing": "", + "report_select_format": "", + "report_time": "", + "run_event_tooltip": "", + "select_icon_event_tooltip": "", + "settings_core_icon": "", + "settings_core_label": "", + "settings_device_scanners": "", + "settings_device_scanners_icon": "", + "settings_device_scanners_info": "", + "settings_device_scanners_label": "", + "settings_enabled": "", + "settings_enabled_icon": "", + "settings_expand_all": "", + "settings_imported": "", + "settings_imported_label": "", + "settings_missing": "", + "settings_missing_block": "", + "settings_old": "", + "settings_other_scanners": "", + "settings_other_scanners_icon": "", + "settings_other_scanners_label": "", + "settings_publishers": "", + "settings_publishers_icon": "", + "settings_publishers_info": "", + "settings_publishers_label": "", + "settings_readonly": "", + "settings_saved": "", + "settings_system_icon": "", + "settings_system_label": "", + "settings_update_item_warning": "", + "test_event_tooltip": "" +} \ No newline at end of file diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index ffb860ac5..61be8afa2 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -804,4 +804,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 2e895ff11..1f3e3bd06 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -804,4 +804,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} +} \ No newline at end of file diff --git a/front/php/templates/language/lang.php b/front/php/templates/language/lang.php index d6b59b278..67c3b64c5 100755 --- a/front/php/templates/language/lang.php +++ b/front/php/templates/language/lang.php @@ -2,6 +2,7 @@ // ################################### // ## Languages +// ## Look-up here: http://www.lingoes.net/en/translator/langcode.htm // ################################### $defaultLang = "en_us"; diff --git a/front/php/templates/language/languages.json b/front/php/templates/language/languages.json index ba018a015..b106a0e4a 100644 --- a/front/php/templates/language/languages.json +++ b/front/php/templates/language/languages.json @@ -8,6 +8,7 @@ { "code": "en_us", "display": "English (en_us)" }, { "code": "es_es", "display": "Spanish (es_es)" }, { "code": "fa_fa", "display": "Farsi (fa_fa)" }, + { "code": "id_id", "display": "Indonesian (id_id)" }, { "code": "fr_fr", "display": "French (fr_fr)" }, { "code": "it_it", "display": "Italian (it_it)" }, { "code": "ja_jp", "display": "Japanese (ja_jp)" }, From 17d95d802fabc2c5fa8eb0c80d528042397dfc09 Mon Sep 17 00:00:00 2001 From: Massimo Pissarello Date: Thu, 5 Mar 2026 06:38:37 +0100 Subject: [PATCH 102/122] Translated using Weblate (Italian) Currently translated at 100.0% (805 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/it/ --- front/php/templates/language/it_it.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index ffb860ac5..9cdabd1ad 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -203,8 +203,8 @@ "Device_MultiEdit_MassActions": "Azioni di massa:", "Device_MultiEdit_No_Devices": "Nessun dispositivo selezionato.", "Device_MultiEdit_Tooltip": "Attento. Facendo clic verrà applicato il valore sulla sinistra a tutti i dispositivi selezionati sopra.", - "Device_NextScan_Imminent": "imminente", - "Device_NextScan_In": "Prossima scansione in ", + "Device_NextScan_Imminent": "Imminente...", + "Device_NextScan_In": "Prossima scansione tra circa ", "Device_NoData_Help": "Se i dispositivi non vengono visualizzati dopo la scansione, controlla l'impostazione SCAN_SUBNETS e la documentazione.", "Device_NoData_Scanning": "In attesa della prima scansione: potrebbero volerci diversi minuti dopo la configurazione iniziale.", "Device_NoData_Title": "Ancora nessun dispositivo trovato", From 2921614eac6788e1bef97efc6673db4a8ae870d2 Mon Sep 17 00:00:00 2001 From: Safeguard Date: Thu, 5 Mar 2026 08:22:47 +0100 Subject: [PATCH 103/122] Translated using Weblate (Russian) Currently translated at 99.0% (797 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/ --- front/php/templates/language/ru_ru.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 6a8cb0df2..602038106 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -139,7 +139,7 @@ "DevDetail_SessionTable_Duration": "Продолжительность", "DevDetail_SessionTable_IP": "IP", "DevDetail_SessionTable_Order": "Порядок", - "DevDetail_Shortcut_CurrentStatus": "Текущий статус", + "DevDetail_Shortcut_CurrentStatus": "Статус", "DevDetail_Shortcut_DownAlerts": "Оповещения о сбое", "DevDetail_Shortcut_Presence": "Присутствие", "DevDetail_Shortcut_Sessions": "Сеансы", @@ -203,16 +203,16 @@ "Device_MultiEdit_MassActions": "Массовые действия:", "Device_MultiEdit_No_Devices": "Устройства не выбраны.", "Device_MultiEdit_Tooltip": "Осторожно. При нажатии на эту кнопку значение слева будет применено ко всем устройствам, выбранным выше.", - "Device_NextScan_Imminent": "", - "Device_NextScan_In": "", - "Device_NoData_Help": "", - "Device_NoData_Scanning": "", - "Device_NoData_Title": "", + "Device_NextScan_Imminent": "Предстоящий...", + "Device_NextScan_In": "Следующее сканирование примерно через· ", + "Device_NoData_Help": "Если устройства не отображаются после сканирования, проверьте настройку SCAN_SUBNETS и документацию.", + "Device_NoData_Scanning": "Ожидание первого сканирования — это может занять несколько минут после первоначальной настройки.", + "Device_NoData_Title": "Устройства пока не найдены", "Device_Save_Failed": "Не удалось сохранить устройство", "Device_Save_Unauthorized": "Не авторизован - недействительный токен API", "Device_Saved_Success": "Устройство успешно сохранено", "Device_Saved_Unexpected": "Обновление устройства дало неожиданный ответ", - "Device_Scanning": "", + "Device_Scanning": "Сканирование...", "Device_Searchbox": "Поиск", "Device_Shortcut_AllDevices": "Мои устройства", "Device_Shortcut_AllNodes": "Все узлы", @@ -224,7 +224,7 @@ "Device_Shortcut_Favorites": "Избранные", "Device_Shortcut_NewDevices": "Новые устройства", "Device_Shortcut_OnlineChart": "Присутствие устройств", - "Device_Shortcut_Unstable": "", + "Device_Shortcut_Unstable": "Нестабильный", "Device_TableHead_AlertDown": "Оповещение о сост. ВЫКЛ", "Device_TableHead_Connected_Devices": "Соединения", "Device_TableHead_CustomProps": "Свойства / Действия", @@ -804,4 +804,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} \ No newline at end of file +} From 5a6de6d83238a6be0cc12302697c9961afb19b1d Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Thu, 5 Mar 2026 18:50:21 +1100 Subject: [PATCH 104/122] LNG: moved languages.json so weblate skips it Signed-off-by: jokob-sk --- front/php/templates/language/lang.php | 2 +- .../language/{ => language_definitions}/languages.json | 0 front/php/templates/language/merge_translations.py | 2 +- server/api_server/graphql_endpoint.py | 2 +- server/api_server/languages_endpoint.py | 2 +- server/initialise.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename front/php/templates/language/{ => language_definitions}/languages.json (100%) diff --git a/front/php/templates/language/lang.php b/front/php/templates/language/lang.php index 67c3b64c5..6c4c3dbcd 100755 --- a/front/php/templates/language/lang.php +++ b/front/php/templates/language/lang.php @@ -8,7 +8,7 @@ $defaultLang = "en_us"; // Load the canonical language list from languages.json — do not hardcode here. -$_langJsonPath = dirname(__FILE__) . '/languages.json'; +$_langJsonPath = dirname(__FILE__) . '/language_definitions/languages.json'; $_langJson = json_decode(file_get_contents($_langJsonPath), true); $allLanguages = array_column($_langJson['languages'], 'code'); diff --git a/front/php/templates/language/languages.json b/front/php/templates/language/language_definitions/languages.json similarity index 100% rename from front/php/templates/language/languages.json rename to front/php/templates/language/language_definitions/languages.json diff --git a/front/php/templates/language/merge_translations.py b/front/php/templates/language/merge_translations.py index bc5fdc2e3..d3cf38ec6 100755 --- a/front/php/templates/language/merge_translations.py +++ b/front/php/templates/language/merge_translations.py @@ -46,7 +46,7 @@ def load_language_codes(languages_json_path): if __name__ == "__main__": current_path = os.path.dirname(os.path.abspath(__file__)) # language codes are loaded from languages.json — add a new language there - languages_json = os.path.join(current_path, "languages.json") + languages_json = os.path.join(current_path, "language_definitions/languages.json") codes = load_language_codes(languages_json) file_paths = [os.path.join(current_path, f"{code}.json") for code in codes] merge_translations(file_paths[0], file_paths[1:]) diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 0396ed7df..5ea3e368a 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -563,7 +563,7 @@ def resolve_langStrings(self, info, langCode=None, langStringKey=None, fallback_ langStrings = [] # --- CORE JSON FILES --- - language_folder = '/app/front/php/templates/language/' + language_folder = '/app/front/php/templates/language/language_definitions/' if os.path.exists(language_folder): for filename in os.listdir(language_folder): if filename.endswith('.json') and filename != 'languages.json': diff --git a/server/api_server/languages_endpoint.py b/server/api_server/languages_endpoint.py index 5babcdb14..53d6fdd54 100644 --- a/server/api_server/languages_endpoint.py +++ b/server/api_server/languages_endpoint.py @@ -7,7 +7,7 @@ INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") LANGUAGES_JSON_PATH = os.path.join( - INSTALL_PATH, "front", "php", "templates", "language", "languages.json" + INSTALL_PATH, "front", "php", "templates", "language", "language_definitions", "languages.json" ) diff --git a/server/initialise.py b/server/initialise.py index 5bc85230e..182b0c02b 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -27,7 +27,7 @@ # =============================================================================== _LANGUAGES_JSON = os.path.join( - applicationPath, "front", "php", "templates", "language", "languages.json" + applicationPath, "front", "php", "templates", "language", "language_definitions" ,"languages.json" ) From f8c09d35a7bdec44ca7387780618cb509fb12ba6 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:24:57 +0000 Subject: [PATCH 105/122] Enhance scan ETA display logic to reload data for newly discovered devices after scanning finishes --- front/devices.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/front/devices.php b/front/devices.php index cc730aa74..fdb8f4588 100755 --- a/front/devices.php +++ b/front/devices.php @@ -624,6 +624,10 @@ function hasEnabledDeviceScanners() { // Update the title-bar ETA subtitle and the DataTables empty-state message. // Called on every nax:scanEtaUpdate; the inner ticker keeps the title bar live between events. function updateScanEtaDisplay(nextScanTime, currentState) { + // Detect scan-finished transition BEFORE updating _currentStateAnchor. + // justFinishedScanning is true only when the backend transitions scanning → idle. + var justFinishedScanning = (currentState === 'Process: Idle') && isScanningState(_currentStateAnchor); + // Prefer the backend-computed values; keep previous anchors if not yet received. _nextScanTimeAnchor = nextScanTime || _nextScanTimeAnchor; _currentStateAnchor = currentState || _currentStateAnchor; @@ -670,6 +674,13 @@ function tickTitleBar() { // Patch the visible cell text without triggering a server-side AJAX reload. $('#tableDevices tbody .dataTables_empty').html(newEmptyMsg); } + + // When scanning just finished and the table is still empty, reload data so + // newly discovered devices appear automatically. Skip reload if there are + // already rows — no need to disturb the user's current view. + if (justFinishedScanning && dt.page.info().recordsTotal === 0) { + dt.ajax.reload(null, false); // false = keep current page position + } } tickTitleBar(); From 1d1a8045a05385243fe500c7645aecf73e9831cb Mon Sep 17 00:00:00 2001 From: Safeguard Date: Thu, 5 Mar 2026 14:00:47 +0100 Subject: [PATCH 106/122] Translated using Weblate (Russian) Currently translated at 99.7% (803 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/ --- front/php/templates/language/ru_ru.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 602038106..246f8748a 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -322,7 +322,7 @@ "Gen_AddDevice": "Добавить устройство", "Gen_Add_All": "Добавить все", "Gen_All_Devices": "Все устройства", - "Gen_Archived": "", + "Gen_Archived": "Архивировано", "Gen_AreYouSure": "Вы уверены?", "Gen_Backup": "Запустить резервное копирование", "Gen_Cancel": "Отмена", @@ -333,7 +333,7 @@ "Gen_Delete": "Удалить", "Gen_DeleteAll": "Удалить все", "Gen_Description": "Описание", - "Gen_Down": "", + "Gen_Down": "Лежит", "Gen_Error": "Ошибка", "Gen_Filter": "Фильтр", "Gen_Flapping": "", @@ -342,7 +342,7 @@ "Gen_Invalid_Value": "Введено некорректное значение", "Gen_LockedDB": "ОШИБКА - Возможно, база данных заблокирована. Проверьте инструменты разработчика F12 -> Консоль или повторите попытку позже.", "Gen_NetworkMask": "Маска сети", - "Gen_New": "", + "Gen_New": "Новый", "Gen_Offline": "Оффлайн", "Gen_Okay": "OK", "Gen_Online": "Онлайн", @@ -360,7 +360,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Выберите для предварительного просмотра", "Gen_Selected_Devices": "Выбранные устройства:", - "Gen_Sleeping": "", + "Gen_Sleeping": "Спящий", "Gen_Subnet": "Подсеть", "Gen_Switch": "Переключить", "Gen_Upd": "Успешное обновление", @@ -590,8 +590,8 @@ "PIALERT_WEB_PROTECTION_name": "Включить вход", "PLUGINS_KEEP_HIST_description": "Сколько записей результатов сканирования истории плагинов следует хранить (для каждого плагина, а не для конкретного устройства).", "PLUGINS_KEEP_HIST_name": "История плагинов", - "PRAGMA_JOURNAL_SIZE_LIMIT_description": "", - "PRAGMA_JOURNAL_SIZE_LIMIT_name": "", + "PRAGMA_JOURNAL_SIZE_LIMIT_description": "Максимальный размер SQLite WAL (журнал упреждающей записи) в МБ перед запуском автоматических контрольных точек. Более низкие значения (10–20 МБ) уменьшают использование диска/хранилища, но увеличивают загрузку ЦП во время сканирования. Более высокие значения (50–100 МБ) уменьшают нагрузку на процессор во время операций, но могут использовать больше оперативной памяти и дискового пространства. Значение по умолчанию 50 МБ компенсирует и то, и другое. Полезно для систем с ограниченными ресурсами, таких как устройства NAS с SD-картами. Перезапустите сервер, чтобы изменения вступили в силу после сохранения настроек.", + "PRAGMA_JOURNAL_SIZE_LIMIT_name": "Ограничение размера WAL (МБ)", "Plugins_DeleteAll": "Удалить все (фильтры игнорируются)", "Plugins_Filters_Mac": "Фильтр MAC-адреса", "Plugins_History": "История событий", From 4eb5947ceba6ed394107af12442b6056bf99e3c2 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:34:19 +0000 Subject: [PATCH 107/122] Update language folder path to include all language definitions --- server/api_server/graphql_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 5ea3e368a..0396ed7df 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -563,7 +563,7 @@ def resolve_langStrings(self, info, langCode=None, langStringKey=None, fallback_ langStrings = [] # --- CORE JSON FILES --- - language_folder = '/app/front/php/templates/language/language_definitions/' + language_folder = '/app/front/php/templates/language/' if os.path.exists(language_folder): for filename in os.listdir(language_folder): if filename.endswith('.json') and filename != 'languages.json': From d49abd9d02488b76d425b75a12a0cd1f1a67d542 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:34:38 +0000 Subject: [PATCH 108/122] Enhance code standards, update contributing guidelines, and add tests for SYNC plugin functionality --- .github/skills/code-standards/SKILL.md | 6 +- .github/workflows/run-all-tests.yml | 16 + CONTRIBUTING.md | 29 +- front/plugins/sync/sync.py | 23 +- test/db_test_helpers.py | 121 +++++++- test/plugins/__init__.py | 0 test/plugins/test_sync_insert.py | 130 ++++++++ test/plugins/test_sync_protocol.py | 410 +++++++++++++++++++++++++ 8 files changed, 709 insertions(+), 26 deletions(-) create mode 100644 test/plugins/__init__.py create mode 100644 test/plugins/test_sync_insert.py create mode 100644 test/plugins/test_sync_protocol.py diff --git a/.github/skills/code-standards/SKILL.md b/.github/skills/code-standards/SKILL.md index 8b9e6ad66..0e9bd1f37 100644 --- a/.github/skills/code-standards/SKILL.md +++ b/.github/skills/code-standards/SKILL.md @@ -5,12 +5,12 @@ description: NetAlertX coding standards and conventions. Use this when writing c # Code Standards -- ask me to review before going to each next step (mention n step out of x) -- before starting, prepare implementation plan +- ask me to review before going to each next step (mention n step out of x) (AI only) +- before starting, prepare implementation plan (AI only) - ask me to review it and ask any clarifying questions first - add test creation as last step - follow repo architecture patterns - do not place in the root of /test - code has to be maintainable, no duplicate code -- follow DRY principle +- follow DRY principle - maintainability of code is more important than speed of implementation - code files should be less than 500 LOC for better maintainability ## File Length diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index dd4fc2f83..83feea364 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -3,6 +3,10 @@ name: 🧪 Manual Test Suite Selector on: workflow_dispatch: inputs: + run_all: + description: '✅ Run ALL tests (overrides individual selectors)' + type: boolean + default: false run_scan: description: '📂 scan/ (Scan, Logic, Locks, IPs)' type: boolean @@ -23,6 +27,10 @@ on: description: '📂 ui/ (Selenium & Dashboard)' type: boolean default: false + run_plugins: + description: '📂 plugins/ (Sync insert schema-aware logic)' + type: boolean + default: false run_root_files: description: '📄 Root Test Files (WOL, Atomicity, etc.)' type: boolean @@ -42,12 +50,20 @@ jobs: id: builder run: | PATHS="" + + # run_all overrides everything + if [ "${{ github.event.inputs.run_all }}" == "true" ]; then + echo "final_paths=test/" >> $GITHUB_OUTPUT + exit 0 + fi + # Folder Mapping with 'test/' prefix if [ "${{ github.event.inputs.run_scan }}" == "true" ]; then PATHS="$PATHS test/scan/"; fi if [ "${{ github.event.inputs.run_api }}" == "true" ]; then PATHS="$PATHS test/api_endpoints/ test/server/"; fi if [ "${{ github.event.inputs.run_backend }}" == "true" ]; then PATHS="$PATHS test/backend/ test/db/"; fi if [ "${{ github.event.inputs.run_docker_env }}" == "true" ]; then PATHS="$PATHS test/docker_tests/"; fi if [ "${{ github.event.inputs.run_ui }}" == "true" ]; then PATHS="$PATHS test/ui/"; fi + if [ "${{ github.event.inputs.run_plugins }}" == "true" ]; then PATHS="$PATHS test/plugins/"; fi # Root Files Mapping (files sitting directly in /test/) if [ "${{ github.event.inputs.run_root_files }}" == "true" ]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28d62003b..4f1087494 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,23 +1,23 @@ -# 🤝 Contributing to NetAlertX +# Contributing to NetAlertX First off, **thank you** for taking the time to contribute! NetAlertX is built and improved with the help of passionate people like you. --- -## 📂 Issues, Bugs, and Feature Requests +## Issues, Bugs, and Feature Requests Please use the [GitHub Issue Tracker](https://github.com/netalertx/NetAlertX/issues) for: -- Bug reports 🐞 -- Feature requests 💡 -- Documentation feedback 📖 +- Bug reports +- Feature requests +- Documentation feedback Before opening a new issue: -- 🛑 [Check Common Issues & Debug Tips](https://docs.netalertx.com/DEBUG_TIPS#common-issues) -- 🔍 [Search Closed Issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) +- [Check Common Issues & Debug Tips](https://docs.netalertx.com/DEBUG_TIPS#common-issues) +- [Search Closed Issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed) --- -## 🚀 Submitting Pull Requests (PRs) +## Submitting Pull Requests (PRs) We welcome PRs to improve the code, docs, or UI! @@ -29,9 +29,14 @@ Please: - If relevant, add or update tests and documentation - For plugins, refer to the [Plugin Dev Guide](https://docs.netalertx.com/PLUGINS_DEV) + +## Code quality + +- read and follow the [code-standards](/.github/skills/code-standards/SKILL.md) + --- -## 🌟 First-Time Contributors +## First-Time Contributors New to open source? Check out these resources: - [How to Fork and Submit a PR](https://opensource.guide/how-to-contribute/) @@ -39,15 +44,15 @@ New to open source? Check out these resources: --- -## 🔐 Code of Conduct +## Code of Conduct By participating, you agree to follow our [Code of Conduct](./CODE_OF_CONDUCT.md), which ensures a respectful and welcoming community. --- -## 📬 Contact +## Contact If you have more in-depth questions or want to discuss contributing in other ways, feel free to reach out at: -📧 [jokob@duck.com](mailto:jokob@duck.com?subject=NetAlertX%20Contribution) +[jokob.sk@gmail.com](mailto:jokob.sk@gmail.com?subject=NetAlertX%20Contribution) We appreciate every contribution, big or small! 💙 diff --git a/front/plugins/sync/sync.py b/front/plugins/sync/sync.py index 919008a91..f6b0a3a75 100755 --- a/front/plugins/sync/sync.py +++ b/front/plugins/sync/sync.py @@ -222,27 +222,30 @@ def main(): extra = '', foreignKey = device['devGUID']) + # Resolve the actual columns that exist in the Devices table once. + # This automatically excludes computed/virtual fields (e.g. devStatus, + # devIsSleeping) and 'rowid' without needing a maintained exclusion list. + cursor.execute("PRAGMA table_info(Devices)") + db_columns = {row[1] for row in cursor.fetchall()} + # Filter out existing devices new_devices = [device for device in device_data if device['devMac'] not in existing_mac_addresses] - # Remove 'rowid' key if it exists - for device in new_devices: - device.pop('rowid', None) - device.pop('devStatus', None) - mylog('verbose', [f'[{pluginName}] All devices: "{len(device_data)}"']) mylog('verbose', [f'[{pluginName}] New devices: "{len(new_devices)}"']) # Prepare the insert statement if new_devices: - # creating insert statement, removing 'rowid', 'devStatus' as handled on the target and devStatus is resolved on the fly - columns = ', '.join(k for k in new_devices[0].keys() if k not in ['rowid', 'devStatus']) - placeholders = ', '.join('?' for k in new_devices[0] if k not in ['rowid', 'devStatus']) + # Only keep keys that are real columns in the target DB; computed + # or unknown fields are silently dropped regardless of source schema. + insert_cols = [k for k in new_devices[0].keys() if k in db_columns] + columns = ', '.join(insert_cols) + placeholders = ', '.join('?' for _ in insert_cols) sql = f'INSERT INTO Devices ({columns}) VALUES ({placeholders})' - # Extract values for the new devices - values = [tuple(device.values()) for device in new_devices] + # Extract only the whitelisted column values for each device + values = [tuple(device.get(col) for col in insert_cols) for device in new_devices] mylog('verbose', [f'[{pluginName}] Inserting Devices SQL : "{sql}"']) mylog('verbose', [f'[{pluginName}] Inserting Devices VALUES: "{values}"']) diff --git a/test/db_test_helpers.py b/test/db_test_helpers.py index f49fd4b51..f27ee559e 100644 --- a/test/db_test_helpers.py +++ b/test/db_test_helpers.py @@ -5,7 +5,7 @@ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - from db_test_helpers import make_db, insert_device, minutes_ago, DummyDB, down_event_macs + from db_test_helpers import make_db, insert_device, minutes_ago, DummyDB, down_event_macs, make_device_dict, sync_insert_devices """ import sqlite3 @@ -202,6 +202,125 @@ def insert_device( ) +def make_device_dict(mac: str = "aa:bb:cc:dd:ee:ff", **overrides) -> dict: + """ + Return a fully-populated Devices row dict with safe defaults. + + Mirrors every column in CREATE_DEVICES so callers can be inserted + directly via sync_insert_devices() or similar helpers. Pass keyword + arguments to override any individual field. + + Computed/view-only columns (devStatus, devIsSleeping, devFlapping, + rowid, …) are intentionally absent — tests that need to verify they are + dropped should add them after calling this function. + """ + base = { + "devMac": mac, + "devName": "Test Device", + "devOwner": "", + "devType": "", + "devVendor": "Acme", + "devFavorite": 0, + "devGroup": "", + "devComments": "", + "devFirstConnection": "2024-01-01 00:00:00", + "devLastConnection": "2024-01-02 00:00:00", + "devLastIP": "192.168.1.10", + "devPrimaryIPv4": "192.168.1.10", + "devPrimaryIPv6": "", + "devVlan": "", + "devForceStatus": "", + "devStaticIP": "", + "devScan": 1, + "devLogEvents": 1, + "devAlertEvents": 1, + "devAlertDown": 1, + "devCanSleep": 0, + "devSkipRepeated": 0, + "devLastNotification": "", + "devPresentLastScan": 1, + "devIsNew": 0, + "devLocation": "", + "devIsArchived": 0, + "devParentMAC": "", + "devParentPort": "", + "devIcon": "", + "devGUID": "test-guid-1", + "devSite": "", + "devSSID": "", + "devSyncHubNode": "node1", + "devSourcePlugin": "", + "devCustomProps": "", + "devFQDN": "", + "devParentRelType": "", + "devReqNicsOnline": 0, + "devMacSource": "", + "devNameSource": "", + "devFQDNSource": "", + "devLastIPSource": "", + "devVendorSource": "", + "devSSIDSource": "", + "devParentMACSource": "", + "devParentPortSource": "", + "devParentRelTypeSource": "", + "devVlanSource": "", + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Sync insert helper (shared by test/plugins/test_sync_insert.py and +# test/plugins/test_sync_protocol.py — mirrors sync.py's insert block) +# --------------------------------------------------------------------------- + +def sync_insert_devices( + conn: sqlite3.Connection, + device_data: list, + existing_macs: set | None = None, +) -> int: + """ + Schema-aware device INSERT mirroring sync.py's Mode-3 insert block. + + Parameters + ---------- + conn: + In-memory (or real) SQLite connection with a Devices table. + device_data: + List of device dicts as received from table_devices.json or a node log. + existing_macs: + Set of MAC addresses already present in Devices. Rows whose devMac is + in this set are skipped. Pass ``None`` (default) to insert everything. + + Returns the number of rows actually inserted. + """ + if not device_data: + return 0 + + cursor = conn.cursor() + + candidates = ( + [d for d in device_data if d["devMac"] not in existing_macs] + if existing_macs is not None + else list(device_data) + ) + + if not candidates: + return 0 + + cursor.execute("PRAGMA table_info(Devices)") + db_columns = {row[1] for row in cursor.fetchall()} + + insert_cols = [k for k in candidates[0].keys() if k in db_columns] + columns = ", ".join(insert_cols) + placeholders = ", ".join("?" for _ in insert_cols) + sql = f"INSERT INTO Devices ({columns}) VALUES ({placeholders})" + values = [tuple(d.get(col) for col in insert_cols) for d in candidates] + cursor.executemany(sql, values) + conn.commit() + return len(values) + + # --------------------------------------------------------------------------- # Assertion helpers # --------------------------------------------------------------------------- diff --git a/test/plugins/__init__.py b/test/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/plugins/test_sync_insert.py b/test/plugins/test_sync_insert.py new file mode 100644 index 000000000..93b1dc76f --- /dev/null +++ b/test/plugins/test_sync_insert.py @@ -0,0 +1,130 @@ +""" +Tests for the SYNC plugin's schema-aware device insert logic. + +The core invariant: only columns that actually exist in the Devices table +are included in the INSERT statement. Computed/virtual fields (devStatus, +devIsSleeping, devFlapping) and unknown future columns must be silently +dropped — never cause an OperationalError. +""" + +import sys +import os + +import pytest + +# Ensure shared helpers and server code are importable. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server")) + +from db_test_helpers import make_db, make_device_dict, sync_insert_devices # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def conn(): + """Fresh in-memory DB with the Devices table and all views.""" + return make_db() + + +class TestSyncInsertSchemaAware: + + def test_clean_device_inserts_successfully(self, conn): + """Happy path: a well-formed device dict inserts without error.""" + device = make_device_dict() + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + cur = conn.cursor() + cur.execute("SELECT devMac FROM Devices WHERE devMac = ?", (device["devMac"],)) + row = cur.fetchone() + assert row is not None + + def test_computed_devStatus_is_silently_dropped(self, conn): + """devStatus is a computed view column — must NOT raise OperationalError.""" + device = make_device_dict() + device["devStatus"] = "Online" # computed in DevicesView, not in Devices table + + # Pre-fix this would raise: sqlite3.OperationalError: table Devices has no column named devStatus + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + def test_computed_devIsSleeping_is_silently_dropped(self, conn): + """devIsSleeping is a CTE/view column — must NOT raise OperationalError.""" + device = make_device_dict() + device["devIsSleeping"] = 0 # the exact field that triggered the original bug report + + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + def test_computed_devFlapping_is_silently_dropped(self, conn): + """devFlapping is also computed in the view.""" + device = make_device_dict() + device["devFlapping"] = 0 + + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + def test_rowid_is_silently_dropped(self, conn): + """rowid must never appear in an INSERT column list.""" + device = make_device_dict() + device["rowid"] = 42 + + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + def test_all_computed_fields_at_once(self, conn): + """All known computed/virtual columns together — none should abort the insert.""" + device = make_device_dict() + device["rowid"] = 99 + device["devStatus"] = "Online" + device["devIsSleeping"] = 0 + device["devFlapping"] = 0 + device["totally_unknown_future_column"] = "ignored" + + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + def test_batch_insert_multiple_devices(self, conn): + """Multiple devices with computed fields all insert correctly.""" + devices = [] + for i in range(3): + d = make_device_dict(mac=f"aa:bb:cc:dd:ee:{i:02x}") + d["devGUID"] = f"guid-{i}" + d["devStatus"] = "Online" # computed + d["devIsSleeping"] = 0 # computed + devices.append(d) + + inserted = sync_insert_devices(conn, devices) + assert inserted == len(devices) + + def test_values_aligned_with_columns_after_filtering(self, conn): + """Values must be extracted in the same order as insert_cols (alignment bug guard).""" + device = make_device_dict() + device["devStatus"] = "SHOULD_BE_DROPPED" + device["devIsSleeping"] = 999 + + sync_insert_devices(conn, [device]) + + cur = conn.cursor() + cur.execute("SELECT devName, devVendor, devLastIP FROM Devices WHERE devMac = ?", (device["devMac"],)) + row = cur.fetchone() + assert row["devName"] == "Test Device" + assert row["devVendor"] == "Acme" + assert row["devLastIP"] == "192.168.1.10" + + def test_unknown_column_does_not_prevent_insert(self, conn): + """A column that was added on the node but doesn't exist on the hub is dropped.""" + device = make_device_dict() + device["devNewFeatureOnlyOnNode"] = "some_value" + + # Must not raise — hub schema wins + inserted = sync_insert_devices(conn, [device]) + assert inserted == 1 + + def test_empty_device_list_returns_zero(self, conn): + """Edge case: empty list should not raise and should return 0.""" + inserted = sync_insert_devices(conn, []) + assert inserted == 0 diff --git a/test/plugins/test_sync_protocol.py b/test/plugins/test_sync_protocol.py new file mode 100644 index 000000000..75a283ad8 --- /dev/null +++ b/test/plugins/test_sync_protocol.py @@ -0,0 +1,410 @@ +""" +Tests for SYNC plugin push/pull/receive behaviour. + +Three modes exercised: + Mode 1 – PUSH (NODE): send_data() POSTs encrypted device data to the hub. + Mode 2 – PULL (HUB): get_data() GETs a base64 JSON blob from each node. + Mode 3 – RECEIVE: hub parses decoded log files and upserts devices into DB. + +sync.py is intentionally NOT imported here — its module-level code has side +effects (reads live config, initialises logging). Instead, the pure logic +under test is extracted into thin local mirrors that match the production +implementation exactly, so any divergence will surface as a test failure. +""" + +import base64 +import json +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest +import requests + +# Make shared helpers + server packages importable from test/plugins/ +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server")) + +from db_test_helpers import make_db, make_device_dict, sync_insert_devices # noqa: E402 +from utils.crypto_utils import encrypt_data, decrypt_data # noqa: E402 + +# --------------------------------------------------------------------------- +# Local mirrors of sync.py logic (no module-level side-effects on import) +# --------------------------------------------------------------------------- + +API_ENDPOINT = "/sync" + + +def _send_data(api_token, file_content, encryption_key, file_path, node_name, pref, hub_url): + """Mirror of sync.send_data() — returns True on HTTP 200, False otherwise.""" + encrypted_data = encrypt_data(file_content, encryption_key) + data = { + "data": encrypted_data, + "file_path": file_path, + "plugin": pref, + "node_name": node_name, + } + headers = {"Authorization": f"Bearer {api_token}"} + try: + response = requests.post(hub_url + API_ENDPOINT, data=data, headers=headers, timeout=5) + return response.status_code == 200 + except requests.RequestException: + return False + + +def _get_data(api_token, node_url): + """Mirror of sync.get_data() — returns parsed JSON dict or '' on any failure.""" + headers = {"Authorization": f"Bearer {api_token}"} + try: + response = requests.get(node_url + API_ENDPOINT, headers=headers, timeout=5) + if response.status_code == 200: + return response.json() + except requests.RequestException: + pass + return "" + + +def _node_name_from_filename(file_name: str) -> str: + """Mirror of the node-name extraction in sync.main().""" + parts = file_name.split(".") + return parts[2] if ("decoded" in file_name or "encoded" in file_name) else parts[1] + + +def _determine_mode(hub_url: str, send_devices: bool, plugins_to_sync: list, pull_nodes: list): + """Mirror of the is_hub / is_node detection block in sync.main().""" + is_node = len(hub_url) > 0 and (send_devices or bool(plugins_to_sync)) + is_hub = len(pull_nodes) > 0 + return is_hub, is_node + + +def _currentscan_candidates(device_data: list[dict]) -> list[dict]: + """ + Mirror of the plugin_objects.add_object() filter in sync.main(). + + Only online (devPresentLastScan=1) and non-internet devices are eligible + to be written to the CurrentScan / plugin result file. + """ + return [ + d for d in device_data + if d.get("devPresentLastScan") == 1 and str(d.get("devMac", "")).lower() != "internet" + ] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +ENCRYPTION_KEY = "test-secret-key" +API_TOKEN = "tok_abc123" +HUB_URL = "http://hub.local:20211" +NODE_URL = "http://node.local:20211" + + +@pytest.fixture +def conn(): + """Fresh in-memory DB with Devices table and all views.""" + return make_db() + + +# =========================================================================== +# Mode detection +# =========================================================================== + +class TestModeDetection: + + def test_is_node_when_hub_url_and_send_devices(self): + is_hub, is_node = _determine_mode(HUB_URL, send_devices=True, plugins_to_sync=[], pull_nodes=[]) + assert is_node is True + assert is_hub is False + + def test_is_node_when_hub_url_and_plugins_set(self): + is_hub, is_node = _determine_mode(HUB_URL, send_devices=False, plugins_to_sync=["NMAP"], pull_nodes=[]) + assert is_node is True + assert is_hub is False + + def test_is_hub_when_pull_nodes_set(self): + is_hub, is_node = _determine_mode("", send_devices=False, plugins_to_sync=[], pull_nodes=[NODE_URL]) + assert is_hub is True + assert is_node is False + + def test_is_both_hub_and_node(self): + is_hub, is_node = _determine_mode(HUB_URL, send_devices=True, plugins_to_sync=[], pull_nodes=[NODE_URL]) + assert is_hub is True + assert is_node is True + + def test_neither_when_no_config(self): + is_hub, is_node = _determine_mode("", send_devices=False, plugins_to_sync=[], pull_nodes=[]) + assert is_hub is False + assert is_node is False + + def test_no_hub_url_means_not_node_even_with_send_devices(self): + is_hub, is_node = _determine_mode("", send_devices=True, plugins_to_sync=[], pull_nodes=[]) + assert is_node is False + + +# =========================================================================== +# send_data (Mode 1 – PUSH) +# =========================================================================== + +class TestSendData: + + def _mock_post(self, status_code=200): + resp = MagicMock() + resp.status_code = status_code + return patch("requests.post", return_value=resp) + + def test_returns_true_on_http_200(self): + with self._mock_post(200): + result = _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + assert result is True + + def test_returns_false_on_non_200(self): + for code in (400, 401, 403, 500, 503): + with self._mock_post(code): + result = _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + assert result is False, f"Expected False for HTTP {code}" + + def test_returns_false_on_connection_error(self): + with patch("requests.post", side_effect=requests.ConnectionError("refused")): + result = _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + assert result is False + + def test_returns_false_on_timeout(self): + with patch("requests.post", side_effect=requests.Timeout("timed out")): + result = _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + assert result is False + + def test_posts_to_correct_endpoint(self): + resp = MagicMock() + resp.status_code = 200 + with patch("requests.post", return_value=resp) as mock_post: + _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + url_called = mock_post.call_args[0][0] + assert url_called == HUB_URL + "/sync" + + def test_bearer_auth_header_sent(self): + resp = MagicMock() + resp.status_code = 200 + with patch("requests.post", return_value=resp) as mock_post: + _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + headers = mock_post.call_args[1]["headers"] + assert headers["Authorization"] == f"Bearer {API_TOKEN}" + + def test_payload_contains_expected_fields(self): + resp = MagicMock() + resp.status_code = 200 + with patch("requests.post", return_value=resp) as mock_post: + _send_data(API_TOKEN, '{"data":[]}', ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + payload = mock_post.call_args[1]["data"] + assert "data" in payload # encrypted blob + assert payload["file_path"] == "/tmp/file.log" + assert payload["plugin"] == "SYNC" + assert payload["node_name"] == "node1" + + def test_payload_data_is_encrypted_not_plaintext(self): + """The 'data' field in the POST must be encrypted, not the raw content.""" + plaintext = '{"secret": "do_not_expose"}' + resp = MagicMock() + resp.status_code = 200 + with patch("requests.post", return_value=resp) as mock_post: + _send_data(API_TOKEN, plaintext, ENCRYPTION_KEY, + "/tmp/file.log", "node1", "SYNC", HUB_URL) + transmitted = mock_post.call_args[1]["data"]["data"] + assert transmitted != plaintext + # Verify it round-trips correctly + assert decrypt_data(transmitted, ENCRYPTION_KEY) == plaintext + + +# =========================================================================== +# get_data (Mode 2 – PULL) +# =========================================================================== + +class TestGetData: + + def _mock_get(self, status_code=200, json_body=None, side_effect=None): + resp = MagicMock() + resp.status_code = status_code + if json_body is not None: + resp.json.return_value = json_body + if side_effect is not None: + return patch("requests.get", side_effect=side_effect) + return patch("requests.get", return_value=resp) + + def test_returns_parsed_json_on_200(self): + body = {"node_name": "node1", "data_base64": base64.b64encode(b"hello").decode()} + with self._mock_get(200, json_body=body): + result = _get_data(API_TOKEN, NODE_URL) + assert result == body + + def test_gets_from_correct_endpoint(self): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} + with patch("requests.get", return_value=resp) as mock_get: + _get_data(API_TOKEN, NODE_URL) + url_called = mock_get.call_args[0][0] + assert url_called == NODE_URL + "/sync" + + def test_bearer_auth_header_sent(self): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} + with patch("requests.get", return_value=resp) as mock_get: + _get_data(API_TOKEN, NODE_URL) + headers = mock_get.call_args[1]["headers"] + assert headers["Authorization"] == f"Bearer {API_TOKEN}" + + def test_returns_empty_string_on_json_decode_error(self): + resp = MagicMock() + resp.status_code = 200 + resp.json.side_effect = json.JSONDecodeError("bad json", "", 0) + with patch("requests.get", return_value=resp): + result = _get_data(API_TOKEN, NODE_URL) + assert result == "" + + def test_returns_empty_string_on_connection_error(self): + with patch("requests.get", side_effect=requests.ConnectionError("refused")): + result = _get_data(API_TOKEN, NODE_URL) + assert result == "" + + def test_returns_empty_string_on_timeout(self): + with patch("requests.get", side_effect=requests.Timeout("timed out")): + result = _get_data(API_TOKEN, NODE_URL) + assert result == "" + + def test_returns_empty_string_on_non_200(self): + resp = MagicMock() + resp.status_code = 401 + with patch("requests.get", return_value=resp): + result = _get_data(API_TOKEN, NODE_URL) + assert result == "" + + +# =========================================================================== +# Node name extraction from filename (Mode 3 – RECEIVE) +# =========================================================================== + +class TestNodeNameExtraction: + + def test_simple_filename(self): + # last_result.MyNode.log → "MyNode" + assert _node_name_from_filename("last_result.MyNode.log") == "MyNode" + + def test_decoded_filename(self): + # last_result.decoded.MyNode.1.log → "MyNode" + assert _node_name_from_filename("last_result.decoded.MyNode.1.log") == "MyNode" + + def test_encoded_filename(self): + # last_result.encoded.MyNode.1.log → "MyNode" + assert _node_name_from_filename("last_result.encoded.MyNode.1.log") == "MyNode" + + def test_node_name_with_underscores(self): + assert _node_name_from_filename("last_result.Wladek_Site.log") == "Wladek_Site" + + def test_decoded_node_name_with_underscores(self): + assert _node_name_from_filename("last_result.decoded.Wladek_Site.1.log") == "Wladek_Site" + + +# =========================================================================== +# CurrentScan candidates filter (Mode 3 – RECEIVE) +# =========================================================================== + +class TestCurrentScanCandidates: + + def test_online_device_is_included(self): + d = make_device_dict(devPresentLastScan=1) + assert len(_currentscan_candidates([d])) == 1 + + def test_offline_device_is_excluded(self): + d = make_device_dict(devPresentLastScan=0) + assert len(_currentscan_candidates([d])) == 0 + + def test_internet_mac_is_excluded(self): + d = make_device_dict(mac="internet", devPresentLastScan=1) + assert len(_currentscan_candidates([d])) == 0 + + def test_internet_mac_case_insensitive(self): + for mac in ("INTERNET", "Internet", "iNtErNeT"): + d = make_device_dict(mac=mac, devPresentLastScan=1) + assert len(_currentscan_candidates([d])) == 0, f"mac={mac!r} should be excluded" + + def test_mixed_batch(self): + devices = [ + make_device_dict(mac="aa:bb:cc:dd:ee:01", devPresentLastScan=1), # included + make_device_dict(mac="aa:bb:cc:dd:ee:02", devPresentLastScan=0), # offline + make_device_dict(mac="internet", devPresentLastScan=1), # root node + make_device_dict(mac="aa:bb:cc:dd:ee:03", devPresentLastScan=1), # included + ] + result = _currentscan_candidates(devices) + macs = [d["devMac"] for d in result] + assert "aa:bb:cc:dd:ee:01" in macs + assert "aa:bb:cc:dd:ee:03" in macs + assert "aa:bb:cc:dd:ee:02" not in macs + assert "internet" not in macs + + +# =========================================================================== +# DB insert filtering – new vs existing devices (Mode 3 – RECEIVE) +# =========================================================================== + +class TestReceiveInsert: + + def test_new_device_is_inserted(self, conn): + device = make_device_dict(mac="aa:bb:cc:dd:ee:01") + inserted = sync_insert_devices(conn, [device], existing_macs=set()) + assert inserted == 1 + cur = conn.cursor() + cur.execute("SELECT devMac FROM Devices WHERE devMac = ?", ("aa:bb:cc:dd:ee:01",)) + assert cur.fetchone() is not None + + def test_existing_device_is_not_reinserted(self, conn): + # Pre-populate Devices + cur = conn.cursor() + cur.execute( + "INSERT INTO Devices (devMac, devName) VALUES (?, ?)", + ("aa:bb:cc:dd:ee:01", "Existing"), + ) + conn.commit() + + device = make_device_dict(mac="aa:bb:cc:dd:ee:01") + inserted = sync_insert_devices(conn, [device], existing_macs={"aa:bb:cc:dd:ee:01"}) + assert inserted == 0 + + def test_only_new_devices_inserted_in_mixed_batch(self, conn): + cur = conn.cursor() + cur.execute( + "INSERT INTO Devices (devMac, devName) VALUES (?, ?)", + ("aa:bb:cc:dd:ee:existing", "Existing"), + ) + conn.commit() + + devices = [ + make_device_dict(mac="aa:bb:cc:dd:ee:existing"), + make_device_dict(mac="aa:bb:cc:dd:ee:new1"), + make_device_dict(mac="aa:bb:cc:dd:ee:new2"), + ] + inserted = sync_insert_devices( + conn, devices, existing_macs={"aa:bb:cc:dd:ee:existing"} + ) + assert inserted == 2 + + def test_computed_fields_in_payload_do_not_abort_insert(self, conn): + """Regression: devIsSleeping / devStatus / devFlapping must be silently dropped.""" + device = make_device_dict(mac="aa:bb:cc:dd:ee:01") + device["devIsSleeping"] = 0 + device["devStatus"] = "Online" + device["devFlapping"] = 0 + device["rowid"] = 99 + # Must not raise OperationalError + inserted = sync_insert_devices(conn, [device], existing_macs=set()) + assert inserted == 1 + + def test_empty_device_list_returns_zero(self, conn): + assert sync_insert_devices(conn, [], existing_macs=set()) == 0 From 0b0c88f712e66b69a3040061850374f66ad5921b Mon Sep 17 00:00:00 2001 From: Safeguard Date: Sat, 7 Mar 2026 11:29:53 +0100 Subject: [PATCH 109/122] Translated using Weblate (Russian) Currently translated at 100.0% (805 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ru/ --- front/php/templates/language/ru_ru.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 246f8748a..020eceb4b 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -203,7 +203,7 @@ "Device_MultiEdit_MassActions": "Массовые действия:", "Device_MultiEdit_No_Devices": "Устройства не выбраны.", "Device_MultiEdit_Tooltip": "Осторожно. При нажатии на эту кнопку значение слева будет применено ко всем устройствам, выбранным выше.", - "Device_NextScan_Imminent": "Предстоящий...", + "Device_NextScan_Imminent": "Скоро...", "Device_NextScan_In": "Следующее сканирование примерно через· ", "Device_NoData_Help": "Если устройства не отображаются после сканирования, проверьте настройку SCAN_SUBNETS и документацию.", "Device_NoData_Scanning": "Ожидание первого сканирования — это может занять несколько минут после первоначальной настройки.", @@ -231,7 +231,7 @@ "Device_TableHead_FQDN": "FQDN", "Device_TableHead_Favorite": "Избранное", "Device_TableHead_FirstSession": "Первый сеанс", - "Device_TableHead_Flapping": "", + "Device_TableHead_Flapping": "Нестабильный", "Device_TableHead_GUID": "GUID", "Device_TableHead_Group": "Группа", "Device_TableHead_IPv4": "IPv4", @@ -336,7 +336,7 @@ "Gen_Down": "Лежит", "Gen_Error": "Ошибка", "Gen_Filter": "Фильтр", - "Gen_Flapping": "", + "Gen_Flapping": "Нестабильный", "Gen_Generate": "Генерировать", "Gen_InvalidMac": "Неверный Mac-адрес.", "Gen_Invalid_Value": "Введено некорректное значение", From 165c9d3baa98a50e6c21e1620c2c483b8d5f1950 Mon Sep 17 00:00:00 2001 From: Sylvain Pichon Date: Sat, 7 Mar 2026 12:41:20 +0100 Subject: [PATCH 110/122] Translated using Weblate (French) Currently translated at 99.2% (799 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/fr/ --- front/php/templates/language/fr_fr.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 4fa8ee6ef..59285211b 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -139,7 +139,7 @@ "DevDetail_SessionTable_Duration": "Durée", "DevDetail_SessionTable_IP": "IP", "DevDetail_SessionTable_Order": "Ordre", - "DevDetail_Shortcut_CurrentStatus": "État actuel", + "DevDetail_Shortcut_CurrentStatus": "État", "DevDetail_Shortcut_DownAlerts": "Alertes de panne", "DevDetail_Shortcut_Presence": "Présence", "DevDetail_Shortcut_Sessions": "Sessions", @@ -203,16 +203,16 @@ "Device_MultiEdit_MassActions": "Actions en masse :", "Device_MultiEdit_No_Devices": "Aucun appareil sélectionné.", "Device_MultiEdit_Tooltip": "Attention. Ceci va appliquer la valeur de gauche à tous les appareils sélectionnés au-dessus.", - "Device_NextScan_Imminent": "", - "Device_NextScan_In": "", - "Device_NoData_Help": "", - "Device_NoData_Scanning": "", - "Device_NoData_Title": "", + "Device_NextScan_Imminent": "Imminent...", + "Device_NextScan_In": "Prochain scan dans ", + "Device_NoData_Help": "Si les appareils n'apparaissent pas après le scan, vérifiez vos paramètres SCAN_SUBNETS et la documentation.", + "Device_NoData_Scanning": "En attente du premier scan - cela peut prendre quelques minutes après le premier paramétrage.", + "Device_NoData_Title": "Aucun appareil trouvé pour le moment", "Device_Save_Failed": "Erreur à l'enregistrement de l'appareil", "Device_Save_Unauthorized": "Non autorisé - Jeton d'API invalide", "Device_Saved_Success": "Appareil enregistré avec succès", "Device_Saved_Unexpected": "La mise à jour de l'appareil a renvoyé une réponse inattendue", - "Device_Scanning": "", + "Device_Scanning": "Scan en cours...", "Device_Searchbox": "Rechercher", "Device_Shortcut_AllDevices": "Mes appareils", "Device_Shortcut_AllNodes": "Tous les nœuds", @@ -322,7 +322,7 @@ "Gen_AddDevice": "Ajouter un appareil", "Gen_Add_All": "Ajouter tous", "Gen_All_Devices": "Tous les appareils", - "Gen_Archived": "", + "Gen_Archived": "Archivés", "Gen_AreYouSure": "Êtes-vous sûr ?", "Gen_Backup": "Lancer la sauvegarde", "Gen_Cancel": "Annuler", @@ -333,7 +333,7 @@ "Gen_Delete": "Supprimer", "Gen_DeleteAll": "Supprimer tous", "Gen_Description": "Description", - "Gen_Down": "", + "Gen_Down": "Bas", "Gen_Error": "Erreur", "Gen_Filter": "Filtrer", "Gen_Flapping": "", @@ -342,7 +342,7 @@ "Gen_Invalid_Value": "Une valeur invalide a été renseignée", "Gen_LockedDB": "Erreur - La base de données est peut-être verrouillée - Vérifier avec les outils de dév via F12 -> Console ou essayer plus tard.", "Gen_NetworkMask": "Masque réseau", - "Gen_New": "", + "Gen_New": "Nouveau", "Gen_Offline": "Hors ligne", "Gen_Okay": "OK", "Gen_Online": "En ligne", @@ -360,7 +360,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Sélectionnez pour prévisualiser", "Gen_Selected_Devices": "Appareils sélectionnés :", - "Gen_Sleeping": "", + "Gen_Sleeping": "Inactif", "Gen_Subnet": "Sous-réseau", "Gen_Switch": "Basculer", "Gen_Upd": "Mise à jour réussie", @@ -804,4 +804,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} \ No newline at end of file +} From aba1ddd3df4d03ad2e29a23882f018be5f47089f Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sun, 8 Mar 2026 07:57:52 +0000 Subject: [PATCH 111/122] Handle JSON decoding errors in _get_data function --- test/plugins/test_sync_protocol.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_sync_protocol.py b/test/plugins/test_sync_protocol.py index 75a283ad8..9d46cf26c 100644 --- a/test/plugins/test_sync_protocol.py +++ b/test/plugins/test_sync_protocol.py @@ -58,7 +58,10 @@ def _get_data(api_token, node_url): try: response = requests.get(node_url + API_ENDPOINT, headers=headers, timeout=5) if response.status_code == 200: - return response.json() + try: + return response.json() + except json.JSONDecodeError: + pass except requests.RequestException: pass return "" From e1d206ca7492ee0438aa714ef07ac5dbdd1bb237 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 9 Mar 2026 20:59:51 +1100 Subject: [PATCH 112/122] BE: new_online defined incorrectly Signed-off-by: jokob-sk --- server/db/db_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/db/db_helper.py b/server/db/db_helper.py index 25ded026e..a962ef2d3 100755 --- a/server/db/db_helper.py +++ b/server/db/db_helper.py @@ -33,7 +33,7 @@ def get_device_conditions(): "unknown": f"WHERE {base_active} AND devName IN ({NULL_EQUIVALENTS_SQL})", "known": f"WHERE {base_active} AND devName NOT IN ({NULL_EQUIVALENTS_SQL})", "favorites_offline": f"WHERE {base_active} AND devFavorite=1 AND devPresentLastScan=0", - "new_online": f"WHERE {base_active} AND devIsNew=1 AND devPresentLastScan=0", + "new_online": f"WHERE {base_active} AND devIsNew=1 AND devPresentLastScan=1", "unstable_devices": f"WHERE {base_active} AND devFlapping=1", "unstable_favorites": f"WHERE {base_active} AND devFavorite=1 AND devFlapping=1", "unstable_network_devices": f"WHERE {base_active} AND devType IN ({network_dev_types}) AND devFlapping=1", From 93fc126da21c86aa27c7ff044bb9098d759d6d02 Mon Sep 17 00:00:00 2001 From: Meatloaf-bot Date: Mon, 9 Mar 2026 19:27:40 -0400 Subject: [PATCH 113/122] docs: clarify ARP flux sysctl limitations with host networking --- docs/DOCKER_COMPOSE.md | 8 +++++++ .../arp-flux-sysctls.md | 22 ++++++++++++++++++- .../entrypoint.d/37-host-optimization.sh | 3 +++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/DOCKER_COMPOSE.md b/docs/DOCKER_COMPOSE.md index e76f08e14..da5254233 100755 --- a/docs/DOCKER_COMPOSE.md +++ b/docs/DOCKER_COMPOSE.md @@ -30,6 +30,14 @@ services: - CHOWN # Required for root-entrypoint to chown /data + /tmp before dropping privileges - SETUID # Required for root-entrypoint to switch to non-root user - SETGID # Required for root-entrypoint to switch to non-root group + # --- ARP FLUX MITIGATION --- + # Note: If running in `network_mode: host`, modern Docker/runc will correctly + # block sysctl overrides via the container configuration to prevent + # unauthorized changes to the host's global kernel settings. + # + # If using host networking, REMOVE the sysctls block below and apply + # settings directly on your Host OS instead (sudo sysctl -w ...). + # --------------------------- sysctls: # ARP flux mitigation (reduces duplicate/ambiguous ARP behavior on host networking) net.ipv4.conf.all.arp_ignore: 1 net.ipv4.conf.all.arp_announce: 2 diff --git a/docs/docker-troubleshooting/arp-flux-sysctls.md b/docs/docker-troubleshooting/arp-flux-sysctls.md index 1070659a7..5e86583cd 100644 --- a/docs/docker-troubleshooting/arp-flux-sysctls.md +++ b/docs/docker-troubleshooting/arp-flux-sysctls.md @@ -21,7 +21,9 @@ The running environment does not provide the expected kernel sysctl values. This ## How to Correct the Issue -Set these sysctls at container runtime. +### Option A: Via Docker (Standard Bridge Networking) + +If you are using standard bridged networking (default), set these sysctls at container runtime. - In `docker-compose.yml` (preferred): ```yaml @@ -44,6 +46,24 @@ Set these sysctls at container runtime. > - Use `--privileged` with `docker run`. > - Use the more restrictive `--cap-add=NET_ADMIN` (or `cap_add: [NET_ADMIN]` in `docker-compose` service definitions) to allow the sysctls to be applied at runtime. +### Option B: Via Host OS (Required for `network_mode: host`) + +If you are running the container with `network_mode: host`, modern Docker versions (specifically the `runc` runtime) **will not allow** you to set `net.*` sysctls via the container configuration. Attempting to do so will result in an OCI runtime error: `sysctl "net.ipv4.conf.all.arp_announce" not allowed in host network namespace`. + +In this scenario, you must apply the settings directly on your host operating system: + +1. **Remove** the `sysctls` section from your `docker-compose.yml`. +2. **Apply** on the host immediately: + ```bash + sudo sysctl -w net.ipv4.conf.all.arp_ignore=1 + sudo sysctl -w net.ipv4.conf.all.arp_announce=2 + ``` +3. **Make persistent** by adding the following lines to `/etc/sysctl.conf` on the host: + ```text + net.ipv4.conf.all.arp_ignore=1 + net.ipv4.conf.all.arp_announce=2 + ``` + ## Additional Resources For broader Docker Compose guidance, see: diff --git a/install/production-filesystem/entrypoint.d/37-host-optimization.sh b/install/production-filesystem/entrypoint.d/37-host-optimization.sh index 4dc5d0196..2fea0ab96 100755 --- a/install/production-filesystem/entrypoint.d/37-host-optimization.sh +++ b/install/production-filesystem/entrypoint.d/37-host-optimization.sh @@ -22,6 +22,9 @@ if [ "$failed" -eq 1 ]; then net.ipv4.conf.all.arp_ignore=1 net.ipv4.conf.all.arp_announce=2 + Note: If using 'network_mode: host', you cannot set these via docker-compose + sysctls. You must configure them directly on your host operating system instead. + Detection accuracy may be reduced until configured. See: https://docs.netalertx.com/docker-troubleshooting/arp-flux-sysctls/ From df3ca50c5c7ba5564507bf03eceb15558090e220 Mon Sep 17 00:00:00 2001 From: Meatloaf Bot Date: Tue, 10 Mar 2026 12:04:30 -0400 Subject: [PATCH 114/122] Address CodeRabbit review: Clarify sysctl behavior in host network mode --- docs/DOCKER_COMPOSE.md | 10 +++++----- docs/docker-troubleshooting/arp-flux-sysctls.md | 8 ++++---- .../entrypoint.d/37-host-optimization.sh | 6 ++++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/DOCKER_COMPOSE.md b/docs/DOCKER_COMPOSE.md index da5254233..92c93cc36 100755 --- a/docs/DOCKER_COMPOSE.md +++ b/docs/DOCKER_COMPOSE.md @@ -31,12 +31,12 @@ services: - SETUID # Required for root-entrypoint to switch to non-root user - SETGID # Required for root-entrypoint to switch to non-root group # --- ARP FLUX MITIGATION --- - # Note: If running in `network_mode: host`, modern Docker/runc will correctly - # block sysctl overrides via the container configuration to prevent - # unauthorized changes to the host's global kernel settings. + # Note: When using `network_mode: host`, these sysctls require the + # NET_ADMIN capability to be applied to the host namespace. # - # If using host networking, REMOVE the sysctls block below and apply - # settings directly on your Host OS instead (sudo sysctl -w ...). + # If your environment restricts capabilities, or you prefer to configure + # them on the Host OS, REMOVE the sysctls block below and apply via: + # sudo sysctl -w net.ipv4.conf.all.arp_ignore=1 net.ipv4.conf.all.arp_announce=2 # --------------------------- sysctls: # ARP flux mitigation (reduces duplicate/ambiguous ARP behavior on host networking) net.ipv4.conf.all.arp_ignore: 1 diff --git a/docs/docker-troubleshooting/arp-flux-sysctls.md b/docs/docker-troubleshooting/arp-flux-sysctls.md index 5e86583cd..786a2f464 100644 --- a/docs/docker-troubleshooting/arp-flux-sysctls.md +++ b/docs/docker-troubleshooting/arp-flux-sysctls.md @@ -21,9 +21,9 @@ The running environment does not provide the expected kernel sysctl values. This ## How to Correct the Issue -### Option A: Via Docker (Standard Bridge Networking) +### Option A: Via Docker (Standard Bridge Networking or `network_mode: host` with `NET_ADMIN`) -If you are using standard bridged networking (default), set these sysctls at container runtime. +If you are using standard bridged networking, or `network_mode: host` and the container is granted the `NET_ADMIN` capability (as is the default recommendation), set these sysctls at container runtime. - In `docker-compose.yml` (preferred): ```yaml @@ -46,9 +46,9 @@ If you are using standard bridged networking (default), set these sysctls at con > - Use `--privileged` with `docker run`. > - Use the more restrictive `--cap-add=NET_ADMIN` (or `cap_add: [NET_ADMIN]` in `docker-compose` service definitions) to allow the sysctls to be applied at runtime. -### Option B: Via Host OS (Required for `network_mode: host`) +### Option B: Via Host OS (Fallback for `network_mode: host`) -If you are running the container with `network_mode: host`, modern Docker versions (specifically the `runc` runtime) **will not allow** you to set `net.*` sysctls via the container configuration. Attempting to do so will result in an OCI runtime error: `sysctl "net.ipv4.conf.all.arp_announce" not allowed in host network namespace`. +If you are running the container with `network_mode: host` and cannot grant the `NET_ADMIN` capability, or if your container runtime environment explicitly blocks sysctl overrides, applying these settings via the container configuration will fail. Attempting to do so without sufficient privileges typically results in an OCI runtime error: `sysctl "net.ipv4.conf.all.arp_announce" not allowed in host network namespace`. In this scenario, you must apply the settings directly on your host operating system: diff --git a/install/production-filesystem/entrypoint.d/37-host-optimization.sh b/install/production-filesystem/entrypoint.d/37-host-optimization.sh index 2fea0ab96..d282ffbfa 100755 --- a/install/production-filesystem/entrypoint.d/37-host-optimization.sh +++ b/install/production-filesystem/entrypoint.d/37-host-optimization.sh @@ -22,8 +22,10 @@ if [ "$failed" -eq 1 ]; then net.ipv4.conf.all.arp_ignore=1 net.ipv4.conf.all.arp_announce=2 - Note: If using 'network_mode: host', you cannot set these via docker-compose - sysctls. You must configure them directly on your host operating system instead. + Note: If using 'network_mode: host', setting these via docker-compose sysctls + requires the NET_ADMIN capability. When granted, these sysctls will + modify the host namespace. Otherwise, you must configure them directly + on your host operating system instead. Detection accuracy may be reduced until configured. From 518608cffc21940812388225ab5d731ceb199f79 Mon Sep 17 00:00:00 2001 From: Sylvain Pichon Date: Tue, 10 Mar 2026 18:31:00 +0100 Subject: [PATCH 115/122] Translated using Weblate (French) Currently translated at 99.5% (801 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/fr/ --- front/php/templates/language/fr_fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 59285211b..4765004f1 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -333,7 +333,7 @@ "Gen_Delete": "Supprimer", "Gen_DeleteAll": "Supprimer tous", "Gen_Description": "Description", - "Gen_Down": "Bas", + "Gen_Down": "En panne", "Gen_Error": "Erreur", "Gen_Filter": "Filtrer", "Gen_Flapping": "", @@ -360,7 +360,7 @@ "Gen_SelectIcon": "", "Gen_SelectToPreview": "Sélectionnez pour prévisualiser", "Gen_Selected_Devices": "Appareils sélectionnés :", - "Gen_Sleeping": "Inactif", + "Gen_Sleeping": "En sommeil", "Gen_Subnet": "Sous-réseau", "Gen_Switch": "Basculer", "Gen_Upd": "Mise à jour réussie", From 13515603e441419bc731aa2d6d5f2fe723c69d67 Mon Sep 17 00:00:00 2001 From: mid Date: Tue, 10 Mar 2026 11:15:29 +0100 Subject: [PATCH 116/122] Translated using Weblate (Japanese) Currently translated at 100.0% (805 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/ja/ --- front/php/templates/language/ja_jp.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 1f3e3bd06..7ef5302a3 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -203,8 +203,8 @@ "Device_MultiEdit_MassActions": "大量のアクション:", "Device_MultiEdit_No_Devices": "デバイスが選択されていません。", "Device_MultiEdit_Tooltip": "注意。これをクリックすると、左側の値が上記で選択したすべてのデバイスに適用されます。", - "Device_NextScan_Imminent": "まもなく", - "Device_NextScan_In": "次のスキャン ", + "Device_NextScan_Imminent": "まもなく...", + "Device_NextScan_In": "次のスキャンまでおよそ ", "Device_NoData_Help": "スキャン後にデバイスが表示されない場合は、SCAN_SUBNETS設定とドキュメントを確認してください。", "Device_NoData_Scanning": "最初のスキャンを待機中 - 初期設定後、数分かかる場合があります。", "Device_NoData_Title": "デバイスが見つかりません", @@ -804,4 +804,4 @@ "settings_system_label": "システム", "settings_update_item_warning": "以下の値を更新してください。以前のフォーマットに従うよう注意してください。検証は行われません。", "test_event_tooltip": "設定をテストする前に、まず変更を保存してください。" -} \ No newline at end of file +} From b5b0bcc766b570071aa4a9df4ee418cd42749a6a Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:52:22 +0000 Subject: [PATCH 117/122] work on stale cache #1554 --- front/deviceDetails.php | 4 ++-- front/js/cache.js | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/front/deviceDetails.php b/front/deviceDetails.php index ec597bb9f..6c0a1180b 100755 --- a/front/deviceDetails.php +++ b/front/deviceDetails.php @@ -299,7 +299,7 @@ function updateChevrons(currentMac) { showSpinner(); - cacheDevices().then(() => { + cacheDevices(true).then(() => { hideSpinner(); // Retry after re-caching @@ -507,7 +507,7 @@ function updateDevicePageName(mac) { if (mac != 'new' && (name === null|| owner === null)) { console.warn("Device not found in cache, retrying after re-cache:", mac); showSpinner(); - cacheDevices().then(() => { + cacheDevices(true).then(() => { hideSpinner(); // Retry after successful cache updateDevicePageName(mac); diff --git a/front/js/cache.js b/front/js/cache.js index 9b35ec837..f224f6f29 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -451,11 +451,23 @@ function getDevDataByMac(macAddress, dbColumn) { } // ----------------------------------------------------------------------------- -// Cache the devices as one JSON -function cacheDevices() +/** + * Fetches the full device list from table_devices.json and stores it in + * localStorage under CACHE_KEYS.DEVICES_ALL. + * + * On subsequent calls the fetch is skipped if the initFlag is already set, + * unless forceRefresh is true. Pass forceRefresh = true whenever the caller + * knows the cached list may be stale (e.g. a device was not found by MAC and + * the page needs to recover without a full clearCache()). + * + * @param {boolean} [forceRefresh=false] - When true, bypasses the initFlag + * guard and always fetches fresh data from the server. + * @returns {Promise} Resolves when the cache has been populated. + */ +function cacheDevices(forceRefresh = false) { return new Promise((resolve, reject) => { - if(getCache(CACHE_KEYS.initFlag('cacheDevices')) === "true") + if(!forceRefresh && getCache(CACHE_KEYS.initFlag('cacheDevices')) === "true") { // One-time migration: normalize legacy { data: [...] } wrapper to a plain array. // Old cache entries from prior versions stored the raw API envelope; re-write From c9cb1f3fba81794ebeb4bf835304bec1a967708e Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:08:26 +0000 Subject: [PATCH 118/122] Add db_count to DeviceResult and update GraphQL response handling; localize Device_NoMatch_Title in multiple languages --- front/devices.php | 11 +++++++---- front/php/templates/language/ar_ar.json | 1 + front/php/templates/language/ca_ca.json | 1 + front/php/templates/language/cs_cz.json | 1 + front/php/templates/language/de_de.json | 1 + front/php/templates/language/en_us.json | 1 + front/php/templates/language/es_es.json | 1 + front/php/templates/language/fa_fa.json | 1 + front/php/templates/language/fr_fr.json | 1 + front/php/templates/language/id_id.json | 1 + front/php/templates/language/it_it.json | 1 + front/php/templates/language/ja_jp.json | 1 + front/php/templates/language/nb_no.json | 1 + front/php/templates/language/pl_pl.json | 1 + front/php/templates/language/pt_br.json | 1 + front/php/templates/language/pt_pt.json | 1 + front/php/templates/language/ru_ru.json | 1 + front/php/templates/language/sv_sv.json | 1 + front/php/templates/language/tr_tr.json | 1 + front/php/templates/language/uk_ua.json | 1 + front/php/templates/language/vi_vn.json | 1 + front/php/templates/language/zh_cn.json | 1 + server/api_server/graphql_endpoint.py | 9 +++++++-- 23 files changed, 35 insertions(+), 6 deletions(-) diff --git a/front/devices.php b/front/devices.php index fdb8f4588..a3345f749 100755 --- a/front/devices.php +++ b/front/devices.php @@ -767,6 +767,7 @@ function initializeDatatable (status) { ${_gqlFields} } count + db_count } } `; @@ -807,9 +808,10 @@ function initializeDatatable (status) { console.log("Raw response:", res); const json = res["data"]; - // Set the total number of records for pagination at the *root level* so DataTables sees them - res.recordsTotal = json.devices.count || 0; - res.recordsFiltered = json.devices.count || 0; + // recordsTotal = raw DB count (before filters/search) so DataTables uses emptyTable + // only when the DB is genuinely empty, and zeroRecords when a filter returns nothing. + res.recordsTotal = json.devices.db_count || 0; + res.recordsFiltered = json.devices.count || 0; // console.log("recordsTotal:", res.recordsTotal, "recordsFiltered:", res.recordsFiltered); // console.log("tableRows:", tableRows); @@ -1049,7 +1051,8 @@ function initializeDatatable (status) { // Processing 'processing' : true, 'language' : { - emptyTable: buildEmptyDeviceTableMessage(getString('Device_NextScan_Imminent')), + emptyTable: buildEmptyDeviceTableMessage(getString('Device_NextScan_Imminent')), + zeroRecords: "", "lengthMenu": "", "search": ": ", "paginate": { diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 98ec45a4c..c55ceb2ed 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 67b4292b8..64057edb2 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "Problemes guardant el dispositiu", "Device_Save_Unauthorized": "Token invàlid - No autoritzat", "Device_Saved_Success": "S'ha guardat el dispositiu", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index d03f57749..4627c5e2b 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 77d4ca40c..73c59bd69 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -212,6 +212,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "Gerät erfolgreich gespeichert", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index a51bbdfc2..69f3e1954 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "If devices don't appear after the scan, check your SCAN_SUBNETS setting and documentation.", "Device_NoData_Scanning": "Waiting for the first scan - this may take several minutes after the initial setup.", "Device_NoData_Title": "No devices found yet", + "Device_NoMatch_Title": "No devices match the current filter", "Device_Save_Failed": "Failed to save device", "Device_Save_Unauthorized": "Unauthorized - invalid API token", "Device_Saved_Success": "Device saved successfully", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 577e1fda8..dc24a9699 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -210,6 +210,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "Fallo al guardar el dispositivo", "Device_Save_Unauthorized": "No autorizado - Token de API inválido", "Device_Saved_Success": "Dispositivo guardado exitósamente", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 8cac2dd36..577ab6762 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index 4765004f1..6c9cbd39f 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "Si les appareils n'apparaissent pas après le scan, vérifiez vos paramètres SCAN_SUBNETS et la documentation.", "Device_NoData_Scanning": "En attente du premier scan - cela peut prendre quelques minutes après le premier paramétrage.", "Device_NoData_Title": "Aucun appareil trouvé pour le moment", + "Device_NoMatch_Title": "", "Device_Save_Failed": "Erreur à l'enregistrement de l'appareil", "Device_Save_Unauthorized": "Non autorisé - Jeton d'API invalide", "Device_Saved_Success": "Appareil enregistré avec succès", diff --git a/front/php/templates/language/id_id.json b/front/php/templates/language/id_id.json index c114cb5cb..9bff957a3 100644 --- a/front/php/templates/language/id_id.json +++ b/front/php/templates/language/id_id.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 7d7413861..07c1f08f3 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "Se i dispositivi non vengono visualizzati dopo la scansione, controlla l'impostazione SCAN_SUBNETS e la documentazione.", "Device_NoData_Scanning": "In attesa della prima scansione: potrebbero volerci diversi minuti dopo la configurazione iniziale.", "Device_NoData_Title": "Ancora nessun dispositivo trovato", + "Device_NoMatch_Title": "", "Device_Save_Failed": "Impossibile salvare il dispositivo", "Device_Save_Unauthorized": "Non autorizzato: token API non valido", "Device_Saved_Success": "Dispositivo salvato correttamente", diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 7ef5302a3..31cfb3822 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "スキャン後にデバイスが表示されない場合は、SCAN_SUBNETS設定とドキュメントを確認してください。", "Device_NoData_Scanning": "最初のスキャンを待機中 - 初期設定後、数分かかる場合があります。", "Device_NoData_Title": "デバイスが見つかりません", + "Device_NoMatch_Title": "", "Device_Save_Failed": "デバイスの保存に失敗しました", "Device_Save_Unauthorized": "許可されていない - 無効なAPIトークン", "Device_Saved_Success": "デバイスが正常に保存されました", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 3fc679663..504732b67 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 1e46dc203..94f1f7d90 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 2005b90ec..b120aa808 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index b60aa27d3..bdba833e9 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 020eceb4b..22e67343d 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "Если устройства не отображаются после сканирования, проверьте настройку SCAN_SUBNETS и документацию.", "Device_NoData_Scanning": "Ожидание первого сканирования — это может занять несколько минут после первоначальной настройки.", "Device_NoData_Title": "Устройства пока не найдены", + "Device_NoMatch_Title": "", "Device_Save_Failed": "Не удалось сохранить устройство", "Device_Save_Unauthorized": "Не авторизован - недействительный токен API", "Device_Saved_Success": "Устройство успешно сохранено", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index c114cb5cb..9bff957a3 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index e9e3d63cb..127458940 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 3fe7e7ca5..41ef856eb 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "Не вдалося зберегти пристрій", "Device_Save_Unauthorized": "Неавторизовано – недійсний токен API", "Device_Saved_Success": "Пристрій успішно збережено", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index c114cb5cb..9bff957a3 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "", "Device_Save_Unauthorized": "", "Device_Saved_Success": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 173219147..8591a191e 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -208,6 +208,7 @@ "Device_NoData_Help": "", "Device_NoData_Scanning": "", "Device_NoData_Title": "", + "Device_NoMatch_Title": "", "Device_Save_Failed": "保存设备失败", "Device_Save_Unauthorized": "未授权 - API 令牌无效", "Device_Saved_Success": "设备保存成功", diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 0396ed7df..ba417d84c 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -108,6 +108,7 @@ class Device(ObjectType): class DeviceResult(ObjectType): devices = List(Device) count = Int() + db_count = Int(description="Total device count in the database, before any status/filter/search is applied") # --- SETTINGS --- @@ -198,7 +199,7 @@ def resolve_devices(self, info, options=None): devices_data = json.load(f)["data"] except (FileNotFoundError, json.JSONDecodeError) as e: mylog("none", f"[graphql_schema] Error loading devices data: {e}") - return DeviceResult(devices=[], count=0) + return DeviceResult(devices=[], count=0, db_count=0) # Int fields that may arrive from the DB as empty strings — coerce to None _INT_FIELDS = [ @@ -223,6 +224,10 @@ def resolve_devices(self, info, options=None): mylog("trace", f"[graphql_schema] devices_data: {devices_data}") + # Raw DB count — before any status, filter, or search is applied. + # Used by the frontend to distinguish "no devices in DB" from "filter returned nothing". + db_count = len(devices_data) + # initialize total_count total_count = len(devices_data) @@ -439,7 +444,7 @@ def resolve_devices(self, info, options=None): # Convert dict objects to Device instances to enable field resolution devices = [Device(**device) for device in devices_data] - return DeviceResult(devices=devices, count=total_count) + return DeviceResult(devices=devices, count=total_count, db_count=db_count) # --- SETTINGS --- settings = Field(SettingResult, filters=List(FilterOptionsInput)) From 9b71c210b21495de628be6dc5fec15c5a336170a Mon Sep 17 00:00:00 2001 From: Massimo Pissarello Date: Fri, 13 Mar 2026 09:54:42 +0100 Subject: [PATCH 119/122] Translated using Weblate (Italian) Currently translated at 100.0% (805 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/it/ --- front/php/templates/language/it_it.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 7d7413861..b5f88e241 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -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'table_custom_endpoint.jsonendpoint del file.", + "API_CUSTOM_SQL_description": "Puoi specificare una query SQL personalizzata che genererà un file JSON e quindi lo esporrà tramite table_custom_endpoint.json endpoint del file.", "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 SYNC, nel server GraphQL e in altri endpoint API. Puoi utilizzare gli endpoint API per creare integrazioni personalizzate come descritto nella documentazione API.", "API_TOKEN_name": "Token API", @@ -567,13 +567,13 @@ "Network_ManageEdit_Type_text": "-- Seleziona tipo --", "Network_ManageLeaf": "Gestisci assegnazione", "Network_ManageUnassign": "Annulla assegnazione", - "Network_NoAssignedDevices": "A questo nodo di rete non sono assegnati dispositivi (nodi foglia). Assegnane uno dall'elenco qui sotto o vai alla scheda Dettagli di qualsiasi dispositivo in Dispositivi e assegnalo a un Nodo di rete (MAC) e una Porta.", + "Network_NoAssignedDevices": "A questo nodo di rete non sono assegnati dispositivi (nodi foglia). Assegnane uno dall'elenco qui sotto o vai alla scheda Dettagli di qualsiasi dispositivo in Dispositivi e assegnalo a un Nodo di rete (MAC) e una Porta.", "Network_NoDevices": "Nessun dispositivo da configurare", "Network_Node": "Nodo di rete", "Network_Node_Name": "Nome nodo", "Network_Parent": "Dispositivo di rete principale", "Network_Root": "Nodo radice", - "Network_Root_Not_Configured": "Seleziona un tipo di dispositivo di rete, ad esempio un Gateway, nel campo Tipo del dispositivo root Internet per iniziare a configurare questa schermata.

    Ulteriore documentazione è disponibile nella guida Come impostare la tua pagina di rete", + "Network_Root_Not_Configured": "Seleziona un tipo di dispositivo di rete, ad esempio un Gateway, nel campo Tipo del dispositivo root Internet per iniziare a configurare questa schermata.

    Ulteriore documentazione è disponibile nella guida Come impostare la tua pagina di rete", "Network_Root_Unconfigurable": "Radice non configurabile", "Network_ShowArchived": "Mostra archiviati", "Network_ShowOffline": "Mostra offline", @@ -584,7 +584,7 @@ "Network_UnassignedDevices": "Dispositivi non assegnati", "Notifications_All": "Tutte le notifiche", "Notifications_Mark_All_Read": "Segna tutto come letto", - "PIALERT_WEB_PASSWORD_description": "La password predefinita è 123456. Per modificare la password esegui /app/back/pialert-cli nel contenitore o utilizza il SETPWD_RUNplugin imposta password.", + "PIALERT_WEB_PASSWORD_description": "La password predefinita è 123456. Per modificare la password esegui /app/back/pialert-cli nel contenitore o utilizza il SETPWD_RUN plugin imposta password.", "PIALERT_WEB_PASSWORD_name": "Password login", "PIALERT_WEB_PROTECTION_description": "Se abilitato, viene mostrata una finestra di login. Leggi attentamente qui sotto se rimani bloccato fuori dall'istanza.", "PIALERT_WEB_PROTECTION_name": "Abilita login", @@ -804,4 +804,4 @@ "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." -} \ No newline at end of file +} From d19cb3d679d3db62235a1108d77bc21c5a91c540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8E=8B=E5=8F=AB=E6=88=91=E6=9D=A5=E5=B7=A1?= =?UTF-8?q?=E5=B1=B1?= Date: Fri, 13 Mar 2026 05:08:39 +0100 Subject: [PATCH 120/122] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 97.7% (787 of 805 strings) Translation: NetAlertX/core Translate-URL: https://hosted.weblate.org/projects/pialert/core/zh_Hans/ --- front/php/templates/language/zh_cn.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 173219147..31f4ce9aa 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -374,9 +374,9 @@ "Gen_create_new_device_info": "通常使用plugins来发现设备。但是,在某些情况下,您可能需要手动添加设备。要探索特定场景,请查看远程网络文档。", "General_display_name": "通用", "General_icon": "", - "HRS_TO_KEEP_NEWDEV_description": "这是一项维护设置删除设备。如果启用该设置(0 为禁用),则标记为新设备的设备(如果其首次会话时间早于此设置中指定的小时数)将被删除。如果您想在 X 小时后自动删除新设备,请使用此设置。", + "HRS_TO_KEEP_NEWDEV_description": "这是一项 删除设备 的维护性设置。如启用(0 代表禁用),被标记为 新设备 的设备将被删除,如果它们的 首个会话 时间比这个设置中指定的时长要短。如果想在 X 个小时后自动删除 新设备 请使用本设置。", "HRS_TO_KEEP_NEWDEV_name": "小时后删除新设备", - "HRS_TO_KEEP_OFFDEV_description": "这是维护设置删除设备。如果启用了这个设置(0是禁用),任何上次链接时间比设置里存的指定时间长的离线设备都会被删除。要是您想X小时以候自动处理下线设备,请用这个设置。", + "HRS_TO_KEEP_OFFDEV_description": "这是删除设备的维护设置。如果启用了这个设置(0是禁用),任何上次连接时间比设置里存的指定时间长的离线设备都会被删除。要是您想在X小时后自动删除离线设备,请用这个设置。", "HRS_TO_KEEP_OFFDEV_name": "保留离线设备", "LOADED_PLUGINS_description": "加载哪些插件。添加插件可能会降低应用程序的速度。在插件文档中详细了解需要启用哪些插件、插件类型或扫描选项。卸载插件将丢失您的设置。只有已禁用的插件才能卸载。", "LOADED_PLUGINS_name": "已加载插件", @@ -804,4 +804,4 @@ "settings_system_label": "系统", "settings_update_item_warning": "更新下面的值。请注意遵循先前的格式。未执行验证。", "test_event_tooltip": "在测试设置之前,请先保存更改。" -} \ No newline at end of file +} From 6f7d2c32533dc06062a9e140921a7ce1f593e951 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:17:30 +0000 Subject: [PATCH 121/122] Rename db_count to dbCount in GraphQL response handling for consistency --- front/devices.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/devices.php b/front/devices.php index a3345f749..19095e527 100755 --- a/front/devices.php +++ b/front/devices.php @@ -767,7 +767,7 @@ function initializeDatatable (status) { ${_gqlFields} } count - db_count + dbCount } } `; @@ -810,7 +810,7 @@ function initializeDatatable (status) { // recordsTotal = raw DB count (before filters/search) so DataTables uses emptyTable // only when the DB is genuinely empty, and zeroRecords when a filter returns nothing. - res.recordsTotal = json.devices.db_count || 0; + res.recordsTotal = json.devices.dbCount || 0; res.recordsFiltered = json.devices.count || 0; // console.log("recordsTotal:", res.recordsTotal, "recordsFiltered:", res.recordsFiltered); From f75c53fc5df477468feef7148f841ef9bdc466d6 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:27:29 +0000 Subject: [PATCH 122/122] Implement notification text templates and update related settings for customizable notifications --- .github/skills/code-standards/SKILL.md | 2 +- docs/NOTIFICATIONS.md | 3 + docs/NOTIFICATION_TEMPLATES.md | 110 ++++++++ .../notification_processing/config.json | 144 ++++++++++ mkdocs.yml | 1 + server/models/notification_instance.py | 36 ++- test/backend/test_notification_templates.py | 266 ++++++++++++++++++ 7 files changed, 553 insertions(+), 9 deletions(-) create mode 100644 docs/NOTIFICATION_TEMPLATES.md create mode 100644 test/backend/test_notification_templates.py diff --git a/.github/skills/code-standards/SKILL.md b/.github/skills/code-standards/SKILL.md index 0e9bd1f37..98c7516b2 100644 --- a/.github/skills/code-standards/SKILL.md +++ b/.github/skills/code-standards/SKILL.md @@ -64,7 +64,7 @@ Use timeNowUTC(as_string=False) for datetime operations (scheduling, comparisons ## String Sanitization -Use sanitizers from `server/helper.py` before storing user input. +Use sanitizers from `server/helper.py` before storing user input. MAC addresses are always lowercased and normalized. IP addresses should be validated. ## Devcontainer Constraints diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index c6a90f586..3bd12d5a6 100755 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -1,5 +1,8 @@ # Notifications 📧 +> [!TIP] +> Want to customize how devices appear in text notifications? See [Notification Text Templates](NOTIFICATION_TEMPLATES.md). + There are 4 ways how to influence notifications: 1. On the device itself diff --git a/docs/NOTIFICATION_TEMPLATES.md b/docs/NOTIFICATION_TEMPLATES.md new file mode 100644 index 000000000..1557416c3 --- /dev/null +++ b/docs/NOTIFICATION_TEMPLATES.md @@ -0,0 +1,110 @@ +# Notification Text Templates + +> Customize how devices and events appear in **text** notifications (email previews, push notifications, Apprise messages). + +By default, NetAlertX formats each device as a vertical list of `Header: Value` pairs. Text templates let you define a **single-line format per device** using `{FieldName}` placeholders — ideal for mobile notification previews and high-volume alerts. + +HTML email tables are **not affected** by these templates. + +## Quick Start + +1. Go to **Settings → Notification Processing**. +2. Set a template string for the section you want to customize, e.g.: + - **Text Template: New Devices** → `{Device name} ({MAC}) - {IP}` +3. Save. The next notification will use your format. + +**Before (default):** +``` +🆕 New devices +--------- +MAC: aa:bb:cc:dd:ee:ff +Datetime: 2025-01-15 10:30:00 +IP: 192.168.1.42 +Event Type: New Device +Device name: MyPhone +Comments: +``` + +**After (with template `{Device name} ({MAC}) - {IP}`):** +``` +🆕 New devices +--------- +MyPhone (aa:bb:cc:dd:ee:ff) - 192.168.1.42 +``` + +## Settings Reference + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `NTFPRCS_TEXT_SECTION_HEADERS` | Boolean | `true` | Show/hide section titles (e.g. `🆕 New devices \n---------`). | +| `NTFPRCS_TEXT_TEMPLATE_new_devices` | String | *(empty)* | Template for new device rows. | +| `NTFPRCS_TEXT_TEMPLATE_down_devices` | String | *(empty)* | Template for down device rows. | +| `NTFPRCS_TEXT_TEMPLATE_down_reconnected` | String | *(empty)* | Template for reconnected device rows. | +| `NTFPRCS_TEXT_TEMPLATE_events` | String | *(empty)* | Template for event rows. | +| `NTFPRCS_TEXT_TEMPLATE_plugins` | String | *(empty)* | Template for plugin event rows. | + +When a template is **empty**, the section uses the original vertical `Header: Value` format (full backward compatibility). + +## Template Syntax + +Use `{FieldName}` to insert a value from the notification data. Field names are **case-sensitive** and must match the column names exactly. + +``` +{Device name} ({MAC}) connected at {Datetime} +``` + +- No loops, conditionals, or nesting — just simple string replacement. +- If a `{FieldName}` does not exist in the data, it is left as-is in the output (safe failure). For example, `{NonExistent}` renders literally as `{NonExistent}`. + +## Variable Availability by Section + +Each section has different available fields because they come from different database queries. + +### `new_devices` and `events` + +| Variable | Description | +|----------|-------------| +| `{MAC}` | Device MAC address | +| `{Datetime}` | Event timestamp | +| `{IP}` | Device IP address | +| `{Event Type}` | Type of event (e.g. `New Device`, `Connected`) | +| `{Device name}` | Device display name | +| `{Comments}` | Device comments | + +**Example:** `{Device name} ({MAC}) - {IP} [{Event Type}]` + +### `down_devices` and `down_reconnected` + +| Variable | Description | +|----------|-------------| +| `{devName}` | Device display name | +| `{eve_MAC}` | Device MAC address | +| `{devVendor}` | Device vendor/manufacturer | +| `{eve_IP}` | Device IP address | +| `{eve_DateTime}` | Event timestamp | +| `{eve_EventType}` | Type of event | + +**Example:** `{devName} ({eve_MAC}) {devVendor} - went down at {eve_DateTime}` + +### `plugins` + +| Variable | Description | +|----------|-------------| +| `{Plugin}` | Plugin code name | +| `{Object_PrimaryId}` | Primary identifier of the object | +| `{Object_SecondaryId}` | Secondary identifier | +| `{DateTimeChanged}` | Timestamp of change | +| `{Watched_Value1}` | First watched value | +| `{Watched_Value2}` | Second watched value | +| `{Watched_Value3}` | Third watched value | +| `{Watched_Value4}` | Fourth watched value | +| `{Status}` | Plugin event status | + +**Example:** `{Plugin}: {Object_PrimaryId} - {Status}` + +> [!NOTE] +> Field names differ between sections because they come from different SQL queries. A template configured for `new_devices` cannot use `{devName}` — that field is only available in `down_devices` and `down_reconnected`. + +## Section Headers Toggle + +Set **Text Section Headers** (`NTFPRCS_TEXT_SECTION_HEADERS`) to `false` to remove the section title separators from text notifications. This is useful when you want compact output without the `🆕 New devices \n---------` banners. diff --git a/front/plugins/notification_processing/config.json b/front/plugins/notification_processing/config.json index 87c52093e..6136fbf3d 100755 --- a/front/plugins/notification_processing/config.json +++ b/front/plugins/notification_processing/config.json @@ -180,6 +180,150 @@ "string": "You can specify a SQL where condition to filter out Events from notifications. For example AND devLastIP NOT LIKE '192.168.3.%' will always exclude any Event notifications for all devices with the IP starting with 192.168.3.%." } ] + }, + { + "function": "TEXT_SECTION_HEADERS", + "type": { + "dataType": "boolean", + "elements": [ + { "elementType": "input", "elementOptions": [{ "type": "checkbox" }], "transformers": [] } + ] + }, + "default_value": true, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Section Headers" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Enable or disable section titles (e.g. 🆕 New devices \\n---------) in text notifications. Enabled by default for backward compatibility." + } + ] + }, + { + "function": "TEXT_TEMPLATE_new_devices", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: New Devices" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for new device notifications. Use {FieldName} placeholders, e.g. {Device name} ({MAC}) - {IP}. Leave empty for default formatting. Available fields: {MAC}, {Datetime}, {IP}, {Event Type}, {Device name}, {Comments}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_down_devices", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Down Devices" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for down device notifications. Use {FieldName} placeholders, e.g. {devName} ({eve_MAC}) - {eve_IP}. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_down_reconnected", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Reconnected" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for reconnected device notifications. Use {FieldName} placeholders. Leave empty for default formatting. Available fields: {devName}, {eve_MAC}, {devVendor}, {eve_IP}, {eve_DateTime}, {eve_EventType}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_events", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Events" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for event notifications. Use {FieldName} placeholders, e.g. {Device name} ({MAC}) {Event Type} at {Datetime}. Leave empty for default formatting. Available fields: {MAC}, {Datetime}, {IP}, {Event Type}, {Device name}, {Comments}." + } + ] + }, + { + "function": "TEXT_TEMPLATE_plugins", + "type": { + "dataType": "string", + "elements": [ + { "elementType": "input", "elementOptions": [], "transformers": [] } + ] + }, + "default_value": "", + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Text Template: Plugins" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "Custom text template for plugin event notifications. Use {FieldName} placeholders, e.g. {Plugin}: {Object_PrimaryId} - {Status}. Leave empty for default formatting. Available fields: {Plugin}, {Object_PrimaryId}, {Object_SecondaryId}, {DateTimeChanged}, {Watched_Value1}, {Watched_Value2}, {Watched_Value3}, {Watched_Value4}, {Status}." + } + ] } ], diff --git a/mkdocs.yml b/mkdocs.yml index 1db4a6539..f6ead95a0 100755 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,6 +53,7 @@ nav: - Advanced guides: - Remote Networks: REMOTE_NETWORKS.md - Notifications Guide: NOTIFICATIONS.md + - Notification Text Templates: NOTIFICATION_TEMPLATES.md - Custom PUID/GUID: PUID_PGID_SECURITY.md - Name Resolution: NAME_RESOLUTION.md - Authelia: AUTHELIA.md diff --git a/server/models/notification_instance.py b/server/models/notification_instance.py index e45f97d3d..d2ac6f4c0 100755 --- a/server/models/notification_instance.py +++ b/server/models/notification_instance.py @@ -1,4 +1,5 @@ import json +import re import uuid import socket from yattag import indent @@ -345,8 +346,16 @@ def construct_notifications(JSON, section): build_direction = "TOP_TO_BOTTOM" text_line = "{}\t{}\n" + # Read template settings + show_headers = get_setting_value("NTFPRCS_TEXT_SECTION_HEADERS") + if show_headers is None or show_headers == "": + show_headers = True + text_template = get_setting_value(f"NTFPRCS_TEXT_TEMPLATE_{section}") or "" + if len(jsn) > 0: - text = tableTitle + "\n---------\n" + # Section header (text) + if show_headers: + text = tableTitle + "\n---------\n" # Convert a JSON into an HTML table html = convert( @@ -363,13 +372,24 @@ def construct_notifications(JSON, section): ) # prepare text-only message - for device in jsn: - for header in headers: - padding = "" - if len(header) < 4: - padding = "\t" - text += text_line.format(header + ": " + padding, device[header]) - text += "\n" + if text_template: + # Custom template: replace {FieldName} placeholders per device + for device in jsn: + line = re.sub( + r'\{(.+?)\}', + lambda m: str(device.get(m.group(1), m.group(0))), + text_template, + ) + text += line + "\n" + else: + # Legacy fallback: vertical Header: Value list + for device in jsn: + for header in headers: + padding = "" + if len(header) < 4: + padding = "\t" + text += text_line.format(header + ": " + padding, device[header]) + text += "\n" # Format HTML table headers for header in headers: diff --git a/test/backend/test_notification_templates.py b/test/backend/test_notification_templates.py new file mode 100644 index 000000000..11de4fbea --- /dev/null +++ b/test/backend/test_notification_templates.py @@ -0,0 +1,266 @@ +""" +NetAlertX Notification Text Template Tests + +Tests the template substitution and section header toggle in +construct_notifications(). All tests mock get_setting_value to avoid +database/config dependencies. + +License: GNU GPLv3 +""" + +import sys +import os +import unittest +from unittest.mock import patch + +# Add the server directory to the path for imports +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/server"]) + + +def _make_json(section, devices, column_names, title="Test Section"): + """Helper to build the JSON structure expected by construct_notifications.""" + return { + section: devices, + f"{section}_meta": { + "title": title, + "columnNames": column_names, + }, + } + + +SAMPLE_NEW_DEVICES = [ + { + "MAC": "AA:BB:CC:DD:EE:FF", + "Datetime": "2025-01-15 10:30:00", + "IP": "192.168.1.42", + "Event Type": "New Device", + "Device name": "MyPhone", + "Comments": "", + }, + { + "MAC": "11:22:33:44:55:66", + "Datetime": "2025-01-15 11:00:00", + "IP": "192.168.1.99", + "Event Type": "New Device", + "Device name": "Laptop", + "Comments": "Office", + }, +] + +NEW_DEVICE_COLUMNS = ["MAC", "Datetime", "IP", "Event Type", "Device name", "Comments"] + + +class TestConstructNotificationsTemplates(unittest.TestCase): + """Tests for template substitution in construct_notifications.""" + + def _setting_factory(self, overrides=None): + """Return a mock get_setting_value that resolves from overrides dict.""" + settings = overrides or {} + + def mock_get(key): + return settings.get(key, "") + + return mock_get + + # ----------------------------------------------------------------- + # Empty section should always return ("", "") regardless of settings + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_empty_section_returns_empty(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.return_value = "" + json_data = _make_json("new_devices", [], []) + html, text = construct_notifications(json_data, "new_devices") + self.assertEqual(html, "") + self.assertEqual(text, "") + + # ----------------------------------------------------------------- + # Legacy fallback: no template → vertical Header: Value per device + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_legacy_fallback_no_template(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + html, text = construct_notifications(json_data, "new_devices") + + # Section header must be present + self.assertIn("🆕 New devices", text) + self.assertIn("---------", text) + + # Legacy format: each header appears as "Header: \tValue" + self.assertIn("MAC:", text) + self.assertIn("AA:BB:CC:DD:EE:FF", text) + self.assertIn("Device name:", text) + self.assertIn("MyPhone", text) + + # HTML must still be generated + self.assertNotEqual(html, "") + + # ----------------------------------------------------------------- + # Template substitution: single-line format per device + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_template_substitution(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC}) - {IP}", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertIn("MyPhone (AA:BB:CC:DD:EE:FF) - 192.168.1.42", text) + self.assertIn("Laptop (11:22:33:44:55:66) - 192.168.1.99", text) + + # ----------------------------------------------------------------- + # Missing field: {NonExistent} left as-is (safe failure) + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_missing_field_safe_failure(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} - {NonExistent}", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertIn("MyPhone - {NonExistent}", text) + self.assertIn("Laptop - {NonExistent}", text) + + # ----------------------------------------------------------------- + # Section headers disabled: no title/separator in text output + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_section_headers_disabled(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": False, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC})", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertNotIn("🆕 New devices", text) + self.assertNotIn("---------", text) + # Template output still present + self.assertIn("MyPhone (AA:BB:CC:DD:EE:FF)", text) + + # ----------------------------------------------------------------- + # Section headers enabled (default when setting absent/empty) + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_section_headers_default_enabled(self, mock_setting): + from models.notification_instance import construct_notifications + + # Simulate setting not configured (returns empty string) + mock_setting.side_effect = self._setting_factory({}) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + # Headers should be shown by default + self.assertIn("🆕 New devices", text) + self.assertIn("---------", text) + + # ----------------------------------------------------------------- + # Mixed valid and invalid fields in same template + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_mixed_valid_and_invalid_fields(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({BadField}) - {IP}", + }) + + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + _, text = construct_notifications(json_data, "new_devices") + + self.assertIn("MyPhone ({BadField}) - 192.168.1.42", text) + + # ----------------------------------------------------------------- + # Down devices section uses different column names + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_down_devices_template(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_down_devices": "{devName} ({eve_MAC}) down since {eve_DateTime}", + }) + + down_devices = [ + { + "devName": "Router", + "eve_MAC": "FF:EE:DD:CC:BB:AA", + "devVendor": "Cisco", + "eve_IP": "10.0.0.1", + "eve_DateTime": "2025-01-15 08:00:00", + "eve_EventType": "Device Down", + } + ] + columns = ["devName", "eve_MAC", "devVendor", "eve_IP", "eve_DateTime", "eve_EventType"] + + json_data = _make_json("down_devices", down_devices, columns, "🔴 Down devices") + _, text = construct_notifications(json_data, "down_devices") + + self.assertIn("Router (FF:EE:DD:CC:BB:AA) down since 2025-01-15 08:00:00", text) + + # ----------------------------------------------------------------- + # HTML output is unchanged regardless of template config + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_html_unchanged_with_template(self, mock_setting): + from models.notification_instance import construct_notifications + + # Get HTML without template + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "", + }) + json_data = _make_json( + "new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices" + ) + html_without, _ = construct_notifications(json_data, "new_devices") + + # Get HTML with template + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "{Device name} ({MAC})", + }) + html_with, _ = construct_notifications(json_data, "new_devices") + + self.assertEqual(html_without, html_with) + + +if __name__ == "__main__": + unittest.main()