Merge pull request #1708 from netalertx/next_release

Next release
This commit is contained in:
Jokob @NetAlertX
2026-07-09 19:15:39 +10:00
committed by GitHub
10 changed files with 116 additions and 52 deletions

View File

@@ -1011,7 +1011,7 @@ height: 50px;
display: block;
position: absolute;
font-size: x-small;
top: -5px;
top: 5px;
}
/* --------------------------------------------------------- */
@@ -2221,7 +2221,7 @@ textarea[readonly],
.pluginsCore .helpIcon
{
margin-left: -20px;
margin-left: -15px;
font-size: x-small;
}
@@ -2607,21 +2607,47 @@ table.dataTable tbody > tr.selected
}
/* -----------------------------------------------------------------------------
Donations
Device -> Details
----------------------------------------------------------------------------- */
.donations .box
#panDetails
{
padding:15px;
margin-bottom: 0px;
padding:10px;
}
.donations .box-header
/* -----------------------------------------------------------------------------
Device -> Tools
----------------------------------------------------------------------------- */
#panTools
{
color:15px;
padding:10px;
}
.donations h3
/* -----------------------------------------------------------------------------
Device -> Sessions
----------------------------------------------------------------------------- */
#panSessions
{
margin-top: 10px;
padding:10px;
}
/* -----------------------------------------------------------------------------
Device -> Presence
----------------------------------------------------------------------------- */
#panPresence
{
padding:10px;
}
/* -----------------------------------------------------------------------------
Device -> Events
----------------------------------------------------------------------------- */
#panEvents
{
padding:10px;
}
/* -----------------------------------------------------------------------------
Field Lock/Unlock Buttons & Source Indicators
----------------------------------------------------------------------------- */
@@ -2660,8 +2686,14 @@ table.dataTable tbody > tr.selected
----------------------------------------------------------------------------- */
.change-log .helpIcon{
font-size: x-small;
top: 65px;
}
#panHistory .change-log .helpIcon{
left: 15px;
font-size: x-small;
top: 45px;
}
#panHistory
@@ -2669,6 +2701,11 @@ table.dataTable tbody > tr.selected
min-height:850px
}
#navDevice .tab-content
{
padding:0px;
}
#panHistory .change-log-skeleton-class
{
margin-left: 0px;
@@ -2729,7 +2766,7 @@ table.dataTable tbody > tr.selected
#settings-skeleton, #devices-skeleton, #device-details-skeleton,
#events-skeleton, #presence-skeleton, #report-skeleton, #skel-app-events,
#notifications-skeleton, #workflows-skeleton, #plugins-skeleton { z-index: 50; }
.skel-tab-pane { margin: -10px; z-index: 10; min-height: 400px; background-color: var(--skel-panel-bg);}
.skel-tab-pane { z-index: 10; min-height: 400px; background-color: var(--skel-panel-bg);}
#settings-skeleton { margin: 15px; min-height: 500px; }
#devices-skeleton { margin: 15px; top: 50px; min-height: 500px; }
#skel-app-events { margin: 15px; top: 50px; min-height: 500px; z-index: inherit;}
@@ -2741,7 +2778,7 @@ table.dataTable tbody > tr.selected
#workflows-skeleton { top: 50px; width: 770px; margin: 0 auto; min-height: 300px; }
#plugins-skeleton { top: 50px; padding: 0; min-height: 400px; }
#change-log-skeleton { display: block; min-height: 400px; z-index: 1; position: absolute; margin-left: 15px; padding-top: 15px; width:100%;}
#skel-tab-sessions, #skel-tab-events, #skel-tab-presence {margin: -10px; padding: 20px;}
#skel-tab-sessions, #skel-tab-events, #skel-tab-presence { padding: 20px;}
/* Hide scrollbars */
#skel-tab-sessions, #skel-tab-events, #skel-tab-presence {

View File

@@ -100,12 +100,12 @@ def get_device_data(site, api):
# --- Devices ---
unifi_devices_resp = api.get_unifi_devices(site_id)
unifi_devices = unifi_devices_resp.get("data", [])
mylog('verbose', [f'[{pluginName}] Site: {site_name} unifi devices: {json.dumps(unifi_devices_resp, indent=2)}'])
mylog('trace', [f'[{pluginName}] Site: {site_name} unifi devices: {json.dumps(unifi_devices_resp, indent=2)}'])
# --- Clients ---
clients_resp = api.get_clients(site_id)
clients = clients_resp.get("data", [])
mylog('verbose', [f'[{pluginName}] Site: {site_name} clients: {json.dumps(clients_resp, indent=2)}'])
mylog('trace', [f'[{pluginName}] Site: {site_name} clients: {json.dumps(clients_resp, indent=2)}'])
# Build a lookup for devices by their 'id' to find parent MAC easily
device_id_to_mac = {}

View File

@@ -1,6 +1,7 @@
<html>
<head></head>
<body>
<span style="display:none;">PREHEADER</span>
<font face=sans-serif>
<table align=center width=100% cellpadding=0 cellspacing=0 style="border-radius: 5px;">
<tr>
@@ -10,7 +11,7 @@
</td>
</tr>
<tr>
<td height=200 valign=top style="padding: 10px">
<td height=200 valign=top style="padding: 10px">
NEW_DEVICES_TABLE
DOWN_DEVICES_TABLE
DOWN_RECONNECTED_TABLE

View File

@@ -221,8 +221,8 @@ def get_notifications(db):
sqlQuery = template.format(**fallback_vars)
parameters = {}
mylog("debug", [f"[Notification] {section} SQL query: ", sqlQuery])
mylog("debug", [f"[Notification] {section} parameters: ", parameters])
mylog("trace", [f"[Notification] {section} SQL query: ", sqlQuery])
mylog("trace", [f"[Notification] {section} parameters: ", parameters])
try:
json_obj = db.get_table_as_json(sqlQuery, parameters)

View File

@@ -206,12 +206,6 @@ class DevicesHistoryInstance:
clauses, params = self._build_clauses(devGUID, changedColumn, changedBy, search)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
mylog("none", f"[HISTORY] SQL {where} params={params}")
mylog("none", f"[HISTORY] sort={sort}")
mylog("none", f"[HISTORY] sort type={type(sort)}")
mylog("none", f"[HISTORY] search {search} ")
mylog("none", f"[HISTORY] FINAL CALL limit={limit} offset={offset} sort={sort}")
rows = self._fetchall(
f"""
SELECT devGUID, timestamp, changedBy, changedColumn, oldValue, newValue

View File

@@ -126,9 +126,14 @@ class NotificationInstance:
mail_text = mail_text.replace("REPORT_DASHBOARD_URL", self.serverUrl)
mail_html = mail_html.replace("REPORT_DASHBOARD_URL", self.serverUrl)
# Preheaders for preview
preheaders = []
# Generate TEXT & HTML for each notification section
for section in SECTION_ORDER:
html, text = construct_notifications(self.JSON, section)
html, text, preheader = construct_notifications(self.JSON, section)
if preheader:
preheaders.append(preheader)
placeholder = f"{section.upper()}_TABLE"
mail_text = mail_text.replace(placeholder, text + "\n")
mail_html = mail_html.replace(placeholder, html)
@@ -141,6 +146,11 @@ class NotificationInstance:
mail_html, conf.REPORT_DASHBOARD_URL + "/deviceDetails.php?mac="
)
# Add Preheaders for preview after mac links created
mail_html = mail_html.replace(
"PREHEADER", "".join(preheaders)
)
final_html = indent(
mail_html, indentation=" ", newline="\r\n", indent_text=True
)
@@ -276,24 +286,23 @@ class NotificationInstance:
# -----------------------------------------------------------------------------
# Reporting
# -----------------------------------------------------------------------------
# ------------------------------------------------------------------------------
def construct_notifications(JSON, section):
jsn = JSON[section]
# Return if empty
if jsn == []:
return "", ""
return "", "", ""
tableTitle = JSON[section + "_meta"]["title"]
headers = JSON[section + "_meta"]["columnNames"]
html = ""
text = ""
preheader_preview = ""
table_attributes = {
"style": "border-collapse: collapse; font-size: 12px; color:#70707",
"style": "border-collapse: collapse; font-size: 12px; color: #70707;",
"width": "100%",
"cellspacing": 0,
"cellpadding": "3px",
@@ -326,6 +335,9 @@ def construct_notifications(JSON, section):
table_attributes=table_attributes,
)
# pre-header in emails (html) to show a more valuable preview
preheader_preview = build_preheader(tableTitle, jsn, headers)
# Cleanup the generated HTML table notification
html = (
format_table(html, "data", headerProps, tableTitle)
@@ -357,7 +369,7 @@ def construct_notifications(JSON, section):
for header in headers:
html = format_table(html, header, thProps)
return html, text
return html, text, preheader_preview
# -----------------------------------------------------------------------------
@@ -378,3 +390,26 @@ def format_table(html, thValue, props, newThValue=""):
return html.replace(
"<th>" + thValue + "</th>", "<th " + props + " >" + newThValue + "</th>"
)
# -----------------------------------------------------------------------------
# Pre-header Preview
def build_preheader(tableTitle, jsn, headers):
previews = []
# pre-headers for email previews
preview_fields = headers[:3]
for entry in jsn[:3]:
vals = [
str(entry[h])
for h in preview_fields
if entry.get(h)
]
previews.append("".join(vals))
suffix = ""
if len(jsn) > 3:
suffix = f" (+{len(jsn) - 3} more)"
return f"{tableTitle}: " + "; ".join(previews) + suffix

View File

@@ -56,7 +56,7 @@ class ConditionGroup:
"""Handles condition groups with AND, OR logic, supporting nested groups."""
def __init__(self, group_json):
mylog("verbose", f"[WF] ConditionGroup json.dumps(group_json): {json.dumps(group_json)}")
mylog("trace", f"[WF] ConditionGroup json.dumps(group_json): {json.dumps(group_json)}")
self.logic = group_json.get("logic", "AND").upper()
self.conditions = []
@@ -76,5 +76,5 @@ class ConditionGroup:
return any(results)
else:
m = f"[WF] ConditionGroup unsupported logic: {self.logic}"
mylog("verbose", [m])
mylog("none", [m])
raise ValueError(m)

View File

@@ -28,7 +28,6 @@ class WorkflowManager:
# -------------------------------------------------------------------------
# Token validation
def _validate_workflow_tokens(self, workflow):
"""Recursively scan a workflow dict for {{trigger.X}} tokens.
Returns True if every token maps to a valid Devices column."""
@@ -52,7 +51,6 @@ class WorkflowManager:
# -------------------------------------------------------------------------
# Loading
def load_workflows(self):
"""Load workflows from workflows.json, rejecting any with invalid tokens."""
try:
@@ -87,7 +85,6 @@ class WorkflowManager:
# -------------------------------------------------------------------------
# Event processing
def process_event(self, event):
"""Process one AppEvent against all enabled workflows."""
evGuid = event["guid"]
@@ -116,7 +113,6 @@ class WorkflowManager:
# -------------------------------------------------------------------------
# Workflow execution
def execute_workflow(self, workflow, trigger):
"""Execute workflow actions if any condition group evaluates to True."""
wfName = workflow["name"]
@@ -130,11 +126,11 @@ class WorkflowManager:
evaluator = ConditionGroup(condition_group)
if evaluator.evaluate(trigger):
mylog("none", f"[WF] Workflow {wfName} will be executed - conditions were evaluated as TRUE")
mylog("debug", [f"[WF] Workflow condition_group: {condition_group}"])
mylog("trace", [f"[WF] Workflow condition_group: {condition_group}"])
self.execute_actions(workflow["actions"], trigger)
return
mylog("none", ["[WF] No condition group matched. Actions not executed."])
mylog("trace", ["[WF] No condition group matched. Actions not executed."])
def _resolve_target_devices(self, action, trigger_device):
"""Return the list of device dicts that the action should be applied to.
@@ -180,7 +176,7 @@ class WorkflowManager:
target_devices = self._resolve_target_devices(action, trigger_obj)
if not target_devices:
mylog("debug", [f"[WF] No target devices matched for action '{action_type}'"])
mylog("trace", [f"[WF] No target devices matched for action '{action_type}'"])
continue
for target_device in target_devices:

View File

@@ -24,7 +24,7 @@ class Trigger:
self.object_type == event["objectType"] and self.event_type == event["appEventType"]
)
mylog("debug", f"""[WF] self.triggered '{self.triggered}' for event '{get_array_from_sql_rows(event)} and trigger {json.dumps(triggerJson)}' """)
mylog("trace", f"""[WF] self.triggered '{self.triggered}' for event '{get_array_from_sql_rows(event)} and trigger {json.dumps(triggerJson)}' """)
if self.triggered:
# object type corresponds with the DB table name
@@ -45,10 +45,10 @@ class Trigger:
WHERE {refField} = '{event["objectGuid"]}'
"""
mylog("debug", [query])
mylog("trace", [query])
result = db.sql.execute(query).fetchall()
if len(result) > 0:
self.object = result[0]
else:

View File

@@ -74,9 +74,10 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
mock_setting.return_value = ""
json_data = _make_json("new_devices", [], [])
html, text = construct_notifications(json_data, "new_devices")
html, text, preheader_preview = construct_notifications(json_data, "new_devices")
self.assertEqual(html, "")
self.assertEqual(text, "")
self.assertEqual(preheader_preview, "")
# -----------------------------------------------------------------
# Legacy fallback: no template → vertical Header: Value per device
@@ -93,7 +94,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
html, text = construct_notifications(json_data, "new_devices")
html, text, preheader_preview = construct_notifications(json_data, "new_devices")
# Section header must be present
self.assertIn("🆕 New devices", text)
@@ -123,7 +124,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
_, text = construct_notifications(json_data, "new_devices")
_, text, _ = construct_notifications(json_data, "new_devices")
self.assertIn("MyPhone (aa:bb:cc:dd:ee:ff) - 192.168.1.42", text)
self.assertIn("Laptop (11:22:33:44:55:66) - 192.168.1.99", text)
@@ -143,7 +144,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
_, text = construct_notifications(json_data, "new_devices")
_, text, _ = construct_notifications(json_data, "new_devices")
self.assertIn("MyPhone - {NonExistent}", text)
self.assertIn("Laptop - {NonExistent}", text)
@@ -163,7 +164,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
_, text = construct_notifications(json_data, "new_devices")
_, text, _ = construct_notifications(json_data, "new_devices")
self.assertNotIn("🆕 New devices", text)
self.assertNotIn("---------", text)
@@ -183,7 +184,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
_, text = construct_notifications(json_data, "new_devices")
_, text, _ = construct_notifications(json_data, "new_devices")
# Headers should be shown by default
self.assertIn("🆕 New devices", text)
@@ -204,7 +205,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
_, text = construct_notifications(json_data, "new_devices")
_, text, _ = construct_notifications(json_data, "new_devices")
self.assertIn("MyPhone ({BadField}) - 192.168.1.42", text)
@@ -234,7 +235,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
columns = ["devName", "eveMac", "devVendor", "eveIp", "eveDateTime", "eveEventType", "devComments"]
json_data = _make_json("down_devices", down_devices, columns, "🔴 Down devices")
_, text = construct_notifications(json_data, "down_devices")
_, text, _ = construct_notifications(json_data, "down_devices")
self.assertIn("Router (ff:ee:dd:cc:bb:aa) down since 2025-01-15 08:00:00", text)
@@ -264,7 +265,7 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
columns = ["devName", "eveMac", "devVendor", "eveIp", "eveDateTime", "eveEventType", "devComments"]
json_data = _make_json("down_reconnected", reconnected, columns, "🔁 Reconnected down devices")
_, text = construct_notifications(json_data, "down_reconnected")
_, text, _ = construct_notifications(json_data, "down_reconnected")
self.assertIn("Switch (aa:11:bb:22:cc:33) reconnected at 2025-01-15 09:30:00", text)
@@ -283,14 +284,14 @@ class TestConstructNotificationsTemplates(unittest.TestCase):
json_data = _make_json(
"new_devices", SAMPLE_NEW_DEVICES, NEW_DEVICE_COLUMNS, "🆕 New devices"
)
html_without, _ = construct_notifications(json_data, "new_devices")
html_without, _, _ = construct_notifications(json_data, "new_devices")
# Get HTML with template
mock_setting.side_effect = self._setting_factory({
"NTFPRCS_TEXT_SECTION_HEADERS": True,
"NTFPRCS_TEXT_TEMPLATE_new_devices": "{devName} ({eveMac})",
})
html_with, _ = construct_notifications(json_data, "new_devices")
html_with, _, _ = construct_notifications(json_data, "new_devices")
self.assertEqual(html_without, html_with)