BE+FE+DOCS: run plugin from custom props/actions

This commit is contained in:
jokob-sk committed 2026-09-05 10:54:56 +10:00
1 parent cddb237cd3
commit 4d0175c660
5 files changed
+132 -6

No files matched your search

+10 -5
View File
@@ -35,10 +35,11 @@ Visible properties (`CUSTPROP_show: true`) are displayed as interactive icons in
- Manage devices with actions like delete.
- Example: Quickly remove a device from the network.
4. **Plugins**:
4. **Plugins (Experimental 🧪)**:
- Future placeholder for running custom plugin scripts.
- **Note**: Not implemented yet.
- Manually trigger an on-demand run of a plugin, regardless of its configured `RUN` schedule.
- Example: Add a button to re-run a scan-type plugin like `NMAPDEV` on demand.
- **Note**: The plugin runs with whatever settings/params it already has configured - it is not passed any device-specific or ad-hoc arguments. Some plugins require settings (credentials, target host, subnet, etc.) to be configured before they'll run properly; triggering an unconfigured plugin this way may fail or do nothing useful.
---
@@ -56,6 +57,10 @@ Visible properties (`CUSTPROP_show: true`) are displayed as interactive icons in
- Enable device removal functionality using `CUSTPROP_type: delete_dev`.
4. **Run a Plugin On Demand (Experimental 🧪)**:
- Use `CUSTPROP_type: run_plugin` with `CUSTPROP_args` set to the target plugin's unique prefix (e.g. `NMAPDEV`) to add a button that triggers that plugin immediately. Make sure the plugin's own settings (credentials, target host, subnet, etc.) are already configured, since none are passed in via this action.
---
## Defining Custom Properties
@@ -97,14 +102,14 @@ Commonly useful fields: `devMac`, `devLastIP`, `devName`, `devVendor`, `devType`
- **Link**: Redirects to a specified URL in the current browser tab. (**Arguments** Needs to contain the full URL.)
- **Link (New Tab)**: Opens a specified URL in a new browser tab. (**Arguments** Needs to contain the full URL.)
- **Delete Device**: Deletes the device using its MAC address.
- **Run Plugin**: Placeholder for executing custom plugins (not implemented yet).
- **Run Plugin (Experimental 🧪)**: Triggers an on-demand run of the plugin named in **Arguments** (its unique prefix, e.g. `NMAPDEV`), regardless of that plugin's configured `RUN` schedule.
---
## Notes
- **Plugin Functionality**: The `run_plugin` action type is currently not implemented and will show an alert if used.
- **Plugin Functionality (Experimental 🧪)**: `run_plugin` requires `CUSTPROP_args` to exactly match an enabled plugin's unique prefix (as listed in the `LOADED_PLUGINS` setting); an unrecognized or disabled prefix is rejected by the backend. It simply re-runs the plugin with its existing configuration - some plugins need required settings/params (e.g. credentials, target host, subnet) filled in first, or the run will fail or silently do nothing. Marked experimental until this is more clearly surfaced in the UI.
- **Custom Icons (Experimental 🧪)**: Use Base64-encoded HTML to provide custom icons for each property. You can add your icons in Setttings via the `CUSTPROP_icon` settings
- **Visibility Control**: Only properties with `CUSTPROP_show: true` will appear on the listing page.
+1 -1
View File
@@ -1308,7 +1308,7 @@ $(document).on('click', '.devicePropAction', function () {
openInNewTab(args);
break;
case "run_plugin":
alert('Not implemented');
runPlugin(args, name);
break;
case "delete_dev":
askDeleteDeviceByMac(mac);
+24
View File
@@ -43,4 +43,28 @@ function deleteDeviceByMac(mac) {
});
}
// -----------------------------------------------------------------------------
// Manually trigger an on-demand plugin run, regardless of its configured RUN
// schedule. Used by the "run_plugin" device custom property action, where
// `prefix` is the plugin's unique_prefix (e.g. NMAPDEV) supplied via CUSTPROP_args.
function runPlugin(prefix, name) {
const apiBase = getApiBase();
const apiToken = getSetting("API_TOKEN");
const url = `${apiBase}/plugin/${encodeURIComponent(prefix)}/run`;
const label = name || prefix;
$.ajax({
url,
method: "POST",
headers: { "Authorization": `Bearer ${apiToken}` },
success: function(response) {
showMessage(response.success ? `Run triggered for ${label}` : (response.error || "Unknown error"));
},
error: function(xhr, status, error) {
console.error("Error running plugin:", status, error);
showMessage("Error: " + (xhr.responseJSON?.error || error));
}
});
}
+30
View File
@@ -2089,6 +2089,36 @@ def api_plugin_stats(payload=None):
return jsonify({"success": True, "data": data})
# --------------------------
# Plugin Run endpoint
# --------------------------
@app.route("/plugin/<prefix>/run", methods=["POST"])
@validate_request(
operation_id="run_plugin",
summary="Run Plugin",
description="Manually trigger an on-demand run of a plugin by its unique prefix, regardless of its "
"configured RUN schedule (e.g. used by the 'Run Plugin' device custom property action).",
path_params=[{
"name": "prefix",
"description": "Plugin unique prefix (e.g. NMAPDEV, ARPSCAN)",
"schema": {"type": "string"}
}],
response_model=BaseResponse,
tags=["plugins"],
validation_error_code=400,
auth_callable=is_authorized
)
def api_run_plugin(prefix, payload=None):
loaded_plugins = get_setting_value('LOADED_PLUGINS')
if prefix not in loaded_plugins:
return jsonify({"success": False, "error": f"Invalid plugin. Must be one of: {', '.join(loaded_plugins)}"}), 400
queue = UserEventsQueueInstance()
queue.add_event(f"run|{prefix}")
return jsonify({"success": True, "message": f"Run triggered for plugin: {prefix}"}), 200
# --------------------------
# Background Server Start
# --------------------------
@@ -0,0 +1,67 @@
"""Tests for /plugin/<prefix>/run endpoint."""
import sys
import os
import pytest
from unittest.mock import patch, MagicMock
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
sys.path.extend([f"{INSTALL_PATH}/server/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_run_plugin_unauthorized(client):
"""Missing token should be forbidden."""
resp = client.post("/plugin/ARPSCAN/run")
assert resp.status_code == 403
assert resp.get_json().get("success") is False
@patch("api_server.api_server_start.UserEventsQueueInstance")
def test_run_plugin_success(mock_queue_class, client, api_token):
"""A known, loaded plugin prefix queues a run event."""
mock_queue = MagicMock()
mock_queue_class.return_value = mock_queue
loaded_plugins = get_setting_value("LOADED_PLUGINS")
prefix = loaded_plugins[0]
resp = client.post(f"/plugin/{prefix}/run", headers=auth_headers(api_token))
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
mock_queue.add_event.assert_called_once()
call_args = mock_queue.add_event.call_args[0]
assert f"run|{prefix}" in call_args[0]
@patch("api_server.api_server_start.UserEventsQueueInstance")
def test_run_plugin_invalid_prefix(mock_queue_class, client, api_token):
"""An unknown plugin prefix is rejected before touching the queue."""
mock_queue = MagicMock()
mock_queue_class.return_value = mock_queue
resp = client.post("/plugin/NOT_A_REAL_PLUGIN/run", headers=auth_headers(api_token))
assert resp.status_code == 400
data = resp.get_json()
assert data["success"] is False
mock_queue.add_event.assert_not_called()