diff --git a/.claude/skills/prd-writing/SKILL.md b/.claude/skills/prd-writing/SKILL.md index 9c5cc388f..374fbcd0e 100644 --- a/.claude/skills/prd-writing/SKILL.md +++ b/.claude/skills/prd-writing/SKILL.md @@ -28,15 +28,16 @@ Both are plausible, well-written, and wrong. Reading the code first catches both 6. **Force every open question to an explicit decision**, even if the decision is "accept as-is for v1, revisit if feedback says otherwise." An open question left unresolved in a PRD gets silently decided by whoever implements it — usually differently than anyone intended. 7. **Write the test plan as part of the PRD, not after.** Concrete test cases — naming real functions/queries, not "add tests for X" — force you to notice design gaps you'd otherwise miss; the moment you try to write "assert Y happens" and realize the current design can't produce Y is often the first time the gap becomes visible. Check the repo for an existing test pattern for this shape of change before inventing a new one (e.g. a prior presence-logic bug fixed via `test/db_test_helpers.py` fixtures is the template for the next one, not a reason to build new test infrastructure). 8. **Ask explicitly whether validating this needs real end-to-end infrastructure** (a new or modified plugin, a UI click-through) or whether synthetic unit-level fixtures suffice — don't assume either way. Check whether the functions under test take a DB connection/dict/list as a parameter (testable in isolation, no real plugin needed) or require a real file on disk (harder to fake, may need one). -9. **Check performance against the real schema and real scale, not assumptions.** For every new or changed query: does it use an existing index, or add an unindexed lookup, a new join, or a correlated subquery? Grep `CREATE INDEX` for *every* index on the tables involved, not just the first one you find — a column can have both a plain index and a separate expression index (e.g. `idx_eve_mac_date_type ON Events(eveMac, ...)` alongside `idx_eve_lower_mac_date_type ON Events(LOWER(eveMac), ...)`), and missing the second one produces a wrong verdict. Then weigh cost by how often the query runs (once is nothing; every few minutes forever is a standing cost) and by real scale — **production users run 10,000+ devices**, not a homelab handful. A `CurrentScan` with 2-5 rows per device (one per contributing plugin) is routinely 20,000-50,000+ rows in one cycle; reason about that number, not a smaller hopeful one. A correlated `EXISTS`/subquery re-evaluated per outer row is fine *if* the correlated column is indexed — e.g. `current_scan_presence_condition()` (`server/scan/presence.py`) is exactly this shape against `CurrentScan.scanMac`, covered by `idx_currentscan_scanmac`. The real risk is an unindexed correlated lookup: an accidental self-join scanning the full inner table per outer row, which looks fine and passes tests at small scale but isn't at production scale. Check with `EXPLAIN QUERY PLAN` at a realistic row count, built against the *complete* real index set (copy every `CREATE INDEX` for the table, or run it against an actual `app.db`) rather than a hand-picked subset — a partial index set produces a misleading plan in either direction, not just "looks worse than it is." If it comes back unindexed for real, a `GROUP BY` aggregate is the usual fix. -10. **Do a dedicated final-check pass, out loud, before calling it done.** Re-read the whole document end to end and specifically check: +9. **Ask explicitly whether this feature/mechanism should be exposed via the API, and give a recommendation, not just flag it as an open question.** This codebase already has three real API surfaces to consider extending rather than inventing a fourth: REST (`server/api_server/*_endpoint.py`), GraphQL (`graphql_endpoint.py`), and a Prometheus `/metrics` endpoint (`prometheus_endpoint.py`). Skipping this question doesn't mean "no API access needed"; it means the answer gets silently decided later by whoever first wants to query the new data externally, usually as its own separate feature request re-litigating a design this PRD already had full context to settle. Not everything needs exposure: purely internal/diagnostic state with no plausible external consumer doesn't, but say so explicitly, with the reason, rather than leaving it unaddressed. +10. **Check performance against the real schema and real scale, not assumptions.** For every new or changed query: does it use an existing index, or add an unindexed lookup, a new join, or a correlated subquery? Grep `CREATE INDEX` for *every* index on the tables involved, not just the first one you find — a column can have both a plain index and a separate expression index (e.g. `idx_eve_mac_date_type ON Events(eveMac, ...)` alongside `idx_eve_lower_mac_date_type ON Events(LOWER(eveMac), ...)`), and missing the second one produces a wrong verdict. Then weigh cost by how often the query runs (once is nothing; every few minutes forever is a standing cost) and by real scale — **production users run 10,000+ devices**, not a homelab handful. A `CurrentScan` with 2-5 rows per device (one per contributing plugin) is routinely 20,000-50,000+ rows in one cycle; reason about that number, not a smaller hopeful one. A correlated `EXISTS`/subquery re-evaluated per outer row is fine *if* the correlated column is indexed — e.g. `current_scan_presence_condition()` (`server/scan/presence.py`) is exactly this shape against `CurrentScan.scanMac`, covered by `idx_currentscan_scanmac`. The real risk is an unindexed correlated lookup: an accidental self-join scanning the full inner table per outer row, which looks fine and passes tests at small scale but isn't at production scale. Check with `EXPLAIN QUERY PLAN` at a realistic row count, built against the *complete* real index set (copy every `CREATE INDEX` for the table, or run it against an actual `app.db`) rather than a hand-picked subset — a partial index set produces a misleading plan in either direction, not just "looks worse than it is." If it comes back unindexed for real, a `GROUP BY` aggregate is the usual fix. +11. **Do a dedicated final-check pass, out loud, before calling it done.** Re-read the whole document end to end and specifically check: - Did a correction made mid-document propagate everywhere it needed to (the Design subsection *and* Affected Files *and* Tests *and* any execution-plan summary)? A correction landing in one place and not its siblings is worse than never catching it, because now the document silently contradicts itself. - Does every "this is the cleanest/simplest real case" claim still hold up if you actually re-read that specific piece of code right now, or was it asserted by pattern-matching a name/category? Re-verify, don't re-assert. - Does anything render incorrectly as markdown — an unfenced ASCII diagram or code block will collapse into one line under lazy-paragraph-continuation, the same class of bug as a list missing its preceding blank line. - Do any internal anchor links' slugs match their headings? - Does the design still cleanly separate its axes, or did a later addition quietly re-conflate two concerns inside what's supposed to be a single-purpose mechanism (the same mistake step 3 exists to catch at the top level can reappear one level down inside an individual mechanism's own value set — e.g. a 3-value enum where two of the values are secretly independent booleans in a trenchcoat). -11. **Leave a visible trail of corrections instead of silently rewriting.** When a review pass — yours or someone else's — finds something wrong, write "**Correction (caught in review):** ..." inline rather than quietly fixing the earlier text and moving on. This is what makes a PRD trustworthy to a second reader: they can see what was checked and what changed, not just receive a polished final answer with no visible seams. -12. **When marking a PRD Implemented, ask once whether anything found along the way generalizes beyond this one document** — a tooling trick, a wrong assumption that took real digging to disprove, a review catch that would recur on the next PRD. The PRD's own "Implementation notes" section is the right place for what happened *in this PRD*; a skill or memory update is the right place for anything that would otherwise have to be rediscovered next time. One question, not a mandatory new section — skip it when nothing generalizes, don't force it on every PRD regardless of size. +12. **Leave a visible trail of corrections instead of silently rewriting.** When a review pass — yours or someone else's — finds something wrong, write "**Correction (caught in review):** ..." inline rather than quietly fixing the earlier text and moving on. This is what makes a PRD trustworthy to a second reader: they can see what was checked and what changed, not just receive a polished final answer with no visible seams. +13. **When marking a PRD Implemented, ask once whether anything found along the way generalizes beyond this one document** — a tooling trick, a wrong assumption that took real digging to disprove, a review catch that would recur on the next PRD. The PRD's own "Implementation notes" section is the right place for what happened *in this PRD*; a skill or memory update is the right place for anything that would otherwise have to be rediscovered next time. One question, not a mandatory new section — skip it when nothing generalizes, don't force it on every PRD regardless of size. ## Structure to follow @@ -46,7 +47,7 @@ Both are plausible, well-written, and wrong. Reading the code first catches both - **Open issues** — each with an explicit recorded decision (step 6), not left dangling. - **Affected files** — concrete `file:function:line` references, not bare filenames. - **Backward compatibility** — explicit default values and why they preserve current behavior for existing consumers. -- **Performance impact** — the baseline (what's unindexed/slow *today*, independent of this change), what the change adds that's negligible, what's new and worth mitigating, and concrete mitigations rather than a vague "should be fine" (step 9). +- **Performance impact** — the baseline (what's unindexed/slow *today*, independent of this change), what the change adds that's negligible, what's new and worth mitigating, and concrete mitigations rather than a vague "should be fine" (step 10). - **Docs/skills to update** — anywhere this needs to be reflected outside the code itself (external docs, paired skill files, template files new authors copy from). - **Tests** — organized by mechanism, each case naming the real function/query it exercises and the concrete assertion (step 7), plus a manual verification checklist for anything that can't be unit-tested (including an `EXPLAIN QUERY PLAN` check at realistic scale if the Performance impact section found a genuine risk). - **(Optional) Execution plan** — phased, referencing the same file/function names used above rather than restating the design in vaguer terms. diff --git a/.gemini/skills/prd-writing/SKILL.md b/.gemini/skills/prd-writing/SKILL.md index 6c412894b..4f873f9cd 100644 --- a/.gemini/skills/prd-writing/SKILL.md +++ b/.gemini/skills/prd-writing/SKILL.md @@ -28,15 +28,16 @@ Both are plausible, well-written, and wrong. Reading the code first catches both 6. **Force every open question to an explicit decision**, even if the decision is "accept as-is for v1, revisit if feedback says otherwise." An open question left unresolved in a PRD gets silently decided by whoever implements it — usually differently than anyone intended. 7. **Write the test plan as part of the PRD, not after.** Concrete test cases — naming real functions/queries, not "add tests for X" — force you to notice design gaps you'd otherwise miss; the moment you try to write "assert Y happens" and realize the current design can't produce Y is often the first time the gap becomes visible. Check the repo for an existing test pattern for this shape of change before inventing a new one (e.g. a prior presence-logic bug fixed via `test/db_test_helpers.py` fixtures is the template for the next one, not a reason to build new test infrastructure). 8. **Ask explicitly whether validating this needs real end-to-end infrastructure** (a new or modified plugin, a UI click-through) or whether synthetic unit-level fixtures suffice — don't assume either way. Check whether the functions under test take a DB connection/dict/list as a parameter (testable in isolation, no real plugin needed) or require a real file on disk (harder to fake, may need one). -9. **Check performance against the real schema and real scale, not assumptions.** For every new or changed query: does it use an existing index, or add an unindexed lookup, a new join, or a correlated subquery? Grep `CREATE INDEX` for *every* index on the tables involved, not just the first one you find — a column can have both a plain index and a separate expression index (e.g. `idx_eve_mac_date_type ON Events(eveMac, ...)` alongside `idx_eve_lower_mac_date_type ON Events(LOWER(eveMac), ...)`), and missing the second one produces a wrong verdict. Then weigh cost by how often the query runs (once is nothing; every few minutes forever is a standing cost) and by real scale — **production users run 10,000+ devices**, not a homelab handful. A `CurrentScan` with 2-5 rows per device (one per contributing plugin) is routinely 20,000-50,000+ rows in one cycle; reason about that number, not a smaller hopeful one. A correlated `EXISTS`/subquery re-evaluated per outer row is fine *if* the correlated column is indexed — e.g. `current_scan_presence_condition()` (`server/scan/presence.py`) is exactly this shape against `CurrentScan.scanMac`, covered by `idx_currentscan_scanmac`. The real risk is an unindexed correlated lookup: an accidental self-join scanning the full inner table per outer row, which looks fine and passes tests at small scale but isn't at production scale. Check with `EXPLAIN QUERY PLAN` at a realistic row count, built against the *complete* real index set (copy every `CREATE INDEX` for the table, or run it against an actual `app.db`) rather than a hand-picked subset — a partial index set produces a misleading plan in either direction, not just "looks worse than it is." If it comes back unindexed for real, a `GROUP BY` aggregate is the usual fix. -10. **Do a dedicated final-check pass, out loud, before calling it done.** Re-read the whole document end to end and specifically check: +9. **Ask explicitly whether this feature/mechanism should be exposed via the API, and give a recommendation, not just flag it as an open question.** This codebase already has three real API surfaces to consider extending rather than inventing a fourth: REST (`server/api_server/*_endpoint.py`), GraphQL (`graphql_endpoint.py`), and a Prometheus `/metrics` endpoint (`prometheus_endpoint.py`). Skipping this question doesn't mean "no API access needed"; it means the answer gets silently decided later by whoever first wants to query the new data externally, usually as its own separate feature request re-litigating a design this PRD already had full context to settle. Not everything needs exposure: purely internal/diagnostic state with no plausible external consumer doesn't, but say so explicitly, with the reason, rather than leaving it unaddressed. +10. **Check performance against the real schema and real scale, not assumptions.** For every new or changed query: does it use an existing index, or add an unindexed lookup, a new join, or a correlated subquery? Grep `CREATE INDEX` for *every* index on the tables involved, not just the first one you find — a column can have both a plain index and a separate expression index (e.g. `idx_eve_mac_date_type ON Events(eveMac, ...)` alongside `idx_eve_lower_mac_date_type ON Events(LOWER(eveMac), ...)`), and missing the second one produces a wrong verdict. Then weigh cost by how often the query runs (once is nothing; every few minutes forever is a standing cost) and by real scale — **production users run 10,000+ devices**, not a homelab handful. A `CurrentScan` with 2-5 rows per device (one per contributing plugin) is routinely 20,000-50,000+ rows in one cycle; reason about that number, not a smaller hopeful one. A correlated `EXISTS`/subquery re-evaluated per outer row is fine *if* the correlated column is indexed — e.g. `current_scan_presence_condition()` (`server/scan/presence.py`) is exactly this shape against `CurrentScan.scanMac`, covered by `idx_currentscan_scanmac`. The real risk is an unindexed correlated lookup: an accidental self-join scanning the full inner table per outer row, which looks fine and passes tests at small scale but isn't at production scale. Check with `EXPLAIN QUERY PLAN` at a realistic row count, built against the *complete* real index set (copy every `CREATE INDEX` for the table, or run it against an actual `app.db`) rather than a hand-picked subset — a partial index set produces a misleading plan in either direction, not just "looks worse than it is." If it comes back unindexed for real, a `GROUP BY` aggregate is the usual fix. +11. **Do a dedicated final-check pass, out loud, before calling it done.** Re-read the whole document end to end and specifically check: - Did a correction made mid-document propagate everywhere it needed to (the Design subsection *and* Affected Files *and* Tests *and* any execution-plan summary)? A correction landing in one place and not its siblings is worse than never catching it, because now the document silently contradicts itself. - Does every "this is the cleanest/simplest real case" claim still hold up if you actually re-read that specific piece of code right now, or was it asserted by pattern-matching a name/category? Re-verify, don't re-assert. - Does anything render incorrectly as markdown — an unfenced ASCII diagram or code block will collapse into one line under lazy-paragraph-continuation, the same class of bug as a list missing its preceding blank line. - Do any internal anchor links' slugs match their headings? - Does the design still cleanly separate its axes, or did a later addition quietly re-conflate two concerns inside what's supposed to be a single-purpose mechanism (the same mistake step 3 exists to catch at the top level can reappear one level down inside an individual mechanism's own value set — e.g. a 3-value enum where two of the values are secretly independent booleans in a trenchcoat). -11. **Leave a visible trail of corrections instead of silently rewriting.** When a review pass — yours or someone else's — finds something wrong, write "**Correction (caught in review):** ..." inline rather than quietly fixing the earlier text and moving on. This is what makes a PRD trustworthy to a second reader: they can see what was checked and what changed, not just receive a polished final answer with no visible seams. -12. **When marking a PRD Implemented, ask once whether anything found along the way generalizes beyond this one document** — a tooling trick, a wrong assumption that took real digging to disprove, a review catch that would recur on the next PRD. The PRD's own "Implementation notes" section is the right place for what happened *in this PRD*; a skill or memory update is the right place for anything that would otherwise have to be rediscovered next time. One question, not a mandatory new section — skip it when nothing generalizes, don't force it on every PRD regardless of size. +12. **Leave a visible trail of corrections instead of silently rewriting.** When a review pass — yours or someone else's — finds something wrong, write "**Correction (caught in review):** ..." inline rather than quietly fixing the earlier text and moving on. This is what makes a PRD trustworthy to a second reader: they can see what was checked and what changed, not just receive a polished final answer with no visible seams. +13. **When marking a PRD Implemented, ask once whether anything found along the way generalizes beyond this one document** — a tooling trick, a wrong assumption that took real digging to disprove, a review catch that would recur on the next PRD. The PRD's own "Implementation notes" section is the right place for what happened *in this PRD*; a skill or memory update is the right place for anything that would otherwise have to be rediscovered next time. One question, not a mandatory new section — skip it when nothing generalizes, don't force it on every PRD regardless of size. ## Structure to follow @@ -46,7 +47,7 @@ Both are plausible, well-written, and wrong. Reading the code first catches both - **Open issues** — each with an explicit recorded decision (step 6), not left dangling. - **Affected files** — concrete `file:function:line` references, not bare filenames. - **Backward compatibility** — explicit default values and why they preserve current behavior for existing consumers. -- **Performance impact** — the baseline (what's unindexed/slow *today*, independent of this change), what the change adds that's negligible, what's new and worth mitigating, and concrete mitigations rather than a vague "should be fine" (step 9). +- **Performance impact** — the baseline (what's unindexed/slow *today*, independent of this change), what the change adds that's negligible, what's new and worth mitigating, and concrete mitigations rather than a vague "should be fine" (step 10). - **Docs/skills to update** — anywhere this needs to be reflected outside the code itself (external docs, paired skill files, template files new authors copy from). - **Tests** — organized by mechanism, each case naming the real function/query it exercises and the concrete assertion (step 7), plus a manual verification checklist for anything that can't be unit-tested (including an `EXPLAIN QUERY PLAN` check at realistic scale if the Performance impact section found a genuine risk). - **(Optional) Execution plan** — phased, referencing the same file/function names used above rather than restating the design in vaguer terms. diff --git a/.github/skills/prd-writing/SKILL.md b/.github/skills/prd-writing/SKILL.md index f64e496ce..5c052e50d 100644 --- a/.github/skills/prd-writing/SKILL.md +++ b/.github/skills/prd-writing/SKILL.md @@ -28,15 +28,16 @@ Both are plausible, well-written, and wrong. Reading the code first catches both 6. **Force every open question to an explicit decision**, even if the decision is "accept as-is for v1, revisit if feedback says otherwise." An open question left unresolved in a PRD gets silently decided by whoever implements it — usually differently than anyone intended. 7. **Write the test plan as part of the PRD, not after.** Concrete test cases — naming real functions/queries, not "add tests for X" — force you to notice design gaps you'd otherwise miss; the moment you try to write "assert Y happens" and realize the current design can't produce Y is often the first time the gap becomes visible. Check the repo for an existing test pattern for this shape of change before inventing a new one (e.g. a prior presence-logic bug fixed via `test/db_test_helpers.py` fixtures is the template for the next one, not a reason to build new test infrastructure). 8. **Ask explicitly whether validating this needs real end-to-end infrastructure** (a new or modified plugin, a UI click-through) or whether synthetic unit-level fixtures suffice — don't assume either way. Check whether the functions under test take a DB connection/dict/list as a parameter (testable in isolation, no real plugin needed) or require a real file on disk (harder to fake, may need one). -9. **Check performance against the real schema and real scale, not assumptions.** For every new or changed query: does it use an existing index, or add an unindexed lookup, a new join, or a correlated subquery? Grep `CREATE INDEX` for *every* index on the tables involved, not just the first one you find — a column can have both a plain index and a separate expression index (e.g. `idx_eve_mac_date_type ON Events(eveMac, ...)` alongside `idx_eve_lower_mac_date_type ON Events(LOWER(eveMac), ...)`), and missing the second one produces a wrong verdict. Then weigh cost by how often the query runs (once is nothing; every few minutes forever is a standing cost) and by real scale — **production users run 10,000+ devices**, not a homelab handful. A `CurrentScan` with 2-5 rows per device (one per contributing plugin) is routinely 20,000-50,000+ rows in one cycle; reason about that number, not a smaller hopeful one. A correlated `EXISTS`/subquery re-evaluated per outer row is fine *if* the correlated column is indexed — e.g. `current_scan_presence_condition()` (`server/scan/presence.py`) is exactly this shape against `CurrentScan.scanMac`, covered by `idx_currentscan_scanmac`. The real risk is an unindexed correlated lookup: an accidental self-join scanning the full inner table per outer row, which looks fine and passes tests at small scale but isn't at production scale. Check with `EXPLAIN QUERY PLAN` at a realistic row count, built against the *complete* real index set (copy every `CREATE INDEX` for the table, or run it against an actual `app.db`) rather than a hand-picked subset — a partial index set produces a misleading plan in either direction, not just "looks worse than it is." If it comes back unindexed for real, a `GROUP BY` aggregate is the usual fix. -10. **Do a dedicated final-check pass, out loud, before calling it done.** Re-read the whole document end to end and specifically check: +9. **Ask explicitly whether this feature/mechanism should be exposed via the API, and give a recommendation, not just flag it as an open question.** This codebase already has three real API surfaces to consider extending rather than inventing a fourth: REST (`server/api_server/*_endpoint.py`), GraphQL (`graphql_endpoint.py`), and a Prometheus `/metrics` endpoint (`prometheus_endpoint.py`). Skipping this question doesn't mean "no API access needed"; it means the answer gets silently decided later by whoever first wants to query the new data externally, usually as its own separate feature request re-litigating a design this PRD already had full context to settle. Not everything needs exposure: purely internal/diagnostic state with no plausible external consumer doesn't, but say so explicitly, with the reason, rather than leaving it unaddressed. +10. **Check performance against the real schema and real scale, not assumptions.** For every new or changed query: does it use an existing index, or add an unindexed lookup, a new join, or a correlated subquery? Grep `CREATE INDEX` for *every* index on the tables involved, not just the first one you find — a column can have both a plain index and a separate expression index (e.g. `idx_eve_mac_date_type ON Events(eveMac, ...)` alongside `idx_eve_lower_mac_date_type ON Events(LOWER(eveMac), ...)`), and missing the second one produces a wrong verdict. Then weigh cost by how often the query runs (once is nothing; every few minutes forever is a standing cost) and by real scale — **production users run 10,000+ devices**, not a homelab handful. A `CurrentScan` with 2-5 rows per device (one per contributing plugin) is routinely 20,000-50,000+ rows in one cycle; reason about that number, not a smaller hopeful one. A correlated `EXISTS`/subquery re-evaluated per outer row is fine *if* the correlated column is indexed — e.g. `current_scan_presence_condition()` (`server/scan/presence.py`) is exactly this shape against `CurrentScan.scanMac`, covered by `idx_currentscan_scanmac`. The real risk is an unindexed correlated lookup: an accidental self-join scanning the full inner table per outer row, which looks fine and passes tests at small scale but isn't at production scale. Check with `EXPLAIN QUERY PLAN` at a realistic row count, built against the *complete* real index set (copy every `CREATE INDEX` for the table, or run it against an actual `app.db`) rather than a hand-picked subset — a partial index set produces a misleading plan in either direction, not just "looks worse than it is." If it comes back unindexed for real, a `GROUP BY` aggregate is the usual fix. +11. **Do a dedicated final-check pass, out loud, before calling it done.** Re-read the whole document end to end and specifically check: - Did a correction made mid-document propagate everywhere it needed to (the Design subsection *and* Affected Files *and* Tests *and* any execution-plan summary)? A correction landing in one place and not its siblings is worse than never catching it, because now the document silently contradicts itself. - Does every "this is the cleanest/simplest real case" claim still hold up if you actually re-read that specific piece of code right now, or was it asserted by pattern-matching a name/category? Re-verify, don't re-assert. - Does anything render incorrectly as markdown — an unfenced ASCII diagram or code block will collapse into one line under lazy-paragraph-continuation, the same class of bug as a list missing its preceding blank line. - Do any internal anchor links' slugs match their headings? - Does the design still cleanly separate its axes, or did a later addition quietly re-conflate two concerns inside what's supposed to be a single-purpose mechanism (the same mistake step 3 exists to catch at the top level can reappear one level down inside an individual mechanism's own value set — e.g. a 3-value enum where two of the values are secretly independent booleans in a trenchcoat). -11. **Leave a visible trail of corrections instead of silently rewriting.** When a review pass — yours or someone else's — finds something wrong, write "**Correction (caught in review):** ..." inline rather than quietly fixing the earlier text and moving on. This is what makes a PRD trustworthy to a second reader: they can see what was checked and what changed, not just receive a polished final answer with no visible seams. -12. **When marking a PRD Implemented, ask once whether anything found along the way generalizes beyond this one document** — a tooling trick, a wrong assumption that took real digging to disprove, a review catch that would recur on the next PRD. The PRD's own "Implementation notes" section is the right place for what happened *in this PRD*; a skill or memory update is the right place for anything that would otherwise have to be rediscovered next time. One question, not a mandatory new section — skip it when nothing generalizes, don't force it on every PRD regardless of size. +12. **Leave a visible trail of corrections instead of silently rewriting.** When a review pass — yours or someone else's — finds something wrong, write "**Correction (caught in review):** ..." inline rather than quietly fixing the earlier text and moving on. This is what makes a PRD trustworthy to a second reader: they can see what was checked and what changed, not just receive a polished final answer with no visible seams. +13. **When marking a PRD Implemented, ask once whether anything found along the way generalizes beyond this one document** — a tooling trick, a wrong assumption that took real digging to disprove, a review catch that would recur on the next PRD. The PRD's own "Implementation notes" section is the right place for what happened *in this PRD*; a skill or memory update is the right place for anything that would otherwise have to be rediscovered next time. One question, not a mandatory new section — skip it when nothing generalizes, don't force it on every PRD regardless of size. ## Structure to follow @@ -46,7 +47,7 @@ Both are plausible, well-written, and wrong. Reading the code first catches both - **Open issues** — each with an explicit recorded decision (step 6), not left dangling. - **Affected files** — concrete `file:function:line` references, not bare filenames. - **Backward compatibility** — explicit default values and why they preserve current behavior for existing consumers. -- **Performance impact** — the baseline (what's unindexed/slow *today*, independent of this change), what the change adds that's negligible, what's new and worth mitigating, and concrete mitigations rather than a vague "should be fine" (step 9). +- **Performance impact** — the baseline (what's unindexed/slow *today*, independent of this change), what the change adds that's negligible, what's new and worth mitigating, and concrete mitigations rather than a vague "should be fine" (step 10). - **Docs/skills to update** — anywhere this needs to be reflected outside the code itself (external docs, paired skill files, template files new authors copy from). - **Tests** — organized by mechanism, each case naming the real function/query it exercises and the concrete assertion (step 7), plus a manual verification checklist for anything that can't be unit-tested (including an `EXPLAIN QUERY PLAN` check at realistic scale if the Performance impact section found a genuine risk). - **(Optional) Execution plan** — phased, referencing the same file/function names used above rather than restating the design in vaguer terms. 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 e3886e5e4..a62b40379 100755 Binary files a/docs/img/PERFORMANCE/db_size_check.png and b/docs/img/PERFORMANCE/db_size_check.png differ diff --git a/front/css/app.css b/front/css/app.css index c1daf7b34..fdc2afbfc 100755 --- a/front/css/app.css +++ b/front/css/app.css @@ -2597,7 +2597,7 @@ textarea[readonly], width: 100%; } -#panInitCheck +#panInitCheck.active { display: flow-root; } diff --git a/front/js/common.js b/front/js/common.js index 135b0b36d..b6d763c7e 100755 --- a/front/js/common.js +++ b/front/js/common.js @@ -91,7 +91,17 @@ function deleteCookie (cookie) { // ----------------------------------------------------------------------------- // DateTime utilities // ----------------------------------------------------------------------------- -function localizeTimestamp(input) { +/** + * Parses a timestamp in any of several common formats (unix seconds/ms, + * DD/MM/YYYY, MM/DD/YYYY, ISO, RFC2822, DD-MM-YYYY) and formats it in the + * user's configured TIMEZONE/UI_LOCALE settings. + * @param {string|number} input - The timestamp to localize. + * @param {Intl.DateTimeFormatOptions} [dtfOptions] - Full Intl.DateTimeFormat + * options object, replacing (not merging with) the default year/month/day/ + * hour/minute/second/hour12 shape - e.g. for a shorter chart-axis label. + * @returns {string} The localized, formatted timestamp. + */ +function localizeTimestamp(input, dtfOptions) { // Read fresh on every call (not a module-level const): getSetting() reads // a localStorage cache that clearCache() wipes before reloading, so a @@ -99,6 +109,16 @@ function localizeTimestamp(input) { const tz = getSetting("TIMEZONE") || 'Europe/Berlin'; const LOCALE = getSetting('UI_LOCALE') || 'en-GB'; + // Optional second arg: full Intl.DateTimeFormat options object, replacing + // (not merging with) the historical year+seconds default below - e.g. a + // chart axis label that wants month/day/hour/minute only. Omit it for the + // original full-precision behavior every existing caller relies on. + const options = dtfOptions || { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false + }; + input = String(input || '').trim(); // 1. Unix timestamps (10 or 13 digits) @@ -106,9 +126,7 @@ function localizeTimestamp(input) { const ms = input.length === 10 ? parseInt(input, 10) * 1000 : parseInt(input, 10); return new Intl.DateTimeFormat('default', { timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false + ...options }).format(new Date(ms)); } @@ -198,9 +216,7 @@ function localizeTimestamp(input) { return new Intl.DateTimeFormat(LOCALE, { // Convert from UTC to user's configured timezone timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false + ...options }).format(date); } } diff --git a/front/js/graph_resource_history.js b/front/js/graph_resource_history.js new file mode 100644 index 000000000..56be2226d --- /dev/null +++ b/front/js/graph_resource_history.js @@ -0,0 +1,210 @@ +// Chart.js instances, kept so a range switch destroys the old chart before +// building a new one on the same canvas (Chart.js throws otherwise). +var resourceHistoryChartInstances = {}; + +// Monotonically increasing id, bumped on every initResourceHistoryGraphs() +// call. A fast double-click across two range buttons fires two overlapping +// AJAX requests; without this, whichever response lands last wins regardless +// of click order, so a stale response can silently overwrite newer data. +var resourceHistoryRequestId = 0; + +// Chart axis labels: time only - date/year are dropped entirely to keep the +// axis readable; the full date (including year) is still available on +// hover via each chart's tooltip title callback below. Passed as +// localizeTimestamp()'s options override so parsing/timezone/locale logic +// stays in one shared place (common.js). +var CHART_TIMESTAMP_OPTIONS_AXIS = { + hour: '2-digit', minute: '2-digit', + hour12: false +}; + +// Full timestamp (including year) shown in the tooltip title on hover. +var CHART_TIMESTAMP_OPTIONS_TOOLTIP = { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false +}; + +/** + * Fetches Resource_History data for the given range ('hour'/'day'/'week'/'month') + * and renders the four Performance-tab charts, or shows the disabled-state + * message when no rows exist yet (collection off, or just enabled). + */ +function initResourceHistoryGraphs(range) { + var requestId = ++resourceHistoryRequestId; + + $.get('php/server/query_json.php', { file: `table_resource_history_${range}.json`, nocache: Date.now() }, function (res) { + if (requestId !== resourceHistoryRequestId) { + return; // a newer range request has since started - discard this stale response + } + + var rows = (res && res.data) ? res.data : []; + + if (rows.length === 0) { + $('#resourceHistoryCharts').addClass('myhidden'); + $('#resourceHistoryDisabledMessage').removeClass('myhidden'); + return; + } + + $('#resourceHistoryDisabledMessage').addClass('myhidden'); + $('#resourceHistoryCharts').removeClass('myhidden'); + + var labels = []; + var fullLabels = []; + var cpuData = []; + var rssData = []; + var ioReadData = []; + var ioWriteData = []; + var durationData = []; + var tickFailed = []; + + function round2(n) { + return n == null ? null : Math.round(n * 100) / 100; + } + + rows.forEach(function (entry) { + // hour/day rows carry resDateTime (raw); week/month rows carry bucket + // (hourly rollup) instead - see const.py's sql_resource_history_* queries. + var ts = entry.resDateTime || entry.bucket; + labels.push(localizeTimestamp(ts, CHART_TIMESTAMP_OPTIONS_AXIS)); + fullLabels.push(localizeTimestamp(ts, CHART_TIMESTAMP_OPTIONS_TOOLTIP)); + cpuData.push(round2(entry.resCpuPercent)); + rssData.push(round2(entry.resRssMb)); + // Bytes -> 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, fullLabels, 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. `labels` + * (time-only, used for the axis) and `fullLabels` (full date+year, used for + * the hover tooltip title) are parallel arrays over the same rows. + */ +function renderResourceHistoryCharts(labels, fullLabels, 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: [{ + // maxTicksLimit caps how many labels Chart.js draws - without it, a + // short/fixed-height chart crams in enough ticks that adjacent labels + // visually overlap. + ticks: { beginAtZero: true, fontColor: '#A0A0A0', maxTicksLimit: 5 }, + // Faint gridlines only at the (few, capped) main ticks - easier to + // read a value off the chart without making it visually noisy. + gridLines: { color: "rgba(160, 160, 160, 0.15)", zeroLineColor: "rgba(160, 160, 160, 0.3)" }, + }], + xAxes: [{ + ticks: { fontColor: '#A0A0A0', maxTicksLimit: 10, autoSkip: true }, + gridLines: { color: "rgba(160, 160, 160, 0.15)", zeroLineColor: "rgba(160, 160, 160, 0.3)" }, + }], + }; + + // Axis labels are time-only (CHART_TIMESTAMP_OPTIONS_AXIS); show the full + // date (including year) on hover instead, via fullLabels. + var commonTooltips = { + callbacks: { + title: function (tooltipItems) { + return fullLabels[tooltipItems[0].index]; + }, + }, + }; + + 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, tooltips: commonTooltips, 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, tooltips: commonTooltips, 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, tooltips: commonTooltips, 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, tooltips: commonTooltips, 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 bb97242be..37d29bfd4 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 fa08f7b81..402688567 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": "Siguiente", @@ -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 ce8bb4153..701c38d0f 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..f01e2d5aa 100755 --- a/front/systeminfoStorage.php +++ b/front/systeminfoStorage.php @@ -10,6 +10,63 @@ 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(); + } catch (Exception $e) { + $tableSizesHTML = ''; + } +} + +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..112741ddf 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,70 @@ 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). + # Defensive: an empty/corrupted setting value must disable this + # optional feature, not raise and kill the whole main loop - + # this sits outside the try/finally below, so an uncaught + # ValueError/TypeError here would have no safety net at all. + try: + resource_history_enabled = int(get_setting_value("MAINT_PERF_DAYS", 30)) != 0 + except (TypeError, ValueError): + resource_history_enabled = False + 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..1f34ba192 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,19 @@ 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)") + # datetime(), not date(): resDateTime carries a time component (timeNowUTC()), + # and a bare date() cutoff (midnight, no time) never compares <= against a + # same-day timestamped row, silently keeping the whole boundary day. + sql = f"""DELETE FROM Resource_History WHERE resDateTime <= datetime('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..4df0e33ff --- /dev/null +++ b/server/scan/resource_history.py @@ -0,0 +1,102 @@ +""" +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] + + # Sampled independently of the INSERT's own try/except below - a psutil + # failure here must zero this one field, not get misreported as an + # "insert failed" and skip the whole row (CPU/IO were already computed + # successfully at this point). + try: + resRssMb = round(psutil.Process().memory_info().rss / (1024 * 1024), 2) + except (psutil.Error, AttributeError) as e: + mylog("verbose", [f"[resource_history] RSS sampling failed, using 0.0: {e}"]) + resRssMb = 0.0 + + try: + db.sql.execute( + """ + INSERT INTO Resource_History + (resDateTime, resCpuPercent, resRssMb, resIoReadBytes, + resIoWriteBytes, resScanDurationMs, resTickFailed) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + timeNowUTC(), + resCpuPercent, + resRssMb, + 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..39888ec73 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,110 @@ 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 (datetime('now', ?), 10.0)", + (f"-{days + 1} day",), + ) + for i in range(recent_count): + cur.execute( + "INSERT INTO Resource_History (resDateTime, resCpuPercent) " + "VALUES (datetime('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 <= datetime('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 + + def test_date_cutoff_would_silently_under_delete_the_boundary_day(self): + """ + Regression, demonstrating the bug the datetime() fix closes: date() + truncates the cutoff to midnight (10-char "YYYY-MM-DD"), while + resDateTime always carries a time component (19-char + "YYYY-MM-DD HH:MM:SS", from timeNowUTC()). Since the bare date string + is a strict prefix of any same-day timestamp, plain string comparison + (SQLite has no typed DATE column here) means resDateTime <= date(...) + is FALSE for every row on the cutoff day, regardless of its time of + day - the whole boundary day silently survives a date() cutoff. + datetime() doesn't have this gap: both sides are 19-char timestamps. + """ + conn = sqlite3.connect(":memory:") + cur = conn.cursor() + date_cutoff = cur.execute("SELECT date('now', '-30 day')").fetchone()[0] + datetime_cutoff = cur.execute("SELECT datetime('now', '-30 day')").fetchone()[0] + + assert len(date_cutoff) == 10 # "YYYY-MM-DD" - no time component + assert len(datetime_cutoff) == 19 # "YYYY-MM-DD HH:MM:SS" + assert datetime_cutoff.startswith(date_cutoff) + + # A same-day resDateTime value (any time after midnight) sorts after + # the bare date cutoff, so it would never satisfy `<=` under date(). + same_day_timestamp = date_cutoff + " 08:00:00" + assert not (same_day_timestamp <= date_cutoff), ( + "date() cutoff must fail to catch a same-day timestamped row - " + "this is exactly the bug datetime() fixes" + ) + + def test_resource_history_trim_uses_datetime_not_date(self): + """ + Regression: assert script.py's actual DELETE uses datetime(), matching + the precision of resDateTime and the read-side range queries + (const.py's sql_resource_history_* use datetime() too) - a bare + date() cutoff would silently under-delete (see test above). + """ + INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") + script_path = os.path.join( + INSTALL_PATH, "server", "plugins", "db_cleanup", "script.py" + ) + with open(script_path) as fh: + source = fh.read() + + expr = "DELETE FROM Resource_History WHERE resDateTime <= datetime('now', '-{str(MAINT_PERF_DAYS)} day')" + assert expr in source, "Resource_History DELETE must use datetime(), not date()" + + # --------------------------------------------------------------------------- # 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..e018c2488 --- /dev/null +++ b/test/scan/test_resource_history.py @@ -0,0 +1,467 @@ +""" +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 psutil +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) + + +def test_rss_sampling_failure_does_not_skip_the_row(monkeypatch): + """ + Regression: a failing psutil RSS read must zero resRssMb, not get caught + by the INSERT's own except block and skip the whole row (CPU/IO were + already computed successfully by that point). + """ + db = _make_resource_history_db() + + def raising_process(): + raise psutil.AccessDenied() + + monkeypatch.setattr(rh.psutil, "Process", raising_process) + + rh.insert_resource_history(db, (0.0, 0, 0), (1.0, 100, 200), duration_ms=1000) + + row = db.sql.execute( + "SELECT resRssMb, resIoReadBytes, resIoWriteBytes FROM Resource_History" + ).fetchone() + assert row is not None, "Row must still be inserted despite the RSS sampling failure" + assert row[0] == 0.0 + assert row[1] == 100 # IO values, computed before the RSS read, are unaffected + assert row[2] == 200 + + +# --------------------------------------------------------------------------- +# __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_maint_perf_days_non_numeric_disables_instead_of_crashing(monkeypatch, main_mod): + """ + Regression: an empty/corrupted MAINT_PERF_DAYS setting value must disable + the optional feature, not raise ValueError/TypeError and kill the whole + main loop - this int() call sits outside the tick's own try/except/finally, + so it has no other safety net. + """ + 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) + # Simulate a corrupted/empty setting value instead of a real integer. + monkeypatch.setattr(main_mod, "get_setting_value", lambda key, default=None: "" 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 + )) + + # Must reach the sentinel (i.e. complete the tick normally), not raise ValueError. + with pytest.raises(_StopTestLoop): + main_mod.main() + + row = conn.execute("SELECT COUNT(*) FROM Resource_History").fetchone() + assert row[0] == 0, "A non-numeric MAINT_PERF_DAYS must disable collection, not crash" + + +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