Merge pull request #1817 from netalertx/next_release

Next release
This commit is contained in:
Jokob @NetAlertX authored and GitHub committed 2026-09-28 07:58:55 +10:00
commit d731838772
32 files changed
+544 -42

No files matched your search

+1 -1
View File
@@ -90,4 +90,4 @@ Procedural/how-to knowledge (running tests, resetting the DB, devcontainer manag
- Reuse `test/db_test_helpers.py` for DB mocks/fixtures in tests rather than redefining `DummyDB`/`make_db` locally.
- Keep files under ~500 lines; split rather than grow.
- Every Python function/method gets a succinct docstring describing its current use and behavior — one or two sentences, not a changelog of what changed or why (that belongs in the commit/PR, not the docstring). Same rule for JS: a JSDoc `/** ... */` block, not a plain `//` line above the function. Whenever you touch a function that only has a plain description comment (Python or JS), convert it to a proper docstring as part of that edit rather than leaving the old style next to new code.
- Before adding a new key to `front/php/templates/language/en_us.json`, search it for an existing key with the same text/purpose and reuse it — prefer generic `Gen_*` keys over page-scoped names for genuinely generic UI text (e.g. `Gen_Prev`/`Gen_Next`, not `Presence_Page_Prev`). Only the English file needs a new key; other locales fall back to it automatically.
- Before adding a new key to `front/php/templates/language/en_us.json`, search it for an existing key with the same text/purpose and reuse it - prefer generic `Gen_*` keys over page-scoped names for genuinely generic UI text (e.g. `Gen_Prev`/`Gen_Next`, not `Presence_Page_Prev`). Only the English file needs a real translation; other locales fall back to it automatically at runtime for a key they don't have. After adding or changing any key in `en_us.json`, run `python3 front/php/templates/language/merge_translations.py` (plain stdlib, no deps) - it re-sorts `en_us.json` alphabetically and propagates the new key into every other locale file with an empty placeholder value, so translators see what needs translating. Skipping this leaves the other 23 locale files out of sync with `en_us.json`'s key set.
+6 -2
View File
@@ -51,6 +51,9 @@ const DEVICE_COLUMN_FIELDS = [
"devPrimaryIPv6", // 32 Device_TableHead_IPv6
"devFlapping", // 33 Device_TableHead_Flapping
"devComments", // 34 Device_TableHead_Comments
"devAlertEvents", // 35 Device_TableHead_AlertEvents
"devCanSleep", // 36 Device_TableHead_CanSleep
"devStaticIP", // 37 Device_TableHead_StaticIP
];
// Named index constants — eliminates all mapIndx(N) magic numbers.
@@ -68,10 +71,8 @@ const NUMERIC_DEFAULTS = new Set([
// These are merged with DEVICE_COLUMN_FIELDS to build the GraphQL query.
const GRAPHQL_EXTRA_FIELDS = [
"devComments",
"devStaticIP",
"devScan",
"devLogEvents",
"devAlertEvents",
"devSkipRepeated",
"devLastNotification",
"devIsNew",
@@ -130,6 +131,9 @@ const COLUMN_NAME_MAP = {
"Device_TableHead_IPv6": "devPrimaryIPv6",
"Device_TableHead_Flapping": "devFlapping",
"Device_TableHead_Comments": "devComments",
"Device_TableHead_AlertEvents": "devAlertEvents",
"Device_TableHead_CanSleep": "devCanSleep",
"Device_TableHead_StaticIP": "devStaticIP",
};
console.log("init device-columns.js");
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "مخطط الاتصال",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "تنبيه عدم الاتصال",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "الأجهزة المتصلة",
"Device_TableHead_CustomProps": "خصائص مخصصة",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "معرف الصف",
"Device_TableHead_SSID": "معرف الشبكة اللاسلكية",
"Device_TableHead_SourcePlugin": "المكون الإضافي المصدر",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "الحالة",
"Device_TableHead_SyncHubNodeName": "اسم عقدة المزامنة",
"Device_TableHead_Type": "النوع",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Dispositius detectats",
"Device_Shortcut_Unstable": "Inestable",
"Device_TableHead_AlertDown": "Cancel·lar alerta",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Connexions",
"Device_TableHead_CustomProps": "Props / Accions",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID de fila",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Connector(Plugin) font",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Estat",
"Device_TableHead_SyncHubNodeName": "Node Sync",
"Device_TableHead_Type": "Tipus",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Výskyt zařízení",
"Device_Shortcut_Unstable": "Nestabilní",
"Device_TableHead_AlertDown": "Upozornění na nedostupnost",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "Komentáře",
"Device_TableHead_Connected_Devices": "Spojení",
"Device_TableHead_CustomProps": "Vlastnosti / Akce",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "Identif. řádku",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Zdrojový zásuvný modul",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Stav",
"Device_TableHead_SyncHubNodeName": "Synchronizovat uzel",
"Device_TableHead_Type": "Typ",
+3
View File
@@ -237,6 +237,8 @@
"Device_Shortcut_OnlineChart": "Gerätepräsenz im Laufe der Zeit",
"Device_Shortcut_Unstable": "Instabil",
"Device_TableHead_AlertDown": "Alarm aus",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "Kommentare",
"Device_TableHead_Connected_Devices": "Verbindungen",
"Device_TableHead_CustomProps": "Eigenschaften / Aktionen",
@@ -267,6 +269,7 @@
"Device_TableHead_Rowid": "Zeilennummer",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Quellerweiterung",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Status",
"Device_TableHead_SyncHubNodeName": "Synchronisationsknoten",
"Device_TableHead_Type": "Typ",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Device presence",
"Device_Shortcut_Unstable": "Unstable",
"Device_TableHead_AlertDown": "Alert Down",
"Device_TableHead_AlertEvents": "Alert Events",
"Device_TableHead_CanSleep": "Can Sleep",
"Device_TableHead_Comments": "Comments",
"Device_TableHead_Connected_Devices": "Connections",
"Device_TableHead_CustomProps": "Props / Actions",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "Row ID",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Source Plugin",
"Device_TableHead_StaticIP": "Static IP",
"Device_TableHead_Status": "Status",
"Device_TableHead_SyncHubNodeName": "Sync Node",
"Device_TableHead_Type": "Type",
+3
View File
@@ -235,6 +235,8 @@
"Device_Shortcut_OnlineChart": "Presencia del dispositivo a lo largo del tiempo",
"Device_Shortcut_Unstable": "Inestable",
"Device_TableHead_AlertDown": "Alerta desactivada",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Conexiones",
"Device_TableHead_CustomProps": "Propiedades / Acciones",
@@ -265,6 +267,7 @@
"Device_TableHead_Rowid": "ID de fila",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Fuente del plugin",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Situación",
"Device_TableHead_SyncHubNodeName": "Nodo de sincronización",
"Device_TableHead_Type": "Tipo",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+4 -1
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Présence de l'appareil",
"Device_Shortcut_Unstable": "Instable",
"Device_TableHead_AlertDown": "Alerter si En panne",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "Commentaires",
"Device_TableHead_Connected_Devices": "Connexions",
"Device_TableHead_CustomProps": "Champs / Actions",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID de colonne",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Source du plugin",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "État",
"Device_TableHead_SyncHubNodeName": "Noeud de synchro",
"Device_TableHead_Type": "Type",
@@ -837,4 +840,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. <b>Il n'y a pas de pas de contrôle.</b>",
"test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage."
}
}
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+7 -6
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Presenza dispositivo",
"Device_Shortcut_Unstable": "Instabile",
"Device_TableHead_AlertDown": "Avviso disconnessione",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "Commenti",
"Device_TableHead_Connected_Devices": "Connessioni",
"Device_TableHead_CustomProps": "Proprietà/Azioni",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID riga",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Plugin sorgente",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Stato",
"Device_TableHead_SyncHubNodeName": "Sincronizza nodo",
"Device_TableHead_Type": "Tipo",
@@ -351,12 +354,12 @@
"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": "Nuovo",
"Gen_Next": "",
"Gen_Next": "Successivo",
"Gen_No_Data": "Nessun dato",
"Gen_Offline": "Offline",
"Gen_Okay": "Ok",
"Gen_Online": "Online",
"Gen_Prev": "",
"Gen_Prev": "Precedente",
"Gen_Purge": "Svuota",
"Gen_ReadDocs": "Maggiori informazioni nella documentazione.",
"Gen_Remove_All": "Rimuovi tutti",
@@ -836,7 +839,5 @@
"settings_system_icon": "fa-solid fa-gear",
"settings_system_label": "Sistema",
"settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. <b>La convalida non viene eseguita.</b>",
"test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni.",
"Gen_Next": "Successivo",
"Gen_Prev": "Precedente"
}
"test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni."
}
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "デバイス検出",
"Device_Shortcut_Unstable": "不安定",
"Device_TableHead_AlertDown": "ダウンアラート",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "接続",
"Device_TableHead_CustomProps": "属性 / アクション",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "行ID",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "ソースプラグイン",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "状態",
"Device_TableHead_SyncHubNodeName": "同期ノード",
"Device_TableHead_Type": "種別",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Enhetens tilstedeværelse",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Tilkoblinger",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "Rad ID",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Status",
"Device_TableHead_SyncHubNodeName": "Synkroniser Node",
"Device_TableHead_Type": "Type",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Obecność urządzenia",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "Alert niedostępny",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Połączenia",
"Device_TableHead_CustomProps": "Właściwości / Akcje",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID wiersza",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Wtyczka źródłowa",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Status",
"Device_TableHead_SyncHubNodeName": "Węzeł synchronizacji",
"Device_TableHead_Type": "Typ",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Presença do dispositivo",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "Alerta em baixo",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Conexões",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID da linha",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Plugin de fonte",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Status",
"Device_TableHead_SyncHubNodeName": "Nó de sincronização",
"Device_TableHead_Type": "Tipo",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Presença do dispositivo",
"Device_Shortcut_Unstable": "Instável",
"Device_TableHead_AlertDown": "Alerta em baixo",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Conexões",
"Device_TableHead_CustomProps": "Propriedades / Ações",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID da linha",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Plugin de fonte",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Estado",
"Device_TableHead_SyncHubNodeName": "Nó de sincronização",
"Device_TableHead_Type": "Tipo",
+4 -1
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Присутствие устройств",
"Device_Shortcut_Unstable": "Нестабильный",
"Device_TableHead_AlertDown": "Оповещение о сост. ВЫКЛ",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "Комментарии",
"Device_TableHead_Connected_Devices": "Соединения",
"Device_TableHead_CustomProps": "Свойства / Действия",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID строки",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Исходный плагин",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Статус",
"Device_TableHead_SyncHubNodeName": "Узел синхронизации",
"Device_TableHead_Type": "Тип",
@@ -837,4 +840,4 @@
"settings_system_label": "Система",
"settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. <b>Проверка не выполняется.</b>",
"test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки."
}
}
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Cihaz Durumu",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "Çalışmama Alarmı",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Bağlantılar",
"Device_TableHead_CustomProps": "Özellikler / Eylemler",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "Satır ID",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Kaynak Eklentisi Modülü",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Durum",
"Device_TableHead_SyncHubNodeName": "Senkronizasyon Node",
"Device_TableHead_Type": "Tür",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "Наявність пристрою",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "Агент Вниз",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "Зв'язки",
"Device_TableHead_CustomProps": "Реквізит / дії",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "ID рядка",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "Вихідний плагін",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "Статус",
"Device_TableHead_SyncHubNodeName": "Вузол синхронізації",
"Device_TableHead_Type": "Тип",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "",
"Device_Shortcut_Unstable": "",
"Device_TableHead_AlertDown": "",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "",
"Device_TableHead_Connected_Devices": "",
"Device_TableHead_CustomProps": "",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "",
"Device_TableHead_SSID": "",
"Device_TableHead_SourcePlugin": "",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "",
"Device_TableHead_SyncHubNodeName": "",
"Device_TableHead_Type": "",
+3
View File
@@ -233,6 +233,8 @@
"Device_Shortcut_OnlineChart": "设备统计",
"Device_Shortcut_Unstable": "不稳定",
"Device_TableHead_AlertDown": "提醒宕机",
"Device_TableHead_AlertEvents": "",
"Device_TableHead_CanSleep": "",
"Device_TableHead_Comments": "注释",
"Device_TableHead_Connected_Devices": "链接",
"Device_TableHead_CustomProps": "属性",
@@ -263,6 +265,7 @@
"Device_TableHead_Rowid": "排行",
"Device_TableHead_SSID": "SSID",
"Device_TableHead_SourcePlugin": "源插件",
"Device_TableHead_StaticIP": "",
"Device_TableHead_Status": "状态",
"Device_TableHead_SyncHubNodeName": "同步节点",
"Device_TableHead_Type": "类型",
+64 -26
View File
@@ -256,25 +256,36 @@ class sensor_config:
# -------------------------------------------------------------------------------
# A single publish() call is retried a bounded number of times (not
# indefinitely) - see publish_mqtt()'s docstring for why.
_PUBLISH_MAX_ATTEMPTS = 20
_PUBLISH_RETRY_DELAY_SEC = 0.1
def publish_mqtt(mqtt_client, topic, message):
"""
Publishes a message to an MQTT topic using the provided MQTT client.
If the message is not a string, it is converted to a JSON-formatted string.
The function retrieves the desired QoS level from settings and logs the publishing process.
If the client is not connected to the broker, the function logs an error and aborts.
It attempts to publish the message, retrying until the publish status indicates success.
Retries a bounded number of times (_PUBLISH_MAX_ATTEMPTS) on failure rather
than indefinitely - an unbounded retry here would burn this plugin's whole
RUN_TIMEOUT budget on one stuck publish call whenever the broker is
degraded (accepting connections but rejecting publishes), silently
dropping every other device queued for this run.
Args:
mqtt_client: The MQTT client instance used to publish the message.
topic (str): The MQTT topic to publish to.
message (Any): The message payload to send. Non-string messages are converted to JSON.
Returns:
bool: True if the message was published successfully, False if not connected to the broker.
bool: True if the message was published successfully, False if not
connected to the broker or the retry budget was exhausted.
"""
status = 1
# convert anything but a simple string to json
if not isinstance(message, str):
message = json.dumps(message).replace("'", '"')
message = json.dumps(message)
qos = get_setting_value('MQTT_QOS')
@@ -286,7 +297,7 @@ def publish_mqtt(mqtt_client, topic, message):
mylog('minimal', [f"[{pluginName}] ⚠ ERROR: Not connected to broker, aborting."])
return False
while status != 0:
for attempt in range(_PUBLISH_MAX_ATTEMPTS):
# mylog('verbose', [f"[{pluginName}] mqtt_client.publish "])
# mylog('verbose', [f"[{pluginName}] mqtt_client.is_connected(): {mqtt_client.is_connected()} "])
@@ -303,10 +314,14 @@ def publish_mqtt(mqtt_client, topic, message):
# mylog('verbose', [f"[{pluginName}] status: {status}"])
# mylog('verbose', [f"[{pluginName}] result: {result}"])
if status != 0:
mylog('debug', [f"[{pluginName}] Waiting to reconnect to MQTT broker"])
time.sleep(0.1)
return True
if status == 0:
return True
mylog('debug', [f"[{pluginName}] Waiting to reconnect to MQTT broker (attempt {attempt + 1}/{_PUBLISH_MAX_ATTEMPTS})"])
time.sleep(_PUBLISH_RETRY_DELAY_SEC)
mylog('minimal', [f"[{pluginName}] ⚠ ERROR: Giving up on topic {topic} after {_PUBLISH_MAX_ATTEMPTS} attempts (status={status})."])
return False
# ------------------------------------------------------------------------------
@@ -433,6 +448,44 @@ def mqtt_create_client():
return mqtt_client
# -----------------------------------------------------------------------------
def build_device_id(mac):
"""Turn a device's MAC into the slug used as its Home Assistant deviceId
(topic/unique_id component) - e.g. 'AA:BB:CC' -> 'mac_aabbcc'."""
return 'mac_' + mac.replace(" ", "").replace(":", "_").lower()
# -----------------------------------------------------------------------------
def build_display_name(name):
"""Strip characters Home Assistant's entity naming doesn't accept from a
device's name, for use as its displayed sensor/device_tracker name."""
return re.sub('[^a-zA-Z0-9-_\\s]', '', normalize_string(name))
# -----------------------------------------------------------------------------
def build_device_tracker_attributes(device, devices, devDisplayName):
"""Build the shared JSON payload published both to a device's individual
sensor state topic and to its device_tracker's json_attributes_topic -
every key here becomes a Home Assistant entity attribute."""
return {
"last_ip": device["devLastIP"],
"is_new": str(device["devIsNew"]),
"alert_down": str(device["devAlertDown"]),
"vendor": sanitize_string(device["devVendor"]),
"mac_address": str(device["devMac"]),
"model": devDisplayName,
"last_connection": format_date_iso(str(device["devLastConnection"])),
"first_connection": format_date_iso(str(device["devFirstConnection"])),
"sync_node": device["devSyncHubNode"],
"group": device["devGroup"],
"location": device["devLocation"],
"ssid": device["devSSID"] if "devSSID" in device.keys() else "",
"vlan": device["devVlan"] if "devVlan" in device.keys() else "",
"network_parent_mac": device["devParentMAC"],
"network_parent_name": next((dev["devName"] for dev in devices if dev["devMac"] == device["devParentMAC"]), "")
}
# -----------------------------------------------------------------------------
def mqtt_start(db):
@@ -493,9 +546,8 @@ def mqtt_start(db):
# # debug statement END 🔺
# Create devices in Home Assistant - send config messages
deviceId = 'mac_' + device["devMac"].replace(" ", "").replace(":", "_").lower()
# Normalize the string and remove unwanted characters
devDisplayName = re.sub('[^a-zA-Z0-9-_\\s]', '', normalize_string(device["devName"]))
deviceId = build_device_id(device["devMac"])
devDisplayName = build_display_name(device["devName"])
sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'sensor', 'last_ip', 'ip-network', device["devMac"])
sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'sensor', 'mac_address', 'folder-key-network', device["devMac"])
@@ -506,21 +558,7 @@ def mqtt_start(db):
# handle device_tracker
# IMPORTANT: shared payload - device_tracker attributes and individual sensors
devJson = {
"last_ip": device["devLastIP"],
"is_new": str(device["devIsNew"]),
"alert_down": str(device["devAlertDown"]),
"vendor": sanitize_string(device["devVendor"]),
"mac_address": str(device["devMac"]),
"model": devDisplayName,
"last_connection": format_date_iso(str(device["devLastConnection"])),
"first_connection": format_date_iso(str(device["devFirstConnection"])),
"sync_node": device["devSyncHubNode"],
"group": device["devGroup"],
"location": device["devLocation"],
"network_parent_mac": device["devParentMAC"],
"network_parent_name": next((dev["devName"] for dev in devices if dev["devMac"] == device["devParentMAC"]), "")
}
devJson = build_device_tracker_attributes(device, devices, devDisplayName)
# bulk update device sensors in home assistant
publish_mqtt(mqtt_client, sensorConfig.state_topic, devJson) # REQUIRED, DON'T DELETE
+4 -1
View File
@@ -477,7 +477,10 @@
"Device_TableHead_IPv4",
"Device_TableHead_IPv6",
"Device_TableHead_Flapping",
"Device_TableHead_Comments"
"Device_TableHead_Comments",
"Device_TableHead_AlertEvents",
"Device_TableHead_CanSleep",
"Device_TableHead_StaticIP"
],
"localized": ["name", "description"],
"name": [
+7 -2
View File
@@ -572,17 +572,22 @@ def save_own_device(db):
# -------------------------------------------------------------------------------
def print_scan_stats(db):
"""
Log diagnostic scan-cycle counts (devices detected, new devices, down
alerts, new connections, disconnections, IP changes, per-plugin scan
method counts) at verbose/trace log levels. No return value.
"""
sql = db.sql # TO-DO
query = f"""
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 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 NOT EXISTS (SELECT 1 FROM CurrentScan WHERE scanMac = devMac)) AS 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)
AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE scanMac = devMac)
) 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,
+2 -2
View File
@@ -95,8 +95,8 @@ CREATE_EVENTS = """
CREATE_CURRENT_SCAN = """
CREATE TABLE IF NOT EXISTS CurrentScan (
scanMac TEXT,
scanLastIP TEXT,
scanMac TEXT COLLATE NOCASE,
scanLastIP TEXT COLLATE NOCASE,
scanVendor TEXT,
scanSourcePlugin TEXT,
scanName TEXT,
+277
View File
@@ -0,0 +1,277 @@
"""Tests for the _publisher_mqtt (MQTT) plugin's device_tracker attribute payload.
mqtt.py is loaded with its NetAlertX-internal dependencies (conf, const,
utils.plugin_utils, plugin_helper, logger, helper, database,
utils.datetime_utils, models.notification_instance) and the external
paho.mqtt.client/pytz packages stubbed out - same approach
test_wificanary.py/test_adguard_import.py use - so this runs without the full
devcontainer environment or a real MQTT broker. `sanitize_string`/
`format_date_iso` are reimplemented locally (same shape as helper's/
utils.datetime_utils') to avoid pulling in their own dependency chains.
Regression coverage for GitHub issue #1816: devSSID and devVlan were missing
from the JSON payload published to both a device's individual sensor state
topic and its device_tracker's json_attributes_topic (mqtt.py's
build_device_tracker_attributes(), extracted from mqtt_start() specifically
so this logic is testable without mocking the whole MQTT publish flow).
Also covers publish_mqtt()'s bounded retry: a failing publish() call used to
retry indefinitely (an unbounded `while status != 0` loop), which could burn
this plugin's entire RUN_TIMEOUT budget on one stuck call against a degraded
broker. It now gives up after _PUBLISH_MAX_ATTEMPTS.
"""
import importlib.util
import json
import os
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"])
def _sanitize_string(value):
"""Same shape as helper.sanitize_string, without its import chain."""
import re
return re.sub(r"[^a-zA-Z0-9-_\s]", "", str(value))
def _format_date_iso(value):
"""Same shape as utils.datetime_utils.format_date_iso, without its import chain."""
return value
def _bytes_to_string(value):
"""Same shape as helper.bytes_to_string, without its import chain."""
if isinstance(value, bytes):
value = value.decode("utf-8")
return value
def _load_mqtt_module():
missing_module = object()
previous_modules = {}
def stub(name, **attributes):
previous_modules[name] = sys.modules.get(name, missing_module)
module = types.ModuleType(name)
for attribute, value in attributes.items():
setattr(module, attribute, value)
sys.modules[name] = module
stub("conf", tz=None)
stub("const", confFileName="/tmp/app.conf", logPath="/tmp")
stub("utils.plugin_utils", getPluginObject=MagicMock())
stub("plugin_helper", Plugin_Objects=MagicMock)
stub("logger", mylog=MagicMock(), Logger=MagicMock())
stub(
"helper",
get_setting_value=MagicMock(return_value="UTC"),
bytes_to_string=_bytes_to_string,
sanitize_string=_sanitize_string,
normalize_string=lambda s: s,
)
stub("database", DB=MagicMock, get_device_stats=MagicMock())
stub("utils.datetime_utils", timeNowUTC=MagicMock(), format_date_iso=_format_date_iso)
stub("models.notification_instance", NotificationInstance=MagicMock)
stub("pytz", timezone=MagicMock(return_value="UTC"))
stub("paho", mqtt=types.ModuleType("paho.mqtt"))
stub("paho.mqtt", client=types.ModuleType("paho.mqtt.client"))
stub("paho.mqtt.client", Client=MagicMock)
module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "_publisher_mqtt" / "mqtt.py"
spec = importlib.util.spec_from_file_location("publisher_mqtt_script", module_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
finally:
for name, previous_module in previous_modules.items():
if previous_module is missing_module:
sys.modules.pop(name, None)
else:
sys.modules[name] = previous_module
return module
mqtt = _load_mqtt_module()
def _device(**overrides):
base = {
"devLastIP": "192.168.1.33",
"devIsNew": 0,
"devAlertDown": 0,
"devVendor": "Some Vendor",
"devMac": "44:ef:44:ef:44:ef",
"devLastConnection": "2026-01-01 00:00:00",
"devFirstConnection": "2025-01-01 00:00:00",
"devSyncHubNode": "",
"devGroup": "",
"devLocation": "",
"devSSID": "",
"devVlan": "",
"devParentMAC": "",
}
base.update(overrides)
return base
class TestBuildDeviceId:
def test_colons_become_underscores(self):
assert mqtt.build_device_id("AA:BB:CC:DD:EE:FF") == "mac_aa_bb_cc_dd_ee_ff"
def test_spaces_are_stripped_hyphens_are_kept(self):
assert mqtt.build_device_id("AA-BB-CC DD:EE:FF") == "mac_aa-bb-ccdd_ee_ff"
class TestBuildDisplayName:
def test_plain_name_unchanged(self):
assert mqtt.build_display_name("Living Room TV") == "Living Room TV"
def test_disallowed_punctuation_is_stripped(self):
assert mqtt.build_display_name("Bob's Phone!") == "Bobs Phone"
def test_hyphen_and_underscore_are_kept(self):
assert mqtt.build_display_name("office-printer_2") == "office-printer_2"
class TestToBinarySensor:
def test_int_at_least_one_is_on(self):
assert mqtt.to_binary_sensor(1) == "ON"
assert mqtt.to_binary_sensor(2) == "ON"
def test_int_zero_is_off(self):
assert mqtt.to_binary_sensor(0) == "OFF"
def test_float_at_least_one_is_on(self):
assert mqtt.to_binary_sensor(1.0) == "ON"
def test_bool_true_is_on(self):
assert mqtt.to_binary_sensor(True) == "ON"
def test_bool_false_is_off(self):
assert mqtt.to_binary_sensor(False) == "OFF"
def test_string_one_is_on(self):
assert mqtt.to_binary_sensor("1") == "ON"
def test_other_strings_are_off(self):
assert mqtt.to_binary_sensor("0") == "OFF"
assert mqtt.to_binary_sensor("yes") == "OFF"
def test_bytes_one_is_on(self):
assert mqtt.to_binary_sensor(b"1") == "ON"
def test_bytes_other_is_off(self):
assert mqtt.to_binary_sensor(b"0") == "OFF"
def test_none_is_off(self):
assert mqtt.to_binary_sensor(None) == "OFF"
class TestBuildDeviceTrackerAttributes:
def test_ssid_and_vlan_are_included(self):
device = _device(devSSID="HomeWiFi", devVlan="10")
attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device")
assert attrs["ssid"] == "HomeWiFi"
assert attrs["vlan"] == "10"
def test_blank_ssid_and_vlan_pass_through_unchanged(self):
device = _device(devSSID="", devVlan="")
attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device")
assert attrs["ssid"] == ""
assert attrs["vlan"] == ""
def test_existing_keys_still_present(self):
"""Regression guard: the extraction in #1816 must not drop any of
the pre-existing payload keys."""
device = _device()
attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device")
for key in (
"last_ip", "is_new", "alert_down", "vendor", "mac_address", "model",
"last_connection", "first_connection", "sync_node", "group", "location",
"network_parent_mac", "network_parent_name",
):
assert key in attrs, f"missing pre-existing key: {key}"
def test_network_parent_name_resolved_from_devices_list(self):
parent = _device(devMac="aa:bb:cc:dd:ee:ff")
parent["devName"] = "Router"
child = _device(devParentMAC="aa:bb:cc:dd:ee:ff")
attrs = mqtt.build_device_tracker_attributes(child, [parent, child], "Child Device")
assert attrs["network_parent_name"] == "Router"
def test_vendor_is_sanitized_but_ssid_is_not(self):
"""vendor goes through sanitize_string() (OUI-lookup text); ssid does
not, since real-world SSIDs commonly contain punctuation that
sanitize_string() would strip out, mangling the displayed value."""
device = _device(devVendor="TP-Link!", devSSID="Bob's WiFi!")
attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device")
assert attrs["vendor"] == "TP-Link"
assert attrs["ssid"] == "Bob's WiFi!"
def test_missing_ssid_and_vlan_columns_default_to_blank(self):
"""MQTT_DEVICES_SQL is a free-text user setting (default SELECT *,
but users can save a narrower custom query). A pre-#1816 custom query
that doesn't select devSSID/devVlan must not crash the whole device
loop - it should fall back to blank for just those two fields."""
device = _device()
del device["devSSID"]
del device["devVlan"]
attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device")
assert attrs["ssid"] == ""
assert attrs["vlan"] == ""
class TestPublishMqttBoundedRetry:
def _client(self, publish_return):
client = MagicMock()
client.publish.return_value = publish_return
return client
def test_succeeds_immediately_on_first_try(self):
mqtt.mqtt_connected_to_broker = True
client = self._client((0, 1))
with patch.object(mqtt.time, "sleep") as mock_sleep:
assert mqtt.publish_mqtt(client, "topic", "payload") is True
assert client.publish.call_count == 1
mock_sleep.assert_not_called()
def test_retries_then_succeeds(self):
mqtt.mqtt_connected_to_broker = True
client = MagicMock()
client.publish.side_effect = [(1, 1), (1, 1), (0, 1)]
with patch.object(mqtt.time, "sleep"):
assert mqtt.publish_mqtt(client, "topic", "payload") is True
assert client.publish.call_count == 3
def test_gives_up_after_max_attempts_instead_of_hanging_forever(self):
"""The regression this guards against: a broker that always rejects
the publish must not spin the caller indefinitely."""
mqtt.mqtt_connected_to_broker = True
client = self._client((1, 1)) # always fails
with patch.object(mqtt.time, "sleep") as mock_sleep:
assert mqtt.publish_mqtt(client, "topic", "payload") is False
assert client.publish.call_count == mqtt._PUBLISH_MAX_ATTEMPTS
assert mock_sleep.call_count == mqtt._PUBLISH_MAX_ATTEMPTS
def test_aborts_immediately_when_not_connected(self):
mqtt.mqtt_connected_to_broker = False
client = self._client((0, 1))
assert mqtt.publish_mqtt(client, "topic", "payload") is False
client.publish.assert_not_called()
def test_dict_payload_with_apostrophe_serializes_to_valid_json(self):
"""Regression guard: a prior post-serialization .replace("'", '"')
corrupted any string value containing an apostrophe (e.g. an SSID
like "Bob's WiFi!") into invalid JSON. json.dumps() output must be
published unmodified."""
mqtt.mqtt_connected_to_broker = True
client = self._client((0, 1))
mqtt.publish_mqtt(client, "topic", {"ssid": "Bob's WiFi!"})
published_payload = client.publish.call_args.kwargs["payload"]
assert json.loads(published_payload) == {"ssid": "Bob's WiFi!"}
+105
View File
@@ -0,0 +1,105 @@
"""
Regression coverage for GitHub issue #1818: print_scan_stats() (device_handling.py)
undercounted "down_alerts"/"new_down_alerts" whenever a device's stored MAC and its
CurrentScan MAC differed only by case.
DevicesView exposes devMac as LOWER(IFNULL(devMac, '')) - a function-derived VIEW
column, not a bare table column. print_scan_stats()'s down-alert subqueries compared
it against CurrentScan.scanMac as `devMac = scanMac` (DevicesView column on the LEFT),
which - unlike a bare COLLATE NOCASE column comparison - loses NOCASE collation and
becomes a case-sensitive byte comparison. Reversing the operand order to
`scanMac = devMac` restores NOCASE collation (SQLite gives precedence to the left
operand's column collation; scanMac is a real COLLATE-free CurrentScan column but the
comparison still resolves via devMac's declared NOCASE affinity once devMac is on the
right, verified empirically against a real SQLite instance built from the actual
DevicesView definition - see server/scan/device_handling.py print_scan_stats()).
This function is diagnostic/verbose-log-only (mylog("verbose"/"trace", ...), no other
consumer) - it does not drive real "Device Down" notifications, which go through
session_events.insert_events() -> presence.current_scan_presence_condition() instead
and were already safe (scanMac on the left there). This test covers the diagnostic
counter only.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from db_test_helpers import ( # noqa: E402
DummyDB,
make_device_dict,
insert_device_from_dict,
make_current_scan_dict,
insert_current_scan_row_from_dict,
)
import scan.device_handling as device_handling # noqa: E402
from scan.device_handling import print_scan_stats # noqa: E402
def _run_stats(conn, monkeypatch):
"""Call print_scan_stats() and return the logged down_alerts/new_down_alerts
counts, parsed straight out of its own mylog("verbose", ...) calls - this
exercises the real function/query rather than a re-derived copy of it."""
logged = {}
def fake_mylog(level, parts):
"""Stand in for logger.mylog: parse the down_alerts/new_down_alerts
counts straight out of print_scan_stats()'s own log lines."""
text = "".join(parts) if isinstance(parts, list) else str(parts)
if "Down Alerts" in text and "New Down Alerts" not in text:
logged["down_alerts"] = int(text.rsplit(":", 1)[1].strip())
elif "New Down Alerts" in text:
logged["new_down_alerts"] = int(text.rsplit(":", 1)[1].strip())
monkeypatch.setattr(device_handling, "mylog", fake_mylog)
db = DummyDB(conn)
print_scan_stats(db)
return logged
class TestScanStatsMacCase:
def test_matching_mac_different_case_is_not_a_false_down_alert(self, scan_db, monkeypatch):
"""Device stored lowercase, present this cycle under an uppercase MAC
(e.g. a plugin like FREEBOX reporting uppercase) must not be counted
as down - this is the exact scenario from issue #1818."""
device = make_device_dict(
mac="54:ef:44:9a:b6:7f",
devAlertDown=1,
devCanSleep=0,
devPresentLastScan=1,
)
insert_device_from_dict(scan_db, device)
current_scan = make_current_scan_dict(mac="54:EF:44:9A:B6:7F")
insert_current_scan_row_from_dict(scan_db, current_scan)
stats = _run_stats(scan_db, monkeypatch)
assert stats["down_alerts"] == 0
assert stats["new_down_alerts"] == 0
def test_truly_absent_device_is_still_counted_as_down(self, scan_db, monkeypatch):
"""Sanity check: a device genuinely missing from CurrentScan this
cycle must still be counted - the fix must not mask real down alerts.
An unrelated CurrentScan row is seeded too: print_scan_stats() GROUPs
BY scanSourcePlugin, so an empty CurrentScan produces zero result rows
and print_scan_stats() falls back to its hardcoded all-zero stub - that
would make this assertion pass for the wrong reason."""
device = make_device_dict(
mac="54:ef:44:9a:b6:7f",
devAlertDown=1,
devCanSleep=0,
devPresentLastScan=1,
)
insert_device_from_dict(scan_db, device)
other_scan = make_current_scan_dict(mac="11:22:33:44:55:66")
insert_current_scan_row_from_dict(scan_db, other_scan)
stats = _run_stats(scan_db, monkeypatch)
assert stats["down_alerts"] == 1
assert stats["new_down_alerts"] == 1