diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f108749..67256a3d 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) --- diff --git a/docs/API_GRAPHQL.md b/docs/API_GRAPHQL.md index e7ccfd10..cd176f57 100755 --- a/docs/API_GRAPHQL.md +++ b/docs/API_GRAPHQL.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. diff --git a/docs/API_SESSIONS.md b/docs/API_SESSIONS.md index d5d19eed..879cdea2 100755 --- a/docs/API_SESSIONS.md +++ b/docs/API_SESSIONS.md @@ -224,15 +224,33 @@ curl -X GET "http://:/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 diff --git a/front/deviceDetailsEvents.php b/front/deviceDetailsEvents.php index c7b7fdfe..87a29885 100755 --- a/front/deviceDetailsEvents.php +++ b/front/deviceDetailsEvents.php @@ -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(); diff --git a/front/events.php b/front/events.php index a0c9b6a8..4aa943dd 100755 --- a/front/events.php +++ b/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: '
', emptyTable: 'No data', lengthMenu: "", - search: ": ", - paginate: { next: "", previous: "" }, - info: "" + search: ": ", + paginate: { next: "", previous: "" }, + 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 } diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 1e19ff07..c76535b0 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -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 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`)} - ${stats.objectDataCount > 0 ? `
${stats.objectDataCount}
` : ""} +
`); @@ -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(` -
- ${generateTabNavigation(prefix, objectData.length, eventData.length, historyData.length)} +
+ ${generateTabNavigation(prefix)}
- ${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)}
${getString(`${prefix}_description`)} @@ -395,14 +496,7 @@ function createTabContent(pluginObj, assignActive) {
`); - // 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 ` `; } -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 => `${getString(`${prefix}_${colDef.column}_name`)}`).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); }); } diff --git a/server/api.py b/server/api.py index 2ed27e37..fd775f47 100755 --- a/server/api.py +++ b/server/api.py @@ -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], diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index c50d7a35..0f1de544 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -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) # -------------------------- diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 99850800..e950a204 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -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 diff --git a/server/api_server/graphql_helpers.py b/server/api_server/graphql_helpers.py new file mode 100644 index 00000000..eeb17641 --- /dev/null +++ b/server/api_server/graphql_helpers.py @@ -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 diff --git a/server/api_server/graphql_types.py b/server/api_server/graphql_types.py new file mode 100644 index 00000000..3465e2cd --- /dev/null +++ b/server/api_server/graphql_types.py @@ -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") diff --git a/server/api_server/sessions_endpoint.py b/server/api_server/sessions_endpoint.py index dd6037d2..bda77dae 100755 --- a/server/api_server/sessions_endpoint.py +++ b/server/api_server/sessions_endpoint.py @@ -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}) diff --git a/server/const.py b/server/const.py index e07eda06..97faa00b 100755 --- a/server/const.py +++ b/server/const.py @@ -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" diff --git a/test/api_endpoints/test_graphq_endpoints.py b/test/api_endpoints/test_graphq_endpoints.py index 15f799ac..86733e94 100644 --- a/test/api_endpoints/test_graphq_endpoints.py +++ b/test/api_endpoints/test_graphq_endpoints.py @@ -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']}" + ) diff --git a/test/api_endpoints/test_sessions_endpoints.py b/test/api_endpoints/test_sessions_endpoints.py index 50ed1fd2..a59e7de9 100644 --- a/test/api_endpoints/test_sessions_endpoints.py +++ b/test/api_endpoints/test_sessions_endpoints.py @@ -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