Compare commits

...

5 Commits

Author SHA1 Message Date
jokob-sk
5fd30fe3c8 FE
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled
Code checks / check-url-paths (push) Has been cancelled
docker / docker_dev (push) Has been cancelled
2025-08-20 08:41:38 +10:00
jokob-sk
2fa181ffbc api layer v0.2.4 - /nettools endpoint 2025-08-20 08:40:14 +10:00
jokob-sk
a2bccdfb8e FE 2025-08-20 08:11:56 +10:00
jokob-sk
f3b159116f Merge branch 'main' of https://github.com/jokob-sk/NetAlertX 2025-08-20 08:11:05 +10:00
jokob-sk
03b9a9cf0d api layer v0.2.3 - /device(s) endpoints work 2025-08-20 08:10:55 +10:00
8 changed files with 343 additions and 86 deletions

View File

@@ -26,30 +26,31 @@
if (isset ($_REQUEST['action']) && !empty ($_REQUEST['action'])) {
$action = $_REQUEST['action'];
switch ($action) {
case 'getServerDeviceData': getServerDeviceData(); break;
case 'setDeviceData': setDeviceData(); break;
case 'deleteDevice': deleteDevice(); break;
case 'deleteAllWithEmptyMACs': deleteAllWithEmptyMACs(); break;
// check server/api_server/api_server_start.py for equivalents
case 'getServerDeviceData': getServerDeviceData(); break; // equivalent: get_device_data
case 'setDeviceData': setDeviceData(); break; // equivalent: set_device_data
case 'deleteDevice': deleteDevice(); break; // equivalent: delete_device(mac)
case 'deleteAllWithEmptyMACs': deleteAllWithEmptyMACs(); break; // equivalent: delete_all_with_empty_macs
case 'deleteAllDevices': deleteAllDevices(); break;
case 'deleteUnknownDevices': deleteUnknownDevices(); break;
case 'deleteEvents': deleteEvents(); break;
case 'deleteEvents30': deleteEvents30(); break;
case 'deleteActHistory': deleteActHistory(); break;
case 'deleteDeviceEvents': deleteDeviceEvents(); break;
case 'resetDeviceProps': resetDeviceProps(); break;
case 'ExportCSV': ExportCSV(); break; // todo
case 'ImportCSV': ImportCSV(); break; // todo
case 'deleteAllDevices': deleteAllDevices(); break; // equivalent: delete_devices(macs)
case 'deleteUnknownDevices': deleteUnknownDevices(); break; // equivalent: delete_unknown_devices
case 'deleteEvents': deleteEvents(); break; // equivalent: delete_events
case 'deleteEvents30': deleteEvents30(); break; // equivalent: delete_events_30
case 'deleteActHistory': deleteActHistory(); break; // equivalent: delete_online_history
case 'deleteDeviceEvents': deleteDeviceEvents(); break; // equivalent: delete_device_events(mac)
case 'resetDeviceProps': resetDeviceProps(); break; // equivalent: reset_device_props
case 'ExportCSV': ExportCSV(); break; // equivalent: export_devices
case 'ImportCSV': ImportCSV(); break; // equivalent: import_csv
case 'getDevicesTotals': getDevicesTotals(); break; // todo
case 'getDevicesListCalendar': getDevicesListCalendar(); break; // todo
case 'getDevicesTotals': getDevicesTotals(); break; // equivalent: devices_totals
case 'getDevicesListCalendar': getDevicesListCalendar(); break; // equivalent: devices_by_status
case 'updateNetworkLeaf': updateNetworkLeaf(); break; // todo
case 'updateNetworkLeaf': updateNetworkLeaf(); break; // equivalent: update_device_column(mac, column_name, column_value)
case 'copyFromDevice': copyFromDevice(); break;
case 'wakeonlan': wakeonlan(); break; // todo
case 'copyFromDevice': copyFromDevice(); break; // equivalent: copy_device(mac_from, mac_to)
case 'wakeonlan': wakeonlan(); break; // equivalent: wakeonlan
default: logServerConsole ('Action: '. $action); break;
default: logServerConsole ('Action: '. $action); break; // equivalent:
}
}

View File

@@ -3,10 +3,11 @@ from flask import Flask, request, jsonify, Response
from flask_cors import CORS
from .graphql_endpoint import devicesSchema
from .device_endpoint import get_device_data, set_device_data, delete_device, delete_device_events, reset_device_props, copy_device, update_device_column
from .devices_endpoint import delete_unknown_devices, delete_all_with_empty_macs, delete_devices, export_devices, import_csv
from .devices_endpoint import get_all_devices, delete_unknown_devices, delete_all_with_empty_macs, delete_devices, export_devices, import_csv, devices_totals, devices_by_status
from .events_endpoint import delete_events, delete_events_30, get_events
from .history_endpoint import delete_online_history
from .prometheus_endpoint import getMetricStats
from .nettools_endpoint import wakeonlan
from .sync_endpoint import handle_sync_post, handle_sync_get
import sys
@@ -28,6 +29,7 @@ CORS(
r"/device/*": {"origins": "*"},
r"/devices/*": {"origins": "*"},
r"/history/*": {"origins": "*"},
r"/nettools/*": {"origins": "*"},
r"/events/*": {"origins": "*"}
},
supports_credentials=True,
@@ -129,6 +131,12 @@ def api_update_device_column(mac):
# Devices Collections
# --------------------------
@app.route("/devices", methods=["GET"])
def api_get_devices():
if not is_authorized():
return jsonify({"error": "Forbidden"}), 403
return get_all_devices()
@app.route("/devices", methods=["DELETE"])
def api_delete_devices():
if not is_authorized():
@@ -150,12 +158,6 @@ def api_delete_unknown_devices():
return jsonify({"error": "Forbidden"}), 403
return delete_unknown_devices()
@app.route("/devices/totals", methods=["GET"])
def api_get_devices_totals():
if not is_authorized():
return jsonify({"error": "Forbidden"}), 403
return get_devices_totals()
@app.route("/devices/export", methods=["GET"])
@app.route("/devices/export/<format>", methods=["GET"])
@@ -172,6 +174,32 @@ def api_import_csv():
return jsonify({"error": "Forbidden"}), 403
return import_csv(request.files.get("file"))
@app.route("/devices/totals", methods=["GET"])
def api_devices_totals():
if not is_authorized():
return jsonify({"error": "Forbidden"}), 403
return devices_totals()
@app.route("/devices/by-status", methods=["GET"])
def api_devices_by_status():
if not is_authorized():
return jsonify({"error": "Forbidden"}), 403
status = request.args.get("status", "") if request.args else None
return devices_by_status(status)
# --------------------------
# Net tools
# --------------------------
@app.route("/nettools/wakeonlan", methods=["POST"])
def api_wakeonlan():
if not is_authorized():
return jsonify({"error": "Forbidden"}), 403
mac = request.json.get("devMac")
return wakeonlan(mac)
# --------------------------
# Online history
# --------------------------

View File

@@ -161,37 +161,39 @@ def set_device_data(mac, data):
devSourcePlugin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
values = (
mac,
data.get("name", ""),
data.get("owner", ""),
data.get("type", ""),
data.get("vendor", ""),
data.get("icon", ""),
data.get("favorite", 0),
data.get("group", ""),
data.get("location", ""),
data.get("comments", ""),
data.get("networknode", ""),
data.get("networknodeport", ""),
data.get("ssid", ""),
data.get("networksite", ""),
data.get("staticIP", 0),
data.get("scancycle", 0),
data.get("alertevents", 0),
data.get("alertdown", 0),
data.get("relType", "default"),
data.get("reqNics", 0),
data.get("skiprepeated", 0),
data.get("newdevice", 0),
data.get("archived", 0),
data.get("devName", ""),
data.get("devOwner", ""),
data.get("devType", ""),
data.get("devVendor", ""),
data.get("devIcon", ""),
data.get("devFavorite", 0),
data.get("devGroup", ""),
data.get("devLocation", ""),
data.get("devComments", ""),
data.get("devParentMAC", ""),
data.get("devParentPort", ""),
data.get("devSSID", ""),
data.get("devSite", ""),
data.get("devStaticIP", 0),
data.get("devScan", 0),
data.get("devAlertEvents", 0),
data.get("devAlertDown", 0),
data.get("devParentRelType", "default"),
data.get("devReqNicsOnline", 0),
data.get("devSkipRepeated", 0),
data.get("devIsNew", 0),
data.get("devIsArchived", 0),
data.get("devLastConnection", datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
data.get("devFirstConnection", datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
data.get("ip", ""),
data.get("devLastIP", ""),
data.get("devGUID", ""),
data.get("devCustomProps", ""),
"DUMMY"
data.get("devSourcePlugin", "DUMMY"),
)
else:
sql = """
UPDATE Devices SET
@@ -204,31 +206,31 @@ def set_device_data(mac, data):
WHERE devMac=?
"""
values = (
data.get("name", ""),
data.get("owner", ""),
data.get("type", ""),
data.get("vendor", ""),
data.get("icon", ""),
data.get("favorite", 0),
data.get("group", ""),
data.get("location", ""),
data.get("comments", ""),
data.get("networknode", ""),
data.get("networknodeport", ""),
data.get("ssid", ""),
data.get("networksite", ""),
data.get("staticIP", 0),
data.get("scancycle", 0),
data.get("alertevents", 0),
data.get("alertdown", 0),
data.get("relType", "default"),
data.get("reqNics", 0),
data.get("skiprepeated", 0),
data.get("newdevice", 0),
data.get("archived", 0),
data.get("devCustomProps", ""),
mac
)
data.get("devName", ""),
data.get("devOwner", ""),
data.get("devType", ""),
data.get("devVendor", ""),
data.get("devIcon", ""),
data.get("devFavorite", 0),
data.get("devGroup", ""),
data.get("devLocation", ""),
data.get("devComments", ""),
data.get("devParentMAC", ""),
data.get("devParentPort", ""),
data.get("devSSID", ""),
data.get("devSite", ""),
data.get("devStaticIP", 0),
data.get("devScan", 0),
data.get("devAlertEvents", 0),
data.get("devAlertDown", 0),
data.get("devParentRelType", "default"),
data.get("devReqNicsOnline", 0),
data.get("devSkipRepeated", 0),
data.get("devIsNew", 0),
data.get("devIsArchived", 0),
data.get("devCustomProps", ""),
mac
)
conn = get_temp_db_connection()
cur = conn.cursor()

View File

@@ -20,13 +20,27 @@ sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from database import get_temp_db_connection
from helper import is_random_mac, format_date, get_setting_value
from db.db_helper import get_table_json
from db.db_helper import get_table_json, get_device_condition_by_status
# --------------------------
# Device Endpoints Functions
# --------------------------
def get_all_devices():
"""Retrieve all devices from the database."""
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM Devices")
rows = cur.fetchall()
# Convert rows to list of dicts using column names
columns = [col[0] for col in cur.description]
devices = [dict(zip(columns, row)) for row in rows]
conn.close()
return jsonify({"success": True, "devices": devices})
def delete_devices(macs):
"""
Delete devices from the Devices table.
@@ -192,4 +206,58 @@ def import_csv(file_storage=None):
"success": True,
"inserted": row_count,
"skipped_lines": skipped
})
})
def devices_totals():
conn = get_temp_db_connection()
sql = conn.cursor()
# Build a combined query with sub-selects for each status
query = f"""
SELECT
(SELECT COUNT(*) FROM Devices {get_device_condition_by_status('my')}) AS devices,
(SELECT COUNT(*) FROM Devices {get_device_condition_by_status('connected')}) AS connected,
(SELECT COUNT(*) FROM Devices {get_device_condition_by_status('favorites')}) AS favorites,
(SELECT COUNT(*) FROM Devices {get_device_condition_by_status('new')}) AS new,
(SELECT COUNT(*) FROM Devices {get_device_condition_by_status('down')}) AS down,
(SELECT COUNT(*) FROM Devices {get_device_condition_by_status('archived')}) AS archived
"""
sql.execute(query)
row = sql.fetchone() # returns a tuple like (devices, connected, favorites, new, down, archived)
conn.close()
# Return counts as JSON array
return jsonify(list(row))
def devices_by_status(status=None):
"""
Return devices filtered by status.
"""
conn = get_temp_db_connection()
sql = conn.cursor()
# Build condition for SQL
condition = get_device_condition_by_status(status) if status else ""
query = f"SELECT * FROM Devices {condition}"
sql.execute(query)
table_data = []
for row in sql.fetchall():
r = dict(row) # Convert sqlite3.Row to dict for .get()
dev_name = r.get("devName", "")
if r.get("devFavorite") == 1:
dev_name = f'<span class="text-yellow">&#9733</span>&nbsp;{dev_name}'
table_data.append({
"id": r.get("devMac", ""),
"title": dev_name,
"favorite": r.get("devFavorite", 0)
})
conn.close()
return jsonify(table_data)

View File

@@ -0,0 +1,21 @@
import subprocess
import re
from flask import jsonify
def wakeonlan(mac):
# Validate MAC
if not re.match(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$', mac):
return jsonify({"success": False, "error": f"Invalid MAC: {mac}"}), 400
try:
result = subprocess.run(
["wakeonlan", mac],
capture_output=True,
text=True,
check=True
)
return jsonify({"success": True, "message": "WOL packet sent", "output": result.stdout.strip()})
except subprocess.CalledProcessError as e:
return jsonify({"success": False, "error": "Failed to send WOL packet", "details": e.stderr.strip()}), 500

View File

@@ -32,10 +32,10 @@ def auth_headers(token):
def test_create_device(client, api_token, test_mac):
payload = {
"createNew": True,
"name": "Test Device",
"owner": "Unit Test",
"type": "Router",
"vendor": "TestVendor",
"devType": "Test Device",
"devOwner": "Unit Test",
"devType": "Router",
"devVendor": "TestVendor",
}
resp = client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token))
assert resp.status_code == 200
@@ -69,7 +69,7 @@ def test_delete_device(client, api_token, test_mac):
def test_copy_device(client, api_token, test_mac):
# Step 1: Create the source device
payload = {"createNew": True, "name": "Source Device"}
payload = {"createNew": True}
resp = client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token))
assert resp.status_code == 200
assert resp.json.get("success") is True

View File

@@ -34,13 +34,27 @@ def auth_headers(token):
def create_dummy(client, api_token, test_mac):
payload = {
"createNew": True,
"name": "Test Device",
"owner": "Unit Test",
"type": "Router",
"vendor": "TestVendor",
"devName": "Test Device",
"devOwner": "Unit Test",
"devType": "Router",
"devVendor": "TestVendor",
}
resp = client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token))
def test_get_all_devices(client, api_token, test_mac):
# Ensure there is at least one device
create_dummy(client, api_token, test_mac)
# Fetch all devices
resp = client.get("/devices", headers=auth_headers(api_token))
assert resp.status_code == 200
assert resp.json.get("success") is True
devices = resp.json.get("devices")
assert isinstance(devices, list)
# Ensure our test device is in the list
assert any(d["devMac"] == test_mac for d in devices)
def test_delete_devices_with_macs(client, api_token, test_mac):
# First create device so it exists
create_dummy(client, api_token, test_mac)
@@ -105,6 +119,8 @@ def test_export_import_cycle_base64(client, api_token, test_mac):
assert resp.status_code == 200
csv_data = resp.data.decode("utf-8")
print(csv_data)
# Ensure our dummy device is in the CSV
assert test_mac in csv_data
assert "Test Device" in csv_data
@@ -126,6 +142,51 @@ def test_export_import_cycle_base64(client, api_token, test_mac):
assert resp.json.get("inserted") >= 1
assert resp.json.get("skipped_lines") == []
def test_devices_totals(client, api_token, test_mac):
# 1. Create a dummy device
create_dummy(client, api_token, test_mac)
# 2. Call the totals endpoint
resp = client.get("/devices/totals", headers=auth_headers(api_token))
assert resp.status_code == 200
# 3. Ensure the response is a JSON list
data = resp.json
assert isinstance(data, list)
assert len(data) == 6 # devices, connected, favorites, new, down, archived
# 4. Check that at least 1 device exists
assert data[0] >= 1 # 'devices' count includes the dummy device
def test_devices_by_status(client, api_token, test_mac):
# 1. Create a dummy device
create_dummy(client, api_token, test_mac)
# 2. Request devices by a valid status
resp = client.get("/devices/by-status?status=my", headers=auth_headers(api_token))
assert resp.status_code == 200
data = resp.json
assert isinstance(data, list)
assert any(d["id"] == test_mac for d in data)
# 3. Request devices with an invalid/unknown status
resp_invalid = client.get("/devices/by-status?status=invalid_status", headers=auth_headers(api_token))
assert resp_invalid.status_code == 200
# Should return empty list for unknown status
assert resp_invalid.json == []
# 4. Check favorite formatting if devFavorite = 1
# Update dummy device to favorite
client.post(
f"/device/{test_mac}",
json={"devFavorite": 1},
headers=auth_headers(api_token)
)
resp_fav = client.get("/devices/by-status?status=my", headers=auth_headers(api_token))
fav_data = next((d for d in resp_fav.json if d["id"] == test_mac), None)
assert fav_data is not None
assert "&#9733" in fav_data["title"]
def test_delete_test_devices(client, api_token, test_mac):

76
test/test_nettools_endpoints.py Executable file
View File

@@ -0,0 +1,76 @@
import sys
import pathlib
import sqlite3
import base64
import random
import string
import uuid
import pytest
INSTALL_PATH = "/app"
sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"])
from helper import timeNowTZ, get_setting_value
from api_server.api_server_start import app
@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
@pytest.fixture
def test_mac():
# Generate a unique MAC for each test run
return "AA:BB:CC:" + ":".join(f"{random.randint(0,255):02X}" for _ in range(3))
def auth_headers(token):
return {"Authorization": f"Bearer {token}"}
def create_dummy(client, api_token, test_mac):
payload = {
"createNew": True,
"devName": "Test Device",
"devOwner": "Unit Test",
"devType": "Router",
"devVendor": "TestVendor",
}
resp = client.post(f"/device/{test_mac}", json=payload, headers=auth_headers(api_token))
def test_wakeonlan_device(client, api_token, test_mac):
# 1. Ensure at least one device exists
create_dummy(client, api_token, test_mac)
# 2. Fetch all devices
resp = client.get("/devices", headers=auth_headers(api_token))
assert resp.status_code == 200
devices = resp.json.get("devices", [])
assert len(devices) > 0
# 3. Pick the first device (or the test device)
device_mac = devices[0]["devMac"]
# 4. Call the wakeonlan endpoint
resp = client.post(
"/nettools/wakeonlan",
json={"devMac": device_mac},
headers=auth_headers(api_token)
)
# 5. Conditional assertions based on MAC
if device_mac.lower() == 'internet' or device_mac == test_mac:
# For athe dummy "internet" or test MAC, expect a 400 response
assert resp.status_code == 400
else:
# For any other MAC, expect a 200 response
assert resp.status_code == 200
data = resp.json
assert data.get("success") is True
assert "WOL packet sent" in data.get("message", "")