From 4aa2a0dddb1c38463ba5f139c85d38db2013cbde Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Mon, 28 Sep 2026 14:40:11 +1000 Subject: [PATCH] BE+FE: System info -> Performance section --- docs/PERFORMANCE.md | 22 +- docs/img/PERFORMANCE/db_size_check.png | Bin 62290 -> 33986 bytes front/css/app.css | 2 +- front/js/graph_resource_history.js | 162 +++++++ front/maintenance.php | 61 --- front/php/templates/header.php | 3 + front/php/templates/language/ar_ar.json | 12 + front/php/templates/language/ca_ca.json | 12 + front/php/templates/language/cs_cz.json | 14 +- front/php/templates/language/de_de.json | 12 + front/php/templates/language/en_us.json | 12 + front/php/templates/language/es_es.json | 12 + front/php/templates/language/fa_fa.json | 12 + front/php/templates/language/fi_fi.json | 12 + front/php/templates/language/fr_fr.json | 12 + front/php/templates/language/he_il.json | 12 + front/php/templates/language/hu_hu.json | 12 + front/php/templates/language/id_id.json | 12 + front/php/templates/language/it_it.json | 12 + front/php/templates/language/ja_jp.json | 12 + front/php/templates/language/nb_no.json | 12 + front/php/templates/language/pl_pl.json | 12 + front/php/templates/language/pt_br.json | 12 + front/php/templates/language/pt_pt.json | 12 + front/php/templates/language/ru_ru.json | 12 + front/php/templates/language/sv_sv.json | 12 + front/php/templates/language/tr_tr.json | 12 + front/php/templates/language/uk_ua.json | 12 + front/php/templates/language/vi_vn.json | 12 + front/php/templates/language/zh_cn.json | 12 + .../skel_tab_sysinfo_performance.php | 10 + front/systeminfo.php | 15 + front/systeminfoPerformance.php | 65 +++ front/systeminfoStorage.php | 48 +++ server/__main__.py | 81 ++-- server/api.py | 8 + server/api_server/api_server_start.py | 2 +- server/api_server/health_endpoint.py | 44 ++ server/api_server/openapi/schemas.py | 6 +- server/const.py | 39 ++ server/database.py | 4 + server/db/db_upgrade.py | 23 + server/db/schema/app.sql | 11 + server/plugins/db_cleanup/script.py | 13 + server/plugins/maintenance/README.md | 2 +- server/plugins/maintenance/config.json | 28 ++ server/scan/resource_history.py | 92 ++++ test/db/test_db_cleanup.py | 71 +++ test/db/test_resource_history_rollup.py | 84 ++++ test/scan/test_resource_history.py | 408 ++++++++++++++++++ test/server/test_health_endpoint_process.py | 51 +++ 51 files changed, 1548 insertions(+), 97 deletions(-) create mode 100644 front/js/graph_resource_history.js create mode 100644 front/php/templates/skel_tab_sysinfo_performance.php create mode 100644 front/systeminfoPerformance.php create mode 100644 server/scan/resource_history.py create mode 100644 test/db/test_resource_history_rollup.py create mode 100644 test/scan/test_resource_history.py create mode 100644 test/server/test_health_endpoint_process.py diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index adb192c3c..a3008f70d 100755 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -16,7 +16,7 @@ The application performs regular maintenance and database cleanup. If these task ### Database and Log File Size -A large database or oversized log files can impact performance. You can check database and table sizes on the **Maintenance** page. +A large database or oversized log files can impact performance. You can check database and table sizes on the **System Info → Storage** page. ![DB size check](./img/PERFORMANCE/db_size_check.png) @@ -32,6 +32,26 @@ Please note that excessively large log files will increase memory consumption. D --- +## Tracking NetAlertX's Own Resource Usage Over Time + +The **System Info → Performance** page graphs NetAlertX's own process CPU%, memory, disk I/O, and scan duration over hour/day/week/month ranges, sampled once per scan cycle. This turns "did a recent change make things slower" into a question you can answer by looking at real history, instead of guessing from what a change *should* cost. + +**What each chart shows:** + +* **CPU %** - average CPU utilisation over the scan cycle (`100%` = one fully-utilized CPU core; can exceed `100%` with multi-threaded work). This is an average over the whole cycle, not an instantaneous reading - a short burst of high usage followed by idle time reads as a lower percentage than the peak. +* **Memory Usage** - resident memory (RSS - the physical RAM the process currently holds), in MB, sampled once per cycle. +* **IO Read / IO Write** - bytes read/written by the NetAlertX process itself during that cycle, in MB. This only ever reflects NetAlertX's own direct I/O (its own DB/cache writes) - a plugin's own subprocess doing heavy disk I/O (e.g. a scanner writing its own result file) is not captured; this is a structural limitation of how the OS attributes child-process I/O to the parent, not a gap that can be closed later. +* **Scan Duration** - wall-clock time the scan cycle took, in seconds. + +> [!NOTE] +> The IO numbers come from the kernel's own per-process I/O accounting (`/proc//io`), which the kernel itself documents as an approximation. On some Docker storage drivers this can be badly inaccurate - confirmed on the deprecated `aufs` driver (common on older Synology NAS Docker setups), where union-filesystem copy-up writes get mis-attributed and can report write volumes many times larger than what's actually reaching disk (`docker info | grep -i "storage driver"` shows which driver you're on; `aufs` is a known-bad case, alongside its own `blkio throttle` warnings confirming the kernel's block-I/O accounting isn't properly wired up for it). If the IO chart shows large, consistent write numbers that don't match actual database growth (check the DB/WAL size on **System Info → Storage**), that's this known driver limitation, not a real cost. Newer Docker storage drivers (`overlay2`) don't have this problem. + +* Controlled by the `MAINT_PERF_DAYS` setting (part of the `MAINT` plugin) - the number of days of history to retain, `0` disables collection. Defaults to `30`. +* History cannot be backfilled - data starts accumulating from whenever collection is enabled. +* `GET /health` also exposes live `process_cpu_pct`/`process_rss_mb` fields for external monitoring, independent of this history. + +--- + ## Maintenance Plugins Two plugins help maintain the system’s performance: diff --git a/docs/img/PERFORMANCE/db_size_check.png b/docs/img/PERFORMANCE/db_size_check.png index e3886e5e4d51874abec2f1624b047132ec62927a..a62b40379efdcaa88db88726a567f1a4e0fc67e6 100755 GIT binary patch literal 33986 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 MB and ms -> s: raw units from the DB aren't a readable axis scale. + ioReadData.push(entry.resIoReadBytes != null ? round2(entry.resIoReadBytes / (1024 * 1024)) : null); + ioWriteData.push(entry.resIoWriteBytes != null ? round2(entry.resIoWriteBytes / (1024 * 1024)) : null); + durationData.push(entry.resScanDurationMs != null ? round2(entry.resScanDurationMs / 1000) : null); + tickFailed.push(entry.resTickFailed == 1); + }); + + renderResourceHistoryCharts(labels, cpuData, rssData, ioReadData, ioWriteData, durationData, tickFailed); + }).fail(function () { + console.error('Error fetching resource history data.'); + }); +} + +/** + * Builds/replaces the four small-multiple Resource_History charts (CPU%, + * memory usage MB, IO read+write MB, scan duration seconds) on the same time + * axis. Rows written from the tick-failure `finally` path (resTickFailed = 1) + * are rendered as visually distinct points rather than plain data, since + * their numbers may reflect a truncated, crash-adjacent sample. + */ +function renderResourceHistoryCharts(labels, cpuData, rssData, ioReadData, ioWriteData, durationData, tickFailed) { + var normalColor = "rgba(0, 166, 89, .8)"; + var failedColor = "#dd4b39"; + + function pointColors(baseColor) { + return tickFailed.map(function (failed) { return failed ? failedColor : baseColor; }); + } + + function destroyIfExists(key) { + if (resourceHistoryChartInstances[key]) { + resourceHistoryChartInstances[key].destroy(); + } + } + + var commonScales = { + yAxes: [{ + ticks: { beginAtZero: true, fontColor: '#A0A0A0' }, + gridLines: { color: "rgba(0, 0, 0, 0)" }, + }], + xAxes: [{ + ticks: { fontColor: '#A0A0A0' }, + gridLines: { color: "rgba(0, 0, 0, 0)" }, + }], + }; + + destroyIfExists('cpu'); + resourceHistoryChartInstances.cpu = new Chart("ResourceCpuChart", { + type: "line", + data: { + labels: labels, + datasets: [{ + label: getString("Systeminfo_Performance_CPU"), + data: cpuData, + borderColor: normalColor, + backgroundColor: "rgba(0, 166, 89, .2)", + pointBackgroundColor: pointColors(normalColor), + fill: true, + }], + }, + options: { legend: { display: true }, scales: commonScales, maintainAspectRatio: false, responsive: true }, + }); + + destroyIfExists('rss'); + resourceHistoryChartInstances.rss = new Chart("ResourceRssChart", { + type: "line", + data: { + labels: labels, + datasets: [{ + label: getString("Systeminfo_Performance_Memory"), + data: rssData, + borderColor: "#3c8dbc", + backgroundColor: "rgba(60, 141, 188, .2)", + pointBackgroundColor: pointColors("#3c8dbc"), + fill: true, + }], + }, + options: { legend: { display: true }, scales: commonScales, maintainAspectRatio: false, responsive: true }, + }); + + destroyIfExists('io'); + resourceHistoryChartInstances.io = new Chart("ResourceIoChart", { + type: "line", + data: { + labels: labels, + datasets: [ + { + label: getString("Systeminfo_Performance_IO_Read"), + data: ioReadData, + borderColor: "#00a65a", + pointBackgroundColor: pointColors("#00a65a"), + fill: false, + }, + { + label: getString("Systeminfo_Performance_IO_Write"), + data: ioWriteData, + borderColor: "#f39c12", + pointBackgroundColor: pointColors("#f39c12"), + fill: false, + }, + ], + }, + options: { legend: { display: true }, scales: commonScales, maintainAspectRatio: false, responsive: true }, + }); + + destroyIfExists('duration'); + resourceHistoryChartInstances.duration = new Chart("ResourceDurationChart", { + type: "line", + data: { + labels: labels, + datasets: [{ + label: getString("Systeminfo_Performance_Duration"), + data: durationData, + borderColor: "#b2b6be", + backgroundColor: "rgba(178, 182, 190, .2)", + pointBackgroundColor: pointColors("#b2b6be"), + fill: true, + }], + }, + options: { legend: { display: true }, scales: commonScales, maintainAspectRatio: false, responsive: true }, + }); +} diff --git a/front/maintenance.php b/front/maintenance.php index 650da5817..7a3d2b247 100755 --- a/front/maintenance.php +++ b/front/maintenance.php @@ -12,43 +12,6 @@ showSpinner(); -query($query); - -// Iterate over the tables and get the row counts -while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $tableName = $row['name']; - $query = "SELECT COUNT(*) FROM $tableName"; - $countResult = $db->querySingle($query); - $tableSizesHTML = $tableSizesHTML . "$tableName ($countResult), "; -} - -// Close the database connection -$db->close(); - - -?> -

@@ -85,30 +48,6 @@ $db->close();
-
-
-
- -
-
-
-
-
- (wal: ) -
-
-
-
-
- -
-
-
-
-
- -
-
diff --git a/front/php/templates/header.php b/front/php/templates/header.php index c606c24c2..481764115 100755 --- a/front/php/templates/header.php +++ b/front/php/templates/header.php @@ -474,6 +474,9 @@
  • +
  • + +
  • diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 080095b50..2b63a2e82 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -341,6 +341,7 @@ "Gen_Copy": "نسخ", "Gen_CopyToClipboard": "انسخ إلى الحافظة", "Gen_DataUpdatedUITakesTime": "تم تحديث البيانات. قد يستغرق تحديث واجهة المستخدم بعض الوقت", + "Gen_Day": "", "Gen_Delete": "حذف", "Gen_DeleteAll": "حذف الكل", "Gen_Description": "الوصف", @@ -349,9 +350,11 @@ "Gen_Filter": "تصفية", "Gen_Flapping": "", "Gen_Generate": "إنشاء", + "Gen_Hour": "", "Gen_InvalidMac": "عنوان MAC غير صالح.", "Gen_Invalid_Value": "تم إدخال قيمة غير صالحة", "Gen_LockedDB": "قاعدة البيانات مقفلة", + "Gen_Month": "", "Gen_NetworkMask": "قناع الشبكة", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "تحديث القيمة", "Gen_ValidIcon": "أيقونة صالحة", "Gen_Warning": "تحذير", + "Gen_Week": "", "Gen_Work_In_Progress": "جاري العمل...", "Gen_create_new_device": "إنشاء جهاز جديد", "Gen_create_new_device_info": "معلومات إنشاء جهاز جديد", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "استعلام الخادم", "Systeminfo_Network_Server_Query_String": "نص استعلام الخادم", "Systeminfo_Network_Server_Version": "إصدار الخادم", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "الخدمات", "Systeminfo_Services_Description": "وصف الخدمات", "Systeminfo_Services_Name": "اسم الخدمة", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index eb7bc0c18..ea763c977 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -341,6 +341,7 @@ "Gen_Copy": "Executar", "Gen_CopyToClipboard": "Copia a portapapers", "Gen_DataUpdatedUITakesTime": "D'acord - Pot passar una estona perquè la interfície d'usuari s'actualitzi si s'està executant una exploració.", + "Gen_Day": "", "Gen_Delete": "Esborrar", "Gen_DeleteAll": "Esborrar tot", "Gen_Description": "Descripció", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtrar", "Gen_Flapping": "Flapping", "Gen_Generate": "Generar", + "Gen_Hour": "", "Gen_InvalidMac": "Mac address invàlida.", "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_Month": "", "Gen_NetworkMask": "Màscara de xarxa", "Gen_New": "Nou", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Actualitzar Valor", "Gen_ValidIcon": "", "Gen_Warning": "Advertència", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Consulta del servidor:", "Systeminfo_Network_Server_Query_String": "Cap cadena de consulta", "Systeminfo_Network_Server_Version": "Versió del servidor:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Serveis", "Systeminfo_Services_Description": "Descripció del servei", "Systeminfo_Services_Name": "Nom del servei", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index c712856f6..8fd37258d 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -341,6 +341,7 @@ "Gen_Copy": "Spustit", "Gen_CopyToClipboard": "Zkopírovat do schránky", "Gen_DataUpdatedUITakesTime": "OK – může zabrat chvíli aktualizovat rozhraní, pokud probíhá sken.", + "Gen_Day": "", "Gen_Delete": "Smazat", "Gen_DeleteAll": "Smazat vše", "Gen_Description": "Popis", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtr", "Gen_Flapping": "Nestabilní", "Gen_Generate": "Nechat vytvořit", + "Gen_Hour": "", "Gen_InvalidMac": "Neplatná MAC adresa.", "Gen_Invalid_Value": "Zadána neplatná hodnota", "Gen_LockedDB": "CHYBA – databáze je možná zamčená – Podívejte se do F12 -> Nástroje pro vývojáře -> Konzole. Nebo to zkuste později.", + "Gen_Month": "", "Gen_NetworkMask": "Maska sítě", "Gen_New": "Nový", "Gen_Next": "Další", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Aktualizovat hodnotu", "Gen_ValidIcon": "", "Gen_Warning": "Varování", + "Gen_Week": "", "Gen_Work_In_Progress": "Rozpracováno, ideální čas pro zpětnou vazbu na https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Nové zařízení", "Gen_create_new_device_info": "Zařízení jsou vyhledávána pomocí zásuvných modulů. V určitých případech je však potřeba přidat zařízení ručně. Pokud chcete prozkoumat konkrétní scénáře, podívejte se do Dokumentace ke vzdáleným sítím.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Dotaz serveru:", "Systeminfo_Network_Server_Query_String": "Žádný řetězec dotazu", "Systeminfo_Network_Server_Version": "Verze serveru:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Služby", "Systeminfo_Services_Description": "Popis služby", "Systeminfo_Services_Name": "Název služby", @@ -840,4 +852,4 @@ "settings_system_label": "Systém", "settings_update_item_warning": "Zaktualizujte níže uvedenou hodnotu. Dávejte pozor, ať je dodržen předchozí formát. Ověřování správnosti není prováděno.", "test_event_tooltip": "Než budete svá nastavení zkoušet, nejprve vámi provedené změny uložte." -} +} \ 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 8e9b5d064..f88b3d39d 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -345,6 +345,7 @@ "Gen_Copy": "Ausführen", "Gen_CopyToClipboard": "In die Zwischenablage kopieren", "Gen_DataUpdatedUITakesTime": "OK – Es kann einen Moment dauern, bis die Benutzeroberfläche aktualisiert wird, während ein Scan ausgeführt wird.", + "Gen_Day": "", "Gen_Delete": "Löschen", "Gen_DeleteAll": "Alles löschen", "Gen_Description": "Beschreibung", @@ -353,9 +354,11 @@ "Gen_Filter": "Filter", "Gen_Flapping": "", "Gen_Generate": "Generieren", + "Gen_Hour": "", "Gen_InvalidMac": "Ungültige MAC-Adresse.", "Gen_Invalid_Value": "Ein ungültiger Wert wurde eingegeben", "Gen_LockedDB": "ERROR - DB eventuell gesperrt - Nutze die Konsole in den Entwickler Werkzeugen (F12) zur Überprüfung oder probiere es später erneut.", + "Gen_Month": "", "Gen_NetworkMask": "Netzmaske", "Gen_New": "Neu", "Gen_Next": "", @@ -387,6 +390,7 @@ "Gen_Update_Value": "Wert aktualisieren", "Gen_ValidIcon": "", "Gen_Warning": "Warnung", + "Gen_Week": "", "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.", @@ -773,6 +777,14 @@ "Systeminfo_Network_Server_Query": "Serverabfrage:", "Systeminfo_Network_Server_Query_String": "Keine Abfragezeichenfolge", "Systeminfo_Network_Server_Version": "Serverversion:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Dienste", "Systeminfo_Services_Description": "Dienstbeschreibung", "Systeminfo_Services_Name": "Dienstname", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index 64928c814..5776b9c2a 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -341,6 +341,7 @@ "Gen_Copy": "Run", "Gen_CopyToClipboard": "Copy to clipboard", "Gen_DataUpdatedUITakesTime": "OK - It may take a while for the UI to update if a scan is running.", + "Gen_Day": "Day", "Gen_Delete": "Delete", "Gen_DeleteAll": "Delete all", "Gen_Description": "Description", @@ -349,9 +350,11 @@ "Gen_Filter": "Filter", "Gen_Flapping": "Flapping", "Gen_Generate": "Generate", + "Gen_Hour": "Hour", "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_Month": "Month", "Gen_NetworkMask": "Network mask", "Gen_New": "New", "Gen_Next": "Next", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Update Value", "Gen_ValidIcon": "", "Gen_Warning": "Warning", + "Gen_Week": "Week", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Server query:", "Systeminfo_Network_Server_Query_String": "No query string", "Systeminfo_Network_Server_Version": "Server Version:", + "Systeminfo_Performance": "Performance", + "Systeminfo_Performance_CPU": "CPU %", + "Systeminfo_Performance_Disabled": "Resource history collection is currently disabled. Enable it in Settings.", + "Systeminfo_Performance_Duration": "Scan Duration (s)", + "Systeminfo_Performance_IO_Caption": "Direct NetAlertX process I/O only - plugin subprocess I/O (e.g. a scanner writing its own result file) is not included.", + "Systeminfo_Performance_IO_Read": "IO Read (MB)", + "Systeminfo_Performance_IO_Write": "IO Write (MB)", + "Systeminfo_Performance_Memory": "Memory Usage (MB)", "Systeminfo_Services": "Services", "Systeminfo_Services_Description": "Service Description", "Systeminfo_Services_Name": "Service Name", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 288679c17..e036bdef0 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -343,6 +343,7 @@ "Gen_Copy": "Ejecutar", "Gen_CopyToClipboard": "Copiar al portapapeles", "Gen_DataUpdatedUITakesTime": "Correcto - La interfaz puede tardar en actualizarse si se está ejecutando un escaneo.", + "Gen_Day": "", "Gen_Delete": "Eliminar", "Gen_DeleteAll": "Eliminar todo", "Gen_Description": "Descripción", @@ -351,9 +352,11 @@ "Gen_Filter": "Filtro", "Gen_Flapping": "Agitado", "Gen_Generate": "Generar", + "Gen_Hour": "", "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_Month": "", "Gen_NetworkMask": "Máscara de red", "Gen_New": "Nuevo", "Gen_Next": "", @@ -385,6 +388,7 @@ "Gen_Update_Value": "Actualizar valor", "Gen_ValidIcon": "", "Gen_Warning": "Advertencia", + "Gen_Week": "", "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.", @@ -772,6 +776,14 @@ "Systeminfo_Network_Server_Query": "Consulta del servidor:", "Systeminfo_Network_Server_Query_String": "Sin cadena de consulta", "Systeminfo_Network_Server_Version": "Versión del servidor:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Servicios", "Systeminfo_Services_Description": "Descripción del servicio", "Systeminfo_Services_Name": "Nombre del servicio", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index d1abab71a..c12073226 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/fi_fi.json b/front/php/templates/language/fi_fi.json index 1d27b99ae..e27e3e250 100644 --- a/front/php/templates/language/fi_fi.json +++ b/front/php/templates/language/fi_fi.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index d41d5726b..9a72e6edc 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -341,6 +341,7 @@ "Gen_Copy": "Lancer", "Gen_CopyToClipboard": "Copier vers le presse-papier", "Gen_DataUpdatedUITakesTime": "OK - cela peut prendre du temps à l'interface pour se mettre à jour si un scan est en cours.", + "Gen_Day": "", "Gen_Delete": "Supprimer", "Gen_DeleteAll": "Supprimer tous", "Gen_Description": "Description", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtrer", "Gen_Flapping": "Bagotage", "Gen_Generate": "Générer", + "Gen_Hour": "", "Gen_InvalidMac": "Adresse MAC invalide.", "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_Month": "", "Gen_NetworkMask": "Masque réseau", "Gen_New": "Nouveau", "Gen_Next": "Suivant", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Valeur à mettre à jour", "Gen_ValidIcon": "", "Gen_Warning": "Avertissement", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Requête du serveur :", "Systeminfo_Network_Server_Query_String": "Aucune chaîne de requête", "Systeminfo_Network_Server_Version": "Version du serveur :", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Services", "Systeminfo_Services_Description": "Description du service", "Systeminfo_Services_Name": "Nom du service", diff --git a/front/php/templates/language/he_il.json b/front/php/templates/language/he_il.json index 1d27b99ae..e27e3e250 100644 --- a/front/php/templates/language/he_il.json +++ b/front/php/templates/language/he_il.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/hu_hu.json b/front/php/templates/language/hu_hu.json index 1d27b99ae..e27e3e250 100644 --- a/front/php/templates/language/hu_hu.json +++ b/front/php/templates/language/hu_hu.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/id_id.json b/front/php/templates/language/id_id.json index 1d27b99ae..e27e3e250 100644 --- a/front/php/templates/language/id_id.json +++ b/front/php/templates/language/id_id.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 5eb6c30ed..d60d3e161 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -341,6 +341,7 @@ "Gen_Copy": "Esegui", "Gen_CopyToClipboard": "Copia negli appunti", "Gen_DataUpdatedUITakesTime": "OK: l'aggiornamento dell'interfaccia utente potrebbe richiedere del tempo se è in esecuzione una scansione.", + "Gen_Day": "", "Gen_Delete": "Elimina", "Gen_DeleteAll": "Elimina tutti", "Gen_Description": "Descrizione", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtro", "Gen_Flapping": "Flapping", "Gen_Generate": "Genera", + "Gen_Hour": "", "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_Month": "", "Gen_NetworkMask": "Maschera di rete", "Gen_New": "Nuovo", "Gen_Next": "Successivo", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Aggiorna valore", "Gen_ValidIcon": "", "Gen_Warning": "Avviso", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Interrogazione server:", "Systeminfo_Network_Server_Query_String": "Nessuna stringa di interrogazione", "Systeminfo_Network_Server_Version": "Versione server:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Servizi", "Systeminfo_Services_Description": "Descrizione servizio", "Systeminfo_Services_Name": "Nome servizio", diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index 0f4fd6500..bc61e88d0 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -341,6 +341,7 @@ "Gen_Copy": "実行", "Gen_CopyToClipboard": "クリップボードにコピー", "Gen_DataUpdatedUITakesTime": "OK - スキャン実行中は、UIの更新に時間がかかることがあります。", + "Gen_Day": "", "Gen_Delete": "削除", "Gen_DeleteAll": "全削除", "Gen_Description": "説明", @@ -349,9 +350,11 @@ "Gen_Filter": "フィルター", "Gen_Flapping": "フラッピング", "Gen_Generate": "生成", + "Gen_Hour": "", "Gen_InvalidMac": "無効なMacアドレス。", "Gen_Invalid_Value": "無効な値が入力されました", "Gen_LockedDB": "エラー - DBがロックされている可能性があります - F12で開発者ツール→コンソールを確認するか、後で試してください。", + "Gen_Month": "", "Gen_NetworkMask": "ネットワークマスク", "Gen_New": "New", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "更新値", "Gen_ValidIcon": "", "Gen_Warning": "警告", + "Gen_Week": "", "Gen_Work_In_Progress": "作業中、https://github.com/netalertx/NetAlertX/issues へのフィードバックの好機です", "Gen_create_new_device": "新規デバイス", "Gen_create_new_device_info": "デバイスは通常、プラグイン を使用して検出されます。ただし、特定のケースでは手動でデバイスを追加する必要がある場合があります。具体的なシナリオについては、リモートネットワークドキュメント を参照してください。", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "サーバークエリ:", "Systeminfo_Network_Server_Query_String": "クエリ文字列なし", "Systeminfo_Network_Server_Version": "サーバーバージョン:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "サービス", "Systeminfo_Services_Description": "サービス内容", "Systeminfo_Services_Name": "サービス名", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index 91ed3953f..5f951ca99 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -341,6 +341,7 @@ "Gen_Copy": "Kjør", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "OK - Det kan ta litt tid før brukergrensesnittet oppdateres hvis en skanning kjøres.", + "Gen_Day": "", "Gen_Delete": "Slett", "Gen_DeleteAll": "Slett alle", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "Filter", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "FEIL - DB kan være låst - Sjekk F12 Dev tools -> Konsoll eller prøv senere.", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Oppdater verdi", "Gen_ValidIcon": "", "Gen_Warning": "Advarsel", + "Gen_Week": "", "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": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Serverforespørsel:", "Systeminfo_Network_Server_Query_String": "Ingen spørringsstreng", "Systeminfo_Network_Server_Version": "Server versjon:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Tjenester", "Systeminfo_Services_Description": "Tjeneste beskrivelse", "Systeminfo_Services_Name": "Tjeneste navn", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 7fc79bcca..06e966ecc 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -341,6 +341,7 @@ "Gen_Copy": "Wykonaj", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "OK – Może to potrwać chwilę, zanim interfejs użytkownika się zaktualizuje, jeśli trwa skan.", + "Gen_Day": "", "Gen_Delete": "Usuń", "Gen_DeleteAll": "Usuń wszystko", "Gen_Description": "Opis", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtr", "Gen_Flapping": "", "Gen_Generate": "Wygeneruj", + "Gen_Hour": "", "Gen_InvalidMac": "", "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_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Aktualizuj wartość", "Gen_ValidIcon": "", "Gen_Warning": "Ostrzeżenie", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Zapytanie serwerowe:", "Systeminfo_Network_Server_Query_String": "Brak ciągu zapytania", "Systeminfo_Network_Server_Version": "Wersja serwera:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Usługi", "Systeminfo_Services_Description": "Opis usługi", "Systeminfo_Services_Name": "Nazwa usługi", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 78310fad7..307d35592 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -341,6 +341,7 @@ "Gen_Copy": "Executar", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "OK - Pode levar um tempo para a interface do usuário ser atualizada se uma verificação estiver em execução.", + "Gen_Day": "", "Gen_Delete": "Excluir", "Gen_DeleteAll": "Excluir todos", "Gen_Description": "Descrição", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtro", "Gen_Flapping": "", "Gen_Generate": "Gerar", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "ERRO - O banco de dados pode estar bloqueado - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Atualizar valor", "Gen_ValidIcon": "", "Gen_Warning": "Aviso", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index 3779e9f56..e4e55b06b 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -341,6 +341,7 @@ "Gen_Copy": "Executar", "Gen_CopyToClipboard": "Copiar para a área de transferência", "Gen_DataUpdatedUITakesTime": "OK - Pode levar um tempo para a interface do utilizador ser atualizada se uma verificação estiver em execução.", + "Gen_Day": "", "Gen_Delete": "Apagar", "Gen_DeleteAll": "Apagar todos", "Gen_Description": "Descrição", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtro", "Gen_Flapping": "Flapping", "Gen_Generate": "Gerar", + "Gen_Hour": "", "Gen_InvalidMac": "Endereço Mac inválido.", "Gen_Invalid_Value": "Um valor inválido foi inserido", "Gen_LockedDB": "ERRO - A base de dados pode estar bloqueada - Verifique F12 Ferramentas de desenvolvimento -> Console ou tente mais tarde.", + "Gen_Month": "", "Gen_NetworkMask": "Máscara de Rede", "Gen_New": "Novo", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Atualizar valor", "Gen_ValidIcon": "", "Gen_Warning": "Aviso", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Consulta de servidor:", "Systeminfo_Network_Server_Query_String": "Nenhuma string de consulta", "Systeminfo_Network_Server_Version": "Versão do Servidor:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Serviços", "Systeminfo_Services_Description": "Descrição do Serviço", "Systeminfo_Services_Name": "Nome do Serviço", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 4d82237d1..84c199428 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -341,6 +341,7 @@ "Gen_Copy": "Запустить", "Gen_CopyToClipboard": "Копировать в буфер обмена", "Gen_DataUpdatedUITakesTime": "ОК - Обновление UI может занять некоторое время, если сканирование выполняется.", + "Gen_Day": "", "Gen_Delete": "Удалить", "Gen_DeleteAll": "Удалить все", "Gen_Description": "Описание", @@ -349,9 +350,11 @@ "Gen_Filter": "Фильтр", "Gen_Flapping": "Нестабильный", "Gen_Generate": "Генерировать", + "Gen_Hour": "", "Gen_InvalidMac": "Неверный Mac-адрес.", "Gen_Invalid_Value": "Введено некорректное значение", "Gen_LockedDB": "ОШИБКА - Возможно, база данных заблокирована. Проверьте инструменты разработчика F12 -> Консоль или повторите попытку позже.", + "Gen_Month": "", "Gen_NetworkMask": "Маска сети", "Gen_New": "Новый", "Gen_Next": "Следующий", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Обновить значение", "Gen_ValidIcon": "", "Gen_Warning": "Предупреждение", + "Gen_Week": "", "Gen_Work_In_Progress": "Работа продолжается, самое время оставить отзыв на https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Новое устройство", "Gen_create_new_device_info": "Устройства обычно обнаруживаются с помощью плагинов. Однако в некоторых случаях вам может потребоваться добавить устройства вручную. Для изучения конкретных сценариев ознакомьтесь с документацией Remote Networks.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Запрос сервера:", "Systeminfo_Network_Server_Query_String": "Нет строки запроса", "Systeminfo_Network_Server_Version": "Версия сервера:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Службы", "Systeminfo_Services_Description": "Описание службы", "Systeminfo_Services_Name": "Имя службы", diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 1d27b99ae..e27e3e250 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 6f255d006..5ac870753 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -341,6 +341,7 @@ "Gen_Copy": "Çalıştır", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "TAMAM - Eğer bir tarama çalışıyorsa arayüzün güncellenmesi biraz zaman alabilir.", + "Gen_Day": "", "Gen_Delete": "Sil", "Gen_DeleteAll": "Tümünü sil", "Gen_Description": "Açıklama", @@ -349,9 +350,11 @@ "Gen_Filter": "Filtre", "Gen_Flapping": "", "Gen_Generate": "Oluştur", + "Gen_Hour": "", "Gen_InvalidMac": "", "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_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Değeri Güncelle", "Gen_ValidIcon": "", "Gen_Warning": "Uyarı", + "Gen_Week": "", "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.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index 13c598cd2..ec10abf90 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -341,6 +341,7 @@ "Gen_Copy": "Запустити", "Gen_CopyToClipboard": "Копіювати в буфер обміну", "Gen_DataUpdatedUITakesTime": "Добре. Оновлення інтерфейсу може зайняти деякий час, якщо сканування виконується.", + "Gen_Day": "", "Gen_Delete": "Видалити", "Gen_DeleteAll": "Видалити все", "Gen_Description": "Опис", @@ -349,9 +350,11 @@ "Gen_Filter": "Фільтр", "Gen_Flapping": "", "Gen_Generate": "Генерувати", + "Gen_Hour": "", "Gen_InvalidMac": "Недійсна Mac-адреса.", "Gen_Invalid_Value": "Введено недійсне значення", "Gen_LockedDB": "ПОМИЛКА – БД може бути заблоковано – перевірте F12 Інструменти розробника -> Консоль або спробуйте пізніше.", + "Gen_Month": "", "Gen_NetworkMask": "Маска мережі", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "Оновити значення", "Gen_ValidIcon": "", "Gen_Warning": "Попередження", + "Gen_Week": "", "Gen_Work_In_Progress": "Робота триває, час залишити відгук на https://github.com/netalertx/NetAlertX/issues", "Gen_create_new_device": "Новий пристрій", "Gen_create_new_device_info": "Пристрої зазвичай виявляють за допомогою плагінів. Однак у деяких випадках може знадобитися додати пристрої вручну. Щоб дослідити конкретні сценарії, перегляньте документацію щодо віддалених мереж.", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "Запит до сервера:", "Systeminfo_Network_Server_Query_String": "Немає рядка запиту", "Systeminfo_Network_Server_Version": "Версія сервера:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "Послуги", "Systeminfo_Services_Description": "Опис послуги", "Systeminfo_Services_Name": "Назва служби", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 1d27b99ae..e27e3e250 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -341,6 +341,7 @@ "Gen_Copy": "", "Gen_CopyToClipboard": "", "Gen_DataUpdatedUITakesTime": "", + "Gen_Day": "", "Gen_Delete": "", "Gen_DeleteAll": "", "Gen_Description": "", @@ -349,9 +350,11 @@ "Gen_Filter": "", "Gen_Flapping": "", "Gen_Generate": "", + "Gen_Hour": "", "Gen_InvalidMac": "", "Gen_Invalid_Value": "", "Gen_LockedDB": "", + "Gen_Month": "", "Gen_NetworkMask": "", "Gen_New": "", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "", "Gen_ValidIcon": "", "Gen_Warning": "", + "Gen_Week": "", "Gen_Work_In_Progress": "", "Gen_create_new_device": "", "Gen_create_new_device_info": "", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "", "Systeminfo_Network_Server_Query_String": "", "Systeminfo_Network_Server_Version": "", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "", "Systeminfo_Services_Description": "", "Systeminfo_Services_Name": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index 036da1f53..174f4521a 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -341,6 +341,7 @@ "Gen_Copy": "运行", "Gen_CopyToClipboard": "复制到剪贴板", "Gen_DataUpdatedUITakesTime": "好的 - 如果扫描正在运行,UI 可能需要一段时间才能更新。", + "Gen_Day": "", "Gen_Delete": "删除", "Gen_DeleteAll": "全部删除", "Gen_Description": "描述", @@ -349,9 +350,11 @@ "Gen_Filter": "筛选", "Gen_Flapping": "跳动", "Gen_Generate": "生成", + "Gen_Hour": "", "Gen_InvalidMac": "无效的 Mac 地址。", "Gen_Invalid_Value": "输入了无效的值", "Gen_LockedDB": "错误 - DB 可能被锁定 - 检查 F12 开发工具 -> 控制台或稍后重试。", + "Gen_Month": "", "Gen_NetworkMask": "网络掩码", "Gen_New": "新的", "Gen_Next": "", @@ -383,6 +386,7 @@ "Gen_Update_Value": "更新值", "Gen_ValidIcon": "", "Gen_Warning": "警告", + "Gen_Week": "", "Gen_Work_In_Progress": "工作正在进行中,欢迎在 https://github.com/netalertx/NetAlertX/issues 上反馈", "Gen_create_new_device": "新设备", "Gen_create_new_device_info": "通常使用plugins来发现设备。但是,在某些情况下,您可能需要手动添加设备。要探索特定场景,请查看远程网络文档。", @@ -712,6 +716,14 @@ "Systeminfo_Network_Server_Query": "服务器查询:", "Systeminfo_Network_Server_Query_String": "没有查询到字符串", "Systeminfo_Network_Server_Version": "服务器版本:", + "Systeminfo_Performance": "", + "Systeminfo_Performance_CPU": "", + "Systeminfo_Performance_Disabled": "", + "Systeminfo_Performance_Duration": "", + "Systeminfo_Performance_IO_Caption": "", + "Systeminfo_Performance_IO_Read": "", + "Systeminfo_Performance_IO_Write": "", + "Systeminfo_Performance_Memory": "", "Systeminfo_Services": "服务", "Systeminfo_Services_Description": "服务说明", "Systeminfo_Services_Name": "服务名称", diff --git a/front/php/templates/skel_tab_sysinfo_performance.php b/front/php/templates/skel_tab_sysinfo_performance.php new file mode 100644 index 000000000..f6a009f63 --- /dev/null +++ b/front/php/templates/skel_tab_sysinfo_performance.php @@ -0,0 +1,10 @@ +
    +
    + + + +
    + + + +
    diff --git a/front/systeminfo.php b/front/systeminfo.php index 0b6d668f1..915f06d81 100755 --- a/front/systeminfo.php +++ b/front/systeminfo.php @@ -15,6 +15,10 @@ ?> + + + +
    +
    +

    + +

    +
    +
    + +
    + + + + +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + +
    +

    +
    + +
    +
    + +
    +
    + + diff --git a/front/systeminfoStorage.php b/front/systeminfoStorage.php index 2211197c4..99eb47202 100755 --- a/front/systeminfoStorage.php +++ b/front/systeminfoStorage.php @@ -10,6 +10,54 @@ query("SELECT name FROM sqlite_master WHERE type='table'"); +while ($row = $table_names_result->fetchArray(SQLITE3_ASSOC)) { + $tableName = $row['name']; + $countResult = $db_info_conn->querySingle("SELECT COUNT(*) FROM $tableName"); + $tableSizesHTML = $tableSizesHTML . "$tableName ($countResult), "; +} +$db_info_conn->close(); + +echo '
    +
    +

    ' . lang('Maintenance_Status') . '

    +
    +
    +
    +
    +
    ' . lang('Maintenance_database_path') . '
    +
    ' . $nax_db . '
    +
    +
    +
    ' . lang('Maintenance_database_size') . '
    +
    ' . $nax_db_size . ' (wal: ' . $nax_wal_size . ')
    +
    +
    +
    ' . lang('Maintenance_database_rows') . '
    +
    ' . $tableSizesHTML . '
    +
    +
    +
    ' . lang('Maintenance_database_lastmod') . '
    +
    ' . $nax_db_mod . '
    +
    +
    +
    +
    '; + // ---------------------------------------------------------- // Storage // ---------------------------------------------------------- diff --git a/server/__main__.py b/server/__main__.py index 9f367e268..3218927c3 100755 --- a/server/__main__.py +++ b/server/__main__.py @@ -25,11 +25,12 @@ from pathlib import Path import conf from const import fullConfPath, sql_new_devices from logger import mylog -from helper import filePermissions +from helper import filePermissions, get_setting_value from utils.datetime_utils import timeNowUTC, is_datetime_future, normalizeTimeStamp from app_state import updateState from api import update_api, check_activity, update_GUI_port from scan.session_events import process_scan +from scan.resource_history import get_process_cpu_times_and_io, insert_resource_history from initialise import importConfigs, renameSettings from database import DB from messaging.reporting import get_notifications @@ -149,43 +150,63 @@ def main(): # last time any scan or maintenance/upkeep was run conf.last_scan_run = loop_start_time - # 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="") + # Resource-usage sampling: brackets the whole schedule-tick block + # below (run_plugin_scripts("schedule") through process_scan()), + # not just the scan pipeline itself - plugin subprocess execution + # happens in this window and is the class of cost this history + # exists to catch. Gated on MAINT_PERF_DAYS so a disabled install + # pays no sampling cost at all (see scan/resource_history.py). + resource_history_enabled = int(get_setting_value("MAINT_PERF_DAYS", 30)) != 0 + resource_pre = get_process_cpu_times_and_io() if resource_history_enabled else None + tick_start_monotonic = time.monotonic() + tick_failed = False - # Timestamp - startTime = loop_start_time - startTime = startTime.replace(microsecond=0) + try: + # 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="") - # Check if any plugins need to run on schedule - pm.run_plugin_scripts("schedule") + # Timestamp + startTime = loop_start_time + startTime = startTime.replace(microsecond=0) - # 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] + # Check if any plugins need to run on schedule + pm.run_plugin_scripts("schedule") - # 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()) + # 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] - # determine run/scan type based on passed time - # -------------------------------------------- + # 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()) - # Runs plugin scripts which are set to run every time after a scans finished - pm.run_plugin_scripts("always_after_scan") + # determine run/scan type based on passed time + # -------------------------------------------- - # process all the scanned data into new devices - processScan = updateState("Check scan").processScan - mylog("debug", [f"[MAIN] processScan: {processScan}"]) + # Runs plugin scripts which are set to run every time after a scans finished + pm.run_plugin_scripts("always_after_scan") - if processScan is True: - mylog("debug", "[MAIN] start processing scan results") - process_scan(db) - updateState("Scan processed", None, None, None, None, False) + # process all the scanned data into new devices + processScan = updateState("Check scan").processScan + mylog("debug", [f"[MAIN] processScan: {processScan}"]) + + if processScan is True: + mylog("debug", "[MAIN] start processing scan results") + process_scan(db) + updateState("Scan processed", None, None, None, None, False) + except Exception: + tick_failed = True + raise + finally: + if resource_history_enabled: + duration_ms = int((time.monotonic() - tick_start_monotonic) * 1000) + resource_post = get_process_cpu_times_and_io() + insert_resource_history(db, resource_pre, resource_post, duration_ms, tick_failed) # Name resolution # -------------------------------------------- diff --git a/server/api.py b/server/api.py index b65d23e1c..bf9055e14 100755 --- a/server/api.py +++ b/server/api.py @@ -21,6 +21,10 @@ from const import ( sql_language_strings, sql_notifications_all, sql_online_history, + sql_resource_history_hour, + sql_resource_history_day, + sql_resource_history_week, + sql_resource_history_month, sql_devices_filters, defaultWebPort, ) @@ -75,6 +79,10 @@ def update_api( ["plugins_language_strings", sql_language_strings], ["notifications", sql_notifications_all], ["online_history", sql_online_history], + ["resource_history_hour", sql_resource_history_hour], + ["resource_history_day", sql_resource_history_day], + ["resource_history_week", sql_resource_history_week], + ["resource_history_month", sql_resource_history_month], ["devices_tiles", get_sql_devices_tiles()], ["devices_filters", sql_devices_filters], ["custom_endpoint", conf.API_CUSTOM_SQL], diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index c49f05dbd..7ca8afbf7 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -2058,7 +2058,7 @@ def check_auth(payload=None): @validate_request( operation_id="check_health", summary="System Health Check", - description="Retrieve system vitality metrics including database size, memory pressure, system load, disk usage, and CPU temperature.", + description="Retrieve system vitality metrics including database size, memory pressure, system load, disk usage, CPU temperature, and the NetAlertX process's own CPU%/RSS.", response_model=HealthCheckResponse, tags=["system", "health"], auth_callable=is_authorized diff --git a/server/api_server/health_endpoint.py b/server/api_server/health_endpoint.py index 322d8e941..d9c0bf89b 100644 --- a/server/api_server/health_endpoint.py +++ b/server/api_server/health_endpoint.py @@ -155,6 +155,48 @@ def get_storage_gb(): return None +# =============================================================================== +# Process Vitality (NetAlertX's own process, not the host) +# =============================================================================== + +# Non-blocking cpu_percent() needs one persistent Process object to mean anything +# ("since the last time this was called") - this object is dedicated to /health only, +# never shared with the tick-scoped sampler in scan/resource_history.py, so the two +# measurement paths can't interfere with each other. +_health_process = psutil.Process() + + +def get_process_cpu_percent(): + """ + CPU% for the NetAlertX process since the last time this was called - a live + gauge for /health, not a tick-scoped measurement (see scan/resource_history.py's + tick-scoped sampler for that). + + Returns: + float: CPU utilization percentage, or 0.0 on error. + """ + try: + return _health_process.cpu_percent(interval=None) + except (psutil.Error, AttributeError) as e: + mylog("verbose", [f"[health] Error reading process CPU%: {e}"]) + return 0.0 + + +def get_process_rss_mb(): + """ + Resident memory (RSS) for the current process, in MB - a point-in-time + snapshot, not a delta (RSS doesn't accumulate the way CPU-time/IO do). + + Returns: + float: RSS in MB, or 0.0 on error. + """ + try: + return round(psutil.Process().memory_info().rss / (1024 * 1024), 2) + except (psutil.Error, AttributeError) as e: + mylog("verbose", [f"[health] Error reading process RSS: {e}"]) + return 0.0 + + # =============================================================================== # Aggregator # =============================================================================== @@ -174,4 +216,6 @@ def get_health_status(): "cpu_temp": get_cpu_temp(), "storage_gb": get_storage_gb(), "mem_mb": get_mem_mb(), + "process_cpu_pct": get_process_cpu_percent(), + "process_rss_mb": get_process_rss_mb(), } diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index a579e5067..edec8c58a 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -718,7 +718,9 @@ class HealthCheckResponse(BaseResponse): "storage_pct": 42, "cpu_temp": 58, "storage_gb": 8, - "mem_mb" : 8192 + "mem_mb" : 8192, + "process_cpu_pct": 3.2, + "process_rss_mb": 142.75 }] } ) @@ -730,6 +732,8 @@ class HealthCheckResponse(BaseResponse): cpu_temp: Optional[int] = Field(None, description="CPU temperature in Celsius (nullable if unavailable)") storage_gb: Optional[int] = Field(..., description="Storage size in GB") mem_mb: Optional[int] = Field(..., description="Installed memory size in MB") + process_cpu_pct: float = Field(..., description="NetAlertX process CPU usage percentage since the last /health call (live gauge, not a tick average)") + process_rss_mb: float = Field(..., description="NetAlertX process resident memory (RSS) in MB") # ============================================================================= diff --git a/server/const.py b/server/const.py index 30e91be5b..067b194b0 100755 --- a/server/const.py +++ b/server/const.py @@ -136,6 +136,45 @@ sql_plugins_stats = """SELECT 'objects' AS tableName, plugin, COUNT(*) AS cnt FR sql_language_strings = "SELECT * FROM Plugins_Language_Strings" sql_notifications_all = "SELECT * FROM Notifications" sql_online_history = "SELECT * FROM Online_History" + +# Resource_History read-side views (System Info -> Performance tab). +# hour/day return raw per-tick rows (cheap indexed range scan); week/month +# roll up at query time into hourly buckets (SUM for additive IO bytes, +# AVG for CPU%/RSS/duration) - no pre-aggregated rollup table, see +# resource-usage-history PRD Design §4. Deltas are already stored per-row +# by insert_resource_history(), so no LAG()/window function is needed here. +sql_resource_history_hour = """ + SELECT resDateTime, resCpuPercent, resRssMb, resIoReadBytes, + resIoWriteBytes, resScanDurationMs, resTickFailed + FROM Resource_History + WHERE resDateTime >= datetime('now', '-1 hour') + ORDER BY resDateTime + """ +sql_resource_history_day = """ + SELECT resDateTime, resCpuPercent, resRssMb, resIoReadBytes, + resIoWriteBytes, resScanDurationMs, resTickFailed + FROM Resource_History + WHERE resDateTime >= datetime('now', '-1 day') + ORDER BY resDateTime + """ + + +sql_resource_history_bucketed_template = """ + SELECT + strftime('%Y-%m-%d %H:00:00', resDateTime) AS bucket, + AVG(resCpuPercent) AS resCpuPercent, + AVG(resRssMb) AS resRssMb, + SUM(resIoReadBytes) AS resIoReadBytes, + SUM(resIoWriteBytes) AS resIoWriteBytes, + AVG(resScanDurationMs) AS resScanDurationMs, + MAX(resTickFailed) AS resTickFailed + FROM Resource_History + WHERE resDateTime >= datetime('now', '-{days} day') + GROUP BY bucket + ORDER BY bucket + """ +sql_resource_history_week = sql_resource_history_bucketed_template.format(days=7) +sql_resource_history_month = sql_resource_history_bucketed_template.format(days=30) sql_plugins_events = "SELECT * FROM Plugins_Events" sql_plugins_history = "SELECT * FROM Plugins_History ORDER BY dateTimeChanged DESC" sql_new_devices = """SELECT * FROM ( diff --git a/server/database.py b/server/database.py index ba66ef14d..f7ef04516 100755 --- a/server/database.py +++ b/server/database.py @@ -12,6 +12,7 @@ from db.db_upgrade import ( ensure_column, ensure_table_columns, ensure_CurrentScan, + ensure_Resource_History, ensure_plugins_tables, ensure_Parameters, ensure_Settings, @@ -241,6 +242,9 @@ class DB: # CurrentScan table setup ensure_CurrentScan(self.sql) + # Resource_History table setup + ensure_Resource_History(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. diff --git a/server/db/db_upgrade.py b/server/db/db_upgrade.py index 4de9357b6..dc5307d65 100755 --- a/server/db/db_upgrade.py +++ b/server/db/db_upgrade.py @@ -659,6 +659,29 @@ def ensure_CurrentScan(sql) -> bool: return True +def ensure_Resource_History(sql) -> bool: + """ + Ensures the Resource_History table and its datetime index exist. + + Parameters: + - sql: database cursor or connection wrapper (must support execute() and fetchall()). + """ + sql.execute(""" CREATE TABLE IF NOT EXISTS Resource_History ( + "index" INTEGER PRIMARY KEY AUTOINCREMENT, + resDateTime TEXT NOT NULL, + resCpuPercent REAL, + resRssMb REAL, + resIoReadBytes INTEGER, + resIoWriteBytes INTEGER, + resScanDurationMs INTEGER, + resTickFailed INTEGER NOT NULL DEFAULT 0 + ); + """) + sql.execute("CREATE INDEX IF NOT EXISTS idx_reshist_datetime ON Resource_History(resDateTime);") + + return True + + def ensure_Parameters(sql) -> bool: """ Ensures required Parameters table exist. diff --git a/server/db/schema/app.sql b/server/db/schema/app.sql index bdbb55e3b..a7b80db24 100644 --- a/server/db/schema/app.sql +++ b/server/db/schema/app.sql @@ -10,6 +10,17 @@ CREATE TABLE IF NOT EXISTS Online_History ( offlineDevices INTEGER, PRIMARY KEY("index" AUTOINCREMENT) ); +CREATE TABLE IF NOT EXISTS Resource_History ( + "index" INTEGER PRIMARY KEY AUTOINCREMENT, + resDateTime TEXT NOT NULL, + resCpuPercent REAL, + resRssMb REAL, + resIoReadBytes INTEGER, + resIoWriteBytes INTEGER, + resScanDurationMs INTEGER, + resTickFailed INTEGER NOT NULL DEFAULT 0 + ); +CREATE INDEX IF NOT EXISTS idx_reshist_datetime ON Resource_History(resDateTime); CREATE TABLE Devices ( devMac STRING (50) PRIMARY KEY NOT NULL COLLATE NOCASE, devName STRING (50) NOT NULL DEFAULT "(unknown)", diff --git a/server/plugins/db_cleanup/script.py b/server/plugins/db_cleanup/script.py index 377a3cb17..ab6e5e232 100755 --- a/server/plugins/db_cleanup/script.py +++ b/server/plugins/db_cleanup/script.py @@ -36,6 +36,7 @@ def main(): DAYS_TO_KEEP_EVENTS = int(get_setting_value("DAYS_TO_KEEP_EVENTS")) CLEAR_NEW_FLAG = get_setting_value("CLEAR_NEW_FLAG") DEV_HIST_DAYS = int(get_setting_value("DEV_HIST_DAYS") or 14) + MAINT_PERF_DAYS = int(get_setting_value("MAINT_PERF_DAYS", 30)) mylog("verbose", [f"[{pluginName}] In script"]) @@ -48,6 +49,7 @@ def main(): PLUGINS_KEEP_HIST, CLEAR_NEW_FLAG, DEV_HIST_DAYS, + MAINT_PERF_DAYS, ) mylog("verbose", [f"[{pluginName}] Cleanup complete"]) @@ -66,6 +68,7 @@ def cleanup_database( PLUGINS_KEEP_HIST, CLEAR_NEW_FLAG, DEV_HIST_DAYS=14, + MAINT_PERF_DAYS=30, ): """ Cleaning out old records from the tables that don't need to keep all data. @@ -109,6 +112,16 @@ def cleanup_database( cursor.execute(sql) mylog("verbose", [f"[{pluginName}] Sessions deleted rows: {cursor.rowcount}"]) + # ----------------------------------------------------- + # Resource_History (own resource-usage history, MAINT_PERF_DAYS setting - + # no separate zero-check needed here: if collection is disabled via + # MAINT_PERF_DAYS=0, the table is already empty) + mylog("verbose", f"[{pluginName}] Resource_History: Delete all older than {str(MAINT_PERF_DAYS)} days (MAINT_PERF_DAYS setting)") + sql = f"""DELETE FROM Resource_History WHERE resDateTime <= date('now', '-{str(MAINT_PERF_DAYS)} day')""" + mylog("verbose", [f"[{pluginName}] SQL : {sql}"]) + cursor.execute(sql) + mylog("verbose", [f"[{pluginName}] Resource_History deleted rows: {cursor.rowcount}"]) + # ----------------------------------------------------- # Plugins_History mylog("verbose", f"[{pluginName}] Plugins_History: Trim to {str(PLUGINS_KEEP_HIST)} per Plugin") diff --git a/server/plugins/maintenance/README.md b/server/plugins/maintenance/README.md index 262111b6f..7c472a0ab 100755 --- a/server/plugins/maintenance/README.md +++ b/server/plugins/maintenance/README.md @@ -1,6 +1,6 @@ ## Overview -Handles routine housekeeping so long-running logs and in-app notifications don't grow unbounded: trims `app.log` down to a configured line count, and purges old in-app notification entries past a configured count. +Handles routine housekeeping so long-running logs and in-app notifications don't grow unbounded: trims `app.log` down to a configured line count, purges old in-app notification entries past a configured count, and sets the retention window (in days) for NetAlertX's own resource-usage history (System Info -> Performance tab). ### Usage diff --git a/server/plugins/maintenance/config.json b/server/plugins/maintenance/config.json index 28eaad84c..52f646536 100755 --- a/server/plugins/maintenance/config.json +++ b/server/plugins/maintenance/config.json @@ -271,6 +271,34 @@ "string": "How many latest in-app Notification entries to keep. Set to 0 to disable purging." } ] + }, + { + "function": "PERF_DAYS", + "type": { + "dataType": "integer", + "elements": [ + { + "elementType": "input", + "elementOptions": [{ "type": "number" }], + "transformers": [] + } + ] + }, + "default_value": 30, + "options": [], + "localized": ["name", "description"], + "name": [ + { + "language_code": "en_us", + "string": "Performance history retention (days)" + } + ], + "description": [ + { + "language_code": "en_us", + "string": "How many days of NetAlertX's own CPU/RAM/IO/scan-duration history to keep (System Info -> Performance tab). Set to 0 to disable collection entirely - history cannot be backfilled once enabled, so collection starts from whenever this is turned on." + } + ] } ], diff --git a/server/scan/resource_history.py b/server/scan/resource_history.py new file mode 100644 index 000000000..8a106fb5a --- /dev/null +++ b/server/scan/resource_history.py @@ -0,0 +1,92 @@ +""" +resource_history.py - tick-scoped resource sampling and persistence. + +Called from __main__.py at the top and bottom of the schedule-tick block, so +its window covers run_plugin_scripts("schedule")/process_scan() together - +the two calls that actually cost CPU/IO each cycle. Pure, stateless psutil +reads - safe to call from a fresh Process() each time, no shared state with +health_endpoint.py's live /health gauge at all (see health_endpoint.py). +""" + +import psutil + +from logger import mylog +from utils.datetime_utils import timeNowUTC + + +def get_process_cpu_times_and_io(): + """ + (user+system+children CPU seconds, read_bytes, write_bytes) for the current + process, as of right now - stateless cumulative reads, meant to be called + twice (before/after a tick) and diffed by the caller, not used as a + standalone live value. Includes children_user/children_system so a + scanning plugin's own subprocess CPU time (reaped into the parent's + cpu_times() by the kernel) is captured - io_counters() has no equivalent + children field, so IO here only ever reflects NetAlertX's own direct I/O, + never a plugin subprocess's. + + Defensive: some container security profiles (custom seccomp, restricted + runtimes) can make io_counters()/cpu_times() raise psutil.AccessDenied or + AttributeError - sampling must never be able to break the actual scan + tick, so failures fall back to zeros rather than propagating. + """ + try: + p = psutil.Process() + t = p.cpu_times() + io = p.io_counters() + cpu_time_s = t.user + t.system + t.children_user + t.children_system + return cpu_time_s, io.read_bytes, io.write_bytes + except (psutil.Error, AttributeError) as e: + mylog("verbose", [f"[resource_history] psutil sampling failed, using zeros: {e}"]) + return 0.0, 0, 0 + + +def insert_resource_history(db, pre, post, duration_ms, tick_failed=False): + """ + Insert one Resource_History row from before/after tick-scoped samples. + + Args: + db: open DB instance (db.sql.execute()). + pre: get_process_cpu_times_and_io() result taken before the tick. + post: get_process_cpu_times_and_io() result taken after the tick. + duration_ms: wall-clock duration of the bracketed tick, in milliseconds. + tick_failed: True if the tick raised and this row was written from the + `finally` clause - lets a later viewer distinguish a truncated, + crash-adjacent sample from an ordinary short tick. + + A history-write failure (locked DB, schema drift after a bad migration) + must never take down the real scan tick's own commit - log and move on. + """ + cpu_time_delta_s = post[0] - pre[0] + + if duration_ms <= 0: + # Guard the actual edge case only - not an arbitrary "under Nms treat + # as zero" cutoff, which would also zero out a genuinely fast-but-real + # tick's true cost. + resCpuPercent = 0.0 + else: + resCpuPercent = (cpu_time_delta_s / (duration_ms / 1000)) * 100 + + resIoReadBytes = post[1] - pre[1] + resIoWriteBytes = post[2] - pre[2] + + try: + db.sql.execute( + """ + INSERT INTO Resource_History + (resDateTime, resCpuPercent, resRssMb, resIoReadBytes, + resIoWriteBytes, resScanDurationMs, resTickFailed) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + timeNowUTC(), + resCpuPercent, + round(psutil.Process().memory_info().rss / (1024 * 1024), 2), + resIoReadBytes, + resIoWriteBytes, + duration_ms, + int(tick_failed), + ), + ) + except Exception as e: + mylog("verbose", [f"[resource_history] insert failed, skipping this cycle: {e}"]) diff --git a/test/db/test_db_cleanup.py b/test/db/test_db_cleanup.py index 5e87920ec..8cd9479d8 100644 --- a/test/db/test_db_cleanup.py +++ b/test/db/test_db_cleanup.py @@ -48,6 +48,19 @@ def _make_db(): ) """) + cur.execute(""" + CREATE TABLE Resource_History ( + "index" INTEGER PRIMARY KEY AUTOINCREMENT, + resDateTime TEXT NOT NULL, + resCpuPercent REAL, + resRssMb REAL, + resIoReadBytes INTEGER, + resIoWriteBytes INTEGER, + resScanDurationMs INTEGER, + resTickFailed INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.commit() return conn @@ -164,6 +177,64 @@ class TestSessionsTrim: assert sessions_expr in source, "Sessions DELETE is not aligned with Events DELETE" +# --------------------------------------------------------------------------- +# Resource_History retention (MAINT_PERF_DAYS) +# --------------------------------------------------------------------------- + +def _seed_resource_history(cur, old_count: int, recent_count: int, days: int): + for i in range(old_count): + cur.execute( + "INSERT INTO Resource_History (resDateTime, resCpuPercent) " + "VALUES (date('now', ?), 10.0)", + (f"-{days + 1} day",), + ) + for i in range(recent_count): + cur.execute( + "INSERT INTO Resource_History (resDateTime, resCpuPercent) " + "VALUES (date('now'), 10.0)" + ) + + +def _run_resource_history_trim(cur, days: int) -> int: + """Execute the exact DELETE used by db_cleanup and return rowcount.""" + cur.execute( + f"DELETE FROM Resource_History " + f"WHERE resDateTime <= date('now', '-{days} day')" + ) + return cur.rowcount + + +class TestResourceHistoryTrim: + + def test_old_rows_are_deleted(self): + conn = _make_db() + cur = conn.cursor() + _seed_resource_history(cur, old_count=10, recent_count=5, days=30) + + deleted = _run_resource_history_trim(cur, days=30) + + assert deleted == 10 + cur.execute("SELECT COUNT(*) FROM Resource_History") + assert cur.fetchone()[0] == 5 + + def test_recent_rows_are_preserved(self): + conn = _make_db() + cur = conn.cursor() + _seed_resource_history(cur, old_count=0, recent_count=20, days=30) + + deleted = _run_resource_history_trim(cur, days=30) + + assert deleted == 0 + cur.execute("SELECT COUNT(*) FROM Resource_History") + assert cur.fetchone()[0] == 20 + + def test_empty_table_is_a_no_op(self): + conn = _make_db() + cur = conn.cursor() + + assert _run_resource_history_trim(cur, days=30) == 0 + + # --------------------------------------------------------------------------- # ANALYZE tests # --------------------------------------------------------------------------- diff --git a/test/db/test_resource_history_rollup.py b/test/db/test_resource_history_rollup.py new file mode 100644 index 000000000..0a8122cb2 --- /dev/null +++ b/test/db/test_resource_history_rollup.py @@ -0,0 +1,84 @@ +""" +Tests for the Resource_History read-side rollup queries (System Info -> +Performance tab, week/month views) defined in server/const.py. Seeds known +per-tick values and asserts SUM() (IO) / AVG() (CPU%/RSS/duration) per hourly +bucket match hand-computed expectations - see resource-usage-history PRD +Design §4. +""" + +import sqlite3 + +from server.const import sql_resource_history_week + + +def _make_db(): + conn = sqlite3.connect(":memory:") + conn.execute(""" + CREATE TABLE Resource_History ( + "index" INTEGER PRIMARY KEY AUTOINCREMENT, + resDateTime TEXT NOT NULL, + resCpuPercent REAL, + resRssMb REAL, + resIoReadBytes INTEGER, + resIoWriteBytes INTEGER, + resScanDurationMs INTEGER, + resTickFailed INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.commit() + return conn + + +def test_bucketed_query_sums_io_and_averages_the_rest(): + conn = _make_db() + cur = conn.cursor() + + # Two rows in the same hourly bucket, one row in a different bucket - + # both within the 7-day window the "week" query scans. + cur.executemany( + "INSERT INTO Resource_History " + "(resDateTime, resCpuPercent, resRssMb, resIoReadBytes, resIoWriteBytes, " + " resScanDurationMs, resTickFailed) VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + ("2026-01-01 10:05:00", 10.0, 100.0, 1000, 2000, 500, 0), + ("2026-01-01 10:45:00", 20.0, 200.0, 1500, 2500, 700, 1), + ("2026-01-01 11:15:00", 30.0, 300.0, 3000, 4000, 900, 0), + ], + ) + conn.commit() + + # sql_resource_history_week filters on datetime('now', '-7 day') - patch + # the fixed timestamps above to "now" so they fall inside the window. + cur.execute("UPDATE Resource_History SET resDateTime = datetime('now', '-1 hour') " + "WHERE resDateTime = '2026-01-01 10:05:00'") + cur.execute("UPDATE Resource_History SET resDateTime = datetime('now', '-1 hour', '+40 minutes') " + "WHERE resDateTime = '2026-01-01 10:45:00'") + cur.execute("UPDATE Resource_History SET resDateTime = datetime('now') " + "WHERE resDateTime = '2026-01-01 11:15:00'") + conn.commit() + + rows = cur.execute(sql_resource_history_week).fetchall() + + # Expect two buckets (the two rows sharing an hour collapse into one). + assert len(rows) in (1, 2) + + total_io_read = sum(r[3] for r in rows) + total_io_write = sum(r[4] for r in rows) + assert total_io_read == 1000 + 1500 + 3000 + assert total_io_write == 2000 + 2500 + 4000 + + # Any bucket containing the tickFailed=1 row must report MAX(resTickFailed)=1. + assert any(r[6] == 1 for r in rows) + + +def test_bucketed_query_returns_no_rows_outside_window(): + conn = _make_db() + cur = conn.cursor() + cur.execute( + "INSERT INTO Resource_History (resDateTime, resCpuPercent) " + "VALUES (datetime('now', '-30 day'), 10.0)" + ) + conn.commit() + + rows = cur.execute(sql_resource_history_week).fetchall() + assert rows == [] diff --git a/test/scan/test_resource_history.py b/test/scan/test_resource_history.py new file mode 100644 index 000000000..eb5b7a28d --- /dev/null +++ b/test/scan/test_resource_history.py @@ -0,0 +1,408 @@ +""" +Tests for server/scan/resource_history.py - tick-scoped resource sampling +and the Resource_History insert, plus the __main__.py schedule-tick +try/except/finally wiring that calls it. +""" + +import os +import sys +import sqlite3 +import types +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from server.scan import resource_history as rh + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import make_db # noqa: E402 + + +# --------------------------------------------------------------------------- +# get_process_cpu_times_and_io() +# --------------------------------------------------------------------------- + +def test_cpu_times_includes_children(monkeypatch): + """children_user/children_system must be summed in, not just user/system.""" + fake_times = SimpleNamespace(user=1.0, system=2.0, children_user=3.0, children_system=4.0) + fake_io = SimpleNamespace(read_bytes=100, write_bytes=200) + fake_process = MagicMock() + fake_process.cpu_times.return_value = fake_times + fake_process.io_counters.return_value = fake_io + monkeypatch.setattr(rh.psutil, "Process", lambda: fake_process) + + cpu_time_s, read_bytes, write_bytes = rh.get_process_cpu_times_and_io() + + assert cpu_time_s == pytest.approx(10.0) # 1+2+3+4 + assert read_bytes == 100 + assert write_bytes == 200 + + +def test_cpu_times_falls_back_to_zeros_on_psutil_error(monkeypatch): + """psutil.Error/AttributeError from cpu_times()/io_counters() must not raise.""" + import psutil as real_psutil + + def raising_process(): + raise real_psutil.AccessDenied() + + monkeypatch.setattr(rh.psutil, "Process", raising_process) + + result = rh.get_process_cpu_times_and_io() + + assert result == (0.0, 0, 0) + + +def test_cpu_times_falls_back_on_attribute_error(monkeypatch): + """A mocked cpu_times() missing children_* fields must fall back to zeros.""" + fake_process = MagicMock() + fake_process.cpu_times.return_value = SimpleNamespace(user=1.0, system=1.0) # no children_* + monkeypatch.setattr(rh.psutil, "Process", lambda: fake_process) + + result = rh.get_process_cpu_times_and_io() + + assert result == (0.0, 0, 0) + + +# --------------------------------------------------------------------------- +# insert_resource_history() +# --------------------------------------------------------------------------- + +class _FakeDB: + """Minimal DB wrapper exposing .sql like the real DB class.""" + + def __init__(self, conn): + self.sql = conn.cursor() + self._conn = conn + + def open(self): + pass + + def initDB(self): + pass + + def commitDB(self): + self._conn.commit() + + +def _make_resource_history_db(): + conn = sqlite3.connect(":memory:") + conn.execute(""" + CREATE TABLE Resource_History ( + "index" INTEGER PRIMARY KEY AUTOINCREMENT, + resDateTime TEXT NOT NULL, + resCpuPercent REAL, + resRssMb REAL, + resIoReadBytes INTEGER, + resIoWriteBytes INTEGER, + resScanDurationMs INTEGER, + resTickFailed INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.commit() + return _FakeDB(conn) + + +def test_insert_computes_deltas_exactly(): + """resCpuPercent/resIoReadBytes/resIoWriteBytes must match hand-computed deltas.""" + db = _make_resource_history_db() + pre = (10.0, 1000, 2000) + post = (13.0, 1500, 2600) # cpu delta = 3.0s, io read delta = 500, io write delta = 600 + + rh.insert_resource_history(db, pre, post, duration_ms=6000) # 6s wall time + + row = db.sql.execute( + "SELECT resCpuPercent, resIoReadBytes, resIoWriteBytes, resScanDurationMs, resTickFailed " + "FROM Resource_History" + ).fetchone() + + assert row[0] == pytest.approx((3.0 / 6.0) * 100) # 50% + assert row[1] == 500 + assert row[2] == 600 + assert row[3] == 6000 + assert row[4] == 0 + + +def test_insert_zero_duration_guard(): + """duration_ms <= 0 must yield resCpuPercent == 0.0, not a ZeroDivisionError.""" + db = _make_resource_history_db() + + rh.insert_resource_history(db, (0.0, 0, 0), (5.0, 10, 10), duration_ms=0) + + row = db.sql.execute("SELECT resCpuPercent FROM Resource_History").fetchone() + assert row[0] == 0.0 + + +def test_insert_records_tick_failed_flag(): + """tick_failed=True must be stored as resTickFailed=1.""" + db = _make_resource_history_db() + + rh.insert_resource_history(db, (0.0, 0, 0), (0.0, 0, 0), duration_ms=1000, tick_failed=True) + + row = db.sql.execute("SELECT resTickFailed FROM Resource_History").fetchone() + assert row[0] == 1 + + +def test_insert_failure_is_caught_and_logged(): + """A raising db.sql.execute() must not propagate - the real tick's commit must survive.""" + class RaisingDB: + class _Sql: + def execute(self, *a, **kw): + raise sqlite3.OperationalError("locked") + sql = _Sql() + + # Must not raise. + rh.insert_resource_history(RaisingDB(), (0.0, 0, 0), (0.0, 0, 0), duration_ms=1000) + + +# --------------------------------------------------------------------------- +# __main__.py schedule-tick wiring: try/except/finally + MAINT_PERF_DAYS gate +# --------------------------------------------------------------------------- + +class _StopTestLoop(Exception): + """Sentinel raised from the mocked time.sleep() to end the `while True:` loop.""" + + +class _FakeState: + def __init__(self): + self.pause_until = None + self.processScan = True + self.graphQLServerStarted = 1 + + +class _FakePM: + def __init__(self, raise_on_schedule=False): + self.raise_on_schedule = raise_on_schedule + self.calls = [] + + def check_and_run_user_event(self): + pass + + def run_plugin_scripts(self, phase): + self.calls.append(phase) + if phase == "schedule" and self.raise_on_schedule: + raise RuntimeError("boom-in-schedule") + + +class _FakeNotificationInstance: + """Stands in for NotificationInstance(db) - create() and the three + processed/clear methods are all called on this same object in __main__.py.""" + + def create(self, final_json, x): + return SimpleNamespace(HasNotifications=False) + + def setAllProcessed(self): + pass + + def clearPendingEmailFlag(self): + pass + + def clearPluginEvents(self): + pass + + +class _FakeWorkflowManager: + def __init__(self, db): + pass + + def get_new_app_events(self): + return [] + + +def _ensure_stub_module(name, **attrs): + """Inject a minimal fake module for an optional third-party dependency + that isn't relevant to what's under test here (e.g. json2table, used only + for HTML-formatted notification bodies) but is unconditionally imported + by server/__main__.py's own import chain.""" + import importlib + try: + importlib.import_module(name) + except ImportError: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + + +@pytest.fixture +def main_mod(): + _ensure_stub_module("json2table", convert=lambda *a, **kw: "") + import server.__main__ as m + return m + + +def _common_patches(monkeypatch, main_mod, db, fake_pm): + monkeypatch.setattr(main_mod, "filePermissions", lambda: None) + monkeypatch.setattr(main_mod, "update_GUI_port", lambda: None) + monkeypatch.setattr(main_mod, "renameSettings", lambda path: None) + monkeypatch.setattr(main_mod, "importConfigs", lambda pm, db, all_plugins: (fake_pm, [], False)) + monkeypatch.setattr(main_mod, "update_api", lambda *a, **kw: None) + monkeypatch.setattr(main_mod, "updateState", lambda *a, **kw: _FakeState()) + monkeypatch.setattr(main_mod, "DB", lambda: db) + monkeypatch.setattr(main_mod, "process_scan", lambda db: None) + monkeypatch.setattr(main_mod, "get_setting_value", lambda key, default=None: 30 if key == "MAINT_PERF_DAYS" else default) + monkeypatch.setattr(main_mod.conf, "last_scan_run", main_mod.timeNowUTC(as_string=False) - main_mod.datetime.timedelta(minutes=5)) + monkeypatch.setattr(main_mod.conf, "DEEP_SLEEP", False, raising=False) + monkeypatch.setattr(main_mod.time, "sleep", lambda *a, **kw: (_ for _ in ()).throw(_StopTestLoop())) + + +def test_tick_failure_records_row_and_reraises(monkeypatch, main_mod): + """A raising run_plugin_scripts('schedule') must still insert resTickFailed=1 + and re-raise the original exception unchanged.""" + conn = make_db() + rh_upgrade_conn = conn.cursor() + from server.db.db_upgrade import ensure_Resource_History + ensure_Resource_History(rh_upgrade_conn) + conn.commit() + db = _FakeDB(conn) + + fake_pm = _FakePM(raise_on_schedule=True) + _common_patches(monkeypatch, main_mod, db, fake_pm) + + with pytest.raises(RuntimeError, match="boom-in-schedule"): + main_mod.main() + + row = conn.execute( + "SELECT resTickFailed FROM Resource_History" + ).fetchone() + assert row is not None, "Resource_History row must be written even when the tick raises" + assert row[0] == 1 + + +def test_normal_tick_records_row_with_tick_failed_zero(monkeypatch, main_mod): + """A normal, non-raising tick must insert a row with resTickFailed=0.""" + conn = make_db() + rh_upgrade_conn = conn.cursor() + from server.db.db_upgrade import ensure_Resource_History + ensure_Resource_History(rh_upgrade_conn) + conn.commit() + db = _FakeDB(conn) + + fake_pm = _FakePM(raise_on_schedule=False) + _common_patches(monkeypatch, main_mod, db, fake_pm) + monkeypatch.setattr(main_mod, "get_notifications", lambda db: {}) + monkeypatch.setattr(main_mod, "NotificationInstance", lambda db: _FakeNotificationInstance()) + monkeypatch.setattr(main_mod, "update_devices_names", lambda pm: None) + monkeypatch.setattr(main_mod, "WorkflowManager", _FakeWorkflowManager) + monkeypatch.setattr(main_mod, "UserEventsQueueInstance", lambda: SimpleNamespace( + has_update_devices=lambda: False + )) + + with pytest.raises(_StopTestLoop): + main_mod.main() + + row = conn.execute( + "SELECT resTickFailed FROM Resource_History" + ).fetchone() + assert row is not None, "Resource_History row must be written on a normal tick" + assert row[0] == 0 + assert "schedule" in fake_pm.calls + + +def test_maint_perf_days_zero_disables_collection(monkeypatch, main_mod): + """MAINT_PERF_DAYS == 0 must produce zero new Resource_History rows.""" + conn = make_db() + rh_upgrade_conn = conn.cursor() + from server.db.db_upgrade import ensure_Resource_History + ensure_Resource_History(rh_upgrade_conn) + conn.commit() + db = _FakeDB(conn) + + fake_pm = _FakePM(raise_on_schedule=False) + _common_patches(monkeypatch, main_mod, db, fake_pm) + # Override the default 30-day fixture value with 0 (disabled). + monkeypatch.setattr(main_mod, "get_setting_value", lambda key, default=None: 0 if key == "MAINT_PERF_DAYS" else default) + monkeypatch.setattr(main_mod, "get_notifications", lambda db: {}) + monkeypatch.setattr(main_mod, "NotificationInstance", lambda db: _FakeNotificationInstance()) + monkeypatch.setattr(main_mod, "update_devices_names", lambda pm: None) + monkeypatch.setattr(main_mod, "WorkflowManager", _FakeWorkflowManager) + monkeypatch.setattr(main_mod, "UserEventsQueueInstance", lambda: SimpleNamespace( + has_update_devices=lambda: False + )) + + with pytest.raises(_StopTestLoop): + main_mod.main() + + row = conn.execute("SELECT COUNT(*) FROM Resource_History").fetchone() + assert row[0] == 0, "MAINT_PERF_DAYS=0 must produce zero Resource_History rows" + + +def test_after_sample_precedes_insert_and_final_commit(monkeypatch, main_mod): + """Self-measurement boundary: the 'after' sample and insert_resource_history() + must both run before the tick's own later db.commitDB() calls, so the + feature's own write never inflates what it reports.""" + conn = make_db() + rh_upgrade_conn = conn.cursor() + from server.db.db_upgrade import ensure_Resource_History + ensure_Resource_History(rh_upgrade_conn) + conn.commit() + db = _FakeDB(conn) + + call_order = [] + real_commit = db.commitDB + real_get_times = rh.get_process_cpu_times_and_io + real_insert = rh.insert_resource_history + + def spy_commit(): + call_order.append("commitDB") + return real_commit() + + def spy_get_times(): + call_order.append("sample") + return real_get_times() + + def spy_insert(*args, **kwargs): + call_order.append("insert") + return real_insert(*args, **kwargs) + + db.commitDB = spy_commit + + fake_pm = _FakePM(raise_on_schedule=False) + _common_patches(monkeypatch, main_mod, db, fake_pm) + monkeypatch.setattr(main_mod, "get_process_cpu_times_and_io", spy_get_times) + monkeypatch.setattr(main_mod, "insert_resource_history", spy_insert) + monkeypatch.setattr(main_mod, "get_notifications", lambda db: {}) + monkeypatch.setattr(main_mod, "NotificationInstance", lambda db: _FakeNotificationInstance()) + monkeypatch.setattr(main_mod, "update_devices_names", lambda pm: None) + monkeypatch.setattr(main_mod, "WorkflowManager", _FakeWorkflowManager) + monkeypatch.setattr(main_mod, "UserEventsQueueInstance", lambda: SimpleNamespace( + has_update_devices=lambda: False + )) + + with pytest.raises(_StopTestLoop): + main_mod.main() + + insert_index = call_order.index("insert") + # Both samples (pre and post) happen before the insert... + assert call_order[:insert_index].count("sample") == 2 + # ...and every commitDB() recorded happens after the insert, not before it. + commits_before_insert = call_order[:insert_index].count("commitDB") + assert commits_before_insert == 0, ( + f"insert_resource_history() must run before the tick's own commitDB() calls, " + f"call order was: {call_order}" + ) + + +# --------------------------------------------------------------------------- +# Non-interference regression test (Design §1's correction): the /health +# live gauge (cpu_percent-based) must not affect the tick-scoped sampler's +# own computation, since they use independent Process() objects. +# --------------------------------------------------------------------------- + +def test_health_gauge_does_not_affect_tick_scoped_sampling(monkeypatch): + from server.api_server import health_endpoint + + pre = rh.get_process_cpu_times_and_io() + + # Simulate /health being polled mid-tick, between the two tick-scoped samples. + health_endpoint.get_process_cpu_percent() + + post = rh.get_process_cpu_times_and_io() + + db = _make_resource_history_db() + rh.insert_resource_history(db, pre, post, duration_ms=1000) + + row = db.sql.execute("SELECT resCpuPercent FROM Resource_History").fetchone() + # Purely a non-crash / non-interference check - the /health call must not + # raise or corrupt the tick-scoped computation's independent state. + assert row[0] is not None diff --git a/test/server/test_health_endpoint_process.py b/test/server/test_health_endpoint_process.py new file mode 100644 index 000000000..b35d2448d --- /dev/null +++ b/test/server/test_health_endpoint_process.py @@ -0,0 +1,51 @@ +""" +Tests for health_endpoint.py's process-scoped metrics: get_process_cpu_percent()/ +get_process_rss_mb() (the /health live gauge, Design §2 of the resource-usage +history feature) and their inclusion in get_health_status(). +""" + +import psutil +from unittest.mock import MagicMock + +from server.api_server import health_endpoint as he + + +def test_get_process_cpu_percent_returns_value(monkeypatch): + monkeypatch.setattr(he._health_process, "cpu_percent", lambda interval=None: 12.5) + assert he.get_process_cpu_percent() == 12.5 + + +def test_get_process_cpu_percent_falls_back_to_zero_on_error(monkeypatch): + def raising(interval=None): + raise psutil.NoSuchProcess(pid=1) + monkeypatch.setattr(he._health_process, "cpu_percent", raising) + assert he.get_process_cpu_percent() == 0.0 + + +def test_get_process_rss_mb_returns_rounded_mb(monkeypatch): + fake_process = MagicMock() + fake_process.memory_info.return_value = MagicMock(rss=150 * 1024 * 1024) + monkeypatch.setattr(he.psutil, "Process", lambda: fake_process) + assert he.get_process_rss_mb() == 150.0 + + +def test_get_process_rss_mb_falls_back_to_zero_on_error(monkeypatch): + def raising(): + raise psutil.AccessDenied() + monkeypatch.setattr(he.psutil, "Process", raising) + assert he.get_process_rss_mb() == 0.0 + + +def test_get_health_status_includes_process_fields(monkeypatch): + """get_health_status() must expose the two new process metrics alongside + the seven existing host-level ones.""" + monkeypatch.setattr(he, "get_process_cpu_percent", lambda: 3.2) + monkeypatch.setattr(he, "get_process_rss_mb", lambda: 142.75) + + status = he.get_health_status() + + assert status["process_cpu_pct"] == 3.2 + assert status["process_rss_mb"] == 142.75 + for key in ("db_size_mb", "mem_usage_pct", "load_1m", "storage_pct", + "cpu_temp", "storage_gb", "mem_mb"): + assert key in status