diff --git a/docs/COMMON_ISSUES.md b/docs/COMMON_ISSUES.md index 85a7d42a..a7b033c3 100755 --- a/docs/COMMON_ISSUES.md +++ b/docs/COMMON_ISSUES.md @@ -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. diff --git a/docs/CUSTOM_PROPERTIES.md b/docs/CUSTOM_PROPERTIES.md index c8c29b32..bcd7b9fa 100755 --- a/docs/CUSTOM_PROPERTIES.md +++ b/docs/CUSTOM_PROPERTIES.md @@ -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. diff --git a/docs/INITIAL_SETUP.md b/docs/INITIAL_SETUP.md index cd524aa5..c8ced5da 100755 --- a/docs/INITIAL_SETUP.md +++ b/docs/INITIAL_SETUP.md @@ -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. --- diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 4753f38b..2a6c1996 100755 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -39,14 +39,14 @@ Two plugins help maintain the system’s 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 it’s 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. diff --git a/docs/PLUGINS_DEV_DATA_CONTRACT.md b/docs/PLUGINS_DEV_DATA_CONTRACT.md index a73a06ab..c2ad2f14 100644 --- a/docs/PLUGINS_DEV_DATA_CONTRACT.md +++ b/docs/PLUGINS_DEV_DATA_CONTRACT.md @@ -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 diff --git a/front/devices.php b/front/devices.php index ab956628..52a38fd1 100755 --- a/front/devices.php +++ b/front/devices.php @@ -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 (`${renderCustomProps(cellData, rowData[mapIndx(COL.devMac)])}`); + $(td).html (`${renderCustomProps(cellData, deviceObjectFromRowData(rowData))}`); } 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 += `
${atob(propMap["CUSTPROP_icon"])}
`; - } - }); - - 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 += `
+ ${atob(propMap["CUSTPROP_icon"])} +
`; + }); + + 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; + } +}); + // ----------------------------------------------------------------------------- diff --git a/front/js/common.js b/front/js/common.js index 13cc8340..fb45fce6 100755 --- a/front/js/common.js +++ b/front/js/common.js @@ -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