mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-30 23:03:03 -07:00
feat(plugins): Implement /plugins/stats endpoint for per-plugin row counts with optional foreignKey filtering
Some checks are pending
🐳 ⚠ docker-unsafe from next_release branch / docker_dev_unsafe (push) Waiting to run
Some checks are pending
🐳 ⚠ docker-unsafe from next_release branch / docker_dev_unsafe (push) Waiting to run
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 } }
|
// Fetch counts for all plugins. Returns { PREFIX: { objects, events, history } }
|
||||||
// or null on failure (fail-open so tabs still render).
|
// or null on failure (fail-open so tabs still render).
|
||||||
// Fast path: static JSON (~1KB) when no MAC filter is active.
|
// Unfiltered: static JSON (~1KB pre-computed).
|
||||||
// Filtered path: batched GraphQL aliases when a foreignKey (MAC) is set.
|
// MAC-filtered: lightweight REST endpoint (single SQL query).
|
||||||
async function fetchPluginCounts(prefixes) {
|
async function fetchPluginCounts(prefixes) {
|
||||||
if (prefixes.length === 0) return {};
|
if (prefixes.length === 0) return {};
|
||||||
|
|
||||||
|
const mac = $("#txtMacFilter").val();
|
||||||
|
const foreignKey = (mac && mac !== "--") ? mac : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mac = $("#txtMacFilter").val();
|
|
||||||
const foreignKey = (mac && mac !== "--") ? mac : null;
|
|
||||||
let counts = {};
|
let counts = {};
|
||||||
|
let rows;
|
||||||
|
|
||||||
if (!foreignKey) {
|
if (!foreignKey) {
|
||||||
// ---- FAST PATH: lightweight pre-computed JSON ----
|
// ---- FAST PATH: pre-computed static JSON ----
|
||||||
const stats = await fetchJson('table_plugins_stats.json');
|
const stats = await fetchJson('table_plugins_stats.json');
|
||||||
for (const row of stats.data) {
|
rows = 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;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// ---- FILTERED PATH: GraphQL with foreignKey ----
|
// ---- MAC-FILTERED PATH: single SQL via REST endpoint ----
|
||||||
const apiToken = getSetting("API_TOKEN");
|
const apiToken = getSetting("API_TOKEN");
|
||||||
const apiBase = getApiBase();
|
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({
|
const response = await $.ajax({
|
||||||
method: "POST",
|
method: "GET",
|
||||||
url: `${apiBase}/graphql`,
|
url: `${apiBase}/plugins/stats?foreignKey=${encodeURIComponent(foreignKey)}`,
|
||||||
headers: { "Authorization": `Bearer ${apiToken}`, "Content-Type": "application/json" },
|
headers: { "Authorization": `Bearer ${apiToken}` },
|
||||||
data: JSON.stringify({ query }),
|
|
||||||
});
|
});
|
||||||
if (response.errors) {
|
if (!response.success) {
|
||||||
console.error("[plugins] badge GQL errors:", response.errors);
|
console.error("[plugins] /plugins/stats error:", response.error);
|
||||||
return null; // fail-open
|
return null;
|
||||||
}
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
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;
|
return counts;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[plugins] fetchPluginCounts failed (fail-open):', 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 .logs_endpoint import clean_log # noqa: E402 [flake8 lint suppression]
|
||||||
from .health_endpoint import get_health_status # 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 .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.user_events_queue_instance import UserEventsQueueInstance # noqa: E402 [flake8 lint suppression]
|
||||||
|
|
||||||
from models.event_instance import EventInstance # 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,
|
AddToQueueRequest, GetSettingResponse,
|
||||||
RecentEventsRequest, SetDeviceAliasRequest,
|
RecentEventsRequest, SetDeviceAliasRequest,
|
||||||
LanguagesResponse,
|
LanguagesResponse,
|
||||||
|
PluginStatsResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
|
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
|
||||||
@@ -2002,6 +2004,33 @@ def list_languages(payload=None):
|
|||||||
}), 500
|
}), 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
|
# Background Server Start
|
||||||
# --------------------------
|
# --------------------------
|
||||||
|
|||||||
@@ -1084,3 +1084,32 @@ class GraphQLRequest(BaseModel):
|
|||||||
"""Request payload for GraphQL queries."""
|
"""Request payload for GraphQL queries."""
|
||||||
query: str = Field(..., description="GraphQL query string", json_schema_extra={"examples": ["{ devices { devMac devName } }"]})
|
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")
|
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)
|
raise ValueError(msg)
|
||||||
|
|
||||||
self._execute("DELETE FROM Plugins_Objects WHERE objectGuid=?", (ObjectGUID,))
|
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