mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-07-25 15:08:10 -04:00
@@ -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 {
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user