Skip to content

Commit cddb237

Browse files
committed
FE+DOCS: Wildcards in custom props #1773
1 parent 939e649 commit cddb237

7 files changed

Lines changed: 156 additions & 49 deletions

File tree

‎docs/COMMON_ISSUES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Slowness can be caused by:
117117

118118
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.
119119

120-
See how to prevent IP flipping in the [ARPSCAN plugin guide](/server/plugins/arp_scan/README.md).
120+
See how to prevent IP flipping in the [ARPSCAN plugin guide](https://github.com/netalertx/NetAlertX/blob/main/server/plugins/arp_scan/README.md).
121121

122122
Alternatively adjust your [notification settings](./NOTIFICATIONS.md) to prevent false positives by filtering out events or devices.
123123

‎docs/CUSTOM_PROPERTIES.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,30 @@ Custom properties are structured as a list of objects, where each property inclu
6666
|--------------------|-----------------------------------------------------------------------------|
6767
| `CUSTPROP_icon` | The icon (Base64-encoded HTML) displayed for the property. |
6868
| `CUSTPROP_type` | The action type (e.g., `show_notes`, `link`, `delete_dev`). |
69-
| `CUSTPROP_name` | A short name or title for the property. |
70-
| `CUSTPROP_args` | Arguments for the action (e.g., URL or modal text). |
69+
| `CUSTPROP_name` | A short name or title for the property. Supports `{{fieldName}}` wildcards. |
70+
| `CUSTPROP_args` | Arguments for the action (e.g., URL or modal text). Supports `{{fieldName}}` wildcards. |
7171
| `CUSTPROP_notes` | Additional notes or details displayed when applicable. |
7272
| `CUSTPROP_show` | A boolean to control visibility (`true` to show on the listing page). |
7373

7474
---
7575

76+
## Wildcards in `CUSTPROP_name` / `CUSTPROP_args`
77+
78+
`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.
79+
80+
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:
81+
82+
```
83+
CUSTPROP_type: link_new_tab
84+
CUSTPROP_args: http://my_adguard_url/#logs?search={{devLastIP}}
85+
```
86+
87+
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.
88+
89+
Commonly useful fields: `devMac`, `devLastIP`, `devName`, `devVendor`, `devType`, `devGUID`.
90+
91+
---
92+
7693
## Available Action Types
7794

7895
- **Show Notes**: Displays a modal with a title and additional notes.

‎docs/INITIAL_SETUP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Get **NetAlertX** up and running in a few simple steps.
2525

2626
> [!NOTE]
2727
> Configure your SMTP settings or enable additional `▶️ publisher` plugins to send alerts.
28-
> For more flexibility, try [📚 `_publisher_apprise`](/server/plugins/_publisher_apprise/), which supports over 80 notification services.
28+
> For more flexibility, try [📚 `_publisher_apprise`](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/_publisher_apprise/), which supports over 80 notification services.
2929
3030
---
3131

‎docs/PERFORMANCE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ Two plugins help maintain the system’s performance:
3939
### **1. Database Cleanup (DBCLNP)**
4040

4141
* Handles database maintenance and cleanup.
42-
* See the [DB Cleanup Plugin Docs](/server/plugins/db_cleanup/README.md).
42+
* See the [DB Cleanup Plugin Docs](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/db_cleanup/README.md).
4343
* Ensure it’s not failing by checking logs.
4444
* Adjust the schedule (`DBCLNP_RUN_SCHD`) and timeout (`DBCLNP_RUN_TIMEOUT`) if necessary.
4545

4646
### **2. Maintenance (MAINT)**
4747

4848
* Cleans logs and performs general maintenance tasks.
49-
* See the [Maintenance Plugin Docs](/server/plugins/maintenance/README.md).
49+
* See the [Maintenance Plugin Docs](https://github.com/netalertx/NetAlertX/tree/main/server/plugins/maintenance/README.md).
5050
* Verify proper operation via logs.
5151
* Adjust the schedule (`MAINT_RUN_SCHD`) and timeout (`MAINT_RUN_TIMEOUT`) if needed.
5252

‎docs/PLUGINS_DEV_DATA_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Plugins communicate with NetAlertX by writing results to a **pipe-delimited log
1818

1919
## Using `plugin_helper.py`
2020

21-
The easiest way to ensure correct output is to use the [`plugin_helper.py`](../server/plugins/plugin_helper.py) library:
21+
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:
2222

2323
```python
2424
from plugin_helper import Plugin_Objects

‎front/devices.php‎

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,18 @@ function badgeFromRowData(rowData) {
586586
);
587587
}
588588

589+
// ---------------------------------------------------------
590+
// Build a plain {fieldName: value} device object from a DataTables positional
591+
// row - for callers (e.g. renderCustomProps' {{fieldName}} wildcards) that
592+
// need to look up an arbitrary device field by name, not just a fixed few.
593+
function deviceObjectFromRowData(rowData) {
594+
const device = {};
595+
DEVICE_COLUMN_FIELDS.forEach((field, oldIndex) => {
596+
device[field] = rowData[mapIndx(oldIndex)];
597+
});
598+
return device;
599+
}
600+
589601
// ---------------------------------------------------------
590602
// Build the rich empty-table onboarding message (HTML).
591603
// Used as the DataTables 'emptyTable' language option.
@@ -1005,7 +1017,7 @@ function initializeDatatable (status) {
10051017
{targets: [mapIndx(COL.devCustomProps)],
10061018
'createdCell': function (td, cellData, rowData, row, col) {
10071019
if (!emptyArr.includes(cellData)){
1008-
$(td).html (`<span>${renderCustomProps(cellData, rowData[mapIndx(COL.devMac)])}</span>`);
1020+
$(td).html (`<span>${renderCustomProps(cellData, deviceObjectFromRowData(rowData))}</span>`);
10091021
} else {
10101022
$(td).html ('');
10111023
}
@@ -1214,57 +1226,98 @@ function getMacsOfShownDevices() {
12141226

12151227

12161228
// -----------------------------------------------------------------------------
1217-
// Handle custom actions/properties on a device
1218-
function renderCustomProps(custProps, mac) {
1219-
// Decode and parse the custom properties
1229+
// Handle custom actions/properties on a device.
1230+
//
1231+
// CUSTPROP_name and CUSTPROP_args support {{fieldName}} wildcards (e.g.
1232+
// {{devLastIP}}) resolved against this row's own fields - see GH #1773. The
1233+
// resulting action is dispatched via a single delegated click handler reading
1234+
// data-* attributes (see below) rather than an inline onclick="..." string,
1235+
// so a device-controlled value (a DHCP hostname containing a quote, say)
1236+
// can't break out of inline JS the way it could with string-built onclick
1237+
// handlers.
1238+
function renderCustomProps(custProps, device) {
1239+
const mac = device.devMac;
12201240

12211241
if (!isBase64(custProps)) {
1222-
12231242
console.error(`Unable to decode CustomProps for ${mac}`);
12241243
console.error(custProps);
1244+
return "Error, check browser Console log";
1245+
}
12251246

1226-
} else{
1227-
const props = JSON.parse(atob(custProps));
1228-
let html = "";
1229-
1230-
props.forEach((propGroup, index) => {
1231-
const propMap = Object.fromEntries(
1232-
propGroup.map(prop => Object.entries(prop)[0]) // Convert array of objects to key-value pairs
1233-
);
1234-
1235-
if (propMap["CUSTPROP_show"] === true) { // Render if visible
1236-
let onClickEvent = "";
1237-
1238-
switch (propMap["CUSTPROP_type"]) {
1239-
case "show_notes":
1240-
onClickEvent = `showModalOK('${propMap["CUSTPROP_name"]}','${propMap["CUSTPROP_notes"]}')`;
1241-
break;
1242-
case "link":
1243-
onClickEvent = `window.location.href='${propMap["CUSTPROP_args"]}';`;
1244-
break;
1245-
case "link_new_tab":
1246-
onClickEvent = `openInNewTab('${propMap["CUSTPROP_args"]}')`;
1247-
break;
1248-
case "run_plugin":
1249-
onClickEvent = `alert('Not implemented')`;
1250-
break;
1251-
case "delete_dev":
1252-
onClickEvent = `askDeleteDeviceByMac('${mac}')`;
1253-
break;
1254-
default:
1255-
break;
1256-
}
1247+
const props = JSON.parse(atob(custProps));
1248+
let html = "";
12571249

1258-
html += `<div class="pointer devicePropAction" onclick="${onClickEvent}" title="${propMap["CUSTPROP_name"]} ${propMap["CUSTPROP_args"]}"> ${atob(propMap["CUSTPROP_icon"])} </div>`;
1259-
}
1260-
});
1250+
props.forEach((propGroup) => {
1251+
const propMap = Object.fromEntries(
1252+
propGroup.map(prop => Object.entries(prop)[0]) // Convert array of objects to key-value pairs
1253+
);
12611254

1262-
return html;
1263-
}
1255+
if (propMap["CUSTPROP_show"] !== true) {
1256+
return; // Not visible
1257+
}
1258+
1259+
const type = propMap["CUSTPROP_type"];
1260+
const isUrlType = (type === "link" || type === "link_new_tab");
1261+
1262+
// Plain (unescaped) resolved values, for the human-readable tooltip.
1263+
const namePlain = resolveDeviceWildcards(propMap["CUSTPROP_name"], device);
1264+
const argsPlain = resolveDeviceWildcards(propMap["CUSTPROP_args"], device);
1265+
1266+
// The value actually used for navigation: URL-encode substituted fields
1267+
// when this prop's args is a URL, so e.g. a device name with a space or
1268+
// "&" in it can't corrupt the query string.
1269+
const argsForAction = isUrlType
1270+
? resolveDeviceWildcards(propMap["CUSTPROP_args"], device, encodeURIComponent)
1271+
: argsPlain;
1272+
1273+
const notesPlain = propMap["CUSTPROP_notes"] || "";
1274+
1275+
html += `<div class="pointer devicePropAction"
1276+
data-action="${encodeSpecialChars(type)}"
1277+
data-args="${encodeSpecialChars(argsForAction)}"
1278+
data-name="${encodeSpecialChars(namePlain)}"
1279+
data-notes="${encodeSpecialChars(notesPlain)}"
1280+
data-mac="${encodeSpecialChars(mac)}"
1281+
title="${encodeSpecialChars(`${namePlain} ${argsPlain}`)}">
1282+
${atob(propMap["CUSTPROP_icon"])}
1283+
</div>`;
1284+
});
12641285

1265-
return "Error, check browser Console log"
1286+
return html;
12661287
}
12671288

1289+
// Single delegated handler for every custom-property action rendered by
1290+
// renderCustomProps() above - bound once, so it keeps working after
1291+
// DataTables redraws without needing to be re-attached per row.
1292+
$(document).on('click', '.devicePropAction', function () {
1293+
const el = $(this);
1294+
const action = el.data('action');
1295+
const args = el.data('args') ?? '';
1296+
const name = el.data('name') ?? '';
1297+
const notes = el.data('notes') ?? '';
1298+
const mac = el.data('mac') ?? '';
1299+
1300+
switch (action) {
1301+
case "show_notes":
1302+
showModalOK(name, notes);
1303+
break;
1304+
case "link":
1305+
window.location.href = args;
1306+
break;
1307+
case "link_new_tab":
1308+
openInNewTab(args);
1309+
break;
1310+
case "run_plugin":
1311+
alert('Not implemented');
1312+
break;
1313+
case "delete_dev":
1314+
askDeleteDeviceByMac(mac);
1315+
break;
1316+
default:
1317+
break;
1318+
}
1319+
});
1320+
12681321

12691322

12701323
// -----------------------------------------------------------------------------

‎front/js/common.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,43 @@ function getDevicesList()
13811381
return devicesList;
13821382
}
13831383

1384+
// -----------------------------------------------------------------------------
1385+
// Resolve {{fieldName}} wildcards in a template string against a device object
1386+
// (case-insensitive field names, so {{devLastIp}} matches devLastIP). Unknown
1387+
// fields are left as the literal "{{fieldName}}" placeholder so a typo while
1388+
// configuring e.g. a custom property stays visible instead of silently
1389+
// disappearing. escapeFn is applied to each substituted VALUE only (not the
1390+
// surrounding template text) - pass encodeURIComponent when the template is a
1391+
// URL, or leave the default identity function for plain text the caller will
1392+
// HTML-escape itself afterwards.
1393+
function resolveDeviceWildcards(str, device, escapeFn = (v) => v) {
1394+
if (!str || typeof str !== 'string' || str.indexOf('{{') === -1 || !device) {
1395+
return str;
1396+
}
1397+
1398+
const lowerCaseFields = {};
1399+
for (const key in device) {
1400+
lowerCaseFields[key.toLowerCase()] = device[key];
1401+
}
1402+
1403+
return str.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, fieldName) => {
1404+
const lowerFieldName = fieldName.toLowerCase();
1405+
if (!(lowerFieldName in lowerCaseFields)) {
1406+
return match; // unknown field - leave the placeholder visible
1407+
}
1408+
const value = lowerCaseFields[lowerFieldName];
1409+
return escapeFn(value === null || value === undefined ? '' : String(value));
1410+
});
1411+
}
1412+
1413+
// -----------------------------------------------------------------------------
1414+
// Convenience wrapper for callers that only have a MAC, not a full device
1415+
// object already in scope - looks the device up from the cached device list.
1416+
function resolveDeviceWildcardsByMac(str, mac, escapeFn = (v) => v) {
1417+
const device = getDevicesList().find(d => d.devMac === mac);
1418+
return device ? resolveDeviceWildcards(str, device, escapeFn) : str;
1419+
}
1420+
13841421

13851422
// -----------------------------------------------------------------------------
13861423
// apply theme

0 commit comments

Comments
 (0)