BE+FE: Plugins -> Sources DevDetails tab

This commit is contained in:
jokob-sk committed 2026-10-09 09:18:04 +11:00
1 parent 2e2462c280
commit f37f2c7df7
58 files changed
+2008 -200

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 keyed by `id(all_plugins)`, 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: check `id(all_plugins)` against the last-seen id, recomputing only when a genuinely new object (an actual reload) appears.
## Plugin Formats
| Format | Purpose | Phase |
@@ -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 by `id(all_plugins)` instead of recomputing every call
- Pattern: `server/db/plugin_field_views.py`'s `get_all_device_field_views()`
## Plugin Formats
| Format | Purpose | Phase |
@@ -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 by `id(all_plugins)` rather than recompute every call
- Example: `server/db/plugin_field_views.py`'s `get_all_device_field_views()`
## Plugin Formats
| Format | Purpose | Runs |
@@ -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:
+1
View File
@@ -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.
-2
View File
@@ -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
View File
@@ -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;
}
+19
View File
@@ -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;
}
+19
View File
@@ -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;
}
+154 -8
View File
@@ -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,122 @@ switch ($UI_THEME) {
var selectedTab = 'tabDetails';
var emptyArr = ['undefined', "", undefined, null];
// -----------------------------------------------------------------------------
// Shared Sources data: Field View and the Session Info tab's Known IPs chip
// list both read the same pluginsObjects(objectPrimaryId = mac) query -
// cached 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 = {};
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);
return [];
}
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. Reading it here once, shared by both
// consumers, is what keeps either one from falling back to "grab whichever
// column happens to be non-empty" - which silently picks up unrelated data
// (e.g. a port number) from a plugin that was never registered for this field.
let _fieldViewDefinitionsCache = null;
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.
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. ?tab=<plugin_prefix> (an existing deep-link into
// Plugin View) and ?field=<field_key> (new, for Field View) are two
// separate URL params on purpose - neither should have to disambiguate
// the other's value.
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);
}
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 +739,7 @@ window.onload = function() {
updateChevrons(mac);
await renderSmallBoxes();
main();
initSourcesViewToggle();
hideDeviceDetailsSkeleton();
});
+15 -13
View File
@@ -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
@@ -550,8 +550,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
}
@@ -575,7 +577,7 @@ function deviceDetailsPageUpdater() {
}
// if visible, load immediately, if not start updater
if (!$('#panDetails:visible').length) {
if (!$('.tab-content:visible').length) {
deviceDetailsPageUpdater();
} else {
getDeviceData();
+257
View File
@@ -0,0 +1,257 @@
<?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} &middot; ${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.
callAfterAppInitialized(() => {
Promise.resolve(renderIpLists(currentMac)).finally(hideSessionInfoTabSkeleton);
});
}
}
} catch (err) {
console.error('[Session Info] render failed, will retry next tick:', err);
sessionInfoInitialized = false;
hideSessionInfoTabSkeleton();
}
setTimeout(sessionInfoUpdater, 200);
}
sessionInfoUpdater();
</script>
+1 -1
View File
@@ -1071,7 +1071,7 @@ function hideSpinner() {
if (!spinner.length) return;
const target = $(".spinnerTarget").last();
const target = resolveSpinnerTarget();
if (target.length) {
// Lock position to target
+180
View File
@@ -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;
}
+16
View File
@@ -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) {
+1
View File
@@ -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>
+11
View File
@@ -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": "حذف المدرج",
+11
View File
@@ -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",
+11
View File
@@ -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": "",
"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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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": "",
+11
View File
@@ -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": "",
+11
View File
@@ -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",
+11
View File
@@ -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": "",
+11
View File
@@ -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": "",
+11
View File
@@ -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": "",
+11
View File
@@ -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",
+11
View File
@@ -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(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": "リストされたオブジェクトを削除",
+11
View File
@@ -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": "",
+11
View File
@@ -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",
+11
View File
@@ -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": "",
+11
View File
@@ -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",
+12 -1
View File
@@ -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": "Сначала сохраните изменения, прежде чем проверять настройки."
}
}
+11
View File
@@ -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": "",
+11
View File
@@ -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": "",
+11
View File
@@ -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": "Видалити перелічені об’єкти",
+11
View File
@@ -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": "",
+11
View File
@@ -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": "删除已列出对象",
@@ -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
View File
@@ -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);
},
+193
View File
@@ -0,0 +1,193 @@
<?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_', ''));
});
}
// -----------------------------------------------------------------------------
// Builds one field's DataTable - Plugin / Value / Status / Last Changed, one
// row per participating plugin's entry. 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.
function buildFieldPaneTable(fieldKey, fieldView, entries) {
const { columns: pluginColumns, value_type } = fieldView;
const rows = pluginColumns
.map(({ plugin, column }) => {
const entry = entries.find(e => e.plugin === plugin);
return entry ? { plugin, value: entry[column], status: entry.status, dateTimeChanged: entry.dateTimeChanged || '' } : null;
})
.filter(row => row !== null);
$(`#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.
function fieldViewUpdater() {
if ($('#sourcesFieldView').is(':visible')) {
const currentMac = getMac();
if (!fieldViewInitialized || currentMac !== fieldViewLastMac) {
fieldViewInitialized = true;
fieldViewLastMac = currentMac;
showFieldViewSkeleton();
callAfterAppInitialized(() => renderFieldView(currentMac).finally(hideFieldViewSkeleton));
}
}
setTimeout(fieldViewUpdater, 200);
}
fieldViewUpdater();
</script>
+11
View File
@@ -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"
}
}
+14
View File
@@ -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',
},
});
+5 -1
View File
@@ -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 = [
+127
View File
@@ -0,0 +1,127 @@
"""
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_id": 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."""
cache_key = id(all_plugins)
if _field_views_cache["all_plugins_id"] != cache_key:
_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_id"] = cache_key
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()
}
+1 -1
View File
@@ -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")
+210
View File
@@ -0,0 +1,210 @@
"""
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}"
# Incidental today - not frozen. A future plugin addition legitimately changes this.
assert len(participants) == 23
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"]
+29
View File
@@ -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.
+32
View File
@@ -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/);
});
+32
View File
@@ -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');
});
+98
View File
@@ -0,0 +1,98 @@
// 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',
},
];
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;
}
// Any other GraphQL query (settings, etc.) 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,
DEVICE_DATA_FIXTURE,
mockSourcesBackend,
};
+25
View File
@@ -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);
});
+30
View File
@@ -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);
});