mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-28 20:33:33 -04:00
feat(plugins): Implement /plugins/stats endpoint for per-plugin row counts with optional foreignKey filtering
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
# --------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
72
test/api_endpoints/test_plugin_stats_endpoints.py
Normal file
72
test/api_endpoints/test_plugin_stats_endpoints.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user