From 3f80d2e57f22f812d04ee960b2c5d1dbc93f9640 Mon Sep 17 00:00:00 2001 From: "Jokob @NetAlertX" <96159884+jokob-sk@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:35:41 +0000 Subject: [PATCH] feat(plugins): Implement /plugins/stats endpoint for per-plugin row counts with optional foreignKey filtering --- front/pluginsCore.php | 56 ++++++--------- server/api_server/api_server_start.py | 29 ++++++++ server/api_server/openapi/schemas.py | 29 ++++++++ server/models/plugin_object_instance.py | 28 ++++++++ .../test_plugin_stats_endpoints.py | 72 +++++++++++++++++++ 5 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 test/api_endpoints/test_plugin_stats_endpoints.py diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 94835edd..868a36d7 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -359,56 +359,44 @@ function postPluginGraphQL(gqlField, prefix, foreignKey, dtRequest, callback) { // Fetch counts for all plugins. Returns { PREFIX: { objects, events, history } } // or null on failure (fail-open so tabs still render). -// Fast path: static JSON (~1KB) when no MAC filter is active. -// Filtered path: batched GraphQL aliases when a foreignKey (MAC) is set. +// Unfiltered: static JSON (~1KB pre-computed). +// MAC-filtered: lightweight REST endpoint (single SQL query). async function fetchPluginCounts(prefixes) { if (prefixes.length === 0) return {}; + const mac = $("#txtMacFilter").val(); + const foreignKey = (mac && mac !== "--") ? mac : null; + try { - const mac = $("#txtMacFilter").val(); - const foreignKey = (mac && mac !== "--") ? mac : null; let counts = {}; + let rows; if (!foreignKey) { - // ---- FAST PATH: lightweight pre-computed JSON ---- + // ---- FAST PATH: pre-computed static JSON ---- const stats = await fetchJson('table_plugins_stats.json'); - for (const row of stats.data) { - const p = row.tableName; // 'objects' | 'events' | 'history' - const plugin = row.plugin; - if (!counts[plugin]) counts[plugin] = { objects: 0, events: 0, history: 0 }; - counts[plugin][p] = row.cnt; - } + rows = stats.data; } else { - // ---- FILTERED PATH: GraphQL with foreignKey ---- + // ---- MAC-FILTERED PATH: single SQL via REST endpoint ---- const apiToken = getSetting("API_TOKEN"); const apiBase = getApiBase(); - const fkOpt = `, 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 }`; const response = await $.ajax({ - method: "POST", - url: `${apiBase}/graphql`, - headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" }, - data: JSON.stringify({ query }), + method: "GET", + url: `${apiBase}/plugins/stats?foreignKey=${encodeURIComponent(foreignKey)}`, + headers: { "Authorization": `Bearer ${apiToken}` }, }); - if (response.errors) { - console.error("[plugins] badge GQL errors:", response.errors); - return null; // fail-open - } - for (const p of prefixes) { - counts[p] = { - objects: response.data[`${p}_obj`]?.dbCount ?? 0, - events: response.data[`${p}_evt`]?.dbCount ?? 0, - history: response.data[`${p}_hist`]?.dbCount ?? 0, - }; + if (!response.success) { + console.error("[plugins] /plugins/stats error:", response.error); + return null; } + rows = response.data; } + for (const row of rows) { + const p = row.tableName; // 'objects' | 'events' | 'history' + const plugin = row.plugin; + if (!counts[plugin]) counts[plugin] = { objects: 0, events: 0, history: 0 }; + counts[plugin][p] = row.cnt; + } return counts; } catch (err) { console.error('[plugins] fetchPluginCounts failed (fail-open):', err); diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 0f1de544..f85f9a57 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -43,6 +43,7 @@ from .sync_endpoint import handle_sync_post, handle_sync_get # noqa: E402 [flak from .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression] from .health_endpoint import get_health_status # noqa: E402 [flake8 lint suppression] from .languages_endpoint import get_languages # noqa: E402 [flake8 lint suppression] +from models.plugin_object_instance import PluginObjectInstance # noqa: E402 [flake8 lint suppression] from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression] from models.event_instance import EventInstance # noqa: E402 [flake8 lint suppression] @@ -97,6 +98,7 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression] AddToQueueRequest, GetSettingResponse, RecentEventsRequest, SetDeviceAliasRequest, LanguagesResponse, + PluginStatsResponse, ) from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression] @@ -2002,6 +2004,33 @@ def list_languages(payload=None): }), 500 +# -------------------------- +# Plugin Stats endpoint +# -------------------------- +@app.route("/plugins/stats", methods=["GET"]) +@validate_request( + operation_id="get_plugin_stats", + summary="Get Plugin Row Counts", + description="Return per-plugin row counts across Objects, Events, and History tables. Optionally filter by foreignKey (MAC).", + response_model=PluginStatsResponse, + tags=["plugins"], + auth_callable=is_authorized, + query_params=[{ + "name": "foreignKey", + "in": "query", + "required": False, + "description": "Filter counts to rows matching this foreignKey (typically a MAC address)", + "schema": {"type": "string"} + }] +) +def api_plugin_stats(payload=None): + """Get per-plugin row counts, optionally filtered by foreignKey.""" + foreign_key = request.args.get("foreignKey", None) + handler = PluginObjectInstance() + data = handler.getStats(foreign_key) + return jsonify({"success": True, "data": data}) + + # -------------------------- # Background Server Start # -------------------------- diff --git a/server/api_server/openapi/schemas.py b/server/api_server/openapi/schemas.py index 4d7d3127..7310f637 100644 --- a/server/api_server/openapi/schemas.py +++ b/server/api_server/openapi/schemas.py @@ -1084,3 +1084,32 @@ class GraphQLRequest(BaseModel): """Request payload for GraphQL queries.""" query: str = Field(..., description="GraphQL query string", json_schema_extra={"examples": ["{ devices { devMac devName } }"]}) variables: Optional[Dict[str, Any]] = Field(None, description="Variables for the GraphQL query") + + +# ============================================================================= +# PLUGIN SCHEMAS +# ============================================================================= +class PluginStatsEntry(BaseModel): + """Per-plugin row count for one table.""" + tableName: str = Field(..., description="Table category: objects, events, or history") + plugin: str = Field(..., description="Plugin unique prefix") + cnt: int = Field(..., ge=0, description="Row count") + + +class PluginStatsResponse(BaseResponse): + """Response for GET /plugins/stats — per-plugin row counts.""" + model_config = ConfigDict( + extra="allow", + json_schema_extra={ + "examples": [{ + "success": True, + "data": [ + {"tableName": "objects", "plugin": "ARPSCAN", "cnt": 42}, + {"tableName": "events", "plugin": "ARPSCAN", "cnt": 5}, + {"tableName": "history", "plugin": "ARPSCAN", "cnt": 100} + ] + }] + } + ) + + data: List[PluginStatsEntry] = Field(default_factory=list, description="Per-plugin row counts") diff --git a/server/models/plugin_object_instance.py b/server/models/plugin_object_instance.py index 5018b6d5..89eb69e7 100755 --- a/server/models/plugin_object_instance.py +++ b/server/models/plugin_object_instance.py @@ -101,3 +101,31 @@ class PluginObjectInstance: raise ValueError(msg) self._execute("DELETE FROM Plugins_Objects WHERE objectGuid=?", (ObjectGUID,)) + + def getStats(self, foreign_key=None): + """Per-plugin row counts across Objects, Events, and History tables. + Optionally scoped to a specific foreignKey (e.g. MAC address).""" + if foreign_key: + sql = """ + SELECT 'objects' AS tableName, plugin, COUNT(*) AS cnt + FROM Plugins_Objects WHERE foreignKey = ? GROUP BY plugin + UNION ALL + SELECT 'events', plugin, COUNT(*) + FROM Plugins_Events WHERE foreignKey = ? GROUP BY plugin + UNION ALL + SELECT 'history', plugin, COUNT(*) + FROM Plugins_History WHERE foreignKey = ? GROUP BY plugin + """ + return self._fetchall(sql, (foreign_key, foreign_key, foreign_key)) + else: + sql = """ + SELECT 'objects' AS tableName, plugin, COUNT(*) AS cnt + FROM Plugins_Objects GROUP BY plugin + UNION ALL + SELECT 'events', plugin, COUNT(*) + FROM Plugins_Events GROUP BY plugin + UNION ALL + SELECT 'history', plugin, COUNT(*) + FROM Plugins_History GROUP BY plugin + """ + return self._fetchall(sql) diff --git a/test/api_endpoints/test_plugin_stats_endpoints.py b/test/api_endpoints/test_plugin_stats_endpoints.py new file mode 100644 index 00000000..3633b79f --- /dev/null +++ b/test/api_endpoints/test_plugin_stats_endpoints.py @@ -0,0 +1,72 @@ +"""Tests for /plugins/stats endpoint.""" + +import sys +import os +import pytest + +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value # noqa: E402 +from api_server.api_server_start import app # noqa: E402 + + +@pytest.fixture(scope="session") +def api_token(): + return get_setting_value("API_TOKEN") + + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + + +def auth_headers(token): + return {"Authorization": f"Bearer {token}"} + + +def test_plugin_stats_unauthorized(client): + """Missing token should be forbidden.""" + resp = client.get("/plugins/stats") + assert resp.status_code == 403 + assert resp.get_json().get("success") is False + + +def test_plugin_stats_success(client, api_token): + """Valid token returns success with data array.""" + resp = client.get("/plugins/stats", headers=auth_headers(api_token)) + assert resp.status_code == 200 + + data = resp.get_json() + assert data.get("success") is True + assert isinstance(data.get("data"), list) + + +def test_plugin_stats_entry_structure(client, api_token): + """Each entry has tableName, plugin, cnt fields.""" + resp = client.get("/plugins/stats", headers=auth_headers(api_token)) + data = resp.get_json() + + for entry in data["data"]: + assert "tableName" in entry + assert "plugin" in entry + assert "cnt" in entry + assert entry["tableName"] in ("objects", "events", "history") + assert isinstance(entry["cnt"], int) + assert entry["cnt"] >= 0 + + +def test_plugin_stats_with_foreignkey(client, api_token): + """foreignKey param filters results and returns valid structure.""" + resp = client.get( + "/plugins/stats?foreignKey=00:00:00:00:00:00", + headers=auth_headers(api_token), + ) + assert resp.status_code == 200 + + data = resp.get_json() + assert data.get("success") is True + assert isinstance(data.get("data"), list) + # With a non-existent MAC, data should be empty + assert len(data["data"]) == 0