mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-04-15 22:51:37 -07:00
Merge pull request #1576 from netalertx/next_release
feat(api): Enhance session events, plugin objects API with pagination, sorting, and filtering
This commit is contained in:
+25
-2
@@ -15,6 +15,21 @@ Before opening a new issue:
|
|||||||
- [Check Common Issues & Debug Tips](https://docs.netalertx.com/DEBUG_TIPS#common-issues)
|
- [Check Common Issues & Debug Tips](https://docs.netalertx.com/DEBUG_TIPS#common-issues)
|
||||||
- [Search Closed Issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed)
|
- [Search Closed Issues](https://github.com/netalertx/NetAlertX/issues?q=is%3Aissue+is%3Aclosed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use of AI
|
||||||
|
|
||||||
|
Use of AI-assisted tools is permitted, provided all generated code is reviewed, understood, and verified before submission.
|
||||||
|
|
||||||
|
- All AI-generated code must meet the project's **quality, security, and performance standards**.
|
||||||
|
- Contributors are responsible for **fully understanding** any code they submit, regardless of how it was produced.
|
||||||
|
- Prefer **clarity and maintainability over cleverness or brevity**. Readable code is always favored over dense or obfuscated implementations.
|
||||||
|
- Follow the **DRY (Don't Repeat Yourself) principle** where appropriate, without sacrificing readability.
|
||||||
|
- Do not submit code that you cannot confidently explain or debug.
|
||||||
|
|
||||||
|
All changes must pass the **full test suite** before opening a PR.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Submitting Pull Requests (PRs)
|
## Submitting Pull Requests (PRs)
|
||||||
@@ -28,11 +43,19 @@ Please:
|
|||||||
- Provide a clear title and description for your PR
|
- Provide a clear title and description for your PR
|
||||||
- If relevant, add or update tests and documentation
|
- If relevant, add or update tests and documentation
|
||||||
- For plugins, refer to the [Plugin Dev Guide](https://docs.netalertx.com/PLUGINS_DEV)
|
- For plugins, refer to the [Plugin Dev Guide](https://docs.netalertx.com/PLUGINS_DEV)
|
||||||
|
- Switch the PR to DRAFT mode if still being worked on
|
||||||
|
- Keep PRs **focused and minimal** — avoid unrelated changes in a single PR
|
||||||
|
- PRs that do not meet these guidelines may be closed without review
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
## Code quality
|
- Use clear, descriptive commit messages
|
||||||
|
- Explain *why* a change was made, not just *what* changed
|
||||||
|
- Reference related issues where applicable
|
||||||
|
|
||||||
- read and follow the [code-standards](/.github/skills/code-standards/SKILL.md)
|
## Code Quality
|
||||||
|
|
||||||
|
- Read and follow the [code standards](/.github/skills/code-standards/SKILL.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+155
-2
@@ -4,6 +4,10 @@ GraphQL queries are **read-optimized for speed**. Data may be slightly out of da
|
|||||||
|
|
||||||
* Devices
|
* Devices
|
||||||
* Settings
|
* Settings
|
||||||
|
* Events
|
||||||
|
* PluginsObjects
|
||||||
|
* PluginsHistory
|
||||||
|
* PluginsEvents
|
||||||
* Language Strings (LangStrings)
|
* Language Strings (LangStrings)
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -254,11 +258,160 @@ curl 'http://host:GRAPHQL_PORT/graphql' \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Plugin Tables (Objects, Events, History)
|
||||||
|
|
||||||
|
Three queries expose the plugin database tables with server-side pagination, filtering, and search:
|
||||||
|
|
||||||
|
* `pluginsObjects` — current plugin object state
|
||||||
|
* `pluginsEvents` — unprocessed plugin events
|
||||||
|
* `pluginsHistory` — historical plugin event log
|
||||||
|
|
||||||
|
All three share the same `PluginQueryOptionsInput` and return the same `PluginEntry` shape.
|
||||||
|
|
||||||
|
### Sample Query
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query GetPluginObjects($options: PluginQueryOptionsInput) {
|
||||||
|
pluginsObjects(options: $options) {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries {
|
||||||
|
index plugin objectPrimaryId objectSecondaryId
|
||||||
|
dateTimeCreated dateTimeChanged
|
||||||
|
watchedValue1 watchedValue2 watchedValue3 watchedValue4
|
||||||
|
status extra userData foreignKey
|
||||||
|
syncHubNodeName helpVal1 helpVal2 helpVal3 helpVal4 objectGuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters (`PluginQueryOptionsInput`)
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| ------------ | ----------------- | ------------------------------------------------------ |
|
||||||
|
| `page` | Int | Page number (1-based). |
|
||||||
|
| `limit` | Int | Rows per page (max 1000). |
|
||||||
|
| `sort` | [SortOptionsInput] | Sorting options (`field`, `order`). |
|
||||||
|
| `search` | String | Free-text search across key columns. |
|
||||||
|
| `filters` | [FilterOptionsInput] | Column-value exact-match filters. |
|
||||||
|
| `plugin` | String | Plugin prefix to scope results (e.g. `"ARPSCAN"`). |
|
||||||
|
| `foreignKey` | String | Foreign key filter (e.g. device MAC). |
|
||||||
|
| `dateFrom` | String | Start of date range filter on `dateTimeCreated`. |
|
||||||
|
| `dateTo` | String | End of date range filter on `dateTimeCreated`. |
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------- | ------------- | ------------------------------------------------------------- |
|
||||||
|
| `dbCount` | Int | Total rows for the requested plugin (before search/filters). |
|
||||||
|
| `count` | Int | Total rows after all filters (before pagination). |
|
||||||
|
| `entries` | [PluginEntry] | Paginated list of plugin entries. |
|
||||||
|
|
||||||
|
### `curl` Example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl 'http://host:GRAPHQL_PORT/graphql' \
|
||||||
|
-X POST \
|
||||||
|
-H 'Authorization: Bearer API_TOKEN' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"query": "query GetPluginObjects($options: PluginQueryOptionsInput) { pluginsObjects(options: $options) { dbCount count entries { index plugin objectPrimaryId status foreignKey } } }",
|
||||||
|
"variables": {
|
||||||
|
"options": {
|
||||||
|
"plugin": "ARPSCAN",
|
||||||
|
"page": 1,
|
||||||
|
"limit": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge Prefetch (Batched Counts)
|
||||||
|
|
||||||
|
Use GraphQL aliases to fetch counts for all plugins in a single request:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query BadgeCounts {
|
||||||
|
ARPSCAN: pluginsObjects(options: {plugin: "ARPSCAN", page: 1, limit: 1}) { dbCount }
|
||||||
|
INTRNT: pluginsObjects(options: {plugin: "INTRNT", page: 1, limit: 1}) { dbCount }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events Query
|
||||||
|
|
||||||
|
Access the Events table with server-side pagination, filtering, and search.
|
||||||
|
|
||||||
|
### Sample Query
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query GetEvents($options: EventQueryOptionsInput) {
|
||||||
|
events(options: $options) {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries {
|
||||||
|
eveMac
|
||||||
|
eveIp
|
||||||
|
eveDateTime
|
||||||
|
eveEventType
|
||||||
|
eveAdditionalInfo
|
||||||
|
evePendingAlertEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters (`EventQueryOptionsInput`)
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| ----------- | ------------------ | ------------------------------------------------ |
|
||||||
|
| `page` | Int | Page number (1-based). |
|
||||||
|
| `limit` | Int | Rows per page (max 1000). |
|
||||||
|
| `sort` | [SortOptionsInput] | Sorting options (`field`, `order`). |
|
||||||
|
| `search` | String | Free-text search across key columns. |
|
||||||
|
| `filters` | [FilterOptionsInput] | Column-value exact-match filters. |
|
||||||
|
| `eveMac` | String | Filter by device MAC address. |
|
||||||
|
| `eventType` | String | Filter by event type (e.g. `"New Device"`). |
|
||||||
|
| `dateFrom` | String | Start of date range filter on `eveDateTime`. |
|
||||||
|
| `dateTo` | String | End of date range filter on `eveDateTime`. |
|
||||||
|
|
||||||
|
### Response Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------- | ------------ | ------------------------------------------------------------ |
|
||||||
|
| `dbCount` | Int | Total rows in the Events table (before any filters). |
|
||||||
|
| `count` | Int | Total rows after all filters (before pagination). |
|
||||||
|
| `entries` | [EventEntry] | Paginated list of event entries. |
|
||||||
|
|
||||||
|
### `curl` Example
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl 'http://host:GRAPHQL_PORT/graphql' \
|
||||||
|
-X POST \
|
||||||
|
-H 'Authorization: Bearer API_TOKEN' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{
|
||||||
|
"query": "query GetEvents($options: EventQueryOptionsInput) { events(options: $options) { dbCount count entries { eveMac eveIp eveDateTime eveEventType } } }",
|
||||||
|
"variables": {
|
||||||
|
"options": {
|
||||||
|
"eveMac": "00:11:22:33:44:55",
|
||||||
|
"page": 1,
|
||||||
|
"limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
* Device, settings, and LangStrings queries can be combined in **one request** since GraphQL supports batching.
|
* Device, settings, LangStrings, plugin, and event queries can be combined in **one request** since GraphQL supports batching.
|
||||||
* The `fallback_to_en` feature ensures UI always has a value even if a translation is missing.
|
* The `fallback_to_en` feature ensures UI always has a value even if a translation is missing.
|
||||||
* Data is **cached in memory** per JSON file; changes to language or plugin files will only refresh after the cache detects a file modification.
|
* Data is **cached in memory** per JSON file; changes to language or plugin files will only refresh after the cache detects a file modification.
|
||||||
* The `setOverriddenByEnv` flag helps identify setting values that are locked at container runtime.
|
* The `setOverriddenByEnv` flag helps identify setting values that are locked at container runtime.
|
||||||
* The schema is **read-only** — updates must be performed through other APIs or configuration management. See the other [API](API.md) endpoints for details.
|
* Plugin queries scope `dbCount` to the requested `plugin`/`foreignKey` so badge counts reflect per-plugin totals.
|
||||||
|
* The schema is **read-only** — updates must be performed through other APIs or configuration management. See the other [API](API.md) endpoints for details.
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -224,15 +224,33 @@ curl -X GET "http://<server_ip>:<GRAPHQL_PORT>/sessions/AA:BB:CC:DD:EE:FF?period
|
|||||||
* `type` → Event type (`all`, `sessions`, `missing`, `voided`, `new`, `down`)
|
* `type` → Event type (`all`, `sessions`, `missing`, `voided`, `new`, `down`)
|
||||||
Default: `all`
|
Default: `all`
|
||||||
* `period` → Period to retrieve events (`7 days`, `1 month`, etc.)
|
* `period` → Period to retrieve events (`7 days`, `1 month`, etc.)
|
||||||
|
* `page` → Page number, 1-based (default: `1`)
|
||||||
|
* `limit` → Rows per page, max 1000 (default: `100`)
|
||||||
|
* `search` → Free-text search filter across all columns
|
||||||
|
* `sortCol` → Column index to sort by, 0-based (default: `0`)
|
||||||
|
* `sortDir` → Sort direction: `asc` or `desc` (default: `desc`)
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```
|
```
|
||||||
/sessions/session-events?type=all&period=7 days
|
/sessions/session-events?type=all&period=7 days&page=1&limit=25&sortCol=3&sortDir=desc
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
Returns a list of events or sessions with formatted connection, disconnection, duration, and IP information.
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [...],
|
||||||
|
"total": 150,
|
||||||
|
"recordsFiltered": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----------------- | ---- | ------------------------------------------------- |
|
||||||
|
| `data` | list | Paginated rows (each row is a list of values). |
|
||||||
|
| `total` | int | Total rows before search filter. |
|
||||||
|
| `recordsFiltered` | int | Total rows after search filter (before paging). |
|
||||||
|
|
||||||
#### `curl` Example
|
#### `curl` Example
|
||||||
|
|
||||||
|
|||||||
@@ -32,51 +32,64 @@
|
|||||||
|
|
||||||
function loadEventsData() {
|
function loadEventsData() {
|
||||||
const hideConnections = $('#chkHideConnectionEvents')[0].checked;
|
const hideConnections = $('#chkHideConnectionEvents')[0].checked;
|
||||||
const hideConnectionsStr = hideConnections ? 'true' : 'false';
|
|
||||||
|
|
||||||
let period = $("#period").val();
|
let period = $("#period").val();
|
||||||
let { start, end } = getPeriodStartEnd(period);
|
let { start, end } = getPeriodStartEnd(period);
|
||||||
|
|
||||||
const rawSql = `
|
const apiToken = getSetting("API_TOKEN");
|
||||||
SELECT eveDateTime, eveEventType, eveIp, eveAdditionalInfo
|
const apiBase = getApiBase();
|
||||||
FROM Events
|
const graphqlUrl = `${apiBase}/graphql`;
|
||||||
WHERE eveMac = "${mac}"
|
|
||||||
AND eveDateTime BETWEEN "${start}" AND "${end}"
|
const query = `
|
||||||
AND (
|
query Events($options: EventQueryOptionsInput) {
|
||||||
(eveEventType NOT IN ("Connected", "Disconnected", "VOIDED - Connected", "VOIDED - Disconnected"))
|
events(options: $options) {
|
||||||
OR "${hideConnectionsStr}" = "false"
|
count
|
||||||
)
|
entries {
|
||||||
|
eveDateTime
|
||||||
|
eveEventType
|
||||||
|
eveIp
|
||||||
|
eveAdditionalInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const apiToken = getSetting("API_TOKEN");
|
|
||||||
|
|
||||||
const apiBaseUrl = getApiBase();
|
|
||||||
const url = `${apiBaseUrl}/dbquery/read`;
|
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: url,
|
url: graphqlUrl,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${apiToken}`
|
"Authorization": `Bearer ${apiToken}`
|
||||||
},
|
},
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
rawSql: btoa(rawSql)
|
query,
|
||||||
|
variables: {
|
||||||
|
options: {
|
||||||
|
eveMac: mac,
|
||||||
|
dateFrom: start,
|
||||||
|
dateTo: end,
|
||||||
|
limit: 500,
|
||||||
|
sort: [{ field: "eveDateTime", order: "desc" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
// assuming read_query returns rows directly
|
const CONNECTION_TYPES = ["Connected", "Disconnected", "VOIDED - Connected", "VOIDED - Disconnected"];
|
||||||
const rows = data["results"].map(row => {
|
|
||||||
const rawDate = row.eveDateTime;
|
|
||||||
const formattedDate = rawDate ? localizeTimestamp(rawDate) : '-';
|
|
||||||
|
|
||||||
return [
|
const rows = data.data.events.entries
|
||||||
formattedDate,
|
.filter(row => !hideConnections || !CONNECTION_TYPES.includes(row.eveEventType))
|
||||||
row.eveDateTime,
|
.map(row => {
|
||||||
row.eveEventType,
|
const rawDate = row.eveDateTime;
|
||||||
row.eveIp,
|
const formattedDate = rawDate ? localizeTimestamp(rawDate) : '-';
|
||||||
row.eveAdditionalInfo
|
|
||||||
];
|
return [
|
||||||
});
|
formattedDate,
|
||||||
|
row.eveDateTime,
|
||||||
|
row.eveEventType,
|
||||||
|
row.eveIp,
|
||||||
|
row.eveAdditionalInfo
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const table = $('#tableEvents').DataTable();
|
const table = $('#tableEvents').DataTable();
|
||||||
table.clear();
|
table.clear();
|
||||||
|
|||||||
+67
-44
@@ -105,21 +105,64 @@ function main() {
|
|||||||
$('#period').val(period);
|
$('#period').val(period);
|
||||||
initializeDatatable();
|
initializeDatatable();
|
||||||
getEventsTotals();
|
getEventsTotals();
|
||||||
getEvents(eventsType);
|
getEvents(eventsType); // triggers first serverSide draw
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Initialize DataTable ---------------- */
|
/* ---------------- Initialize DataTable ---------------- */
|
||||||
function initializeDatatable() {
|
function initializeDatatable() {
|
||||||
const table = $('#tableEvents').DataTable({
|
const apiBase = getApiBase();
|
||||||
paging: true,
|
const apiToken = getSetting("API_TOKEN");
|
||||||
|
|
||||||
|
$('#tableEvents').DataTable({
|
||||||
|
processing: true,
|
||||||
|
serverSide: true,
|
||||||
|
paging: true,
|
||||||
lengthChange: true,
|
lengthChange: true,
|
||||||
lengthMenu: getLengthMenu(getSetting("UI_DEFAULT_PAGE_SIZE")),
|
lengthMenu: getLengthMenu(getSetting("UI_DEFAULT_PAGE_SIZE")),
|
||||||
searching: true,
|
searching: true,
|
||||||
ordering: true,
|
ordering: true,
|
||||||
info: true,
|
info: true,
|
||||||
autoWidth: false,
|
autoWidth: false,
|
||||||
order: [[0, "desc"], [3, "desc"], [5, "desc"]],
|
order: [[0, "desc"]],
|
||||||
pageLength: tableRows,
|
pageLength: tableRows,
|
||||||
|
|
||||||
|
ajax: function (dtRequest, callback) {
|
||||||
|
const page = Math.floor(dtRequest.start / dtRequest.length) + 1;
|
||||||
|
const limit = dtRequest.length;
|
||||||
|
const search = dtRequest.search?.value || '';
|
||||||
|
const sortCol = dtRequest.order?.length ? dtRequest.order[0].column : 0;
|
||||||
|
const sortDir = dtRequest.order?.length ? dtRequest.order[0].dir : 'desc';
|
||||||
|
|
||||||
|
const url = `${apiBase}/sessions/session-events`
|
||||||
|
+ `?type=${encodeURIComponent(eventsType)}`
|
||||||
|
+ `&period=${encodeURIComponent(period)}`
|
||||||
|
+ `&page=${page}`
|
||||||
|
+ `&limit=${limit}`
|
||||||
|
+ `&sortCol=${sortCol}`
|
||||||
|
+ `&sortDir=${sortDir}`
|
||||||
|
+ (search ? `&search=${encodeURIComponent(search)}` : '');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url,
|
||||||
|
method: "GET",
|
||||||
|
dataType: "json",
|
||||||
|
headers: { "Authorization": `Bearer ${apiToken}` },
|
||||||
|
success: function (response) {
|
||||||
|
callback({
|
||||||
|
data: response.data || [],
|
||||||
|
recordsTotal: response.total || 0,
|
||||||
|
recordsFiltered: response.recordsFiltered || 0
|
||||||
|
});
|
||||||
|
hideSpinner();
|
||||||
|
},
|
||||||
|
error: function (xhr, status, error) {
|
||||||
|
console.error("Error fetching session events:", status, error, xhr.responseText);
|
||||||
|
callback({ data: [], recordsTotal: 0, recordsFiltered: 0 });
|
||||||
|
hideSpinner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ targets: [0,5,6,7,8,10,11,12,13], visible: false },
|
{ targets: [0,5,6,7,8,10,11,12,13], visible: false },
|
||||||
{ targets: [7], orderData: [8] },
|
{ targets: [7], orderData: [8] },
|
||||||
@@ -131,14 +174,14 @@ function initializeDatatable() {
|
|||||||
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
|
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
|
||||||
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }
|
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }
|
||||||
],
|
],
|
||||||
processing: true, // Shows "processing" overlay
|
|
||||||
language: {
|
language: {
|
||||||
processing: '<table><td width="130px" align="middle"><?= lang("Events_Loading"); ?></td><td><i class="fa-solid fa-spinner fa-spin-pulse"></i></td></table>',
|
processing: '<table><td width="130px" align="middle"><?= lang("Events_Loading"); ?></td><td><i class="fa-solid fa-spinner fa-spin-pulse"></i></td></table>',
|
||||||
emptyTable: 'No data',
|
emptyTable: 'No data',
|
||||||
lengthMenu: "<?= lang('Events_Tablelenght'); ?>",
|
lengthMenu: "<?= lang('Events_Tablelenght'); ?>",
|
||||||
search: "<?= lang('Events_Searchbox'); ?>: ",
|
search: "<?= lang('Events_Searchbox'); ?>: ",
|
||||||
paginate: { next: "<?= lang('Events_Table_nav_next'); ?>", previous: "<?= lang('Events_Table_nav_prev'); ?>" },
|
paginate: { next: "<?= lang('Events_Table_nav_next'); ?>", previous: "<?= lang('Events_Table_nav_prev'); ?>" },
|
||||||
info: "<?= lang('Events_Table_info'); ?>"
|
info: "<?= lang('Events_Table_info'); ?>"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,53 +222,33 @@ function getEventsTotals() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- Fetch events and reload DataTable ---------------- */
|
/* ---------------- Switch event type and reload DataTable ---------------- */
|
||||||
function getEvents(type) {
|
function getEvents(type) {
|
||||||
eventsType = type;
|
eventsType = type;
|
||||||
const table = $('#tableEvents').DataTable();
|
const table = $('#tableEvents').DataTable();
|
||||||
|
|
||||||
// Event type config: title, color, session columns visibility
|
// Event type config: title, color, session columns visibility
|
||||||
const config = {
|
const config = {
|
||||||
all: {title: 'Events_Shortcut_AllEvents', color: 'aqua', sesionCols: false},
|
all: {title: 'Events_Shortcut_AllEvents', color: 'aqua', sesionCols: false},
|
||||||
sessions: {title: 'Events_Shortcut_Sessions', color: 'green', sesionCols: true},
|
sessions: {title: 'Events_Shortcut_Sessions', color: 'green', sesionCols: true},
|
||||||
missing: {title: 'Events_Shortcut_MissSessions', color: 'yellow', sesionCols: true},
|
missing: {title: 'Events_Shortcut_MissSessions', color: 'yellow', sesionCols: true},
|
||||||
voided: {title: 'Events_Shortcut_VoidSessions', color: 'yellow', sesionCols: false},
|
voided: {title: 'Events_Shortcut_VoidSessions', color: 'yellow', sesionCols: false},
|
||||||
new: {title: 'Events_Shortcut_NewDevices', color: 'yellow', sesionCols: false},
|
new: {title: 'Events_Shortcut_NewDevices', color: 'yellow', sesionCols: false},
|
||||||
down: {title: 'Events_Shortcut_DownAlerts', color: 'red', sesionCols: false}
|
down: {title: 'Events_Shortcut_DownAlerts', color: 'red', sesionCols: false}
|
||||||
}[type] || {title: 'Events_Shortcut_Events', color: '', sesionCols: false};
|
}[type] || {title: 'Events_Shortcut_Events', color: '', sesionCols: false};
|
||||||
|
|
||||||
// Update title and color
|
// Update title and color
|
||||||
$('#tableEventsTitle').attr('class', 'box-title text-' + config.color).html(getString(config.title));
|
$('#tableEventsTitle').attr('class', 'box-title text-' + config.color).html(getString(config.title));
|
||||||
$('#tableEventsBox').attr('class', 'box box-' + config.color);
|
$('#tableEventsBox').attr('class', 'box box-' + config.color);
|
||||||
|
|
||||||
// Toggle columns visibility
|
// Toggle column visibility
|
||||||
table.column(3).visible(!config.sesionCols);
|
table.column(3).visible(!config.sesionCols);
|
||||||
table.column(4).visible(!config.sesionCols);
|
table.column(4).visible(!config.sesionCols);
|
||||||
table.column(5).visible(config.sesionCols);
|
table.column(5).visible(config.sesionCols);
|
||||||
table.column(6).visible(config.sesionCols);
|
table.column(6).visible(config.sesionCols);
|
||||||
table.column(7).visible(config.sesionCols);
|
table.column(7).visible(config.sesionCols);
|
||||||
|
|
||||||
// Build API URL
|
showSpinner();
|
||||||
const apiBase = getApiBase();
|
table.ajax.reload(null, true); // reset to page 1
|
||||||
const apiToken = getSetting("API_TOKEN");
|
|
||||||
const url = `${apiBase}/sessions/session-events?type=${encodeURIComponent(type)}&period=${encodeURIComponent(period)}`;
|
|
||||||
|
|
||||||
table.clear().draw(); // Clear old rows
|
|
||||||
|
|
||||||
showSpinner()
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url,
|
|
||||||
method: "GET",
|
|
||||||
dataType: "json",
|
|
||||||
headers: { "Authorization": `Bearer ${apiToken}` },
|
|
||||||
beforeSend: showSpinner, // Show spinner during fetch
|
|
||||||
complete: hideSpinner, // Hide spinner after fetch
|
|
||||||
success: response => {
|
|
||||||
const data = Array.isArray(response) ? response : response.data || [];
|
|
||||||
table.rows.add(data).draw();
|
|
||||||
},
|
|
||||||
error: (xhr, status, error) => console.error("Error fetching session events:", status, error, xhr.responseText)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+191
-96
@@ -273,26 +273,14 @@ function genericSaveData (id) {
|
|||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
pluginDefinitions = []
|
pluginDefinitions = []
|
||||||
pluginUnprocessedEvents = []
|
|
||||||
pluginObjects = []
|
|
||||||
pluginHistory = []
|
|
||||||
|
|
||||||
async function getData() {
|
async function getData() {
|
||||||
try {
|
try {
|
||||||
showSpinner();
|
showSpinner();
|
||||||
console.log("Plugins getData called");
|
console.log("Plugins getData called");
|
||||||
|
|
||||||
const [plugins, events, objects, history] = await Promise.all([
|
const plugins = await fetchJson('plugins.json');
|
||||||
fetchJson('plugins.json'),
|
|
||||||
fetchJson('table_plugins_events.json'),
|
|
||||||
fetchJson('table_plugins_objects.json'),
|
|
||||||
fetchJson('table_plugins_history.json')
|
|
||||||
]);
|
|
||||||
|
|
||||||
pluginDefinitions = plugins.data;
|
pluginDefinitions = plugins.data;
|
||||||
pluginUnprocessedEvents = events.data;
|
|
||||||
pluginObjects = objects.data;
|
|
||||||
pluginHistory = history.data;
|
|
||||||
|
|
||||||
generateTabs();
|
generateTabs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -306,6 +294,106 @@ async function fetchJson(filename) {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GraphQL helper — fires a paginated plugin table query and calls back with
|
||||||
|
// the DataTables-compatible response plus the raw GraphQL result object.
|
||||||
|
function postPluginGraphQL(gqlField, prefix, foreignKey, dtRequest, callback) {
|
||||||
|
const apiToken = getSetting("API_TOKEN");
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const page = Math.floor(dtRequest.start / dtRequest.length) + 1;
|
||||||
|
const limit = dtRequest.length;
|
||||||
|
const search = dtRequest.search?.value || null;
|
||||||
|
|
||||||
|
let sort = [];
|
||||||
|
if (dtRequest.order?.length > 0) {
|
||||||
|
const order = dtRequest.order[0];
|
||||||
|
sort.push({ field: dtRequest.columns[order.column].data, order: order.dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query PluginData($options: PluginQueryOptionsInput) {
|
||||||
|
${gqlField}(options: $options) {
|
||||||
|
count
|
||||||
|
dbCount
|
||||||
|
entries {
|
||||||
|
index plugin objectPrimaryId objectSecondaryId
|
||||||
|
dateTimeCreated dateTimeChanged
|
||||||
|
watchedValue1 watchedValue2 watchedValue3 watchedValue4
|
||||||
|
status extra userData foreignKey
|
||||||
|
syncHubNodeName helpVal1 helpVal2 helpVal3 helpVal4 objectGuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
method: "POST",
|
||||||
|
url: `${apiBase}/graphql`,
|
||||||
|
headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
||||||
|
data: JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables: { options: { page, limit, search, sort, plugin: prefix, foreignKey } }
|
||||||
|
}),
|
||||||
|
success: function(response) {
|
||||||
|
if (response.errors) {
|
||||||
|
console.error("[plugins] GraphQL errors:", response.errors);
|
||||||
|
callback({ data: [], recordsTotal: 0, recordsFiltered: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = response.data[gqlField];
|
||||||
|
callback({ data: result.entries, recordsTotal: result.dbCount, recordsFiltered: result.count }, result);
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
callback({ data: [], recordsTotal: 0, recordsFiltered: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire a single batched GraphQL request to fetch the Objects dbCount for
|
||||||
|
// every plugin and populate the sidebar badges immediately on page load.
|
||||||
|
function prefetchPluginBadges() {
|
||||||
|
const apiToken = getSetting("API_TOKEN");
|
||||||
|
const apiBase = getApiBase();
|
||||||
|
const mac = $("#txtMacFilter").val();
|
||||||
|
const foreignKey = (mac && mac !== "--") ? mac : null;
|
||||||
|
|
||||||
|
// Build one aliased sub-query per visible plugin
|
||||||
|
const prefixes = pluginDefinitions
|
||||||
|
.filter(p => p.show_ui)
|
||||||
|
.map(p => p.unique_prefix);
|
||||||
|
|
||||||
|
if (prefixes.length === 0) return;
|
||||||
|
|
||||||
|
// GraphQL aliases must be valid identifiers — prefixes already are (A-Z0-9_)
|
||||||
|
const fkOpt = foreignKey ? `, foreignKey: "${foreignKey}"` : '';
|
||||||
|
const fragments = prefixes.map(p => [
|
||||||
|
`${p}_obj: pluginsObjects(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
|
||||||
|
`${p}_evt: pluginsEvents(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
|
||||||
|
`${p}_hist: pluginsHistory(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
|
||||||
|
].join('\n ')).join('\n ');
|
||||||
|
|
||||||
|
const query = `query BadgeCounts {\n ${fragments}\n }`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
method: "POST",
|
||||||
|
url: `${apiBase}/graphql`,
|
||||||
|
headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
||||||
|
data: JSON.stringify({ query }),
|
||||||
|
success: function(response) {
|
||||||
|
if (response.errors) {
|
||||||
|
console.error("[plugins] badge prefetch errors:", response.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prefixes.forEach(p => {
|
||||||
|
const obj = response.data[`${p}_obj`];
|
||||||
|
const evt = response.data[`${p}_evt`];
|
||||||
|
const hist = response.data[`${p}_hist`];
|
||||||
|
if (obj) { $(`#badge_${p}`).text(obj.dbCount); $(`#objCount_${p}`).text(obj.dbCount); }
|
||||||
|
if (evt) { $(`#evtCount_${p}`).text(evt.dbCount); }
|
||||||
|
if (hist) { $(`#histCount_${p}`).text(hist.dbCount); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function generateTabs() {
|
function generateTabs() {
|
||||||
|
|
||||||
@@ -315,17 +403,32 @@ function generateTabs() {
|
|||||||
// Sort pluginDefinitions by unique_prefix alphabetically
|
// Sort pluginDefinitions by unique_prefix alphabetically
|
||||||
pluginDefinitions.sort((a, b) => a.unique_prefix.localeCompare(b.unique_prefix));
|
pluginDefinitions.sort((a, b) => a.unique_prefix.localeCompare(b.unique_prefix));
|
||||||
|
|
||||||
assignActive = true;
|
let assignActive = true;
|
||||||
|
|
||||||
// Iterate over the sorted pluginDefinitions to create tab headers and content
|
// Iterate over the sorted pluginDefinitions to create tab headers and content
|
||||||
pluginDefinitions.forEach(pluginObj => {
|
pluginDefinitions.forEach(pluginObj => {
|
||||||
if (pluginObj.show_ui) {
|
if (pluginObj.show_ui) {
|
||||||
stats = createTabContent(pluginObj, assignActive); // Create the content for each tab
|
createTabContent(pluginObj, assignActive);
|
||||||
|
createTabHeader(pluginObj, assignActive);
|
||||||
|
assignActive = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if(stats.objectDataCount > 0)
|
// Now that ALL DOM elements exist (both <a> headers and tab panes),
|
||||||
{
|
// wire up DataTable initialization: immediate for the active tab,
|
||||||
createTabHeader(pluginObj, stats, assignActive); // Create the header for each tab
|
// deferred via shown.bs.tab for the rest.
|
||||||
assignActive = false; // only mark first with content active
|
let firstVisible = true;
|
||||||
|
pluginDefinitions.forEach(pluginObj => {
|
||||||
|
if (pluginObj.show_ui) {
|
||||||
|
const prefix = pluginObj.unique_prefix;
|
||||||
|
const colDefinitions = getColumnDefinitions(pluginObj);
|
||||||
|
if (firstVisible) {
|
||||||
|
initializeDataTables(prefix, colDefinitions, pluginObj);
|
||||||
|
firstVisible = false;
|
||||||
|
} else {
|
||||||
|
$(`a[href="#${prefix}"]`).one('shown.bs.tab', function() {
|
||||||
|
initializeDataTables(prefix, colDefinitions, pluginObj);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -338,6 +441,9 @@ function generateTabs() {
|
|||||||
tabContainer: '#tabs-location'
|
tabContainer: '#tabs-location'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-fetch badge counts for every plugin in a single batched GraphQL call.
|
||||||
|
prefetchPluginBadges();
|
||||||
|
|
||||||
hideSpinner()
|
hideSpinner()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,11 +455,11 @@ function resetTabs() {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// left headers
|
// left headers
|
||||||
function createTabHeader(pluginObj, stats, assignActive) {
|
function createTabHeader(pluginObj, assignActive) {
|
||||||
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin
|
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin
|
||||||
|
|
||||||
// Determine the active class for the first tab
|
// Determine the active class for the first tab
|
||||||
assignActive ? activeClass = "active" : activeClass = "";
|
const activeClass = assignActive ? "active" : "";
|
||||||
|
|
||||||
// Append the tab header to the tabs location
|
// Append the tab header to the tabs location
|
||||||
$('#tabs-location').append(`
|
$('#tabs-location').append(`
|
||||||
@@ -362,7 +468,7 @@ function createTabHeader(pluginObj, stats, assignActive) {
|
|||||||
${getString(`${prefix}_icon`)} ${getString(`${prefix}_display_name`)}
|
${getString(`${prefix}_icon`)} ${getString(`${prefix}_display_name`)}
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
${stats.objectDataCount > 0 ? `<div class="pluginBadgeWrap"><span title="" class="badge pluginBadge" >${stats.objectDataCount}</span></div>` : ""}
|
<div class="pluginBadgeWrap"><span title="" class="badge pluginBadge" id="badge_${prefix}">…</span></div>
|
||||||
</li>
|
</li>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -374,19 +480,14 @@ function createTabContent(pluginObj, assignActive) {
|
|||||||
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin
|
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin
|
||||||
const colDefinitions = getColumnDefinitions(pluginObj); // Get column definitions for DataTables
|
const colDefinitions = getColumnDefinitions(pluginObj); // Get column definitions for DataTables
|
||||||
|
|
||||||
// Get data for events, objects, and history related to the plugin
|
|
||||||
const objectData = getObjectData(prefix, colDefinitions, pluginObj);
|
|
||||||
const eventData = getEventData(prefix, colDefinitions, pluginObj);
|
|
||||||
const historyData = getHistoryData(prefix, colDefinitions, pluginObj);
|
|
||||||
|
|
||||||
// Append the content structure for the plugin's tab to the content location
|
// Append the content structure for the plugin's tab to the content location
|
||||||
$('#tabs-content-location').append(`
|
$('#tabs-content-location').append(`
|
||||||
<div id="${prefix}" class="tab-pane ${objectData.length > 0 && assignActive? 'active' : ''}">
|
<div id="${prefix}" class="tab-pane ${assignActive ? 'active' : ''}">
|
||||||
${generateTabNavigation(prefix, objectData.length, eventData.length, historyData.length)} <!-- Create tab navigation -->
|
${generateTabNavigation(prefix)} <!-- Create tab navigation -->
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
${generateDataTable(prefix, 'Objects', objectData, colDefinitions)}
|
${generateDataTable(prefix, 'Objects', colDefinitions)}
|
||||||
${generateDataTable(prefix, 'Events', eventData, colDefinitions)}
|
${generateDataTable(prefix, 'Events', colDefinitions)}
|
||||||
${generateDataTable(prefix, 'History', historyData, colDefinitions)}
|
${generateDataTable(prefix, 'History', colDefinitions)}
|
||||||
</div>
|
</div>
|
||||||
<div class='plugins-description'>
|
<div class='plugins-description'>
|
||||||
${getString(`${prefix}_description`)} <!-- Display the plugin description -->
|
${getString(`${prefix}_description`)} <!-- Display the plugin description -->
|
||||||
@@ -395,14 +496,7 @@ function createTabContent(pluginObj, assignActive) {
|
|||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Initialize DataTables for the respective sections
|
// DataTable init is handled by generateTabs() after all DOM elements exist.
|
||||||
initializeDataTables(prefix, objectData, eventData, historyData, colDefinitions);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"objectDataCount": objectData.length,
|
|
||||||
"eventDataCount": eventData.length,
|
|
||||||
"historyDataCount": historyData.length
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getColumnDefinitions(pluginObj) {
|
function getColumnDefinitions(pluginObj) {
|
||||||
@@ -410,53 +504,26 @@ function getColumnDefinitions(pluginObj) {
|
|||||||
return pluginObj["database_column_definitions"].filter(colDef => colDef.show);
|
return pluginObj["database_column_definitions"].filter(colDef => colDef.show);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventData(prefix, colDefinitions, pluginObj) {
|
function generateTabNavigation(prefix) {
|
||||||
// Extract event data specific to the plugin and format it for DataTables
|
|
||||||
return pluginUnprocessedEvents
|
|
||||||
.filter(event => event.plugin === prefix && shouldBeShown(event, pluginObj)) // Filter events for the specific plugin
|
|
||||||
.map(event => colDefinitions.map(colDef => event[colDef.column] || '')); // Map to the defined columns
|
|
||||||
}
|
|
||||||
|
|
||||||
function getObjectData(prefix, colDefinitions, pluginObj) {
|
|
||||||
// Extract object data specific to the plugin and format it for DataTables
|
|
||||||
return pluginObjects
|
|
||||||
.filter(object => object.plugin === prefix && shouldBeShown(object, pluginObj)) // Filter objects for the specific plugin
|
|
||||||
.map(object => colDefinitions.map(colDef => getFormControl(colDef, object[colDef.column], object["index"], colDefinitions, object))); // Map to the defined columns
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHistoryData(prefix, colDefinitions, pluginObj) {
|
|
||||||
|
|
||||||
return pluginHistory
|
|
||||||
.filter(history => history.plugin === prefix && shouldBeShown(history, pluginObj)) // First, filter based on the plugin prefix
|
|
||||||
.sort((a, b) => b.index - a.index) // Then, sort by the Index field in descending order
|
|
||||||
.slice(0, 50) // Limit the result to the first 50 entries
|
|
||||||
.map(object =>
|
|
||||||
colDefinitions.map(colDef =>
|
|
||||||
getFormControl(colDef, object[colDef.column], object["index"], colDefinitions, object)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateTabNavigation(prefix, objectCount, eventCount, historyCount) {
|
|
||||||
// Create navigation tabs for Objects, Unprocessed Events, and History
|
// Create navigation tabs for Objects, Unprocessed Events, and History
|
||||||
return `
|
return `
|
||||||
<div class="nav-tabs-custom" style="margin-bottom: 0px">
|
<div class="nav-tabs-custom" style="margin-bottom: 0px">
|
||||||
<ul class="nav nav-tabs">
|
<ul class="nav nav-tabs">
|
||||||
<li class="active">
|
<li class="active">
|
||||||
<a href="#objectsTarget_${prefix}" data-toggle="tab"><i class="fa fa-cube"></i> ${getString('Plugins_Objects')} (${objectCount})</a>
|
<a href="#objectsTarget_${prefix}" data-toggle="tab"><i class="fa fa-cube"></i> ${getString('Plugins_Objects')} (<span id="objCount_${prefix}">…</span>)</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#eventsTarget_${prefix}" data-toggle="tab"><i class="fa fa-bolt"></i> ${getString('Plugins_Unprocessed_Events')} (${eventCount})</a>
|
<a href="#eventsTarget_${prefix}" data-toggle="tab"><i class="fa fa-bolt"></i> ${getString('Plugins_Unprocessed_Events')} (<span id="evtCount_${prefix}">…</span>)</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#historyTarget_${prefix}" data-toggle="tab"><i class="fa fa-clock"></i> ${getString('Plugins_History')} (${historyCount})</a>
|
<a href="#historyTarget_${prefix}" data-toggle="tab"><i class="fa fa-clock"></i> ${getString('Plugins_History')} (<span id="histCount_${prefix}">…</span>)</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateDataTable(prefix, tableType, data, colDefinitions) {
|
function generateDataTable(prefix, tableType, colDefinitions) {
|
||||||
// Generate HTML for a DataTable and associated buttons for a given table type
|
// Generate HTML for a DataTable and associated buttons for a given table type
|
||||||
const headersHtml = colDefinitions.map(colDef => `<th class="${colDef.css_classes}">${getString(`${prefix}_${colDef.column}_name`)}</th>`).join('');
|
const headersHtml = colDefinitions.map(colDef => `<th class="${colDef.css_classes}">${getString(`${prefix}_${colDef.column}_name`)}</th>`).join('');
|
||||||
|
|
||||||
@@ -473,34 +540,62 @@ function generateDataTable(prefix, tableType, data, colDefinitions) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeDataTables(prefix, objectData, eventData, historyData, colDefinitions) {
|
function initializeDataTables(prefix, colDefinitions, pluginObj) {
|
||||||
// Common settings for DataTables initialization
|
const mac = $("#txtMacFilter").val();
|
||||||
const commonDataTableSettings = {
|
const foreignKey = (mac && mac !== "--") ? mac : null;
|
||||||
orderable: false, // Disable ordering
|
|
||||||
createdRow: function(row, data) {
|
const tableConfigs = [
|
||||||
$(row).attr('data-my-index', data[0]); // Set data attribute for indexing
|
{ tableId: `objectsTable_${prefix}`, gqlField: 'pluginsObjects', countId: `objCount_${prefix}`, badgeId: `badge_${prefix}` },
|
||||||
|
{ tableId: `eventsTable_${prefix}`, gqlField: 'pluginsEvents', countId: `evtCount_${prefix}`, badgeId: null },
|
||||||
|
{ tableId: `historyTable_${prefix}`, gqlField: 'pluginsHistory', countId: `histCount_${prefix}`, badgeId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildDT(tableId, gqlField, countId, badgeId) {
|
||||||
|
if ($.fn.DataTable.isDataTable(`#${tableId}`)) {
|
||||||
|
return; // already initialized
|
||||||
}
|
}
|
||||||
};
|
$(`#${tableId}`).DataTable({
|
||||||
|
processing: true,
|
||||||
|
serverSide: true,
|
||||||
|
paging: true,
|
||||||
|
searching: true,
|
||||||
|
ordering: false,
|
||||||
|
pageLength: 25,
|
||||||
|
lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||||
|
createdRow: function(row, data) {
|
||||||
|
$(row).attr('data-my-index', data.index);
|
||||||
|
},
|
||||||
|
ajax: function(dtRequest, callback) {
|
||||||
|
postPluginGraphQL(gqlField, prefix, foreignKey, dtRequest, function(dtResponse, result) {
|
||||||
|
if (result) {
|
||||||
|
$(`#${countId}`).text(result.count);
|
||||||
|
if (badgeId) $(`#${badgeId}`).text(result.dbCount);
|
||||||
|
}
|
||||||
|
callback(dtResponse);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
columns: colDefinitions.map(colDef => ({
|
||||||
|
data: colDef.column,
|
||||||
|
title: getString(`${prefix}_${colDef.column}_name`),
|
||||||
|
className: colDef.css_classes || '',
|
||||||
|
createdCell: function(td, cellData, rowData) {
|
||||||
|
$(td).html(getFormControl(colDef, cellData, rowData.index));
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize DataTable for Objects
|
// Initialize the Objects table immediately (it is the active/visible sub-tab).
|
||||||
$(`#objectsTable_${prefix}`).DataTable({
|
// Defer Events and History tables until their sub-tab is first shown.
|
||||||
data: objectData,
|
const [objCfg, evtCfg, histCfg] = tableConfigs;
|
||||||
columns: colDefinitions.map(colDef => ({ title: getString(`${prefix}_${colDef.column}_name`) })), // Column titles
|
buildDT(objCfg.tableId, objCfg.gqlField, objCfg.countId, objCfg.badgeId);
|
||||||
...commonDataTableSettings // Spread common settings
|
|
||||||
|
$(`a[href="#eventsTarget_${prefix}"]`).one('shown.bs.tab', function() {
|
||||||
|
buildDT(evtCfg.tableId, evtCfg.gqlField, evtCfg.countId, evtCfg.badgeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize DataTable for Unprocessed Events
|
$(`a[href="#historyTarget_${prefix}"]`).one('shown.bs.tab', function() {
|
||||||
$(`#eventsTable_${prefix}`).DataTable({
|
buildDT(histCfg.tableId, histCfg.gqlField, histCfg.countId, histCfg.badgeId);
|
||||||
data: eventData,
|
|
||||||
columns: colDefinitions.map(colDef => ({ title: getString(`${prefix}_${colDef.column}_name`) })), // Column titles
|
|
||||||
...commonDataTableSettings // Spread common settings
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize DataTable for History
|
|
||||||
$(`#historyTable_${prefix}`).DataTable({
|
|
||||||
data: historyData,
|
|
||||||
columns: colDefinitions.map(colDef => ({ title: getString(`${prefix}_${colDef.column}_name`) })), // Column titles
|
|
||||||
...commonDataTableSettings // Spread common settings
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from const import (
|
|||||||
apiPath,
|
apiPath,
|
||||||
sql_appevents,
|
sql_appevents,
|
||||||
sql_devices_all,
|
sql_devices_all,
|
||||||
|
sql_events_all,
|
||||||
sql_events_pending_alert,
|
sql_events_pending_alert,
|
||||||
sql_settings,
|
sql_settings,
|
||||||
sql_plugins_events,
|
sql_plugins_events,
|
||||||
@@ -59,6 +60,7 @@ def update_api(
|
|||||||
dataSourcesSQLs = [
|
dataSourcesSQLs = [
|
||||||
["appevents", sql_appevents],
|
["appevents", sql_appevents],
|
||||||
["devices", sql_devices_all],
|
["devices", sql_devices_all],
|
||||||
|
["events", sql_events_all],
|
||||||
["events_pending_alert", sql_events_pending_alert],
|
["events_pending_alert", sql_events_pending_alert],
|
||||||
["settings", sql_settings],
|
["settings", sql_settings],
|
||||||
["plugins_events", sql_plugins_events],
|
["plugins_events", sql_plugins_events],
|
||||||
|
|||||||
@@ -1750,8 +1750,13 @@ def api_device_sessions(mac, payload=None):
|
|||||||
summary="Get Session Events",
|
summary="Get Session Events",
|
||||||
description="Retrieve events associated with sessions.",
|
description="Retrieve events associated with sessions.",
|
||||||
query_params=[
|
query_params=[
|
||||||
{"name": "type", "description": "Event type", "required": False, "schema": {"type": "string", "default": "all"}},
|
{"name": "type", "description": "Event type", "required": False, "schema": {"type": "string", "default": "all"}},
|
||||||
{"name": "period", "description": "Time period", "required": False, "schema": {"type": "string", "default": "7 days"}}
|
{"name": "period", "description": "Time period", "required": False, "schema": {"type": "string", "default": "7 days"}},
|
||||||
|
{"name": "page", "description": "Page number (1-based)", "required": False, "schema": {"type": "integer", "default": 1}},
|
||||||
|
{"name": "limit", "description": "Rows per page (max 1000)", "required": False, "schema": {"type": "integer", "default": 100}},
|
||||||
|
{"name": "search", "description": "Free-text search filter", "required": False, "schema": {"type": "string"}},
|
||||||
|
{"name": "sortCol", "description": "Column index to sort by (0-based)", "required": False, "schema": {"type": "integer", "default": 0}},
|
||||||
|
{"name": "sortDir", "description": "Sort direction: asc or desc", "required": False, "schema": {"type": "string", "default": "desc"}}
|
||||||
],
|
],
|
||||||
tags=["sessions"],
|
tags=["sessions"],
|
||||||
auth_callable=is_authorized
|
auth_callable=is_authorized
|
||||||
@@ -1759,7 +1764,12 @@ def api_device_sessions(mac, payload=None):
|
|||||||
def api_get_session_events(payload=None):
|
def api_get_session_events(payload=None):
|
||||||
session_event_type = request.args.get("type", "all")
|
session_event_type = request.args.get("type", "all")
|
||||||
period = get_date_from_period(request.args.get("period", "7 days"))
|
period = get_date_from_period(request.args.get("period", "7 days"))
|
||||||
return get_session_events(session_event_type, period)
|
page = request.args.get("page", 1, type=int)
|
||||||
|
limit = request.args.get("limit", 100, type=int)
|
||||||
|
search = request.args.get("search", None)
|
||||||
|
sort_col = request.args.get("sortCol", 0, type=int)
|
||||||
|
sort_dir = request.args.get("sortDir", "desc")
|
||||||
|
return get_session_events(session_event_type, period, page=page, limit=limit, search=search, sort_col=sort_col, sort_dir=sort_dir)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import graphene
|
import graphene
|
||||||
from graphene import (
|
from graphene import ObjectType, List, Field, Argument, String
|
||||||
ObjectType, String, Int, Boolean, List, Field, InputObjectType, Argument
|
|
||||||
)
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -19,175 +17,30 @@ from helper import ( # noqa: E402 [flake8 lint suppression]
|
|||||||
get_setting_value,
|
get_setting_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Define a base URL with the user's home directory
|
from .graphql_types import ( # noqa: E402 [flake8 lint suppression]
|
||||||
|
FilterOptionsInput, PageQueryOptionsInput,
|
||||||
|
Device, DeviceResult,
|
||||||
|
Setting, SettingResult,
|
||||||
|
LangString, LangStringResult,
|
||||||
|
AppEvent, AppEventResult,
|
||||||
|
PluginQueryOptionsInput, PluginEntry,
|
||||||
|
PluginsObjectsResult, PluginsEventsResult, PluginsHistoryResult,
|
||||||
|
EventQueryOptionsInput, EventEntry, EventsResult,
|
||||||
|
)
|
||||||
|
from .graphql_helpers import ( # noqa: E402 [flake8 lint suppression]
|
||||||
|
mixed_type_sort_key,
|
||||||
|
apply_common_pagination,
|
||||||
|
apply_plugin_filters,
|
||||||
|
apply_events_filters,
|
||||||
|
)
|
||||||
|
|
||||||
folder = apiPath
|
folder = apiPath
|
||||||
|
|
||||||
|
|
||||||
# --- DEVICES ---
|
|
||||||
# Pagination and Sorting Input Types
|
|
||||||
class SortOptionsInput(InputObjectType):
|
|
||||||
field = String()
|
|
||||||
order = String()
|
|
||||||
|
|
||||||
|
|
||||||
class FilterOptionsInput(InputObjectType):
|
|
||||||
filterColumn = String()
|
|
||||||
filterValue = String()
|
|
||||||
|
|
||||||
|
|
||||||
class PageQueryOptionsInput(InputObjectType):
|
|
||||||
page = Int()
|
|
||||||
limit = Int()
|
|
||||||
sort = List(SortOptionsInput)
|
|
||||||
search = String()
|
|
||||||
status = String()
|
|
||||||
filters = List(FilterOptionsInput)
|
|
||||||
|
|
||||||
|
|
||||||
# Device ObjectType
|
|
||||||
class Device(ObjectType):
|
|
||||||
rowid = Int(description="Database row ID")
|
|
||||||
devMac = String(description="Device MAC address (e.g., 00:11:22:33:44:55)")
|
|
||||||
devName = String(description="Device display name/alias")
|
|
||||||
devOwner = String(description="Device owner")
|
|
||||||
devType = String(description="Device type classification")
|
|
||||||
devVendor = String(description="Hardware vendor from OUI lookup")
|
|
||||||
devFavorite = Int(description="Favorite flag (0 or 1)")
|
|
||||||
devGroup = String(description="Device group")
|
|
||||||
devComments = String(description="User comments")
|
|
||||||
devFirstConnection = String(description="Timestamp of first discovery")
|
|
||||||
devLastConnection = String(description="Timestamp of last connection")
|
|
||||||
devLastIP = String(description="Last known IP address")
|
|
||||||
devPrimaryIPv4 = String(description="Primary IPv4 address")
|
|
||||||
devPrimaryIPv6 = String(description="Primary IPv6 address")
|
|
||||||
devVlan = String(description="VLAN identifier")
|
|
||||||
devForceStatus = String(description="Force device status (online/offline/dont_force)")
|
|
||||||
devStaticIP = Int(description="Static IP flag (0 or 1)")
|
|
||||||
devScan = Int(description="Scan flag (0 or 1)")
|
|
||||||
devLogEvents = Int(description="Log events flag (0 or 1)")
|
|
||||||
devAlertEvents = Int(description="Alert events flag (0 or 1)")
|
|
||||||
devAlertDown = Int(description="Alert on down flag (0 or 1)")
|
|
||||||
devSkipRepeated = Int(description="Skip repeated alerts flag (0 or 1)")
|
|
||||||
devLastNotification = String(description="Timestamp of last notification")
|
|
||||||
devPresentLastScan = Int(description="Present in last scan flag (0 or 1)")
|
|
||||||
devIsNew = Int(description="Is new device flag (0 or 1)")
|
|
||||||
devLocation = String(description="Device location")
|
|
||||||
devIsArchived = Int(description="Is archived flag (0 or 1)")
|
|
||||||
devParentMAC = String(description="Parent device MAC address")
|
|
||||||
devParentPort = String(description="Parent device port")
|
|
||||||
devIcon = String(description="Base64-encoded HTML/SVG markup used to render the device icon")
|
|
||||||
devGUID = String(description="Unique device GUID")
|
|
||||||
devSite = String(description="Site name")
|
|
||||||
devSSID = String(description="SSID connected to")
|
|
||||||
devSyncHubNode = String(description="Sync hub node name")
|
|
||||||
devSourcePlugin = String(description="Plugin that discovered the device")
|
|
||||||
devCustomProps = String(description="Base64-encoded custom properties in JSON format")
|
|
||||||
devStatus = String(description="Online/Offline status")
|
|
||||||
devIsRandomMac = Int(description="Calculated: Is MAC address randomized?")
|
|
||||||
devParentChildrenCount = Int(description="Calculated: Number of children attached to this parent")
|
|
||||||
devIpLong = String(description="Calculated: IP address in long format (returned as string to support the full unsigned 32-bit range)")
|
|
||||||
devFilterStatus = String(description="Calculated: Device status for UI filtering")
|
|
||||||
devFQDN = String(description="Fully Qualified Domain Name")
|
|
||||||
devParentRelType = String(description="Relationship type to parent")
|
|
||||||
devReqNicsOnline = Int(description="Required NICs online flag")
|
|
||||||
devMacSource = String(description="Source tracking for devMac (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devNameSource = String(description="Source tracking for devName (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devFQDNSource = String(description="Source tracking for devFQDN (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devLastIPSource = String(description="Source tracking for devLastIP (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devVendorSource = String(description="Source tracking for devVendor (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devSSIDSource = String(description="Source tracking for devSSID (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devParentMACSource = String(description="Source tracking for devParentMAC (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devParentPortSource = String(description="Source tracking for devParentPort (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devParentRelTypeSource = String(description="Source tracking for devParentRelType (USER, LOCKED, NEWDEV, or plugin prefix)")
|
|
||||||
devVlanSource = String(description="Source tracking for devVlan")
|
|
||||||
devFlapping = Int(description="Indicates flapping device (device changing between online/offline states frequently)")
|
|
||||||
devCanSleep = Int(description="Can this device sleep? (0 or 1). When enabled, offline periods within NTFPRCS_sleep_time are reported as Sleeping instead of Down.")
|
|
||||||
devIsSleeping = Int(description="Computed: Is device currently in a sleep window? (0 or 1)")
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceResult(ObjectType):
|
|
||||||
devices = List(Device)
|
|
||||||
count = Int()
|
|
||||||
db_count = Int(description="Total device count in the database, before any status/filter/search is applied")
|
|
||||||
|
|
||||||
|
|
||||||
# --- SETTINGS ---
|
|
||||||
|
|
||||||
|
|
||||||
# Setting ObjectType
|
|
||||||
class Setting(ObjectType):
|
|
||||||
setKey = String(description="Unique configuration key")
|
|
||||||
setName = String(description="Human-readable setting name")
|
|
||||||
setDescription = String(description="Detailed description of the setting")
|
|
||||||
setType = String(description="Config-driven type definition used to determine value type and UI rendering")
|
|
||||||
setOptions = String(description="JSON string of available options")
|
|
||||||
setGroup = String(description="UI group for categorization")
|
|
||||||
setValue = String(description="Current value")
|
|
||||||
setEvents = String(description="JSON string of events")
|
|
||||||
setOverriddenByEnv = Boolean(description="Whether the value is currently overridden by an environment variable")
|
|
||||||
|
|
||||||
|
|
||||||
class SettingResult(ObjectType):
|
|
||||||
settings = List(Setting, description="List of setting objects")
|
|
||||||
count = Int(description="Total count of settings")
|
|
||||||
|
|
||||||
# --- LANGSTRINGS ---
|
|
||||||
|
|
||||||
|
|
||||||
# In-memory cache for lang strings
|
# In-memory cache for lang strings
|
||||||
_langstrings_cache = {} # caches lists per file (core JSON or plugin)
|
_langstrings_cache = {} # caches lists per file (core JSON or plugin)
|
||||||
_langstrings_cache_mtime = {} # tracks last modified times
|
_langstrings_cache_mtime = {} # tracks last modified times
|
||||||
|
|
||||||
|
|
||||||
# LangString ObjectType
|
|
||||||
class LangString(ObjectType):
|
|
||||||
langCode = String(description="Language code (e.g., en_us, de_de)")
|
|
||||||
langStringKey = String(description="Unique translation key")
|
|
||||||
langStringText = String(description="Translated text content")
|
|
||||||
|
|
||||||
|
|
||||||
class LangStringResult(ObjectType):
|
|
||||||
langStrings = List(LangString, description="List of language string objects")
|
|
||||||
count = Int(description="Total count of strings")
|
|
||||||
|
|
||||||
|
|
||||||
# --- APP EVENTS ---
|
|
||||||
|
|
||||||
class AppEvent(ObjectType):
|
|
||||||
index = Int(description="Internal index")
|
|
||||||
guid = String(description="Unique event GUID")
|
|
||||||
appEventProcessed = Int(description="Processing status (0 or 1)")
|
|
||||||
dateTimeCreated = String(description="Event creation timestamp")
|
|
||||||
|
|
||||||
objectType = String(description="Type of the related object (Device, Setting, etc.)")
|
|
||||||
objectGuid = String(description="GUID of the related object")
|
|
||||||
objectPlugin = String(description="Plugin associated with the object")
|
|
||||||
objectPrimaryId = String(description="Primary identifier of the object")
|
|
||||||
objectSecondaryId = String(description="Secondary identifier of the object")
|
|
||||||
objectForeignKey = String(description="Foreign key reference")
|
|
||||||
objectIndex = Int(description="Object index")
|
|
||||||
|
|
||||||
objectIsNew = Int(description="Is the object new? (0 or 1)")
|
|
||||||
objectIsArchived = Int(description="Is the object archived? (0 or 1)")
|
|
||||||
objectStatusColumn = String(description="Column used for status")
|
|
||||||
objectStatus = String(description="Object status value")
|
|
||||||
|
|
||||||
appEventType = String(description="Type of application event")
|
|
||||||
|
|
||||||
helper1 = String(description="Generic helper field 1")
|
|
||||||
helper2 = String(description="Generic helper field 2")
|
|
||||||
helper3 = String(description="Generic helper field 3")
|
|
||||||
extra = String(description="Additional JSON data")
|
|
||||||
|
|
||||||
|
|
||||||
class AppEventResult(ObjectType):
|
|
||||||
appEvents = List(AppEvent, description="List of application events")
|
|
||||||
count = Int(description="Total count of events")
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Define Query Type with Pagination Support
|
|
||||||
class Query(ObjectType):
|
class Query(ObjectType):
|
||||||
# --- DEVICES ---
|
# --- DEVICES ---
|
||||||
devices = Field(DeviceResult, options=PageQueryOptionsInput())
|
devices = Field(DeviceResult, options=PageQueryOptionsInput())
|
||||||
@@ -652,15 +505,75 @@ class Query(ObjectType):
|
|||||||
|
|
||||||
return LangStringResult(langStrings=langStrings, count=len(langStrings))
|
return LangStringResult(langStrings=langStrings, count=len(langStrings))
|
||||||
|
|
||||||
|
# --- PLUGINS_OBJECTS ---
|
||||||
|
pluginsObjects = Field(PluginsObjectsResult, options=PluginQueryOptionsInput())
|
||||||
|
|
||||||
# helps sorting inconsistent dataset mixed integers and strings
|
def resolve_pluginsObjects(self, info, options=None):
|
||||||
def mixed_type_sort_key(value):
|
return _resolve_plugin_table("table_plugins_objects.json", options, PluginsObjectsResult)
|
||||||
if value is None or value == "":
|
|
||||||
return (2, "") # Place None or empty strings last
|
# --- PLUGINS_EVENTS ---
|
||||||
|
pluginsEvents = Field(PluginsEventsResult, options=PluginQueryOptionsInput())
|
||||||
|
|
||||||
|
def resolve_pluginsEvents(self, info, options=None):
|
||||||
|
return _resolve_plugin_table("table_plugins_events.json", options, PluginsEventsResult)
|
||||||
|
|
||||||
|
# --- PLUGINS_HISTORY ---
|
||||||
|
pluginsHistory = Field(PluginsHistoryResult, options=PluginQueryOptionsInput())
|
||||||
|
|
||||||
|
def resolve_pluginsHistory(self, info, options=None):
|
||||||
|
return _resolve_plugin_table("table_plugins_history.json", options, PluginsHistoryResult)
|
||||||
|
|
||||||
|
# --- EVENTS ---
|
||||||
|
events = Field(EventsResult, options=EventQueryOptionsInput())
|
||||||
|
|
||||||
|
def resolve_events(self, info, options=None):
|
||||||
|
try:
|
||||||
|
with open(folder + "table_events.json", "r") as f:
|
||||||
|
data = json.load(f).get("data", [])
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
|
mylog("none", f"[graphql_schema] Error loading events data: {e}")
|
||||||
|
return EventsResult(entries=[], count=0, db_count=0)
|
||||||
|
|
||||||
|
db_count = len(data)
|
||||||
|
data = apply_events_filters(data, options)
|
||||||
|
data, total_count = apply_common_pagination(data, options)
|
||||||
|
return EventsResult(
|
||||||
|
entries=[EventEntry(**r) for r in data],
|
||||||
|
count=total_count,
|
||||||
|
db_count=db_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Private resolver helper — shared by all three plugin table resolvers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_plugin_table(json_file, options, ResultType):
|
||||||
try:
|
try:
|
||||||
return (0, int(value)) # Integers get priority
|
with open(folder + json_file, "r") as f:
|
||||||
except (ValueError, TypeError):
|
data = json.load(f).get("data", [])
|
||||||
return (1, str(value)) # Strings come next
|
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||||
|
mylog("none", f"[graphql_schema] Error loading {json_file}: {e}")
|
||||||
|
return ResultType(entries=[], count=0, db_count=0)
|
||||||
|
|
||||||
|
# Scope to the requested plugin + foreignKey FIRST so db_count
|
||||||
|
# reflects the total for THIS plugin, not the entire table.
|
||||||
|
if options:
|
||||||
|
if options.plugin:
|
||||||
|
pl = options.plugin.lower()
|
||||||
|
data = [r for r in data if str(r.get("plugin", "")).lower() == pl]
|
||||||
|
if options.foreignKey:
|
||||||
|
fk = options.foreignKey.lower()
|
||||||
|
data = [r for r in data if str(r.get("foreignKey", "")).lower() == fk]
|
||||||
|
|
||||||
|
db_count = len(data)
|
||||||
|
data = apply_plugin_filters(data, options)
|
||||||
|
data, total_count = apply_common_pagination(data, options)
|
||||||
|
return ResultType(
|
||||||
|
entries=[PluginEntry(**r) for r in data],
|
||||||
|
count=total_count,
|
||||||
|
db_count=db_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Schema Definition
|
# Schema Definition
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
graphql_helpers.py — Shared utility functions for GraphQL resolvers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MAX_LIMIT = 1000
|
||||||
|
_DEFAULT_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
|
def mixed_type_sort_key(value):
|
||||||
|
"""Sort key that handles mixed int/string datasets without crashing.
|
||||||
|
|
||||||
|
Ordering priority:
|
||||||
|
0 — integers (sorted numerically)
|
||||||
|
1 — strings (sorted lexicographically)
|
||||||
|
2 — None / empty string (always last)
|
||||||
|
"""
|
||||||
|
if value is None or value == "":
|
||||||
|
return (2, "")
|
||||||
|
try:
|
||||||
|
return (0, int(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return (1, str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_common_pagination(data, options):
|
||||||
|
"""Apply sort + capture total_count + paginate.
|
||||||
|
|
||||||
|
Returns (paged_data, total_count).
|
||||||
|
Enforces a hard limit cap of _MAX_LIMIT — never returns unbounded results.
|
||||||
|
"""
|
||||||
|
if not options:
|
||||||
|
return data, len(data)
|
||||||
|
|
||||||
|
# --- SORT ---
|
||||||
|
if options.sort:
|
||||||
|
for sort_option in reversed(options.sort):
|
||||||
|
field = sort_option.field
|
||||||
|
reverse = (sort_option.order or "asc").lower() == "desc"
|
||||||
|
data = sorted(
|
||||||
|
data,
|
||||||
|
key=lambda x: mixed_type_sort_key(x.get(field)),
|
||||||
|
reverse=reverse,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_count = len(data)
|
||||||
|
|
||||||
|
# --- PAGINATE ---
|
||||||
|
if options.page is not None and options.limit is not None:
|
||||||
|
effective_limit = min(options.limit, _MAX_LIMIT)
|
||||||
|
page = max(1, options.page)
|
||||||
|
start = (page - 1) * effective_limit
|
||||||
|
end = start + effective_limit
|
||||||
|
data = data[start:end]
|
||||||
|
|
||||||
|
return data, total_count
|
||||||
|
|
||||||
|
|
||||||
|
def apply_plugin_filters(data, options):
|
||||||
|
"""Filter a list of plugin table rows (Plugins_Objects/Events/History).
|
||||||
|
|
||||||
|
Handles: date range, column filters, free-text search.
|
||||||
|
NOTE: plugin prefix and foreignKey scoping is done in the resolver
|
||||||
|
BEFORE db_count is captured — do NOT duplicate here.
|
||||||
|
"""
|
||||||
|
if not options:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Date-range filter on dateTimeCreated
|
||||||
|
if options.dateFrom:
|
||||||
|
data = [r for r in data if str(r.get("dateTimeCreated", "")) >= options.dateFrom]
|
||||||
|
if options.dateTo:
|
||||||
|
data = [r for r in data if str(r.get("dateTimeCreated", "")) <= options.dateTo]
|
||||||
|
|
||||||
|
# Column-value exact-match filters
|
||||||
|
if options.filters:
|
||||||
|
for f in options.filters:
|
||||||
|
if f.filterColumn and f.filterValue is not None:
|
||||||
|
data = [
|
||||||
|
r for r in data
|
||||||
|
if str(r.get(f.filterColumn, "")).lower() == str(f.filterValue).lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if options.search:
|
||||||
|
term = options.search.lower()
|
||||||
|
searchable = [
|
||||||
|
"plugin", "objectPrimaryId", "objectSecondaryId",
|
||||||
|
"watchedValue1", "watchedValue2", "watchedValue3", "watchedValue4",
|
||||||
|
"status", "extra", "foreignKey", "objectGuid", "userData",
|
||||||
|
]
|
||||||
|
data = [
|
||||||
|
r for r in data
|
||||||
|
if any(term in str(r.get(field, "")).lower() for field in searchable)
|
||||||
|
]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def apply_events_filters(data, options):
|
||||||
|
"""Filter a list of Events table rows.
|
||||||
|
|
||||||
|
Handles: eveMac, eventType, date range, column filters, free-text search.
|
||||||
|
"""
|
||||||
|
if not options:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# MAC filter
|
||||||
|
if options.eveMac:
|
||||||
|
mac = options.eveMac.lower()
|
||||||
|
data = [r for r in data if str(r.get("eveMac", "")).lower() == mac]
|
||||||
|
|
||||||
|
# Event-type filter
|
||||||
|
if options.eventType:
|
||||||
|
et = options.eventType.lower()
|
||||||
|
data = [r for r in data if str(r.get("eveEventType", "")).lower() == et]
|
||||||
|
|
||||||
|
# Date-range filter on eveDateTime
|
||||||
|
if options.dateFrom:
|
||||||
|
data = [r for r in data if str(r.get("eveDateTime", "")) >= options.dateFrom]
|
||||||
|
if options.dateTo:
|
||||||
|
data = [r for r in data if str(r.get("eveDateTime", "")) <= options.dateTo]
|
||||||
|
|
||||||
|
# Column-value exact-match filters
|
||||||
|
if options.filters:
|
||||||
|
for f in options.filters:
|
||||||
|
if f.filterColumn and f.filterValue is not None:
|
||||||
|
data = [
|
||||||
|
r for r in data
|
||||||
|
if str(r.get(f.filterColumn, "")).lower() == str(f.filterValue).lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Free-text search
|
||||||
|
if options.search:
|
||||||
|
term = options.search.lower()
|
||||||
|
searchable = ["eveMac", "eveIp", "eveEventType", "eveAdditionalInfo"]
|
||||||
|
data = [
|
||||||
|
r for r in data
|
||||||
|
if any(term in str(r.get(field, "")).lower() for field in searchable)
|
||||||
|
]
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import graphene # noqa: F401 (re-exported for schema creation in graphql_endpoint.py)
|
||||||
|
from graphene import (
|
||||||
|
ObjectType, String, Int, Boolean, List, InputObjectType,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared Input Types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SortOptionsInput(InputObjectType):
|
||||||
|
field = String()
|
||||||
|
order = String()
|
||||||
|
|
||||||
|
|
||||||
|
class FilterOptionsInput(InputObjectType):
|
||||||
|
filterColumn = String()
|
||||||
|
filterValue = String()
|
||||||
|
|
||||||
|
|
||||||
|
class PageQueryOptionsInput(InputObjectType):
|
||||||
|
page = Int()
|
||||||
|
limit = Int()
|
||||||
|
sort = List(SortOptionsInput)
|
||||||
|
search = String()
|
||||||
|
status = String()
|
||||||
|
filters = List(FilterOptionsInput)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Devices
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Device(ObjectType):
|
||||||
|
rowid = Int(description="Database row ID")
|
||||||
|
devMac = String(description="Device MAC address (e.g., 00:11:22:33:44:55)")
|
||||||
|
devName = String(description="Device display name/alias")
|
||||||
|
devOwner = String(description="Device owner")
|
||||||
|
devType = String(description="Device type classification")
|
||||||
|
devVendor = String(description="Hardware vendor from OUI lookup")
|
||||||
|
devFavorite = Int(description="Favorite flag (0 or 1)")
|
||||||
|
devGroup = String(description="Device group")
|
||||||
|
devComments = String(description="User comments")
|
||||||
|
devFirstConnection = String(description="Timestamp of first discovery")
|
||||||
|
devLastConnection = String(description="Timestamp of last connection")
|
||||||
|
devLastIP = String(description="Last known IP address")
|
||||||
|
devPrimaryIPv4 = String(description="Primary IPv4 address")
|
||||||
|
devPrimaryIPv6 = String(description="Primary IPv6 address")
|
||||||
|
devVlan = String(description="VLAN identifier")
|
||||||
|
devForceStatus = String(description="Force device status (online/offline/dont_force)")
|
||||||
|
devStaticIP = Int(description="Static IP flag (0 or 1)")
|
||||||
|
devScan = Int(description="Scan flag (0 or 1)")
|
||||||
|
devLogEvents = Int(description="Log events flag (0 or 1)")
|
||||||
|
devAlertEvents = Int(description="Alert events flag (0 or 1)")
|
||||||
|
devAlertDown = Int(description="Alert on down flag (0 or 1)")
|
||||||
|
devSkipRepeated = Int(description="Skip repeated alerts flag (0 or 1)")
|
||||||
|
devLastNotification = String(description="Timestamp of last notification")
|
||||||
|
devPresentLastScan = Int(description="Present in last scan flag (0 or 1)")
|
||||||
|
devIsNew = Int(description="Is new device flag (0 or 1)")
|
||||||
|
devLocation = String(description="Device location")
|
||||||
|
devIsArchived = Int(description="Is archived flag (0 or 1)")
|
||||||
|
devParentMAC = String(description="Parent device MAC address")
|
||||||
|
devParentPort = String(description="Parent device port")
|
||||||
|
devIcon = String(description="Base64-encoded HTML/SVG markup used to render the device icon")
|
||||||
|
devGUID = String(description="Unique device GUID")
|
||||||
|
devSite = String(description="Site name")
|
||||||
|
devSSID = String(description="SSID connected to")
|
||||||
|
devSyncHubNode = String(description="Sync hub node name")
|
||||||
|
devSourcePlugin = String(description="Plugin that discovered the device")
|
||||||
|
devCustomProps = String(description="Base64-encoded custom properties in JSON format")
|
||||||
|
devStatus = String(description="Online/Offline status")
|
||||||
|
devIsRandomMac = Int(description="Calculated: Is MAC address randomized?")
|
||||||
|
devParentChildrenCount = Int(description="Calculated: Number of children attached to this parent")
|
||||||
|
devIpLong = String(description="Calculated: IP address in long format (returned as string to support the full unsigned 32-bit range)")
|
||||||
|
devFilterStatus = String(description="Calculated: Device status for UI filtering")
|
||||||
|
devFQDN = String(description="Fully Qualified Domain Name")
|
||||||
|
devParentRelType = String(description="Relationship type to parent")
|
||||||
|
devReqNicsOnline = Int(description="Required NICs online flag")
|
||||||
|
devMacSource = String(description="Source tracking for devMac (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devNameSource = String(description="Source tracking for devName (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devFQDNSource = String(description="Source tracking for devFQDN (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devLastIPSource = String(description="Source tracking for devLastIP (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devVendorSource = String(description="Source tracking for devVendor (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devSSIDSource = String(description="Source tracking for devSSID (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devParentMACSource = String(description="Source tracking for devParentMAC (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devParentPortSource = String(description="Source tracking for devParentPort (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devParentRelTypeSource = String(description="Source tracking for devParentRelType (USER, LOCKED, NEWDEV, or plugin prefix)")
|
||||||
|
devVlanSource = String(description="Source tracking for devVlan")
|
||||||
|
devFlapping = Int(description="Indicates flapping device (device changing between online/offline states frequently)")
|
||||||
|
devCanSleep = Int(description="Can this device sleep? (0 or 1). When enabled, offline periods within NTFPRCS_sleep_time are reported as Sleeping instead of Down.")
|
||||||
|
devIsSleeping = Int(description="Computed: Is device currently in a sleep window? (0 or 1)")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceResult(ObjectType):
|
||||||
|
devices = List(Device)
|
||||||
|
count = Int()
|
||||||
|
db_count = Int(description="Total device count in the database, before any status/filter/search is applied")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Setting(ObjectType):
|
||||||
|
setKey = String(description="Unique configuration key")
|
||||||
|
setName = String(description="Human-readable setting name")
|
||||||
|
setDescription = String(description="Detailed description of the setting")
|
||||||
|
setType = String(description="Config-driven type definition used to determine value type and UI rendering")
|
||||||
|
setOptions = String(description="JSON string of available options")
|
||||||
|
setGroup = String(description="UI group for categorization")
|
||||||
|
setValue = String(description="Current value")
|
||||||
|
setEvents = String(description="JSON string of events")
|
||||||
|
setOverriddenByEnv = Boolean(description="Whether the value is currently overridden by an environment variable")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingResult(ObjectType):
|
||||||
|
settings = List(Setting, description="List of setting objects")
|
||||||
|
count = Int(description="Total count of settings")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Language Strings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LangString(ObjectType):
|
||||||
|
langCode = String(description="Language code (e.g., en_us, de_de)")
|
||||||
|
langStringKey = String(description="Unique translation key")
|
||||||
|
langStringText = String(description="Translated text content")
|
||||||
|
|
||||||
|
|
||||||
|
class LangStringResult(ObjectType):
|
||||||
|
langStrings = List(LangString, description="List of language string objects")
|
||||||
|
count = Int(description="Total count of strings")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App Events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AppEvent(ObjectType):
|
||||||
|
index = Int(description="Internal index")
|
||||||
|
guid = String(description="Unique event GUID")
|
||||||
|
appEventProcessed = Int(description="Processing status (0 or 1)")
|
||||||
|
dateTimeCreated = String(description="Event creation timestamp")
|
||||||
|
|
||||||
|
objectType = String(description="Type of the related object (Device, Setting, etc.)")
|
||||||
|
objectGuid = String(description="GUID of the related object")
|
||||||
|
objectPlugin = String(description="Plugin associated with the object")
|
||||||
|
objectPrimaryId = String(description="Primary identifier of the object")
|
||||||
|
objectSecondaryId = String(description="Secondary identifier of the object")
|
||||||
|
objectForeignKey = String(description="Foreign key reference")
|
||||||
|
objectIndex = Int(description="Object index")
|
||||||
|
|
||||||
|
objectIsNew = Int(description="Is the object new? (0 or 1)")
|
||||||
|
objectIsArchived = Int(description="Is the object archived? (0 or 1)")
|
||||||
|
objectStatusColumn = String(description="Column used for status")
|
||||||
|
objectStatus = String(description="Object status value")
|
||||||
|
|
||||||
|
appEventType = String(description="Type of application event")
|
||||||
|
|
||||||
|
helper1 = String(description="Generic helper field 1")
|
||||||
|
helper2 = String(description="Generic helper field 2")
|
||||||
|
helper3 = String(description="Generic helper field 3")
|
||||||
|
extra = String(description="Additional JSON data")
|
||||||
|
|
||||||
|
|
||||||
|
class AppEventResult(ObjectType):
|
||||||
|
appEvents = List(AppEvent, description="List of application events")
|
||||||
|
count = Int(description="Total count of events")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Plugin tables (Plugins_Objects, Plugins_Events, Plugins_History)
|
||||||
|
# All three tables share the same schema — one ObjectType, three result wrappers.
|
||||||
|
# GraphQL requires distinct named types even when fields are identical.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PluginQueryOptionsInput(InputObjectType):
|
||||||
|
page = Int()
|
||||||
|
limit = Int()
|
||||||
|
sort = List(SortOptionsInput)
|
||||||
|
search = String()
|
||||||
|
filters = List(FilterOptionsInput)
|
||||||
|
plugin = String(description="Filter by plugin prefix (e.g. 'ARPSCAN')")
|
||||||
|
foreignKey = String(description="Filter by foreignKey (e.g. device MAC)")
|
||||||
|
dateFrom = String(description="dateTimeCreated >= dateFrom (ISO datetime string)")
|
||||||
|
dateTo = String(description="dateTimeCreated <= dateTo (ISO datetime string)")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginEntry(ObjectType):
|
||||||
|
index = Int(description="Auto-increment primary key")
|
||||||
|
plugin = String(description="Plugin prefix identifier")
|
||||||
|
objectPrimaryId = String(description="Primary identifier (e.g. MAC, IP)")
|
||||||
|
objectSecondaryId = String(description="Secondary identifier")
|
||||||
|
dateTimeCreated = String(description="Record creation timestamp")
|
||||||
|
dateTimeChanged = String(description="Record last-changed timestamp")
|
||||||
|
watchedValue1 = String(description="Monitored value 1")
|
||||||
|
watchedValue2 = String(description="Monitored value 2")
|
||||||
|
watchedValue3 = String(description="Monitored value 3")
|
||||||
|
watchedValue4 = String(description="Monitored value 4")
|
||||||
|
status = String(description="Record status")
|
||||||
|
extra = String(description="Extra JSON payload")
|
||||||
|
userData = String(description="User-supplied data")
|
||||||
|
foreignKey = String(description="Foreign key (e.g. device MAC)")
|
||||||
|
syncHubNodeName = String(description="Sync hub node name")
|
||||||
|
helpVal1 = String(description="Helper value 1")
|
||||||
|
helpVal2 = String(description="Helper value 2")
|
||||||
|
helpVal3 = String(description="Helper value 3")
|
||||||
|
helpVal4 = String(description="Helper value 4")
|
||||||
|
objectGuid = String(description="Object GUID")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsObjectsResult(ObjectType):
|
||||||
|
entries = List(PluginEntry, description="Plugins_Objects rows")
|
||||||
|
count = Int(description="Filtered count (before pagination)")
|
||||||
|
db_count = Int(description="Total rows in table before any filter")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsEventsResult(ObjectType):
|
||||||
|
entries = List(PluginEntry, description="Plugins_Events rows")
|
||||||
|
count = Int(description="Filtered count (before pagination)")
|
||||||
|
db_count = Int(description="Total rows in table before any filter")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginsHistoryResult(ObjectType):
|
||||||
|
entries = List(PluginEntry, description="Plugins_History rows")
|
||||||
|
count = Int(description="Filtered count (before pagination)")
|
||||||
|
db_count = Int(description="Total rows in table before any filter")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Events table (device presence events)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EventQueryOptionsInput(InputObjectType):
|
||||||
|
page = Int()
|
||||||
|
limit = Int()
|
||||||
|
sort = List(SortOptionsInput)
|
||||||
|
search = String()
|
||||||
|
filters = List(FilterOptionsInput)
|
||||||
|
eveMac = String(description="Filter by device MAC address")
|
||||||
|
eventType = String(description="Filter by eveEventType (exact match)")
|
||||||
|
dateFrom = String(description="eveDateTime >= dateFrom (ISO datetime string)")
|
||||||
|
dateTo = String(description="eveDateTime <= dateTo (ISO datetime string)")
|
||||||
|
|
||||||
|
|
||||||
|
class EventEntry(ObjectType):
|
||||||
|
rowid = Int(description="SQLite rowid")
|
||||||
|
eveMac = String(description="Device MAC address")
|
||||||
|
eveIp = String(description="Device IP at event time")
|
||||||
|
eveDateTime = String(description="Event timestamp")
|
||||||
|
eveEventType = String(description="Event type (Connected, New Device, etc.)")
|
||||||
|
eveAdditionalInfo = String(description="Additional event info")
|
||||||
|
evePendingAlertEmail = Int(description="Pending alert flag (0 or 1)")
|
||||||
|
evePairEventRowid = Int(description="Paired event rowid (for session pairing)")
|
||||||
|
|
||||||
|
|
||||||
|
class EventsResult(ObjectType):
|
||||||
|
entries = List(EventEntry, description="Events table rows")
|
||||||
|
count = Int(description="Filtered count (before pagination)")
|
||||||
|
db_count = Int(description="Total rows in table before any filter")
|
||||||
@@ -295,10 +295,16 @@ def get_device_sessions(mac, period):
|
|||||||
return jsonify({"success": True, "sessions": sessions})
|
return jsonify({"success": True, "sessions": sessions})
|
||||||
|
|
||||||
|
|
||||||
def get_session_events(event_type, period_date):
|
def get_session_events(event_type, period_date, page=1, limit=100, search=None, sort_col=0, sort_dir="desc"):
|
||||||
"""
|
"""
|
||||||
Fetch events or sessions based on type and period.
|
Fetch events or sessions based on type and period.
|
||||||
|
Supports server-side pagination (page/limit), free-text search, and sorting.
|
||||||
|
Returns { data, total, recordsFiltered } so callers can drive DataTables serverSide mode.
|
||||||
"""
|
"""
|
||||||
|
_MAX_LIMIT = 1000
|
||||||
|
limit = min(max(1, int(limit)), _MAX_LIMIT)
|
||||||
|
page = max(1, int(page))
|
||||||
|
|
||||||
conn = get_temp_db_connection()
|
conn = get_temp_db_connection()
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -420,4 +426,30 @@ def get_session_events(event_type, period_date):
|
|||||||
|
|
||||||
table_data["data"].append(row)
|
table_data["data"].append(row)
|
||||||
|
|
||||||
return jsonify(table_data)
|
all_rows = table_data["data"]
|
||||||
|
|
||||||
|
# --- Sorting ---
|
||||||
|
num_cols = len(all_rows[0]) if all_rows else 0
|
||||||
|
if 0 <= sort_col < num_cols:
|
||||||
|
reverse = sort_dir.lower() == "desc"
|
||||||
|
all_rows.sort(
|
||||||
|
key=lambda r: (r[sort_col] is None, r[sort_col] if r[sort_col] is not None else ""),
|
||||||
|
reverse=reverse,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(all_rows)
|
||||||
|
|
||||||
|
# --- Free-text search (applied after formatting so display values are searchable) ---
|
||||||
|
if search:
|
||||||
|
search_lower = search.strip().lower()
|
||||||
|
|
||||||
|
def _row_matches(r):
|
||||||
|
return any(search_lower in str(v).lower() for v in r if v is not None)
|
||||||
|
all_rows = [r for r in all_rows if _row_matches(r)]
|
||||||
|
records_filtered = len(all_rows)
|
||||||
|
|
||||||
|
# --- Pagination ---
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
paged_rows = all_rows[offset: offset + limit]
|
||||||
|
|
||||||
|
return jsonify({"data": paged_rows, "total": total, "recordsFiltered": records_filtered})
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ sql_devices_stats = f"""
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
sql_events_pending_alert = "SELECT * FROM Events where evePendingAlertEmail is not 0"
|
sql_events_pending_alert = "SELECT * FROM Events where evePendingAlertEmail is not 0"
|
||||||
|
sql_events_all = "SELECT rowid, * FROM Events ORDER BY eveDateTime DESC"
|
||||||
sql_settings = "SELECT * FROM Settings"
|
sql_settings = "SELECT * FROM Settings"
|
||||||
sql_plugins_objects = "SELECT * FROM Plugins_Objects"
|
sql_plugins_objects = "SELECT * FROM Plugins_Objects"
|
||||||
sql_language_strings = "SELECT * FROM Plugins_Language_Strings"
|
sql_language_strings = "SELECT * FROM Plugins_Language_Strings"
|
||||||
|
|||||||
@@ -192,3 +192,242 @@ def test_graphql_langstrings_excludes_languages_json(client, api_token):
|
|||||||
f"languages.json leaked into langStrings as {len(polluted)} entries; "
|
f"languages.json leaked into langStrings as {len(polluted)} entries; "
|
||||||
"graphql_endpoint.py must exclude it from the directory scan"
|
"graphql_endpoint.py must exclude it from the directory scan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- PLUGINS_OBJECTS TESTS ---
|
||||||
|
|
||||||
|
def test_graphql_plugins_objects_no_options(client, api_token):
|
||||||
|
"""pluginsObjects without options returns valid schema (entries list + count fields)"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
{
|
||||||
|
pluginsObjects {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries {
|
||||||
|
index
|
||||||
|
plugin
|
||||||
|
objectPrimaryId
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
result = body["data"]["pluginsObjects"]
|
||||||
|
assert isinstance(result["entries"], list)
|
||||||
|
assert isinstance(result["dbCount"], int)
|
||||||
|
assert isinstance(result["count"], int)
|
||||||
|
assert result["dbCount"] >= result["count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_plugins_objects_pagination(client, api_token):
|
||||||
|
"""pluginsObjects with limit=5 returns at most 5 entries and count reflects filter total"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
query PluginsObjectsPaged($options: PluginQueryOptionsInput) {
|
||||||
|
pluginsObjects(options: $options) {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries { index plugin }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"options": {"page": 1, "limit": 5}}
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
result = body["data"]["pluginsObjects"]
|
||||||
|
assert len(result["entries"]) <= 5
|
||||||
|
assert result["count"] >= len(result["entries"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_plugins_events_no_options(client, api_token):
|
||||||
|
"""pluginsEvents without options returns valid schema"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
{
|
||||||
|
pluginsEvents {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries { index plugin objectPrimaryId dateTimeCreated }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
result = body["data"]["pluginsEvents"]
|
||||||
|
assert isinstance(result["entries"], list)
|
||||||
|
assert isinstance(result["count"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_plugins_history_no_options(client, api_token):
|
||||||
|
"""pluginsHistory without options returns valid schema"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
{
|
||||||
|
pluginsHistory {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries { index plugin watchedValue1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
result = body["data"]["pluginsHistory"]
|
||||||
|
assert isinstance(result["entries"], list)
|
||||||
|
assert isinstance(result["count"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_plugins_hard_cap(client, api_token):
|
||||||
|
"""limit=99999 is clamped server-side to at most 1000 entries"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
query PluginsHardCap($options: PluginQueryOptionsInput) {
|
||||||
|
pluginsObjects(options: $options) {
|
||||||
|
count
|
||||||
|
entries { index }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"options": {"page": 1, "limit": 99999}}
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
entries = body["data"]["pluginsObjects"]["entries"]
|
||||||
|
assert len(entries) <= 1000, f"Hard cap violated: got {len(entries)} entries"
|
||||||
|
|
||||||
|
|
||||||
|
# --- EVENTS TESTS ---
|
||||||
|
|
||||||
|
def test_graphql_events_no_options(client, api_token):
|
||||||
|
"""events without options returns valid schema (entries list + count fields)"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
{
|
||||||
|
events {
|
||||||
|
dbCount
|
||||||
|
count
|
||||||
|
entries {
|
||||||
|
eveMac
|
||||||
|
eveIp
|
||||||
|
eveDateTime
|
||||||
|
eveEventType
|
||||||
|
eveAdditionalInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
result = body["data"]["events"]
|
||||||
|
assert isinstance(result["entries"], list)
|
||||||
|
assert isinstance(result["count"], int)
|
||||||
|
assert isinstance(result["dbCount"], int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_events_filter_by_mac(client, api_token):
|
||||||
|
"""events filtered by eveMac='00:00:00:00:00:00' returns only that MAC (or empty)"""
|
||||||
|
query = {
|
||||||
|
"query": """
|
||||||
|
query EventsByMac($options: EventQueryOptionsInput) {
|
||||||
|
events(options: $options) {
|
||||||
|
count
|
||||||
|
entries { eveMac eveEventType eveDateTime }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"options": {"eveMac": "00:00:00:00:00:00", "limit": 50}}
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert "errors" not in body
|
||||||
|
result = body["data"]["events"]
|
||||||
|
for entry in result["entries"]:
|
||||||
|
assert entry["eveMac"].upper() == "00:00:00:00:00:00", (
|
||||||
|
f"MAC filter leaked a non-matching row: {entry['eveMac']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- PLUGIN FILTER SCOPING TESTS ---
|
||||||
|
|
||||||
|
def test_graphql_plugins_objects_dbcount_scoped_to_plugin(client, api_token):
|
||||||
|
"""dbCount should reflect only the rows for the requested plugin, not the entire table."""
|
||||||
|
# First, get the unscoped total
|
||||||
|
query_all = {
|
||||||
|
"query": "{ pluginsObjects { dbCount count } }"
|
||||||
|
}
|
||||||
|
resp_all = client.post("/graphql", json=query_all, headers=auth_headers(api_token))
|
||||||
|
assert resp_all.status_code == 200
|
||||||
|
total_all = resp_all.get_json()["data"]["pluginsObjects"]["dbCount"]
|
||||||
|
|
||||||
|
# Now request a non-existent plugin — dbCount must be 0
|
||||||
|
query_fake = {
|
||||||
|
"query": """
|
||||||
|
query Scoped($options: PluginQueryOptionsInput) {
|
||||||
|
pluginsObjects(options: $options) { dbCount count entries { plugin } }
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"options": {"plugin": "NONEXISTENT_PLUGIN_XYZ"}}
|
||||||
|
}
|
||||||
|
resp_fake = client.post("/graphql", json=query_fake, headers=auth_headers(api_token))
|
||||||
|
assert resp_fake.status_code == 200
|
||||||
|
body_fake = resp_fake.get_json()
|
||||||
|
assert "errors" not in body_fake
|
||||||
|
result_fake = body_fake["data"]["pluginsObjects"]
|
||||||
|
assert result_fake["dbCount"] == 0, (
|
||||||
|
f"dbCount should be 0 for non-existent plugin, got {result_fake['dbCount']}"
|
||||||
|
)
|
||||||
|
assert result_fake["count"] == 0
|
||||||
|
assert result_fake["entries"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_graphql_plugins_objects_scoped_entries_match_plugin(client, api_token):
|
||||||
|
"""When filtering by plugin, all returned entries must belong to that plugin."""
|
||||||
|
# Get first available plugin prefix from the unscoped query
|
||||||
|
query_sample = {
|
||||||
|
"query": "{ pluginsObjects(options: {page: 1, limit: 1}) { entries { plugin } } }"
|
||||||
|
}
|
||||||
|
resp = client.post("/graphql", json=query_sample, headers=auth_headers(api_token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
entries = resp.get_json()["data"]["pluginsObjects"]["entries"]
|
||||||
|
if not entries:
|
||||||
|
pytest.skip("No plugin objects in database")
|
||||||
|
target = entries[0]["plugin"]
|
||||||
|
|
||||||
|
# Query scoped to that plugin
|
||||||
|
query_scoped = {
|
||||||
|
"query": """
|
||||||
|
query Scoped($options: PluginQueryOptionsInput) {
|
||||||
|
pluginsObjects(options: $options) { dbCount count entries { plugin } }
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"options": {"plugin": target, "page": 1, "limit": 100}}
|
||||||
|
}
|
||||||
|
resp2 = client.post("/graphql", json=query_scoped, headers=auth_headers(api_token))
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
result = resp2.get_json()["data"]["pluginsObjects"]
|
||||||
|
assert result["dbCount"] > 0
|
||||||
|
for entry in result["entries"]:
|
||||||
|
assert entry["plugin"].upper() == target.upper(), (
|
||||||
|
f"Plugin filter leaked: expected {target}, got {entry['plugin']}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -160,6 +160,43 @@ def test_device_session_events(client, api_token, test_mac):
|
|||||||
assert isinstance(sessions, list)
|
assert isinstance(sessions, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_events_pagination(client, api_token):
|
||||||
|
"""session-events supports page, limit, and returns total/recordsFiltered."""
|
||||||
|
resp = client.get(
|
||||||
|
"/sessions/session-events?type=all&period=1 year&page=1&limit=5",
|
||||||
|
headers=auth_headers(api_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json
|
||||||
|
assert "data" in body
|
||||||
|
assert "total" in body
|
||||||
|
assert "recordsFiltered" in body
|
||||||
|
assert isinstance(body["total"], int)
|
||||||
|
assert len(body["data"]) <= 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_events_sorting(client, api_token):
|
||||||
|
"""session-events supports sortCol and sortDir without errors."""
|
||||||
|
resp_desc = client.get(
|
||||||
|
"/sessions/session-events?type=all&period=1 year&page=1&limit=10&sortCol=0&sortDir=desc",
|
||||||
|
headers=auth_headers(api_token),
|
||||||
|
)
|
||||||
|
assert resp_desc.status_code == 200
|
||||||
|
desc_data = resp_desc.json["data"]
|
||||||
|
|
||||||
|
resp_asc = client.get(
|
||||||
|
"/sessions/session-events?type=all&period=1 year&page=1&limit=10&sortCol=0&sortDir=asc",
|
||||||
|
headers=auth_headers(api_token),
|
||||||
|
)
|
||||||
|
assert resp_asc.status_code == 200
|
||||||
|
asc_data = resp_asc.json["data"]
|
||||||
|
|
||||||
|
# If there are at least 2 rows, order should differ (or be identical if all same)
|
||||||
|
if len(desc_data) >= 2 and len(asc_data) >= 2:
|
||||||
|
# First row of desc should >= first row of asc (column 0 is the order column)
|
||||||
|
assert desc_data[0][0] >= asc_data[0][0] or desc_data == asc_data
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
def test_delete_session(client, api_token, test_mac):
|
def test_delete_session(client, api_token, test_mac):
|
||||||
# First create session
|
# First create session
|
||||||
|
|||||||
Reference in New Issue
Block a user