Skip to content

Commit 3f80d2e

Browse files
committed
feat(plugins): Implement /plugins/stats endpoint for per-plugin row counts with optional foreignKey filtering
1 parent b18cf98 commit 3f80d2e

File tree

5 files changed

+180
-34
lines changed

5 files changed

+180
-34
lines changed

front/pluginsCore.php

Lines changed: 22 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -359,56 +359,44 @@ function postPluginGraphQL(gqlField, prefix, foreignKey, dtRequest, callback) {
359359

360360
// Fetch counts for all plugins. Returns { PREFIX: { objects, events, history } }
361361
// or null on failure (fail-open so tabs still render).
362-
// Fast path: static JSON (~1KB) when no MAC filter is active.
363-
// Filtered path: batched GraphQL aliases when a foreignKey (MAC) is set.
362+
// Unfiltered: static JSON (~1KB pre-computed).
363+
// MAC-filtered: lightweight REST endpoint (single SQL query).
364364
async function fetchPluginCounts(prefixes) {
365365
if (prefixes.length === 0) return {};
366366

367+
const mac = $("#txtMacFilter").val();
368+
const foreignKey = (mac && mac !== "--") ? mac : null;
369+
367370
try {
368-
const mac = $("#txtMacFilter").val();
369-
const foreignKey = (mac && mac !== "--") ? mac : null;
370371
let counts = {};
372+
let rows;
371373

372374
if (!foreignKey) {
373-
// ---- FAST PATH: lightweight pre-computed JSON ----
375+
// ---- FAST PATH: pre-computed static JSON ----
374376
const stats = await fetchJson('table_plugins_stats.json');
375-
for (const row of stats.data) {
376-
const p = row.tableName; // 'objects' | 'events' | 'history'
377-
const plugin = row.plugin;
378-
if (!counts[plugin]) counts[plugin] = { objects: 0, events: 0, history: 0 };
379-
counts[plugin][p] = row.cnt;
380-
}
377+
rows = stats.data;
381378
} else {
382-
// ---- FILTERED PATH: GraphQL with foreignKey ----
379+
// ---- MAC-FILTERED PATH: single SQL via REST endpoint ----
383380
const apiToken = getSetting("API_TOKEN");
384381
const apiBase = getApiBase();
385-
const fkOpt = `, foreignKey: "${foreignKey}"`;
386-
const fragments = prefixes.map(p => [
387-
`${p}_obj: pluginsObjects(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
388-
`${p}_evt: pluginsEvents(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
389-
`${p}_hist: pluginsHistory(options: {plugin: "${p}", page: 1, limit: 1${fkOpt}}) { dbCount }`,
390-
].join('\n ')).join('\n ');
391-
392-
const query = `query BadgeCounts {\n ${fragments}\n }`;
393382
const response = await $.ajax({
394-
method: "POST",
395-
url: `${apiBase}/graphql`,
396-
headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
397-
data: JSON.stringify({ query }),
383+
method: "GET",
384+
url: `${apiBase}/plugins/stats?foreignKey=${encodeURIComponent(foreignKey)}`,
385+
headers: { "Authorization": `Bearer ${apiToken}` },
398386
});
399-
if (response.errors) {
400-
console.error("[plugins] badge GQL errors:", response.errors);
401-
return null; // fail-open
402-
}
403-
for (const p of prefixes) {
404-
counts[p] = {
405-
objects: response.data[`${p}_obj`]?.dbCount ?? 0,
406-
events: response.data[`${p}_evt`]?.dbCount ?? 0,
407-
history: response.data[`${p}_hist`]?.dbCount ?? 0,
408-
};
387+
if (!response.success) {
388+
console.error("[plugins] /plugins/stats error:", response.error);
389+
return null;
409390
}
391+
rows = response.data;
410392
}
411393

394+
for (const row of rows) {
395+
const p = row.tableName; // 'objects' | 'events' | 'history'
396+
const plugin = row.plugin;
397+
if (!counts[plugin]) counts[plugin] = { objects: 0, events: 0, history: 0 };
398+
counts[plugin][p] = row.cnt;
399+
}
412400
return counts;
413401
} catch (err) {
414402
console.error('[plugins] fetchPluginCounts failed (fail-open):', err);

server/api_server/api_server_start.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression]
4444
from .health_endpoint import get_health_status # noqa: E402 [flake8 lint suppression]
4545
from .languages_endpoint import get_languages # noqa: E402 [flake8 lint suppression]
46+
from models.plugin_object_instance import PluginObjectInstance # noqa: E402 [flake8 lint suppression]
4647
from models.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression]
4748

4849
from models.event_instance import EventInstance # noqa: E402 [flake8 lint suppression]
@@ -97,6 +98,7 @@
9798
AddToQueueRequest, GetSettingResponse,
9899
RecentEventsRequest, SetDeviceAliasRequest,
99100
LanguagesResponse,
101+
PluginStatsResponse,
100102
)
101103

102104
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
@@ -2002,6 +2004,33 @@ def list_languages(payload=None):
20022004
}), 500
20032005

20042006

2007+
# --------------------------
2008+
# Plugin Stats endpoint
2009+
# --------------------------
2010+
@app.route("/plugins/stats", methods=["GET"])
2011+
@validate_request(
2012+
operation_id="get_plugin_stats",
2013+
summary="Get Plugin Row Counts",
2014+
description="Return per-plugin row counts across Objects, Events, and History tables. Optionally filter by foreignKey (MAC).",
2015+
response_model=PluginStatsResponse,
2016+
tags=["plugins"],
2017+
auth_callable=is_authorized,
2018+
query_params=[{
2019+
"name": "foreignKey",
2020+
"in": "query",
2021+
"required": False,
2022+
"description": "Filter counts to rows matching this foreignKey (typically a MAC address)",
2023+
"schema": {"type": "string"}
2024+
}]
2025+
)
2026+
def api_plugin_stats(payload=None):
2027+
"""Get per-plugin row counts, optionally filtered by foreignKey."""
2028+
foreign_key = request.args.get("foreignKey", None)
2029+
handler = PluginObjectInstance()
2030+
data = handler.getStats(foreign_key)
2031+
return jsonify({"success": True, "data": data})
2032+
2033+
20052034
# --------------------------
20062035
# Background Server Start
20072036
# --------------------------

server/api_server/openapi/schemas.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,3 +1084,32 @@ class GraphQLRequest(BaseModel):
10841084
"""Request payload for GraphQL queries."""
10851085
query: str = Field(..., description="GraphQL query string", json_schema_extra={"examples": ["{ devices { devMac devName } }"]})
10861086
variables: Optional[Dict[str, Any]] = Field(None, description="Variables for the GraphQL query")
1087+
1088+
1089+
# =============================================================================
1090+
# PLUGIN SCHEMAS
1091+
# =============================================================================
1092+
class PluginStatsEntry(BaseModel):
1093+
"""Per-plugin row count for one table."""
1094+
tableName: str = Field(..., description="Table category: objects, events, or history")
1095+
plugin: str = Field(..., description="Plugin unique prefix")
1096+
cnt: int = Field(..., ge=0, description="Row count")
1097+
1098+
1099+
class PluginStatsResponse(BaseResponse):
1100+
"""Response for GET /plugins/stats — per-plugin row counts."""
1101+
model_config = ConfigDict(
1102+
extra="allow",
1103+
json_schema_extra={
1104+
"examples": [{
1105+
"success": True,
1106+
"data": [
1107+
{"tableName": "objects", "plugin": "ARPSCAN", "cnt": 42},
1108+
{"tableName": "events", "plugin": "ARPSCAN", "cnt": 5},
1109+
{"tableName": "history", "plugin": "ARPSCAN", "cnt": 100}
1110+
]
1111+
}]
1112+
}
1113+
)
1114+
1115+
data: List[PluginStatsEntry] = Field(default_factory=list, description="Per-plugin row counts")

server/models/plugin_object_instance.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,31 @@ def delete(self, ObjectGUID):
101101
raise ValueError(msg)
102102

103103
self._execute("DELETE FROM Plugins_Objects WHERE objectGuid=?", (ObjectGUID,))
104+
105+
def getStats(self, foreign_key=None):
106+
"""Per-plugin row counts across Objects, Events, and History tables.
107+
Optionally scoped to a specific foreignKey (e.g. MAC address)."""
108+
if foreign_key:
109+
sql = """
110+
SELECT 'objects' AS tableName, plugin, COUNT(*) AS cnt
111+
FROM Plugins_Objects WHERE foreignKey = ? GROUP BY plugin
112+
UNION ALL
113+
SELECT 'events', plugin, COUNT(*)
114+
FROM Plugins_Events WHERE foreignKey = ? GROUP BY plugin
115+
UNION ALL
116+
SELECT 'history', plugin, COUNT(*)
117+
FROM Plugins_History WHERE foreignKey = ? GROUP BY plugin
118+
"""
119+
return self._fetchall(sql, (foreign_key, foreign_key, foreign_key))
120+
else:
121+
sql = """
122+
SELECT 'objects' AS tableName, plugin, COUNT(*) AS cnt
123+
FROM Plugins_Objects GROUP BY plugin
124+
UNION ALL
125+
SELECT 'events', plugin, COUNT(*)
126+
FROM Plugins_Events GROUP BY plugin
127+
UNION ALL
128+
SELECT 'history', plugin, COUNT(*)
129+
FROM Plugins_History GROUP BY plugin
130+
"""
131+
return self._fetchall(sql)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for /plugins/stats endpoint."""
2+
3+
import sys
4+
import os
5+
import pytest
6+
7+
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
8+
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
9+
10+
from helper import get_setting_value # noqa: E402
11+
from api_server.api_server_start import app # noqa: E402
12+
13+
14+
@pytest.fixture(scope="session")
15+
def api_token():
16+
return get_setting_value("API_TOKEN")
17+
18+
19+
@pytest.fixture
20+
def client():
21+
with app.test_client() as client:
22+
yield client
23+
24+
25+
def auth_headers(token):
26+
return {"Authorization": f"Bearer {token}"}
27+
28+
29+
def test_plugin_stats_unauthorized(client):
30+
"""Missing token should be forbidden."""
31+
resp = client.get("/plugins/stats")
32+
assert resp.status_code == 403
33+
assert resp.get_json().get("success") is False
34+
35+
36+
def test_plugin_stats_success(client, api_token):
37+
"""Valid token returns success with data array."""
38+
resp = client.get("/plugins/stats", headers=auth_headers(api_token))
39+
assert resp.status_code == 200
40+
41+
data = resp.get_json()
42+
assert data.get("success") is True
43+
assert isinstance(data.get("data"), list)
44+
45+
46+
def test_plugin_stats_entry_structure(client, api_token):
47+
"""Each entry has tableName, plugin, cnt fields."""
48+
resp = client.get("/plugins/stats", headers=auth_headers(api_token))
49+
data = resp.get_json()
50+
51+
for entry in data["data"]:
52+
assert "tableName" in entry
53+
assert "plugin" in entry
54+
assert "cnt" in entry
55+
assert entry["tableName"] in ("objects", "events", "history")
56+
assert isinstance(entry["cnt"], int)
57+
assert entry["cnt"] >= 0
58+
59+
60+
def test_plugin_stats_with_foreignkey(client, api_token):
61+
"""foreignKey param filters results and returns valid structure."""
62+
resp = client.get(
63+
"/plugins/stats?foreignKey=00:00:00:00:00:00",
64+
headers=auth_headers(api_token),
65+
)
66+
assert resp.status_code == 200
67+
68+
data = resp.get_json()
69+
assert data.get("success") is True
70+
assert isinstance(data.get("data"), list)
71+
# With a non-existent MAC, data should be empty
72+
assert len(data["data"]) == 0

0 commit comments

Comments
 (0)