mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-30 23:03:03 -07:00
feat(api): Enhance session events API with pagination, sorting, and filtering
- Added support for pagination (page and limit) in the session events endpoint. - Implemented sorting functionality based on specified columns and directions. - Introduced free-text search capability for session events. - Updated SQL queries to retrieve all events and added a new SQL constant for events. - Refactored GraphQL types and helpers to support new plugin and event queries. - Created new GraphQL resolvers for plugins and events with pagination and filtering. - Added comprehensive tests for new GraphQL endpoints and session events functionality.
This commit is contained in:
@@ -15,6 +15,21 @@ Before opening a new issue:
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
@@ -28,11 +43,19 @@ Please:
|
||||
- Provide a clear title and description for your PR
|
||||
- If relevant, add or update tests and documentation
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ GraphQL queries are **read-optimized for speed**. Data may be slightly out of da
|
||||
|
||||
* Devices
|
||||
* Settings
|
||||
* Events
|
||||
* PluginsObjects
|
||||
* PluginsHistory
|
||||
* PluginsEvents
|
||||
* Language Strings (LangStrings)
|
||||
|
||||
## 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
|
||||
|
||||
* 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.
|
||||
* 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 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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
Default: `all`
|
||||
* `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:**
|
||||
|
||||
```
|
||||
/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:**
|
||||
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
|
||||
|
||||
|
||||
@@ -32,51 +32,64 @@
|
||||
|
||||
function loadEventsData() {
|
||||
const hideConnections = $('#chkHideConnectionEvents')[0].checked;
|
||||
const hideConnectionsStr = hideConnections ? 'true' : 'false';
|
||||
|
||||
let period = $("#period").val();
|
||||
let { start, end } = getPeriodStartEnd(period);
|
||||
|
||||
const rawSql = `
|
||||
SELECT eveDateTime, eveEventType, eveIp, eveAdditionalInfo
|
||||
FROM Events
|
||||
WHERE eveMac = "${mac}"
|
||||
AND eveDateTime BETWEEN "${start}" AND "${end}"
|
||||
AND (
|
||||
(eveEventType NOT IN ("Connected", "Disconnected", "VOIDED - Connected", "VOIDED - Disconnected"))
|
||||
OR "${hideConnectionsStr}" = "false"
|
||||
)
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
const apiBase = getApiBase();
|
||||
const graphqlUrl = `${apiBase}/graphql`;
|
||||
|
||||
const query = `
|
||||
query Events($options: EventQueryOptionsInput) {
|
||||
events(options: $options) {
|
||||
count
|
||||
entries {
|
||||
eveDateTime
|
||||
eveEventType
|
||||
eveIp
|
||||
eveAdditionalInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
|
||||
const apiBaseUrl = getApiBase();
|
||||
const url = `${apiBaseUrl}/dbquery/read`;
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
url: graphqlUrl,
|
||||
method: "POST",
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiToken}`
|
||||
},
|
||||
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) {
|
||||
// assuming read_query returns rows directly
|
||||
const rows = data["results"].map(row => {
|
||||
const rawDate = row.eveDateTime;
|
||||
const formattedDate = rawDate ? localizeTimestamp(rawDate) : '-';
|
||||
const CONNECTION_TYPES = ["Connected", "Disconnected", "VOIDED - Connected", "VOIDED - Disconnected"];
|
||||
|
||||
return [
|
||||
formattedDate,
|
||||
row.eveDateTime,
|
||||
row.eveEventType,
|
||||
row.eveIp,
|
||||
row.eveAdditionalInfo
|
||||
];
|
||||
});
|
||||
const rows = data.data.events.entries
|
||||
.filter(row => !hideConnections || !CONNECTION_TYPES.includes(row.eveEventType))
|
||||
.map(row => {
|
||||
const rawDate = row.eveDateTime;
|
||||
const formattedDate = rawDate ? localizeTimestamp(rawDate) : '-';
|
||||
|
||||
return [
|
||||
formattedDate,
|
||||
row.eveDateTime,
|
||||
row.eveEventType,
|
||||
row.eveIp,
|
||||
row.eveAdditionalInfo
|
||||
];
|
||||
});
|
||||
|
||||
const table = $('#tableEvents').DataTable();
|
||||
table.clear();
|
||||
|
||||
111
front/events.php
111
front/events.php
@@ -105,21 +105,64 @@ function main() {
|
||||
$('#period').val(period);
|
||||
initializeDatatable();
|
||||
getEventsTotals();
|
||||
getEvents(eventsType);
|
||||
getEvents(eventsType); // triggers first serverSide draw
|
||||
}
|
||||
|
||||
/* ---------------- Initialize DataTable ---------------- */
|
||||
function initializeDatatable() {
|
||||
const table = $('#tableEvents').DataTable({
|
||||
paging: true,
|
||||
const apiBase = getApiBase();
|
||||
const apiToken = getSetting("API_TOKEN");
|
||||
|
||||
$('#tableEvents').DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
paging: true,
|
||||
lengthChange: true,
|
||||
lengthMenu: getLengthMenu(getSetting("UI_DEFAULT_PAGE_SIZE")),
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
autoWidth: false,
|
||||
order: [[0, "desc"], [3, "desc"], [5, "desc"]],
|
||||
pageLength: tableRows,
|
||||
lengthMenu: getLengthMenu(getSetting("UI_DEFAULT_PAGE_SIZE")),
|
||||
searching: true,
|
||||
ordering: true,
|
||||
info: true,
|
||||
autoWidth: false,
|
||||
order: [[0, "desc"]],
|
||||
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: [
|
||||
{ targets: [0,5,6,7,8,10,11,12,13], visible: false },
|
||||
{ targets: [7], orderData: [8] },
|
||||
@@ -131,14 +174,14 @@ function initializeDatatable() {
|
||||
{ targets: [3], createdCell: (td, cellData) => $(td).html(localizeTimestamp(cellData)) },
|
||||
{ targets: [4,5,6,7], createdCell: (td, cellData) => $(td).html(translateHTMLcodes(cellData)) }
|
||||
],
|
||||
processing: true, // Shows "processing" overlay
|
||||
|
||||
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>',
|
||||
emptyTable: 'No data',
|
||||
lengthMenu: "<?= lang('Events_Tablelenght'); ?>",
|
||||
search: "<?= lang('Events_Searchbox'); ?>: ",
|
||||
paginate: { next: "<?= lang('Events_Table_nav_next'); ?>", previous: "<?= lang('Events_Table_nav_prev'); ?>" },
|
||||
info: "<?= lang('Events_Table_info'); ?>"
|
||||
search: "<?= lang('Events_Searchbox'); ?>: ",
|
||||
paginate: { next: "<?= lang('Events_Table_nav_next'); ?>", previous: "<?= lang('Events_Table_nav_prev'); ?>" },
|
||||
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) {
|
||||
eventsType = type;
|
||||
const table = $('#tableEvents').DataTable();
|
||||
|
||||
// Event type config: title, color, session columns visibility
|
||||
const config = {
|
||||
all: {title: 'Events_Shortcut_AllEvents', color: 'aqua', sesionCols: false},
|
||||
sessions: {title: 'Events_Shortcut_Sessions', color: 'green', sesionCols: true},
|
||||
missing: {title: 'Events_Shortcut_MissSessions', color: 'yellow', sesionCols: true},
|
||||
voided: {title: 'Events_Shortcut_VoidSessions', color: 'yellow', sesionCols: false},
|
||||
new: {title: 'Events_Shortcut_NewDevices', color: 'yellow', sesionCols: false},
|
||||
down: {title: 'Events_Shortcut_DownAlerts', color: 'red', sesionCols: false}
|
||||
all: {title: 'Events_Shortcut_AllEvents', color: 'aqua', sesionCols: false},
|
||||
sessions: {title: 'Events_Shortcut_Sessions', color: 'green', sesionCols: true},
|
||||
missing: {title: 'Events_Shortcut_MissSessions', color: 'yellow', sesionCols: true},
|
||||
voided: {title: 'Events_Shortcut_VoidSessions', color: 'yellow', sesionCols: false},
|
||||
new: {title: 'Events_Shortcut_NewDevices', color: 'yellow', sesionCols: false},
|
||||
down: {title: 'Events_Shortcut_DownAlerts', color: 'red', sesionCols: false}
|
||||
}[type] || {title: 'Events_Shortcut_Events', color: '', sesionCols: false};
|
||||
|
||||
// Update title and color
|
||||
$('#tableEventsTitle').attr('class', 'box-title text-' + config.color).html(getString(config.title));
|
||||
$('#tableEventsBox').attr('class', 'box box-' + config.color);
|
||||
|
||||
// Toggle columns visibility
|
||||
// Toggle column visibility
|
||||
table.column(3).visible(!config.sesionCols);
|
||||
table.column(4).visible(!config.sesionCols);
|
||||
table.column(5).visible(config.sesionCols);
|
||||
table.column(6).visible(config.sesionCols);
|
||||
table.column(7).visible(config.sesionCols);
|
||||
|
||||
// Build API URL
|
||||
const apiBase = getApiBase();
|
||||
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)
|
||||
});
|
||||
showSpinner();
|
||||
table.ajax.reload(null, true); // reset to page 1
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -273,26 +273,14 @@ function genericSaveData (id) {
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
pluginDefinitions = []
|
||||
pluginUnprocessedEvents = []
|
||||
pluginObjects = []
|
||||
pluginHistory = []
|
||||
|
||||
async function getData() {
|
||||
try {
|
||||
showSpinner();
|
||||
console.log("Plugins getData called");
|
||||
|
||||
const [plugins, events, objects, history] = await Promise.all([
|
||||
fetchJson('plugins.json'),
|
||||
fetchJson('table_plugins_events.json'),
|
||||
fetchJson('table_plugins_objects.json'),
|
||||
fetchJson('table_plugins_history.json')
|
||||
]);
|
||||
|
||||
const plugins = await fetchJson('plugins.json');
|
||||
pluginDefinitions = plugins.data;
|
||||
pluginUnprocessedEvents = events.data;
|
||||
pluginObjects = objects.data;
|
||||
pluginHistory = history.data;
|
||||
|
||||
generateTabs();
|
||||
} catch (err) {
|
||||
@@ -306,6 +294,106 @@ async function fetchJson(filename) {
|
||||
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() {
|
||||
|
||||
@@ -315,17 +403,32 @@ function generateTabs() {
|
||||
// Sort pluginDefinitions by unique_prefix alphabetically
|
||||
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
|
||||
pluginDefinitions.forEach(pluginObj => {
|
||||
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)
|
||||
{
|
||||
createTabHeader(pluginObj, stats, assignActive); // Create the header for each tab
|
||||
assignActive = false; // only mark first with content active
|
||||
// Now that ALL DOM elements exist (both <a> headers and tab panes),
|
||||
// wire up DataTable initialization: immediate for the active tab,
|
||||
// deferred via shown.bs.tab for the rest.
|
||||
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'
|
||||
});
|
||||
|
||||
// Pre-fetch badge counts for every plugin in a single batched GraphQL call.
|
||||
prefetchPluginBadges();
|
||||
|
||||
hideSpinner()
|
||||
}
|
||||
|
||||
@@ -349,11 +455,11 @@ function resetTabs() {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// left headers
|
||||
function createTabHeader(pluginObj, stats, assignActive) {
|
||||
function createTabHeader(pluginObj, assignActive) {
|
||||
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin
|
||||
|
||||
// Determine the active class for the first tab
|
||||
assignActive ? activeClass = "active" : activeClass = "";
|
||||
const activeClass = assignActive ? "active" : "";
|
||||
|
||||
// Append the tab header to the tabs location
|
||||
$('#tabs-location').append(`
|
||||
@@ -362,7 +468,7 @@ function createTabHeader(pluginObj, stats, assignActive) {
|
||||
${getString(`${prefix}_icon`)} ${getString(`${prefix}_display_name`)}
|
||||
|
||||
</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>
|
||||
`);
|
||||
|
||||
@@ -374,19 +480,14 @@ function createTabContent(pluginObj, assignActive) {
|
||||
const prefix = pluginObj.unique_prefix; // Get the unique prefix for the plugin
|
||||
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
|
||||
$('#tabs-content-location').append(`
|
||||
<div id="${prefix}" class="tab-pane ${objectData.length > 0 && assignActive? 'active' : ''}">
|
||||
${generateTabNavigation(prefix, objectData.length, eventData.length, historyData.length)} <!-- Create tab navigation -->
|
||||
<div id="${prefix}" class="tab-pane ${assignActive ? 'active' : ''}">
|
||||
${generateTabNavigation(prefix)} <!-- Create tab navigation -->
|
||||
<div class="tab-content">
|
||||
${generateDataTable(prefix, 'Objects', objectData, colDefinitions)}
|
||||
${generateDataTable(prefix, 'Events', eventData, colDefinitions)}
|
||||
${generateDataTable(prefix, 'History', historyData, colDefinitions)}
|
||||
${generateDataTable(prefix, 'Objects', colDefinitions)}
|
||||
${generateDataTable(prefix, 'Events', colDefinitions)}
|
||||
${generateDataTable(prefix, 'History', colDefinitions)}
|
||||
</div>
|
||||
<div class='plugins-description'>
|
||||
${getString(`${prefix}_description`)} <!-- Display the plugin description -->
|
||||
@@ -395,14 +496,7 @@ function createTabContent(pluginObj, assignActive) {
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Initialize DataTables for the respective sections
|
||||
initializeDataTables(prefix, objectData, eventData, historyData, colDefinitions);
|
||||
|
||||
return {
|
||||
"objectDataCount": objectData.length,
|
||||
"eventDataCount": eventData.length,
|
||||
"historyDataCount": historyData.length
|
||||
}
|
||||
// DataTable init is handled by generateTabs() after all DOM elements exist.
|
||||
}
|
||||
|
||||
function getColumnDefinitions(pluginObj) {
|
||||
@@ -410,53 +504,26 @@ function getColumnDefinitions(pluginObj) {
|
||||
return pluginObj["database_column_definitions"].filter(colDef => colDef.show);
|
||||
}
|
||||
|
||||
function getEventData(prefix, colDefinitions, pluginObj) {
|
||||
// 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) {
|
||||
function generateTabNavigation(prefix) {
|
||||
// Create navigation tabs for Objects, Unprocessed Events, and History
|
||||
return `
|
||||
<div class="nav-tabs-custom" style="margin-bottom: 0px">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active">
|
||||
<a href="#objectsTarget_${prefix}" data-toggle="tab"><i class="fa fa-cube"></i> ${getString('Plugins_Objects')} (${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>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</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
|
||||
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) {
|
||||
// Common settings for DataTables initialization
|
||||
const commonDataTableSettings = {
|
||||
orderable: false, // Disable ordering
|
||||
createdRow: function(row, data) {
|
||||
$(row).attr('data-my-index', data[0]); // Set data attribute for indexing
|
||||
function initializeDataTables(prefix, colDefinitions, pluginObj) {
|
||||
const mac = $("#txtMacFilter").val();
|
||||
const foreignKey = (mac && mac !== "--") ? mac : null;
|
||||
|
||||
const tableConfigs = [
|
||||
{ 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
|
||||
$(`#objectsTable_${prefix}`).DataTable({
|
||||
data: objectData,
|
||||
columns: colDefinitions.map(colDef => ({ title: getString(`${prefix}_${colDef.column}_name`) })), // Column titles
|
||||
...commonDataTableSettings // Spread common settings
|
||||
// Initialize the Objects table immediately (it is the active/visible sub-tab).
|
||||
// Defer Events and History tables until their sub-tab is first shown.
|
||||
const [objCfg, evtCfg, histCfg] = tableConfigs;
|
||||
buildDT(objCfg.tableId, objCfg.gqlField, objCfg.countId, objCfg.badgeId);
|
||||
|
||||
$(`a[href="#eventsTarget_${prefix}"]`).one('shown.bs.tab', function() {
|
||||
buildDT(evtCfg.tableId, evtCfg.gqlField, evtCfg.countId, evtCfg.badgeId);
|
||||
});
|
||||
|
||||
// Initialize DataTable for Unprocessed Events
|
||||
$(`#eventsTable_${prefix}`).DataTable({
|
||||
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
|
||||
$(`a[href="#historyTarget_${prefix}"]`).one('shown.bs.tab', function() {
|
||||
buildDT(histCfg.tableId, histCfg.gqlField, histCfg.countId, histCfg.badgeId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from const import (
|
||||
apiPath,
|
||||
sql_appevents,
|
||||
sql_devices_all,
|
||||
sql_events_all,
|
||||
sql_events_pending_alert,
|
||||
sql_settings,
|
||||
sql_plugins_events,
|
||||
@@ -59,6 +60,7 @@ def update_api(
|
||||
dataSourcesSQLs = [
|
||||
["appevents", sql_appevents],
|
||||
["devices", sql_devices_all],
|
||||
["events", sql_events_all],
|
||||
["events_pending_alert", sql_events_pending_alert],
|
||||
["settings", sql_settings],
|
||||
["plugins_events", sql_plugins_events],
|
||||
|
||||
@@ -1750,8 +1750,13 @@ def api_device_sessions(mac, payload=None):
|
||||
summary="Get Session Events",
|
||||
description="Retrieve events associated with sessions.",
|
||||
query_params=[
|
||||
{"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": "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": "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"],
|
||||
auth_callable=is_authorized
|
||||
@@ -1759,7 +1764,12 @@ def api_device_sessions(mac, payload=None):
|
||||
def api_get_session_events(payload=None):
|
||||
session_event_type = request.args.get("type", "all")
|
||||
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
|
||||
from graphene import (
|
||||
ObjectType, String, Int, Boolean, List, Field, InputObjectType, Argument
|
||||
)
|
||||
from graphene import ObjectType, List, Field, Argument, String
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
@@ -19,175 +17,30 @@ from helper import ( # noqa: E402 [flake8 lint suppression]
|
||||
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
|
||||
|
||||
|
||||
# --- 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
|
||||
_langstrings_cache = {} # caches lists per file (core JSON or plugin)
|
||||
_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):
|
||||
# --- DEVICES ---
|
||||
devices = Field(DeviceResult, options=PageQueryOptionsInput())
|
||||
@@ -652,15 +505,75 @@ class Query(ObjectType):
|
||||
|
||||
return LangStringResult(langStrings=langStrings, count=len(langStrings))
|
||||
|
||||
# --- PLUGINS_OBJECTS ---
|
||||
pluginsObjects = Field(PluginsObjectsResult, options=PluginQueryOptionsInput())
|
||||
|
||||
# helps sorting inconsistent dataset mixed integers and strings
|
||||
def mixed_type_sort_key(value):
|
||||
if value is None or value == "":
|
||||
return (2, "") # Place None or empty strings last
|
||||
def resolve_pluginsObjects(self, info, options=None):
|
||||
return _resolve_plugin_table("table_plugins_objects.json", options, PluginsObjectsResult)
|
||||
|
||||
# --- 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:
|
||||
return (0, int(value)) # Integers get priority
|
||||
except (ValueError, TypeError):
|
||||
return (1, str(value)) # Strings come next
|
||||
with open(folder + json_file, "r") as f:
|
||||
data = json.load(f).get("data", [])
|
||||
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
|
||||
|
||||
140
server/api_server/graphql_helpers.py
Normal file
140
server/api_server/graphql_helpers.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
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)
|
||||
start = (options.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
|
||||
261
server/api_server/graphql_types.py
Normal file
261
server/api_server/graphql_types.py
Normal file
@@ -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})
|
||||
|
||||
|
||||
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.
|
||||
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.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
@@ -420,4 +426,30 @@ def get_session_events(event_type, period_date):
|
||||
|
||||
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
|
||||
"""
|
||||
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_plugins_objects = "SELECT * FROM Plugins_Objects"
|
||||
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; "
|
||||
"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)
|
||||
|
||||
|
||||
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):
|
||||
# First create session
|
||||
|
||||
Reference in New Issue
Block a user