FE+DOCS: Wildcards in custom props #1773

This commit is contained in:
jokob-sk committed 2026-09-05 10:36:19 +10:00
1 parent 939e6494ba
commit cddb237cd3
7 files changed
+158 -51

No files matched your search

+1 -1
View File
@@ -117,7 +117,7 @@ Slowness can be caused by:
With `ARPSCAN` scans some devices might flip IP addresses after each scan triggering false notifications. This is because some devices respond to broadcast calls and thus different IPs after scans are logged.
See how to prevent IP flipping in the [ARPSCAN plugin guide](/server/plugins/arp_scan/README.md).
See how to prevent IP flipping in the [ARPSCAN plugin guide](https://github.com/netalertx/NetAlertX/blob/main/server/plugins/arp_scan/README.md).
Alternatively adjust your [notification settings](./NOTIFICATIONS.md) to prevent false positives by filtering out events or devices.
+19 -2
View File
@@ -66,13 +66,30 @@ Custom properties are structured as a list of objects, where each property inclu
|--------------------|-----------------------------------------------------------------------------|
| `CUSTPROP_icon` | The icon (Base64-encoded HTML) displayed for the property. |
| `CUSTPROP_type` | The action type (e.g., `show_notes`, `link`, `delete_dev`). |
| `CUSTPROP_name` | A short name or title for the property. |
| `CUSTPROP_args` | Arguments for the action (e.g., URL or modal text). |
| `CUSTPROP_name` | A short name or title for the property. Supports `{{fieldName}}` wildcards. |
| `CUSTPROP_args` | Arguments for the action (e.g., URL or modal text). Supports `{{fieldName}}` wildcards. |
| `CUSTPROP_notes` | Additional notes or details displayed when applicable. |
| `CUSTPROP_show` | A boolean to control visibility (`true` to show on the listing page). |
---
## Wildcards in `CUSTPROP_name` / `CUSTPROP_args`
`CUSTPROP_name` and `CUSTPROP_args` are resolved per-device before rendering, so you can reference any of that device's own fields with `{{fieldName}}` - field names are matched case-insensitively, so `{{devLastIp}}` and `{{devLastIP}}` are equivalent. If a field name doesn't exist, the placeholder is left as-is (e.g. `{{devTypo}}` stays visible) rather than silently disappearing, to make a typo obvious while you're setting one up.
This is what makes a single `link`/`link_new_tab` custom property work across every device rather than one URL per device - e.g. to jump to a device's traffic log in an AdGuard Home instance, filtered to that device's IP:
```
CUSTPROP_type: link_new_tab
CUSTPROP_args: http://my_adguard_url/#logs?search={{devLastIP}}
```
Because this is set once (either directly on a device, or as a default applied to every new device via the `NEWDEV_devCustomProps` setting), it applies across your whole device list, not just one device at a time.
Commonly useful fields: `devMac`, `devLastIP`, `devName`, `devVendor`, `devType`, `devGUID`.
---
## Available Action Types
- **Show Notes**: Displays a modal with a title and additional notes.
+1 -1
View File
@@ -25,7 +25,7 @@ Get **NetAlertX** up and running in a few simple steps.
> [!NOTE]
> Configure your SMTP settings or enable additional `▶️ publisher` plugins to send alerts.
> For more flexibility, try [📚 `_publisher_apprise`](/server/plugins/_publisher_apprise/), which supports over 80 notification services.
> For more flexibility, try [📚 `_publisher_apprise`](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_apprise/), which supports over 80 notification services.
---
+2 -2
View File
@@ -39,14 +39,14 @@ Two plugins help maintain the systems performance:
### **1. Database Cleanup (DBCLNP)**
* Handles database maintenance and cleanup.
* See the [DB Cleanup Plugin Docs](/server/plugins/db_cleanup/README.md).
* See the [DB Cleanup Plugin Docs](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/db_cleanup/README.md).
* Ensure its not failing by checking logs.
* Adjust the schedule (`DBCLNP_RUN_SCHD`) and timeout (`DBCLNP_RUN_TIMEOUT`) if necessary.
### **2. Maintenance (MAINT)**
* Cleans logs and performs general maintenance tasks.
* See the [Maintenance Plugin Docs](/server/plugins/maintenance/README.md).
* See the [Maintenance Plugin Docs](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/maintenance/README.md).
* Verify proper operation via logs.
* Adjust the schedule (`MAINT_RUN_SCHD`) and timeout (`MAINT_RUN_TIMEOUT`) if needed.
+1 -1
View File
@@ -18,7 +18,7 @@ Plugins communicate with NetAlertX by writing results to a **pipe-delimited log
## Using `plugin_helper.py`
The easiest way to ensure correct output is to use the [`plugin_helper.py`](../server/plugins/plugin_helper.py) library:
The easiest way to ensure correct output is to use the [`plugin_helper.py`](https://github.com/netalertx/NetAlertX/blob/main/server/plugins/plugin_helper.py) library:
```python
from plugin_helper import Plugin_Objects
+97 -44
View File
@@ -586,6 +586,18 @@ function badgeFromRowData(rowData) {
);
}
// ---------------------------------------------------------
// Build a plain {fieldName: value} device object from a DataTables positional
// row - for callers (e.g. renderCustomProps' {{fieldName}} wildcards) that
// need to look up an arbitrary device field by name, not just a fixed few.
function deviceObjectFromRowData(rowData) {
const device = {};
DEVICE_COLUMN_FIELDS.forEach((field, oldIndex) => {
device[field] = rowData[mapIndx(oldIndex)];
});
return device;
}
// ---------------------------------------------------------
// Build the rich empty-table onboarding message (HTML).
// Used as the DataTables 'emptyTable' language option.
@@ -1005,7 +1017,7 @@ function initializeDatatable (status) {
{targets: [mapIndx(COL.devCustomProps)],
'createdCell': function (td, cellData, rowData, row, col) {
if (!emptyArr.includes(cellData)){
$(td).html (`<span>${renderCustomProps(cellData, rowData[mapIndx(COL.devMac)])}</span>`);
$(td).html (`<span>${renderCustomProps(cellData, deviceObjectFromRowData(rowData))}</span>`);
} else {
$(td).html ('');
}
@@ -1214,57 +1226,98 @@ function getMacsOfShownDevices() {
// -----------------------------------------------------------------------------
// Handle custom actions/properties on a device
function renderCustomProps(custProps, mac) {
// Decode and parse the custom properties
// Handle custom actions/properties on a device.
//
// CUSTPROP_name and CUSTPROP_args support {{fieldName}} wildcards (e.g.
// {{devLastIP}}) resolved against this row's own fields - see GH #1773. The
// resulting action is dispatched via a single delegated click handler reading
// data-* attributes (see below) rather than an inline onclick="..." string,
// so a device-controlled value (a DHCP hostname containing a quote, say)
// can't break out of inline JS the way it could with string-built onclick
// handlers.
function renderCustomProps(custProps, device) {
const mac = device.devMac;
if (!isBase64(custProps)) {
console.error(`Unable to decode CustomProps for ${mac}`);
console.error(custProps);
} else{
const props = JSON.parse(atob(custProps));
let html = "";
props.forEach((propGroup, index) => {
const propMap = Object.fromEntries(
propGroup.map(prop => Object.entries(prop)[0]) // Convert array of objects to key-value pairs
);
if (propMap["CUSTPROP_show"] === true) { // Render if visible
let onClickEvent = "";
switch (propMap["CUSTPROP_type"]) {
case "show_notes":
onClickEvent = `showModalOK('${propMap["CUSTPROP_name"]}','${propMap["CUSTPROP_notes"]}')`;
break;
case "link":
onClickEvent = `window.location.href='${propMap["CUSTPROP_args"]}';`;
break;
case "link_new_tab":
onClickEvent = `openInNewTab('${propMap["CUSTPROP_args"]}')`;
break;
case "run_plugin":
onClickEvent = `alert('Not implemented')`;
break;
case "delete_dev":
onClickEvent = `askDeleteDeviceByMac('${mac}')`;
break;
default:
break;
}
html += `<div class="pointer devicePropAction" onclick="${onClickEvent}" title="${propMap["CUSTPROP_name"]} ${propMap["CUSTPROP_args"]}"> ${atob(propMap["CUSTPROP_icon"])} </div>`;
}
});
return html;
return "Error, check browser Console log";
}
return "Error, check browser Console log"
const props = JSON.parse(atob(custProps));
let html = "";
props.forEach((propGroup) => {
const propMap = Object.fromEntries(
propGroup.map(prop => Object.entries(prop)[0]) // Convert array of objects to key-value pairs
);
if (propMap["CUSTPROP_show"] !== true) {
return; // Not visible
}
const type = propMap["CUSTPROP_type"];
const isUrlType = (type === "link" || type === "link_new_tab");
// Plain (unescaped) resolved values, for the human-readable tooltip.
const namePlain = resolveDeviceWildcards(propMap["CUSTPROP_name"], device);
const argsPlain = resolveDeviceWildcards(propMap["CUSTPROP_args"], device);
// The value actually used for navigation: URL-encode substituted fields
// when this prop's args is a URL, so e.g. a device name with a space or
// "&" in it can't corrupt the query string.
const argsForAction = isUrlType
? resolveDeviceWildcards(propMap["CUSTPROP_args"], device, encodeURIComponent)
: argsPlain;
const notesPlain = propMap["CUSTPROP_notes"] || "";
html += `<div class="pointer devicePropAction"
data-action="${encodeSpecialChars(type)}"
data-args="${encodeSpecialChars(argsForAction)}"
data-name="${encodeSpecialChars(namePlain)}"
data-notes="${encodeSpecialChars(notesPlain)}"
data-mac="${encodeSpecialChars(mac)}"
title="${encodeSpecialChars(`${namePlain} ${argsPlain}`)}">
${atob(propMap["CUSTPROP_icon"])}
</div>`;
});
return html;
}
// Single delegated handler for every custom-property action rendered by
// renderCustomProps() above - bound once, so it keeps working after
// DataTables redraws without needing to be re-attached per row.
$(document).on('click', '.devicePropAction', function () {
const el = $(this);
const action = el.data('action');
const args = el.data('args') ?? '';
const name = el.data('name') ?? '';
const notes = el.data('notes') ?? '';
const mac = el.data('mac') ?? '';
switch (action) {
case "show_notes":
showModalOK(name, notes);
break;
case "link":
window.location.href = args;
break;
case "link_new_tab":
openInNewTab(args);
break;
case "run_plugin":
alert('Not implemented');
break;
case "delete_dev":
askDeleteDeviceByMac(mac);
break;
default:
break;
}
});
// -----------------------------------------------------------------------------
+37
View File
@@ -1381,6 +1381,43 @@ function getDevicesList()
return devicesList;
}
// -----------------------------------------------------------------------------
// Resolve {{fieldName}} wildcards in a template string against a device object
// (case-insensitive field names, so {{devLastIp}} matches devLastIP). Unknown
// fields are left as the literal "{{fieldName}}" placeholder so a typo while
// configuring e.g. a custom property stays visible instead of silently
// disappearing. escapeFn is applied to each substituted VALUE only (not the
// surrounding template text) - pass encodeURIComponent when the template is a
// URL, or leave the default identity function for plain text the caller will
// HTML-escape itself afterwards.
function resolveDeviceWildcards(str, device, escapeFn = (v) => v) {
if (!str || typeof str !== 'string' || str.indexOf('{{') === -1 || !device) {
return str;
}
const lowerCaseFields = {};
for (const key in device) {
lowerCaseFields[key.toLowerCase()] = device[key];
}
return str.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, fieldName) => {
const lowerFieldName = fieldName.toLowerCase();
if (!(lowerFieldName in lowerCaseFields)) {
return match; // unknown field - leave the placeholder visible
}
const value = lowerCaseFields[lowerFieldName];
return escapeFn(value === null || value === undefined ? '' : String(value));
});
}
// -----------------------------------------------------------------------------
// Convenience wrapper for callers that only have a MAC, not a full device
// object already in scope - looks the device up from the cached device list.
function resolveDeviceWildcardsByMac(str, mac, escapeFn = (v) => v) {
const device = getDevicesList().find(d => d.devMac === mac);
return device ? resolveDeviceWildcards(str, device, escapeFn) : str;
}
// -----------------------------------------------------------------------------
// apply theme