Merge branch 'next_release' of https://github.com/netalertx/NetAlertX into next_release

This commit is contained in:
Jokob @NetAlertX
2026-03-15 01:42:23 +00:00
158 changed files with 7576 additions and 2892 deletions

View File

@@ -61,8 +61,8 @@ class DeviceInstance:
def getDown(self):
return self._fetchall("""
SELECT * FROM Devices
WHERE devAlertDown = 1 AND devPresentLastScan = 0
SELECT * FROM DevicesView
WHERE devAlertDown = 1 AND devPresentLastScan = 0 AND devIsSleeping = 0
""")
def getOffline(self):
@@ -338,7 +338,7 @@ class DeviceInstance:
for key, condition in conditions.items():
# Make sure the alias is SQL-safe (no spaces or special chars)
alias = key.replace(" ", "_").lower()
sub_queries.append(f'(SELECT COUNT(*) FROM Devices {condition}) AS "{alias}"')
sub_queries.append(f'(SELECT COUNT(*) FROM DevicesView {condition}) AS "{alias}"')
# Join all sub-selects with commas
query = "SELECT\n " + ",\n ".join(sub_queries)
@@ -360,7 +360,7 @@ class DeviceInstance:
for key, condition in conditions.items():
# Make sure the alias is SQL-safe (no spaces or special chars)
alias = key.replace(" ", "_").lower()
sub_queries.append(f'(SELECT COUNT(*) FROM Devices {condition}) AS "{alias}"')
sub_queries.append(f'(SELECT COUNT(*) FROM DevicesView {condition}) AS "{alias}"')
# Join all sub-selects with commas
query = "SELECT\n " + ",\n ".join(sub_queries)
@@ -381,7 +381,8 @@ class DeviceInstance:
# Build condition for SQL
condition = get_device_condition_by_status(status) if status else ""
query = f"SELECT * FROM Devices {condition}"
# Only DevicesView has devFlapping
query = f"SELECT * FROM DevicesView {condition}"
sql.execute(query)
table_data = []
@@ -453,7 +454,9 @@ class DeviceInstance:
"devPresenceHours": 0,
"devFQDN": "",
"devForceStatus" : "dont_force",
"devVlan": ""
"devVlan": "",
"devCanSleep": 0,
"devIsSleeping": 0
}
return device_data
@@ -462,44 +465,41 @@ class DeviceInstance:
# Fetch device info + computed fields
sql = f"""
SELECT
d.*,
CASE
WHEN d.devAlertDown != 0 AND d.devPresentLastScan = 0 THEN 'Down'
WHEN d.devPresentLastScan = 1 THEN 'On-line'
ELSE 'Off-line'
END AS devStatus,
SELECT
d.*,
LOWER(d.devMac) AS devMac,
LOWER(d.devParentMAC) AS devParentMAC,
(SELECT COUNT(*) FROM Sessions
WHERE ses_MAC = d.devMac AND (
ses_DateTimeConnection >= {period_date_sql} OR
ses_DateTimeDisconnection >= {period_date_sql} OR
ses_StillConnected = 1
)) AS devSessions,
(SELECT COUNT(*) FROM Sessions
WHERE LOWER(ses_MAC) = LOWER(d.devMac) AND (
ses_DateTimeConnection >= {period_date_sql} OR
ses_DateTimeDisconnection >= {period_date_sql} OR
ses_StillConnected = 1
)) AS devSessions,
(SELECT COUNT(*) FROM Events
WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql}
AND eve_EventType NOT IN ('Connected','Disconnected')) AS devEvents,
(SELECT COUNT(*) FROM Events
WHERE LOWER(eve_MAC) = LOWER(d.devMac) AND eve_DateTime >= {period_date_sql}
AND eve_EventType NOT IN ('Connected','Disconnected')) AS devEvents,
(SELECT COUNT(*) FROM Events
WHERE eve_MAC = d.devMac AND eve_DateTime >= {period_date_sql}
AND eve_EventType = 'Device Down') AS devDownAlerts,
(SELECT COUNT(*) FROM Events
WHERE LOWER(eve_MAC) = LOWER(d.devMac) AND eve_DateTime >= {period_date_sql}
AND eve_EventType = 'Device Down') AS devDownAlerts,
(SELECT CAST(MAX(0, SUM(
julianday(IFNULL(ses_DateTimeDisconnection,'{now}')) -
julianday(CASE WHEN ses_DateTimeConnection < {period_date_sql}
THEN {period_date_sql} ELSE ses_DateTimeConnection END)
) * 24) AS INT)
FROM Sessions
WHERE ses_MAC = d.devMac
AND ses_DateTimeConnection IS NOT NULL
AND (ses_DateTimeDisconnection IS NOT NULL OR ses_StillConnected = 1)
AND (ses_DateTimeConnection >= {period_date_sql}
OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1)
) AS devPresenceHours
(SELECT CAST(MAX(0, SUM(
julianday(IFNULL(ses_DateTimeDisconnection,'{now}')) -
julianday(CASE WHEN ses_DateTimeConnection < {period_date_sql}
THEN {period_date_sql} ELSE ses_DateTimeConnection END)
) * 24) AS INT)
FROM Sessions
WHERE LOWER(ses_MAC) = LOWER(d.devMac)
AND ses_DateTimeConnection IS NOT NULL
AND (ses_DateTimeDisconnection IS NOT NULL OR ses_StillConnected = 1)
AND (ses_DateTimeConnection >= {period_date_sql}
OR ses_DateTimeDisconnection >= {period_date_sql} OR ses_StillConnected = 1)
) AS devPresenceHours
FROM Devices d
WHERE d.devMac = ? OR CAST(d.rowid AS TEXT) = ?
FROM DevicesView d
WHERE LOWER(d.devMac) = LOWER(?) OR CAST(d.rowid AS TEXT) = ?
"""
conn = get_temp_db_connection()
@@ -567,7 +567,8 @@ class DeviceInstance:
"devIsArchived",
"devCustomProps",
"devForceStatus",
"devVlan"
"devVlan",
"devCanSleep"
}
# Only mark USER for tracked fields that this method actually updates.
@@ -613,12 +614,12 @@ class DeviceInstance:
devMac, devName, devOwner, devType, devVendor, devIcon,
devFavorite, devGroup, devLocation, devComments,
devParentMAC, devParentPort, devSSID, devSite,
devStaticIP, devScan, devAlertEvents, devAlertDown,
devStaticIP, devScan, devAlertEvents, devAlertDown, devCanSleep,
devParentRelType, devReqNicsOnline, devSkipRepeated,
devIsNew, devIsArchived, devLastConnection,
devFirstConnection, devLastIP, devGUID, devCustomProps,
devSourcePlugin, devForceStatus, devVlan
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
values = (
@@ -640,6 +641,7 @@ class DeviceInstance:
data.get("devScan") or 0,
data.get("devAlertEvents") or 0,
data.get("devAlertDown") or 0,
data.get("devCanSleep") or 0,
data.get("devParentRelType") or "default",
data.get("devReqNicsOnline") or 0,
data.get("devSkipRepeated") or 0,
@@ -661,7 +663,7 @@ class DeviceInstance:
devName=?, devOwner=?, devType=?, devVendor=?, devIcon=?,
devFavorite=?, devGroup=?, devLocation=?, devComments=?,
devParentMAC=?, devParentPort=?, devSSID=?, devSite=?,
devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?,
devStaticIP=?, devScan=?, devAlertEvents=?, devAlertDown=?, devCanSleep=?,
devParentRelType=?, devReqNicsOnline=?, devSkipRepeated=?,
devIsNew=?, devIsArchived=?, devCustomProps=?, devForceStatus=?, devVlan=?
WHERE devMac=?
@@ -684,6 +686,7 @@ class DeviceInstance:
data.get("devScan") or 0,
data.get("devAlertEvents") or 0,
data.get("devAlertDown") or 0,
data.get("devCanSleep") or 0,
data.get("devParentRelType") or "default",
data.get("devReqNicsOnline") or 0,
data.get("devSkipRepeated") or 0,
@@ -817,9 +820,9 @@ class DeviceInstance:
conn = get_temp_db_connection()
cur = conn.cursor()
# Build safe SQL with column name
sql = f"UPDATE Devices SET {column_name}=? WHERE devMac=?"
cur.execute(sql, (column_value, mac))
# Convert the MAC to lowercase for comparison
sql = f"UPDATE Devices SET {column_name}=? WHERE LOWER(devMac)=?"
cur.execute(sql, (column_value, mac.lower()))
conn.commit()
if cur.rowcount > 0:

View File

@@ -218,3 +218,50 @@ class EventInstance:
# Return as list
return [row[0], row[1], row[2], row[3], row[4], row[5]]
def get_unstable_devices(self, hours: int = 1, threshold: int = 3, macs_only: bool = True):
"""
Return unstable devices based on flap detection.
A device is considered unstable if it has >= threshold events within the last `hours`.
Events considered:
- Connected
- Disconnected
- Device Down
- Down Reconnected
Args:
hours (int): Time window in hours (default: 1)
threshold (int): Minimum number of events to be considered unstable (default: 3)
macs_only (bool): If True, return only MAC addresses (set). Otherwise return full rows.
Returns:
set[str] OR list[dict]
"""
if hours <= 0 or threshold <= 0:
mylog("warn", f"[Events] get_unstable_devices invalid params: hours={hours}, threshold={threshold}")
return set() if macs_only else []
conn = self._conn()
sql = """
SELECT eve_MAC, COUNT(*) as event_count
FROM Events
WHERE eve_EventType IN ('Connected','Disconnected','Device Down','Down Reconnected')
AND eve_DateTime >= datetime('now', ?)
GROUP BY eve_MAC
HAVING COUNT(*) >= ?
"""
# SQLite expects "-1 hours" format
window = f"-{hours} hours"
rows = conn.execute(sql, (window, threshold)).fetchall()
conn.close()
if macs_only:
return {row["eve_MAC"] for row in rows}
return [dict(row) for row in rows]

View File

@@ -1,4 +1,5 @@
import json
import re
import uuid
import socket
from yattag import indent
@@ -307,8 +308,16 @@ def construct_notifications(JSON, section):
build_direction = "TOP_TO_BOTTOM"
text_line = "{}\t{}\n"
# Read template settings
show_headers = get_setting_value("NTFPRCS_TEXT_SECTION_HEADERS")
if show_headers is None or show_headers == "":
show_headers = True
text_template = get_setting_value(f"NTFPRCS_TEXT_TEMPLATE_{section}") or ""
if len(jsn) > 0:
text = tableTitle + "\n---------\n"
# Section header (text)
if show_headers:
text = tableTitle + "\n---------\n"
# Convert a JSON into an HTML table
html = convert(
@@ -325,13 +334,24 @@ def construct_notifications(JSON, section):
)
# prepare text-only message
for device in jsn:
for header in headers:
padding = ""
if len(header) < 4:
padding = "\t"
text += text_line.format(header + ": " + padding, device[header])
text += "\n"
if text_template:
# Custom template: replace {FieldName} placeholders per device
for device in jsn:
line = re.sub(
r'\{(.+?)\}',
lambda m: str(device.get(m.group(1), m.group(0))),
text_template,
)
text += line + "\n"
else:
# Legacy fallback: vertical Header: Value list
for device in jsn:
for header in headers:
padding = ""
if len(header) < 4:
padding = "\t"
text += text_line.format(header + ": " + padding, device[header])
text += "\n"
# Format HTML table headers
for header in headers:

View File

@@ -49,7 +49,7 @@ class PluginObjectInstance:
"SELECT * FROM Plugins_Objects WHERE Plugin = ?", (plugin,)
)
def getLastNCreatedPerPLugin(self, plugin, entries=1):
def getLastNCreatedPerPlugin(self, plugin, entries=1):
return self._fetchall(
"""
SELECT *