mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-28 12:01:13 -04:00
Merge pull request #1837 from netalertx/next_release
BE+FE: Plugins -> Sources DevDetails tab
This commit is contained in:
80 files changed
+2762
-261
No files matched your search
@@ -61,6 +61,12 @@ Every mapped field (`objectPrimaryId`/`objectSecondaryId`/`watchedValue1-4`/`ext
|
||||
| `on_new_device` | When new device detected |
|
||||
| `on_notification` | When notification triggered |
|
||||
|
||||
## `all_plugins` Lifecycle - Cache Hot-Path Derivations by Identity
|
||||
|
||||
`all_plugins` (every plugin's parsed `config.json`) is loaded once, in `initialise.py`'s `get_plugins_configs()` call, and reused as the same list object everywhere after that until an actual config reload replaces it. `update_api()` (`server/api.py`), which receives `all_plugins` as a parameter, runs far more often than that - on every scan cycle and every plugin completion (`server/__main__.py`, `server/plugin.py`), not just on reload.
|
||||
|
||||
Any function deriving something from `all_plugins` that gets called from a hot path like `update_api()` should cache its result, not recompute on every call - the input is almost always the same object even though the call site fires constantly. `server/db/plugin_field_views.py`'s `get_all_device_field_views()` is the pattern: retain a reference to the last-seen `all_plugins` list and compare it to the current one with `is`, recomputing only when a genuinely new object (an actual reload) appears. Don't key the cache by `id(all_plugins)` alone - once the old list is garbage collected, Python can reuse that id for an unrelated object, which would wrongly serve a stale cached result for what is actually a new reload.
|
||||
|
||||
## Plugin Formats
|
||||
|
||||
| Format | Purpose | Phase |
|
||||
|
||||
@@ -53,11 +53,22 @@ Both are plausible, well-written, and wrong. Reading the code first catches both
|
||||
- **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.
|
||||
- **Implementation checklist** — added to the PRD file itself when implementation actually starts (not at draft time), three boxes matching the "Implementing an approved PRD" section below: plan written before any code; test written and confirmed red against the pre-fix code before the fix was applied; no PRD filename/path/word left in committed code. Check each box only as it's actually done, not preemptively - the point is a forcing function visible in the file you're editing, not a formality.
|
||||
|
||||
## Before starting: check for an existing architecture-reference skill
|
||||
|
||||
If a skill already documents the subsystem the feature touches, load it before researching from scratch — don't re-derive call graphs or mechanism details that are already written down. If the feature touches a subsystem with no such skill, and understanding it required significant re-derivation from raw code, that's a signal to write one afterward so the next PRD in that area doesn't start from zero.
|
||||
|
||||
## Implementing an approved PRD
|
||||
|
||||
Add the Implementation checklist (see Structure above) to the PRD file as the first edit, before touching any other code - an unchecked, visible checklist in the file you're actively working in is the forcing function; a rule read once at skill-load time is not.
|
||||
|
||||
Before writing a single line of implementation code, write an explicit plan (via `EnterPlanMode` or an equivalent written plan) covering which files change, in what order, and which test(s) get written first. Understanding the PRD's design is not the same as planning its implementation - in practice these have been conflated, going straight from "I've read and understood this PRD" into code edits without ever writing either the plan or the test required below.
|
||||
|
||||
Then follow step 7 above literally, not just in spirit, at implementation time too: write the test, run it against the pre-fix code, confirm it fails for the right reason (a real behavioral mismatch, or a legitimate missing-interface error for a brand-new contract - not a setup bug), and only then write the fix. Writing the test after the implementation and asserting it "would have failed" is not equivalent - it's an unverified claim. If you catch yourself having implemented first, the correct recovery is to actually revert the implementation, confirm the test fails red against the reverted code, and only then reapply the fix - not to just add tests afterward and call it done.
|
||||
|
||||
Never reference a PRD's filename, path, or the word "PRD" in committed code, comments, docstrings, or commit messages - `PRDs/` is gitignored and local-only, so a reader of the committed code later has no way to find what it's pointing at, and the reference rots the moment the PRD file is deleted or moved. Explain the *why* self-containedly in the code itself (a design PRD's rationale restated in the comment, not a pointer to go read the PRD) instead.
|
||||
|
||||
## Where to save
|
||||
|
||||
`.gemini/internal-docs/PRDs/<kebab-case-name>.md`, unless the user specifies otherwise. Mark the status line (`**Status:** Draft — pending review`) so it's clear this hasn't been approved yet, and keep the author line accurate about who made the calls (a design discussion with an assistant is not sole assistant authorship).
|
||||
@@ -59,6 +59,7 @@ This is the scan-pipeline-local half of a bigger attribution system — see `dat
|
||||
8. **A correlated helper's `mac_column` argument silently binds to the helper's own inner row, not the caller's, whenever the helper's inner table already has a column of that name in scope - alias or no alias.** SQL resolves an unqualified name in the innermost enclosing scope first and only searches outward if nothing matches there; `nic_derived_presence_condition()`'s inner scan is `FROM Devices`, and `Devices` has a `devMac` column, so *any* bare `"devMac"` argument - not just one that happens to collide with an alias name - bound to the helper's own inner row. Every bare-`"devMac"` call site (both `Device Down` queries, `Disconnected`, `update_devLastConnection_from_CurrentScan()`) was affected: any NIC-covered device anywhere in `Devices` made every *other* absent device, including one with no NIC children at all, look NIC-derived-present, silently suppressing its real event or bumping its `devLastConnection`. (A qualified-but-colliding argument, e.g. `"nic_parent.devMac"` passed from a caller aliasing its own row `nic_parent` while the helper's own inner alias was also `nic_parent`, is the same root cause in a narrower form.) Fixed by requiring every caller to pass a qualified reference to *its own* table/alias (`"Devices.devMac"`, `"DevicesView.devMac"`) and having `nic_derived_presence_condition()` reject a bare `mac_column` outright, on top of the existing `presence_scan`/`nic_presence_parent`-collision guards. `current_scan_presence_condition()` doesn't need this: its inner scan is `FROM CurrentScan`, which has no `devMac` column, so a bare `"devMac"` has nothing to bind to inward and correctly falls back to the caller's row. Only caught by a test with two sibling devices in one DB where one should match and the other shouldn't - every single-device test passed regardless, because "any row" and "this row" are the same row when there's only one.
|
||||
|
||||
7. **`CurrentScan.scanMac`/`Events.eveMac` are permanently MAC-keyed, independent of the devGUID-as-PK migration.** See `database-patterns`' "Device Identity" section - `devMac` is today's actual schema PK, `devGUID` is the intended long-term identity, but a plugin can only ever report a MAC from network discovery, never an app-internal `devGUID`. Don't design around these tables ever becoming devGUID-keyed.
|
||||
9. **`insert_events()`'s `IP Changed` query evaluates every distinct present address for a MAC individually, not one `MIN()`-reduced candidate.** A device can have more than one simultaneously-valid, already-known address in the same family (e.g. two live IPv6 addresses); testing only the `MIN()`'d one against `devPrimaryIPv4`/`devPrimaryIPv6`/`devLastIP` can pick the one address that happens not to be sitting in the single-valued slot, firing a false event even though nothing actually changed. The fix makes "known" additive: an address counts as known if it matches the three-slot check *or* if it's been reported (and isn't `missing-in-last-scan`) by an eligible plugin in `Plugins_Objects`, via `_known_plugin_ip_addresses_sql()`'s use of `plugin_field_views.get_plugin_columns_for_field(all_plugins, "ip")` - the same plugin-to-column mapping the Field View tab uses. `all_plugins` is optional on both `insert_events()` and `process_scan()` (default `None`); when it's not eligible for any plugin (or a caller hasn't passed it), the additive half is skipped entirely and the query reduces to exactly the three-slot check. Addresses that fail `known()` are aggregated into one `Events` row per MAC per cycle (today's shape), listing every new address in `eveAdditionalInfo` rather than firing one row per address.
|
||||
|
||||
## When to read this vs. other docs/skills
|
||||
|
||||
|
||||
@@ -57,4 +57,4 @@ This does not apply to PRDs (`.gemini/internal-docs/PRDs/`) — those keep their
|
||||
|
||||
## Keep the three trees in sync
|
||||
|
||||
Most skills exist as three near-identical copies (`.claude/skills/<name>/SKILL.md`, `.gemini/skills/<name>/SKILL.md`, `.github/skills/<name>/SKILL.md` — see `.gemini/skills/skills-index/SKILL.md` for the pairing map). When a hygiene fix changes a skill's body, apply the same fix to all paired copies so they stay identical (frontmatter `name`/`description` may differ per tree's own convention; the body should not). `scripts/check_skill_pairs.py`'s `GROUPS` list only flags when some-but-not-all paired files changed in a diff — it doesn't check the bodies actually match, so a manual diff after editing is still worth it.
|
||||
Most skills exist as three near-identical copies (`.claude/skills/<name>/SKILL.md`, `.gemini/skills/<name>/SKILL.md`, `.github/skills/<name>/SKILL.md` - see `.gemini/skills/skills-index/SKILL.md` for the pairing map). When a hygiene fix changes a skill's body, apply the same fix to all paired copies so they stay identical (frontmatter `name`/`description` may differ per tree's own convention; the body should not). `scripts/check_skill_pairs.py` checks both: it flags a diff that touched some but not all of a group's paired files, and - for the groups the skills index documents as covering identical content - it also diffs the bodies directly and flags when they've actually drifted apart. The CI step calling it is non-blocking, so a manual diff after editing is still worth doing yourself.
|
||||
@@ -11,6 +11,30 @@ Don't introduce new UX behavior or visual patterns unless a PRD explicitly calls
|
||||
|
||||
Real, recent example: a presence-page Prev/Next pager was first built with custom `<button class="btn btn-xs">` elements floated in a `.box-header`. The DataTables pagination pattern (`dataTables_wrapper` / `dataTables_paginate` / `ul.pagination` / `li.paginate_button.previous|next`) already existed elsewhere in the app and does the exact same job. The custom version looked visually broken in the actual UI and had to be reimplemented using the existing pattern once that was caught in manual testing - reusing it also picked up dark-mode theming (`front/css/dark-patch.css`'s `.pagination li > a` / `.disabled` rules) for free, which the hand-rolled version didn't have. Grep first: e.g. `grep -rn "pagination\|paginate_button" front/` before adding a "previous/next" control of your own; the same applies to modals, filter inputs, badges, tooltips, tables - anything that already has an established shape somewhere in `front/`.
|
||||
|
||||
A second real example: a new device-details tab's status display was hand-written as `${icon} ${label}` plain text, and its field-group markup was approximated from memory (`<div class="form-group">` / plain `<div class="col-sm-8">`) instead of read from the actual existing tab it was meant to match. Both were wrong in ways that only showed up once rendered: the real status chip everywhere else in the app (`devices-table.js`, `network-tabs.js`, `network-api.js`) is `<span class="badge ${cssClass}">${iconHtml} ${label}</span>` via the shared `getStatusBadgeParts()`, not bare text; the real field-group markup needs `col-xs-12` on both the `.form-group` and its value `<div>` (plus `.input-group` on the value div), which a plain `grep` for the class names would have shown immediately. Approximating a reference pattern "from memory" instead of reading its actual current markup is the same mistake as not searching at all - read the real template/function that produces the reference UI, don't reconstruct it from a general impression of what it probably looks like.
|
||||
|
||||
A third real example, and the one to remember when the new thing isn't markup but behavior: Field View's per-field tables were first built as raw `<table><tbody>` HTML-string concatenation, when `pluginsCore.php`'s own `buildDT()` - a few hundred lines away, same feature area - already builds near-identical plugin-data tables: same DataTables paging/searching/ordering/lengthMenu options, same `createdCell` + `getFormControl()` cell-rendering approach. The fix was extracting the shared options into `getStandardDataTableOptions()` (`front/js/plugin_column_render.js`) and reusing the same cell-rendering pattern, rather than two parallel table implementations answering the same "render a plugin-sourced table" need. Duplication risk isn't only in markup/CSS - a JS initialization config or rendering callback can be just as duplicated. Before writing a new `.DataTable({...})` call (or any other substantial JS behavior-config object), grep for existing instances of it in the same subsystem first: `grep -rn '\.DataTable(' front/`.
|
||||
|
||||
## Loading skeletons and spinnerTarget
|
||||
|
||||
Two non-obvious mechanisms govern how a per-tab loading skeleton behaves, and skipping either produces a skeleton that visually escapes its own tab or gets stuck showing forever.
|
||||
|
||||
**Positioning:** `resolveSpinnerTarget()` (`front/js/common.js`) looks for `.spinnerTarget` among the active tab-pane's *direct children only* (`activePane.children(".spinnerTarget:visible")`), falling back to the pane itself if none is found. A skeleton needs its own `#panX` id added to `app.css`'s positioning-anchor rule (`#panDetails, #panSessions, #panPresence, #panEvents, #appEvents, #panSessionInfo { position: relative; }`) and must be a direct child of that pane - no wrapper `<div>` in between. Introducing an extra wrapper div (even just to group the skeleton with its content) breaks the direct-child lookup and the pane's own `position: relative`, and the skeleton escapes to overlay the whole page instead of staying inside its tab. Match the existing pattern exactly: `front/deviceDetailsPresence.php`'s `<?php require '.../skel_device_details_tab_presence.php'; ?>` as the very first line, no wrapper, `#panPresence` already in the anchor list.
|
||||
|
||||
**Lifecycle:** a per-tab polling updater (`setTimeout(updater, 200)`) that shows a skeleton while data loads and hides it once rendered needs to track *what it's waiting for*, not just whether it has ever rendered once. Track the mac (or whatever identity the content depends on) and re-render when it changes - `currentMac !== lastMac`, not just a one-time boolean flag - or switching to a different device while the tab stays active leaves stale data on screen with no re-render. Use `.finally(hideSkeleton)` on the render promise, not `.then(hideSkeleton)` - if the render throws or its fetch rejects, `.then()` never runs and the skeleton is stuck visible forever; `.finally()` always runs regardless of outcome. The render call itself must also sit inside a `try`/`catch` (or be deferred into an async callback, as below) - the updater's own `setTimeout(updater, 200)` call is what keeps the polling loop alive, and if it sits *after* an inline render call that throws synchronously, the throw skips it and the loop dies permanently, leaving the skeleton stuck with no further retries ever.
|
||||
|
||||
**Show/hide symmetry:** `showSpinner()`/`hideSpinner()` (`front/js/common.js`) are a pair and must resolve their target the same way. `showSpinner()` calls `resolveSpinnerTarget()`; `hideSpinner()` must too, not an independent lookup (e.g. grabbing `$(".spinnerTarget").last()` in raw document order) - two different targeting strategies on the two halves of one show/hide pair means the one feature whose tab happens to sit earliest in the page's markup gets the wrong element locked in when the overlay animates closed. If you add a new global or shared show/hide pair, check that both halves call the identical resolver.
|
||||
|
||||
**Readiness gating:** a render function invoked from a per-tab polling updater that issues its own network call (GraphQL/REST) must be gated the same way an existing equivalent is - e.g. wrapped in `callAfterAppInitialized()` (`front/js/app-init.js`), which waits for both `isAppInitialized()` and the GraphQL server to be confirmed running before calling back. A render path added without this gate can fire its request at the earliest possible point in the page lifecycle (most likely when its tab loads as the *default* tab on page load, before the server is confirmed up) and fail. If the underlying request is wrapped in a cache keyed by id (e.g. a per-mac promise cache) that caches unconditionally, that one early failure poisons the cache entry permanently - nothing will ever retry it, even once the server comes up moments later. Any such cache must evict its own entry on rejection so a later poll tick gets a fresh attempt.
|
||||
|
||||
## Fetch once, filter client-side
|
||||
|
||||
When a page needs two or more related views over the same underlying data (e.g. a "currently valid" list and a "stale/historical" list derived from the same rows), issue one request and split the result client-side rather than issuing a second request with a different filter. The data doesn't change between the two views, so a second round-trip only adds latency and server load for no new information. Example: Session Info's "Known IPs" and "IP History" chip lists (`front/deviceDetailsSessionInfo.php`) both come from one `getSourcesFieldData()` call - the response is partitioned into the two lists by `entry.status` in JS, not fetched twice with two different status filters. This is also why `getSourcesFieldData()`/`getFieldViewDefinitions()` are themselves cached per-mac (`front/deviceDetails.php`) - so Field View and Session Info, which need the same underlying rows for different purposes, share one fetch instead of each issuing its own.
|
||||
|
||||
## Don't omit information to simplify a view
|
||||
|
||||
A UI must not be made worse by omitting information - if data exists and is relevant, default to showing it (with a status/staleness indicator if needed) rather than hiding it outright to make a view look cleaner. Example: Session Info's "Known IPs" chip list originally filtered out any plugin entry with `status === 'missing-in-last-scan'`, which produced a confusing "sometimes shows No data" bug even when a plugin had reported the IP recently - the fix was a separate "IP History" list for those entries (mirroring Field View's own "Not Reported" status badge for the same status, rather than hiding it), not filtering them out of existence. Reserve actual omission for genuinely irrelevant data (e.g. a field the user has no permission to see), not for data that's merely uncertain, pending, or outdated.
|
||||
|
||||
## Priority order for design decisions
|
||||
|
||||
When several options are all locally reasonable, resolve the choice in this order - highest wins on conflict:
|
||||
|
||||
@@ -73,6 +73,13 @@ Every mapped field (`objectPrimaryId`/`objectSecondaryId`/`watchedValue1-4`/`ext
|
||||
| `on_new_device` | When new device detected |
|
||||
| `on_notification` | When notification triggered |
|
||||
|
||||
## `all_plugins` Caching
|
||||
|
||||
`all_plugins` is loaded once (`initialise.py`'s `get_plugins_configs()`) and reused as the same object until a reload - but `update_api()` (`server/api.py`), which takes it as a parameter, runs on every scan cycle and plugin completion, not just on reload.
|
||||
|
||||
- If you write a function that derives something from `all_plugins` and gets called from `update_api()` or another frequent call site, cache the result instead of recomputing every call - retain a reference to the last-seen `all_plugins` list and compare it to the current one with `is`, not by `id(all_plugins)` alone (once the old list is garbage collected, Python can reuse that id for an unrelated object, wrongly serving a stale cached result for what is actually a new reload)
|
||||
- Pattern: `server/db/plugin_field_views.py`'s `get_all_device_field_views()`
|
||||
|
||||
## Plugin Formats
|
||||
|
||||
| Format | Purpose | Phase |
|
||||
|
||||
@@ -53,11 +53,22 @@ Both are plausible, well-written, and wrong. Reading the code first catches both
|
||||
- **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.
|
||||
- **Implementation checklist** — added to the PRD file itself when implementation actually starts (not at draft time), three boxes matching the "Implementing an approved PRD" section below: plan written before any code; test written and confirmed red against the pre-fix code before the fix was applied; no PRD filename/path/word left in committed code. Check each box only as it's actually done, not preemptively - the point is a forcing function visible in the file you're editing, not a formality.
|
||||
|
||||
## Before starting: check for an existing architecture-reference skill
|
||||
|
||||
If a skill already documents the subsystem the feature touches, load it before researching from scratch — don't re-derive call graphs or mechanism details that are already written down. If the feature touches a subsystem with no such skill, and understanding it required significant re-derivation from raw code, that's a signal to write one afterward so the next PRD in that area doesn't start from zero.
|
||||
|
||||
## Implementing an approved PRD
|
||||
|
||||
Add the Implementation checklist (see Structure above) to the PRD file as the first edit, before touching any other code - an unchecked, visible checklist in the file you're actively working in is the forcing function; a rule read once at skill-load time is not.
|
||||
|
||||
Before writing a single line of implementation code, write an explicit plan (via `EnterPlanMode` or an equivalent written plan) covering which files change, in what order, and which test(s) get written first. Understanding the PRD's design is not the same as planning its implementation - in practice these have been conflated, going straight from "I've read and understood this PRD" into code edits without ever writing either the plan or the test required below.
|
||||
|
||||
Then follow step 7 above literally, not just in spirit, at implementation time too: write the test, run it against the pre-fix code, confirm it fails for the right reason (a real behavioral mismatch, or a legitimate missing-interface error for a brand-new contract - not a setup bug), and only then write the fix. Writing the test after the implementation and asserting it "would have failed" is not equivalent - it's an unverified claim. If you catch yourself having implemented first, the correct recovery is to actually revert the implementation, confirm the test fails red against the reverted code, and only then reapply the fix - not to just add tests afterward and call it done.
|
||||
|
||||
Never reference a PRD's filename, path, or the word "PRD" in committed code, comments, docstrings, or commit messages - `PRDs/` is gitignored and local-only, so a reader of the committed code later has no way to find what it's pointing at, and the reference rots the moment the PRD file is deleted or moved. Explain the *why* self-containedly in the code itself (a design PRD's rationale restated in the comment, not a pointer to go read the PRD) instead.
|
||||
|
||||
## Where to save
|
||||
|
||||
`.gemini/internal-docs/PRDs/<kebab-case-name>.md`, unless the user specifies otherwise. Mark the status line (`**Status:** Draft — pending review`) so it's clear this hasn't been approved yet, and keep the author line accurate about who made the calls (a design discussion with an assistant is not sole assistant authorship).
|
||||
@@ -59,6 +59,7 @@ This is the scan-pipeline-local half of a bigger attribution system — see `dat
|
||||
8. **A correlated helper's `mac_column` argument silently binds to the helper's own inner row, not the caller's, whenever the helper's inner table already has a column of that name in scope - alias or no alias.** SQL resolves an unqualified name in the innermost enclosing scope first and only searches outward if nothing matches there; `nic_derived_presence_condition()`'s inner scan is `FROM Devices`, and `Devices` has a `devMac` column, so *any* bare `"devMac"` argument - not just one that happens to collide with an alias name - bound to the helper's own inner row. Every bare-`"devMac"` call site (both `Device Down` queries, `Disconnected`, `update_devLastConnection_from_CurrentScan()`) was affected: any NIC-covered device anywhere in `Devices` made every *other* absent device, including one with no NIC children at all, look NIC-derived-present, silently suppressing its real event or bumping its `devLastConnection`. (A qualified-but-colliding argument, e.g. `"nic_parent.devMac"` passed from a caller aliasing its own row `nic_parent` while the helper's own inner alias was also `nic_parent`, is the same root cause in a narrower form.) Fixed by requiring every caller to pass a qualified reference to *its own* table/alias (`"Devices.devMac"`, `"DevicesView.devMac"`) and having `nic_derived_presence_condition()` reject a bare `mac_column` outright, on top of the existing `presence_scan`/`nic_presence_parent`-collision guards. `current_scan_presence_condition()` doesn't need this: its inner scan is `FROM CurrentScan`, which has no `devMac` column, so a bare `"devMac"` has nothing to bind to inward and correctly falls back to the caller's row. Only caught by a test with two sibling devices in one DB where one should match and the other shouldn't - every single-device test passed regardless, because "any row" and "this row" are the same row when there's only one.
|
||||
|
||||
7. **`CurrentScan.scanMac`/`Events.eveMac` are permanently MAC-keyed, independent of the devGUID-as-PK migration.** See `database-patterns`' "Device Identity" section - `devMac` is today's actual schema PK, `devGUID` is the intended long-term identity, but a plugin can only ever report a MAC from network discovery, never an app-internal `devGUID`. Don't design around these tables ever becoming devGUID-keyed.
|
||||
9. **`insert_events()`'s `IP Changed` query evaluates every distinct present address for a MAC individually, not one `MIN()`-reduced candidate.** A device can have more than one simultaneously-valid, already-known address in the same family (e.g. two live IPv6 addresses); testing only the `MIN()`'d one against `devPrimaryIPv4`/`devPrimaryIPv6`/`devLastIP` can pick the one address that happens not to be sitting in the single-valued slot, firing a false event even though nothing actually changed. The fix makes "known" additive: an address counts as known if it matches the three-slot check *or* if it's been reported (and isn't `missing-in-last-scan`) by an eligible plugin in `Plugins_Objects`, via `_known_plugin_ip_addresses_sql()`'s use of `plugin_field_views.get_plugin_columns_for_field(all_plugins, "ip")` - the same plugin-to-column mapping the Field View tab uses. `all_plugins` is optional on both `insert_events()` and `process_scan()` (default `None`); when it's not eligible for any plugin (or a caller hasn't passed it), the additive half is skipped entirely and the query reduces to exactly the three-slot check. Addresses that fail `known()` are aggregated into one `Events` row per MAC per cycle (today's shape), listing every new address in `eveAdditionalInfo` rather than firing one row per address.
|
||||
|
||||
## When to read this vs. other docs/skills
|
||||
|
||||
|
||||
@@ -57,4 +57,4 @@ This does not apply to PRDs (`.gemini/internal-docs/PRDs/`) — those keep their
|
||||
|
||||
## Keep the three trees in sync
|
||||
|
||||
Most skills exist as three near-identical copies (`.claude/skills/<name>/SKILL.md`, `.gemini/skills/<name>/SKILL.md`, `.github/skills/<name>/SKILL.md` — see `.gemini/skills/skills-index/SKILL.md` for the pairing map). When a hygiene fix changes a skill's body, apply the same fix to all paired copies so they stay identical (frontmatter `name`/`description` may differ per tree's own convention; the body should not). `scripts/check_skill_pairs.py`'s `GROUPS` list only flags when some-but-not-all paired files changed in a diff — it doesn't check the bodies actually match, so a manual diff after editing is still worth it.
|
||||
Most skills exist as three near-identical copies (`.claude/skills/<name>/SKILL.md`, `.gemini/skills/<name>/SKILL.md`, `.github/skills/<name>/SKILL.md` - see `.gemini/skills/skills-index/SKILL.md` for the pairing map). When a hygiene fix changes a skill's body, apply the same fix to all paired copies so they stay identical (frontmatter `name`/`description` may differ per tree's own convention; the body should not). `scripts/check_skill_pairs.py` checks both: it flags a diff that touched some but not all of a group's paired files, and - for the groups the skills index documents as covering identical content - it also diffs the bodies directly and flags when they've actually drifted apart. The CI step calling it is non-blocking, so a manual diff after editing is still worth doing yourself.
|
||||
@@ -11,6 +11,30 @@ Don't introduce new UX behavior or visual patterns unless a PRD explicitly calls
|
||||
|
||||
Real, recent example: a presence-page Prev/Next pager was first built with custom `<button class="btn btn-xs">` elements floated in a `.box-header`. The DataTables pagination pattern (`dataTables_wrapper` / `dataTables_paginate` / `ul.pagination` / `li.paginate_button.previous|next`) already existed elsewhere in the app and does the exact same job. The custom version looked visually broken in the actual UI and had to be reimplemented using the existing pattern once that was caught in manual testing - reusing it also picked up dark-mode theming (`front/css/dark-patch.css`'s `.pagination li > a` / `.disabled` rules) for free, which the hand-rolled version didn't have. Grep first: e.g. `grep -rn "pagination\|paginate_button" front/` before adding a "previous/next" control of your own; the same applies to modals, filter inputs, badges, tooltips, tables - anything that already has an established shape somewhere in `front/`.
|
||||
|
||||
A second real example: a new device-details tab's status display was hand-written as `${icon} ${label}` plain text, and its field-group markup was approximated from memory (`<div class="form-group">` / plain `<div class="col-sm-8">`) instead of read from the actual existing tab it was meant to match. Both were wrong in ways that only showed up once rendered: the real status chip everywhere else in the app (`devices-table.js`, `network-tabs.js`, `network-api.js`) is `<span class="badge ${cssClass}">${iconHtml} ${label}</span>` via the shared `getStatusBadgeParts()`, not bare text; the real field-group markup needs `col-xs-12` on both the `.form-group` and its value `<div>` (plus `.input-group` on the value div), which a plain `grep` for the class names would have shown immediately. Approximating a reference pattern "from memory" instead of reading its actual current markup is the same mistake as not searching at all - read the real template/function that produces the reference UI, don't reconstruct it from a general impression of what it probably looks like.
|
||||
|
||||
A third real example, and the one to remember when the new thing isn't markup but behavior: Field View's per-field tables were first built as raw `<table><tbody>` HTML-string concatenation, when `pluginsCore.php`'s own `buildDT()` - a few hundred lines away, same feature area - already builds near-identical plugin-data tables: same DataTables paging/searching/ordering/lengthMenu options, same `createdCell` + `getFormControl()` cell-rendering approach. The fix was extracting the shared options into `getStandardDataTableOptions()` (`front/js/plugin_column_render.js`) and reusing the same cell-rendering pattern, rather than two parallel table implementations answering the same "render a plugin-sourced table" need. Duplication risk isn't only in markup/CSS - a JS initialization config or rendering callback can be just as duplicated. Before writing a new `.DataTable({...})` call (or any other substantial JS behavior-config object), grep for existing instances of it in the same subsystem first: `grep -rn '\.DataTable(' front/`.
|
||||
|
||||
## Loading skeletons and spinnerTarget
|
||||
|
||||
Two non-obvious mechanisms govern how a per-tab loading skeleton behaves, and skipping either produces a skeleton that visually escapes its own tab or gets stuck showing forever.
|
||||
|
||||
**Positioning:** `resolveSpinnerTarget()` (`front/js/common.js`) looks for `.spinnerTarget` among the active tab-pane's *direct children only* (`activePane.children(".spinnerTarget:visible")`), falling back to the pane itself if none is found. A skeleton needs its own `#panX` id added to `app.css`'s positioning-anchor rule (`#panDetails, #panSessions, #panPresence, #panEvents, #appEvents, #panSessionInfo { position: relative; }`) and must be a direct child of that pane - no wrapper `<div>` in between. Introducing an extra wrapper div (even just to group the skeleton with its content) breaks the direct-child lookup and the pane's own `position: relative`, and the skeleton escapes to overlay the whole page instead of staying inside its tab. Match the existing pattern exactly: `front/deviceDetailsPresence.php`'s `<?php require '.../skel_device_details_tab_presence.php'; ?>` as the very first line, no wrapper, `#panPresence` already in the anchor list.
|
||||
|
||||
**Lifecycle:** a per-tab polling updater (`setTimeout(updater, 200)`) that shows a skeleton while data loads and hides it once rendered needs to track *what it's waiting for*, not just whether it has ever rendered once. Track the mac (or whatever identity the content depends on) and re-render when it changes - `currentMac !== lastMac`, not just a one-time boolean flag - or switching to a different device while the tab stays active leaves stale data on screen with no re-render. Use `.finally(hideSkeleton)` on the render promise, not `.then(hideSkeleton)` - if the render throws or its fetch rejects, `.then()` never runs and the skeleton is stuck visible forever; `.finally()` always runs regardless of outcome. The render call itself must also sit inside a `try`/`catch` (or be deferred into an async callback, as below) - the updater's own `setTimeout(updater, 200)` call is what keeps the polling loop alive, and if it sits *after* an inline render call that throws synchronously, the throw skips it and the loop dies permanently, leaving the skeleton stuck with no further retries ever.
|
||||
|
||||
**Show/hide symmetry:** `showSpinner()`/`hideSpinner()` (`front/js/common.js`) are a pair and must resolve their target the same way. `showSpinner()` calls `resolveSpinnerTarget()`; `hideSpinner()` must too, not an independent lookup (e.g. grabbing `$(".spinnerTarget").last()` in raw document order) - two different targeting strategies on the two halves of one show/hide pair means the one feature whose tab happens to sit earliest in the page's markup gets the wrong element locked in when the overlay animates closed. If you add a new global or shared show/hide pair, check that both halves call the identical resolver.
|
||||
|
||||
**Readiness gating:** a render function invoked from a per-tab polling updater that issues its own network call (GraphQL/REST) must be gated the same way an existing equivalent is - e.g. wrapped in `callAfterAppInitialized()` (`front/js/app-init.js`), which waits for both `isAppInitialized()` and the GraphQL server to be confirmed running before calling back. A render path added without this gate can fire its request at the earliest possible point in the page lifecycle (most likely when its tab loads as the *default* tab on page load, before the server is confirmed up) and fail. If the underlying request is wrapped in a cache keyed by id (e.g. a per-mac promise cache) that caches unconditionally, that one early failure poisons the cache entry permanently - nothing will ever retry it, even once the server comes up moments later. Any such cache must evict its own entry on rejection so a later poll tick gets a fresh attempt.
|
||||
|
||||
## Fetch once, filter client-side
|
||||
|
||||
When a page needs two or more related views over the same underlying data (e.g. a "currently valid" list and a "stale/historical" list derived from the same rows), issue one request and split the result client-side rather than issuing a second request with a different filter. The data doesn't change between the two views, so a second round-trip only adds latency and server load for no new information. Example: Session Info's "Known IPs" and "IP History" chip lists (`front/deviceDetailsSessionInfo.php`) both come from one `getSourcesFieldData()` call - the response is partitioned into the two lists by `entry.status` in JS, not fetched twice with two different status filters. This is also why `getSourcesFieldData()`/`getFieldViewDefinitions()` are themselves cached per-mac (`front/deviceDetails.php`) - so Field View and Session Info, which need the same underlying rows for different purposes, share one fetch instead of each issuing its own.
|
||||
|
||||
## Don't omit information to simplify a view
|
||||
|
||||
A UI must not be made worse by omitting information - if data exists and is relevant, default to showing it (with a status/staleness indicator if needed) rather than hiding it outright to make a view look cleaner. Example: Session Info's "Known IPs" chip list originally filtered out any plugin entry with `status === 'missing-in-last-scan'`, which produced a confusing "sometimes shows No data" bug even when a plugin had reported the IP recently - the fix was a separate "IP History" list for those entries (mirroring Field View's own "Not Reported" status badge for the same status, rather than hiding it), not filtering them out of existence. Reserve actual omission for genuinely irrelevant data (e.g. a field the user has no permission to see), not for data that's merely uncertain, pending, or outdated.
|
||||
|
||||
## Priority order for design decisions
|
||||
|
||||
When several options are all locally reasonable, resolve the choice in this order - highest wins on conflict:
|
||||
|
||||
@@ -74,6 +74,13 @@ Every mapped field (`objectPrimaryId`/`objectSecondaryId`/`watchedValue1-4`/`ext
|
||||
- `on_new_device`: runs when new device detected
|
||||
- `on_notification`: runs when notification triggered
|
||||
|
||||
## `all_plugins` Caching
|
||||
|
||||
- `all_plugins` is loaded once (`get_plugins_configs()` in `initialise.py`) and reused as the same object until a config reload
|
||||
- `update_api()` (`server/api.py`) receives `all_plugins` but runs on every scan cycle and plugin completion - far more often than reloads happen
|
||||
- A function deriving something from `all_plugins`, called from a hot path like `update_api()`, should cache its result rather than recompute every call - retain a reference to the last-seen `all_plugins` list and compare it to the current one with `is`, not by `id(all_plugins)` alone (once the old list is garbage collected, Python can reuse that id for an unrelated object, wrongly serving a stale cached result for what is actually a new reload)
|
||||
- Example: `server/db/plugin_field_views.py`'s `get_all_device_field_views()`
|
||||
|
||||
## Plugin Formats
|
||||
|
||||
| Format | Purpose | Runs |
|
||||
|
||||
@@ -53,11 +53,22 @@ Both are plausible, well-written, and wrong. Reading the code first catches both
|
||||
- **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.
|
||||
- **Implementation checklist** — added to the PRD file itself when implementation actually starts (not at draft time), three boxes matching the "Implementing an approved PRD" section below: plan written before any code; test written and confirmed red against the pre-fix code before the fix was applied; no PRD filename/path/word left in committed code. Check each box only as it's actually done, not preemptively - the point is a forcing function visible in the file you're editing, not a formality.
|
||||
|
||||
## Before starting: check for an existing architecture-reference skill
|
||||
|
||||
If a skill already documents the subsystem the feature touches, load it before researching from scratch — don't re-derive call graphs or mechanism details that are already written down. If the feature touches a subsystem with no such skill, and understanding it required significant re-derivation from raw code, that's a signal to write one afterward so the next PRD in that area doesn't start from zero.
|
||||
|
||||
## Implementing an approved PRD
|
||||
|
||||
Add the Implementation checklist (see Structure above) to the PRD file as the first edit, before touching any other code - an unchecked, visible checklist in the file you're actively working in is the forcing function; a rule read once at skill-load time is not.
|
||||
|
||||
Before writing a single line of implementation code, write an explicit plan (via `EnterPlanMode` or an equivalent written plan) covering which files change, in what order, and which test(s) get written first. Understanding the PRD's design is not the same as planning its implementation - in practice these have been conflated, going straight from "I've read and understood this PRD" into code edits without ever writing either the plan or the test required below.
|
||||
|
||||
Then follow step 7 above literally, not just in spirit, at implementation time too: write the test, run it against the pre-fix code, confirm it fails for the right reason (a real behavioral mismatch, or a legitimate missing-interface error for a brand-new contract - not a setup bug), and only then write the fix. Writing the test after the implementation and asserting it "would have failed" is not equivalent - it's an unverified claim. If you catch yourself having implemented first, the correct recovery is to actually revert the implementation, confirm the test fails red against the reverted code, and only then reapply the fix - not to just add tests afterward and call it done.
|
||||
|
||||
Never reference a PRD's filename, path, or the word "PRD" in committed code, comments, docstrings, or commit messages - `PRDs/` is gitignored and local-only, so a reader of the committed code later has no way to find what it's pointing at, and the reference rots the moment the PRD file is deleted or moved. Explain the *why* self-containedly in the code itself (a design PRD's rationale restated in the comment, not a pointer to go read the PRD) instead.
|
||||
|
||||
## Where to save
|
||||
|
||||
`.gemini/internal-docs/PRDs/<kebab-case-name>.md`, unless the user specifies otherwise. Mark the status line (`**Status:** Draft — pending review`) so it's clear this hasn't been approved yet, and keep the author line accurate about who made the calls (a design discussion with an assistant is not sole assistant authorship).
|
||||
@@ -59,6 +59,7 @@ This is the scan-pipeline-local half of a bigger attribution system — see `dat
|
||||
8. **A correlated helper's `mac_column` argument silently binds to the helper's own inner row, not the caller's, whenever the helper's inner table already has a column of that name in scope - alias or no alias.** SQL resolves an unqualified name in the innermost enclosing scope first and only searches outward if nothing matches there; `nic_derived_presence_condition()`'s inner scan is `FROM Devices`, and `Devices` has a `devMac` column, so *any* bare `"devMac"` argument - not just one that happens to collide with an alias name - bound to the helper's own inner row. Every bare-`"devMac"` call site (both `Device Down` queries, `Disconnected`, `update_devLastConnection_from_CurrentScan()`) was affected: any NIC-covered device anywhere in `Devices` made every *other* absent device, including one with no NIC children at all, look NIC-derived-present, silently suppressing its real event or bumping its `devLastConnection`. (A qualified-but-colliding argument, e.g. `"nic_parent.devMac"` passed from a caller aliasing its own row `nic_parent` while the helper's own inner alias was also `nic_parent`, is the same root cause in a narrower form.) Fixed by requiring every caller to pass a qualified reference to *its own* table/alias (`"Devices.devMac"`, `"DevicesView.devMac"`) and having `nic_derived_presence_condition()` reject a bare `mac_column` outright, on top of the existing `presence_scan`/`nic_presence_parent`-collision guards. `current_scan_presence_condition()` doesn't need this: its inner scan is `FROM CurrentScan`, which has no `devMac` column, so a bare `"devMac"` has nothing to bind to inward and correctly falls back to the caller's row. Only caught by a test with two sibling devices in one DB where one should match and the other shouldn't - every single-device test passed regardless, because "any row" and "this row" are the same row when there's only one.
|
||||
|
||||
7. **`CurrentScan.scanMac`/`Events.eveMac` are permanently MAC-keyed, independent of the devGUID-as-PK migration.** See `database-patterns`' "Device Identity" section - `devMac` is today's actual schema PK, `devGUID` is the intended long-term identity, but a plugin can only ever report a MAC from network discovery, never an app-internal `devGUID`. Don't design around these tables ever becoming devGUID-keyed.
|
||||
9. **`insert_events()`'s `IP Changed` query evaluates every distinct present address for a MAC individually, not one `MIN()`-reduced candidate.** A device can have more than one simultaneously-valid, already-known address in the same family (e.g. two live IPv6 addresses); testing only the `MIN()`'d one against `devPrimaryIPv4`/`devPrimaryIPv6`/`devLastIP` can pick the one address that happens not to be sitting in the single-valued slot, firing a false event even though nothing actually changed. The fix makes "known" additive: an address counts as known if it matches the three-slot check *or* if it's been reported (and isn't `missing-in-last-scan`) by an eligible plugin in `Plugins_Objects`, via `_known_plugin_ip_addresses_sql()`'s use of `plugin_field_views.get_plugin_columns_for_field(all_plugins, "ip")` - the same plugin-to-column mapping the Field View tab uses. `all_plugins` is optional on both `insert_events()` and `process_scan()` (default `None`); when it's not eligible for any plugin (or a caller hasn't passed it), the additive half is skipped entirely and the query reduces to exactly the three-slot check. Addresses that fail `known()` are aggregated into one `Events` row per MAC per cycle (today's shape), listing every new address in `eveAdditionalInfo` rather than firing one row per address.
|
||||
|
||||
## When to read this vs. other docs/skills
|
||||
|
||||
|
||||
@@ -57,4 +57,4 @@ This does not apply to PRDs (`.gemini/internal-docs/PRDs/`) — those keep their
|
||||
|
||||
## Keep the three trees in sync
|
||||
|
||||
Most skills exist as three near-identical copies (`.claude/skills/<name>/SKILL.md`, `.gemini/skills/<name>/SKILL.md`, `.github/skills/<name>/SKILL.md` — see `.gemini/skills/skills-index/SKILL.md` for the pairing map). When a hygiene fix changes a skill's body, apply the same fix to all paired copies so they stay identical (frontmatter `name`/`description` may differ per tree's own convention; the body should not). `scripts/check_skill_pairs.py`'s `GROUPS` list only flags when some-but-not-all paired files changed in a diff — it doesn't check the bodies actually match, so a manual diff after editing is still worth it.
|
||||
Most skills exist as three near-identical copies (`.claude/skills/<name>/SKILL.md`, `.gemini/skills/<name>/SKILL.md`, `.github/skills/<name>/SKILL.md` - see `.gemini/skills/skills-index/SKILL.md` for the pairing map). When a hygiene fix changes a skill's body, apply the same fix to all paired copies so they stay identical (frontmatter `name`/`description` may differ per tree's own convention; the body should not). `scripts/check_skill_pairs.py` checks both: it flags a diff that touched some but not all of a group's paired files, and - for the groups the skills index documents as covering identical content - it also diffs the bodies directly and flags when they've actually drifted apart. The CI step calling it is non-blocking, so a manual diff after editing is still worth doing yourself.
|
||||
@@ -11,6 +11,30 @@ Don't introduce new UX behavior or visual patterns unless a PRD explicitly calls
|
||||
|
||||
Real, recent example: a presence-page Prev/Next pager was first built with custom `<button class="btn btn-xs">` elements floated in a `.box-header`. The DataTables pagination pattern (`dataTables_wrapper` / `dataTables_paginate` / `ul.pagination` / `li.paginate_button.previous|next`) already existed elsewhere in the app and does the exact same job. The custom version looked visually broken in the actual UI and had to be reimplemented using the existing pattern once that was caught in manual testing - reusing it also picked up dark-mode theming (`front/css/dark-patch.css`'s `.pagination li > a` / `.disabled` rules) for free, which the hand-rolled version didn't have. Grep first: e.g. `grep -rn "pagination\|paginate_button" front/` before adding a "previous/next" control of your own; the same applies to modals, filter inputs, badges, tooltips, tables - anything that already has an established shape somewhere in `front/`.
|
||||
|
||||
A second real example: a new device-details tab's status display was hand-written as `${icon} ${label}` plain text, and its field-group markup was approximated from memory (`<div class="form-group">` / plain `<div class="col-sm-8">`) instead of read from the actual existing tab it was meant to match. Both were wrong in ways that only showed up once rendered: the real status chip everywhere else in the app (`devices-table.js`, `network-tabs.js`, `network-api.js`) is `<span class="badge ${cssClass}">${iconHtml} ${label}</span>` via the shared `getStatusBadgeParts()`, not bare text; the real field-group markup needs `col-xs-12` on both the `.form-group` and its value `<div>` (plus `.input-group` on the value div), which a plain `grep` for the class names would have shown immediately. Approximating a reference pattern "from memory" instead of reading its actual current markup is the same mistake as not searching at all - read the real template/function that produces the reference UI, don't reconstruct it from a general impression of what it probably looks like.
|
||||
|
||||
A third real example, and the one to remember when the new thing isn't markup but behavior: Field View's per-field tables were first built as raw `<table><tbody>` HTML-string concatenation, when `pluginsCore.php`'s own `buildDT()` - a few hundred lines away, same feature area - already builds near-identical plugin-data tables: same DataTables paging/searching/ordering/lengthMenu options, same `createdCell` + `getFormControl()` cell-rendering approach. The fix was extracting the shared options into `getStandardDataTableOptions()` (`front/js/plugin_column_render.js`) and reusing the same cell-rendering pattern, rather than two parallel table implementations answering the same "render a plugin-sourced table" need. Duplication risk isn't only in markup/CSS - a JS initialization config or rendering callback can be just as duplicated. Before writing a new `.DataTable({...})` call (or any other substantial JS behavior-config object), grep for existing instances of it in the same subsystem first: `grep -rn '\.DataTable(' front/`.
|
||||
|
||||
## Loading skeletons and spinnerTarget
|
||||
|
||||
Two non-obvious mechanisms govern how a per-tab loading skeleton behaves, and skipping either produces a skeleton that visually escapes its own tab or gets stuck showing forever.
|
||||
|
||||
**Positioning:** `resolveSpinnerTarget()` (`front/js/common.js`) looks for `.spinnerTarget` among the active tab-pane's *direct children only* (`activePane.children(".spinnerTarget:visible")`), falling back to the pane itself if none is found. A skeleton needs its own `#panX` id added to `app.css`'s positioning-anchor rule (`#panDetails, #panSessions, #panPresence, #panEvents, #appEvents, #panSessionInfo { position: relative; }`) and must be a direct child of that pane - no wrapper `<div>` in between. Introducing an extra wrapper div (even just to group the skeleton with its content) breaks the direct-child lookup and the pane's own `position: relative`, and the skeleton escapes to overlay the whole page instead of staying inside its tab. Match the existing pattern exactly: `front/deviceDetailsPresence.php`'s `<?php require '.../skel_device_details_tab_presence.php'; ?>` as the very first line, no wrapper, `#panPresence` already in the anchor list.
|
||||
|
||||
**Lifecycle:** a per-tab polling updater (`setTimeout(updater, 200)`) that shows a skeleton while data loads and hides it once rendered needs to track *what it's waiting for*, not just whether it has ever rendered once. Track the mac (or whatever identity the content depends on) and re-render when it changes - `currentMac !== lastMac`, not just a one-time boolean flag - or switching to a different device while the tab stays active leaves stale data on screen with no re-render. Use `.finally(hideSkeleton)` on the render promise, not `.then(hideSkeleton)` - if the render throws or its fetch rejects, `.then()` never runs and the skeleton is stuck visible forever; `.finally()` always runs regardless of outcome. The render call itself must also sit inside a `try`/`catch` (or be deferred into an async callback, as below) - the updater's own `setTimeout(updater, 200)` call is what keeps the polling loop alive, and if it sits *after* an inline render call that throws synchronously, the throw skips it and the loop dies permanently, leaving the skeleton stuck with no further retries ever.
|
||||
|
||||
**Show/hide symmetry:** `showSpinner()`/`hideSpinner()` (`front/js/common.js`) are a pair and must resolve their target the same way. `showSpinner()` calls `resolveSpinnerTarget()`; `hideSpinner()` must too, not an independent lookup (e.g. grabbing `$(".spinnerTarget").last()` in raw document order) - two different targeting strategies on the two halves of one show/hide pair means the one feature whose tab happens to sit earliest in the page's markup gets the wrong element locked in when the overlay animates closed. If you add a new global or shared show/hide pair, check that both halves call the identical resolver.
|
||||
|
||||
**Readiness gating:** a render function invoked from a per-tab polling updater that issues its own network call (GraphQL/REST) must be gated the same way an existing equivalent is - e.g. wrapped in `callAfterAppInitialized()` (`front/js/app-init.js`), which waits for both `isAppInitialized()` and the GraphQL server to be confirmed running before calling back. A render path added without this gate can fire its request at the earliest possible point in the page lifecycle (most likely when its tab loads as the *default* tab on page load, before the server is confirmed up) and fail. If the underlying request is wrapped in a cache keyed by id (e.g. a per-mac promise cache) that caches unconditionally, that one early failure poisons the cache entry permanently - nothing will ever retry it, even once the server comes up moments later. Any such cache must evict its own entry on rejection so a later poll tick gets a fresh attempt.
|
||||
|
||||
## Fetch once, filter client-side
|
||||
|
||||
When a page needs two or more related views over the same underlying data (e.g. a "currently valid" list and a "stale/historical" list derived from the same rows), issue one request and split the result client-side rather than issuing a second request with a different filter. The data doesn't change between the two views, so a second round-trip only adds latency and server load for no new information. Example: Session Info's "Known IPs" and "IP History" chip lists (`front/deviceDetailsSessionInfo.php`) both come from one `getSourcesFieldData()` call - the response is partitioned into the two lists by `entry.status` in JS, not fetched twice with two different status filters. This is also why `getSourcesFieldData()`/`getFieldViewDefinitions()` are themselves cached per-mac (`front/deviceDetails.php`) - so Field View and Session Info, which need the same underlying rows for different purposes, share one fetch instead of each issuing its own.
|
||||
|
||||
## Don't omit information to simplify a view
|
||||
|
||||
A UI must not be made worse by omitting information - if data exists and is relevant, default to showing it (with a status/staleness indicator if needed) rather than hiding it outright to make a view look cleaner. Example: Session Info's "Known IPs" chip list originally filtered out any plugin entry with `status === 'missing-in-last-scan'`, which produced a confusing "sometimes shows No data" bug even when a plugin had reported the IP recently - the fix was a separate "IP History" list for those entries (mirroring Field View's own "Not Reported" status badge for the same status, rather than hiding it), not filtering them out of existence. Reserve actual omission for genuinely irrelevant data (e.g. a field the user has no permission to see), not for data that's merely uncertain, pending, or outdated.
|
||||
|
||||
## Priority order for design decisions
|
||||
|
||||
When several options are all locally reasonable, resolve the choice in this order - highest wins on conflict:
|
||||
|
||||
@@ -87,6 +87,7 @@ Procedural/how-to knowledge (running tests, resetting the DB, devcontainer manag
|
||||
- Every `subprocess` call needs an explicit timeout; a nested subprocess call needs its own — an outer timeout doesn't propagate.
|
||||
- Always run MACs through `normalize_mac()` (`plugin_helper.py`) before writing to DB; MAC literals in tests must be lowercase.
|
||||
- No inline imports — everything at module top level.
|
||||
- No unnecessary inline `style="..."` attributes in `front/` markup. A static value (the same on every render) belongs in `front/css/app.css` as a rule keyed to the element's id/class, not scattered across inline attributes that bypass dark-mode/responsive theming conventions. Only a genuinely dynamic/computed value (e.g. a JS-measured offset set at render time) belongs inline.
|
||||
- Reuse `test/db_test_helpers.py` for DB mocks/fixtures in tests rather than redefining `DummyDB`/`make_db` locally.
|
||||
- Keep files under ~500 lines; split rather than grow.
|
||||
- Every Python function/method gets a succinct docstring describing its current use and behavior — one or two sentences, not a changelog of what changed or why (that belongs in the commit/PR, not the docstring). Same rule for JS: a JSDoc `/** ... */` block, not a plain `//` line above the function. Whenever you touch a function that only has a plain description comment (Python or JS), convert it to a proper docstring as part of that edit rather than leaving the old style next to new code.
|
||||
|
||||
@@ -104,8 +104,6 @@
|
||||
const apiBaseUrl = getApiBase();
|
||||
const url = `${apiBaseUrl}/graphql`;
|
||||
|
||||
console.log(url);
|
||||
|
||||
// Resolve device filter from MAC if present
|
||||
let devGUID = null;
|
||||
if (urlMac) {
|
||||
|
||||
+115
-1
@@ -2173,6 +2173,9 @@ textarea[readonly],
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1135px) {
|
||||
.dev-detail-tab-name
|
||||
{
|
||||
display: none;
|
||||
@@ -2213,6 +2216,22 @@ textarea[readonly],
|
||||
.plugin-content
|
||||
{
|
||||
padding-bottom: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
/* Its col-sm-2/col-sm-10 children float without a .row wrapper, so without
|
||||
this the container collapses to 0 height and the floated content bleeds
|
||||
past it instead of being contained. */
|
||||
.plugin-content:before,
|
||||
.plugin-content:after
|
||||
{
|
||||
content: " ";
|
||||
display: table;
|
||||
}
|
||||
|
||||
.plugin-content:after
|
||||
{
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.plugin-content .nav-tabs li a
|
||||
@@ -2220,6 +2239,12 @@ textarea[readonly],
|
||||
border-right-width: 0px;
|
||||
}
|
||||
|
||||
#tabs-content-location-wrap,
|
||||
#field-tabs-content-location-wrap
|
||||
{
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#tabs-content-location-wrap
|
||||
{
|
||||
min-height: 90vh;
|
||||
@@ -2236,6 +2261,11 @@ textarea[readonly],
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
#field-tabs-location
|
||||
{
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.plugin-content #tabs-content-location
|
||||
{
|
||||
margin: 0px;
|
||||
@@ -2265,6 +2295,90 @@ textarea[readonly],
|
||||
font-size: x-small;
|
||||
}
|
||||
|
||||
.field-view-content
|
||||
{
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.plugin-tab-nav
|
||||
{
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
/* #navDevice .tab-content resets padding to 0px for the whole device-details
|
||||
tab area - outrank it explicitly (same specificity otherwise, source order
|
||||
would decide) so Sources tab's two sub-views keep their padding. */
|
||||
#navDevice #tabs-content-location .tab-content,
|
||||
#navDevice #field-tabs-content-location .tab-content
|
||||
{
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.plugin-tab-pane
|
||||
{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skel-plugins-tab-pane
|
||||
{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.plugin-no-data
|
||||
{
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.field-pane-table
|
||||
{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-sources-li
|
||||
{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#tabSources
|
||||
{
|
||||
transition: padding-right 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-sources-li.active #tabSources
|
||||
{
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.sources-view-toggle-inline
|
||||
{
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.tab-sources-li.active .sources-view-toggle-inline
|
||||
{
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sources-view-toggle .dropdown-toggle
|
||||
{
|
||||
cursor: pointer;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.sources-view-toggle .dropdown-toggle:hover,
|
||||
.sources-view-toggle .dropdown-toggle:focus
|
||||
{
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pluginsCore .tab-content
|
||||
{
|
||||
padding-left: 5px;
|
||||
@@ -2814,7 +2928,7 @@ table.dataTable tbody > tr.selected
|
||||
/* Positioning anchors */
|
||||
#settingsPage, #devicesPage, #deviceDetailsPage,
|
||||
#eventsPage, #presencePage, #reportPage, #notifications, #wf-content-wrapper, #pluginsPage,
|
||||
#panDetails, #panSessions, #panPresence, #panEvents, #appEvents {
|
||||
#panDetails, #panSessions, #panPresence, #panEvents, #appEvents, #panSessionInfo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -521,6 +521,25 @@ textarea[readonly],
|
||||
color: var(--color-lightgray);
|
||||
border: 1px solid #171c20;
|
||||
}
|
||||
.sources-view-toggle .dropdown-toggle {
|
||||
color: #8e959b;
|
||||
}
|
||||
.sources-view-toggle .dropdown-toggle:hover,
|
||||
.sources-view-toggle .dropdown-toggle:focus {
|
||||
color: var(--color-lightgray);
|
||||
}
|
||||
#sourcesViewMenu {
|
||||
background-color: #4c5761;
|
||||
border: 1px solid #171c20;
|
||||
}
|
||||
#sourcesViewMenu > li > a {
|
||||
color: var(--color-lightgray);
|
||||
}
|
||||
#sourcesViewMenu > li > a:hover,
|
||||
#sourcesViewMenu > li > a:focus {
|
||||
background-color: #353c42;
|
||||
color: var(--color-lightgray);
|
||||
}
|
||||
.table-striped > tbody > tr:nth-of-type(2n + 1) {
|
||||
background-color: #2d343a;
|
||||
}
|
||||
|
||||
@@ -517,6 +517,25 @@
|
||||
color: #bec5cb;
|
||||
border: 1px solid #171c20;
|
||||
}
|
||||
.sources-view-toggle .dropdown-toggle {
|
||||
color: #8e959b;
|
||||
}
|
||||
.sources-view-toggle .dropdown-toggle:hover,
|
||||
.sources-view-toggle .dropdown-toggle:focus {
|
||||
color: var(--color-lightgray);
|
||||
}
|
||||
#sourcesViewMenu {
|
||||
background-color: #4c5761;
|
||||
border: 1px solid #171c20;
|
||||
}
|
||||
#sourcesViewMenu > li > a {
|
||||
color: var(--color-lightgray);
|
||||
}
|
||||
#sourcesViewMenu > li > a:hover,
|
||||
#sourcesViewMenu > li > a:focus {
|
||||
background-color: #353c42;
|
||||
color: var(--color-lightgray);
|
||||
}
|
||||
.table-striped > tbody > tr:nth-of-type(2n + 1) {
|
||||
background-color: #2d343a;
|
||||
}
|
||||
|
||||
+183
-8
@@ -71,6 +71,14 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="tabSessionInfo" href="#panSessionInfo" data-toggle="tab">
|
||||
<i class="fa fa-square-poll-horizontal"></i>
|
||||
<span class="dev-detail-tab-name">
|
||||
<?= lang('DevDetail_SessionInfo_Title');?>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="tabTools" href="#panTools" data-toggle="tab">
|
||||
<i class="fa fa-screwdriver-wrench"></i>
|
||||
@@ -103,13 +111,22 @@
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a id="tabPlugins" href="#panPlugins" data-toggle="tab">
|
||||
<li class="tab-sources-li">
|
||||
<a id="tabSources" href="#panSources" data-toggle="tab">
|
||||
<i class="fa fa-plug"></i>
|
||||
<span class="dev-detail-tab-name">
|
||||
<?= lang('DevDetail_Tab_Plugins');?>
|
||||
<?= lang('DevDetail_Tab_Sources');?>
|
||||
</span>
|
||||
</a>
|
||||
<div class="dropdown sources-view-toggle sources-view-toggle-inline">
|
||||
<a href="#" id="sourcesViewToggle" class="dropdown-toggle" data-toggle="dropdown" data-view="plugin">
|
||||
<i class="fa fa-puzzle-piece"></i>
|
||||
</a>
|
||||
<ul id="sourcesViewMenu" class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="#" data-view="plugin" onclick="setSourcesView('plugin'); return false;"><i class="fa fa-puzzle-piece"></i> <?= lang('DevDetail_Sources_PluginView');?></a></li>
|
||||
<li><a href="#" data-view="field" onclick="setSourcesView('field'); return false;"><i class="fa fa-list"></i> <?= lang('DevDetail_Sources_FieldView');?></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a id="tabHistory" href="#panHistory" data-toggle="tab">
|
||||
@@ -138,6 +155,11 @@
|
||||
require 'deviceDetailsEdit.php';
|
||||
?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="panSessionInfo">
|
||||
<?php
|
||||
require 'deviceDetailsSessionInfo.php';
|
||||
?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="panSessions">
|
||||
<?php
|
||||
require 'deviceDetailsSessions.php';
|
||||
@@ -160,11 +182,18 @@
|
||||
include 'deviceDetailsEvents.php';
|
||||
?>
|
||||
</div>
|
||||
<div class="tab-pane fade table-responsive" id="panPlugins">
|
||||
<?php
|
||||
// Include the other page
|
||||
include 'pluginsCore.php';
|
||||
?>
|
||||
<div class="tab-pane fade" id="panSources">
|
||||
<div id="sourcesPluginView">
|
||||
<?php
|
||||
// Include the other page
|
||||
include 'pluginsCore.php';
|
||||
?>
|
||||
</div>
|
||||
<div id="sourcesFieldView" style="display:none;">
|
||||
<?php
|
||||
include 'pluginsFieldView.php';
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="panHistory">
|
||||
@@ -225,6 +254,151 @@ switch ($UI_THEME) {
|
||||
var selectedTab = 'tabDetails';
|
||||
var emptyArr = ['undefined', "", undefined, null];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shared Sources data cache: Field View and the Session Info tab's Known IPs
|
||||
// chip list both read the same pluginsObjects(objectPrimaryId = mac) query -
|
||||
// keyed by mac so a second caller gets the cached promise, not a second
|
||||
// network call. Neither consumer should issue its own pluginsObjects query.
|
||||
const _sourcesFieldDataCache = {};
|
||||
|
||||
/**
|
||||
* Fetch (or return the cached/in-flight promise for) this mac's pluginsObjects
|
||||
* entries - shared by Field View and the Session Info tab's Known IPs chip list.
|
||||
* @param {string} mac
|
||||
* @returns {Promise<object[]>} Rejects on an ajax failure or a GraphQL-level error.
|
||||
*/
|
||||
function getSourcesFieldData(mac) {
|
||||
if (_sourcesFieldDataCache[mac]) {
|
||||
return _sourcesFieldDataCache[mac];
|
||||
}
|
||||
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const apiBaseUrl = getApiBase();
|
||||
|
||||
const query = `
|
||||
query PluginsObjectsForMac($options: PluginQueryOptionsInput) {
|
||||
pluginsObjects(options: $options) {
|
||||
entries {
|
||||
plugin objectSecondaryId
|
||||
watchedValue1 watchedValue2 watchedValue3 watchedValue4
|
||||
status dateTimeChanged
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const promise = $.ajax({
|
||||
method: "POST",
|
||||
url: `${apiBaseUrl}/graphql`,
|
||||
headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
||||
data: JSON.stringify({
|
||||
query,
|
||||
variables: { options: { filters: [{ filterColumn: "objectPrimaryId", filterValue: mac }] } }
|
||||
}),
|
||||
dataType: "json"
|
||||
}).then(function(response) {
|
||||
if (response.errors) {
|
||||
console.error("[Sources] pluginsObjects GraphQL errors:", response.errors);
|
||||
// Reject (not resolve with []) - an empty-but-successful result would
|
||||
// skip the cache eviction below, permanently poisoning this mac's
|
||||
// entry with no way to retry.
|
||||
throw new Error("pluginsObjects GraphQL error");
|
||||
}
|
||||
return response.data.pluginsObjects.entries;
|
||||
});
|
||||
|
||||
// Don't cache a failed request forever - a transient failure (e.g. the
|
||||
// GraphQL server not up yet on first page load) would otherwise poison
|
||||
// this mac's entry for the rest of the page's life, with no way to retry.
|
||||
promise.catch(() => { delete _sourcesFieldDataCache[mac]; });
|
||||
|
||||
_sourcesFieldDataCache[mac] = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shared field_views lookup: both Field View and the Session Info tab's Known
|
||||
// IPs chip list need to know, PER PLUGIN, which exact Plugins_Objects column
|
||||
// (objectSecondaryId/watchedValue1-4) holds a given field's value - that
|
||||
// mapping is computed server-side (get_plugin_columns_for_field()) and shipped
|
||||
// in plugins.json's field_views block.
|
||||
let _fieldViewDefinitionsCache = null;
|
||||
|
||||
/**
|
||||
* Fetch (or return the cached) field_views block from plugins.json - which
|
||||
* plugin/column answers each DEVICE_FIELD_VIEWS field. Shared by Field View
|
||||
* and the Session Info tab's Known IPs chip list, so neither one falls back
|
||||
* to "grab whichever column happens to be non-empty" and silently picks up
|
||||
* unrelated data (e.g. a port number) from a plugin never registered for the field.
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function getFieldViewDefinitions() {
|
||||
if (_fieldViewDefinitionsCache) {
|
||||
return _fieldViewDefinitionsCache;
|
||||
}
|
||||
const response = await fetch(`php/server/query_json.php?file=${encodeURIComponent('plugins.json')}&nocache=${Date.now()}`);
|
||||
if (!response.ok) throw new Error('Failed to load plugins.json');
|
||||
const json = await response.json();
|
||||
_fieldViewDefinitionsCache = json.field_views || {};
|
||||
return _fieldViewDefinitionsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a field's value for one plugin's pluginsObjects entry, using the
|
||||
* exact column role field_views registered for that plugin - not a fallback
|
||||
* chain over whichever watchedValue happens to be non-empty.
|
||||
* @param {{plugin: string, column: string}[]} fieldColumns
|
||||
* @param {object} entry - One pluginsObjects entry.
|
||||
* @returns {*} The entry's value for this field, or undefined if entry's plugin isn't registered for it.
|
||||
*/
|
||||
function resolveFieldValueForPlugin(fieldColumns, entry) {
|
||||
const match = fieldColumns.find(c => c.plugin === entry.plugin);
|
||||
return match ? entry[match.column] : undefined;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sources tab: Plugin View / Field View toggle. Plugin View is the default
|
||||
// and the fallback for anyone who's never touched the toggle, matching
|
||||
// today's only behavior.
|
||||
/**
|
||||
* Switch the Sources tab between Plugin View and Field View: toggles the two
|
||||
* panes, updates the toggle button's icon/data-view, and persists the choice.
|
||||
* ?tab=<plugin_prefix> (an existing deep-link into Plugin View) and
|
||||
* ?field=<field_key> (Field View) are two separate URL params on purpose -
|
||||
* neither should have to disambiguate the other's value.
|
||||
* @param {('plugin'|'field')} view
|
||||
*/
|
||||
function setSourcesView(view) {
|
||||
const isField = view === 'field';
|
||||
$('#sourcesFieldView').toggle(isField);
|
||||
$('#sourcesPluginView').toggle(!isField);
|
||||
$('#sourcesViewToggle')
|
||||
.attr('data-view', view)
|
||||
.find('i').attr('class', isField ? 'fa fa-list' : 'fa fa-puzzle-piece');
|
||||
setCache('activeSourcesView', view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Sources tab's initial view on page load, from (in priority order)
|
||||
* the ?field= URL param, the ?tab= URL param, the cached last-used view, or
|
||||
* the 'plugin' default.
|
||||
*/
|
||||
function initSourcesViewToggle() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (urlParams.has('field')) {
|
||||
setSourcesView('field');
|
||||
return;
|
||||
}
|
||||
if (urlParams.has('tab')) {
|
||||
setSourcesView('plugin');
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getCache('activeSourcesView');
|
||||
setSourcesView(!emptyArr.includes(cached) ? cached : 'plugin');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
function main () {
|
||||
|
||||
@@ -594,6 +768,7 @@ window.onload = function() {
|
||||
updateChevrons(mac);
|
||||
await renderSmallBoxes();
|
||||
main();
|
||||
initSourcesViewToggle();
|
||||
hideDeviceDetailsSkeleton();
|
||||
});
|
||||
|
||||
|
||||
+39
-20
@@ -40,6 +40,11 @@ require_once $_SERVER["DOCUMENT_ROOT"] . "/php/templates/security.php"; ?>
|
||||
// Global variable to store device data for access by toggleFieldLock and other functions
|
||||
let deviceData = {};
|
||||
|
||||
// Global lookup of NEWDEV/CUSTPROP settings by setKey (e.g. "NEWDEV_devPrimaryIPv4"),
|
||||
// populated once the settings fetch below resolves. Session Info reuses setName from
|
||||
// here for its own labels instead of a second, hand-maintained lang key with the same text.
|
||||
let newdevSettingsByKey = {};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Get plugin and settings data from API endpoints
|
||||
function getDeviceData() {
|
||||
@@ -107,6 +112,9 @@ function getDeviceData() {
|
||||
|
||||
const settingsData = response.data.settings.settings;
|
||||
|
||||
newdevSettingsByKey = {};
|
||||
settingsData.forEach(s => { newdevSettingsByKey[s.setKey] = s; });
|
||||
|
||||
// columns to hide
|
||||
hiddenFields = ["NEWDEV_devScan", "NEWDEV_devPresentLastScan"]
|
||||
// columns to disable/readonly - conditional depending if a new dummy device is created
|
||||
@@ -163,16 +171,7 @@ function getDeviceData() {
|
||||
inputGroupClasses: "field-group display-group col-lg-4 col-sm-6 col-xs-12",
|
||||
labelClasses: "col-sm-4 col-xs-12 control-label",
|
||||
inputClasses: "col-sm-8 col-xs-12 input-group"
|
||||
},
|
||||
// Group for session information
|
||||
DevDetail_SessionInfo_Title: {
|
||||
data: ["devPrimaryIPv4", "devPrimaryIPv6", "devStatus", "devLastConnection", "devFirstConnection", "devFQDN"],
|
||||
docs: "https://docs.netalertx.com/SESSION_INFO",
|
||||
iconClass: "fa fa-calendar",
|
||||
inputGroupClasses: "field-group session-group col-lg-4 col-sm-6 col-xs-12",
|
||||
labelClasses: "col-sm-4 col-xs-12 control-label",
|
||||
inputClasses: "col-sm-8 col-xs-12 input-group"
|
||||
},
|
||||
},
|
||||
// Group for Custom properties.
|
||||
DevDetail_CustomProperties_Title: {
|
||||
data: ["devCustomProps"],
|
||||
@@ -191,6 +190,7 @@ function getDeviceData() {
|
||||
labelClasses: "col-sm-12 col-xs-12 control-label",
|
||||
inputClasses: "col-sm-12 col-xs-12 input-group"
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
// Filter settings data to get relevant settings
|
||||
@@ -378,9 +378,27 @@ function getDeviceData() {
|
||||
hideSpinner();
|
||||
hideDetailsTabSkeleton();
|
||||
|
||||
}}); // $.get callback
|
||||
},
|
||||
error: function(xhr, status, err) {
|
||||
// Reset the initialized flag so deviceDetailsPageUpdater()'s next
|
||||
// tick retries instead of leaving the tab permanently stuck with
|
||||
// its skeleton hidden (by the page's own fallback timer) and the
|
||||
// form never populated - previously only a full page reload could
|
||||
// recover from this (e.g. a stale API token mid cache-refresh).
|
||||
console.error('[Device Details] settings fetch failed, will retry:', status, err);
|
||||
deviceDetailsPageInitialized = false;
|
||||
hideSpinner();
|
||||
hideDetailsTabSkeleton();
|
||||
}
|
||||
}); // $.ajax (settings)
|
||||
}, 100); // setTimeout
|
||||
} // ajax success
|
||||
}, // ajax success
|
||||
error: function(xhr, status, err) {
|
||||
console.error('[Device Details] device fetch failed, will retry:', status, err);
|
||||
deviceDetailsPageInitialized = false;
|
||||
hideSpinner();
|
||||
hideDetailsTabSkeleton();
|
||||
}
|
||||
}); // $.ajax
|
||||
} // getDeviceData
|
||||
|
||||
@@ -550,8 +568,10 @@ function toggleNetworkConfiguration(disable) {
|
||||
var deviceDetailsPageInitialized = false;
|
||||
|
||||
function initdeviceDetailsPage() {
|
||||
// Only proceed if .plugin-content is visible
|
||||
if (!$('#panDetails:visible').length) {
|
||||
// deviceData is shared by every tab (Details, Session Info, ...), not just
|
||||
// Details itself - check the shared .tab-content wrapper, not #panDetails,
|
||||
// or deviceData never loads when a different tab is the active/default one.
|
||||
if (!$('.tab-content:visible').length) {
|
||||
return; // exit early if nothing is visible
|
||||
}
|
||||
|
||||
@@ -574,12 +594,11 @@ function deviceDetailsPageUpdater() {
|
||||
setTimeout(deviceDetailsPageUpdater, 200);
|
||||
}
|
||||
|
||||
// if visible, load immediately, if not start updater
|
||||
if (!$('#panDetails:visible').length) {
|
||||
deviceDetailsPageUpdater();
|
||||
} else {
|
||||
getDeviceData();
|
||||
}
|
||||
// Always go through the poller - its first tick calls initdeviceDetailsPage()
|
||||
// immediately (no delay) whether the tab is already visible or not, and its
|
||||
// later ticks are what let a failed initial load (see getDeviceData()'s error
|
||||
// handlers above) retry automatically instead of needing a page refresh.
|
||||
deviceDetailsPageUpdater();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Lock/Unlock field to prevent plugin overwrites
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
//------------------------------------------------------------------------------
|
||||
// check if authenticated
|
||||
require_once $_SERVER["DOCUMENT_ROOT"] . "/php/templates/security.php";
|
||||
?>
|
||||
|
||||
<?php require $_SERVER['DOCUMENT_ROOT'] . '/php/templates/skel_device_details_tab_session_info.php'; ?>
|
||||
|
||||
<div class="row" id="deviceDetailsSessionInfo">
|
||||
<div class="box-body form-horizontal">
|
||||
|
||||
<div class="field-group session-group col-lg-4 col-sm-6 col-xs-12">
|
||||
<h5><i class="fa fa-calendar"></i> <?= lang('DevDetail_SessionInfo_Title');?>
|
||||
<span class="helpIconSmallTopRight">
|
||||
<a target="_blank" href="https://docs.netalertx.com/SESSION_INFO">
|
||||
<i class="fa fa-circle-question"></i>
|
||||
</a>
|
||||
</span>
|
||||
</h5>
|
||||
<hr>
|
||||
|
||||
<div class="form-group col-xs-12">
|
||||
<label class="col-sm-4 col-xs-12 control-label" id="sessionInfo_label_devPrimaryIPv4">
|
||||
<span id="sessionInfo_label_devPrimaryIPv4_text"></span>
|
||||
<i my-set-key="NEWDEV_devPrimaryIPv4"
|
||||
title="<?= lang('Settings_Show_Description');?>"
|
||||
class="fa fa-circle-info pointer helpIconSmallTopRight"
|
||||
onclick="showDescriptionPopup(this)">
|
||||
</i>
|
||||
</label>
|
||||
<div class="col-sm-8 col-xs-12 form-control-static" id="sessionInfo_devPrimaryIPv4"></div>
|
||||
</div>
|
||||
<div class="form-group col-xs-12">
|
||||
<label class="col-sm-4 col-xs-12 control-label" id="sessionInfo_label_devPrimaryIPv6">
|
||||
<span id="sessionInfo_label_devPrimaryIPv6_text"></span>
|
||||
<i my-set-key="NEWDEV_devPrimaryIPv6"
|
||||
title="<?= lang('Settings_Show_Description');?>"
|
||||
class="fa fa-circle-info pointer helpIconSmallTopRight"
|
||||
onclick="showDescriptionPopup(this)">
|
||||
</i>
|
||||
</label>
|
||||
<div class="col-sm-8 col-xs-12 form-control-static" id="sessionInfo_devPrimaryIPv6"></div>
|
||||
</div>
|
||||
<div class="form-group col-xs-12">
|
||||
<label class="col-sm-4 col-xs-12 control-label"><?= lang('Device_TableHead_Status');?>
|
||||
<i my-set-key="Device_TableHead_Status"
|
||||
title="<?= lang('Settings_Show_Description');?>"
|
||||
class="fa fa-circle-info pointer helpIconSmallTopRight"
|
||||
onclick="showDescriptionPopup(this)">
|
||||
</i>
|
||||
</label>
|
||||
<div class="col-sm-8 col-xs-12 form-control-static" id="sessionInfo_devStatus"></div>
|
||||
</div>
|
||||
<div class="form-group col-xs-12">
|
||||
<label class="col-sm-4 col-xs-12 control-label"><?= lang('Device_TableHead_LastSession');?>
|
||||
<i my-set-key="NEWDEV_devLastConnection"
|
||||
title="<?= lang('Settings_Show_Description');?>"
|
||||
class="fa fa-circle-info pointer helpIconSmallTopRight"
|
||||
onclick="showDescriptionPopup(this)">
|
||||
</i>
|
||||
</label>
|
||||
<div class="col-sm-8 col-xs-12 form-control-static" id="sessionInfo_devLastConnection"></div>
|
||||
</div>
|
||||
<div class="form-group col-xs-12">
|
||||
<label class="col-sm-4 col-xs-12 control-label"><?= lang('Device_TableHead_FirstSession');?>
|
||||
<i my-set-key="NEWDEV_devFirstConnection"
|
||||
title="<?= lang('Settings_Show_Description');?>"
|
||||
class="fa fa-circle-info pointer helpIconSmallTopRight"
|
||||
onclick="showDescriptionPopup(this)">
|
||||
</i>
|
||||
</label>
|
||||
<div class="col-sm-8 col-xs-12 form-control-static" id="sessionInfo_devFirstConnection"></div>
|
||||
</div>
|
||||
<div class="form-group col-xs-12">
|
||||
<label class="col-sm-4 col-xs-12 control-label"><?= lang('Device_TableHead_FQDN');?>
|
||||
<i my-set-key="NEWDEV_devFQDN"
|
||||
title="<?= lang('Settings_Show_Description');?>"
|
||||
class="fa fa-circle-info pointer helpIconSmallTopRight"
|
||||
onclick="showDescriptionPopup(this)">
|
||||
</i>
|
||||
</label>
|
||||
<div class="col-sm-8 col-xs-12 form-control-static" id="sessionInfo_devFQDN"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group col-lg-4 col-sm-6 col-xs-12">
|
||||
<h5><i class="fa fa-network-wired"></i> <?= lang('DevDetail_SessionInfo_KnownIPs');?></h5>
|
||||
<hr>
|
||||
<div id="sessionInfo_knownIps"></div>
|
||||
</div>
|
||||
|
||||
<div class="field-group col-lg-4 col-sm-6 col-xs-12">
|
||||
<h5><i class="fa fa-clock-rotate-left"></i> <?= lang('DevDetail_SessionInfo_IPHistory');?></h5>
|
||||
<hr>
|
||||
<div id="sessionInfo_ipHistory"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
function hideSessionInfoTabSkeleton() {
|
||||
$('#skel-tab-session-info').fadeOut(0, function() { $(this).hide(); });
|
||||
}
|
||||
function showSessionInfoTabSkeleton() {
|
||||
var $skel = $('#skel-tab-session-info');
|
||||
$skel.stop(true, true).fadeIn(10);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Labels for devPrimaryIPv4/devPrimaryIPv6 reuse NEWDEV's own setName
|
||||
// (newdevSettingsByKey, populated by deviceDetailsEdit.php's getDeviceData())
|
||||
// instead of a second, hand-maintained lang key with the same text. These are
|
||||
// locale text, not per-device values - fill them in independently of the
|
||||
// per-mac render below, once the settings fetch has actually resolved, and
|
||||
// skip re-writing once already set.
|
||||
function renderSessionInfoLabels() {
|
||||
if ($('#sessionInfo_label_devPrimaryIPv4_text').text()) {
|
||||
return; // already set - settings are locale text, not per-mac
|
||||
}
|
||||
const ipv4Setting = newdevSettingsByKey['NEWDEV_devPrimaryIPv4'];
|
||||
const ipv6Setting = newdevSettingsByKey['NEWDEV_devPrimaryIPv6'];
|
||||
if (!ipv4Setting || !ipv6Setting) {
|
||||
return; // settings fetch hasn't resolved yet - try again next tick
|
||||
}
|
||||
// Write into the inner _text span, not the <label> itself - the label also
|
||||
// holds the static help-icon <i>, which a .text() on the label would wipe out.
|
||||
$('#sessionInfo_label_devPrimaryIPv4_text').text(ipv4Setting.setName);
|
||||
$('#sessionInfo_label_devPrimaryIPv6_text').text(ipv6Setting.setName);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Plain read-only rows sourced directly from the already-fetched global
|
||||
// deviceData (deviceDetailsEdit.php's getDeviceData()) - no settings lookup,
|
||||
// no form inputs, no save path. devStatus is rendered via the same
|
||||
// badgeFromDevice() the rest of the UI already uses for device status,
|
||||
// rather than printing the raw devStatus string.
|
||||
function renderSessionInfoFields() {
|
||||
$('#sessionInfo_devPrimaryIPv4').text(deviceData.devPrimaryIPv4 || '');
|
||||
$('#sessionInfo_devPrimaryIPv6').text(deviceData.devPrimaryIPv6 || '');
|
||||
$('#sessionInfo_devFQDN').text(deviceData.devFQDN || '');
|
||||
$('#sessionInfo_devLastConnection').text(localizeTimestamp(deviceData.devLastConnection));
|
||||
$('#sessionInfo_devFirstConnection').text(localizeTimestamp(deviceData.devFirstConnection));
|
||||
|
||||
// Same badge markup devices-table.js/network-tabs.js/network-api.js all use
|
||||
// (<span class="badge ${cssClass}">) - a plain span, not their <a href>, since
|
||||
// that link just points back at the device page we're already on.
|
||||
const statusBadge = badgeFromDevice(deviceData);
|
||||
$('#sessionInfo_devStatus').html(`<span class="badge ${statusBadge.cssClass}">${statusBadge.iconHtml} ${statusBadge.label}</span>`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Renders one chip list (Known IPs or IP History) into targetSelector, or a
|
||||
// "No data" placeholder if items is empty. Shared by renderIpLists() below so
|
||||
// both lists use identical chip markup.
|
||||
function renderIpChipList(targetSelector, items) {
|
||||
if (items.length === 0) {
|
||||
$(targetSelector).html(`<span class="text-muted">${getString('Gen_No_Data')}</span>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chips = items.map(({ entry, value }) => {
|
||||
return `<span class="label label-default" style="margin: 2px; display: inline-block;">${value} · ${entry.plugin}</span>`;
|
||||
}).join(' ');
|
||||
|
||||
$(targetSelector).html(chips);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Known IPs / IP History: two compact chip lists, not a table, split from a
|
||||
// single getSourcesFieldData() fetch (the same one Field View uses) rather
|
||||
// than two separate queries - one request, filtered client-side into two
|
||||
// buckets by status. Only plugins registered in field_views.ip are
|
||||
// considered, and each one's value is read from its exact registered column
|
||||
// (getFieldViewDefinitions()/resolveFieldValueForPlugin(), shared with Field
|
||||
// View) - not a fallback chain over whichever watchedValue happens to be
|
||||
// non-empty, which would also pick up unrelated data (e.g. a port number)
|
||||
// from a plugin that was never registered for the "ip" field.
|
||||
//
|
||||
// status === 'missing-in-last-scan' doesn't mean the IP is permanently gone -
|
||||
// it only means the owning plugin's own last run didn't re-report it, and it
|
||||
// can flip back to 'exists' on that plugin's very next run. So rather than
|
||||
// hiding those entries outright (which just looks like missing data), they
|
||||
// go into their own "IP History" list instead of being dropped.
|
||||
async function renderIpLists(mac) {
|
||||
const [fieldViews, entries] = await Promise.all([getFieldViewDefinitions(), getSourcesFieldData(mac)]);
|
||||
const ipColumns = (fieldViews.ip && fieldViews.ip.columns) || [];
|
||||
|
||||
const withIpValue = entries
|
||||
.map(e => ({ entry: e, value: resolveFieldValueForPlugin(ipColumns, e) }))
|
||||
.filter(({ value }) => value);
|
||||
|
||||
const known = withIpValue.filter(({ entry }) => entry.status !== 'missing-in-last-scan');
|
||||
const history = withIpValue.filter(({ entry }) => entry.status === 'missing-in-last-scan');
|
||||
|
||||
renderIpChipList('#sessionInfo_knownIps', known);
|
||||
renderIpChipList('#sessionInfo_ipHistory', history);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Poll for visibility the same way pluginsFieldView.php's fieldViewUpdater()
|
||||
// does - deviceData is populated asynchronously by deviceDetailsEdit.php's own
|
||||
// getDeviceData(), so wait for it rather than assuming it's ready.
|
||||
//
|
||||
// Tracks the mac (not just a one-time flag) so switching devices while this
|
||||
// tab stays active re-renders instead of leaving the previous device's stale
|
||||
// values on screen - mirroring fieldViewUpdater()'s currentMac !== lastMac
|
||||
// check. Also resets the "initialized" flag whenever deviceData becomes
|
||||
// unavailable (e.g. cleared mid-switch), so the skeleton that gets shown
|
||||
// again in that case is guaranteed a matching hide once real data returns -
|
||||
// without this, a device switch could re-show the skeleton while the
|
||||
// one-time flag silently blocked the render path that would hide it again.
|
||||
// .finally() (not .then()) on the render promise is a second safety net:
|
||||
// if renderIpLists() ever throws or its fetch rejects, the skeleton still
|
||||
// gets hidden instead of staying stuck forever.
|
||||
let sessionInfoInitialized = false;
|
||||
let sessionInfoLastMac = null;
|
||||
|
||||
function sessionInfoUpdater() {
|
||||
// A throw anywhere in here must never skip the setTimeout below - otherwise
|
||||
// the polling loop dies permanently and the skeleton is stuck visible forever
|
||||
// (this is exactly what fieldViewUpdater() avoids by deferring its render into
|
||||
// an async callback instead of calling it inline).
|
||||
try {
|
||||
if ($('#panSessionInfo').is(':visible')) {
|
||||
renderSessionInfoLabels();
|
||||
|
||||
const currentMac = getMac();
|
||||
if (!deviceData || !deviceData.devMac) {
|
||||
showSessionInfoTabSkeleton();
|
||||
sessionInfoInitialized = false;
|
||||
} else if (!sessionInfoInitialized || currentMac !== sessionInfoLastMac) {
|
||||
sessionInfoInitialized = true;
|
||||
sessionInfoLastMac = currentMac;
|
||||
renderSessionInfoFields();
|
||||
// Known IPs/IP History need the GraphQL server up - gated the same
|
||||
// way fieldViewUpdater() gates renderFieldView(), so this never fires
|
||||
// (and never poisons getSourcesFieldData()'s per-mac cache with a
|
||||
// too-early failure) before the server is actually ready to answer.
|
||||
// On a rejected render (e.g. getSourcesFieldData()'s GraphQL error
|
||||
// path), reset sessionInfoInitialized so the next tick retries instead
|
||||
// of leaving this mac permanently stuck unrendered.
|
||||
callAfterAppInitialized(() => {
|
||||
Promise.resolve(renderIpLists(currentMac))
|
||||
.catch(err => {
|
||||
console.error('[Session Info] renderIpLists failed, will retry next tick:', err);
|
||||
sessionInfoInitialized = false;
|
||||
})
|
||||
.finally(hideSessionInfoTabSkeleton);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Session Info] render failed, will retry next tick:', err);
|
||||
sessionInfoInitialized = false;
|
||||
hideSessionInfoTabSkeleton();
|
||||
}
|
||||
setTimeout(sessionInfoUpdater, 200);
|
||||
}
|
||||
|
||||
sessionInfoUpdater();
|
||||
|
||||
</script>
|
||||
+5
-1
@@ -172,9 +172,13 @@ function initializeDatatable() {
|
||||
{ targets: [9], orderData: [10] },
|
||||
// Use `render` (not `createdCell`) so encodeSpecialChars runs before
|
||||
// DataTables sets td.innerHTML, preventing devName XSS execution.
|
||||
// devName is null for a deleted device's still-shown historical event
|
||||
// (Events_Devices LEFT JOINs Devices) - fall back to the MAC (row[13])
|
||||
// instead of rendering the literal string "null".
|
||||
{ targets: [1], render: function (data, type, row) {
|
||||
if (type !== 'display') { return data; }
|
||||
return `<b><a href="deviceDetails.php?mac=${row[13]}">${encodeSpecialChars(data)}</a></b>`;
|
||||
const label = (data === null || data === undefined || data === '') ? row[13] : data;
|
||||
return `<b><a href="deviceDetails.php?mac=${row[13]}">${encodeSpecialChars(label)}</a></b>`;
|
||||
}},
|
||||
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
|
||||
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }
|
||||
|
||||
+1
-1
@@ -1071,7 +1071,7 @@ function hideSpinner() {
|
||||
|
||||
if (!spinner.length) return;
|
||||
|
||||
const target = $(".spinnerTarget").last();
|
||||
const target = resolveSpinnerTarget();
|
||||
|
||||
if (target.length) {
|
||||
// Lock position to target
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* -----------------------------------------------------------------------------
|
||||
* NetAlertX
|
||||
* Open Source Network Guard / WIFI & LAN intrusion detector
|
||||
*
|
||||
* plugin_column_render.js - Shared, plugin-agnostic column-value renderer.
|
||||
* Extracted verbatim from pluginsCore.php so Plugin View and the Sources
|
||||
* tab's Field View render database_column_definitions-typed values
|
||||
* identically instead of duplicating this switch statement.
|
||||
*-------------------------------------------------------------------------------
|
||||
# jokob support@netalertx.com GNU GPLv3
|
||||
----------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The DataTables init options shared by every plugin-data table in the app
|
||||
* (Plugin View's Objects/Events/History tables, Field View's per-field
|
||||
* tables) - paging/searching/ordering/lengthMenu are identical everywhere a
|
||||
* plugin's rows get rendered into a table, so this is the one place that set
|
||||
* is defined. Callers spread this into their own config and add whatever
|
||||
* else they need (ajax/serverSide for a server-paginated table, plain
|
||||
* data/columns for a small client-side one, createdRow, etc).
|
||||
* @param {string|null} skelSelector - jQuery selector for this table's own loading skeleton, faded out after the first draw. Pass null if the table has no per-table skeleton (e.g. a shared tab-level one is used instead).
|
||||
* @returns {object} A partial DataTables options object.
|
||||
*/
|
||||
function getStandardDataTableOptions(skelSelector) {
|
||||
return {
|
||||
autoWidth: false,
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
pageLength: 25,
|
||||
lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||
language: { emptyTable: getString('Gen_No_Data') },
|
||||
// Fade out the skeleton only after the first draw so there is no gap
|
||||
// between the skeleton disappearing and the table rows appearing.
|
||||
initComplete: function() {
|
||||
if (skelSelector) {
|
||||
$(skelSelector).fadeOut(0, function() { $(this).hide(); });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Get form control according to the column definition from config.json > database_column_definitions
|
||||
function getFormControl(dbColumnDef, value, index) {
|
||||
|
||||
result = ''
|
||||
|
||||
// Check if mapped_to_column_data exists and has a value to override the supplied value which is most likely `undefined`
|
||||
if (dbColumnDef.mapped_to_column_data && dbColumnDef.mapped_to_column_data.value) {
|
||||
value = dbColumnDef.mapped_to_column_data.value;
|
||||
}
|
||||
|
||||
|
||||
result = processColumnValue(dbColumnDef, value, index, dbColumnDef.type)
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Process column value
|
||||
function processColumnValue(dbColumnDef, value, index, type) {
|
||||
if (type.includes('.')) {
|
||||
const typeParts = type.split('.');
|
||||
|
||||
// recursion
|
||||
for (const typePart of typeParts) {
|
||||
value = processColumnValue(dbColumnDef, value, index, typePart)
|
||||
}
|
||||
|
||||
} else{
|
||||
// pick form control based on the supplied type
|
||||
switch(type)
|
||||
{
|
||||
case 'label':
|
||||
value = `<span>${value}<span>`;
|
||||
break;
|
||||
case 'none':
|
||||
value = `${value}`;
|
||||
break;
|
||||
case 'textarea_readonly':
|
||||
value = `<textarea cols="70" rows="3" wrap="off" readonly style="white-space: pre-wrap;">
|
||||
${value.replace(/^b'(.*)'$/gm, '$1').replace(/\\n/g, '\n').replace(/\\r/g, '\r')}
|
||||
</textarea>`;
|
||||
break;
|
||||
case 'textbox_save':
|
||||
|
||||
value = value == 'null' ? '' : value; // hide 'null' values
|
||||
|
||||
id = `${dbColumnDef.column}_${index}`
|
||||
|
||||
value = `<span class="form-group">
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="text" value="${value}" id="${id}" data-my-column="${dbColumnDef.column}" data-my-index="${index}" name="${dbColumnDef.column}">
|
||||
<span class="input-group-addon"><i class="fa fa-save pointer" onclick="genericSaveData('${id}');"></i></span>
|
||||
</div>
|
||||
<span>`;
|
||||
break;
|
||||
case 'url':
|
||||
value = `<span><a href="${value}" target="_blank">${value}</a><span>`;
|
||||
break;
|
||||
case 'url_http_https':
|
||||
value = `<span>
|
||||
<a href="http://${value}" target="_blank">
|
||||
<i class="fa fa-lock-open "></i>
|
||||
</a>
|
||||
/
|
||||
<a href="https://${value}" target="_blank">
|
||||
<i class="fa fa-lock "></i>
|
||||
</a>
|
||||
</span>`;
|
||||
break;
|
||||
case 'device_name_mac':
|
||||
value = `<div class="text-center"> ${value}
|
||||
<br/>
|
||||
${createDeviceLink(value)}
|
||||
</div>`;
|
||||
break;
|
||||
case 'device_mac':
|
||||
value = `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${value}" target="_blank">${value}</a><span>`;
|
||||
break;
|
||||
case 'device_ip':
|
||||
value = `<span class="anonymizeIp"><a href="#" onclick="navigateToDeviceWithIp('${value}')" >${value}</a><span>`;
|
||||
break;
|
||||
case 'threshold':
|
||||
|
||||
valueTmp = ''
|
||||
|
||||
$.each(dbColumnDef.options, function(index, obj) {
|
||||
if(Number(value) < Number(obj.maximum) && valueTmp == '')
|
||||
{
|
||||
valueTmp = `<div class="thresholdFormControl" style="background-color:${obj.hexColor}">${value}</div>`
|
||||
// return;
|
||||
}
|
||||
});
|
||||
|
||||
value = valueTmp;
|
||||
|
||||
break;
|
||||
case 'replace':
|
||||
$.each(dbColumnDef.options, function(index, obj) {
|
||||
if(value == obj.equals)
|
||||
{
|
||||
value = `<span title="${value}">${obj.replacement}</span>`
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'regex':
|
||||
|
||||
for (const option of dbColumnDef.options) {
|
||||
if (option.type === type) {
|
||||
|
||||
const regexPattern = new RegExp(option.param);
|
||||
const match = value.match(regexPattern);
|
||||
if (match) {
|
||||
// Return the first match
|
||||
value = match[0];
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'eval':
|
||||
|
||||
for (const option of dbColumnDef.options) {
|
||||
if (option.type === type) {
|
||||
// console.log(option.param)
|
||||
value = eval(option.param);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
value = value + `<div style='text-align:center' title="${getString("Plugins_no_control")}"><i class='fa-solid fa-circle-question'></i></div>` ;
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior if no match is found
|
||||
return value;
|
||||
}
|
||||
@@ -103,6 +103,22 @@ function initializeTabsShared(options) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build the <li> markup for a Bootstrap sub-tab nav strip from a list of tab
|
||||
* descriptors. Shared by pluginsCore.php's per-plugin Objects/Events/History
|
||||
* sub-tabs and pluginsFieldView.php's per-field Plugin Objects sub-tab so
|
||||
* both produce identical nav-tabs-custom <li>/<a> markup.
|
||||
* @param {Array<{href: string, icon: string, label: string, badgeHtml?: string, active?: boolean}>} tabs
|
||||
* @returns {string} Concatenated <li> markup for a <ul class="nav nav-tabs">.
|
||||
*/
|
||||
function buildTabNavItems(tabs) {
|
||||
return tabs.map(t => `
|
||||
<li class="${t.active ? 'active' : ''}">
|
||||
<a href="${t.href}" data-toggle="tab"><i class="fa ${t.icon}"></i> ${t.label}${t.badgeHtml ? ` (${t.badgeHtml})` : ''}</a>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Utility function to generate a random API token in the format t_<random string of specified length>
|
||||
function generateApiToken(elem, length) {
|
||||
|
||||
@@ -16,17 +16,26 @@ require dirname(__FILE__).'/../templates/skinUI.php';
|
||||
// check if authenticated
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/php/templates/security.php';
|
||||
|
||||
$FUNCTION = [];
|
||||
$SETTINGS = [];
|
||||
$FUNCTION = '';
|
||||
$SETTINGS = '';
|
||||
|
||||
// savesettings is state-changing and must never be reachable via GET - a GET
|
||||
// URL can be triggered cross-site by a simple top-level navigation, which
|
||||
// still carries the session cookie under default SameSite=Lax behavior.
|
||||
if (($_GET['function'] ?? '') === 'savesettings') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// init request params
|
||||
if(array_key_exists('function', $_REQUEST) != FALSE)
|
||||
{
|
||||
$FUNCTION = $_REQUEST['function'];
|
||||
if (array_key_exists('function', $_POST)) {
|
||||
$FUNCTION = $_POST['function'];
|
||||
}
|
||||
if(array_key_exists('settings', $_REQUEST) != FALSE)
|
||||
{
|
||||
$SETTINGS = $_REQUEST['settings'];
|
||||
if (array_key_exists('settings', $_POST)) {
|
||||
$SETTINGS = $_POST['settings'];
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +44,7 @@ switch ($FUNCTION) {
|
||||
|
||||
case 'savesettings':
|
||||
|
||||
requireCsrfToken();
|
||||
saveSettings();
|
||||
break;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<script>window.NAX_APP_VERSION = "<?php echo trim(file_exists('/app/.VERSION') ? file_get_contents('/app/.VERSION') : 'dev'); ?>";</script>
|
||||
<script src="js/cache.js?v=<?php include 'php/templates/version.php'; ?>"></script>
|
||||
<script src="js/common.js?v=<?php include 'php/templates/version.php'; ?>"></script>
|
||||
<script src="js/plugin_column_render.js?v=<?php include 'php/templates/version.php'; ?>"></script>
|
||||
<script src="js/app-init.js?v=<?php include 'php/templates/version.php'; ?>"></script>
|
||||
<script src="js/sse_manager.js?v=<?php include 'php/templates/version.php'; ?>"></script>
|
||||
<script src="js/api.js?v=<?php include 'php/templates/version.php'; ?>"></script>
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "تنفيذ الإجراءات",
|
||||
"DevDetail_Run_Actions_Tooltip": "تنفيذ الإجراءات المخصصة",
|
||||
"DevDetail_SessionInfo_FirstSession": "أول جلسة",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "آخر عنوان IP",
|
||||
"DevDetail_SessionInfo_LastSession": "آخر جلسة",
|
||||
"DevDetail_SessionInfo_StaticIP": "عنوان IP ثابت",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "تنبيهات عدم الاتصال",
|
||||
"DevDetail_Shortcut_Presence": "التواجد",
|
||||
"DevDetail_Shortcut_Sessions": "الجلسات",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "التفاصيل",
|
||||
"DevDetail_Tab_Events": "الأحداث",
|
||||
"DevDetail_Tab_EventsTableDate": "التاريخ",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "المكونات الإضافية",
|
||||
"DevDetail_Tab_Presence": "التواجد",
|
||||
"DevDetail_Tab_Sessions": "الجلسات",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "الأدوات",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "تعرض أداة معلومات الإنترنت معلومات حول اتصال الإنترنت، مثل عنوان IP والمدينة والبلد ورمز المنطقة والمنطقة الزمنية.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "خطأ في جلب معلومات الإنترنت",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "المكون الإضافي المصدر",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "الحالة",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "اسم عقدة المزامنة",
|
||||
"Device_TableHead_Type": "النوع",
|
||||
"Device_TableHead_Vendor": "المصنع",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "حذف الكل",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "تصفية عنوان MAC",
|
||||
"Plugins_History": "السجل",
|
||||
"Plugins_Obj_DeleteListed": "حذف المدرج",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Executar l'acció al dispositiu",
|
||||
"DevDetail_Run_Actions_Tooltip": "Executar una acció al dispositiu actual des de la llista desplegable.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Primera Sessió",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Darrera IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Últim Fora de línia",
|
||||
"DevDetail_SessionInfo_StaticIP": "IP estàtica",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Aturar alertes",
|
||||
"DevDetail_Shortcut_Presence": "Presència",
|
||||
"DevDetail_Shortcut_Sessions": "Sessions",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Detalls",
|
||||
"DevDetail_Tab_Events": "Esdeveniments",
|
||||
"DevDetail_Tab_EventsTableDate": "Data",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Connectors (Plugins)",
|
||||
"DevDetail_Tab_Presence": "Presència",
|
||||
"DevDetail_Tab_Sessions": "Sessions",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Eines",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "L'eina d'informació d'Internet mostra informació sobre la connexió a Internet, com ara adreça IP, ciutat, país, codi d'àrea i zona horària.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "S'ha produït un error",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Connector(Plugin) font",
|
||||
"Device_TableHead_StaticIP": "IP estàtica",
|
||||
"Device_TableHead_Status": "Estat",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Node Sync",
|
||||
"Device_TableHead_Type": "Tipus",
|
||||
"Device_TableHead_Vendor": "Venedor",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL (Write-Ahead Log) mida màxima en MB abans de desencadenar punts de verificació automàtics. Els valors més baixos (10-20 MB) redueixen l'ús del disc / emmagatzematge, però augmenten l'ús de la CPU durant les exploracions. Els valors més alts (50-100 MB) redueixen els pics de CPU durant les operacions, però poden utilitzar més memòria RAM i espai de disc. Default <code>50 MB</code> saldos ambdós. Útil per a sistemes formats per recursos com dispositius NAS amb targetes SD. Preguntes Freqüents - FAQ.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "Límit de mida WAL (MB)",
|
||||
"Plugins_DeleteAll": "Elimina tot (s'ignoraran els filtres)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtre de MAC",
|
||||
"Plugins_History": "Historial d'Esdeveniments",
|
||||
"Plugins_Obj_DeleteListed": "Suprimeix els objectes mostrats",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Spustit akci na zařízení",
|
||||
"DevDetail_Run_Actions_Tooltip": "Spustit akci na vybraném zařízení.",
|
||||
"DevDetail_SessionInfo_FirstSession": "První relace",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Poslední IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Naposledy offline",
|
||||
"DevDetail_SessionInfo_StaticIP": "Statická IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Výstrahy ohledně nedostupnosti",
|
||||
"DevDetail_Shortcut_Presence": "Výskyt",
|
||||
"DevDetail_Shortcut_Sessions": "Relace",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Podrobnosti",
|
||||
"DevDetail_Tab_Events": "Události",
|
||||
"DevDetail_Tab_EventsTableDate": "Datum",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Zásuvné moduly",
|
||||
"DevDetail_Tab_Presence": "Výskyt",
|
||||
"DevDetail_Tab_Sessions": "Relace",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Nástroje",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Nástroj „Informace k Internetu“ zobrazuje informace o připojení k Internetu jako je IP adresa, město, stát, kód oblasti a časové pásmo.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Vyskytla se chyba",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Zdrojový zásuvný modul",
|
||||
"Device_TableHead_StaticIP": "Statická IP adresa",
|
||||
"Device_TableHead_Status": "Stav",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Synchronizovat uzel",
|
||||
"Device_TableHead_Type": "Typ",
|
||||
"Device_TableHead_Vendor": "Výrobce",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "Nejvyšší umožněná velikost (v MB) pro SQLite WAL (Write-Ahead Log), jejíž překročení spouští automatické kontrolní body. Nižší hodnoty (10-20 MB) snižují využití úložiště, ale při skenech zvýší vytěžování procesoru. Vyšší hodnoty (50-100 MB) omezí špičky vytěžování procesoru při operacích, ale může docházet k využívání více operační paměti a prostoru na úložišti. Výchozí <code>50 MB</code> je kompromisem mezi obojím. Užitečné pro systémy s omezenými systémovými prostředky, jako například NAS zařízení se systémem na úložišti typu SD karta (eMMC, atp.). Aby se změny projevily, po uložení nastavení server restartujte.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "Limit velikost WAL (MB)",
|
||||
"Plugins_DeleteAll": "Smazat vše (filtry jsou ignorovány)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtr MAC adres",
|
||||
"Plugins_History": "Historie událostí",
|
||||
"Plugins_Obj_DeleteListed": "Smazat vypsané objekty",
|
||||
|
||||
@@ -138,6 +138,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Aktion auf Gerät ausführen",
|
||||
"DevDetail_Run_Actions_Tooltip": "Eine Aktion aus der Dropdown-Liste auf dem aktuellen Gerät ausführen.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Erste Sitzung",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Letzte IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Zuletzt offline",
|
||||
"DevDetail_SessionInfo_StaticIP": "Statische IP",
|
||||
@@ -153,6 +155,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Down Meldungen",
|
||||
"DevDetail_Shortcut_Presence": "Anwesenheit",
|
||||
"DevDetail_Shortcut_Sessions": "Sitzungen",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Details",
|
||||
"DevDetail_Tab_Events": "Ereignisse",
|
||||
"DevDetail_Tab_EventsTableDate": "Datum",
|
||||
@@ -172,6 +176,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugins",
|
||||
"DevDetail_Tab_Presence": "Anwesenheit",
|
||||
"DevDetail_Tab_Sessions": "Sitzungen",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Werkzeuge",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Das Internet-Info-Tool zeigt Informationen über die Internetverbindung an, wie z. B. IP-Adresse, Stadt, Land, Ortsvorwahl und Zeitzone.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Es ist ein Fehler aufgetreten",
|
||||
@@ -271,6 +276,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Quellerweiterung",
|
||||
"Device_TableHead_StaticIP": "Statische IP",
|
||||
"Device_TableHead_Status": "Status",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Synchronisationsknoten",
|
||||
"Device_TableHead_Type": "Typ",
|
||||
"Device_TableHead_Vendor": "Hersteller",
|
||||
@@ -647,6 +653,11 @@
|
||||
"PUSHSAFER_display_name": "Pushsafer",
|
||||
"PUSHSAFER_icon": "<i class=\"fa fa-bell\"></i>",
|
||||
"Plugins_DeleteAll": "Alles löschen (Filter werden ignoriert)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Mac Filter",
|
||||
"Plugins_History": "Verlauf der Ereignisse",
|
||||
"Plugins_Obj_DeleteListed": "Aufgelistete Objekte löschen",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Run action on device",
|
||||
"DevDetail_Run_Actions_Tooltip": "Run an action on the current device from the dropdown list.",
|
||||
"DevDetail_SessionInfo_FirstSession": "First Session",
|
||||
"DevDetail_SessionInfo_IPHistory": "IP History",
|
||||
"DevDetail_SessionInfo_KnownIPs": "Known IPs",
|
||||
"DevDetail_SessionInfo_LastIP": "Last IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Last Offline",
|
||||
"DevDetail_SessionInfo_StaticIP": "Static IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Down Alerts",
|
||||
"DevDetail_Shortcut_Presence": "Presence",
|
||||
"DevDetail_Shortcut_Sessions": "Sessions",
|
||||
"DevDetail_Sources_FieldView": "Field View",
|
||||
"DevDetail_Sources_PluginView": "Plugin View",
|
||||
"DevDetail_Tab_Details": "Details",
|
||||
"DevDetail_Tab_Events": "Events",
|
||||
"DevDetail_Tab_EventsTableDate": "Date",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugins",
|
||||
"DevDetail_Tab_Presence": "Presence",
|
||||
"DevDetail_Tab_Sessions": "Sessions",
|
||||
"DevDetail_Tab_Sources": "Sources",
|
||||
"DevDetail_Tab_Tools": "Tools",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "The Internet info tool displays information about the Internet connection, such as IP address, city, country, area code and time zone.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "An error has occurred",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Source Plugin",
|
||||
"Device_TableHead_StaticIP": "Static IP",
|
||||
"Device_TableHead_Status": "Status",
|
||||
"Device_TableHead_Status_description": "The device's current presence state, derived from scan results and any manual override.",
|
||||
"Device_TableHead_SyncHubNodeName": "Sync Node",
|
||||
"Device_TableHead_Type": "Type",
|
||||
"Device_TableHead_Vendor": "Vendor",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL (Write-Ahead Log) maximum size in MB before triggering automatic checkpoints. Lower values (10-20 MB) reduce disk/storage usage but increase CPU usage during scans. Higher values (50-100 MB) reduce CPU spikes during operations but may use more RAM and disk space. Default <code>50 MB</code> balances both. Useful for resource-constrained systems like NAS devices with SD cards. Restart server for changes to take effect after saving the settings.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "WAL size limit (MB)",
|
||||
"Plugins_DeleteAll": "Delete all (filters are ignored)",
|
||||
"Plugins_FieldStatus_Changed": "Changed since last report",
|
||||
"Plugins_FieldStatus_New": "Newly observed",
|
||||
"Plugins_FieldStatus_NotReported": "Not reported last run",
|
||||
"Plugins_FieldStatus_Unchanged": "Unchanged since last report",
|
||||
"Plugins_Field_LastChanged": "Last Changed",
|
||||
"Plugins_Filters_Mac": "Mac Filter",
|
||||
"Plugins_History": "Events History",
|
||||
"Plugins_Obj_DeleteListed": "Delete Listed Objects",
|
||||
|
||||
@@ -136,6 +136,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Ejecutar acción en el dispositivo",
|
||||
"DevDetail_Run_Actions_Tooltip": "Ejecutar la acción del desplegable sobre el dispositivo actual.",
|
||||
"DevDetail_SessionInfo_FirstSession": "1ra. sesión",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Última IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Última conexión",
|
||||
"DevDetail_SessionInfo_StaticIP": "IP estática",
|
||||
@@ -151,6 +153,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Alerta(s) de caída(s)",
|
||||
"DevDetail_Shortcut_Presence": "Historial",
|
||||
"DevDetail_Shortcut_Sessions": "Sesiones",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Detalles",
|
||||
"DevDetail_Tab_Events": "Eventos",
|
||||
"DevDetail_Tab_EventsTableDate": "Fecha",
|
||||
@@ -170,6 +174,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugins",
|
||||
"DevDetail_Tab_Presence": "Historial",
|
||||
"DevDetail_Tab_Sessions": "Sesiones",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Herramientas",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "La herramienta de información de internet muestra información sobre la conexión a Internet, como dirección IP, ciudad, país, código de área y zona horaria.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Se ha producido un error",
|
||||
@@ -269,6 +274,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Fuente del plugin",
|
||||
"Device_TableHead_StaticIP": "IP estática",
|
||||
"Device_TableHead_Status": "Estado",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Nodo de sincronización",
|
||||
"Device_TableHead_Type": "Tipo",
|
||||
"Device_TableHead_Vendor": "Fabricante",
|
||||
@@ -645,6 +651,11 @@
|
||||
"PUSHSAFER_display_name": "Pushsafer",
|
||||
"PUSHSAFER_icon": "<i class=\"fa fa-bell\"></i>",
|
||||
"Plugins_DeleteAll": "Eliminar todo (se ignoran los filtros)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtro MAC",
|
||||
"Plugins_History": "Historial de eventos",
|
||||
"Plugins_Obj_DeleteListed": "Eliminar objetos enumerados",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Lancer l'action sur l'appareil",
|
||||
"DevDetail_Run_Actions_Tooltip": "Lancer une action sur l'appareil depuis la liste déroulante.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Première session",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Dernière IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Hors ligne depuis",
|
||||
"DevDetail_SessionInfo_StaticIP": "IP statique",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Alertes de panne",
|
||||
"DevDetail_Shortcut_Presence": "Présence",
|
||||
"DevDetail_Shortcut_Sessions": "Sessions",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Détails",
|
||||
"DevDetail_Tab_Events": "Événements",
|
||||
"DevDetail_Tab_EventsTableDate": "Date",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugins",
|
||||
"DevDetail_Tab_Presence": "Présence",
|
||||
"DevDetail_Tab_Sessions": "Sessions",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Outils",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "L'outil Infos Internet affiche les informations sur la connexion Internet, comme l'adresse IP, la ville, la région, le code région et le fuseau horaire.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Une erreur est survenue",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Source du plugin",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "État",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Noeud de synchro",
|
||||
"Device_TableHead_Type": "Type",
|
||||
"Device_TableHead_Vendor": "Fabriquant",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "Taille maximale du SQLite WAL (Write-Ahead Log) en Mo avant le déclenchement automatique des points de contrôle. Des valeurs basses (10-20 Mo) réduisent l'utilisation du disque/stockage mais augmentent l'utilisation du CPU durant ces scans. Des valeurs élevées (50-100 Mo) réduisent les pics CPU durant les opérations mais peuvent utiliser plus de RAM et d'espace disque. Par défaut, <code>50 Mo</code> est un compromis entre ces 2. Utilise pour les systèmes à ressources limitées comme des NAS avec des cartes SD. Redémarrer le serveur pour que le changement soit effective après avoir sauvegardé ce paramètre.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "Limite de taille du WAL (Mo)",
|
||||
"Plugins_DeleteAll": "Tout supprimer (ne prend pas en compte les filtres)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtrer par MAC",
|
||||
"Plugins_History": "Historique des événements",
|
||||
"Plugins_Obj_DeleteListed": "Effacer les objets listés",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Esegui azione sul dispositivo",
|
||||
"DevDetail_Run_Actions_Tooltip": "Esegui un'azione sul dispositivo corrente dall'elenco a discesa.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Prima sessione",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Ultimo IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Ultimo offline",
|
||||
"DevDetail_SessionInfo_StaticIP": "IP statico",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Avvisi disconnessione",
|
||||
"DevDetail_Shortcut_Presence": "Presenza",
|
||||
"DevDetail_Shortcut_Sessions": "Sessioni",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Dettagli",
|
||||
"DevDetail_Tab_Events": "Eventi",
|
||||
"DevDetail_Tab_EventsTableDate": "Data",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugin",
|
||||
"DevDetail_Tab_Presence": "Presenza",
|
||||
"DevDetail_Tab_Sessions": "Sessioni",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Strumenti",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Lo strumento informazioni Internet visualizza informazioni sulla connessione Internet, come indirizzo IP, città, paese, prefisso e fuso orario.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Si è verificato un errore",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Plugin sorgente",
|
||||
"Device_TableHead_StaticIP": "IP statico",
|
||||
"Device_TableHead_Status": "Stato",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Sincronizza nodo",
|
||||
"Device_TableHead_Type": "Tipo",
|
||||
"Device_TableHead_Vendor": "Produttore",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "Dimensione massima in MB del WAL (Write-Ahead Log) di SQLite prima dell'attivazione dei checkpoint automatici. Valori inferiori (10-20 MB) riducono l'utilizzo di disco/archiviazione, ma aumentano l'utilizzo della CPU durante le scansioni. Valori superiori (50-100 MB) riducono i picchi di CPU durante le operazioni, ma potrebbero richiedere più RAM e spazio su disco. Il valore predefinito di <code>50 MB</code> bilancia entrambi. Utile per sistemi con risorse limitate, come dispositivi NAS con schede SD. Riavviare il server affinché le modifiche abbiano effetto dopo aver salvato le impostazioni.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "Limite dimensione WAL (MB)",
|
||||
"Plugins_DeleteAll": "Elimina tutti (i filtri vengono ignorati)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtro MAC",
|
||||
"Plugins_History": "Storico eventi",
|
||||
"Plugins_Obj_DeleteListed": "Elimina oggetti elencati",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> デバイス上でアクションを実行",
|
||||
"DevDetail_Run_Actions_Tooltip": "ドロップダウンリストから現在のデバイスに対してアクションを実行。",
|
||||
"DevDetail_SessionInfo_FirstSession": "初回セッション",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "直近のIP",
|
||||
"DevDetail_SessionInfo_LastSession": "最終オフライン",
|
||||
"DevDetail_SessionInfo_StaticIP": "静的IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "ダウンアラート",
|
||||
"DevDetail_Shortcut_Presence": "検出",
|
||||
"DevDetail_Shortcut_Sessions": "セッション",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "詳細",
|
||||
"DevDetail_Tab_Events": "イベント",
|
||||
"DevDetail_Tab_EventsTableDate": "日付",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "プラグイン",
|
||||
"DevDetail_Tab_Presence": "検出",
|
||||
"DevDetail_Tab_Sessions": "セッション",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "ツール",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "インターネット情報ツールは、IPアドレス、都市、国、地域コード、タイムゾーンなど、インターネット接続に関する情報を表示します。",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "エラーが発生しました",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "ソースプラグイン",
|
||||
"Device_TableHead_StaticIP": "静的IP",
|
||||
"Device_TableHead_Status": "状態",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "同期ノード",
|
||||
"Device_TableHead_Type": "種別",
|
||||
"Device_TableHead_Vendor": "ベンダー",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL(Write-Ahead Log)の自動チェックポイント発生前の最大サイズ(MB単位)。低い値(10~20 MB)ではディスク/ストレージ使用量を削減しますが、スキャン時のCPU使用率が増加します。高い値(50~100 MB)は操作中のCPUスパイクを軽減しますが、RAMとディスク容量をより多く消費する可能性があります。デフォルトの <code>50 MB</code> は両者のバランスを取ります。SDカードを搭載したNASデバイスなどのリソース制約のあるシステムで有用です。設定保存後、変更を有効にするにはサーバーを再起動してください。",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "WALサイズ制限(MB)",
|
||||
"Plugins_DeleteAll": "すべて削除(フィルターは無視されます)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Macフィルター",
|
||||
"Plugins_History": "イベント履歴",
|
||||
"Plugins_Obj_DeleteListed": "リストされたオブジェクトを削除",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Kjør handling på enheten",
|
||||
"DevDetail_Run_Actions_Tooltip": "Kjør en handling på gjeldende enhet fra nedtrekks menyen.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Første Sesjon",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Siste IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Siste Sesjon",
|
||||
"DevDetail_SessionInfo_StaticIP": "Statisk IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Nede Varslinger",
|
||||
"DevDetail_Shortcut_Presence": "Tilstedeværelse",
|
||||
"DevDetail_Shortcut_Sessions": "Sesjoner",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Detaljer",
|
||||
"DevDetail_Tab_Events": "Hendelser",
|
||||
"DevDetail_Tab_EventsTableDate": "Dato",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugins",
|
||||
"DevDetail_Tab_Presence": "Tilstedeværelse",
|
||||
"DevDetail_Tab_Sessions": "Økter",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Verktøy",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Internett-informasjonsverktøyet viser informasjon om Internett-tilkoblingen, for eksempel IP-adresse, by, land, retningsnummer og tidssone.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "En feil har oppstått",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "Status",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Synkroniser Node",
|
||||
"Device_TableHead_Type": "Type",
|
||||
"Device_TableHead_Vendor": "Leverandør",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "Slett alle (filtre blir ignorert)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Mac filter",
|
||||
"Plugins_History": "Hendelses historikk",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Uruchom akcję na urządzeniu",
|
||||
"DevDetail_Run_Actions_Tooltip": "Uruchom akcję na bieżącym urządzeniu z listy rozwijanej.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Pierwsza sesja",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Ostatni adres IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Ostatnia nieaktywność",
|
||||
"DevDetail_SessionInfo_StaticIP": "Statyczny adres IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Alerty o awarii",
|
||||
"DevDetail_Shortcut_Presence": "Obecność",
|
||||
"DevDetail_Shortcut_Sessions": "Sesje",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Szczegóły",
|
||||
"DevDetail_Tab_Events": "Zdarzenia",
|
||||
"DevDetail_Tab_EventsTableDate": "Data",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Wtyczki",
|
||||
"DevDetail_Tab_Presence": "Obecność",
|
||||
"DevDetail_Tab_Sessions": "Sesje",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Narzędzia",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Narzędzie informacyjne o Internecie wyświetla informacje o połączeniu internetowym, takie jak adres IP, miasto, kraj, kod obszaru oraz strefa czasowa.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Wystąpił błąd",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Wtyczka źródłowa",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "Status",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Węzeł synchronizacji",
|
||||
"Device_TableHead_Type": "Typ",
|
||||
"Device_TableHead_Vendor": "Producent",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "Usuń wszystkie (filtry są ignorowane)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtr MAC",
|
||||
"Plugins_History": "Historia zdarzeń",
|
||||
"Plugins_Obj_DeleteListed": "Usuń wymienione obiekty",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Executar ação no dispositivo",
|
||||
"DevDetail_Run_Actions_Tooltip": "Execute uma ação no dispositivo atual da lista suspensa.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Primeira sessão",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Último IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Último offline",
|
||||
"DevDetail_SessionInfo_StaticIP": "IP estático",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Alertas para baixo",
|
||||
"DevDetail_Shortcut_Presence": "Presença",
|
||||
"DevDetail_Shortcut_Sessions": "Sessões",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Detalhes",
|
||||
"DevDetail_Tab_Events": "Eventos",
|
||||
"DevDetail_Tab_EventsTableDate": "Data",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "<i class=\"fa fa-plug\"> </i> Plugins",
|
||||
"DevDetail_Tab_Presence": "Presença",
|
||||
"DevDetail_Tab_Sessions": "Sessões",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Ferramentas",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "A ferramenta info Internet exibe informações sobre a conexão com a Internet, como endereço IP, cidade, país, código de área e fuso horário.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Um erro ocorreu",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Plugin de fonte",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "Status",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Nó de sincronização",
|
||||
"Device_TableHead_Type": "Tipo",
|
||||
"Device_TableHead_Vendor": "Fornecedor",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Executar ação no dispositivo",
|
||||
"DevDetail_Run_Actions_Tooltip": "Execute uma ação no dispositivo atual da lista suspensa.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Primeira sessão",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Último IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Último offline",
|
||||
"DevDetail_SessionInfo_StaticIP": "IP estático",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Alertas para baixo",
|
||||
"DevDetail_Shortcut_Presence": "Presença",
|
||||
"DevDetail_Shortcut_Sessions": "Sessões",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Pormenores",
|
||||
"DevDetail_Tab_Events": "Eventos",
|
||||
"DevDetail_Tab_EventsTableDate": "Data",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Plugins",
|
||||
"DevDetail_Tab_Presence": "Presença",
|
||||
"DevDetail_Tab_Sessions": "Sessões",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Ferramentas",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "A ferramenta de informações da Internet apresenta dados sobre a ligação à Internet, como endereço IP, cidade, país, código de área e fuso horário.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Ocorreu um erro",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Plugin de fonte",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "Estado",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Nó de sincronização",
|
||||
"Device_TableHead_Type": "Tipo",
|
||||
"Device_TableHead_Vendor": "Fornecedor",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "Tamanho máximo do SQLite WAL (Write-Ahead Log) em MB antes de ativar pontos de controlo automáticos. Valores mais pequenos (10-20MB) reduzem utilização de disco/armazenamento durante análises. Valores mais altos (50-100MB) reduzem picos de CPU durante operações mas podem usar mais RAM e espaço no disco. O padrão <code>50 MB</code> equilibra ambos. Útil para sistemas com recursos limitados como dispositivos NAS com cartões SD. Reinicie o servidor para que as mudanças entrem em vigor após guardar as definições.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "Tamanho limite do WAL (MB)",
|
||||
"Plugins_DeleteAll": "Eliminar todos (filtros são ignorados)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Filtro Mac",
|
||||
"Plugins_History": "Histórico de Eventos",
|
||||
"Plugins_Obj_DeleteListed": "Eliminar Objetos Listados",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Выполнить действие на устройстве",
|
||||
"DevDetail_Run_Actions_Tooltip": "Выполнить действие на текущем устройстве из раскрывающегося списка.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Первый сеанс",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Последний IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Посл. не в сети",
|
||||
"DevDetail_SessionInfo_StaticIP": "Статический IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Оповещения о сбое",
|
||||
"DevDetail_Shortcut_Presence": "Присутствие",
|
||||
"DevDetail_Shortcut_Sessions": "Сеансы",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Детали",
|
||||
"DevDetail_Tab_Events": "События",
|
||||
"DevDetail_Tab_EventsTableDate": "Дата",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Плагины",
|
||||
"DevDetail_Tab_Presence": "Присутствие",
|
||||
"DevDetail_Tab_Sessions": "Сеансы",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Инструменты",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Инструмент «Информация об Интернете» отображает информацию о подключении к Интернету, такую как IP-адрес, город, страна, код города и часовой пояс.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Произошла ошибка",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Исходный плагин",
|
||||
"Device_TableHead_StaticIP": "Статический IP",
|
||||
"Device_TableHead_Status": "Статус",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Узел синхронизации",
|
||||
"Device_TableHead_Type": "Тип",
|
||||
"Device_TableHead_Vendor": "Поставщик",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "Максимальный размер SQLite WAL (журнал упреждающей записи) в МБ перед запуском автоматических контрольных точек. Более низкие значения (10–20 МБ) уменьшают использование диска/хранилища, но увеличивают загрузку ЦП во время сканирования. Более высокие значения (50–100 МБ) уменьшают нагрузку на процессор во время операций, но могут использовать больше оперативной памяти и дискового пространства. Значение по умолчанию <code>50 МБ</code> компенсирует и то, и другое. Полезно для систем с ограниченными ресурсами, таких как устройства NAS с SD-картами. Перезапустите сервер, чтобы изменения вступили в силу после сохранения настроек.",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "Ограничение размера WAL (МБ)",
|
||||
"Plugins_DeleteAll": "Удалить все (фильтры игнорируются)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Фильтр MAC-адреса",
|
||||
"Plugins_History": "История событий",
|
||||
"Plugins_Obj_DeleteListed": "Удалить перечисленные объекты",
|
||||
@@ -854,4 +865,4 @@
|
||||
"settings_system_label": "Система",
|
||||
"settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. <b>Проверка не выполняется.</b>",
|
||||
"test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки."
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i>Komutu cihazda çalıştır",
|
||||
"DevDetail_Run_Actions_Tooltip": "Mevcut cihaz üzerinde açılır menüden bir işlem çalıştırın.",
|
||||
"DevDetail_SessionInfo_FirstSession": "İlk Oturum",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Son IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Son Çevrimdışı",
|
||||
"DevDetail_SessionInfo_StaticIP": "Statik IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Kapalı Alarmlar",
|
||||
"DevDetail_Shortcut_Presence": "Mevcudiyet",
|
||||
"DevDetail_Shortcut_Sessions": "Oturumlar",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Detaylar",
|
||||
"DevDetail_Tab_Events": "Etkinlikler",
|
||||
"DevDetail_Tab_EventsTableDate": "Tarih",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Eklentiler",
|
||||
"DevDetail_Tab_Presence": "Mevcudiyet",
|
||||
"DevDetail_Tab_Sessions": "Oturumlar",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Araçlar",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "İnternet Bilgisi aracı, IP adresi, şehir, ülke, alan kodu ve saat dilimi gibi internet bağlantısı hakkında bilgi görüntüler.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Bir hata oluştu",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Kaynak Eklentisi Modülü",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "Durum",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Senkronizasyon Node",
|
||||
"Device_TableHead_Type": "Tür",
|
||||
"Device_TableHead_Vendor": "Üretici",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> Запустити дію на пристрої",
|
||||
"DevDetail_Run_Actions_Tooltip": "Виконати дію на поточному пристрої зі спадного списку.",
|
||||
"DevDetail_SessionInfo_FirstSession": "Перша сесія",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "Останній IP",
|
||||
"DevDetail_SessionInfo_LastSession": "Останній офлайн",
|
||||
"DevDetail_SessionInfo_StaticIP": "Статичний IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "Сповіщення про падіння",
|
||||
"DevDetail_Shortcut_Presence": "Присутність",
|
||||
"DevDetail_Shortcut_Sessions": "Сеанси",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "Подробиці",
|
||||
"DevDetail_Tab_Events": "Події",
|
||||
"DevDetail_Tab_EventsTableDate": "Дата",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "Плагіни",
|
||||
"DevDetail_Tab_Presence": "Присутність",
|
||||
"DevDetail_Tab_Sessions": "Сеанси",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "Інструменти",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "Інструмент Інтернет-інформації відображає інформацію про підключення до Інтернету, таку як IP-адреса, місто, країна, код міста та часовий пояс.",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "Сталася помилка",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "Вихідний плагін",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "Статус",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "Вузол синхронізації",
|
||||
"Device_TableHead_Type": "Тип",
|
||||
"Device_TableHead_Vendor": "Продавець",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "Видалити все (фільтри ігноруються)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Фільтр Mac",
|
||||
"Plugins_History": "Історія подій",
|
||||
"Plugins_Obj_DeleteListed": "Видалити перелічені об’єкти",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "",
|
||||
"DevDetail_Run_Actions_Tooltip": "",
|
||||
"DevDetail_SessionInfo_FirstSession": "",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "",
|
||||
"DevDetail_SessionInfo_LastSession": "",
|
||||
"DevDetail_SessionInfo_StaticIP": "",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "",
|
||||
"DevDetail_Shortcut_Presence": "",
|
||||
"DevDetail_Shortcut_Sessions": "",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "",
|
||||
"DevDetail_Tab_Events": "",
|
||||
"DevDetail_Tab_EventsTableDate": "",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "",
|
||||
"DevDetail_Tab_Presence": "",
|
||||
"DevDetail_Tab_Sessions": "",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "",
|
||||
"Device_TableHead_Type": "",
|
||||
"Device_TableHead_Vendor": "",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "",
|
||||
"Plugins_DeleteAll": "",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "",
|
||||
"Plugins_History": "",
|
||||
"Plugins_Obj_DeleteListed": "",
|
||||
|
||||
@@ -134,6 +134,8 @@
|
||||
"DevDetail_Run_Actions_Title": "<i class=\"fa fa-play\"></i> 在设备上运行操作",
|
||||
"DevDetail_Run_Actions_Tooltip": "从下拉列表中对当前设备运行操作。",
|
||||
"DevDetail_SessionInfo_FirstSession": "第一节",
|
||||
"DevDetail_SessionInfo_IPHistory": "",
|
||||
"DevDetail_SessionInfo_KnownIPs": "",
|
||||
"DevDetail_SessionInfo_LastIP": "上次 IP",
|
||||
"DevDetail_SessionInfo_LastSession": "上次离线",
|
||||
"DevDetail_SessionInfo_StaticIP": "静态 IP",
|
||||
@@ -149,6 +151,8 @@
|
||||
"DevDetail_Shortcut_DownAlerts": "下线警报",
|
||||
"DevDetail_Shortcut_Presence": "存在",
|
||||
"DevDetail_Shortcut_Sessions": "会话",
|
||||
"DevDetail_Sources_FieldView": "",
|
||||
"DevDetail_Sources_PluginView": "",
|
||||
"DevDetail_Tab_Details": "详细信息",
|
||||
"DevDetail_Tab_Events": "事件",
|
||||
"DevDetail_Tab_EventsTableDate": "日期",
|
||||
@@ -168,6 +172,7 @@
|
||||
"DevDetail_Tab_Plugins": "插件",
|
||||
"DevDetail_Tab_Presence": "存在",
|
||||
"DevDetail_Tab_Sessions": "会话",
|
||||
"DevDetail_Tab_Sources": "",
|
||||
"DevDetail_Tab_Tools": "工具",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Description": "互联网信息工具显示有关互联网连接的信息,例如 IP 地址、城市、国家、区号和时区。",
|
||||
"DevDetail_Tab_Tools_Internet_Info_Error": "发生了错误",
|
||||
@@ -267,6 +272,7 @@
|
||||
"Device_TableHead_SourcePlugin": "源插件",
|
||||
"Device_TableHead_StaticIP": "",
|
||||
"Device_TableHead_Status": "状态",
|
||||
"Device_TableHead_Status_description": "",
|
||||
"Device_TableHead_SyncHubNodeName": "同步节点",
|
||||
"Device_TableHead_Type": "类型",
|
||||
"Device_TableHead_Vendor": "制造商",
|
||||
@@ -614,6 +620,11 @@
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_description": "SQLite WAL(预写日志)在触发自动检查点之前的最大大小(以MB为单位)。较低的值(10-20 MB)可减少磁盘/存储使用,但在扫描过程中会增加CPU使用率。较高的值(50-100 MB)可在操作过程中减少CPU峰值,但可能会占用更多RAM和磁盘空间。默认值<code>50 MB</code>兼顾了两者。适用于资源受限的系统,如带有SD卡的NAS设备。保存设置后,需要重新启动服务器以使更改生效。",
|
||||
"PRAGMA_JOURNAL_SIZE_LIMIT_name": "预写入日志大小限制(MB)",
|
||||
"Plugins_DeleteAll": "全部删除(忽略过滤器)",
|
||||
"Plugins_FieldStatus_Changed": "",
|
||||
"Plugins_FieldStatus_New": "",
|
||||
"Plugins_FieldStatus_NotReported": "",
|
||||
"Plugins_FieldStatus_Unchanged": "",
|
||||
"Plugins_Field_LastChanged": "",
|
||||
"Plugins_Filters_Mac": "Mac 过滤器",
|
||||
"Plugins_History": "事件历史",
|
||||
"Plugins_Obj_DeleteListed": "删除已列出对象",
|
||||
|
||||
@@ -5,6 +5,35 @@ if (session_status() == PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return this session's CSRF token, generating one on first use.
|
||||
*/
|
||||
function getCsrfToken(): string {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Halt the request with a 403 JSON response unless $_POST['csrf_token']
|
||||
* matches this session's token. Guards every state-changing PHP endpoint
|
||||
* that relies on the ambient session cookie rather than a request body
|
||||
* credential - without this, a cross-site POST can reuse a logged-in
|
||||
* operator's session to trigger the same action (CWE-352).
|
||||
*/
|
||||
function requireCsrfToken(): void {
|
||||
$submittedToken = $_POST['csrf_token'] ?? '';
|
||||
|
||||
if (!is_string($submittedToken) || !hash_equals(getCsrfToken(), $submittedToken)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Constants
|
||||
$configFolderPath = rtrim(getenv('NETALERTX_CONFIG') ?: '/data/config', '/');
|
||||
$legacyConfigPath = $_SERVER['DOCUMENT_ROOT'] . "/../config/app.conf";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- Field View Tab Skeleton ================================================= -->
|
||||
<div id="skel-tab-field-view" class="skel-tab-pane" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; min-height: 0; overflow: hidden;">
|
||||
<div class="skel-detail-form-tab clearfix">
|
||||
<div class="col-sm-2">
|
||||
<div class="skel-form-row"><span class="skel-form-input skel-shimmer" style="max-width:100%"></span></div>
|
||||
<div class="skel-form-row"><span class="skel-form-input skel-shimmer" style="max-width:100%"></span></div>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<div class="skel-form-row"><span class="skel-form-input skel-shimmer" style="max-width:100%;height:30px"></span></div>
|
||||
<div class="skel-form-row"><span class="skel-form-input skel-shimmer" style="max-width:100%;height:30px"></span></div>
|
||||
<div class="skel-form-row"><span class="skel-form-input skel-shimmer" style="max-width:100%;height:30px"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- Session Info Tab Skeleton ================================================ -->
|
||||
<div id="skel-tab-session-info" class="skel-tab-pane spinnerTarget" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; min-height: 0; overflow: hidden;">
|
||||
<div class="skel-detail-form-tab clearfix">
|
||||
<div class="col-lg-4 col-sm-6 col-xs-12">
|
||||
<div class="skel-form-row">
|
||||
<span class="skel-form-label skel-shimmer"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:280px"></span>
|
||||
</div>
|
||||
<div class="skel-form-row">
|
||||
<span class="skel-form-label skel-shimmer"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:280px"></span>
|
||||
</div>
|
||||
<div class="skel-form-row">
|
||||
<span class="skel-form-label skel-shimmer"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:220px"></span>
|
||||
</div>
|
||||
<div class="skel-form-row">
|
||||
<span class="skel-form-label skel-shimmer"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:260px"></span>
|
||||
</div>
|
||||
<div class="skel-form-row">
|
||||
<span class="skel-form-label skel-shimmer"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:260px"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-sm-6 col-xs-12">
|
||||
<div class="skel-form-row">
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:90px"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:120px"></span>
|
||||
<span class="skel-form-input skel-shimmer" style="max-width:100px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+18
-172
@@ -19,7 +19,7 @@
|
||||
<input class="col-sm-3" id="txtMacFilter" type="text" value="--" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-tabs-custom plugin-content" style="margin-bottom: 0px;">
|
||||
<div class="nav-tabs-custom plugin-content">
|
||||
|
||||
<ul id="tabs-location" class="nav nav-tabs col-sm-2 ">
|
||||
<!-- PLACEHOLDER -->
|
||||
@@ -96,146 +96,6 @@ function initFields() {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Get form control according to the column definition from config.json > database_column_definitions
|
||||
function getFormControl(dbColumnDef, value, index) {
|
||||
|
||||
result = ''
|
||||
|
||||
// Check if mapped_to_column_data exists and has a value to override the supplied value which is most likely `undefined`
|
||||
if (dbColumnDef.mapped_to_column_data && dbColumnDef.mapped_to_column_data.value) {
|
||||
value = dbColumnDef.mapped_to_column_data.value;
|
||||
}
|
||||
|
||||
|
||||
result = processColumnValue(dbColumnDef, value, index, dbColumnDef.type)
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Process column value
|
||||
function processColumnValue(dbColumnDef, value, index, type) {
|
||||
if (type.includes('.')) {
|
||||
const typeParts = type.split('.');
|
||||
|
||||
// recursion
|
||||
for (const typePart of typeParts) {
|
||||
value = processColumnValue(dbColumnDef, value, index, typePart)
|
||||
}
|
||||
|
||||
} else{
|
||||
// pick form control based on the supplied type
|
||||
switch(type)
|
||||
{
|
||||
case 'label':
|
||||
value = `<span>${value}<span>`;
|
||||
break;
|
||||
case 'none':
|
||||
value = `${value}`;
|
||||
break;
|
||||
case 'textarea_readonly':
|
||||
value = `<textarea cols="70" rows="3" wrap="off" readonly style="white-space: pre-wrap;">
|
||||
${value.replace(/^b'(.*)'$/gm, '$1').replace(/\\n/g, '\n').replace(/\\r/g, '\r')}
|
||||
</textarea>`;
|
||||
break;
|
||||
case 'textbox_save':
|
||||
|
||||
value = value == 'null' ? '' : value; // hide 'null' values
|
||||
|
||||
id = `${dbColumnDef.column}_${index}`
|
||||
|
||||
value = `<span class="form-group">
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="text" value="${value}" id="${id}" data-my-column="${dbColumnDef.column}" data-my-index="${index}" name="${dbColumnDef.column}">
|
||||
<span class="input-group-addon"><i class="fa fa-save pointer" onclick="genericSaveData('${id}');"></i></span>
|
||||
</div>
|
||||
<span>`;
|
||||
break;
|
||||
case 'url':
|
||||
value = `<span><a href="${value}" target="_blank">${value}</a><span>`;
|
||||
break;
|
||||
case 'url_http_https':
|
||||
value = `<span>
|
||||
<a href="http://${value}" target="_blank">
|
||||
<i class="fa fa-lock-open "></i>
|
||||
</a>
|
||||
/
|
||||
<a href="https://${value}" target="_blank">
|
||||
<i class="fa fa-lock "></i>
|
||||
</a>
|
||||
</span>`;
|
||||
break;
|
||||
case 'device_name_mac':
|
||||
value = `<div class="text-center"> ${value}
|
||||
<br/>
|
||||
${createDeviceLink(value)}
|
||||
</div>`;
|
||||
break;
|
||||
case 'device_mac':
|
||||
value = `<span class="anonymizeMac"><a href="/deviceDetails.php?mac=${value}" target="_blank">${value}</a><span>`;
|
||||
break;
|
||||
case 'device_ip':
|
||||
value = `<span class="anonymizeIp"><a href="#" onclick="navigateToDeviceWithIp('${value}')" >${value}</a><span>`;
|
||||
break;
|
||||
case 'threshold':
|
||||
|
||||
valueTmp = ''
|
||||
|
||||
$.each(dbColumnDef.options, function(index, obj) {
|
||||
if(Number(value) < Number(obj.maximum) && valueTmp == '')
|
||||
{
|
||||
valueTmp = `<div class="thresholdFormControl" style="background-color:${obj.hexColor}">${value}</div>`
|
||||
// return;
|
||||
}
|
||||
});
|
||||
|
||||
value = valueTmp;
|
||||
|
||||
break;
|
||||
case 'replace':
|
||||
$.each(dbColumnDef.options, function(index, obj) {
|
||||
if(value == obj.equals)
|
||||
{
|
||||
value = `<span title="${value}">${obj.replacement}</span>`
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'regex':
|
||||
|
||||
for (const option of dbColumnDef.options) {
|
||||
if (option.type === type) {
|
||||
|
||||
const regexPattern = new RegExp(option.param);
|
||||
const match = value.match(regexPattern);
|
||||
if (match) {
|
||||
// Return the first match
|
||||
value = match[0];
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'eval':
|
||||
|
||||
for (const option of dbColumnDef.options) {
|
||||
if (option.type === type) {
|
||||
// console.log(option.param)
|
||||
value = eval(option.param);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
value = value + `<div style='text-align:center' title="${getString("Plugins_no_control")}"><i class='fa-solid fa-circle-question'></i></div>` ;
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior if no match is found
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Update the corresponding DB column and entry
|
||||
@@ -541,7 +401,7 @@ function generateTabs() {
|
||||
});
|
||||
|
||||
if (visiblePlugins.length === 0) {
|
||||
$('#tabs-content-location').html(`<p class="text-muted" style="padding: 15px;">${getString('Gen_No_Data')}</p>`);
|
||||
$('#tabs-content-location').html(`<p class="text-muted plugin-no-data">${getString('Gen_No_Data')}</p>`);
|
||||
hideSpinner();
|
||||
hidePluginsSkeleton();
|
||||
return;
|
||||
@@ -603,11 +463,11 @@ function createTabContent(pluginObj, assignActive, counts) {
|
||||
${generateDataTable(prefix, 'Objects', colDefinitions)}
|
||||
${generateDataTable(prefix, 'Events', colDefinitions)}
|
||||
${generateDataTable(prefix, 'History', colDefinitions)}
|
||||
</div>
|
||||
<div class='plugins-description'>
|
||||
${getString(`${prefix}_description`)} <!-- Display the plugin description -->
|
||||
<span><a href="https://docs.netalertx.com/plugins/${pluginObj.code_name}" target="_blank">${getString('Gen_ReadDocs')}</a></span> <!-- Link to documentation -->
|
||||
</div>
|
||||
<div class='plugins-description'>
|
||||
${getString(`${prefix}_description`)} <!-- Display the plugin description -->
|
||||
<span><a href="https://docs.netalertx.com/plugins/${pluginObj.code_name}" target="_blank">${getString('Gen_ReadDocs')}</a></span> <!-- Link to documentation -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -624,19 +484,15 @@ function generateTabNavigation(prefix, counts) {
|
||||
const evtCount = counts ? counts.events : '…';
|
||||
const histCount = counts ? counts.history : '…';
|
||||
|
||||
const navItems = buildTabNavItems([
|
||||
{ href: `#objectsTarget_${prefix}`, icon: 'fa-cube', label: getString('Plugins_Objects'), badgeHtml: `<span id="objCount_${prefix}">${objCount}</span>`, active: true },
|
||||
{ href: `#eventsTarget_${prefix}`, icon: 'fa-bolt', label: getString('Plugins_Unprocessed_Events'), badgeHtml: `<span id="evtCount_${prefix}">${evtCount}</span>` },
|
||||
{ href: `#historyTarget_${prefix}`, icon: 'fa-clock', label: getString('Plugins_History'), badgeHtml: `<span id="histCount_${prefix}">${histCount}</span>` },
|
||||
]);
|
||||
|
||||
return `
|
||||
<div class="nav-tabs-custom" style="margin-bottom: 0px">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active">
|
||||
<a href="#objectsTarget_${prefix}" data-toggle="tab"><i class="fa fa-cube"></i> ${getString('Plugins_Objects')} (<span id="objCount_${prefix}">${objCount}</span>)</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#eventsTarget_${prefix}" data-toggle="tab"><i class="fa fa-bolt"></i> ${getString('Plugins_Unprocessed_Events')} (<span id="evtCount_${prefix}">${evtCount}</span>)</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#historyTarget_${prefix}" data-toggle="tab"><i class="fa fa-clock"></i> ${getString('Plugins_History')} (<span id="histCount_${prefix}">${histCount}</span>)</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="nav-tabs-custom plugin-tab-nav">
|
||||
<ul class="nav nav-tabs">${navItems}</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -648,8 +504,8 @@ function generateDataTable(prefix, tableType, colDefinitions) {
|
||||
const skelHtml = skelPluginsTable;
|
||||
|
||||
return `
|
||||
<div id="${tableType.toLowerCase()}Target_${prefix}" class="tab-pane ${tableType == "Objects" ? "active":""}" style="position:relative;">
|
||||
<div id="skel-${tableType.toLowerCase()}Target_${prefix}" class="skel-plugins-tab-pane" style="position:absolute;top:0;left:0;right:0;z-index:1">${skelHtml}</div>
|
||||
<div id="${tableType.toLowerCase()}Target_${prefix}" class="tab-pane plugin-tab-pane ${tableType == "Objects" ? "active":""}">
|
||||
<div id="skel-${tableType.toLowerCase()}Target_${prefix}" class="skel-plugins-tab-pane">${skelHtml}</div>
|
||||
<table id="${tableType.toLowerCase()}Table_${prefix}" class="display table table-striped table-stretched" data-my-dbtable="Plugins_${tableType}">
|
||||
<thead><tr>${headersHtml}</tr></thead>
|
||||
</table>
|
||||
@@ -692,19 +548,9 @@ function initializeDataTables(prefix, colDefinitions, pluginObj) {
|
||||
}
|
||||
const skelId = `#skel-${tableId.replace('Table_', 'Target_')}`;
|
||||
$(`#${tableId}`).DataTable({
|
||||
autoWidth: false,
|
||||
...getStandardDataTableOptions(skelId),
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
paging: true,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
pageLength: 25,
|
||||
lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||
// Fade out the skeleton only after the first draw so there is no gap
|
||||
// between the skeleton disappearing and the table rows appearing.
|
||||
initComplete: function() {
|
||||
$(skelId).fadeOut(0, function() { $(this).hide(); });
|
||||
},
|
||||
createdRow: function(row, data) {
|
||||
$(row).attr('data-my-index', data.index);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
//------------------------------------------------------------------------------
|
||||
// check if authenticated
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/php/templates/security.php';
|
||||
?>
|
||||
|
||||
<!-- Main content ---------------------------------------------------------- -->
|
||||
<div class="nav-tabs-custom content plugin-content field-view-content">
|
||||
|
||||
<?php require $_SERVER['DOCUMENT_ROOT'] . '/php/templates/skel_device_details_tab_field_view.php'; ?>
|
||||
|
||||
<ul id="field-tabs-location" class="nav nav-tabs col-sm-2 ">
|
||||
<!-- PLACEHOLDER -->
|
||||
</ul>
|
||||
<div id="field-tabs-content-location-wrap" class="tab-content col-sm-10">
|
||||
<div id="field-tabs-content-location" class="tab-content col-sm-12">
|
||||
<!-- PLACEHOLDER -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Status values from Plugins_Objects are plugin-observation state, not network
|
||||
// liveness - render them accordingly rather than implying reachability.
|
||||
// Localized via getString(), not hardcoded English, matching the rest of the UI.
|
||||
const FIELD_VIEW_STATUS_LABELS = {
|
||||
'new': 'Plugins_FieldStatus_New',
|
||||
'watched-changed': 'Plugins_FieldStatus_Changed',
|
||||
'watched-not-changed': 'Plugins_FieldStatus_Unchanged',
|
||||
'missing-in-last-scan': 'Plugins_FieldStatus_NotReported',
|
||||
};
|
||||
|
||||
let fieldViewLastMac = null;
|
||||
let fieldViewInitialized = false;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
function hideFieldViewSkeleton() {
|
||||
$('#skel-tab-field-view').fadeOut(0, function() { $(this).hide(); });
|
||||
}
|
||||
function showFieldViewSkeleton() {
|
||||
var $skel = $('#skel-tab-field-view');
|
||||
$skel.stop(true, true).fadeIn(10);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Builds the side-nav of fields and an empty shell pane per field. Each pane's
|
||||
// rows are built lazily, the first time its tab is actually focused (see the
|
||||
// shown.bs.tab binding below) - fieldViews/entries are already fully fetched
|
||||
// by this point (one shared getSourcesFieldData() call, not a per-field
|
||||
// query), so deferring is purely about not building table markup the user
|
||||
// may never look at, not an extra round-trip.
|
||||
// getFieldViewDefinitions() is the shared lookup declared in deviceDetails.php -
|
||||
// Session Info's Known IPs chip list reads the exact same data, not a duplicate.
|
||||
async function renderFieldView(mac) {
|
||||
const fieldViews = await getFieldViewDefinitions();
|
||||
const entries = await getSourcesFieldData(mac);
|
||||
|
||||
const fieldKeys = Object.keys(fieldViews);
|
||||
if (fieldKeys.length === 0) {
|
||||
$('#field-tabs-content-location').html(`<p class="text-muted plugin-no-data">${getString('Gen_No_Data')}</p>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const requestedField = urlParams.get('field');
|
||||
const activeField = fieldKeys.includes(requestedField) ? requestedField : fieldKeys[0];
|
||||
|
||||
// Tear down any DataTable instances from a previous mac before wiping the
|
||||
// markup they're attached to - .empty() alone would orphan them instead of
|
||||
// cleanly releasing their state.
|
||||
$('#field-tabs-content-location table').each(function() {
|
||||
if ($.fn.DataTable.isDataTable(this)) {
|
||||
$(this).DataTable().destroy();
|
||||
}
|
||||
});
|
||||
$('#field-tabs-location').empty();
|
||||
$('#field-tabs-content-location').empty();
|
||||
|
||||
fieldKeys.forEach(fieldKey => {
|
||||
const isActive = fieldKey === activeField;
|
||||
const { label_key } = fieldViews[fieldKey];
|
||||
|
||||
$('#field-tabs-location').append(`
|
||||
<li class="left-nav ${isActive ? 'active' : ''}">
|
||||
<a class="col-sm-12 textOverflow" href="#fieldPane_${fieldKey}" data-toggle="tab">
|
||||
${getString(label_key)}
|
||||
</a>
|
||||
</li>
|
||||
`);
|
||||
|
||||
const navItems = buildTabNavItems([
|
||||
{ href: `#objectsTarget_field_${fieldKey}`, icon: 'fa-cube', label: getString('Plugins_Objects'), active: true },
|
||||
]);
|
||||
|
||||
$('#field-tabs-content-location').append(`
|
||||
<div id="fieldPane_${fieldKey}" class="tab-pane ${isActive ? 'active' : ''}">
|
||||
<div class="nav-tabs-custom plugin-tab-nav">
|
||||
<ul class="nav nav-tabs">${navItems}</ul>
|
||||
<div class="tab-content">
|
||||
<div id="objectsTarget_field_${fieldKey}" class="tab-pane active">
|
||||
<table id="fieldTable_${fieldKey}" class="display table table-striped table-stretched field-pane-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
const builtFields = new Set();
|
||||
function buildFieldPaneTableOnce(fieldKey) {
|
||||
if (builtFields.has(fieldKey)) return;
|
||||
builtFields.add(fieldKey);
|
||||
buildFieldPaneTable(fieldKey, fieldViews[fieldKey], entries);
|
||||
}
|
||||
|
||||
// The field active from the start builds immediately; every other field's
|
||||
// table waits for its tab to actually be clicked.
|
||||
buildFieldPaneTableOnce(activeField);
|
||||
$('#field-tabs-location a[data-toggle="tab"]').off('shown.bs.tab.fieldView').on('shown.bs.tab.fieldView', function() {
|
||||
buildFieldPaneTableOnce($(this).attr('href').replace('#fieldPane_', ''));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build fieldKey's DataTable - Plugin / Value / Status / Last Changed, one row
|
||||
* per participating plugin entry (a plugin can report more than one entry for
|
||||
* the same device, e.g. multiple IPs - every matching entry becomes its own
|
||||
* row, not just the first). Reuses the exact same shared options
|
||||
* (getStandardDataTableOptions()) and cell-rendering approach
|
||||
* (createdCell + getFormControl()) pluginsCore.php's own DataTables use,
|
||||
* rather than a parallel hand-rolled table - only the data source differs
|
||||
* (a small, already-fetched client-side array here vs. a server-paginated
|
||||
* GraphQL query there). The column each plugin's value is read from is
|
||||
* whatever get_plugin_columns_for_field() determined server-side, not a
|
||||
* hardcoded column role; value_type comes from the field's own
|
||||
* DEVICE_FIELD_VIEWS declaration (e.g. 'device_ip' for the IP field's
|
||||
* clickable link, 'none' for a plain value) rather than being hardcoded to
|
||||
* the IP field's rendering for every field.
|
||||
* @param {string} fieldKey - The field view's key (e.g. 'ip').
|
||||
* @param {object} fieldView - fieldViews[fieldKey] from getFieldViewDefinitions().
|
||||
* @param {object[]} entries - This device's pluginsObjects entries from getSourcesFieldData().
|
||||
*/
|
||||
function buildFieldPaneTable(fieldKey, fieldView, entries) {
|
||||
const { columns: pluginColumns, value_type } = fieldView;
|
||||
|
||||
const rows = pluginColumns.flatMap(({ plugin, column }) =>
|
||||
entries
|
||||
.filter(e => e.plugin === plugin)
|
||||
.map(entry => ({ plugin, value: entry[column], status: entry.status, dateTimeChanged: entry.dateTimeChanged || '' }))
|
||||
);
|
||||
|
||||
$(`#fieldTable_${fieldKey}`).DataTable({
|
||||
...getStandardDataTableOptions(null), // no per-table skeleton - the whole tab shares #skel-tab-field-view
|
||||
data: rows,
|
||||
columns: [
|
||||
{ data: 'plugin', title: getString('AppEvents_Plugin') },
|
||||
{
|
||||
data: 'value',
|
||||
title: getString('WF_Action_value'),
|
||||
createdCell: function(td, cellData, rowData) {
|
||||
$(td).html(getFormControl({ type: value_type }, cellData, rowData.plugin));
|
||||
}
|
||||
},
|
||||
{
|
||||
data: 'status',
|
||||
title: getString('Device_TableHead_Status'),
|
||||
render: function(data) {
|
||||
const statusLabelKey = FIELD_VIEW_STATUS_LABELS[data];
|
||||
return statusLabelKey ? getString(statusLabelKey) : data;
|
||||
}
|
||||
},
|
||||
{ data: 'dateTimeChanged', title: getString('Plugins_Field_LastChanged') },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for visibility the same way pluginsCore.php's own updater() does -
|
||||
* #sourcesFieldView only reports :visible once both the Sources tab is the
|
||||
* active top-level tab AND the Plugin/Field toggle has selected Field View.
|
||||
* Resets fieldViewInitialized on a rejected render so a transient failure
|
||||
* (e.g. getSourcesFieldData()'s GraphQL error path) gets retried on the next
|
||||
* tick instead of leaving this mac permanently stuck unrendered.
|
||||
*/
|
||||
function fieldViewUpdater() {
|
||||
if ($('#sourcesFieldView').is(':visible')) {
|
||||
const currentMac = getMac();
|
||||
if (!fieldViewInitialized || currentMac !== fieldViewLastMac) {
|
||||
fieldViewInitialized = true;
|
||||
fieldViewLastMac = currentMac;
|
||||
showFieldViewSkeleton();
|
||||
callAfterAppInitialized(() => {
|
||||
renderFieldView(currentMac)
|
||||
.catch(err => {
|
||||
console.error('[Field View] render failed, will retry next tick:', err);
|
||||
fieldViewInitialized = false;
|
||||
})
|
||||
.finally(hideFieldViewSkeleton);
|
||||
});
|
||||
}
|
||||
}
|
||||
setTimeout(fieldViewUpdater, 200);
|
||||
}
|
||||
|
||||
fieldViewUpdater();
|
||||
|
||||
</script>
|
||||
@@ -620,6 +620,7 @@ $settingsJSON_DB = json_encode($settings, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX
|
||||
url: "php/server/util.php",
|
||||
data: {
|
||||
function: 'savesettings',
|
||||
csrf_token: <?= json_encode(getCsrfToken()) ?>,
|
||||
settings: JSON.stringify(settingsArray) },
|
||||
success: function(data, textStatus) {
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "netalertx-e2e",
|
||||
"private": true,
|
||||
"description": "Playwright characterization tests for the Sources tab (Plugin View + Field View) and Session Info tab. Scoped to these specs only - not a general frontend testing framework.",
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Playwright config scoped to the Sources (Plugin View + Field View) and
|
||||
// Session Info characterization tests only - see test/e2e/README.md.
|
||||
// baseURL points at a running NetAlertX instance (devcontainer nginx by
|
||||
// default); every spec mocks its own GraphQL/REST calls via page.route(),
|
||||
// so no real DB or seeded scan data is required.
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: 'test/e2e',
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: process.env.NAX_BASE_URL || 'http://localhost:20211',
|
||||
},
|
||||
});
|
||||
+109
-17
@@ -2,17 +2,27 @@
|
||||
"""
|
||||
Flag PRs that touch some but not all files in a group of mirrored skill
|
||||
files (`.gemini/skills/`, `.github/skills/`, `.claude/skills/`) without
|
||||
touching the others. `.gemini/skills/skills-index/SKILL.md` documents these
|
||||
groups and says to "keep body content identical" across them - but nothing
|
||||
previously enforced that, and the plugin-development pair had already
|
||||
drifted apart before this check existed.
|
||||
touching the others, and - for the groups documented in
|
||||
`.gemini/skills/skills-index/SKILL.md` as covering identical content -
|
||||
flag when their bodies have actually drifted apart, not just whether a
|
||||
change touched one side.
|
||||
|
||||
This can't verify the files still say the *same thing* (that needs
|
||||
judgment - some groups are intentionally different in depth, and the three
|
||||
devcontainer-management targets each cover only part of the Gemini file),
|
||||
only that a change to one file didn't forget the others exist. Exit
|
||||
non-zero (but the CI step calling this is non-blocking) when a group looks
|
||||
one-sided.
|
||||
Two independent checks:
|
||||
|
||||
1. Presence drift (every group): a PR touched some files in a group but
|
||||
not the others. Still only a hint ("did you forget the other file?"),
|
||||
not proof either way - a genuinely tool-specific change can legitimately
|
||||
touch just one side.
|
||||
2. Content drift (CONTENT_MATCH_GROUPS only): the groups the skills index
|
||||
explicitly documents as "All three cover [the same things]" should have
|
||||
byte-identical bodies (frontmatter excluded - `name`/`description` are
|
||||
allowed to differ per assistant). `settings`/`settings-management`,
|
||||
`mcp-activation`, `project-navigation`, and the three devcontainer-
|
||||
management targets are deliberately NOT in this list - the skills index
|
||||
documents them as intentionally different in depth/scope, not drifted.
|
||||
|
||||
Exit non-zero (but the CI step calling this is non-blocking) when either
|
||||
check finds something.
|
||||
|
||||
python3 scripts/check_skill_pairs.py origin/main
|
||||
"""
|
||||
@@ -44,6 +54,27 @@ GROUPS = [
|
||||
[".gemini/skills/devcontainer-management/SKILL.md", ".github/skills/devcontainer-configs/SKILL.md"],
|
||||
]
|
||||
|
||||
# Subset of GROUPS (by first-member path) the skills index documents as
|
||||
# covering identical content across every member - everything else in
|
||||
# GROUPS is documented there as intentionally different in depth/scope
|
||||
# (settings, mcp-activation, project-navigation) or intentionally partial
|
||||
# (the three devcontainer-management targets, each covering only part of
|
||||
# the Gemini file).
|
||||
CONTENT_MATCH_GROUP_KEYS = {
|
||||
".gemini/skills/plugin-development/plugin-skill.md",
|
||||
".gemini/skills/plugin-readme/plugin-readme-skill.md",
|
||||
".gemini/skills/testing-workflow/SKILL.md",
|
||||
".gemini/skills/pr-analysis/SKILL.md",
|
||||
".gemini/skills/scan-pipeline/SKILL.md",
|
||||
".gemini/skills/database-patterns/SKILL.md",
|
||||
".gemini/skills/prd-writing/SKILL.md",
|
||||
".gemini/skills/ux-design-patterns/SKILL.md",
|
||||
".gemini/skills/skill-hygiene/SKILL.md",
|
||||
".gemini/skills/plugin-review/SKILL.md",
|
||||
".gemini/skills/install-scripts/SKILL.md",
|
||||
".gemini/skills/logging-standards/SKILL.md",
|
||||
}
|
||||
|
||||
|
||||
def changed_files(base_ref):
|
||||
result = subprocess.run(
|
||||
@@ -53,12 +84,27 @@ def changed_files(base_ref):
|
||||
return set(result.stdout.splitlines())
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: check_skill_pairs.py <base-ref>", file=sys.stderr)
|
||||
return 2
|
||||
def strip_frontmatter(text):
|
||||
"""Drop a leading YAML frontmatter block (--- ... ---) so name/
|
||||
description, which are allowed to differ per assistant, don't count as
|
||||
content drift. Returns the stripped-and-trimmed body, or the original
|
||||
text trimmed if there's no frontmatter block."""
|
||||
if text.startswith("---"):
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
return parts[2].strip()
|
||||
return text.strip()
|
||||
|
||||
changed = changed_files(sys.argv[1])
|
||||
|
||||
def read_body(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return strip_frontmatter(f.read())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def check_presence_drift(changed):
|
||||
problems = []
|
||||
for group in GROUPS:
|
||||
touched = [path for path in group if path in changed]
|
||||
@@ -67,12 +113,58 @@ def main():
|
||||
problems.append(
|
||||
f"- touched {', '.join(touched)} but not {', '.join(untouched)}."
|
||||
)
|
||||
return problems
|
||||
|
||||
if problems:
|
||||
|
||||
def check_content_drift():
|
||||
problems = []
|
||||
for group in GROUPS:
|
||||
if group[0] not in CONTENT_MATCH_GROUP_KEYS:
|
||||
continue
|
||||
bodies = {path: read_body(path) for path in group}
|
||||
|
||||
missing = [path for path, body in bodies.items() if body is None]
|
||||
if missing:
|
||||
for path in missing:
|
||||
problems.append(f"- {path} is missing (part of a content-match group).")
|
||||
continue
|
||||
|
||||
reference_path, reference_body = group[0], bodies[group[0]]
|
||||
for path, body in bodies.items():
|
||||
if path == reference_path:
|
||||
continue
|
||||
if body != reference_body:
|
||||
problems.append(
|
||||
f"- {path} and {reference_path} are documented as covering "
|
||||
f"identical content but their bodies differ."
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: check_skill_pairs.py <base-ref>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
changed = changed_files(sys.argv[1])
|
||||
presence_problems = check_presence_drift(changed)
|
||||
content_problems = check_content_drift()
|
||||
|
||||
if presence_problems:
|
||||
print("Possible skill-group drift (only some mirrored files were touched):")
|
||||
print("\n".join(problems))
|
||||
print("\n".join(presence_problems))
|
||||
print("\nIf the change is genuinely tool-specific, ignore this. "
|
||||
"Otherwise update the other file(s) too - see .gemini/skills/skills-index/SKILL.md.")
|
||||
|
||||
if content_problems:
|
||||
if presence_problems:
|
||||
print()
|
||||
print("Skill-pair content drift (bodies documented as identical, but aren't):")
|
||||
print("\n".join(content_problems))
|
||||
print("\nDiff the files directly to see what changed - "
|
||||
"see .gemini/skills/skills-index/SKILL.md for which groups this applies to.")
|
||||
|
||||
if presence_problems or content_problems:
|
||||
return 1
|
||||
|
||||
print("No skill-group drift detected.")
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ def main():
|
||||
|
||||
if processScan is True:
|
||||
mylog("debug", "[MAIN] start processing scan results")
|
||||
process_scan(db)
|
||||
process_scan(db, all_plugins)
|
||||
updateState("Scan processed", None, None, None, None, False)
|
||||
except Exception:
|
||||
tick_failed = True
|
||||
|
||||
+5
-1
@@ -29,6 +29,7 @@ from const import (
|
||||
defaultWebPort,
|
||||
)
|
||||
from db.db_helper import get_sql_devices_tiles
|
||||
from db.plugin_field_views import build_field_views_payload
|
||||
from logger import mylog
|
||||
from helper import write_file, get_setting_value
|
||||
from utils.datetime_utils import timeNowUTC
|
||||
@@ -63,7 +64,10 @@ def update_api(
|
||||
app_state = updateState()
|
||||
|
||||
# Save plugins
|
||||
write_file(apiPath + "plugins.json", json.dumps({"data": all_plugins}))
|
||||
write_file(
|
||||
apiPath + "plugins.json",
|
||||
json.dumps({"data": all_plugins, "field_views": build_field_views_payload(all_plugins)}),
|
||||
)
|
||||
|
||||
# Prepare database tables we want to expose
|
||||
dataSourcesSQLs = [
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Registry and discovery for device "field views" - the Sources tab's Field View,
|
||||
which pivots Plugins_Objects data by a semantic field (e.g. "ip") across every
|
||||
plugin that reports it, instead of by plugin.
|
||||
|
||||
DEVICE_FIELD_VIEWS is small and hand-maintained on purpose: it records the
|
||||
semantic-to-schema mapping (target_column), the product capability
|
||||
(multi_valued), the display label (label_key), and the frontend rendering
|
||||
type (value_type) that cannot be derived safely from the plugin configs.
|
||||
Which plugin's column actually answers a field is NOT hand-maintained -
|
||||
that's derived fresh from all_plugins by get_plugin_columns_for_field()
|
||||
below, since that fact has exactly one real source already (each plugin's
|
||||
own config.json).
|
||||
"""
|
||||
|
||||
_PLUGINS_OBJECTS_COLUMN_ROLES = (
|
||||
"objectPrimaryId",
|
||||
"objectSecondaryId",
|
||||
"watchedValue1",
|
||||
"watchedValue2",
|
||||
"watchedValue3",
|
||||
"watchedValue4",
|
||||
)
|
||||
|
||||
DEVICE_FIELD_VIEWS = {
|
||||
"ip": {
|
||||
# Reuses the existing "IP" label already defined for deviceDetails.php's
|
||||
# own Sessions table - en_us.json already had 4 other "IP" keys before this
|
||||
# one; search before adding a near-duplicate for the same plain text.
|
||||
"label_key": "DevDetail_SessionTable_IP",
|
||||
"target_column": "scanLastIP",
|
||||
"multi_valued": True,
|
||||
# Renders via plugin_column_render.js's device_ip case - a clickable
|
||||
# "find other devices with this IP" link.
|
||||
"value_type": "device_ip",
|
||||
},
|
||||
"name": {
|
||||
"label_key": "Device_TableHead_Name",
|
||||
"target_column": "scanName",
|
||||
"multi_valued": True,
|
||||
"value_type": "none",
|
||||
},
|
||||
"vendor": {
|
||||
"label_key": "Device_TableHead_Vendor",
|
||||
"target_column": "scanVendor",
|
||||
"multi_valued": True,
|
||||
"value_type": "none",
|
||||
},
|
||||
"type": {
|
||||
"label_key": "Device_TableHead_Type",
|
||||
"target_column": "scanType",
|
||||
"multi_valued": True,
|
||||
"value_type": "none",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_plugin_columns_for_field(all_plugins, field_key):
|
||||
"""Return [(plugin_prefix, column_role)] for every CurrentScan-mapped plugin whose
|
||||
database_column_definitions maps DEVICE_FIELD_VIEWS[field_key]'s target_column to one of
|
||||
Plugins_Objects' identity/watched columns (objectPrimaryId/objectSecondaryId/watchedValue1-4
|
||||
- not 'extra' or any other column), AND whose own objectPrimaryId maps to scanMac
|
||||
specifically (deliberately not scanParentMAC too - see the PRD's correction trail).
|
||||
column_role is whichever of those columns the match landed on."""
|
||||
target_column = DEVICE_FIELD_VIEWS[field_key]["target_column"]
|
||||
results = []
|
||||
|
||||
for plugin in all_plugins:
|
||||
if plugin.get("mapped_to_table") != "CurrentScan":
|
||||
continue
|
||||
|
||||
columns = plugin.get("database_column_definitions", [])
|
||||
|
||||
primary_id_col = next(
|
||||
(c for c in columns if c.get("column") == "objectPrimaryId"), None
|
||||
)
|
||||
if not primary_id_col or primary_id_col.get("mapped_to_column") != "scanMac":
|
||||
continue
|
||||
|
||||
field_col = None
|
||||
for c in columns:
|
||||
if c.get("column") in _PLUGINS_OBJECTS_COLUMN_ROLES and c.get("mapped_to_column") == target_column:
|
||||
field_col = c
|
||||
break
|
||||
if not field_col:
|
||||
continue
|
||||
|
||||
results.append((plugin.get("unique_prefix"), field_col["column"]))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
_field_views_cache = {"all_plugins_ref": None, "result": None}
|
||||
|
||||
|
||||
def get_all_device_field_views(all_plugins):
|
||||
"""Return {field_key: [(plugin_prefix, column_role), ...]} for every DEVICE_FIELD_VIEWS
|
||||
field, cached by all_plugins' object identity. all_plugins is loaded once at startup/config
|
||||
reload and reused as the same object across every later call (e.g. update_api() runs on
|
||||
every scan cycle and plugin completion) - caching by identity avoids re-scanning every
|
||||
plugin's config on each of those calls, recomputing only when a genuinely new all_plugins
|
||||
object (an actual reload) is passed in. The cache retains a reference to the list itself
|
||||
(compared with `is`), not just its id() - an id() alone can be reused by an unrelated
|
||||
object once the original all_plugins list is garbage collected, which would wrongly
|
||||
serve a stale cached result for what is actually a new reload."""
|
||||
if _field_views_cache["all_plugins_ref"] is not all_plugins:
|
||||
_field_views_cache["result"] = {
|
||||
field_key: get_plugin_columns_for_field(all_plugins, field_key)
|
||||
for field_key in DEVICE_FIELD_VIEWS
|
||||
}
|
||||
_field_views_cache["all_plugins_ref"] = all_plugins
|
||||
return _field_views_cache["result"]
|
||||
|
||||
|
||||
def build_field_views_payload(all_plugins):
|
||||
"""Return get_all_device_field_views()'s result in the JSON-serializable shape embedded
|
||||
into plugins.json's field_views block: {field_key: {"label_key": ..., "value_type": ...,
|
||||
"columns": [{"plugin": ..., "column": ...}, ...]}}. label_key/value_type travel alongside
|
||||
their columns so the frontend reads DEVICE_FIELD_VIEWS' own declared label and rendering
|
||||
type instead of re-deriving them per field_key itself."""
|
||||
all_columns = get_all_device_field_views(all_plugins)
|
||||
return {
|
||||
field_key: {
|
||||
"label_key": DEVICE_FIELD_VIEWS[field_key]["label_key"],
|
||||
"value_type": DEVICE_FIELD_VIEWS[field_key]["value_type"],
|
||||
"columns": [{"plugin": prefix, "column": column} for prefix, column in columns],
|
||||
}
|
||||
for field_key, columns in all_columns.items()
|
||||
}
|
||||
@@ -42,6 +42,14 @@ DATETIME_FIELDS = {
|
||||
# Placeholders:
|
||||
# {condition} — optional WHERE clause appended by condition builder
|
||||
# {alert_down_minutes} — runtime value, only used by down_devices
|
||||
#
|
||||
# Every section here reads through Events_Devices, which LEFT JOINs Devices
|
||||
# onto Events - a device's pending event survives deleting the device (Events
|
||||
# is kept as a persistent audit trail, not cleaned up on delete), so the join
|
||||
# still returns that row with every Devices-sourced column NULL. The
|
||||
# `devMac IS NOT NULL` guard in each template below is what excludes that
|
||||
# orphaned row from notifications; it does not affect the underlying Events
|
||||
# row itself, which the events.php page still shows.
|
||||
# ---------------------------------------------------------------------------
|
||||
SQL_TEMPLATES = {
|
||||
"new_devices": """
|
||||
@@ -59,6 +67,7 @@ SQL_TEMPLATES = {
|
||||
FROM Events_Devices
|
||||
WHERE evePendingAlertEmail = 1
|
||||
AND eveEventType = 'New Device' {condition}
|
||||
AND devMac IS NOT NULL
|
||||
ORDER BY eveDateTime
|
||||
""",
|
||||
"down_devices": """
|
||||
@@ -77,6 +86,7 @@ SQL_TEMPLATES = {
|
||||
WHERE evePendingAlertEmail = 1
|
||||
AND down_events.eveEventType = 'Device Down'
|
||||
AND eveDateTime < datetime('now', '-{alert_down_minutes} minutes')
|
||||
AND down_events.devMac IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM Events AS connected_events
|
||||
@@ -101,6 +111,7 @@ SQL_TEMPLATES = {
|
||||
FROM Events_Devices AS reconnected_devices
|
||||
WHERE reconnected_devices.eveEventType = 'Down Reconnected'
|
||||
AND reconnected_devices.evePendingAlertEmail = 1
|
||||
AND reconnected_devices.devMac IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM Events AS newer
|
||||
WHERE newer.eveMac = reconnected_devices.eveMac
|
||||
@@ -125,6 +136,7 @@ SQL_TEMPLATES = {
|
||||
FROM Events_Devices
|
||||
WHERE evePendingAlertEmail = 1
|
||||
AND eveEventType IN ({event_types}) {condition}
|
||||
AND devMac IS NOT NULL
|
||||
ORDER BY eveDateTime
|
||||
""",
|
||||
"plugins": """
|
||||
|
||||
@@ -359,7 +359,7 @@ def update_ipv4_ipv6(db):
|
||||
edge case - gets both fields populated from that one cycle instead of only
|
||||
whichever family happened to win devLastIP's single-value reduction.
|
||||
Skips empty/presence-suppressed rows and preserves existing values for a
|
||||
family not refreshed this cycle. See .gemini/internal-docs/PRDs/dual-stack-primary-ip-support.md.
|
||||
family not refreshed this cycle.
|
||||
"""
|
||||
sql = db.sql
|
||||
mylog("debug", "[Update Devices] Updating devPrimaryIPv4 / devPrimaryIPv6 from CurrentScan")
|
||||
|
||||
@@ -16,6 +16,7 @@ from scan.device_handling import (
|
||||
from helper import get_setting_value
|
||||
from scan.presence import current_scan_presence_condition, nic_derived_presence_condition
|
||||
from db.db_helper import print_table_schema
|
||||
from db.plugin_field_views import get_plugin_columns_for_field
|
||||
from utils.datetime_utils import timeNowUTC
|
||||
from logger import mylog, Logger
|
||||
from messaging.reporting import skip_repeated_notifications
|
||||
@@ -45,6 +46,40 @@ def _connect_event_type_case(event_type_expr, pending_expr):
|
||||
END"""
|
||||
|
||||
|
||||
def _known_plugin_ip_addresses_sql(all_plugins):
|
||||
"""Build a SQL derived-table body (scanMac, knownAddr) unioning every
|
||||
(plugin, column_role) pair get_plugin_columns_for_field() names for the
|
||||
'ip' field - the Plugins_Objects-derived half of insert_events()'s IP
|
||||
Changed query's additive "known" test. An address already reported (and
|
||||
not missing-in-last-scan) by any
|
||||
eligible plugin for a MAC counts as known, on top of the existing
|
||||
devPrimaryIPv4/devPrimaryIPv6/devLastIP three-slot check - never instead
|
||||
of it, so a device whose only contributing plugins can't participate
|
||||
(dockerdisc, wificanary) gets an always-empty set here and the overall
|
||||
check reduces to exactly today's three-slot behavior.
|
||||
|
||||
Returns None when all_plugins has no eligible plugin, so the caller can
|
||||
omit the NOT EXISTS clause entirely rather than build a degenerate query.
|
||||
|
||||
A plugin's unique_prefix is a trusted config.json value (read at
|
||||
installation/reload time, not request time) and column is always one of
|
||||
plugin_field_views._PLUGINS_OBJECTS_COLUMN_ROLES' fixed literals - both
|
||||
interpolated here the same way startTime already is elsewhere in this
|
||||
file, not user input requiring parameterization.
|
||||
"""
|
||||
pairs = get_plugin_columns_for_field(all_plugins or [], "ip")
|
||||
if not pairs:
|
||||
return None
|
||||
return " UNION ALL ".join(
|
||||
f"""SELECT objectPrimaryId AS scanMac, {column} AS knownAddr
|
||||
FROM Plugins_Objects
|
||||
WHERE plugin = '{plugin}'
|
||||
AND status != 'missing-in-last-scan'
|
||||
AND {column} IS NOT NULL"""
|
||||
for plugin, column in pairs
|
||||
)
|
||||
|
||||
|
||||
# Make sure log level is initialized correctly
|
||||
Logger(get_setting_value("LOG_LEVEL"))
|
||||
|
||||
@@ -53,7 +88,7 @@ Logger(get_setting_value("LOG_LEVEL"))
|
||||
# ===============================================================================
|
||||
|
||||
|
||||
def process_scan(db):
|
||||
def process_scan(db, all_plugins=None):
|
||||
|
||||
# Save own device data into CurrentScan TODO:move potentially into a separate plugin
|
||||
mylog("verbose", "[Process Scan] Processing scan results")
|
||||
@@ -72,7 +107,7 @@ def process_scan(db):
|
||||
|
||||
# Create Events
|
||||
mylog("verbose", "[Process Scan] Sessions Events (connect / disconnect)")
|
||||
insert_events(db)
|
||||
insert_events(db, all_plugins)
|
||||
|
||||
# Create New Devices
|
||||
# after create events -> avoid 'connection' event
|
||||
@@ -194,7 +229,12 @@ def create_sessions_snapshot(db):
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
def insert_events(db):
|
||||
def insert_events(db, all_plugins=None):
|
||||
"""Insert this cycle's Device Down/New Connections/Disconnected/IP Changed
|
||||
Events rows. all_plugins (every plugin's parsed config.json) is optional
|
||||
and defaults to None/empty - the IP Changed query's Plugins_Objects-
|
||||
derived "known address" half is then skipped entirely, matching today's
|
||||
three-slot-only behavior exactly."""
|
||||
sql = db.sql # TO-DO
|
||||
startTime = timeNowUTC()
|
||||
|
||||
@@ -330,35 +370,69 @@ def insert_events(db):
|
||||
# Unlike Device Down/Disconnected (which fire on row *absence*), IP
|
||||
# Changed fires from a present row, so quiet is consulted additively here:
|
||||
# suppress if EITHER the live aggregate says quiet OR devAlertEvents is
|
||||
# off. Same present_agg/quiet_agg split as the New Connections query
|
||||
# above, for the same reasons - present_agg must require scanPresence = 1
|
||||
# or an abstain-only row with a differing IP would look like a live
|
||||
# change, and MIN(scanLastIP) keeps two presence-asserting plugins with
|
||||
# different IPs from each inserting their own event.
|
||||
# off. Same quiet_agg split as the New Connections query above.
|
||||
#
|
||||
# Per-address evaluation, not a single MIN()-reduced candidate: a device
|
||||
# with two simultaneously present, already-known addresses in one family
|
||||
# must not have only the
|
||||
# MIN()'d one tested against the three-slot check - that's exactly how a
|
||||
# known address can still fail every comparison and fire a false event.
|
||||
# Every distinct present address is instead judged on its own membership
|
||||
# in known(), additive over today's three-slot check with the
|
||||
# Plugins_Objects-derived set below (empty, hence a no-op, for a device
|
||||
# whose only contributing plugins can't participate - dockerdisc,
|
||||
# wificanary). Addresses that fail known() are aggregated into exactly
|
||||
# one Events row per MAC per cycle (today's shape), listing every one of
|
||||
# them in eveAdditionalInfo rather than multiplying event/notification
|
||||
# volume with one row per address.
|
||||
known_plugin_ips_sql = _known_plugin_ip_addresses_sql(all_plugins)
|
||||
known_plugin_ips_clause = (
|
||||
f"""AND NOT EXISTS (
|
||||
SELECT 1 FROM ({known_plugin_ips_sql}) known_plugin_ips
|
||||
WHERE known_plugin_ips.scanMac = present_addrs.scanMac
|
||||
AND known_plugin_ips.knownAddr = present_addrs.scanLastIP
|
||||
)"""
|
||||
if known_plugin_ips_sql else ""
|
||||
)
|
||||
sql.execute(f"""INSERT OR IGNORE INTO Events (eveMac, eveIp, eveDateTime,
|
||||
eveEventType, eveAdditionalInfo,
|
||||
evePendingAlertEmail)
|
||||
SELECT present_agg.scanMac, present_agg.scanLastIP, '{startTime}', 'IP Changed',
|
||||
'Previous IP: '|| devLastIP,
|
||||
CASE WHEN quiet_agg.scanQuiet = 1 THEN 0 ELSE devAlertEvents END
|
||||
FROM Devices
|
||||
JOIN (
|
||||
SELECT scanMac, MIN(scanLastIP) AS scanLastIP
|
||||
WITH present_addrs AS (
|
||||
SELECT DISTINCT scanMac, scanLastIP
|
||||
FROM CurrentScan
|
||||
WHERE scanPresence = 1
|
||||
AND scanLastIP IS NOT NULL
|
||||
AND scanLastIP NOT IN ({NULL_EQUIVALENTS_SQL})
|
||||
GROUP BY scanMac
|
||||
) present_agg ON present_agg.scanMac = devMac
|
||||
JOIN (
|
||||
),
|
||||
quiet_agg AS (
|
||||
SELECT scanMac,
|
||||
MAX(CASE WHEN scanNotificationMode = 'quiet' THEN 1 ELSE 0 END) AS scanQuiet
|
||||
FROM CurrentScan
|
||||
GROUP BY scanMac
|
||||
) quiet_agg ON quiet_agg.scanMac = devMac
|
||||
WHERE present_agg.scanLastIP <> COALESCE(devPrimaryIPv4, '')
|
||||
AND present_agg.scanLastIP <> COALESCE(devPrimaryIPv6, '')
|
||||
AND present_agg.scanLastIP <> COALESCE(devLastIP, '') """)
|
||||
),
|
||||
new_addrs AS (
|
||||
SELECT present_addrs.scanMac, present_addrs.scanLastIP
|
||||
FROM present_addrs
|
||||
JOIN Devices ON Devices.devMac = present_addrs.scanMac
|
||||
WHERE present_addrs.scanLastIP <> COALESCE(Devices.devPrimaryIPv4, '')
|
||||
AND present_addrs.scanLastIP <> COALESCE(Devices.devPrimaryIPv6, '')
|
||||
AND present_addrs.scanLastIP <> COALESCE(Devices.devLastIP, '')
|
||||
{known_plugin_ips_clause}
|
||||
),
|
||||
new_addrs_agg AS (
|
||||
SELECT scanMac,
|
||||
MIN(scanLastIP) AS firstNewIP,
|
||||
GROUP_CONCAT(scanLastIP, ', ') AS allNewIPs
|
||||
FROM new_addrs
|
||||
GROUP BY scanMac
|
||||
)
|
||||
SELECT new_addrs_agg.scanMac, new_addrs_agg.firstNewIP, '{startTime}', 'IP Changed',
|
||||
'Previous IP: ' || Devices.devLastIP || ' | New IP(s): ' || new_addrs_agg.allNewIPs,
|
||||
CASE WHEN quiet_agg.scanQuiet = 1 THEN 0 ELSE Devices.devAlertEvents END
|
||||
FROM new_addrs_agg
|
||||
JOIN Devices ON Devices.devMac = new_addrs_agg.scanMac
|
||||
JOIN quiet_agg ON quiet_agg.scanMac = new_addrs_agg.scanMac
|
||||
""")
|
||||
mylog("debug", "[Events] - Events end")
|
||||
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class WorkflowManager:
|
||||
|
||||
result = get_unprocessed(self.db)
|
||||
|
||||
mylog("none", [f"[WF] get_new_app_events - new events count: {len(result)}"])
|
||||
mylog("debug", [f"[WF] get_new_app_events - new events count: {len(result)}"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -25,13 +25,19 @@ pytestmark = pytest.mark.skipif(PHP_BIN is None, reason="PHP CLI (php or php83)
|
||||
|
||||
# Stands in for the web request: reads {"front": ..., "settings": [...]} from stdin,
|
||||
# satisfies security.php's request-only dependencies and lets util.php dispatch savesettings.
|
||||
# util.php only reads function/settings from $_POST (not $_REQUEST) and requires a matching
|
||||
# csrf_token - seed $_SESSION directly (same PHP process, so security.php's session_start()
|
||||
# just resumes it) rather than $_REQUEST, to mirror a real authenticated POST.
|
||||
PHP_RUNNER = (
|
||||
'$in = json_decode(stream_get_contents(STDIN), true);'
|
||||
'if (!function_exists("apache_request_headers")) { function apache_request_headers() { return []; } }'
|
||||
'$_SERVER["DOCUMENT_ROOT"] = $in["front"];'
|
||||
'$_SERVER["HTTP_HOST"] = "localhost";'
|
||||
'$_SERVER["REQUEST_URI"] = "/php/server/util.php";'
|
||||
'$_REQUEST = ["function" => "savesettings", "settings" => json_encode($in["settings"])];'
|
||||
'session_start();'
|
||||
'$_SESSION["csrf_token"] = "test-harness-csrf-token";'
|
||||
'$_POST = ["function" => "savesettings", "csrf_token" => "test-harness-csrf-token", '
|
||||
'"settings" => json_encode($in["settings"])];'
|
||||
'require $in["front"] . "/php/server/util.php";'
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Tests for notification_sections.py's SQL_TEMPLATES against a pending event
|
||||
for a MAC that no longer has a Devices row (the device was deleted while an
|
||||
event was still waiting to be reported).
|
||||
|
||||
Events_Devices (server/db/db_upgrade.py) is Events LEFT JOIN Devices, so a
|
||||
deleted device's orphaned pending event still surfaces through it with every
|
||||
Devices-sourced column NULL. Nothing else in the notification pipeline
|
||||
excludes it: the "disable if device opted out" pre-filters in
|
||||
get_notifications() and skip_repeated_notifications() both gate via
|
||||
`eveMac IN (SELECT devMac FROM Devices WHERE ...)`, which can never match a
|
||||
MAC with no Devices row at all.
|
||||
|
||||
Deleting a device must not delete its Events rows (Events is a persistent,
|
||||
append-only audit trail read directly by the global events.php page, not
|
||||
just the per-device tab) - so the fix is at the query level, not by cleaning
|
||||
up Events on delete.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from messaging.notification_sections import SQL_TEMPLATES # noqa: E402
|
||||
from db_test_helpers import make_db, insert_device, now_utc # noqa: E402
|
||||
|
||||
EXISTING_MAC = "aa:bb:cc:dd:ee:01"
|
||||
DELETED_MAC = "aa:bb:cc:dd:ee:02"
|
||||
|
||||
|
||||
def _insert_event(conn, mac, event_type, minutes_old=0):
|
||||
cur = conn.cursor()
|
||||
event_time = now_utc() if minutes_old == 0 else _minutes_ago(minutes_old)
|
||||
cur.execute(
|
||||
"""INSERT INTO Events
|
||||
(eveMac, eveIp, eveDateTime, eveEventType, eveAdditionalInfo, evePendingAlertEmail)
|
||||
VALUES (?, '192.168.1.1', ?, ?, '', 1)""",
|
||||
(mac, event_time, event_type),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _minutes_ago(n):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
return (datetime.now(timezone.utc) - timedelta(minutes=n)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _setup(conn):
|
||||
"""EXISTING_MAC has a Devices row; DELETED_MAC does not (simulates a
|
||||
device deleted while it still had a pending, unreported event)."""
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, EXISTING_MAC, alert_down=1)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _run_template(conn, section, **format_vars):
|
||||
sql = SQL_TEMPLATES[section].format(condition="", **format_vars)
|
||||
return conn.execute(sql).fetchall()
|
||||
|
||||
|
||||
class TestNewDevicesExcludesOrphanedEvent:
|
||||
def test_orphaned_mac_excluded_existing_mac_kept(self):
|
||||
conn = make_db()
|
||||
_setup(conn)
|
||||
_insert_event(conn, EXISTING_MAC, "New Device")
|
||||
_insert_event(conn, DELETED_MAC, "New Device")
|
||||
|
||||
rows = _run_template(conn, "new_devices")
|
||||
macs = {r["eveMac"] for r in rows}
|
||||
|
||||
assert EXISTING_MAC in macs
|
||||
assert DELETED_MAC not in macs
|
||||
|
||||
|
||||
class TestDownDevicesExcludesOrphanedEvent:
|
||||
def test_orphaned_mac_excluded_existing_mac_kept(self):
|
||||
conn = make_db()
|
||||
_setup(conn)
|
||||
_insert_event(conn, EXISTING_MAC, "Device Down", minutes_old=10)
|
||||
_insert_event(conn, DELETED_MAC, "Device Down", minutes_old=10)
|
||||
|
||||
rows = _run_template(conn, "down_devices", alert_down_minutes=0)
|
||||
macs = {r["eveMac"] for r in rows}
|
||||
|
||||
assert EXISTING_MAC in macs
|
||||
assert DELETED_MAC not in macs
|
||||
|
||||
|
||||
class TestDownReconnectedExcludesOrphanedEvent:
|
||||
def test_orphaned_mac_excluded_existing_mac_kept(self):
|
||||
conn = make_db()
|
||||
_setup(conn)
|
||||
_insert_event(conn, EXISTING_MAC, "Down Reconnected")
|
||||
_insert_event(conn, DELETED_MAC, "Down Reconnected")
|
||||
|
||||
rows = _run_template(conn, "down_reconnected")
|
||||
macs = {r["eveMac"] for r in rows}
|
||||
|
||||
assert EXISTING_MAC in macs
|
||||
assert DELETED_MAC not in macs
|
||||
|
||||
|
||||
class TestEventsExcludesOrphanedEvent:
|
||||
def test_orphaned_mac_excluded_existing_mac_kept(self):
|
||||
conn = make_db()
|
||||
_setup(conn)
|
||||
_insert_event(conn, EXISTING_MAC, "Connected")
|
||||
_insert_event(conn, DELETED_MAC, "Connected")
|
||||
|
||||
rows = _run_template(
|
||||
conn, "events",
|
||||
event_types="'Connected', 'Down Reconnected', 'Disconnected','IP Changed'",
|
||||
)
|
||||
macs = {r["eveMac"] for r in rows}
|
||||
|
||||
assert EXISTING_MAC in macs
|
||||
assert DELETED_MAC not in macs
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Tests for the Sources tab's Field View backend (server/db/plugin_field_views.py).
|
||||
|
||||
Four things are tested:
|
||||
1. get_plugin_columns_for_field() correctness against a fabricated plugin list -
|
||||
every column role, both exclusion reasons, and that matching is on
|
||||
mapped_to_column, not the advisory type tag.
|
||||
2. The MAC-identity invariant holds for every real, participating plugin.
|
||||
3. The IP-discovery structural check holds against the real plugin inventory,
|
||||
and dockerfdisc/wificanary are excluded for their documented reasons.
|
||||
4. get_all_device_field_views()'s identity-based cache.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server"))
|
||||
|
||||
from db.plugin_field_views import ( # noqa: E402
|
||||
DEVICE_FIELD_VIEWS,
|
||||
build_field_views_payload,
|
||||
get_all_device_field_views,
|
||||
get_plugin_columns_for_field,
|
||||
)
|
||||
from utils.plugin_utils import get_plugins_configs # noqa: E402
|
||||
|
||||
|
||||
def _plugin(prefix, primary_mapped_to="scanMac", extra_columns=None):
|
||||
"""Build a fake CurrentScan-mapped plugin dict for get_plugin_columns_for_field() tests."""
|
||||
columns = [{"column": "objectPrimaryId", "mapped_to_column": primary_mapped_to}]
|
||||
if extra_columns:
|
||||
columns.extend(extra_columns)
|
||||
return {
|
||||
"unique_prefix": prefix,
|
||||
"mapped_to_table": "CurrentScan",
|
||||
"database_column_definitions": columns,
|
||||
}
|
||||
|
||||
|
||||
class TestGetPluginColumnsForFieldCorrectness:
|
||||
def test_matches_objectSecondaryId(self):
|
||||
plugins = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP", "type": "device_ip"},
|
||||
])]
|
||||
assert get_plugin_columns_for_field(plugins, "ip") == [("A", "objectSecondaryId")]
|
||||
|
||||
def test_matches_watchedValue_role_regardless_of_type_tag(self):
|
||||
"""Matching is on mapped_to_column, not the advisory type tag - a mislabeled
|
||||
column (type: 'label' on an actual scanLastIP mapping) must still be included."""
|
||||
plugins = [_plugin("B", extra_columns=[
|
||||
{"column": "watchedValue2", "mapped_to_column": "scanLastIP", "type": "label"},
|
||||
])]
|
||||
assert get_plugin_columns_for_field(plugins, "ip") == [("B", "watchedValue2")]
|
||||
|
||||
def test_excludes_extra_mapped_column(self):
|
||||
"""scanLastIP mapped to 'extra' (not an identity/watched column) must be excluded -
|
||||
there's no column role get_plugin_columns_for_field() can point at."""
|
||||
plugins = [_plugin("C", extra_columns=[
|
||||
{"column": "extra", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
assert get_plugin_columns_for_field(plugins, "ip") == []
|
||||
|
||||
def test_excludes_non_scanMac_objectPrimaryId(self):
|
||||
"""A plugin whose objectPrimaryId maps to scanParentMAC (not scanMac) is excluded,
|
||||
even if it maps scanLastIP correctly - the MAC-identity invariant is checked first."""
|
||||
plugins = [_plugin("D", primary_mapped_to="scanParentMAC", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
assert get_plugin_columns_for_field(plugins, "ip") == []
|
||||
|
||||
def test_excludes_non_CurrentScan_mapped_plugin(self):
|
||||
plugin = _plugin("E", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])
|
||||
plugin["mapped_to_table"] = "Plugins_Objects"
|
||||
assert get_plugin_columns_for_field([plugin], "ip") == []
|
||||
|
||||
def test_multiple_plugins_mixed(self):
|
||||
plugins = [
|
||||
_plugin("A", extra_columns=[{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"}]),
|
||||
_plugin("B", primary_mapped_to="scanParentMAC", extra_columns=[
|
||||
{"column": "extra", "mapped_to_column": "scanLastIP"},
|
||||
]),
|
||||
_plugin("F", extra_columns=[{"column": "watchedValue1", "mapped_to_column": "scanLastIP"}]),
|
||||
]
|
||||
assert get_plugin_columns_for_field(plugins, "ip") == [
|
||||
("A", "objectSecondaryId"),
|
||||
("F", "watchedValue1"),
|
||||
]
|
||||
|
||||
|
||||
class TestRealPluginInventory:
|
||||
"""Checked against the real plugin inventory (get_plugins_configs(True)), not a
|
||||
fabricated list - these are the two tests that would catch a real plugin's config.json
|
||||
silently breaking the invariant get_plugin_columns_for_field() enforces."""
|
||||
|
||||
def test_mac_identity_invariant_holds_for_participants(self):
|
||||
all_plugins = get_plugins_configs(True)
|
||||
participants = get_plugin_columns_for_field(all_plugins, "ip")
|
||||
by_prefix = {p.get("unique_prefix"): p for p in all_plugins}
|
||||
|
||||
for prefix, _column in participants:
|
||||
plugin = by_prefix[prefix]
|
||||
primary_id_col = next(
|
||||
c for c in plugin["database_column_definitions"]
|
||||
if c.get("column") == "objectPrimaryId"
|
||||
)
|
||||
assert primary_id_col.get("mapped_to_column") == "scanMac", (
|
||||
f"{prefix} participates in the 'ip' field view but its objectPrimaryId "
|
||||
f"does not map to scanMac - this should have been excluded"
|
||||
)
|
||||
|
||||
def test_ip_discovery_structural_check(self):
|
||||
all_plugins = get_plugins_configs(True)
|
||||
participants = get_plugin_columns_for_field(all_plugins, "ip")
|
||||
participant_prefixes = {prefix for prefix, _ in participants}
|
||||
|
||||
assert "dockerdisc" not in participant_prefixes, (
|
||||
"dockerdisc maps its IP to 'extra' and objectPrimaryId to scanParentMAC - "
|
||||
"excluded for both reasons, should never appear here"
|
||||
)
|
||||
assert "wificanary" not in participant_prefixes, (
|
||||
"wificanary has no MAC-based objectPrimaryId at all - should never appear here"
|
||||
)
|
||||
|
||||
valid_roles = {"objectPrimaryId", "objectSecondaryId", "watchedValue1", "watchedValue2", "watchedValue3", "watchedValue4"}
|
||||
for prefix, column in participants:
|
||||
assert column in valid_roles, f"{prefix} returned an invalid column role: {column}"
|
||||
|
||||
|
||||
class TestGetAllDeviceFieldViewsCache:
|
||||
def test_same_object_returns_cached_result(self):
|
||||
plugins = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
first = get_all_device_field_views(plugins)
|
||||
second = get_all_device_field_views(plugins)
|
||||
assert first is second
|
||||
|
||||
def test_different_object_recomputes(self):
|
||||
plugins_v1 = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
plugins_v2 = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
]), _plugin("G", extra_columns=[
|
||||
{"column": "watchedValue3", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
|
||||
result_v1 = get_all_device_field_views(plugins_v1)
|
||||
result_v2 = get_all_device_field_views(plugins_v2)
|
||||
|
||||
assert result_v1 is not result_v2
|
||||
assert result_v1["ip"] == [("A", "objectSecondaryId")]
|
||||
assert result_v2["ip"] == [("A", "objectSecondaryId"), ("G", "watchedValue3")]
|
||||
|
||||
def test_covers_every_registered_field(self):
|
||||
plugins = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
result = get_all_device_field_views(plugins)
|
||||
assert set(result.keys()) == set(DEVICE_FIELD_VIEWS.keys())
|
||||
|
||||
|
||||
class TestBuildFieldViewsPayload:
|
||||
"""The exact shape embedded into plugins.json's field_views block - the frontend
|
||||
contract, not just producer/helper equality. Each field_key maps to an object
|
||||
carrying its own label_key/value_type alongside its columns, so the frontend
|
||||
reads DEVICE_FIELD_VIEWS' declared label/rendering type instead of re-deriving
|
||||
them per field_key."""
|
||||
|
||||
def test_matches_get_all_device_field_views_content(self):
|
||||
plugins = [
|
||||
_plugin("A", extra_columns=[{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"}]),
|
||||
_plugin("F", extra_columns=[{"column": "watchedValue2", "mapped_to_column": "scanLastIP"}]),
|
||||
]
|
||||
payload = build_field_views_payload(plugins)
|
||||
expected = get_all_device_field_views(plugins)
|
||||
|
||||
assert payload["ip"]["columns"] == [
|
||||
{"plugin": prefix, "column": column} for prefix, column in expected["ip"]
|
||||
]
|
||||
|
||||
def test_entry_shape_has_label_key_value_type_and_columns(self):
|
||||
plugins = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
payload = build_field_views_payload(plugins)
|
||||
|
||||
assert set(payload["ip"].keys()) == {"label_key", "value_type", "columns"}
|
||||
assert payload["ip"]["label_key"] == DEVICE_FIELD_VIEWS["ip"]["label_key"]
|
||||
assert payload["ip"]["value_type"] == DEVICE_FIELD_VIEWS["ip"]["value_type"]
|
||||
assert len(payload["ip"]["columns"]) == 1
|
||||
column_entry = payload["ip"]["columns"][0]
|
||||
assert set(column_entry.keys()) == {"plugin", "column"}
|
||||
assert column_entry == {"plugin": "A", "column": "objectSecondaryId"}
|
||||
|
||||
def test_covers_every_registered_field_with_its_own_label_key(self):
|
||||
plugins = [_plugin("A", extra_columns=[
|
||||
{"column": "objectSecondaryId", "mapped_to_column": "scanLastIP"},
|
||||
])]
|
||||
payload = build_field_views_payload(plugins)
|
||||
|
||||
assert set(payload.keys()) == set(DEVICE_FIELD_VIEWS.keys())
|
||||
for field_key, field_def in DEVICE_FIELD_VIEWS.items():
|
||||
assert payload[field_key]["label_key"] == field_def["label_key"]
|
||||
assert payload[field_key]["value_type"] == field_def["value_type"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Sources / Session Info characterization tests
|
||||
|
||||
Playwright specs for the Sources tab (Plugin View + Field View) and the
|
||||
Session Info tab. These are characterization tests, not unit tests of
|
||||
internal functions: each one mocks the relevant GraphQL/REST calls via
|
||||
`page.route()` with fixed fixture data (no real DB, no seeded scan data)
|
||||
and asserts on rendered, observable DOM - the same assertions should hold
|
||||
against a future frontend rewrite, unchanged.
|
||||
|
||||
Run against a live NetAlertX instance (defaults to `http://localhost:20211`,
|
||||
override with `NAX_BASE_URL`):
|
||||
|
||||
```
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Scope is deliberately narrow - four specs covering the Sources/Session Info
|
||||
PRD's acceptance criteria, not a general frontend test suite. Do not add
|
||||
unrelated specs here without a separate, deliberate decision to broaden this
|
||||
setup.
|
||||
|
||||
**Status as of this implementation pass:** written and manually reviewed for
|
||||
correctness against the actual frontend code (GraphQL shapes, route URLs,
|
||||
DOM selectors), but not yet run against a live instance - no running
|
||||
NetAlertX server was available in the environment these were written in.
|
||||
Run them against a real devcontainer before considering this PRD's Playwright
|
||||
work complete.
|
||||
@@ -0,0 +1,32 @@
|
||||
// Test #8 (PRD): ?tab=<plugin_prefix> (existing deep-link) and ?field=<key>
|
||||
// (new) must not interfere with each other's resolution. Note: the
|
||||
// top-level Sources tab itself isn't auto-activated by either param today
|
||||
// (initializeTabs() on the device-details page only reads cache, not the
|
||||
// URL) - that's pre-existing behavior, unchanged by this PRD, so both tests
|
||||
// click the Sources tab explicitly before asserting, matching reality.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
|
||||
|
||||
test('?tab=<plugin> deep-link resolves to Plugin View with that plugin active', async ({ page }) => {
|
||||
await mockSourcesBackend(page);
|
||||
|
||||
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}&tab=FREEBOX`);
|
||||
await page.click('#tabSources');
|
||||
|
||||
await expect(page.locator('#sourcesPluginView')).toBeVisible();
|
||||
await expect(page.locator('#sourcesFieldView')).toBeHidden();
|
||||
await expect(page.locator('#sourcesViewToggle')).toHaveAttribute('data-view', 'plugin');
|
||||
await expect(page.locator('a#FREEBOX_id')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('?field=<key> deep-link resolves to Field View with that field active', async ({ page }) => {
|
||||
await mockSourcesBackend(page);
|
||||
|
||||
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}&field=ip`);
|
||||
await page.click('#tabSources');
|
||||
|
||||
await expect(page.locator('#sourcesFieldView')).toBeVisible();
|
||||
await expect(page.locator('#sourcesPluginView')).toBeHidden();
|
||||
await expect(page.locator('#sourcesViewToggle')).toHaveAttribute('data-view', 'field');
|
||||
await expect(page.locator('#fieldPane_ip')).toHaveClass(/active/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Test #4 (PRD): Field View's join logic - picks each plugin's value from
|
||||
// whichever Plugins_Objects column field_views.ip names for it, not a
|
||||
// hardcoded column role, and status renders as plugin-observation wording.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
|
||||
|
||||
test('Field View renders Plugin/Value/Status/Last-Changed from the correct column per plugin', async ({ page }) => {
|
||||
await mockSourcesBackend(page);
|
||||
|
||||
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}`);
|
||||
await page.click('#tabSources');
|
||||
await page.click('#sourcesViewToggle');
|
||||
await page.click('#sourcesViewMenu a[data-view="field"]');
|
||||
|
||||
const rows = page.locator('#fieldPane_ip tbody tr');
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
// FREEBOX's value comes from objectSecondaryId.
|
||||
await expect(rows.filter({ hasText: 'FREEBOX' })).toContainText('fe80::1');
|
||||
// ARPSCAN's value comes from watchedValue1 - not objectSecondaryId.
|
||||
await expect(rows.filter({ hasText: 'ARPSCAN' })).toContainText('192.168.1.50');
|
||||
// SNMPDSC's value comes from watchedValue2 - proves the join isn't
|
||||
// hardcoded to either of the other two roles.
|
||||
await expect(rows.filter({ hasText: 'SNMPDSC' })).toContainText('fe80::2');
|
||||
|
||||
// missing-in-last-scan renders as plugin-observation wording, not a
|
||||
// liveness claim.
|
||||
const snmpRow = rows.filter({ hasText: 'SNMPDSC' });
|
||||
await expect(snmpRow).toContainText('Not reported last run');
|
||||
await expect(snmpRow).not.toContainText('offline');
|
||||
await expect(snmpRow).not.toContainText('unreachable');
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// Shared fixture data and route-mocking helpers for the Sources/Session Info
|
||||
// characterization tests. Every spec uses these instead of hand-rolling its
|
||||
// own mock shapes, so a GraphQL/REST contract change only needs updating here.
|
||||
|
||||
const TEST_MAC = 'aa:bb:cc:dd:ee:01';
|
||||
|
||||
const PLUGINS_JSON_FIXTURE = {
|
||||
data: [
|
||||
{ unique_prefix: 'FREEBOX', show_ui: true, mapped_to_table: 'CurrentScan' },
|
||||
{ unique_prefix: 'ARPSCAN', show_ui: true, mapped_to_table: 'CurrentScan' },
|
||||
{ unique_prefix: 'SNMPDSC', show_ui: true, mapped_to_table: 'CurrentScan' },
|
||||
],
|
||||
field_views: {
|
||||
ip: {
|
||||
label_key: 'DevDetail_SessionTable_IP',
|
||||
value_type: 'device_ip',
|
||||
columns: [
|
||||
{ plugin: 'FREEBOX', column: 'objectSecondaryId' },
|
||||
{ plugin: 'ARPSCAN', column: 'watchedValue1' },
|
||||
{ plugin: 'SNMPDSC', column: 'watchedValue2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PLUGINS_OBJECTS_FIXTURE = [
|
||||
{
|
||||
plugin: 'FREEBOX', objectSecondaryId: 'fe80::1', watchedValue1: null, watchedValue2: null,
|
||||
watchedValue3: null, watchedValue4: null, status: 'watched-not-changed', dateTimeChanged: '2026-10-05 10:00:00',
|
||||
},
|
||||
{
|
||||
plugin: 'ARPSCAN', objectSecondaryId: null, watchedValue1: '192.168.1.50', watchedValue2: null,
|
||||
watchedValue3: null, watchedValue4: null, status: 'new', dateTimeChanged: '2026-10-05 10:01:00',
|
||||
},
|
||||
{
|
||||
plugin: 'SNMPDSC', objectSecondaryId: null, watchedValue1: null, watchedValue2: 'fe80::2',
|
||||
watchedValue3: null, watchedValue4: null, status: 'missing-in-last-scan', dateTimeChanged: '2026-10-05 09:50:00',
|
||||
},
|
||||
];
|
||||
|
||||
// Minimal settings fixture matching the real settings GraphQL query's response
|
||||
// shape (data.settings.settings) - deviceDetailsEdit.php's getDeviceData()
|
||||
// reads response.data.settings.settings unconditionally, so a generic empty
|
||||
// `{ data: {} }` fallback would throw there instead of letting
|
||||
// renderSessionInfoLabels() populate the Known IPs labels.
|
||||
const SETTINGS_FIXTURE = [
|
||||
{ setKey: 'NEWDEV_devPrimaryIPv4', setName: 'Primary IPv4' },
|
||||
{ setKey: 'NEWDEV_devPrimaryIPv6', setName: 'Primary IPv6' },
|
||||
];
|
||||
|
||||
const DEVICE_DATA_FIXTURE = {
|
||||
devMac: TEST_MAC,
|
||||
devName: 'Test Device',
|
||||
devStatus: 'Online',
|
||||
devPresentLastScan: 1,
|
||||
devAlertDown: 1,
|
||||
devFlapping: 0,
|
||||
devIsSleeping: 0,
|
||||
devIsArchived: 0,
|
||||
devIsNew: 0,
|
||||
devPrimaryIPv4: '192.168.1.50',
|
||||
devPrimaryIPv6: 'fe80::1',
|
||||
devFQDN: 'test-device.lan',
|
||||
devLastConnection: '2026-10-05 10:00:00',
|
||||
devFirstConnection: '2025-01-01 00:00:00',
|
||||
};
|
||||
|
||||
// Routes every GraphQL call through to the right fixture based on the query
|
||||
// text in the POST body, and plugins.json/device endpoint by URL.
|
||||
async function mockSourcesBackend(page, { mac = TEST_MAC, pluginsObjects = PLUGINS_OBJECTS_FIXTURE } = {}) {
|
||||
let pluginsObjectsCallCount = 0;
|
||||
|
||||
await page.route('**/php/server/query_json.php?file=plugins.json*', route => {
|
||||
route.fulfill({ json: PLUGINS_JSON_FIXTURE });
|
||||
});
|
||||
|
||||
await page.route('**/server/graphql', route => {
|
||||
const body = JSON.parse(route.request().postData());
|
||||
|
||||
if (body.query.includes('pluginsObjects')) {
|
||||
pluginsObjectsCallCount += 1;
|
||||
route.fulfill({
|
||||
json: { data: { pluginsObjects: { entries: pluginsObjects } } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.query.includes('settings')) {
|
||||
route.fulfill({
|
||||
json: { data: { settings: { settings: SETTINGS_FIXTURE, count: SETTINGS_FIXTURE.length } } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other GraphQL query this flow happens to fire - return an
|
||||
// empty-but-valid shape rather than failing the request.
|
||||
route.fulfill({ json: { data: {} } });
|
||||
});
|
||||
|
||||
await page.route(`**/server/device/${encodeURIComponent(mac)}*`, route => {
|
||||
route.fulfill({ json: DEVICE_DATA_FIXTURE });
|
||||
});
|
||||
|
||||
return {
|
||||
getPluginsObjectsCallCount: () => pluginsObjectsCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TEST_MAC,
|
||||
PLUGINS_JSON_FIXTURE,
|
||||
PLUGINS_OBJECTS_FIXTURE,
|
||||
SETTINGS_FIXTURE,
|
||||
DEVICE_DATA_FIXTURE,
|
||||
mockSourcesBackend,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Test #5 (PRD): Session Info renders without a settings lookup - all 6
|
||||
// migrated fields display, including devStatus (the one that's never
|
||||
// actually worked before this PRD), and there's no save/submit control.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
|
||||
|
||||
test('Session Info displays all 6 migrated fields including devStatus, with no save control', async ({ page }) => {
|
||||
await mockSourcesBackend(page);
|
||||
|
||||
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}`);
|
||||
await page.click('#tabSessionInfo');
|
||||
|
||||
await expect(page.locator('#sessionInfo_devPrimaryIPv4')).toHaveText('192.168.1.50');
|
||||
await expect(page.locator('#sessionInfo_devPrimaryIPv6')).toHaveText('fe80::1');
|
||||
await expect(page.locator('#sessionInfo_devFQDN')).toHaveText('test-device.lan');
|
||||
await expect(page.locator('#sessionInfo_devLastConnection')).not.toBeEmpty();
|
||||
await expect(page.locator('#sessionInfo_devFirstConnection')).not.toBeEmpty();
|
||||
|
||||
// devStatus: the dead field this PRD fixes - must actually render something,
|
||||
// not be silently blank the way it always has been before this change.
|
||||
await expect(page.locator('#sessionInfo_devStatus')).not.toBeEmpty();
|
||||
|
||||
await expect(page.locator('#panSessionInfo button[type="submit"]')).toHaveCount(0);
|
||||
await expect(page.locator('#panSessionInfo .pa-btn-save, #panSessionInfo #btnSave')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Test #6 (PRD): Known IPs (Session Info) and Field View read the same
|
||||
// pluginsObjects data via exactly one fetch - the data assertion alone
|
||||
// wouldn't catch two independently-issued calls that happen to agree, so
|
||||
// this also asserts the call count.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { TEST_MAC, mockSourcesBackend } = require('./fixtures');
|
||||
|
||||
test('Known IPs and Field View show consistent filtered data via one shared fetch', async ({ page }) => {
|
||||
const mocks = await mockSourcesBackend(page);
|
||||
|
||||
await page.goto(`/deviceDetails.php?mac=${encodeURIComponent(TEST_MAC)}`);
|
||||
|
||||
// Open Session Info first.
|
||||
await page.click('#tabSessionInfo');
|
||||
await expect(page.locator('#sessionInfo_knownIps')).toContainText('fe80::1');
|
||||
await expect(page.locator('#sessionInfo_knownIps')).toContainText('192.168.1.50');
|
||||
// SNMPDSC's fe80::2 is missing-in-last-scan - excluded from Known IPs.
|
||||
await expect(page.locator('#sessionInfo_knownIps')).not.toContainText('fe80::2');
|
||||
|
||||
// Then open Sources -> Field View, which shows the full, unfiltered set.
|
||||
await page.click('#tabSources');
|
||||
await page.click('#sourcesViewToggle');
|
||||
await page.click('#sourcesViewMenu a[data-view="field"]');
|
||||
const rows = page.locator('#fieldPane_ip tbody tr');
|
||||
await expect(rows).toHaveCount(3); // includes the missing-in-last-scan row
|
||||
|
||||
// The actual proof of the shared-fetch mechanism: exactly one network
|
||||
// call for pluginsObjects across both views, not one per view.
|
||||
expect(mocks.getPluginsObjectsCallCount()).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Tests for insert_events()'s IP Changed query (server/scan/session_events.py)
|
||||
with a device that has more than one simultaneously-present, already-known
|
||||
address in one address family.
|
||||
|
||||
Today's query reduces every scanPresence=1 CurrentScan row for a MAC to a
|
||||
single MIN(scanLastIP) candidate, then tests that one value against
|
||||
devPrimaryIPv4/devPrimaryIPv6/devLastIP. A MAC with two simultaneously-valid,
|
||||
already-known addresses can have MIN() select the one not currently sitting
|
||||
in the single-valued devPrimaryIPv6 slot, failing all three comparisons
|
||||
despite not being new.
|
||||
|
||||
The fix makes the "known" test additive (three-slot check OR an address
|
||||
already seen, and not missing-in-last-scan, by an eligible plugin for this
|
||||
MAC's "ip" field) and evaluates every distinct present address individually
|
||||
instead of via one MIN()-reduced stand-in value.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from db_test_helpers import ( # noqa: E402
|
||||
make_db,
|
||||
insert_device,
|
||||
make_current_scan_dict,
|
||||
insert_current_scan_row_from_dict,
|
||||
seed_plugin_object,
|
||||
CREATE_PLUGINS_OBJECTS,
|
||||
DummyDB,
|
||||
)
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server"))
|
||||
from scan.session_events import insert_events # noqa: E402
|
||||
|
||||
MAC = "aa:bb:cc:dd:ee:01"
|
||||
|
||||
|
||||
def _ip_eligible_plugin(prefix="FREEBOX", column="objectSecondaryId"):
|
||||
"""A plugin config participating in the 'ip' field view - objectPrimaryId
|
||||
maps to scanMac and the given column role maps to scanLastIP, matching
|
||||
get_plugin_columns_for_field()'s two requirements."""
|
||||
return {
|
||||
"unique_prefix": prefix,
|
||||
"mapped_to_table": "CurrentScan",
|
||||
"database_column_definitions": [
|
||||
{"column": "objectPrimaryId", "mapped_to_column": "scanMac"},
|
||||
{"column": column, "mapped_to_column": "scanLastIP"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _gap_plugin(prefix="dockerdisc"):
|
||||
"""A plugin config that structurally cannot participate in the 'ip' field
|
||||
view - objectPrimaryId maps to scanParentMAC, not scanMac - mirroring
|
||||
dockerdisc's real shape."""
|
||||
return {
|
||||
"unique_prefix": prefix,
|
||||
"mapped_to_table": "CurrentScan",
|
||||
"database_column_definitions": [
|
||||
{"column": "objectPrimaryId", "mapped_to_column": "scanParentMAC"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _make_db_with_plugins_objects():
|
||||
"""make_db() plus the Plugins_Objects table - needed here but not by the
|
||||
other scan tests, so built locally rather than folding into make_db()."""
|
||||
conn = make_db()
|
||||
conn.executescript(CREATE_PLUGINS_OBJECTS)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
class TestMultiAddressFalsePositiveFixed:
|
||||
"""Reproduces netalertx/NetAlertX#1831's captured log, then confirms the fix."""
|
||||
|
||||
def test_known_second_address_does_not_fire(self):
|
||||
conn = _make_db_with_plugins_objects()
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, MAC, alert_down=1, present_last_scan=1, last_ip="IPv6-4")
|
||||
cur.execute("UPDATE Devices SET devPrimaryIPv6 = 'IPv6-4' WHERE devMac = ?", (MAC,))
|
||||
conn.commit()
|
||||
|
||||
# Two simultaneously-present rows for the same MAC - IPv6-4 (the
|
||||
# known devPrimaryIPv6) and IPv6-2 (also known, via Plugins_Objects,
|
||||
# but not sitting in any of the three slots).
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="IPv6-4"))
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="IPv6-2"))
|
||||
|
||||
seed_plugin_object(
|
||||
cur, "FREEBOX", MAC, secondary_id="IPv6-2", status="watched-not-changed",
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
insert_events(db, all_plugins=[_ip_eligible_plugin()])
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM Events WHERE eveMac = ? AND eveEventType = 'IP Changed'", (MAC,)
|
||||
).fetchall()
|
||||
assert rows == [], "IPv6-2 is known via Plugins_Objects - must not fire IP Changed"
|
||||
|
||||
|
||||
class TestGenuineCrossFamilyChangeNotSuppressed:
|
||||
"""The additive/per-address design must not trade the false positive above
|
||||
for a false negative: a genuinely new address in one family must still
|
||||
fire even when another family's already-known address is present too."""
|
||||
|
||||
def test_new_ipv4_fires_alongside_known_ipv6(self):
|
||||
conn = _make_db_with_plugins_objects()
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, MAC, alert_down=1, present_last_scan=1, last_ip="192.168.1.10")
|
||||
cur.execute("UPDATE Devices SET devPrimaryIPv4 = '192.168.1.10', "
|
||||
"devPrimaryIPv6 = 'IPv6-known' WHERE devMac = ?", (MAC,))
|
||||
conn.commit()
|
||||
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="IPv6-known"))
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="10.0.0.99"))
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
insert_events(db, all_plugins=[_ip_eligible_plugin()])
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT eveIp, eveAdditionalInfo FROM Events WHERE eveMac = ? AND eveEventType = 'IP Changed'",
|
||||
(MAC,),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["eveIp"] == "10.0.0.99"
|
||||
assert "10.0.0.99" in rows[0]["eveAdditionalInfo"]
|
||||
assert "IPv6-known" not in rows[0]["eveAdditionalInfo"]
|
||||
|
||||
|
||||
class TestGapPluginDeviceUnaffected:
|
||||
"""A device whose only contributing plugin can't participate in the 'ip'
|
||||
field view (dockerdisc's real shape) must see exactly today's three-slot
|
||||
behavior - the additive set is empty and must be a true no-op."""
|
||||
|
||||
def test_real_change_still_fires(self):
|
||||
conn = _make_db_with_plugins_objects()
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, MAC, alert_down=1, present_last_scan=1, last_ip="192.168.1.10")
|
||||
cur.execute("UPDATE Devices SET devPrimaryIPv4 = '192.168.1.10' WHERE devMac = ?", (MAC,))
|
||||
conn.commit()
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="192.168.1.77"))
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
insert_events(db, all_plugins=[_gap_plugin()])
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT eveIp FROM Events WHERE eveMac = ? AND eveEventType = 'IP Changed'", (MAC,)
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["eveIp"] == "192.168.1.77"
|
||||
|
||||
def test_non_change_still_suppressed(self):
|
||||
conn = _make_db_with_plugins_objects()
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, MAC, alert_down=1, present_last_scan=1, last_ip="192.168.1.10")
|
||||
cur.execute("UPDATE Devices SET devPrimaryIPv4 = '192.168.1.10' WHERE devMac = ?", (MAC,))
|
||||
conn.commit()
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="192.168.1.10"))
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
insert_events(db, all_plugins=[_gap_plugin()])
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM Events WHERE eveMac = ? AND eveEventType = 'IP Changed'", (MAC,)
|
||||
).fetchall()
|
||||
assert rows == []
|
||||
|
||||
def test_no_all_plugins_argument_is_equivalent(self):
|
||||
"""Omitting all_plugins entirely (the pre-fix call shape) must behave
|
||||
identically - callers that haven't been updated yet see no change."""
|
||||
conn = _make_db_with_plugins_objects()
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, MAC, alert_down=1, present_last_scan=1, last_ip="192.168.1.10")
|
||||
cur.execute("UPDATE Devices SET devPrimaryIPv4 = '192.168.1.10' WHERE devMac = ?", (MAC,))
|
||||
conn.commit()
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="192.168.1.77"))
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
insert_events(db)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT eveIp FROM Events WHERE eveMac = ? AND eveEventType = 'IP Changed'", (MAC,)
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["eveIp"] == "192.168.1.77"
|
||||
|
||||
|
||||
class TestEventAggregation:
|
||||
"""Multiple genuinely-new addresses in one cycle must still produce
|
||||
exactly one Events row, listing every new address in eveAdditionalInfo -
|
||||
today's one-row-per-MAC-per-cycle shape, not multiplied volume."""
|
||||
|
||||
def test_two_new_addresses_one_cycle_one_row(self):
|
||||
conn = _make_db_with_plugins_objects()
|
||||
cur = conn.cursor()
|
||||
insert_device(cur, MAC, alert_down=1, present_last_scan=1, last_ip="192.168.1.10")
|
||||
cur.execute("UPDATE Devices SET devPrimaryIPv4 = '192.168.1.10', "
|
||||
"devPrimaryIPv6 = '' WHERE devMac = ?", (MAC,))
|
||||
conn.commit()
|
||||
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="10.0.0.50"))
|
||||
insert_current_scan_row_from_dict(conn, make_current_scan_dict(MAC, scanLastIP="IPv6-new"))
|
||||
conn.commit()
|
||||
|
||||
db = DummyDB(conn)
|
||||
insert_events(db, all_plugins=[_ip_eligible_plugin()])
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT eveAdditionalInfo FROM Events WHERE eveMac = ? AND eveEventType = 'IP Changed'",
|
||||
(MAC,),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
info = rows[0]["eveAdditionalInfo"]
|
||||
assert "10.0.0.50" in info
|
||||
assert "IPv6-new" in info
|
||||
@@ -264,7 +264,7 @@ def _common_patches(monkeypatch, main_mod, db, fake_pm):
|
||||
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, "process_scan", lambda db, all_plugins=None: 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)
|
||||
|
||||
Reference in new issue
Block a user