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:
Jokob @NetAlertX
2026-03-26 20:57:10 +00:00
parent 250e533655
commit ec3e4c8988
15 changed files with 1312 additions and 352 deletions

View File

@@ -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)
---

View File

@@ -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.

View File

@@ -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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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);
});
}

View File

@@ -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],

View File

@@ -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)
# --------------------------

View File

@@ -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

View 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

View 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")

View File

@@ -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})

View File

@@ -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"

View File

@@ -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']}"
)

View File

@@ -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