From 256d658a35c0c848c43a8ccdf7fb9dd391039c86 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 24 Aug 2026 11:23:20 +0100 Subject: [PATCH 01/13] Add PRTG Network Monitor plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low-code plugin for the PRTG v1 HTTP API, indexing probes, groups, devices and sensors, with ten data streams and six out-of-the-box dashboards (estate overview, sites, and a perspective per object type). Notes on a few non-obvious choices, all confirmed against a live PRTG 26.3 instance: - Reads the `_raw` variant of every text column. PRTG's display columns carry markup — `message` is `
OK
`, `lastcheck` appends `[165 s ago]`. - `table.json` OLE datetimes are UTC, so they convert directly. `historicdata.json` reports only server-local wall-clock text with no UTC equivalent, which is why the PRTG time zone is configurable and why that stream needs a post-request script. - `content=channels` HTML-escapes `lastvalue` where `content=sensors` does not, so only Sensor Channels decodes entities. - Sensor History averages hourly beyond seven days. Measured at the 5-minute default, a 30-day range on a nine-channel sensor unpivots to roughly 7 MB and exceeds the response size limit. - Sensor counts come back as empty strings rather than zero, hence the `Number(x) || 0` guards in System Status. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CODEOWNERS | 1 + cspell.json | 4 +- plugins/PRTG/v1/configValidation.json | 11 + plugins/PRTG/v1/custom_types.json | 30 ++ .../PRTG/v1/dataStreams/containerDevices.json | 183 +++++++++ .../PRTG/v1/dataStreams/containerSensors.json | 232 +++++++++++ plugins/PRTG/v1/dataStreams/devices.json | 171 ++++++++ plugins/PRTG/v1/dataStreams/groups.json | 159 ++++++++ plugins/PRTG/v1/dataStreams/logs.json | 91 +++++ plugins/PRTG/v1/dataStreams/probes.json | 122 ++++++ .../v1/dataStreams/scripts/sensorHistory.js | 81 ++++ .../PRTG/v1/dataStreams/sensorChannels.json | 66 +++ .../PRTG/v1/dataStreams/sensorHistory.json | 112 +++++ plugins/PRTG/v1/dataStreams/sensors.json | 219 ++++++++++ plugins/PRTG/v1/dataStreams/systemStatus.json | 124 ++++++ .../PRTG/v1/defaultContent/device.dash.json | 234 +++++++++++ .../PRTG/v1/defaultContent/group.dash.json | 270 +++++++++++++ plugins/PRTG/v1/defaultContent/manifest.json | 28 ++ .../PRTG/v1/defaultContent/overview.dash.json | 381 ++++++++++++++++++ .../PRTG/v1/defaultContent/probe.dash.json | 270 +++++++++++++ .../PRTG/v1/defaultContent/sensor.dash.json | 161 ++++++++ .../PRTG/v1/defaultContent/sites.dash.json | 374 +++++++++++++++++ plugins/PRTG/v1/docs/README.md | 133 ++++++ plugins/PRTG/v1/icon.svg | 32 ++ plugins/PRTG/v1/indexDefinitions/default.json | 131 ++++++ plugins/PRTG/v1/metadata.json | 50 +++ plugins/PRTG/v1/scopes.json | 50 +++ plugins/PRTG/v1/ui.json | 80 ++++ 28 files changed, 3799 insertions(+), 1 deletion(-) create mode 100644 plugins/PRTG/v1/configValidation.json create mode 100644 plugins/PRTG/v1/custom_types.json create mode 100644 plugins/PRTG/v1/dataStreams/containerDevices.json create mode 100644 plugins/PRTG/v1/dataStreams/containerSensors.json create mode 100644 plugins/PRTG/v1/dataStreams/devices.json create mode 100644 plugins/PRTG/v1/dataStreams/groups.json create mode 100644 plugins/PRTG/v1/dataStreams/logs.json create mode 100644 plugins/PRTG/v1/dataStreams/probes.json create mode 100644 plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js create mode 100644 plugins/PRTG/v1/dataStreams/sensorChannels.json create mode 100644 plugins/PRTG/v1/dataStreams/sensorHistory.json create mode 100644 plugins/PRTG/v1/dataStreams/sensors.json create mode 100644 plugins/PRTG/v1/dataStreams/systemStatus.json create mode 100644 plugins/PRTG/v1/defaultContent/device.dash.json create mode 100644 plugins/PRTG/v1/defaultContent/group.dash.json create mode 100644 plugins/PRTG/v1/defaultContent/manifest.json create mode 100644 plugins/PRTG/v1/defaultContent/overview.dash.json create mode 100644 plugins/PRTG/v1/defaultContent/probe.dash.json create mode 100644 plugins/PRTG/v1/defaultContent/sensor.dash.json create mode 100644 plugins/PRTG/v1/defaultContent/sites.dash.json create mode 100644 plugins/PRTG/v1/docs/README.md create mode 100644 plugins/PRTG/v1/icon.svg create mode 100644 plugins/PRTG/v1/indexDefinitions/default.json create mode 100644 plugins/PRTG/v1/metadata.json create mode 100644 plugins/PRTG/v1/scopes.json create mode 100644 plugins/PRTG/v1/ui.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c44719cf..886cf3ae 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ plugins/Huntress/* @Deenk plugins/FantasyPremierLeague/* @TimWheeler-SQUP plugins/GoogleSheets/* @kieranlangton plugins/MetOffice/* @blackgrouse +plugins/PRTG/* @Deenk plugins/Phare/* @vinbab plugins/Postcoder/* @richbenwell plugins/RDAP/* @richbenwell diff --git a/cspell.json b/cspell.json index d563d67b..ab612a7f 100644 --- a/cspell.json +++ b/cspell.json @@ -5,6 +5,8 @@ "rdap", "whois", "rootly", - "UniFi" + "UniFi", + "PRTG", + "Paessler" ] } \ No newline at end of file diff --git a/plugins/PRTG/v1/configValidation.json b/plugins/PRTG/v1/configValidation.json new file mode 100644 index 00000000..c8f38187 --- /dev/null +++ b/plugins/PRTG/v1/configValidation.json @@ -0,0 +1,11 @@ +{ + "steps": [ + { + "displayName": "Authenticate", + "dataStream": { "name": "systemStatus" }, + "required": true, + "error": "Could not connect to PRTG. Check the PRTG URL is reachable from SquaredUp, and that the API key is correct and has not been deleted.", + "success": "Connected to PRTG successfully." + } + ] +} diff --git a/plugins/PRTG/v1/custom_types.json b/plugins/PRTG/v1/custom_types.json new file mode 100644 index 00000000..828e26ad --- /dev/null +++ b/plugins/PRTG/v1/custom_types.json @@ -0,0 +1,30 @@ +[ + { + "name": "PRTG Probe", + "sourceType": "PRTG Probe", + "icon": "tower-broadcast", + "singular": "Probe", + "plural": "Probes" + }, + { + "name": "PRTG Group", + "sourceType": "PRTG Group", + "icon": "sitemap", + "singular": "Group", + "plural": "Groups" + }, + { + "name": "PRTG Device", + "sourceType": "PRTG Device", + "icon": "server", + "singular": "Device", + "plural": "Devices" + }, + { + "name": "PRTG Sensor", + "sourceType": "PRTG Sensor", + "icon": "gauge", + "singular": "Sensor", + "plural": "Sensors" + } +] diff --git a/plugins/PRTG/v1/dataStreams/containerDevices.json b/plugins/PRTG/v1/dataStreams/containerDevices.json new file mode 100644 index 00000000..f2bf6b51 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/containerDevices.json @@ -0,0 +1,183 @@ +{ + "name": "containerDevices", + "displayName": "Devices in Probe or Group", + "description": "Devices beneath a probe or group, with status, host address and sensor counts by state", + "tags": [ + "Infrastructure", + "Status" + ], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "devices" + }, + { + "key": "id", + "value": "{{object.rawId}}" + }, + { + "key": "columns", + "value": "objid,name,parentid,probe,group,host,status,active,tags,location,icon,totalsens,upsens,downsens,warnsens,pausedsens" + }, + { + "key": "sortby", + "value": "objid" + }, + { + "key": "count", + "value": "50000" + } + ], + "pathToData": "devices", + "paging": { + "mode": "none" + } + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "PRTG Probe", + "PRTG Group" + ] + } + }, + "metadata": [ + { + "name": "objid", + "displayName": "ID", + "shape": "string", + "role": "id" + }, + { + "name": "name", + "displayName": "Device", + "shape": "string", + "role": "label" + }, + { + "name": "parentid", + "displayName": "Parent ID", + "shape": "string", + "visible": false + }, + { + "name": "probe", + "displayName": "Probe", + "shape": "string" + }, + { + "name": "group", + "displayName": "Group", + "shape": "string" + }, + { + "name": "host", + "displayName": "Host", + "shape": "string" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Up" + ], + "warning": [ + "Warning", + "Unusual" + ], + "error": [ + "Down", + "Down (Partial)", + "Down (Acknowledged)", + "No Probe", + "Not Connected" + ], + "unknown": [ + "Unknown", + "None", + "Scanning" + ], + "unmonitored": [ + "Paused", + "Paused by User", + "Paused by Dependency", + "Paused by Schedule", + "Paused by License", + "Paused until" + ] + } + } + ] + }, + { + "name": "status_raw", + "displayName": "Status Code", + "shape": "number", + "visible": false + }, + { + "name": "statusText", + "displayName": "Status Text", + "computed": true, + "shape": "string", + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + }, + { + "name": "totalsens_raw", + "displayName": "Total Sensors", + "shape": "number", + "role": "value" + }, + { + "name": "upsens_raw", + "displayName": "Up Sensors", + "shape": "number" + }, + { + "name": "downsens_raw", + "displayName": "Down Sensors", + "shape": "number" + }, + { + "name": "warnsens_raw", + "displayName": "Warning Sensors", + "shape": "number" + }, + { + "name": "pausedsens_raw", + "displayName": "Paused Sensors", + "shape": "number" + }, + { + "name": "location_raw", + "displayName": "Location", + "shape": "string" + }, + { + "name": "tags", + "displayName": "Tags", + "shape": "string" + }, + { + "name": "icon", + "displayName": "Icon", + "shape": "string", + "visible": false + }, + { + "name": "active", + "displayName": "Monitoring Active", + "shape": "boolean" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/containerSensors.json b/plugins/PRTG/v1/dataStreams/containerSensors.json new file mode 100644 index 00000000..ebfd05cf --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/containerSensors.json @@ -0,0 +1,232 @@ +{ + "name": "containerSensors", + "displayName": "Sensors in Probe, Group or Device", + "description": "Sensors beneath a probe, group or device, with status, last reading and uptime", + "tags": [ + "Monitoring", + "Status" + ], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "sensors" + }, + { + "key": "id", + "value": "{{object.rawId}}" + }, + { + "key": "columns", + "value": "objid,name,parentid,probe,group,device,type,status,message,lastvalue,lastcheck,interval,priority,active,tags,uptime,downtime" + }, + { + "key": "sortby", + "value": "objid" + }, + { + "key": "count", + "value": "50000" + } + ], + "pathToData": "sensors", + "paging": { + "mode": "none" + } + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "PRTG Probe", + "PRTG Group", + "PRTG Device" + ] + } + }, + "metadata": [ + { + "name": "objid", + "displayName": "ID", + "shape": "string", + "role": "id" + }, + { + "name": "name", + "displayName": "Sensor", + "shape": "string", + "role": "label" + }, + { + "name": "parentid", + "displayName": "Device ID", + "shape": "string", + "visible": false + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "group", + "displayName": "Group", + "shape": "string" + }, + { + "name": "probe", + "displayName": "Probe", + "shape": "string" + }, + { + "name": "type", + "displayName": "Sensor Type", + "shape": "string" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Up" + ], + "warning": [ + "Warning", + "Unusual" + ], + "error": [ + "Down", + "Down (Partial)", + "Down (Acknowledged)", + "No Probe", + "Not Connected" + ], + "unknown": [ + "Unknown", + "None", + "Scanning" + ], + "unmonitored": [ + "Paused", + "Paused by User", + "Paused by Dependency", + "Paused by Schedule", + "Paused by License", + "Paused until" + ] + } + } + ] + }, + { + "name": "status_raw", + "displayName": "Status Code", + "shape": "number", + "visible": false + }, + { + "name": "statusText", + "displayName": "Status Text", + "computed": true, + "shape": "string", + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + }, + { + "name": "message_raw", + "displayName": "Message", + "shape": "string" + }, + { + "name": "lastvalue", + "displayName": "Last Value", + "shape": "string" + }, + { + "name": "lastvalue_raw", + "displayName": "Last Value (Numeric)", + "shape": "number" + }, + { + "name": "lastcheck_raw", + "displayName": "Last Check (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "lastCheck", + "displayName": "Last Check", + "computed": true, + "shape": "date", + "valueExpression": "{{ Number($['lastcheck_raw']) > 0 ? new Date((Number($['lastcheck_raw']) - 25569) * 86400000).toISOString() : null }}" + }, + { + "name": "uptime_raw", + "displayName": "Uptime (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "uptimePercent", + "displayName": "Uptime", + "computed": true, + "role": "value", + "shape": [ + "percent", + { + "decimalPlaces": 2 + } + ], + "valueExpression": "{{ $['uptime_raw'] === '' || $['uptime_raw'] === null || $['uptime_raw'] === undefined ? null : Number($['uptime_raw']) / 10000 }}" + }, + { + "name": "downtime_raw", + "displayName": "Downtime (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "downtimePercent", + "displayName": "Downtime", + "computed": true, + "shape": [ + "percent", + { + "decimalPlaces": 2 + } + ], + "valueExpression": "{{ $['downtime_raw'] === '' || $['downtime_raw'] === null || $['downtime_raw'] === undefined ? null : Number($['downtime_raw']) / 10000 }}" + }, + { + "name": "interval_raw", + "displayName": "Scanning Interval", + "shape": "seconds" + }, + { + "name": "priority_raw", + "displayName": "Priority", + "shape": "number" + }, + { + "name": "tags", + "displayName": "Tags", + "shape": "string" + }, + { + "name": "active", + "displayName": "Monitoring Active", + "shape": "boolean" + }, + { + "sourceId": "parentid", + "sourceType": "PRTG Device", + "name": "device" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/devices.json b/plugins/PRTG/v1/dataStreams/devices.json new file mode 100644 index 00000000..67f61485 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/devices.json @@ -0,0 +1,171 @@ +{ + "name": "devices", + "displayName": "Devices", + "description": "All PRTG devices with their status, host address and sensor counts by state", + "tags": [ + "Infrastructure", + "Status" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "devices" + }, + { + "key": "columns", + "value": "objid,name,parentid,probe,group,host,status,active,tags,location,icon,totalsens,upsens,downsens,warnsens,pausedsens" + }, + { + "key": "sortby", + "value": "objid" + }, + { + "key": "count", + "value": "50000" + } + ], + "pathToData": "devices", + "paging": { + "mode": "none" + } + }, + "matches": "none", + "metadata": [ + { + "name": "objid", + "displayName": "ID", + "shape": "string", + "role": "id" + }, + { + "name": "name", + "displayName": "Device", + "shape": "string", + "role": "label" + }, + { + "name": "parentid", + "displayName": "Parent ID", + "shape": "string", + "visible": false + }, + { + "name": "probe", + "displayName": "Probe", + "shape": "string" + }, + { + "name": "group", + "displayName": "Group", + "shape": "string" + }, + { + "name": "host", + "displayName": "Host", + "shape": "string" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Up" + ], + "warning": [ + "Warning", + "Unusual" + ], + "error": [ + "Down", + "Down (Partial)", + "Down (Acknowledged)", + "No Probe", + "Not Connected" + ], + "unknown": [ + "Unknown", + "None", + "Scanning" + ], + "unmonitored": [ + "Paused", + "Paused by User", + "Paused by Dependency", + "Paused by Schedule", + "Paused by License", + "Paused until" + ] + } + } + ] + }, + { + "name": "status_raw", + "displayName": "Status Code", + "shape": "number", + "visible": false + }, + { + "name": "statusText", + "displayName": "Status Text", + "computed": true, + "shape": "string", + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + }, + { + "name": "totalsens_raw", + "displayName": "Total Sensors", + "shape": "number", + "role": "value" + }, + { + "name": "upsens_raw", + "displayName": "Up Sensors", + "shape": "number" + }, + { + "name": "downsens_raw", + "displayName": "Down Sensors", + "shape": "number" + }, + { + "name": "warnsens_raw", + "displayName": "Warning Sensors", + "shape": "number" + }, + { + "name": "pausedsens_raw", + "displayName": "Paused Sensors", + "shape": "number" + }, + { + "name": "location_raw", + "displayName": "Location", + "shape": "string" + }, + { + "name": "tags", + "displayName": "Tags", + "shape": "string" + }, + { + "name": "icon", + "displayName": "Icon", + "shape": "string", + "visible": false + }, + { + "name": "active", + "displayName": "Monitoring Active", + "shape": "boolean" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/groups.json b/plugins/PRTG/v1/dataStreams/groups.json new file mode 100644 index 00000000..2a4f10c7 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/groups.json @@ -0,0 +1,159 @@ +{ + "name": "groups", + "displayName": "Groups", + "description": "All PRTG groups with their status and sensor counts by state", + "tags": [ + "Infrastructure", + "Status" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "groups" + }, + { + "key": "filter_objid", + "value": "@neq(0)" + }, + { + "key": "columns", + "value": "objid,name,parentid,probe,status,active,totalsens,upsens,downsens,warnsens,pausedsens,location,tags" + }, + { + "key": "sortby", + "value": "objid" + }, + { + "key": "count", + "value": "50000" + } + ], + "pathToData": "groups", + "paging": { + "mode": "none" + } + }, + "matches": "none", + "metadata": [ + { + "name": "objid", + "displayName": "ID", + "shape": "string", + "role": "id" + }, + { + "name": "name", + "displayName": "Group", + "shape": "string", + "role": "label" + }, + { + "name": "parentid", + "displayName": "Parent ID", + "shape": "string", + "visible": false + }, + { + "name": "probe", + "displayName": "Probe", + "shape": "string" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Up" + ], + "warning": [ + "Warning", + "Unusual" + ], + "error": [ + "Down", + "Down (Partial)", + "Down (Acknowledged)", + "No Probe", + "Not Connected" + ], + "unknown": [ + "Unknown", + "None", + "Scanning" + ], + "unmonitored": [ + "Paused", + "Paused by User", + "Paused by Dependency", + "Paused by Schedule", + "Paused by License", + "Paused until" + ] + } + } + ] + }, + { + "name": "status_raw", + "displayName": "Status Code", + "shape": "number", + "visible": false + }, + { + "name": "statusText", + "displayName": "Status Text", + "computed": true, + "shape": "string", + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + }, + { + "name": "totalsens_raw", + "displayName": "Total Sensors", + "shape": "number", + "role": "value" + }, + { + "name": "upsens_raw", + "displayName": "Up Sensors", + "shape": "number" + }, + { + "name": "downsens_raw", + "displayName": "Down Sensors", + "shape": "number" + }, + { + "name": "warnsens_raw", + "displayName": "Warning Sensors", + "shape": "number" + }, + { + "name": "pausedsens_raw", + "displayName": "Paused Sensors", + "shape": "number" + }, + { + "name": "active", + "displayName": "Monitoring Active", + "shape": "boolean" + }, + { + "name": "location_raw", + "displayName": "Location", + "shape": "string" + }, + { + "name": "tags", + "displayName": "Tags", + "shape": "string" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/logs.json b/plugins/PRTG/v1/dataStreams/logs.json new file mode 100644 index 00000000..0eddc708 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/logs.json @@ -0,0 +1,91 @@ +{ + "name": "logs", + "displayName": "Log", + "description": "PRTG log entries with the object they relate to and the event that was recorded", + "tags": [ + "Events", + "Status" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "messages" + }, + { + "key": "columns", + "value": "objid,datetime,parent,type,name,status,message" + }, + { + "key": "filter_dstart", + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.start); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + }, + { + "key": "filter_dend", + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.end); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + }, + { + "key": "count", + "value": "5000" + } + ], + "pathToData": "messages", + "paging": { + "mode": "none" + } + }, + "matches": "none", + "metadata": [ + { + "name": "datetime_raw", + "displayName": "Time (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "time", + "displayName": "Time", + "computed": true, + "shape": "date", + "role": "timestamp", + "valueExpression": "{{ Number($['datetime_raw']) > 0 ? new Date((Number($['datetime_raw']) - 25569) * 86400000).toISOString() : null }}" + }, + { + "name": "objid", + "displayName": "Object ID", + "shape": "string", + "visible": false + }, + { + "name": "name", + "displayName": "Object", + "shape": "string", + "role": "label" + }, + { + "name": "parent", + "displayName": "Parent", + "shape": "string" + }, + { + "name": "type", + "displayName": "Type", + "shape": "string" + }, + { + "name": "status", + "displayName": "Event", + "shape": "string" + }, + { + "name": "message_raw", + "displayName": "Message", + "shape": "string" + } + ], + "timeframes": true, + "defaultTimeframe": "dashboard" +} diff --git a/plugins/PRTG/v1/dataStreams/probes.json b/plugins/PRTG/v1/dataStreams/probes.json new file mode 100644 index 00000000..ce6c5b31 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/probes.json @@ -0,0 +1,122 @@ +{ + "name": "probes", + "displayName": "Probes", + "description": "All PRTG probes with their status and connection state", + "tags": [ + "Infrastructure", + "Status" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "probes" + }, + { + "key": "filter_type", + "value": "probenode" + }, + { + "key": "columns", + "value": "objid,name,status,condition,active,location" + }, + { + "key": "sortby", + "value": "objid" + }, + { + "key": "count", + "value": "50000" + } + ], + "pathToData": "probes", + "paging": { + "mode": "none" + } + }, + "matches": "none", + "metadata": [ + { + "name": "objid", + "displayName": "ID", + "shape": "string", + "role": "id" + }, + { + "name": "name", + "displayName": "Probe", + "shape": "string", + "role": "label" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Up" + ], + "warning": [ + "Warning", + "Unusual" + ], + "error": [ + "Down", + "Down (Partial)", + "Down (Acknowledged)", + "No Probe", + "Not Connected" + ], + "unknown": [ + "Unknown", + "None", + "Scanning" + ], + "unmonitored": [ + "Paused", + "Paused by User", + "Paused by Dependency", + "Paused by Schedule", + "Paused by License", + "Paused until" + ] + } + } + ] + }, + { + "name": "status_raw", + "displayName": "Status Code", + "shape": "number", + "visible": false + }, + { + "name": "statusText", + "displayName": "Status Text", + "computed": true, + "shape": "string", + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + }, + { + "name": "condition", + "displayName": "Connection", + "shape": "string" + }, + { + "name": "active", + "displayName": "Monitoring Active", + "shape": "boolean" + }, + { + "name": "location_raw", + "displayName": "Location", + "shape": "string" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js new file mode 100644 index 00000000..c7905748 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js @@ -0,0 +1,81 @@ +// PRTG returns historic data pivoted: each sensor channel becomes its own +// dynamically-named column, so the column set differs per sensor (a disk sensor +// yields "Free Space C:", a ping sensor "Response Time"). A declared-column data +// stream cannot express that, so unpivot to one row per channel per interval. +// +// Timestamps need care. Unlike table.json's `*_raw` columns — which are OLE +// dates in UTC — historicdata.json only ever returns `datetime` as a string in +// the PRTG server's *local* time, with no raw/epoch equivalent. Emitting that +// as-is would put this stream an hour (or more) away from `Last Check` and the +// Log stream on the same dashboard, so convert local -> UTC here using the +// configured zone, which is interpolated into this script at request time. +const TIME_ZONE = + '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return tz || "UTC"; })() }}'; + +// Offset (ms) that `zone` was running at the given instant. Reading an instant +// as a wall clock and re-parsing it as UTC yields exactly that offset. +const offsetAt = (instant, zone) => { + const wall = new Date(instant).toLocaleString('sv-SE', { timeZone: zone }); + return Date.parse(wall.replace(' ', 'T') + 'Z') - instant; +}; + +// Interpret a naive wall-clock string as a time in `zone` and return real UTC. +// Applied twice so a reading that lands near a DST transition resolves against +// the offset actually in force rather than the one on the other side of it. +const wallClockToUtc = (naive, zone) => { + let guess = naive - offsetAt(naive, zone); + guess = naive - offsetAt(guess, zone); + return guess; +}; + +// `datetime` is either a single stamp ("8/20/2026 9:04:15 PM", when avg=0) or a +// bucket range ("8/20/2026 9:00:00 PM - 9:05:00 PM"). Take the bucket start. +const parseWhen = (raw) => { + const text = String(raw || ''); + const start = text.includes(' - ') ? text.split(' - ')[0].trim() : text.trim(); + const naive = new Date(start).getTime(); + if (isNaN(naive)) return null; + + let utc = naive; + try { + utc = wallClockToUtc(naive, TIME_ZONE); + } catch (e) { + // Unrecognised zone — fall back to treating the wall clock as UTC, + // matching the request side's own fallback. + utc = naive; + } + return new Date(utc).toISOString(); +}; + +// "100 %" -> 100 +const parseCoverage = (raw) => { + const num = parseFloat(String(raw || '').replace('%', '').trim()); + return isNaN(num) ? null : num; +}; + +// historicdata.json answers HTTP 200 with the bare text "Not enough monitoring +// data" when the window holds no retained readings, so `data` is not always an object. +const rows = (data && typeof data === 'object' && data.histdata) || []; +const out = []; + +for (const row of rows) { + const timestamp = parseWhen(row.datetime); + if (!timestamp) continue; + + const coverage = parseCoverage(row.coverage); + + for (const [channel, raw] of Object.entries(row)) { + if (channel === 'datetime' || channel === 'coverage') continue; + + // Intervals with no coverage come back as empty strings — drop them so + // they leave a genuine gap in the chart rather than plotting as zero. + if (raw === '' || raw === null || raw === undefined) continue; + + const value = Number(raw); + if (isNaN(value)) continue; + + out.push({ timestamp, channel, value, coverage }); + } +} + +result = out; diff --git a/plugins/PRTG/v1/dataStreams/sensorChannels.json b/plugins/PRTG/v1/dataStreams/sensorChannels.json new file mode 100644 index 00000000..59a03b4b --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/sensorChannels.json @@ -0,0 +1,66 @@ +{ + "name": "sensorChannels", + "displayName": "Sensor Channels", + "description": "Current, minimum and maximum reading for each channel of a sensor", + "tags": ["Monitoring", "Performance"], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { "key": "content", "value": "channels" }, + { "key": "id", "value": "{{object.rawId}}" }, + { + "key": "columns", + "value": "objid,name,lastvalue,minimum,maximum" + }, + { "key": "count", "value": "50000" } + ], + "pathToData": "channels", + "paging": { "mode": "none" } + }, + "matches": { "sourceType": { "type": "oneOf", "values": ["PRTG Sensor"] } }, + "metadata": [ + { + "name": "objid", + "displayName": "Channel ID", + "shape": "string", + "visible": false + }, + { + "name": "name", + "displayName": "Channel", + "shape": "string", + "role": "label" + }, + { + "name": "lastvalue_raw", + "displayName": "Last Value (Numeric)", + "shape": "number", + "role": "value" + }, + { + "name": "lastvalue", + "shape": "string", + "visible": false + }, + { + "name": "lastValueFormatted", + "displayName": "Last Value", + "computed": true, + "shape": "string", + "valueExpression": "{{ String($['lastvalue'] === null || $['lastvalue'] === undefined ? '' : $['lastvalue']).replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/ /g, ' ') }}" + }, + { + "name": "minimum_raw", + "displayName": "Minimum", + "shape": "number" + }, + { + "name": "maximum_raw", + "displayName": "Maximum", + "shape": "number" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/sensorHistory.json b/plugins/PRTG/v1/dataStreams/sensorHistory.json new file mode 100644 index 00000000..365e78d0 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/sensorHistory.json @@ -0,0 +1,112 @@ +{ + "name": "sensorHistory", + "displayName": "Sensor History", + "description": "Historic channel readings for a sensor, one row per channel per interval", + "tags": [ + "Monitoring", + "Performance" + ], + "baseDataSourceName": "httpRequestScopedSingle", + "config": { + "httpMethod": "get", + "endpointPath": "historicdata.json", + "getArgs": [ + { + "key": "id", + "value": "{{object.rawId}}" + }, + { + "key": "usecaption", + "value": "1" + }, + { + "key": "sdate", + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.start); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + }, + { + "key": "edate", + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.end); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + }, + { + "key": "avg", + "value": "{{ average || (new Date(timeframe.end) - new Date(timeframe.start) > 604800000 ? '3600' : null) }}" + } + ], + "postRequestScript": "sensorHistory.js" + }, + "matches": { + "sourceType": { + "type": "oneOf", + "values": [ + "PRTG Sensor" + ] + } + }, + "ui": [ + { + "type": "choiceChips", + "name": "average", + "label": "Averaging interval", + "help": "How PRTG aggregates the readings. Left unset, PRTG uses 5-minute buckets, or hourly on ranges longer than seven days. **Raw** returns every individual reading — only practical over short timeframes.", + "options": [ + { + "value": "0", + "label": "Raw" + }, + { + "value": "300", + "label": "5 minutes" + }, + { + "value": "3600", + "label": "1 hour" + }, + { + "value": "86400", + "label": "1 day" + } + ], + "tileEditorStep": [ + "Timeframe" + ] + } + ], + "metadata": [ + { + "name": "timestamp", + "displayName": "Time", + "shape": "date", + "role": "timestamp" + }, + { + "name": "channel", + "displayName": "Channel", + "shape": "string", + "role": "label" + }, + { + "name": "value", + "displayName": "Value", + "shape": "number", + "role": "value" + }, + { + "name": "coverage", + "displayName": "Coverage", + "shape": [ + "percent", + { + "decimalPlaces": 0 + } + ] + } + ], + "timeframes": [ + "last1hour", + "last12hours", + "last24hours", + "last7days", + "last30days" + ], + "defaultTimeframe": "dashboard" +} diff --git a/plugins/PRTG/v1/dataStreams/sensors.json b/plugins/PRTG/v1/dataStreams/sensors.json new file mode 100644 index 00000000..0fe64f1b --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/sensors.json @@ -0,0 +1,219 @@ +{ + "name": "sensors", + "displayName": "Sensors", + "description": "All PRTG sensors with their status, last reading, uptime and scanning interval", + "tags": [ + "Monitoring", + "Status" + ], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "table.json", + "getArgs": [ + { + "key": "content", + "value": "sensors" + }, + { + "key": "columns", + "value": "objid,name,parentid,probe,group,device,type,status,message,lastvalue,lastcheck,interval,priority,active,tags,uptime,downtime" + }, + { + "key": "sortby", + "value": "objid" + }, + { + "key": "count", + "value": "50000" + } + ], + "pathToData": "sensors", + "paging": { + "mode": "none" + } + }, + "matches": "none", + "metadata": [ + { + "name": "objid", + "displayName": "ID", + "shape": "string", + "role": "id" + }, + { + "name": "name", + "displayName": "Sensor", + "shape": "string", + "role": "label" + }, + { + "name": "parentid", + "displayName": "Device ID", + "shape": "string", + "visible": false + }, + { + "name": "device", + "displayName": "Device", + "shape": "string" + }, + { + "name": "group", + "displayName": "Group", + "shape": "string" + }, + { + "name": "probe", + "displayName": "Probe", + "shape": "string" + }, + { + "name": "type", + "displayName": "Sensor Type", + "shape": "string" + }, + { + "name": "status", + "displayName": "Status", + "shape": [ + "state", + { + "map": { + "success": [ + "Up" + ], + "warning": [ + "Warning", + "Unusual" + ], + "error": [ + "Down", + "Down (Partial)", + "Down (Acknowledged)", + "No Probe", + "Not Connected" + ], + "unknown": [ + "Unknown", + "None", + "Scanning" + ], + "unmonitored": [ + "Paused", + "Paused by User", + "Paused by Dependency", + "Paused by Schedule", + "Paused by License", + "Paused until" + ] + } + } + ] + }, + { + "name": "status_raw", + "displayName": "Status Code", + "shape": "number", + "visible": false + }, + { + "name": "statusText", + "displayName": "Status Text", + "computed": true, + "shape": "string", + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + }, + { + "name": "message_raw", + "displayName": "Message", + "shape": "string" + }, + { + "name": "lastvalue", + "displayName": "Last Value", + "shape": "string" + }, + { + "name": "lastvalue_raw", + "displayName": "Last Value (Numeric)", + "shape": "number" + }, + { + "name": "lastcheck_raw", + "displayName": "Last Check (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "lastCheck", + "displayName": "Last Check", + "computed": true, + "shape": "date", + "valueExpression": "{{ Number($['lastcheck_raw']) > 0 ? new Date((Number($['lastcheck_raw']) - 25569) * 86400000).toISOString() : null }}" + }, + { + "name": "uptime_raw", + "displayName": "Uptime (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "uptimePercent", + "displayName": "Uptime", + "computed": true, + "role": "value", + "shape": [ + "percent", + { + "decimalPlaces": 2 + } + ], + "valueExpression": "{{ $['uptime_raw'] === '' || $['uptime_raw'] === null || $['uptime_raw'] === undefined ? null : Number($['uptime_raw']) / 10000 }}" + }, + { + "name": "downtime_raw", + "displayName": "Downtime (Raw)", + "shape": "number", + "visible": false + }, + { + "name": "downtimePercent", + "displayName": "Downtime", + "computed": true, + "shape": [ + "percent", + { + "decimalPlaces": 2 + } + ], + "valueExpression": "{{ $['downtime_raw'] === '' || $['downtime_raw'] === null || $['downtime_raw'] === undefined ? null : Number($['downtime_raw']) / 10000 }}" + }, + { + "name": "interval_raw", + "displayName": "Scanning Interval", + "shape": "seconds" + }, + { + "name": "priority_raw", + "displayName": "Priority", + "shape": "number" + }, + { + "name": "tags", + "displayName": "Tags", + "shape": "string" + }, + { + "name": "active", + "displayName": "Monitoring Active", + "shape": "boolean" + }, + { + "sourceId": "parentid", + "sourceType": "PRTG Device", + "name": "device" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/dataStreams/systemStatus.json b/plugins/PRTG/v1/dataStreams/systemStatus.json new file mode 100644 index 00000000..9c8722f9 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/systemStatus.json @@ -0,0 +1,124 @@ +{ + "name": "systemStatus", + "displayName": "System Status", + "description": "Sensor counts by state, plus PRTG server version and edition", + "tags": ["Status", "Health"], + "baseDataSourceName": "httpRequestUnscoped", + "config": { + "httpMethod": "get", + "endpointPath": "getstatus.htm", + "getArgs": [{ "key": "id", "value": "0" }] + }, + "matches": "none", + "metadata": [ + { "name": "UpSens", "shape": "string", "visible": false }, + { "name": "WarnSens", "shape": "string", "visible": false }, + { "name": "Alarms", "shape": "string", "visible": false }, + { "name": "PartialAlarms", "shape": "string", "visible": false }, + { "name": "AckAlarms", "shape": "string", "visible": false }, + { "name": "UnusualSens", "shape": "string", "visible": false }, + { "name": "PausedSens", "shape": "string", "visible": false }, + { "name": "UnknownSens", "shape": "string", "visible": false }, + { "name": "NewMessages", "shape": "string", "visible": false }, + { "name": "NewAlarms", "shape": "string", "visible": false }, + { + "name": "upSensors", + "displayName": "Up", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['UpSens']) || 0 }}" + }, + { + "name": "warningSensors", + "displayName": "Warning", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['WarnSens']) || 0 }}" + }, + { + "name": "downSensors", + "displayName": "Down", + "computed": true, + "shape": "number", + "role": "value", + "valueExpression": "{{ Number($['Alarms']) || 0 }}" + }, + { + "name": "downPartialSensors", + "displayName": "Down (Partial)", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['PartialAlarms']) || 0 }}" + }, + { + "name": "downAcknowledgedSensors", + "displayName": "Down (Acknowledged)", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['AckAlarms']) || 0 }}" + }, + { + "name": "unusualSensors", + "displayName": "Unusual", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['UnusualSens']) || 0 }}" + }, + { + "name": "pausedSensors", + "displayName": "Paused", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['PausedSens']) || 0 }}" + }, + { + "name": "unknownSensors", + "displayName": "Unknown", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['UnknownSens']) || 0 }}" + }, + { + "name": "totalSensors", + "displayName": "Total Sensors", + "computed": true, + "shape": "number", + "valueExpression": "{{ ['UpSens','WarnSens','Alarms','PartialAlarms','AckAlarms','UnusualSens','PausedSens','UnknownSens'].reduce((t, k) => t + (Number($[k]) || 0), 0) }}" + }, + { + "name": "newLogEntries", + "displayName": "New Log Entries", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['NewMessages']) || 0 }}" + }, + { + "name": "newAlarms", + "displayName": "New Alarms", + "computed": true, + "shape": "number", + "valueExpression": "{{ Number($['NewAlarms']) || 0 }}" + }, + { + "name": "Version", + "displayName": "PRTG Version", + "shape": "string" + }, + { + "name": "EditionType", + "displayName": "Edition", + "shape": "string" + }, + { + "name": "Clock", + "displayName": "Server Time", + "shape": "string" + }, + { + "name": "UserTimeZone", + "displayName": "Server Time Zone", + "shape": "string" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/defaultContent/device.dash.json b/plugins/PRTG/v1/defaultContent/device.dash.json new file mode 100644 index 00000000..20b8801b --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/device.dash.json @@ -0,0 +1,234 @@ +{ + "name": "Device", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": [ + "{{variables.[Device]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "1bb12b4c-8810-483b-8edd-51b352c6b16d", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Device Details", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Devices]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Device]}}" + }, + "variables": [ + "{{variables.[Device]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "345f14f6-a2ea-4900-a118-0e8b88b0d4c9", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "priority_raw", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Devices]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Device]}}" + }, + "variables": [ + "{{variables.[Device]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "device", + "type", + "lastvalue", + "message_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "lastcheck_raw", + "uptime_raw", + "downtime_raw", + "lastvalue_raw", + "tags", + "active", + "probe" + ] + } + } + } + } + }, + { + "i": "62a62764-7448-46ca-b9de-8ca7990eb372", + "x": 0, + "y": 4, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "statusText", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Devices]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Device]}}" + }, + "variables": [ + "{{variables.[Device]}}" + ], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "statusText_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "8764365c-0d8d-4629-b2f5-ea00fa313cc1", + "x": 2, + "y": 4, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensor Health", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Devices]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Device]}}" + }, + "variables": [ + "{{variables.[Device]}}" + ], + "visualisation": { + "type": "data-stream-blocks", + "config": { + "data-stream-blocks": { + "labelColumn": "name", + "stateColumn": "status", + "sublabel": "lastvalue", + "linkColumn": "none", + "columns": 3 + } + } + } + } + } + ] + } +} diff --git a/plugins/PRTG/v1/defaultContent/group.dash.json b/plugins/PRTG/v1/defaultContent/group.dash.json new file mode 100644 index 00000000..3b8c35bd --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/group.dash.json @@ -0,0 +1,270 @@ +{ + "name": "Group", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": [ + "{{variables.[Group]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "7496a666-a94e-471e-a517-0f3279d891a7", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Group Details", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Groups]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Group]}}" + }, + "variables": [ + "{{variables.[Group]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "1ea390a6-e8b4-47b4-878b-b80010da653b", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Devices", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerDevices]}}", + "name": "containerDevices", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "totalsens_raw", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Groups]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Group]}}" + }, + "variables": [ + "{{variables.[Group]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "host", + "group", + "totalsens_raw", + "downsens_raw", + "warnsens_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "icon", + "tags", + "active", + "location_raw", + "upsens_raw", + "pausedsens_raw" + ] + } + } + } + } + }, + { + "i": "f1f82fea-9132-4b2d-a7a0-c2cd46534c4f", + "x": 0, + "y": 4, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "statusText", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Groups]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Group]}}" + }, + "variables": [ + "{{variables.[Group]}}" + ], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "statusText_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "2a6c2660-c49f-4fb3-82d6-1033fdbdf26a", + "x": 2, + "y": 4, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors Needing Attention", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "priority_raw", + "desc" + ] + ] + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "statusText", + "operation": "notequals", + "value": "Up" + } + ] + } + }, + "scope": { + "scope": "{{scopes.[Groups]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Group]}}" + }, + "variables": [ + "{{variables.[Group]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "device", + "type", + "lastvalue", + "message_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "lastcheck_raw", + "uptime_raw", + "downtime_raw", + "lastvalue_raw", + "tags", + "active", + "probe" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/PRTG/v1/defaultContent/manifest.json b/plugins/PRTG/v1/defaultContent/manifest.json new file mode 100644 index 00000000..e324425b --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/manifest.json @@ -0,0 +1,28 @@ +{ + "items": [ + { + "name": "overview", + "type": "dashboard" + }, + { + "name": "sites", + "type": "dashboard" + }, + { + "name": "probe", + "type": "dashboard" + }, + { + "name": "group", + "type": "dashboard" + }, + { + "name": "device", + "type": "dashboard" + }, + { + "name": "sensor", + "type": "dashboard" + } + ] +} diff --git a/plugins/PRTG/v1/defaultContent/overview.dash.json b/plugins/PRTG/v1/defaultContent/overview.dash.json new file mode 100644 index 00000000..9396c17a --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/overview.dash.json @@ -0,0 +1,381 @@ +{ + "name": "Overview", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "10446ece-2e09-46a4-8557-104d33b6c192", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Total Sensors", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[systemStatus]}}", + "name": "systemStatus", + "pluginConfigId": "{{configId}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "totalSensors", + "comparisonColumn": "none" + } + } + } + } + }, + { + "i": "b109556e-40d9-4a7f-9a61-d94c1ce08d6c", + "x": 1, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Up", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[systemStatus]}}", + "name": "systemStatus", + "pluginConfigId": "{{configId}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "upSensors", + "comparisonColumn": "none" + } + } + } + } + }, + { + "i": "d6ce20d0-4ad5-44a4-a675-b676dd9e9bce", + "x": 2, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Warning", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[systemStatus]}}", + "name": "systemStatus", + "pluginConfigId": "{{configId}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "warningSensors", + "comparisonColumn": "none" + } + } + } + } + }, + { + "i": "cc6eab65-7a26-4914-8c6d-83c1a17190a8", + "x": 3, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Down", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[systemStatus]}}", + "name": "systemStatus", + "pluginConfigId": "{{configId}}" + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "downSensors", + "comparisonColumn": "none" + } + } + } + } + }, + { + "i": "a2dd7f2b-6ae1-44c1-ba24-b9c5e438bac1", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[sensors]}}", + "name": "sensors", + "pluginConfigId": "{{configId}}", + "group": { + "by": [["statusText", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]] } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "statusText_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "40eda2eb-edf2-49da-882d-c6ded2718dbd", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors Needing Attention", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[sensors]}}", + "name": "sensors", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "statusText", + "operation": "notequals", + "value": "Up" + } + ] + }, + "sort": { "by": [["priority_raw", "desc"]] } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "device", + "message_raw", + "lastvalue", + "priority_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "lastcheck_raw", + "uptime_raw", + "downtime_raw", + "lastvalue_raw", + "tags", + "active", + "group", + "probe" + ] + } + } + } + } + }, + { + "i": "c5bcf1af-e267-49eb-bad3-74a01357a5da", + "x": 0, + "y": 5, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Devices", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[devices]}}", + "name": "devices", + "pluginConfigId": "{{configId}}", + "sort": { "by": [["totalsens_raw", "desc"]] } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "host", + "group", + "totalsens_raw", + "downsens_raw", + "warnsens_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "icon", + "tags", + "active", + "probe", + "location_raw", + "upsens_raw", + "pausedsens_raw" + ] + } + } + } + } + }, + { + "i": "3df87dd0-1393-464d-b1ba-ec4d57d8163b", + "x": 2, + "y": 5, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors per Device", + "description": "", + "timeframe": "none", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[sensors]}}", + "name": "sensors", + "pluginConfigId": "{{configId}}", + "group": { + "by": [["device", "uniqueValues"]], + "aggregate": [{ "type": "count" }] + }, + "sort": { "by": [["count", "desc"]], "top": 10 } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "device_uniqueValues", + "yAxisData": ["count"], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Sensors", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { "type": "auto" } + } + } + } + } + }, + { + "i": "1ed6f225-3d35-4b94-be18-7571d1517df0", + "x": 0, + "y": 8, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Recent Log Entries", + "description": "", + "activePluginConfigIds": ["{{configId}}"], + "dataStream": { + "id": "{{dataStreams.[logs]}}", + "name": "logs", + "pluginConfigId": "{{configId}}", + "sort": { "by": [["time", "desc"]] } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "time", + "name", + "status", + "message_raw" + ], + "hiddenColumns": ["objid", "datetime_raw"] + } + } + } + } + } + ] + } +} diff --git a/plugins/PRTG/v1/defaultContent/probe.dash.json b/plugins/PRTG/v1/defaultContent/probe.dash.json new file mode 100644 index 00000000..1cd9aa3d --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/probe.dash.json @@ -0,0 +1,270 @@ +{ + "name": "Probe", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": [ + "{{variables.[Probe]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "baa06389-4177-4be5-8250-705039ec0be9", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Probe Details", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Probes]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Probe]}}" + }, + "variables": [ + "{{variables.[Probe]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "07606a01-c88f-4ceb-947c-c4a8f99cd53b", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Devices", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerDevices]}}", + "name": "containerDevices", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "totalsens_raw", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Probes]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Probe]}}" + }, + "variables": [ + "{{variables.[Probe]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "host", + "group", + "totalsens_raw", + "downsens_raw", + "warnsens_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "icon", + "tags", + "active", + "location_raw", + "upsens_raw", + "pausedsens_raw" + ] + } + } + } + } + }, + { + "i": "2357762b-9473-4786-b33f-5aa153f4c5b8", + "x": 0, + "y": 4, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors by Status", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}", + "group": { + "by": [ + [ + "statusText", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Probes]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Probe]}}" + }, + "variables": [ + "{{variables.[Probe]}}" + ], + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "count", + "labelColumn": "statusText_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "69095342-c57d-45b7-b878-801fef0bb329", + "x": 2, + "y": 4, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors Needing Attention", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[containerSensors]}}", + "name": "containerSensors", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "priority_raw", + "desc" + ] + ] + }, + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "statusText", + "operation": "notequals", + "value": "Up" + } + ] + } + }, + "scope": { + "scope": "{{scopes.[Probes]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Probe]}}" + }, + "variables": [ + "{{variables.[Probe]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "name", + "device", + "type", + "lastvalue", + "message_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "lastcheck_raw", + "uptime_raw", + "downtime_raw", + "lastvalue_raw", + "tags", + "active", + "probe" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/PRTG/v1/defaultContent/sensor.dash.json b/plugins/PRTG/v1/defaultContent/sensor.dash.json new file mode 100644 index 00000000..dc9129fd --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/sensor.dash.json @@ -0,0 +1,161 @@ +{ + "name": "Sensor", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "variables": [ + "{{variables.[Sensor]}}" + ], + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "cbf63d04-ab31-4719-9d12-4508c228d203", + "x": 0, + "y": 0, + "w": 1, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensor Details", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "datastream-properties", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Sensors]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sensor]}}" + }, + "variables": [ + "{{variables.[Sensor]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": true + } + } + } + } + }, + { + "i": "106790b9-2dbb-4c15-809f-fa7301be4eaa", + "x": 1, + "y": 0, + "w": 3, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Channels", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[sensorChannels]}}", + "name": "sensorChannels", + "pluginConfigId": "{{configId}}" + }, + "scope": { + "scope": "{{scopes.[Sensors]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sensor]}}" + }, + "variables": [ + "{{variables.[Sensor]}}" + ], + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "name", + "lastValueFormatted", + "lastvalue_raw", + "minimum_raw", + "maximum_raw" + ], + "hiddenColumns": [ + "objid", + "lastvalue" + ] + } + } + } + } + }, + { + "i": "cd81bb9b-815f-4e70-95d0-6fdff5a3d1f2", + "x": 0, + "y": 4, + "w": 4, + "h": 4, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Channel History", + "description": "", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[sensorHistory]}}", + "name": "sensorHistory", + "pluginConfigId": "{{configId}}", + "sort": { + "by": [ + [ + "timestamp", + "asc" + ] + ] + } + }, + "scope": { + "scope": "{{scopes.[Sensors]}}", + "workspace": "{{workspaceId}}", + "variable": "{{variables.[Sensor]}}" + }, + "variables": [ + "{{variables.[Sensor]}}" + ], + "visualisation": { + "type": "data-stream-line-graph", + "config": { + "data-stream-line-graph": { + "xAxisColumn": "timestamp", + "yAxisColumn": [ + "value" + ], + "seriesColumn": "channel", + "showLegend": true, + "legendPosition": "bottom", + "yAxisLabel": "", + "showYAxisLabel": false, + "showTrendLine": false + } + } + } + } + } + ] + } +} diff --git a/plugins/PRTG/v1/defaultContent/sites.dash.json b/plugins/PRTG/v1/defaultContent/sites.dash.json new file mode 100644 index 00000000..9bf81382 --- /dev/null +++ b/plugins/PRTG/v1/defaultContent/sites.dash.json @@ -0,0 +1,374 @@ +{ + "name": "Sites", + "schemaVersion": "1.5", + "timeframe": "last24hours", + "dashboard": { + "_type": "layout/grid", + "columns": 4, + "version": 1, + "contents": [ + { + "i": "52e399da-f733-4c80-977c-2a954fb8a570", + "x": 0, + "y": 0, + "w": 1, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sites", + "description": "Distinct locations set in PRTG", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[devices]}}", + "name": "devices", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "location_raw", + "operation": "notempty" + } + ] + }, + "group": { + "by": [], + "aggregate": [ + { + "type": "distinctCount", + "names": [ + "location_raw" + ] + } + ] + } + }, + "visualisation": { + "type": "data-stream-scalar", + "config": { + "data-stream-scalar": { + "value": "location_raw_distinctCount", + "comparisonColumn": "none" + } + } + } + } + }, + { + "i": "4b14f0c1-58ca-45fb-8852-ff3e88e89997", + "x": 1, + "y": 0, + "w": 3, + "h": 2, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Devices per Site", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[devices]}}", + "name": "devices", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "location_raw", + "operation": "notempty" + } + ] + }, + "group": { + "by": [ + [ + "location_raw", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "count" + } + ] + }, + "sort": { + "by": [ + [ + "count", + "desc" + ] + ], + "top": 15 + } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "location_raw_uniqueValues", + "yAxisData": [ + "count" + ], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Devices", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "cf8d29bf-32d9-4a07-919d-5a47bab1278c", + "x": 0, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors Down by Site", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[devices]}}", + "name": "devices", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "location_raw", + "operation": "notempty" + } + ] + }, + "group": { + "by": [ + [ + "location_raw", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "sum", + "names": [ + "downsens_raw" + ] + } + ] + }, + "sort": { + "by": [ + [ + "downsens_raw_sum", + "desc" + ] + ], + "top": 15 + } + }, + "visualisation": { + "type": "data-stream-bar-chart", + "config": { + "data-stream-bar-chart": { + "xAxisData": "location_raw_uniqueValues", + "yAxisData": [ + "downsens_raw_sum" + ], + "xAxisGroup": "none", + "xAxisLabel": "", + "yAxisLabel": "Sensors down", + "showXAxisLabel": false, + "showYAxisLabel": true, + "showLegend": false, + "legendPosition": "bottom", + "showGrid": true, + "horizontalLayout": "horizontal", + "displayMode": "actual", + "showTotals": false, + "showValue": true, + "grouping": false, + "range": { + "type": "auto" + } + } + } + } + } + }, + { + "i": "ce89869f-2e91-417d-97ea-8f07133ff836", + "x": 2, + "y": 2, + "w": 2, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Sensors by Site", + "description": "", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[devices]}}", + "name": "devices", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "location_raw", + "operation": "notempty" + } + ] + }, + "group": { + "by": [ + [ + "location_raw", + "uniqueValues" + ] + ], + "aggregate": [ + { + "type": "sum", + "names": [ + "totalsens_raw" + ] + } + ] + }, + "sort": { + "by": [ + [ + "totalsens_raw_sum", + "desc" + ] + ] + } + }, + "visualisation": { + "type": "data-stream-donut-chart", + "config": { + "data-stream-donut-chart": { + "valueColumn": "totalsens_raw_sum", + "labelColumn": "location_raw_uniqueValues", + "hideCenterValue": false, + "showValuesAsPercentage": false, + "legendPosition": "auto", + "legendMode": "table" + } + } + } + } + }, + { + "i": "da8413f2-9466-46fe-9735-47584da96ed0", + "x": 0, + "y": 5, + "w": 4, + "h": 3, + "moved": false, + "static": false, + "z": 0, + "config": { + "_type": "tile/data-stream", + "title": "Devices by Site", + "description": "Devices with a location set in PRTG", + "timeframe": "none", + "activePluginConfigIds": [ + "{{configId}}" + ], + "dataStream": { + "id": "{{dataStreams.[devices]}}", + "name": "devices", + "pluginConfigId": "{{configId}}", + "filter": { + "multiOperation": "and", + "filters": [ + { + "column": "location_raw", + "operation": "notempty" + } + ] + }, + "sort": { + "by": [ + [ + "location_raw", + "asc" + ] + ] + } + }, + "visualisation": { + "type": "data-stream-table", + "config": { + "data-stream-table": { + "transpose": false, + "columnOrder": [ + "status", + "location_raw", + "name", + "host", + "group", + "probe", + "totalsens_raw", + "downsens_raw", + "warnsens_raw" + ], + "hiddenColumns": [ + "objid", + "parentid", + "status_raw", + "statusText", + "status[Expanded].rawState", + "icon", + "tags", + "active", + "upsens_raw", + "pausedsens_raw" + ] + } + } + } + } + } + ] + } +} diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md new file mode 100644 index 00000000..b0b6e9ab --- /dev/null +++ b/plugins/PRTG/v1/docs/README.md @@ -0,0 +1,133 @@ +Monitor your [PRTG Network Monitor](https://www.paessler.com/prtg) installation in SquaredUp — probes, groups, +devices and sensors, with current status, channel readings, historic sensor data and the PRTG log — via the +[PRTG HTTP API](https://www.paessler.com/manuals/prtg/http_api). + +> ⚠️ This plugin uses the **PRTG API v1** (`/api/table.json`). It does not use PRTG API v2, whose object +> endpoints are still marked experimental by Paessler. Any PRTG version that supports API keys will work, +> including PRTG Hosted Monitor, PRTG Network Monitor and PRTG Enterprise Monitor. + +## Setup + +You will need the **URL** of your PRTG server and a PRTG **API key**. + +1. Sign in to your PRTG web interface as a user allowed to create API keys. +2. Go to **Setup → Account Settings → [API Keys](https://www.paessler.com/manuals/prtg/api_keys)**. +3. Hover over the **Add** button and choose **Add API Key**. +4. Set the token type to **Scripting** — the **Desktop** type is reserved for PRTG MultiBoard, and only one + is allowed per account. +5. Give the key **Read access**. The plugin only ever reads, so `Acknowledge`, `Write` and `Full` access + are unnecessary. +6. Click **OK**, then copy the generated key immediately — PRTG will not show it again. If you lose it, + delete the key and create a new one. +7. Paste the key into the **API key** field, and your PRTG address into **PRTG URL**. +8. Set **PRTG time zone** to the time zone of the account whose key you just created — it is shown in PRTG + under **Setup → Account Settings → My Account → Time Zone**. + +## Configuration fields + +| Field | What it is | Where to find it | Required | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------- | +| **PRTG URL** | The base address of your PRTG web interface — for example `https://prtg.example.com` or `https://yourname.my-prtg.com`. Include the sub-path if PRTG sits behind a reverse proxy, but no query string. | The address bar of your PRTG web interface. | Yes | +| **API key** | Authenticates every request. Sent as the `apitoken` query parameter, which is the only scheme PRTG's v1 API accepts. | PRTG → **Setup → Account Settings → API Keys**. | Yes | +| **PRTG time zone** | The time zone of the PRTG account whose API key you supplied, as an IANA name such as `Europe/London`. Only affects **Sensor History** and **Log**. Defaults to UTC. | PRTG → **Setup → Account Settings → My Account → Time Zone**. | No | +| **Ignore certificate errors** | Skips TLS certificate validation. Only enable for an on-premise PRTG server using a self-signed certificate. | — | No | + +On save, the plugin calls PRTG's status endpoint to confirm the URL and key. A failure means the URL is +unreachable or the key is invalid, expired, or deleted. + +> ⚠️ **Get the time zone right.** PRTG interprets date ranges in its *own* time zone rather than UTC, so the +> wrong zone shifts **Sensor History** and **Log** — and on short timeframes can make them look empty. +> Everything else is unaffected. Note that PRTG labels a zone by its *standard* offset, so a UK server shown +> as "(UTC+00:00) … London" is really running an hour ahead during British Summer Time — pick +> `Europe/London` rather than `UTC` and daylight saving is handled for you. + +## What this plugin monitors + +- **Estate health at a glance** — how many sensors are up, warning, down, paused, unusual or unknown across + the whole installation, plus your PRTG version and edition. +- **The PRTG hierarchy** — probes, groups, devices and sensors are all indexed, so you can search for them, + scope dashboards to them, and use them as dashboard variables. +- **Device and sensor state** — per-device sensor counts by state and host address; per-sensor status, last + reading, status message, uptime and downtime percentage, scanning interval and priority. +- **Sensor detail over time** — the current, minimum and maximum reading of every channel on a sensor, and + historic readings for any timeframe. +- **The PRTG log** — what PRTG recorded, against which object, over the dashboard's timeframe. +- **Sites** — where a **Location** is set in PRTG, devices and their sensor counts are broken down by it, so + you can compare sites side by side. + +The out-of-the-box dashboards include an estate-wide **Overview**, a **Sites** breakdown, and a perspective +for each **Probe**, **Group**, **Device** and **Sensor**. + +## Data streams + +- **System Status** — installation-wide sensor counts by state, plus PRTG version, edition and server time. Account-wide. +- **Probes** — every probe with its status and connection state. Account-wide. +- **Groups** — every group with its status and sensor counts by state. Account-wide. +- **Devices** — every device with its status, host address and sensor counts by state. Account-wide. +- **Sensors** — every sensor with its status, last reading, uptime and scanning interval. Account-wide. +- **Sensors in Probe, Group or Device** — the same sensor detail, restricted to everything beneath one probe, group or device. Per-object. +- **Devices in Probe or Group** — device detail restricted to everything beneath one probe or group. Per-object. +- **Sensor Channels** — the current, minimum and maximum reading for each channel of one sensor. Per-object. +- **Sensor History** — historic channel readings for one sensor, one row per channel per interval, over the selected timeframe. Per-object. +- **Log** — PRTG log entries over the selected timeframe. Account-wide. + +## What gets indexed + +| Object type | API source | Represents | +| ---------------- | ------------------------------------------------------ | -------------------------------------------------------------- | +| **PRTG Probe** | `GET /api/table.json?content=probes&filter_type=probenode` | A local or remote probe that performs monitoring. | +| **PRTG Group** | `GET /api/table.json?content=groups` | A group of devices. Groups can nest inside other groups. | +| **PRTG Device** | `GET /api/table.json?content=devices` | A monitored host, identified by its address. | +| **PRTG Sensor** | `GET /api/table.json?content=sensors` | A single check running against a device. | + +**Relationships:** every object stores its PRTG parent's id as a `parentId` property, and sensors also store +`deviceId`, `deviceName`, `groupName` and `probeName`. The **Sensors** stream links its Device column +straight to the **PRTG Device** object, so you can click through from a sensor to the device it runs on. See +the first limitation below for why these are properties rather than graph relationships. + +**Sites:** PRTG has no "site" object. Probes, groups and devices each store a `location` property holding +PRTG's **Location** field, which is the closest thing — a remote probe usually maps to one site, and groups +are the usual way to organise by site. The **Sites** dashboard groups devices by `location`. + +## Known limitations + +- **No graph relationships between PRTG objects.** SquaredUp only supports creating graph edges from + code-based plugins, not low-code ones like this. The PRTG hierarchy is therefore expressed as properties + (`parentId`, `deviceName`, `groupName`, `probeName`) and through dashboard scoping and drilldown, rather + than as traversable parent/child links in the graph. +- **Date ranges follow PRTG's own time zone.** See the **PRTG time zone** field above. PRTG offers no way to + query in UTC and accepts no relative ranges, so the zone has to be supplied. It is also used to convert + **Sensor History** timestamps to UTC, because that endpoint reports times only as local text with no UTC + equivalent — so the wrong zone shifts both the range queried *and* the times plotted. An unrecognised zone + name falls back to UTC rather than failing the request. +- **Very large installations may hit a response size limit.** Each object type is fetched in a single + request rather than page by page. In practice the sensor import is the binding constraint and should + comfortably handle around 10,000 sensors, which is also Paessler's own recommended maximum per core + server. Larger installations may fail to import sensors. +- **Sensor History is limited to 30 days**, and beyond a week it is averaged hourly. Finer buckets over a + long range return more rows than the platform's response size limit allows, so when you have not chosen an + **Averaging interval** the plugin asks PRTG for hourly figures on ranges longer than seven days. Choosing + **5 minutes** or **Raw** explicitly on a long range can still exceed the limit and fail the tile. +- **The log returns at most 5,000 entries per query.** PRTG returns newest first, so on a busy installation + over a long timeframe the oldest entries in the range are dropped without warning. Unlike the object + tables, PRTG reports no usable total for the log, so there is no way to detect that truncation happened — + shorten the dashboard timeframe to see further back. +- **PRTG tags are a single string.** They are indexed as a `prtgTags` property holding PRTG's + comma-separated list, not as native SquaredUp tags. +- **Location is only reported where it is set, and never on sensors.** PRTG's API returns a `location` for a + probe, group or device only when one is set on that object directly — it is not inherited down the tree in + the API response, and sensors never carry one at all. The **Sites** dashboard therefore aggregates + *devices* (and PRTG's per-device sensor counts) by location, and shows nothing until you set **Location** + on your devices in PRTG. +- **No map visualisation.** SquaredUp has no map tile type, and PRTG's API exposes no usable coordinates — + `lat`/`lon` are not valid columns and the `lonlat` property reads `0,0` unless PRTG has geocoded the + location. Sites are therefore shown as charts and tables, not on a map. +- **Sensor status in the graph is not indexed.** Because objects are re-imported only every 12 hours by + default, indexing a sensor's status would show stale health. Use the **Sensors** stream for current status. +- **Historic data granularity is PRTG's.** PRTG aggregates history into buckets and returns a `coverage` + percentage per bucket; intervals PRTG has no data for are omitted rather than plotted as zero. Choosing + **Raw** on **Sensor History** is only practical over short timeframes. +- **The API key travels in the query string.** PRTG's v1 API rejects `Authorization: Bearer`, so the token + must be sent as the `apitoken` query parameter. Always use HTTPS. +- **Read-only.** The plugin never creates, modifies, acknowledges, pauses or deletes anything in PRTG, and a + **Read access** API key is all it needs. diff --git a/plugins/PRTG/v1/icon.svg b/plugins/PRTG/v1/icon.svg new file mode 100644 index 00000000..6cb211ed --- /dev/null +++ b/plugins/PRTG/v1/icon.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/plugins/PRTG/v1/indexDefinitions/default.json b/plugins/PRTG/v1/indexDefinitions/default.json new file mode 100644 index 00000000..a4c60383 --- /dev/null +++ b/plugins/PRTG/v1/indexDefinitions/default.json @@ -0,0 +1,131 @@ +{ + "steps": [ + { + "name": "probes", + "dataStream": { + "name": "probes" + }, + "timeframe": "none", + "objectMapping": { + "id": "objid", + "name": "name", + "type": { + "value": "PRTG Probe" + }, + "properties": [ + { + "connection": "condition" + }, + { + "location": "location_raw" + } + ] + } + }, + { + "name": "groups", + "dataStream": { + "name": "groups" + }, + "timeframe": "none", + "objectMapping": { + "id": "objid", + "name": "name", + "type": { + "value": "PRTG Group" + }, + "properties": [ + { + "parentId": "parentid" + }, + { + "probeName": "probe" + }, + { + "location": "location_raw" + }, + { + "prtgTags": "tags" + } + ] + } + }, + { + "name": "devices", + "dataStream": { + "name": "devices" + }, + "timeframe": "none", + "objectMapping": { + "id": "objid", + "name": "name", + "type": { + "value": "PRTG Device" + }, + "properties": [ + { + "parentId": "parentid" + }, + { + "host": "host" + }, + { + "probeName": "probe" + }, + { + "groupName": "group" + }, + { + "location": "location_raw" + }, + { + "prtgTags": "tags" + } + ] + } + }, + { + "name": "sensors", + "dataStream": { + "name": "sensors" + }, + "timeframe": "none", + "objectMapping": { + "id": "objid", + "name": "name", + "type": { + "value": "PRTG Sensor" + }, + "properties": [ + { + "parentId": "parentid" + }, + { + "deviceId": "parentid" + }, + { + "sensorType": "type" + }, + { + "deviceName": "device" + }, + { + "groupName": "group" + }, + { + "probeName": "probe" + }, + { + "scanningInterval": "interval_raw" + }, + { + "priority": "priority_raw" + }, + { + "prtgTags": "tags" + } + ] + } + } + ] +} diff --git a/plugins/PRTG/v1/metadata.json b/plugins/PRTG/v1/metadata.json new file mode 100644 index 00000000..fdde78b7 --- /dev/null +++ b/plugins/PRTG/v1/metadata.json @@ -0,0 +1,50 @@ +{ + "name": "prtg", + "displayName": "PRTG Network Monitor", + "version": "1.0.0", + "author": { "name": "@Deenk", "type": "community" }, + "description": "Monitor your PRTG installation — probes, groups, devices and sensors, with sensor status, channel readings and historic data.", + "category": "Monitoring", + "type": "hybrid", + "schemaVersion": "2.1", + "importNotSupported": false, + "restrictedToPlatforms": [], + "keywords": [ + "prtg", + "paessler", + "network", + "monitoring", + "sensors", + "snmp", + "infrastructure" + ], + "objectTypes": [ + "PRTG Probe", + "PRTG Group", + "PRTG Device", + "PRTG Sensor" + ], + "links": [ + { + "category": "documentation", + "url": "https://github.com/squaredup/plugins/blob/main/plugins/PRTG/v1/docs/README.md", + "label": "Help adding this plugin" + }, + { + "category": "source", + "url": "https://github.com/squaredup/plugins/tree/main/plugins/PRTG/v1", + "label": "Repository" + } + ], + "base": { + "plugin": "WebAPI", + "majorVersion": "1", + "config": { + "baseUrl": "{{host.endsWith('/') ? host.slice(0, -1) : host}}/api", + "authMode": "none", + "headers": [], + "queryArgs": [{ "key": "apitoken", "value": "{{apiToken}}" }], + "ignoreCertificateErrors": "{{typeof ignoreCertificateErrors !== 'undefined' && ignoreCertificateErrors === true}}" + } + } +} diff --git a/plugins/PRTG/v1/scopes.json b/plugins/PRTG/v1/scopes.json new file mode 100644 index 00000000..a3ad5767 --- /dev/null +++ b/plugins/PRTG/v1/scopes.json @@ -0,0 +1,50 @@ +[ + { + "name": "Probes", + "matches": { + "sourceType": { "type": "oneOf", "values": ["PRTG Probe"] } + }, + "variable": { + "name": "Probe", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Groups", + "matches": { + "sourceType": { "type": "oneOf", "values": ["PRTG Group"] } + }, + "variable": { + "name": "Group", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Devices", + "matches": { + "sourceType": { "type": "oneOf", "values": ["PRTG Device"] } + }, + "variable": { + "name": "Device", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + }, + { + "name": "Sensors", + "matches": { + "sourceType": { "type": "oneOf", "values": ["PRTG Sensor"] } + }, + "variable": { + "name": "Sensor", + "allowMultipleSelection": false, + "default": "none", + "type": "object" + } + } +] diff --git a/plugins/PRTG/v1/ui.json b/plugins/PRTG/v1/ui.json new file mode 100644 index 00000000..c0b32649 --- /dev/null +++ b/plugins/PRTG/v1/ui.json @@ -0,0 +1,80 @@ +[ + { + "name": "host", + "label": "PRTG URL", + "type": "url", + "help": "Base URL of your PRTG web interface. Include the port if PRTG does not run on the default one, and the path if PRTG sits behind a reverse proxy.", + "placeholder": "https://prtg.example.com", + "validation": { + "required": true, + "pattern": { + "value": "^https?://[^\\s/?#]+(/[^\\s?#]*)?$", + "message": "Enter the scheme and host, e.g. https://prtg.example.com — no query string" + } + } + }, + { + "name": "apiToken", + "label": "API key", + "type": "password", + "placeholder": "Enter the PRTG API key", + "help": "A PRTG API key with **Read access**, created under **Setup → Account Settings → API Keys** using the **Scripting** token type. PRTG only shows the key once, so copy it when you create it.", + "validation": { "required": true } + }, + { + "name": "serverTimeZone", + "label": "PRTG time zone", + "type": "autocomplete", + "allowCustomValues": true, + "isClearable": true, + "help": "The time zone of the PRTG **account** whose API key you entered above — PRTG interprets date ranges in that zone rather than UTC. Find it in PRTG under **Setup → Account Settings → My Account → Time Zone**. Daylight saving is handled automatically, so pick the region (for example `Europe/London`) rather than a fixed offset. Any [IANA time zone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) can be typed in. Defaults to UTC if left empty — the **System Status** data stream reports the zone PRTG is actually using.", + "data": { + "source": "fixed", + "values": [ + { "value": "UTC", "label": "UTC" }, + { "value": "Europe/London", "label": "Europe/London (UK)" }, + { "value": "Europe/Dublin", "label": "Europe/Dublin" }, + { "value": "Europe/Lisbon", "label": "Europe/Lisbon" }, + { "value": "Europe/Paris", "label": "Europe/Paris" }, + { "value": "Europe/Berlin", "label": "Europe/Berlin" }, + { "value": "Europe/Madrid", "label": "Europe/Madrid" }, + { "value": "Europe/Amsterdam", "label": "Europe/Amsterdam" }, + { "value": "Europe/Stockholm", "label": "Europe/Stockholm" }, + { "value": "Europe/Warsaw", "label": "Europe/Warsaw" }, + { "value": "Europe/Athens", "label": "Europe/Athens" }, + { "value": "Europe/Helsinki", "label": "Europe/Helsinki" }, + { "value": "Europe/Moscow", "label": "Europe/Moscow" }, + { "value": "America/New_York", "label": "America/New_York (US Eastern)" }, + { "value": "America/Chicago", "label": "America/Chicago (US Central)" }, + { "value": "America/Denver", "label": "America/Denver (US Mountain)" }, + { "value": "America/Phoenix", "label": "America/Phoenix (no DST)" }, + { "value": "America/Los_Angeles", "label": "America/Los_Angeles (US Pacific)" }, + { "value": "America/Toronto", "label": "America/Toronto" }, + { "value": "America/Vancouver", "label": "America/Vancouver" }, + { "value": "America/Mexico_City", "label": "America/Mexico_City" }, + { "value": "America/Sao_Paulo", "label": "America/Sao_Paulo" }, + { "value": "Africa/Lagos", "label": "Africa/Lagos" }, + { "value": "Africa/Cairo", "label": "Africa/Cairo" }, + { "value": "Africa/Johannesburg", "label": "Africa/Johannesburg" }, + { "value": "Asia/Dubai", "label": "Asia/Dubai" }, + { "value": "Asia/Kolkata", "label": "Asia/Kolkata (India)" }, + { "value": "Asia/Singapore", "label": "Asia/Singapore" }, + { "value": "Asia/Hong_Kong", "label": "Asia/Hong_Kong" }, + { "value": "Asia/Shanghai", "label": "Asia/Shanghai" }, + { "value": "Asia/Tokyo", "label": "Asia/Tokyo" }, + { "value": "Asia/Seoul", "label": "Asia/Seoul" }, + { "value": "Australia/Perth", "label": "Australia/Perth" }, + { "value": "Australia/Brisbane", "label": "Australia/Brisbane" }, + { "value": "Australia/Sydney", "label": "Australia/Sydney" }, + { "value": "Australia/Melbourne", "label": "Australia/Melbourne" }, + { "value": "Pacific/Auckland", "label": "Pacific/Auckland" } + ] + } + }, + { + "type": "checkbox", + "name": "ignoreCertificateErrors", + "label": "Ignore certificate errors", + "help": "Enable when connecting to a PRTG server with a self-signed certificate." + } +] From 2ca1426ad87bf0c892fce872f837478b7452d69e Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 24 Aug 2026 12:24:31 +0100 Subject: [PATCH 02/13] Address CodeRabbit review on the PRTG plugin Correctness fixes: - Map PRTG status code 11 to "Not Licensed" across all six object streams, and drop "Paused by License" from the unmonitored state maps -- PRTG never returns that string. - Parse the historic-data `datetime` string explicitly instead of relying on `new Date()`, which rejects PRTG's European format ("04.12.2017 16:35:08") and resolves the others against the host time zone rather than PRTG's. - Report a genuine 0% coverage as 0 rather than null. Robustness: - Build the PRTG date strings from Intl.DateTimeFormat.formatToParts in sensorHistory.js, sensorHistory.json and logs.json, so nothing depends on how a given ICU build separates the date from the time. - Return null from offsetAt when the parts are not numeric, and propagate that, so `new Date(NaN).toISOString()` is unreachable. - Strip everything outside the IANA character set from the interpolated time zone, so a quote in a custom value cannot inject into the request script. - Clamp the Sensor History window to 30 days, so a tile following a longer dashboard timeframe returns the most recent 30 days instead of asking PRTG for a range it cannot serve within the response cap. Conventions: - Rename source types to unprefixed upstream terms (Probe, Group, Device, Sensor) per REVIEW.md. Display text is unchanged. - Move scopes.json to defaultContent/, where the other 30 plugins and the authoring docs put it. - Use a recursive CODEOWNERS pattern for the plugin directory. Documentation: - Reword the authentication notes. PRTG's manual documents `Authorization: Bearer`, but 26.3.122.1665 answers 401 "Unsupported authorization scheme" to that, to `Authorization: apitoken` and to `X-Api-Key`, leaving the apitoken query parameter as the only scheme that works. Verified against PRTG 26.3.122.1665 with the account time zone at UTC+1. `table.json` raw datetimes are confirmed UTC, so the log conversion is unchanged. `datetime_raw` is not usable in historicdata.json: it is only returned when `usecaption` is omitted, which collapses every channel into a single unnamed column, and it carries the bucket end rather than its start. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CODEOWNERS | 2 +- plugins/PRTG/v1/custom_types.json | 16 +-- .../PRTG/v1/dataStreams/containerDevices.json | 8 +- .../PRTG/v1/dataStreams/containerSensors.json | 12 +- plugins/PRTG/v1/dataStreams/devices.json | 4 +- plugins/PRTG/v1/dataStreams/groups.json | 4 +- plugins/PRTG/v1/dataStreams/logs.json | 4 +- plugins/PRTG/v1/dataStreams/probes.json | 4 +- .../v1/dataStreams/scripts/sensorHistory.js | 111 ++++++++++++++---- .../PRTG/v1/dataStreams/sensorChannels.json | 2 +- .../PRTG/v1/dataStreams/sensorHistory.json | 6 +- plugins/PRTG/v1/dataStreams/sensors.json | 6 +- .../PRTG/v1/{ => defaultContent}/scopes.json | 8 +- plugins/PRTG/v1/docs/README.md | 21 ++-- plugins/PRTG/v1/indexDefinitions/default.json | 8 +- plugins/PRTG/v1/metadata.json | 8 +- 16 files changed, 143 insertions(+), 81 deletions(-) rename plugins/PRTG/v1/{ => defaultContent}/scopes.json (76%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 886cf3ae..224e6a31 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,7 +14,7 @@ plugins/Huntress/* @Deenk plugins/FantasyPremierLeague/* @TimWheeler-SQUP plugins/GoogleSheets/* @kieranlangton plugins/MetOffice/* @blackgrouse -plugins/PRTG/* @Deenk +/plugins/PRTG/ @Deenk plugins/Phare/* @vinbab plugins/Postcoder/* @richbenwell plugins/RDAP/* @richbenwell diff --git a/plugins/PRTG/v1/custom_types.json b/plugins/PRTG/v1/custom_types.json index 828e26ad..4b5f99a1 100644 --- a/plugins/PRTG/v1/custom_types.json +++ b/plugins/PRTG/v1/custom_types.json @@ -1,28 +1,28 @@ [ { - "name": "PRTG Probe", - "sourceType": "PRTG Probe", + "name": "Probe", + "sourceType": "Probe", "icon": "tower-broadcast", "singular": "Probe", "plural": "Probes" }, { - "name": "PRTG Group", - "sourceType": "PRTG Group", + "name": "Group", + "sourceType": "Group", "icon": "sitemap", "singular": "Group", "plural": "Groups" }, { - "name": "PRTG Device", - "sourceType": "PRTG Device", + "name": "Device", + "sourceType": "Device", "icon": "server", "singular": "Device", "plural": "Devices" }, { - "name": "PRTG Sensor", - "sourceType": "PRTG Sensor", + "name": "Sensor", + "sourceType": "Sensor", "icon": "gauge", "singular": "Sensor", "plural": "Sensors" diff --git a/plugins/PRTG/v1/dataStreams/containerDevices.json b/plugins/PRTG/v1/dataStreams/containerDevices.json index f2bf6b51..b706f90e 100644 --- a/plugins/PRTG/v1/dataStreams/containerDevices.json +++ b/plugins/PRTG/v1/dataStreams/containerDevices.json @@ -41,8 +41,8 @@ "sourceType": { "type": "oneOf", "values": [ - "PRTG Probe", - "PRTG Group" + "Probe", + "Group" ] } }, @@ -111,7 +111,7 @@ "Paused by User", "Paused by Dependency", "Paused by Schedule", - "Paused by License", + "Not Licensed", "Paused until" ] } @@ -129,7 +129,7 @@ "displayName": "Status Text", "computed": true, "shape": "string", - "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Not Licensed',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" }, { "name": "totalsens_raw", diff --git a/plugins/PRTG/v1/dataStreams/containerSensors.json b/plugins/PRTG/v1/dataStreams/containerSensors.json index ebfd05cf..a50f18ab 100644 --- a/plugins/PRTG/v1/dataStreams/containerSensors.json +++ b/plugins/PRTG/v1/dataStreams/containerSensors.json @@ -41,9 +41,9 @@ "sourceType": { "type": "oneOf", "values": [ - "PRTG Probe", - "PRTG Group", - "PRTG Device" + "Probe", + "Group", + "Device" ] } }, @@ -117,7 +117,7 @@ "Paused by User", "Paused by Dependency", "Paused by Schedule", - "Paused by License", + "Not Licensed", "Paused until" ] } @@ -135,7 +135,7 @@ "displayName": "Status Text", "computed": true, "shape": "string", - "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Not Licensed',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" }, { "name": "message_raw", @@ -224,7 +224,7 @@ }, { "sourceId": "parentid", - "sourceType": "PRTG Device", + "sourceType": "Device", "name": "device" } ], diff --git a/plugins/PRTG/v1/dataStreams/devices.json b/plugins/PRTG/v1/dataStreams/devices.json index 67f61485..a68d9d7f 100644 --- a/plugins/PRTG/v1/dataStreams/devices.json +++ b/plugins/PRTG/v1/dataStreams/devices.json @@ -99,7 +99,7 @@ "Paused by User", "Paused by Dependency", "Paused by Schedule", - "Paused by License", + "Not Licensed", "Paused until" ] } @@ -117,7 +117,7 @@ "displayName": "Status Text", "computed": true, "shape": "string", - "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Not Licensed',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" }, { "name": "totalsens_raw", diff --git a/plugins/PRTG/v1/dataStreams/groups.json b/plugins/PRTG/v1/dataStreams/groups.json index 2a4f10c7..b3dadcb9 100644 --- a/plugins/PRTG/v1/dataStreams/groups.json +++ b/plugins/PRTG/v1/dataStreams/groups.json @@ -93,7 +93,7 @@ "Paused by User", "Paused by Dependency", "Paused by Schedule", - "Paused by License", + "Not Licensed", "Paused until" ] } @@ -111,7 +111,7 @@ "displayName": "Status Text", "computed": true, "shape": "string", - "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Not Licensed',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" }, { "name": "totalsens_raw", diff --git a/plugins/PRTG/v1/dataStreams/logs.json b/plugins/PRTG/v1/dataStreams/logs.json index 0eddc708..7bf35ce3 100644 --- a/plugins/PRTG/v1/dataStreams/logs.json +++ b/plugins/PRTG/v1/dataStreams/logs.json @@ -21,11 +21,11 @@ }, { "key": "filter_dstart", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.start); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(timeframe.start)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" }, { "key": "filter_dend", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.end); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(timeframe.end)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" }, { "key": "count", diff --git a/plugins/PRTG/v1/dataStreams/probes.json b/plugins/PRTG/v1/dataStreams/probes.json index ce6c5b31..fce83668 100644 --- a/plugins/PRTG/v1/dataStreams/probes.json +++ b/plugins/PRTG/v1/dataStreams/probes.json @@ -82,7 +82,7 @@ "Paused by User", "Paused by Dependency", "Paused by Schedule", - "Paused by License", + "Not Licensed", "Paused until" ] } @@ -100,7 +100,7 @@ "displayName": "Status Text", "computed": true, "shape": "string", - "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Not Licensed',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" }, { "name": "condition", diff --git a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js index c7905748..63bf7c0e 100644 --- a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js +++ b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js @@ -3,53 +3,109 @@ // yields "Free Space C:", a ping sensor "Response Time"). A declared-column data // stream cannot express that, so unpivot to one row per channel per interval. // -// Timestamps need care. Unlike table.json's `*_raw` columns — which are OLE -// dates in UTC — historicdata.json only ever returns `datetime` as a string in -// the PRTG server's *local* time, with no raw/epoch equivalent. Emitting that -// as-is would put this stream an hour (or more) away from `Last Check` and the -// Log stream on the same dashboard, so convert local -> UTC here using the -// configured zone, which is interpolated into this script at request time. +// Timestamps have to come from the `datetime` string, awkward as that is. +// historicdata.json does expose a `datetime_raw` OLE date, but only when +// `usecaption` is omitted entirely — and in that mode every channel collapses +// into one unnamed `value` column, which defeats the whole point of this stream. +// The two are mutually exclusive. `datetime_raw` is also the bucket *end* while +// `datetime` shows the bucket range, so swapping to it would shift every point +// by one interval. Verified against PRTG 26.3.122.1665 (see the PR description). +// +// So: parse the wall clock, then convert it to UTC using the configured zone. +// Without that, this stream sits an hour (or more) away from `Last Check` and +// the Log stream on the same dashboard. +// +// The zone is interpolated at request time. IANA names only ever contain +// [A-Za-z0-9_+/-], so stripping everything else keeps a stray quote from +// terminating the string literal below and injecting into this script. const TIME_ZONE = - '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return tz || "UTC"; })() }}'; + '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return String(tz || "UTC").replace(/[^A-Za-z0-9_+\/-]/g, "") || "UTC"; })() }}'; + +// PRTG formats `datetime` in the account's regional setting, so the shape varies +// by installation. Parse the known forms explicitly rather than leaning on +// `new Date(str)`, which rejects the European form outright and would otherwise +// resolve the others against the *host* time zone instead of PRTG's. +// Returns a wall-clock instant expressed as if UTC, or null if unrecognised. +const parseNaive = (text) => { + // M/D/YYYY h:mm:ss AM|PM + let m = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})(?:\s*([AaPp])\.?[Mm]\.?)?$/); + if (m) { + let hour = Number(m[4]); + if (m[7]) { + if (hour === 12) hour = 0; + if (m[7].toUpperCase() === 'P') hour += 12; + } + return Date.UTC(Number(m[3]), Number(m[1]) - 1, Number(m[2]), hour, Number(m[5]), Number(m[6])); + } + // D.M.YYYY HH:mm:ss + m = text.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})$/); + if (m) return Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6])); + // YYYY-MM-DD HH:mm:ss + m = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2}):(\d{2})/); + if (m) return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); + return null; +}; -// Offset (ms) that `zone` was running at the given instant. Reading an instant -// as a wall clock and re-parsing it as UTC yields exactly that offset. +// Offset (ms) that `zone` was running at the given instant. Read via +// formatToParts rather than a formatted string, so nothing depends on how a +// given ICU build separates the date from the time. Returns null if the parts +// do not come back as numbers, so no caller can reach `new Date(NaN)`. const offsetAt = (instant, zone) => { - const wall = new Date(instant).toLocaleString('sv-SE', { timeZone: zone }); - return Date.parse(wall.replace(' ', 'T') + 'Z') - instant; + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).formatToParts(new Date(instant)); + + const p = {}; + for (const part of parts) p[part.type] = part.value; + + const wall = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)); + return isFinite(wall) ? wall - instant : null; }; -// Interpret a naive wall-clock string as a time in `zone` and return real UTC. -// Applied twice so a reading that lands near a DST transition resolves against -// the offset actually in force rather than the one on the other side of it. +// Interpret a naive wall-clock instant as a time in `zone` and return real UTC. +// Applied twice so a reading near a DST transition resolves against the offset +// actually in force rather than the one on the other side of it. const wallClockToUtc = (naive, zone) => { - let guess = naive - offsetAt(naive, zone); - guess = naive - offsetAt(guess, zone); - return guess; + const first = offsetAt(naive, zone); + if (first === null) return null; + const second = offsetAt(naive - first, zone); + return second === null ? null : naive - second; }; // `datetime` is either a single stamp ("8/20/2026 9:04:15 PM", when avg=0) or a -// bucket range ("8/20/2026 9:00:00 PM - 9:05:00 PM"). Take the bucket start. +// bucket range ("8/24/2026 11:00:00 AM - 11:05:00 AM"). Take the bucket start. const parseWhen = (raw) => { const text = String(raw || ''); const start = text.includes(' - ') ? text.split(' - ')[0].trim() : text.trim(); - const naive = new Date(start).getTime(); - if (isNaN(naive)) return null; + + const naive = parseNaive(start); + if (naive === null || !isFinite(naive)) return null; let utc = naive; try { - utc = wallClockToUtc(naive, TIME_ZONE); + const converted = wallClockToUtc(naive, TIME_ZONE); + // Unrecognised zone or unusable parts — fall back to treating the wall + // clock as UTC, matching the request side's own fallback. + if (converted !== null) utc = converted; } catch (e) { - // Unrecognised zone — fall back to treating the wall clock as UTC, - // matching the request side's own fallback. utc = naive; } - return new Date(utc).toISOString(); + + return isFinite(utc) ? new Date(utc).toISOString() : null; }; -// "100 %" -> 100 +// "100 %" -> 100. Test for null/undefined rather than falsiness so a genuine +// zero reports as 0% rather than as no reading at all. const parseCoverage = (raw) => { - const num = parseFloat(String(raw || '').replace('%', '').trim()); + if (raw === null || raw === undefined) return null; + const num = parseFloat(String(raw).replace('%', '').trim()); return isNaN(num) ? null : num; }; @@ -66,6 +122,9 @@ for (const row of rows) { for (const [channel, raw] of Object.entries(row)) { if (channel === 'datetime' || channel === 'coverage') continue; + // Only present when `usecaption` is omitted, which this stream never + // does — cheap insurance against them surfacing as channels. + if (channel.endsWith('_raw')) continue; // Intervals with no coverage come back as empty strings — drop them so // they leave a genuine gap in the chart rather than plotting as zero. diff --git a/plugins/PRTG/v1/dataStreams/sensorChannels.json b/plugins/PRTG/v1/dataStreams/sensorChannels.json index 59a03b4b..89504968 100644 --- a/plugins/PRTG/v1/dataStreams/sensorChannels.json +++ b/plugins/PRTG/v1/dataStreams/sensorChannels.json @@ -19,7 +19,7 @@ "pathToData": "channels", "paging": { "mode": "none" } }, - "matches": { "sourceType": { "type": "oneOf", "values": ["PRTG Sensor"] } }, + "matches": { "sourceType": { "type": "oneOf", "values": ["Sensor"] } }, "metadata": [ { "name": "objid", diff --git a/plugins/PRTG/v1/dataStreams/sensorHistory.json b/plugins/PRTG/v1/dataStreams/sensorHistory.json index 365e78d0..e160fcd9 100644 --- a/plugins/PRTG/v1/dataStreams/sensorHistory.json +++ b/plugins/PRTG/v1/dataStreams/sensorHistory.json @@ -21,11 +21,11 @@ }, { "key": "sdate", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.start); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var MAX = 30 * 86400000; var start = new Date(timeframe.start).getTime(); var end = new Date(timeframe.end).getTime(); if (end - start > MAX) { start = end - MAX; } var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(start)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" }, { "key": "edate", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var d = new Date(timeframe.end); var fmt = function(zone){ return d.toLocaleString(\"sv-SE\", { timeZone: zone }).replace(\" \", \"-\").replace(/:/g, \"-\"); }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var MAX = 30 * 86400000; var start = new Date(timeframe.start).getTime(); var end = new Date(timeframe.end).getTime(); var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(end)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" }, { "key": "avg", @@ -38,7 +38,7 @@ "sourceType": { "type": "oneOf", "values": [ - "PRTG Sensor" + "Sensor" ] } }, diff --git a/plugins/PRTG/v1/dataStreams/sensors.json b/plugins/PRTG/v1/dataStreams/sensors.json index 0fe64f1b..49d91c86 100644 --- a/plugins/PRTG/v1/dataStreams/sensors.json +++ b/plugins/PRTG/v1/dataStreams/sensors.json @@ -104,7 +104,7 @@ "Paused by User", "Paused by Dependency", "Paused by Schedule", - "Paused by License", + "Not Licensed", "Paused until" ] } @@ -122,7 +122,7 @@ "displayName": "Status Text", "computed": true, "shape": "string", - "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Paused',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" + "valueExpression": "{{ ({0:'None',1:'Unknown',2:'Scanning',3:'Up',4:'Warning',5:'Down',6:'No Probe',7:'Paused',8:'Paused',9:'Paused',10:'Unusual',11:'Not Licensed',12:'Paused',13:'Down (Acknowledged)',14:'Down (Partial)'})[Number($['status_raw'])] || 'Unknown' }}" }, { "name": "message_raw", @@ -211,7 +211,7 @@ }, { "sourceId": "parentid", - "sourceType": "PRTG Device", + "sourceType": "Device", "name": "device" } ], diff --git a/plugins/PRTG/v1/scopes.json b/plugins/PRTG/v1/defaultContent/scopes.json similarity index 76% rename from plugins/PRTG/v1/scopes.json rename to plugins/PRTG/v1/defaultContent/scopes.json index a3ad5767..148e3dc8 100644 --- a/plugins/PRTG/v1/scopes.json +++ b/plugins/PRTG/v1/defaultContent/scopes.json @@ -2,7 +2,7 @@ { "name": "Probes", "matches": { - "sourceType": { "type": "oneOf", "values": ["PRTG Probe"] } + "sourceType": { "type": "oneOf", "values": ["Probe"] } }, "variable": { "name": "Probe", @@ -14,7 +14,7 @@ { "name": "Groups", "matches": { - "sourceType": { "type": "oneOf", "values": ["PRTG Group"] } + "sourceType": { "type": "oneOf", "values": ["Group"] } }, "variable": { "name": "Group", @@ -26,7 +26,7 @@ { "name": "Devices", "matches": { - "sourceType": { "type": "oneOf", "values": ["PRTG Device"] } + "sourceType": { "type": "oneOf", "values": ["Device"] } }, "variable": { "name": "Device", @@ -38,7 +38,7 @@ { "name": "Sensors", "matches": { - "sourceType": { "type": "oneOf", "values": ["PRTG Sensor"] } + "sourceType": { "type": "oneOf", "values": ["Sensor"] } }, "variable": { "name": "Sensor", diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md index b0b6e9ab..3a0f21bd 100644 --- a/plugins/PRTG/v1/docs/README.md +++ b/plugins/PRTG/v1/docs/README.md @@ -28,7 +28,7 @@ You will need the **URL** of your PRTG server and a PRTG **API key**. | Field | What it is | Where to find it | Required | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------- | | **PRTG URL** | The base address of your PRTG web interface — for example `https://prtg.example.com` or `https://yourname.my-prtg.com`. Include the sub-path if PRTG sits behind a reverse proxy, but no query string. | The address bar of your PRTG web interface. | Yes | -| **API key** | Authenticates every request. Sent as the `apitoken` query parameter, which is the only scheme PRTG's v1 API accepts. | PRTG → **Setup → Account Settings → API Keys**. | Yes | +| **API key** | Authenticates every request. Sent as the `apitoken` query parameter — PRTG rejects the key in an `Authorization` header. | PRTG → **Setup → Account Settings → API Keys**. | Yes | | **PRTG time zone** | The time zone of the PRTG account whose API key you supplied, as an IANA name such as `Europe/London`. Only affects **Sensor History** and **Log**. Defaults to UTC. | PRTG → **Setup → Account Settings → My Account → Time Zone**. | No | | **Ignore certificate errors** | Skips TLS certificate validation. Only enable for an on-premise PRTG server using a self-signed certificate. | — | No | @@ -75,14 +75,14 @@ for each **Probe**, **Group**, **Device** and **Sensor**. | Object type | API source | Represents | | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------- | -| **PRTG Probe** | `GET /api/table.json?content=probes&filter_type=probenode` | A local or remote probe that performs monitoring. | -| **PRTG Group** | `GET /api/table.json?content=groups` | A group of devices. Groups can nest inside other groups. | -| **PRTG Device** | `GET /api/table.json?content=devices` | A monitored host, identified by its address. | -| **PRTG Sensor** | `GET /api/table.json?content=sensors` | A single check running against a device. | +| **Probe** | `GET /api/table.json?content=probes&filter_type=probenode` | A local or remote probe that performs monitoring. | +| **Group** | `GET /api/table.json?content=groups` | A group of devices. Groups can nest inside other groups. | +| **Device** | `GET /api/table.json?content=devices` | A monitored host, identified by its address. | +| **Sensor** | `GET /api/table.json?content=sensors` | A single check running against a device. | **Relationships:** every object stores its PRTG parent's id as a `parentId` property, and sensors also store `deviceId`, `deviceName`, `groupName` and `probeName`. The **Sensors** stream links its Device column -straight to the **PRTG Device** object, so you can click through from a sensor to the device it runs on. See +straight to the **Device** object, so you can click through from a sensor to the device it runs on. See the first limitation below for why these are properties rather than graph relationships. **Sites:** PRTG has no "site" object. Probes, groups and devices each store a `location` property holding @@ -107,7 +107,8 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by - **Sensor History is limited to 30 days**, and beyond a week it is averaged hourly. Finer buckets over a long range return more rows than the platform's response size limit allows, so when you have not chosen an **Averaging interval** the plugin asks PRTG for hourly figures on ranges longer than seven days. Choosing - **5 minutes** or **Raw** explicitly on a long range can still exceed the limit and fail the tile. + **5 minutes** or **Raw** explicitly on a long range can still exceed the limit and fail the tile. A tile + following a dashboard timeframe longer than 30 days shows the most recent 30 days rather than failing. - **The log returns at most 5,000 entries per query.** PRTG returns newest first, so on a busy installation over a long timeframe the oldest entries in the range are dropped without warning. Unlike the object tables, PRTG reports no usable total for the log, so there is no way to detect that truncation happened — @@ -127,7 +128,9 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by - **Historic data granularity is PRTG's.** PRTG aggregates history into buckets and returns a `coverage` percentage per bucket; intervals PRTG has no data for are omitted rather than plotted as zero. Choosing **Raw** on **Sensor History** is only practical over short timeframes. -- **The API key travels in the query string.** PRTG's v1 API rejects `Authorization: Bearer`, so the token - must be sent as the `apitoken` query parameter. Always use HTTPS. +- **The API key travels in the query string.** PRTG's manual documents `Authorization: Bearer` for API keys, + but PRTG rejects it in practice — `Bearer`, `X-Api-Key` and `Authorization: apitoken` all answer + `401 Unsupported authorization scheme` on 26.3.122.1665, leaving the `apitoken` query parameter as the only + scheme that works. Always use HTTPS. - **Read-only.** The plugin never creates, modifies, acknowledges, pauses or deletes anything in PRTG, and a **Read access** API key is all it needs. diff --git a/plugins/PRTG/v1/indexDefinitions/default.json b/plugins/PRTG/v1/indexDefinitions/default.json index a4c60383..8f2fe4f7 100644 --- a/plugins/PRTG/v1/indexDefinitions/default.json +++ b/plugins/PRTG/v1/indexDefinitions/default.json @@ -10,7 +10,7 @@ "id": "objid", "name": "name", "type": { - "value": "PRTG Probe" + "value": "Probe" }, "properties": [ { @@ -32,7 +32,7 @@ "id": "objid", "name": "name", "type": { - "value": "PRTG Group" + "value": "Group" }, "properties": [ { @@ -60,7 +60,7 @@ "id": "objid", "name": "name", "type": { - "value": "PRTG Device" + "value": "Device" }, "properties": [ { @@ -94,7 +94,7 @@ "id": "objid", "name": "name", "type": { - "value": "PRTG Sensor" + "value": "Sensor" }, "properties": [ { diff --git a/plugins/PRTG/v1/metadata.json b/plugins/PRTG/v1/metadata.json index fdde78b7..27377243 100644 --- a/plugins/PRTG/v1/metadata.json +++ b/plugins/PRTG/v1/metadata.json @@ -19,10 +19,10 @@ "infrastructure" ], "objectTypes": [ - "PRTG Probe", - "PRTG Group", - "PRTG Device", - "PRTG Sensor" + "Probe", + "Group", + "Device", + "Sensor" ], "links": [ { From fc4fa534cc42e88950b0e69b093073fad600a8a9 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 24 Aug 2026 12:44:17 +0100 Subject: [PATCH 03/13] Condense the sensorHistory.js header comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the narrative framing and subjective wording, keeping the reason `datetime_raw` is unusable — it is only returned when `usecaption` is omitted, and it carries the bucket end rather than its start. Co-Authored-By: Claude Opus 5 (1M context) --- .../v1/dataStreams/scripts/sensorHistory.js | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js index 63bf7c0e..700d86ae 100644 --- a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js +++ b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js @@ -3,21 +3,16 @@ // yields "Free Space C:", a ping sensor "Response Time"). A declared-column data // stream cannot express that, so unpivot to one row per channel per interval. // -// Timestamps have to come from the `datetime` string, awkward as that is. -// historicdata.json does expose a `datetime_raw` OLE date, but only when -// `usecaption` is omitted entirely — and in that mode every channel collapses -// into one unnamed `value` column, which defeats the whole point of this stream. -// The two are mutually exclusive. `datetime_raw` is also the bucket *end* while -// `datetime` shows the bucket range, so swapping to it would shift every point -// by one interval. Verified against PRTG 26.3.122.1665 (see the PR description). +// Timestamps come from the `datetime` string. historicdata.json also returns a +// `datetime_raw` OLE date, but only when `usecaption` is omitted — a mode that +// collapses every channel into one unnamed `value` column — and it carries the +// bucket end rather than its start, so neither form is usable here (checked +// against PRTG 26.3.122.1665). // -// So: parse the wall clock, then convert it to UTC using the configured zone. -// Without that, this stream sits an hour (or more) away from `Last Check` and -// the Log stream on the same dashboard. -// -// The zone is interpolated at request time. IANA names only ever contain -// [A-Za-z0-9_+/-], so stripping everything else keeps a stray quote from -// terminating the string literal below and injecting into this script. +// `datetime` is a local wall clock, so convert it to UTC with the configured +// zone, or this stream sits an hour or more from `Last Check` and the Log stream +// on the same dashboard. IANA names contain only [A-Za-z0-9_+/-]; stripping +// other characters stops a quote in a custom value terminating the literal below. const TIME_ZONE = '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return String(tz || "UTC").replace(/[^A-Za-z0-9_+\/-]/g, "") || "UTC"; })() }}'; From 543a168b3a501f7a286c68276470ad264e3737f2 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Thu, 27 Aug 2026 10:14:59 +0100 Subject: [PATCH 04/13] Warn on setup when the PRTG time zone is wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRTG's v1 API interprets `filter_dstart`/`filter_dend` in the time zone of the account whose API key is in use and offers no UTC option, so the zone has to be configured. Until now a wrong answer failed silently: Log and Sensor History shifted by the difference, or looked empty on a short timeframe, with nothing to point at the cause. `getstatus.htm` reports the account's zone as a fixed offset in `UserTimeZone`, which System Status already surfaces. Too coarse to replace the setting — Sensor History spans 30 days and needs the daylight saving transitions an IANA name carries — but enough to check it against. Adds a hidden `timeZoneCheck` stream and a non-blocking Time zone step in configValidation. Two things worth knowing about the comparison: - It accepts the zone's current offset *or* its standard one. PRTG labels zones by standard offset in its own UI, so which of the two `UserTimeZone` carries is unknown. Accepting both still catches a zone picked on the wrong continent without crying wolf every summer. - A `UserTimeZone` it cannot parse passes rather than warns — a PRTG version that words the field differently must not fail setup. A bad IANA name is compared as UTC, because that is what the request side falls back to sending. The step returns one row on agreement and none on mismatch because `errorOnEmptyResults` can only fail a step on empty. It is `required: false` so a false positive cannot block anyone from connecting. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/PRTG/v1/configValidation.json | 10 +++ .../v1/dataStreams/scripts/timeZoneCheck.js | 88 +++++++++++++++++++ .../PRTG/v1/dataStreams/timeZoneCheck.json | 24 +++++ plugins/PRTG/v1/docs/README.md | 14 ++- plugins/PRTG/v1/ui.json | 2 +- 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js create mode 100644 plugins/PRTG/v1/dataStreams/timeZoneCheck.json diff --git a/plugins/PRTG/v1/configValidation.json b/plugins/PRTG/v1/configValidation.json index c8f38187..1bb49670 100644 --- a/plugins/PRTG/v1/configValidation.json +++ b/plugins/PRTG/v1/configValidation.json @@ -6,6 +6,16 @@ "required": true, "error": "Could not connect to PRTG. Check the PRTG URL is reachable from SquaredUp, and that the API key is correct and has not been deleted.", "success": "Connected to PRTG successfully." + }, + { + "displayName": "Time zone", + "dataStream": { + "name": "timeZoneCheck", + "config": { "errorOnEmptyResults": true } + }, + "required": false, + "error": "The PRTG time zone selected does not match the one PRTG reports. Compare it against Setup → Account Settings → My Account → Time Zone in PRTG — until they agree, Log and Sensor History will be shifted by the difference.", + "success": "Time zone matches PRTG." } ] } diff --git a/plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js b/plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js new file mode 100644 index 00000000..b959c99f --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js @@ -0,0 +1,88 @@ +// PRTG's legacy API interprets `filter_dstart`/`filter_dend` and reports historic +// data in the time zone of the *account* whose API key is in use, and offers no +// way to ask for UTC. That zone therefore has to be configured, and getting it +// wrong shifts Log and Sensor History by hours with nothing to show for it. +// +// It can, however, be read back: `getstatus.htm` reports the account's zone as a +// fixed offset in `UserTimeZone` (for example "UTC-03:00"). That is too coarse to +// replace the setting — Sensor History spans up to 30 days and needs the daylight +// saving transitions an IANA name carries — but it is enough to check it. This +// stream exists only for the `Time zone` step in configValidation.json, which +// treats no rows as a mismatch, so agreement has to be the non-empty case. +// +// IANA names contain only [A-Za-z0-9_+/-]; stripping other characters stops a +// quote in a custom value terminating the literal below. +const TIME_ZONE = + '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return String(tz || "UTC").replace(/[^A-Za-z0-9_+\/-]/g, "") || "UTC"; })() }}'; + +// Offset (minutes) that `zone` was running at the given instant. Read via +// formatToParts rather than a formatted string, so nothing depends on how a given +// ICU build separates the date from the time. Returns null if the parts do not +// come back as numbers, so no caller can reach `new Date(NaN)`. +const offsetAt = (instant, zone) => { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }).formatToParts(new Date(instant)); + + const p = {}; + for (const part of parts) p[part.type] = part.value; + + const wall = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)); + return isFinite(wall) ? Math.round((wall - instant) / 60000) : null; +}; + +// PRTG labels a zone by its *standard* offset in its own UI, so it is not safe to +// assume `UserTimeZone` reports the offset in force right now. Accept either the +// current offset or the standard one — daylight saving only ever adds to the +// offset, so the standard offset is the smaller of the two solstice readings in +// both hemispheres. Accepting both still catches the mistakes that matter (a zone +// picked on the wrong continent) without crying wolf every summer. +// +// An unusable zone falls back to UTC, matching what the request side in logs.json +// and sensorHistory.json does with one — the queries really are being sent as UTC +// in that case, so a non-UTC server should still be flagged. +const candidateOffsets = (zone) => { + try { + const now = Date.now(); + const year = new Date(now).getUTCFullYear(); + const winter = offsetAt(Date.UTC(year, 0, 1), zone); + const summer = offsetAt(Date.UTC(year, 6, 1), zone); + const current = offsetAt(now, zone); + if (winter === null || summer === null || current === null) return [0]; + return [current, Math.min(winter, summer)]; + } catch (e) { + return [0]; + } +}; + +// "UTC-03:00" -> -180. Also accepts a bare "UTC", tolerates the separator being +// dropped ("UTC+0530"), and reads the offset out of the bracketed form PRTG +// labels zones with in its own UI ("(UTC+01:00) Amsterdam, Berlin") in case this +// field ever carries that instead. Returns null for anything else, which the +// caller reads as "cannot tell" rather than "mismatch" — a PRTG version that +// words this field differently must not fail the check. +const parseReportedOffset = (raw) => { + const m = String(raw == null ? '' : raw) + .trim() + .match(/^\(?\s*(?:UTC|GMT)(?:\s*([+-])\s*(\d{1,2}):?(\d{2})?)?\s*(?:\)|$)/i); + if (!m) return null; + if (!m[1]) return 0; + const minutes = Number(m[2]) * 60 + Number(m[3] || 0); + return m[1] === '-' ? -minutes : minutes; +}; + +const reportedRaw = data && typeof data === 'object' ? data.UserTimeZone : null; +const reported = parseReportedOffset(reportedRaw); + +// No row means "mismatch" to configValidation, so every uncertain case has to +// return one: a value it cannot read, or a status body PRTG did not answer with. +const agrees = reported === null || candidateOffsets(TIME_ZONE).includes(reported); + +result = agrees ? [{ serverTimeZone: reportedRaw === null || reportedRaw === undefined ? TIME_ZONE : String(reportedRaw) }] : []; diff --git a/plugins/PRTG/v1/dataStreams/timeZoneCheck.json b/plugins/PRTG/v1/dataStreams/timeZoneCheck.json new file mode 100644 index 00000000..2c1012ea --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/timeZoneCheck.json @@ -0,0 +1,24 @@ +{ + "name": "timeZoneCheck", + "displayName": "Time Zone Check", + "description": "Compares the configured PRTG time zone against the zone the PRTG server reports it is using", + "tags": ["Status"], + "baseDataSourceName": "httpRequestUnscoped", + "visibility": { "type": "hidden" }, + "config": { + "httpMethod": "get", + "endpointPath": "getstatus.htm", + "getArgs": [{ "key": "id", "value": "0" }], + "postRequestScript": "timeZoneCheck.js" + }, + "matches": "none", + "metadata": [ + { + "name": "serverTimeZone", + "displayName": "Server Time Zone", + "shape": "string", + "role": "label" + } + ], + "timeframes": false +} diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md index 3a0f21bd..603c7b93 100644 --- a/plugins/PRTG/v1/docs/README.md +++ b/plugins/PRTG/v1/docs/README.md @@ -21,7 +21,8 @@ You will need the **URL** of your PRTG server and a PRTG **API key**. delete the key and create a new one. 7. Paste the key into the **API key** field, and your PRTG address into **PRTG URL**. 8. Set **PRTG time zone** to the time zone of the account whose key you just created — it is shown in PRTG - under **Setup → Account Settings → My Account → Time Zone**. + under **Setup → Account Settings → My Account → Time Zone**. Saving checks your answer against the zone + PRTG reports, so a mismatch is flagged there and then. ## Configuration fields @@ -32,8 +33,10 @@ You will need the **URL** of your PRTG server and a PRTG **API key**. | **PRTG time zone** | The time zone of the PRTG account whose API key you supplied, as an IANA name such as `Europe/London`. Only affects **Sensor History** and **Log**. Defaults to UTC. | PRTG → **Setup → Account Settings → My Account → Time Zone**. | No | | **Ignore certificate errors** | Skips TLS certificate validation. Only enable for an on-premise PRTG server using a self-signed certificate. | — | No | -On save, the plugin calls PRTG's status endpoint to confirm the URL and key. A failure means the URL is -unreachable or the key is invalid, expired, or deleted. +On save, the plugin calls PRTG's status endpoint twice: once to confirm the URL and key — a failure there +means the URL is unreachable or the key is invalid, expired, or deleted — and once to compare **PRTG time +zone** against the zone PRTG says it is using. A zone mismatch is a warning rather than a failure, so it +will not stop you connecting. > ⚠️ **Get the time zone right.** PRTG interprets date ranges in its *own* time zone rather than UTC, so the > wrong zone shifts **Sensor History** and **Log** — and on short timeframes can make them look empty. @@ -100,6 +103,11 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by **Sensor History** timestamps to UTC, because that endpoint reports times only as local text with no UTC equivalent — so the wrong zone shifts both the range queried *and* the times plotted. An unrecognised zone name falls back to UTC rather than failing the request. + + PRTG does report the zone it is using, as a fixed offset — the **System Status** data stream surfaces it as + **Server Time Zone**, and saving the configuration warns when it disagrees with the zone you picked. That + offset cannot replace the setting, though: it describes only the present moment, whereas **Sensor History** + covers up to 30 days and needs the daylight saving transitions an IANA name carries. - **Very large installations may hit a response size limit.** Each object type is fetched in a single request rather than page by page. In practice the sensor import is the binding constraint and should comfortably handle around 10,000 sensors, which is also Paessler's own recommended maximum per core diff --git a/plugins/PRTG/v1/ui.json b/plugins/PRTG/v1/ui.json index c0b32649..013254da 100644 --- a/plugins/PRTG/v1/ui.json +++ b/plugins/PRTG/v1/ui.json @@ -27,7 +27,7 @@ "type": "autocomplete", "allowCustomValues": true, "isClearable": true, - "help": "The time zone of the PRTG **account** whose API key you entered above — PRTG interprets date ranges in that zone rather than UTC. Find it in PRTG under **Setup → Account Settings → My Account → Time Zone**. Daylight saving is handled automatically, so pick the region (for example `Europe/London`) rather than a fixed offset. Any [IANA time zone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) can be typed in. Defaults to UTC if left empty — the **System Status** data stream reports the zone PRTG is actually using.", + "help": "The time zone of the PRTG **account** whose API key you entered above — PRTG interprets date ranges in that zone rather than UTC. Find it in PRTG under **Setup → Account Settings → My Account → Time Zone**. Daylight saving is handled automatically, so pick the region (for example `Europe/London`) rather than a fixed offset. Any [IANA time zone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) can be typed in. Defaults to UTC if left empty. Saving checks this against the zone PRTG reports and warns if they disagree; the **System Status** data stream also shows it as **Server Time Zone**.", "data": { "source": "fixed", "values": [ From dd06067711fe072e8613d616dd5dbf8e7c5618b9 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Thu, 27 Aug 2026 12:13:22 +0100 Subject: [PATCH 05/13] Explain why the PRTG plugin uses API v1 Answers the review question on whether this should target PRTG API v2. Checked against the published API v2 OpenAPI specification: v2 has no log endpoint, no arbitrary historic-data range, and no time zone endpoint, so the Log and Sensor History streams and the setup time zone check could not be built on it. What remains is marked experimental, and v2 is unavailable on Hosted Monitor and off by default on existing installations. Records the conditions under which a v2-based plugin becomes worth having, as a new major version rather than a change to this one. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/PRTG/v1/docs/README.md | 72 ++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md index 603c7b93..f90773cb 100644 --- a/plugins/PRTG/v1/docs/README.md +++ b/plugins/PRTG/v1/docs/README.md @@ -2,9 +2,10 @@ Monitor your [PRTG Network Monitor](https://www.paessler.com/prtg) installation devices and sensors, with current status, channel readings, historic sensor data and the PRTG log — via the [PRTG HTTP API](https://www.paessler.com/manuals/prtg/http_api). -> ⚠️ This plugin uses the **PRTG API v1** (`/api/table.json`). It does not use PRTG API v2, whose object -> endpoints are still marked experimental by Paessler. Any PRTG version that supports API keys will work, -> including PRTG Hosted Monitor, PRTG Network Monitor and PRTG Enterprise Monitor. +> ⚠️ **This plugin uses the PRTG API v1** — `/api/table.json`, `/api/historicdata.json` and +> `/api/getstatus.htm`. Any PRTG version that supports API keys will work, including PRTG Network Monitor, +> PRTG Enterprise Monitor and PRTG Hosted Monitor, and you do **not** need to enable the new UI or API v2. +> See [Why this plugin uses API v1](#why-this-plugin-uses-api-v1) for the reasoning. ## Setup @@ -142,3 +143,68 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by scheme that works. Always use HTTPS. - **Read-only.** The plugin never creates, modifies, acknowledges, pauses or deletes anything in PRTG, and a **Read access** API key is all it needs. + +## Why this plugin uses API v1 + +PRTG also has a newer [API v2](https://www.paessler.com/support/prtg/api/v2/overview/index.html), and where +it is stable it is the better API: ISO 8601 timestamps rather than Excel-style serial numbers, typed status +enumerations rather than numeric codes, native latitude and longitude, a sensor status summary embedded in +every probe, group and device, and a `path` array giving each object its place in the tree. It cannot yet +run this plugin, for three separate reasons. + +### 1. Endpoints this plugin needs that API v2 does not have + +Checked against the published +[API v2 OpenAPI specification](https://www.paessler.com/support/prtg/api/v2/oas/prtg.api.yaml): + +| What the plugin needs | API v1 | API v2 | +| --------------------- | ------ | ------ | +| List every probe, group, device and sensor, for indexing | `table.json?content=…&count=50000` — one request per type | Only `GET /experimental/{probes,groups,devices,sensors}`. The non-experimental equivalents are deprecated, and the stable endpoints are single-object `GET /{type}/{id}` lookups. Capped at 3,000 objects per request. | +| Everything beneath one probe, group or device | `table.json?content=sensors&id=…` — PRTG walks the subtree | Only through the experimental `filter` parameter on those experimental list endpoints. | +| Channel readings for one sensor | `table.json?content=channels&id=…` | `GET /sensors/{id}/data` — **stable, and better than API v1.** | +| Historic readings over an arbitrary window | `historicdata.json?sdate=&edate=&avg=` — any range, and a choice of raw, 5-minute, hourly or daily buckets | `GET /experimental/timeseries/{id}/{type}`, where `type` is one of four fixed windows: `live` (4 hours), `short` (2 days), `medium` (60 days), `long` (365 days). No arbitrary range and no averaging control. | +| PRTG log entries over a timeframe | `table.json?content=messages&filter_dstart=&filter_dend=` | **Nothing.** The specification contains no log, message or event endpoint. | +| Installation-wide sensor counts, version and edition | `getstatus.htm?id=0` — one request | `GET /sensor-status-summary` and `GET /version` are stable and cover most of it, and the experimental `/license` covers the edition — but that is three requests instead of one, and the new-message count, new-alarm count and server clock have no equivalent. | +| The PRTG account's time zone | `getstatus.htm?id=0` → `UserTimeZone` | **Nothing.** The word "timezone" does not appear in the specification. | + +On API v2, then, the **Log** data stream could not be built at all, **Sensor History** could not follow a +dashboard timeframe, and the time zone check performed when you save the configuration would not be +possible. + +### 2. What remains is marked experimental + +Paessler define an experimental endpoint as one that "might change between releases", and API v2 has +already moved: the plain `/probes`, `/groups`, `/devices`, `/sensors` and `/channels` endpoints are +deprecated in favour of `/experimental/…` ones, the original `/experimental/timeseries/{id}` is deprecated +in favour of `/experimental/timeseries/{id}/{type}`, `/experimental/channels` is simultaneously deprecated +*and* experimental, and the API was substantially reworked in PRTG 24.3.100. Every object list this plugin +indexes would sit on an endpoint Paessler reserve the right to change. + +API v1 carries the lower churn risk today, not the higher one. It is not deprecated, it has no announced +end of life, and Paessler's own API v2 reference still says that if you cannot achieve your objective with +API v2 you can use API v1 instead. + +### 3. API v2 is not available everywhere API v1 is + +Per Paessler's [guidance on the new UI and API v2](https://helpdesk.paessler.com/en/support/solutions/articles/76000063881-i-want-to-use-the-new-ui-and-api-v2-what-do-i-need-to-know-), +API v2 is not available on PRTG Hosted Monitor at all, clusters are not supported, and on an existing +installation it stays off until an administrator enables it under **Setup → Activate New UI And API v2**, +which needs ports 1615, 1616 and 23580 free on the PRTG server. It is only on by default for installations +created since PRTG 25.2.106. + +A low-code plugin has a single base URL and a single authentication configuration, so using API v2 even for +part of the data would make all of that a prerequisite for using the plugin at all, and would end Hosted +Monitor support. The one stream that would benefit — Sensor Channels — is not worth that trade. + +### When this should be revisited + +A separate PRTG plugin built on API v2 becomes worth having once: + +1. API v2 exposes a log or messages endpoint that can be filtered by date; +2. historic data can be requested for an arbitrary start and end time, with a choice of averaging interval; +3. the probe, group, device and sensor list endpoints leave `/experimental/` without being deprecated, and + offer either a subtree filter or a page size that makes indexing tens of thousands of sensors practical; +4. API v2 reaches PRTG Hosted Monitor, or dropping Hosted Monitor support becomes an accepted trade. + +The first two are hard blockers; the rest are cost. Because moving off API v1 would break existing users, +it belongs in a new major version of this plugin alongside this one rather than as a change to it. From 33f28631b10f974ac77e99de885f45236fe21948 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Thu, 27 Aug 2026 12:13:34 +0100 Subject: [PATCH 06/13] Document the PRTG time zone constraint and the UTC workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API v1 time zone is a property of the PRTG account the key belongs to, not of the request — Paessler document no per-request override. Cite that, and note API v2 has no time zone endpoint either. Because the zone follows the account, pointing the key at a dedicated PRTG account set to UTC removes the problem entirely, daylight saving included. That was not written down anywhere; add it to Setup, worded so it does not read as contradicting the advice to pick a region rather than an offset. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/PRTG/v1/docs/README.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md index f90773cb..ea2560db 100644 --- a/plugins/PRTG/v1/docs/README.md +++ b/plugins/PRTG/v1/docs/README.md @@ -25,6 +25,13 @@ You will need the **URL** of your PRTG server and a PRTG **API key**. under **Setup → Account Settings → My Account → Time Zone**. Saving checks your answer against the zone PRTG reports, so a mismatch is flagged there and then. +> **Tip: give SquaredUp its own PRTG account, set to UTC.** PRTG has no per-request time zone parameter — +> every response uses the time zone of the account the key belongs to. Change that account's time zone to +> **UTC** in PRTG, create the key there, and answer `UTC` here: daylight saving stops mattering, and nobody +> editing their personal time zone can silently shift your dashboards. Note this means genuinely setting the +> PRTG account to UTC — answering `UTC` for an account still on another zone is the mismatch the save-time +> check exists to catch. + ## Configuration fields | Field | What it is | Where to find it | Required | @@ -99,11 +106,14 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by code-based plugins, not low-code ones like this. The PRTG hierarchy is therefore expressed as properties (`parentId`, `deviceName`, `groupName`, `probeName`) and through dashboard scoping and drilldown, rather than as traversable parent/child links in the graph. -- **Date ranges follow PRTG's own time zone.** See the **PRTG time zone** field above. PRTG offers no way to - query in UTC and accepts no relative ranges, so the zone has to be supplied. It is also used to convert - **Sensor History** timestamps to UTC, because that endpoint reports times only as local text with no UTC - equivalent — so the wrong zone shifts both the range queried *and* the times plotted. An unrecognised zone - name falls back to UTC rather than failing the request. +- **Date ranges follow the API account's time zone.** See the **PRTG time zone** field above. The zone is a + property of the PRTG *account* the key belongs to, and Paessler + [document no per-request override](https://helpdesk.paessler.com/en/support/solutions/articles/76000073098-output-of-api-table-json-content-messages-contains-datetime-is-it-in-gmt-utc-filter-dstart) — there is no + UTC parameter and no relative ranges — so the zone has to be supplied. API v2 does not help; it exposes no + time zone endpoint at all. The zone is also used to convert **Sensor History** timestamps to UTC, because + that endpoint reports times only as local text with no UTC equivalent — so the wrong zone shifts both the + range queried *and* the times plotted. An unrecognised zone name falls back to UTC rather than failing the + request. PRTG does report the zone it is using, as a fixed offset — the **System Status** data stream surfaces it as **Server Time Zone**, and saving the configuration warns when it disagrees with the zone you picked. That From 200b7819d0d5cfd4f850965578935ecfdc8a43c0 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Thu, 27 Aug 2026 12:13:41 +0100 Subject: [PATCH 07/13] Use the PRTG gauge mark alone as the plugin icon The icon was the stacked "PRTG / NETWORK / MONITOR" lockup. Each text line was only about 63px tall inside the 512 canvas, so at tile size the words rendered around 4px tall and read as a smudge. Drop the three wordmark paths and rescale the four gauge-arc paths, which were already in the file, to fill the square. Keep the white plate: it is the convention for the other 512-viewBox icons here, and the needle is navy, so it would disappear on a dark tile without one. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/PRTG/v1/icon.svg | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/plugins/PRTG/v1/icon.svg b/plugins/PRTG/v1/icon.svg index 6cb211ed..fec7195a 100644 --- a/plugins/PRTG/v1/icon.svg +++ b/plugins/PRTG/v1/icon.svg @@ -1,32 +1,25 @@ - + - - + + + + + + + d="m 2124.33,1394 c 38.66,-48.27 62.52,-108.81 64.42,-175.11 1.52,-52.89 -11.15,-102.89 -34.47,-146.54 l 138.46,-84.854 c 64.95,91.814 93.1,208.864 68.37,327.364 -17.51,83.83 -59.23,156.5 -116.17,212.76" /> + + From b46dea74c9d233455b577e9bb59f2a918bdcbe68 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 21 Sep 2026 10:15:56 +0100 Subject: [PATCH 08/13] Page the PRTG object tables instead of asking for everything at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object streams fetched `count=50000` in a single request with `paging: { mode: "none" }`. That is the stopgap 4aff6af called out on NinjaOne — it works until an estate outgrows one response, then fails whole rather than degrading, and it cannot be tuned per installation. `table.json` takes `start` as a row offset alongside `count`, which maps onto the platform's row-offset paging directly. Row counting reads back through `payloadArraySize` on the same path as `pathToData`, so a short page ends the run — no dependence on `treesize`, which is honest for the object tables but a placeholder `1000000` for `content=messages`. All six paged streams already sorted by `objid`. That matters more than it looks: offset paging over an unstable order silently skips and repeats rows. `sensorChannels` keeps `mode: "none"`. A sensor's channel count is bounded small and the endpoint has no stable sort to page over, so the machinery would add risk for nothing — its `count=50000` is dropped in favour of PRTG's own default. `logs` is left alone too: it is capped at 5,000 newest-first with no usable total, which is a different problem. Co-Authored-By: Claude Opus 5 --- .../PRTG/v1/dataStreams/containerDevices.json | 23 +++++++++++++++---- .../PRTG/v1/dataStreams/containerSensors.json | 23 +++++++++++++++---- plugins/PRTG/v1/dataStreams/devices.json | 23 +++++++++++++++---- plugins/PRTG/v1/dataStreams/groups.json | 23 +++++++++++++++---- plugins/PRTG/v1/dataStreams/probes.json | 23 +++++++++++++++---- .../PRTG/v1/dataStreams/sensorChannels.json | 3 +-- plugins/PRTG/v1/dataStreams/sensors.json | 23 +++++++++++++++---- 7 files changed, 109 insertions(+), 32 deletions(-) diff --git a/plugins/PRTG/v1/dataStreams/containerDevices.json b/plugins/PRTG/v1/dataStreams/containerDevices.json index b706f90e..0aebfd26 100644 --- a/plugins/PRTG/v1/dataStreams/containerDevices.json +++ b/plugins/PRTG/v1/dataStreams/containerDevices.json @@ -26,15 +26,28 @@ { "key": "sortby", "value": "objid" - }, - { - "key": "count", - "value": "50000" } ], "pathToData": "devices", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "count", + "value": "500" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "devices" + }, + "base": "0" + }, + "out": { + "realm": "queryArg", + "path": "start" + } } }, "matches": { diff --git a/plugins/PRTG/v1/dataStreams/containerSensors.json b/plugins/PRTG/v1/dataStreams/containerSensors.json index a50f18ab..420abbaa 100644 --- a/plugins/PRTG/v1/dataStreams/containerSensors.json +++ b/plugins/PRTG/v1/dataStreams/containerSensors.json @@ -26,15 +26,28 @@ { "key": "sortby", "value": "objid" - }, - { - "key": "count", - "value": "50000" } ], "pathToData": "sensors", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "count", + "value": "500" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "sensors" + }, + "base": "0" + }, + "out": { + "realm": "queryArg", + "path": "start" + } } }, "matches": { diff --git a/plugins/PRTG/v1/dataStreams/devices.json b/plugins/PRTG/v1/dataStreams/devices.json index a68d9d7f..a8b3e487 100644 --- a/plugins/PRTG/v1/dataStreams/devices.json +++ b/plugins/PRTG/v1/dataStreams/devices.json @@ -22,15 +22,28 @@ { "key": "sortby", "value": "objid" - }, - { - "key": "count", - "value": "50000" } ], "pathToData": "devices", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "count", + "value": "500" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "devices" + }, + "base": "0" + }, + "out": { + "realm": "queryArg", + "path": "start" + } } }, "matches": "none", diff --git a/plugins/PRTG/v1/dataStreams/groups.json b/plugins/PRTG/v1/dataStreams/groups.json index b3dadcb9..813b2202 100644 --- a/plugins/PRTG/v1/dataStreams/groups.json +++ b/plugins/PRTG/v1/dataStreams/groups.json @@ -26,15 +26,28 @@ { "key": "sortby", "value": "objid" - }, - { - "key": "count", - "value": "50000" } ], "pathToData": "groups", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "count", + "value": "500" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "groups" + }, + "base": "0" + }, + "out": { + "realm": "queryArg", + "path": "start" + } } }, "matches": "none", diff --git a/plugins/PRTG/v1/dataStreams/probes.json b/plugins/PRTG/v1/dataStreams/probes.json index fce83668..f8d18e2c 100644 --- a/plugins/PRTG/v1/dataStreams/probes.json +++ b/plugins/PRTG/v1/dataStreams/probes.json @@ -26,15 +26,28 @@ { "key": "sortby", "value": "objid" - }, - { - "key": "count", - "value": "50000" } ], "pathToData": "probes", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "count", + "value": "500" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "probes" + }, + "base": "0" + }, + "out": { + "realm": "queryArg", + "path": "start" + } } }, "matches": "none", diff --git a/plugins/PRTG/v1/dataStreams/sensorChannels.json b/plugins/PRTG/v1/dataStreams/sensorChannels.json index 89504968..c288d507 100644 --- a/plugins/PRTG/v1/dataStreams/sensorChannels.json +++ b/plugins/PRTG/v1/dataStreams/sensorChannels.json @@ -13,8 +13,7 @@ { "key": "columns", "value": "objid,name,lastvalue,minimum,maximum" - }, - { "key": "count", "value": "50000" } + } ], "pathToData": "channels", "paging": { "mode": "none" } diff --git a/plugins/PRTG/v1/dataStreams/sensors.json b/plugins/PRTG/v1/dataStreams/sensors.json index 49d91c86..739778f5 100644 --- a/plugins/PRTG/v1/dataStreams/sensors.json +++ b/plugins/PRTG/v1/dataStreams/sensors.json @@ -22,15 +22,28 @@ { "key": "sortby", "value": "objid" - }, - { - "key": "count", - "value": "50000" } ], "pathToData": "sensors", "paging": { - "mode": "none" + "mode": "offset", + "pageSize": { + "realm": "queryArg", + "path": "count", + "value": "500" + }, + "offset": { + "mode": "row", + "rowCountIn": { + "realm": "payloadArraySize", + "path": "sensors" + }, + "base": "0" + }, + "out": { + "realm": "queryArg", + "path": "start" + } } }, "matches": "none", From 26cb3316c641f5ba5a7f9f5f7076582c6b034bd2 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 21 Sep 2026 10:16:10 +0100 Subject: [PATCH 09/13] Correct the API v2 comparison in the PRTG README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Why this plugin uses API v1" section was written to answer a review question, so its accuracy is the whole point of it. Three claims did not survive checking against the published OpenAPI specification: - Object listing does not need one request per type. `GET /experimental/objects` returns probes, groups, devices, sensors and channels from one paged endpoint, and its `filter` takes `parentid` plus `children`/`descendants`/`ancestors`. 3,000 is a per-request page size with RFC 5988 `Link` headers, not a ceiling. This is better than API v1, not worse. - Arbitrary historic ranges do exist, via `from`/`to` on `/experimental/timeseries/{id}`. That endpoint is deprecated and slated for removal, and neither form has an averaging control — which is the real objection, and a narrower one than "no arbitrary range". - `/experimental/channels` is experimental but not deprecated. The `deprecated` markers in that block sit on its `filter` and `sort_by` parameters. Drops the time zone row entirely. It listed the absence of a time zone endpoint as an API v2 gap, when API v2 returns ISO 8601 in UTC throughout and would remove this plugin's time zone setting altogether. Presenting an advantage as a deficiency was the least defensible line in the section, and the Known limitations note that echoed it is corrected too. Reorders the three reasons to lead with deployment reach — no Hosted Monitor, no clusters, administrator opt-in below 25.2.106 — because that is the one that actually binds a single-base-URL low-code plugin, and the endpoint table was carrying an argument it cannot support alone. Also states Paessler's stated intent to retire API v1 eventually, rather than leaving "no announced end of life" to be read as spin. Names the tested build in the compatibility callout, and rewrites the response-size limitation now that the object tables page. Co-Authored-By: Claude Opus 5 --- plugins/PRTG/v1/docs/README.md | 110 ++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md index ea2560db..166509e2 100644 --- a/plugins/PRTG/v1/docs/README.md +++ b/plugins/PRTG/v1/docs/README.md @@ -3,8 +3,9 @@ devices and sensors, with current status, channel readings, historic sensor data [PRTG HTTP API](https://www.paessler.com/manuals/prtg/http_api). > ⚠️ **This plugin uses the PRTG API v1** — `/api/table.json`, `/api/historicdata.json` and -> `/api/getstatus.htm`. Any PRTG version that supports API keys will work, including PRTG Network Monitor, -> PRTG Enterprise Monitor and PRTG Hosted Monitor, and you do **not** need to enable the new UI or API v2. +> `/api/getstatus.htm`. It works against PRTG Network Monitor, PRTG Enterprise Monitor and PRTG Hosted +> Monitor, and you do **not** need to enable the new UI or API v2. Any version supporting API keys is +> expected to work; this plugin was built and tested against PRTG 26.3.122.1665. > See [Why this plugin uses API v1](#why-this-plugin-uses-api-v1) for the reasoning. ## Setup @@ -109,8 +110,10 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by - **Date ranges follow the API account's time zone.** See the **PRTG time zone** field above. The zone is a property of the PRTG *account* the key belongs to, and Paessler [document no per-request override](https://helpdesk.paessler.com/en/support/solutions/articles/76000073098-output-of-api-table-json-content-messages-contains-datetime-is-it-in-gmt-utc-filter-dstart) — there is no - UTC parameter and no relative ranges — so the zone has to be supplied. API v2 does not help; it exposes no - time zone endpoint at all. The zone is also used to convert **Sensor History** timestamps to UTC, because + UTC parameter and no relative ranges — so the zone has to be supplied. (API v2 would remove the problem + entirely, since it returns ISO 8601 in UTC throughout, but it cannot yet run this plugin — see + [Why this plugin uses API v1](#why-this-plugin-uses-api-v1).) The zone is also used to convert + **Sensor History** timestamps to UTC, because that endpoint reports times only as local text with no UTC equivalent — so the wrong zone shifts both the range queried *and* the times plotted. An unrecognised zone name falls back to UTC rather than failing the request. @@ -119,10 +122,10 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by **Server Time Zone**, and saving the configuration warns when it disagrees with the zone you picked. That offset cannot replace the setting, though: it describes only the present moment, whereas **Sensor History** covers up to 30 days and needs the daylight saving transitions an IANA name carries. -- **Very large installations may hit a response size limit.** Each object type is fetched in a single - request rather than page by page. In practice the sensor import is the binding constraint and should - comfortably handle around 10,000 sensors, which is also Paessler's own recommended maximum per core - server. Larger installations may fail to import sensors. +- **Very large installations may still hit a response size limit.** Probes, groups, devices and sensors are + fetched page by page, 500 rows per request, so there is no per-request ceiling on how many objects can be + imported. The combined result is still subject to the platform's overall response size limit, so an estate + far beyond Paessler's recommended maximum of 10,000 sensors per core server may fail to import. - **Sensor History is limited to 30 days**, and beyond a week it is averaged hourly. Finer buckets over a long range return more rows than the platform's response size limit allows, so when you have not chosen an **Averaging interval** the plugin asks PRTG for hourly figures on ranges longer than seven days. Choosing @@ -157,44 +160,13 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by ## Why this plugin uses API v1 PRTG also has a newer [API v2](https://www.paessler.com/support/prtg/api/v2/overview/index.html), and where -it is stable it is the better API: ISO 8601 timestamps rather than Excel-style serial numbers, typed status -enumerations rather than numeric codes, native latitude and longitude, a sensor status summary embedded in -every probe, group and device, and a `path` array giving each object its place in the tree. It cannot yet -run this plugin, for three separate reasons. +it is stable it is the better API: ISO 8601 timestamps in UTC rather than Excel-style serial numbers in the +account's local time, typed status enumerations rather than numeric codes, native latitude and longitude, a +sensor status summary embedded in every probe, group and device, and a `path` array giving each object its +place in the tree. Adopting it would remove the **PRTG time zone** setting from this plugin entirely. It +still cannot run this plugin, for three reasons, in descending order of how binding they are. -### 1. Endpoints this plugin needs that API v2 does not have - -Checked against the published -[API v2 OpenAPI specification](https://www.paessler.com/support/prtg/api/v2/oas/prtg.api.yaml): - -| What the plugin needs | API v1 | API v2 | -| --------------------- | ------ | ------ | -| List every probe, group, device and sensor, for indexing | `table.json?content=…&count=50000` — one request per type | Only `GET /experimental/{probes,groups,devices,sensors}`. The non-experimental equivalents are deprecated, and the stable endpoints are single-object `GET /{type}/{id}` lookups. Capped at 3,000 objects per request. | -| Everything beneath one probe, group or device | `table.json?content=sensors&id=…` — PRTG walks the subtree | Only through the experimental `filter` parameter on those experimental list endpoints. | -| Channel readings for one sensor | `table.json?content=channels&id=…` | `GET /sensors/{id}/data` — **stable, and better than API v1.** | -| Historic readings over an arbitrary window | `historicdata.json?sdate=&edate=&avg=` — any range, and a choice of raw, 5-minute, hourly or daily buckets | `GET /experimental/timeseries/{id}/{type}`, where `type` is one of four fixed windows: `live` (4 hours), `short` (2 days), `medium` (60 days), `long` (365 days). No arbitrary range and no averaging control. | -| PRTG log entries over a timeframe | `table.json?content=messages&filter_dstart=&filter_dend=` | **Nothing.** The specification contains no log, message or event endpoint. | -| Installation-wide sensor counts, version and edition | `getstatus.htm?id=0` — one request | `GET /sensor-status-summary` and `GET /version` are stable and cover most of it, and the experimental `/license` covers the edition — but that is three requests instead of one, and the new-message count, new-alarm count and server clock have no equivalent. | -| The PRTG account's time zone | `getstatus.htm?id=0` → `UserTimeZone` | **Nothing.** The word "timezone" does not appear in the specification. | - -On API v2, then, the **Log** data stream could not be built at all, **Sensor History** could not follow a -dashboard timeframe, and the time zone check performed when you save the configuration would not be -possible. - -### 2. What remains is marked experimental - -Paessler define an experimental endpoint as one that "might change between releases", and API v2 has -already moved: the plain `/probes`, `/groups`, `/devices`, `/sensors` and `/channels` endpoints are -deprecated in favour of `/experimental/…` ones, the original `/experimental/timeseries/{id}` is deprecated -in favour of `/experimental/timeseries/{id}/{type}`, `/experimental/channels` is simultaneously deprecated -*and* experimental, and the API was substantially reworked in PRTG 24.3.100. Every object list this plugin -indexes would sit on an endpoint Paessler reserve the right to change. - -API v1 carries the lower churn risk today, not the higher one. It is not deprecated, it has no announced -end of life, and Paessler's own API v2 reference still says that if you cannot achieve your objective with -API v2 you can use API v1 instead. - -### 3. API v2 is not available everywhere API v1 is +### 1. API v2 is not available everywhere API v1 is Per Paessler's [guidance on the new UI and API v2](https://helpdesk.paessler.com/en/support/solutions/articles/76000063881-i-want-to-use-the-new-ui-and-api-v2-what-do-i-need-to-know-), API v2 is not available on PRTG Hosted Monitor at all, clusters are not supported, and on an existing @@ -202,18 +174,54 @@ installation it stays off until an administrator enables it under **Setup → Ac which needs ports 1615, 1616 and 23580 free on the PRTG server. It is only on by default for installations created since PRTG 25.2.106. -A low-code plugin has a single base URL and a single authentication configuration, so using API v2 even for -part of the data would make all of that a prerequisite for using the plugin at all, and would end Hosted -Monitor support. The one stream that would benefit — Sensor Channels — is not worth that trade. +A low-code plugin has a single base URL and a single authentication configuration, so building on API v2 — +even for part of the data — would make all of that a prerequisite for using the plugin at all, and would end +Hosted Monitor support outright. This is the binding constraint, and it is not about what the API can do but +about who would be able to use it. + +### 2. Endpoints this plugin needs that API v2 does not have + +Checked against the published +[API v2 OpenAPI specification](https://www.paessler.com/support/prtg/api/v2/oas/prtg.api.yaml): + +| What the plugin needs | API v1 | API v2 | +| --------------------- | ------ | ------ | +| List every probe, group, device and sensor, for indexing | `table.json?content=…` — one request per type | `GET /experimental/objects` returns all four from one paged endpoint, channels included. 3,000 is the per-request page size rather than a ceiling, and responses carry RFC 5988 `Link` headers for the next page. **Better than API v1.** | +| Everything beneath one probe, group or device | `table.json?content=sensors&id=…` — PRTG walks the subtree | The same endpoint's `filter` supports `parentid`, plus `children`, `descendants` and `ancestors` prefixes. Equivalent. | +| Channel readings for one sensor | `table.json?content=channels&id=…` | `GET /sensors/{id}/data` — **stable, and better than API v1.** | +| Historic readings over an arbitrary window | `historicdata.json?sdate=&edate=&avg=` — any range, and a choice of raw, 5-minute, hourly or daily buckets | The supported `GET /experimental/timeseries/{id}/{type}` offers four fixed windows only: `live` (4 hours), `short` (2 days), `medium` (60 days), `long` (365 days). Arbitrary `from`/`to` exist only on `GET /experimental/timeseries/{id}`, which Paessler have deprecated and say will be removed. Neither form has an averaging control. | +| PRTG log entries over a timeframe | `table.json?content=messages&filter_dstart=&filter_dend=` | **Nothing.** The specification contains no log, message or event endpoint at any maturity level. | +| Installation-wide sensor counts, version and edition | `getstatus.htm?id=0` — one request | `GET /sensor-status-summary` and `GET /version` are stable and cover most of it, and the experimental `/license` covers the edition — but that is three requests instead of one, and the new-message count and new-alarm count have no equivalent. | + +Object indexing and channel readings are therefore not the obstacle — API v2 handles both, and handles them +better. Two things are. The **Log** data stream has no endpoint to build on at all. And **Sensor History** +could only follow a dashboard timeframe by building on an endpoint Paessler have already marked for removal, +and would lose the averaging interval that keeps a long range inside the response size limit. + +### 3. What remains is marked experimental + +Paessler define an experimental endpoint as one that "might change between releases", and API v2 has already +moved: the plain `/objects`, `/probes`, `/groups`, `/devices`, `/sensors` and `/channels` endpoints are all +deprecated in favour of `/experimental/…` ones, and `/experimental/timeseries/{id}` is deprecated in favour +of `/experimental/timeseries/{id}/{type}`. The specification carries 26 deprecated operations, every one of +them stamped with the same release — PRTG 24.3.100, where the API was substantially reworked. Every object +list this plugin indexes would sit on an endpoint Paessler reserve the right to change. + +API v1 carries the lower churn risk today. It is not deprecated, it has no announced end of life, and +Paessler's own API v2 reference still says that if you cannot achieve your objective with API v2 you can use +API v1 instead. Paessler have said they plan to deactivate the classic web interface and API v1 once the new +UI and API v2 are stable and feature complete, and to give advance notice of that period — so this is a +question of when rather than whether. No date has been given, and Paessler still describe API v2 as not yet +feature complete. ### When this should be revisited A separate PRTG plugin built on API v2 becomes worth having once: 1. API v2 exposes a log or messages endpoint that can be filtered by date; -2. historic data can be requested for an arbitrary start and end time, with a choice of averaging interval; -3. the probe, group, device and sensor list endpoints leave `/experimental/` without being deprecated, and - offer either a subtree filter or a page size that makes indexing tens of thousands of sensors practical; +2. historic data can be requested for an arbitrary start and end time on a non-deprecated endpoint, with a + choice of averaging interval; +3. `/experimental/objects` leaves `/experimental/` without being deprecated in turn; 4. API v2 reaches PRTG Hosted Monitor, or dropping Hosted Monitor support becomes an accepted trade. The first two are hard blockers; the rest are cost. Because moving off API v1 would break existing users, From ed2568a1238faac1e3d8bdaa7672f12db9124c3d Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 21 Sep 2026 10:16:19 +0100 Subject: [PATCH 10/13] Follow repo convention for cspell, CODEOWNERS and drop dead code Three unrelated bits of drift, none behavioural: - `PRTG` and `Paessler` were added to the root `cspell.json`. AGENTS.md documents the per-plugin list as the place for product names the root list does not cover, and eleven plugins already do that, so they move to `plugins/PRTG/v1/cspell.json` and the root file returns to matching `main`. - The CODEOWNERS entry was `/plugins/PRTG/`, the only one of 26 with leading and trailing slashes. Recursive ownership is arguably the better pattern, but it is a repo-wide question rather than a PRTG one, so match the surrounding `plugins/X/*` form instead of being the exception. - The `edate` expression in `sensorHistory.json` declared `MAX` and `start` and used neither, copied from `sdate` where the 30-day clamp genuinely applies. Co-Authored-By: Claude Opus 5 --- .github/CODEOWNERS | 2 +- cspell.json | 4 +--- plugins/PRTG/v1/cspell.json | 6 ++++++ plugins/PRTG/v1/dataStreams/sensorHistory.json | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 plugins/PRTG/v1/cspell.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5e44327d..8e805716 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,7 +15,7 @@ plugins/IncidentIO/ @noorulkhan-n plugins/FantasyPremierLeague/* @TimWheeler-SQUP plugins/GoogleSheets/* @kieranlangton plugins/MetOffice/* @blackgrouse -/plugins/PRTG/ @Deenk +plugins/PRTG/* @Deenk plugins/Phare/* @vinbab plugins/Postcoder/* @richbenwell plugins/RDAP/* @richbenwell diff --git a/cspell.json b/cspell.json index ab612a7f..d563d67b 100644 --- a/cspell.json +++ b/cspell.json @@ -5,8 +5,6 @@ "rdap", "whois", "rootly", - "UniFi", - "PRTG", - "Paessler" + "UniFi" ] } \ No newline at end of file diff --git a/plugins/PRTG/v1/cspell.json b/plugins/PRTG/v1/cspell.json new file mode 100644 index 00000000..5f1448ed --- /dev/null +++ b/plugins/PRTG/v1/cspell.json @@ -0,0 +1,6 @@ +{ + "words": [ + "PRTG", + "Paessler" + ] +} diff --git a/plugins/PRTG/v1/dataStreams/sensorHistory.json b/plugins/PRTG/v1/dataStreams/sensorHistory.json index e160fcd9..3ee72b22 100644 --- a/plugins/PRTG/v1/dataStreams/sensorHistory.json +++ b/plugins/PRTG/v1/dataStreams/sensorHistory.json @@ -25,7 +25,7 @@ }, { "key": "edate", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var MAX = 30 * 86400000; var start = new Date(timeframe.start).getTime(); var end = new Date(timeframe.end).getTime(); var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(end)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var end = new Date(timeframe.end).getTime(); var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(end)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" }, { "key": "avg", From 2821f7050ed3084de13c5d4ffd1bfcf83ffd3a4b Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 28 Sep 2026 10:23:37 +0100 Subject: [PATCH 11/13] Page PRTG object tables by objid cursor, since start clamps instead of ending table.json's start parameter returns the last page again once it runs past the end, so row-offset paging never terminated. Page with filter_objid=@above() instead, which returns nothing once no rows remain. The scripts also rebuild status from status_raw, because the status text varies by PRTG version. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PRTG/v1/dataStreams/containerDevices.json | 18 ++-- .../PRTG/v1/dataStreams/containerSensors.json | 18 ++-- plugins/PRTG/v1/dataStreams/devices.json | 18 ++-- plugins/PRTG/v1/dataStreams/groups.json | 22 ++--- plugins/PRTG/v1/dataStreams/probes.json | 18 ++-- .../dataStreams/scripts/containerDevices.js | 22 +++++ .../dataStreams/scripts/containerSensors.js | 22 +++++ .../PRTG/v1/dataStreams/scripts/devices.js | 20 +++++ plugins/PRTG/v1/dataStreams/scripts/groups.js | 21 +++++ plugins/PRTG/v1/dataStreams/scripts/probes.js | 20 +++++ .../PRTG/v1/dataStreams/scripts/sensors.js | 20 +++++ .../v1/dataStreams/scripts/timeZoneCheck.js | 88 ------------------- plugins/PRTG/v1/dataStreams/sensors.json | 18 ++-- .../PRTG/v1/dataStreams/timeZoneCheck.json | 24 ----- 14 files changed, 167 insertions(+), 182 deletions(-) create mode 100644 plugins/PRTG/v1/dataStreams/scripts/containerDevices.js create mode 100644 plugins/PRTG/v1/dataStreams/scripts/containerSensors.js create mode 100644 plugins/PRTG/v1/dataStreams/scripts/devices.js create mode 100644 plugins/PRTG/v1/dataStreams/scripts/groups.js create mode 100644 plugins/PRTG/v1/dataStreams/scripts/probes.js create mode 100644 plugins/PRTG/v1/dataStreams/scripts/sensors.js delete mode 100644 plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js delete mode 100644 plugins/PRTG/v1/dataStreams/timeZoneCheck.json diff --git a/plugins/PRTG/v1/dataStreams/containerDevices.json b/plugins/PRTG/v1/dataStreams/containerDevices.json index 0aebfd26..964f9875 100644 --- a/plugins/PRTG/v1/dataStreams/containerDevices.json +++ b/plugins/PRTG/v1/dataStreams/containerDevices.json @@ -28,27 +28,23 @@ "value": "objid" } ], - "pathToData": "devices", "paging": { - "mode": "offset", + "mode": "token", "pageSize": { "realm": "queryArg", "path": "count", "value": "500" }, - "offset": { - "mode": "row", - "rowCountIn": { - "realm": "payloadArraySize", - "path": "devices" - }, - "base": "0" + "in": { + "realm": "payload", + "path": "nextCursor" }, "out": { "realm": "queryArg", - "path": "start" + "path": "filter_objid" } - } + }, + "postRequestScript": "containerDevices.js" }, "matches": { "sourceType": { diff --git a/plugins/PRTG/v1/dataStreams/containerSensors.json b/plugins/PRTG/v1/dataStreams/containerSensors.json index 420abbaa..b2e10e45 100644 --- a/plugins/PRTG/v1/dataStreams/containerSensors.json +++ b/plugins/PRTG/v1/dataStreams/containerSensors.json @@ -28,27 +28,23 @@ "value": "objid" } ], - "pathToData": "sensors", "paging": { - "mode": "offset", + "mode": "token", "pageSize": { "realm": "queryArg", "path": "count", "value": "500" }, - "offset": { - "mode": "row", - "rowCountIn": { - "realm": "payloadArraySize", - "path": "sensors" - }, - "base": "0" + "in": { + "realm": "payload", + "path": "nextCursor" }, "out": { "realm": "queryArg", - "path": "start" + "path": "filter_objid" } - } + }, + "postRequestScript": "containerSensors.js" }, "matches": { "sourceType": { diff --git a/plugins/PRTG/v1/dataStreams/devices.json b/plugins/PRTG/v1/dataStreams/devices.json index a8b3e487..ba852001 100644 --- a/plugins/PRTG/v1/dataStreams/devices.json +++ b/plugins/PRTG/v1/dataStreams/devices.json @@ -24,27 +24,23 @@ "value": "objid" } ], - "pathToData": "devices", "paging": { - "mode": "offset", + "mode": "token", "pageSize": { "realm": "queryArg", "path": "count", "value": "500" }, - "offset": { - "mode": "row", - "rowCountIn": { - "realm": "payloadArraySize", - "path": "devices" - }, - "base": "0" + "in": { + "realm": "payload", + "path": "nextCursor" }, "out": { "realm": "queryArg", - "path": "start" + "path": "filter_objid" } - } + }, + "postRequestScript": "devices.js" }, "matches": "none", "metadata": [ diff --git a/plugins/PRTG/v1/dataStreams/groups.json b/plugins/PRTG/v1/dataStreams/groups.json index 813b2202..cb35d47b 100644 --- a/plugins/PRTG/v1/dataStreams/groups.json +++ b/plugins/PRTG/v1/dataStreams/groups.json @@ -15,10 +15,6 @@ "key": "content", "value": "groups" }, - { - "key": "filter_objid", - "value": "@neq(0)" - }, { "key": "columns", "value": "objid,name,parentid,probe,status,active,totalsens,upsens,downsens,warnsens,pausedsens,location,tags" @@ -28,27 +24,23 @@ "value": "objid" } ], - "pathToData": "groups", "paging": { - "mode": "offset", + "mode": "token", "pageSize": { "realm": "queryArg", "path": "count", "value": "500" }, - "offset": { - "mode": "row", - "rowCountIn": { - "realm": "payloadArraySize", - "path": "groups" - }, - "base": "0" + "in": { + "realm": "payload", + "path": "nextCursor" }, "out": { "realm": "queryArg", - "path": "start" + "path": "filter_objid" } - } + }, + "postRequestScript": "groups.js" }, "matches": "none", "metadata": [ diff --git a/plugins/PRTG/v1/dataStreams/probes.json b/plugins/PRTG/v1/dataStreams/probes.json index f8d18e2c..d284eb39 100644 --- a/plugins/PRTG/v1/dataStreams/probes.json +++ b/plugins/PRTG/v1/dataStreams/probes.json @@ -28,27 +28,23 @@ "value": "objid" } ], - "pathToData": "probes", "paging": { - "mode": "offset", + "mode": "token", "pageSize": { "realm": "queryArg", "path": "count", "value": "500" }, - "offset": { - "mode": "row", - "rowCountIn": { - "realm": "payloadArraySize", - "path": "probes" - }, - "base": "0" + "in": { + "realm": "payload", + "path": "nextCursor" }, "out": { "realm": "queryArg", - "path": "start" + "path": "filter_objid" } - } + }, + "postRequestScript": "probes.js" }, "matches": "none", "metadata": [ diff --git a/plugins/PRTG/v1/dataStreams/scripts/containerDevices.js b/plugins/PRTG/v1/dataStreams/scripts/containerDevices.js new file mode 100644 index 00000000..b154d0da --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/containerDevices.js @@ -0,0 +1,22 @@ +// PRTG's `status` text varies by version and language (a paused object reads +// "Paused (paused by user)" on some builds), so the state map in the stream's +// metadata could miss it. Rebuild it from `status_raw`, which is stable, and keep +// PRTG's own text only for a code this table does not know. +const STATUS_TEXT = { + 0: 'None', 1: 'Unknown', 2: 'Scanning', 3: 'Up', 4: 'Warning', 5: 'Down', 6: 'No Probe', + 7: 'Paused by User', 8: 'Paused by Dependency', 9: 'Paused by Schedule', 10: 'Unusual', + 11: 'Not Licensed', 12: 'Paused until', 13: 'Down (Acknowledged)', 14: 'Down (Partial)' +}; +const withStatus = (row) => { + const text = STATUS_TEXT[Number(row.status_raw)]; + return text && row.status_raw !== '' ? Object.assign({}, row, { status: text }) : row; +}; + +// table.json's `start` parameter clamps to the last page instead of returning an empty +// one once it runs past the end, so row-offset paging never terminates. Cursor on objid +// instead: PRTG's @above() filter genuinely returns nothing once none remain above it. +// This stream is httpRequestScopedSingle, so pagingContext is expected to reset between +// the separate per-object request cycles rather than leaking across them. +const rows = (data && data.devices) || []; +result = rows.map(withStatus); +pagingContext = rows.length ? '@above(' + Number(rows[rows.length - 1].objid_raw) + ')' : undefined; diff --git a/plugins/PRTG/v1/dataStreams/scripts/containerSensors.js b/plugins/PRTG/v1/dataStreams/scripts/containerSensors.js new file mode 100644 index 00000000..15047fac --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/containerSensors.js @@ -0,0 +1,22 @@ +// PRTG's `status` text varies by version and language (a paused object reads +// "Paused (paused by user)" on some builds), so the state map in the stream's +// metadata could miss it. Rebuild it from `status_raw`, which is stable, and keep +// PRTG's own text only for a code this table does not know. +const STATUS_TEXT = { + 0: 'None', 1: 'Unknown', 2: 'Scanning', 3: 'Up', 4: 'Warning', 5: 'Down', 6: 'No Probe', + 7: 'Paused by User', 8: 'Paused by Dependency', 9: 'Paused by Schedule', 10: 'Unusual', + 11: 'Not Licensed', 12: 'Paused until', 13: 'Down (Acknowledged)', 14: 'Down (Partial)' +}; +const withStatus = (row) => { + const text = STATUS_TEXT[Number(row.status_raw)]; + return text && row.status_raw !== '' ? Object.assign({}, row, { status: text }) : row; +}; + +// table.json's `start` parameter clamps to the last page instead of returning an empty +// one once it runs past the end, so row-offset paging never terminates. Cursor on objid +// instead: PRTG's @above() filter genuinely returns nothing once none remain above it. +// This stream is httpRequestScopedSingle, so pagingContext is expected to reset between +// the separate per-object request cycles rather than leaking across them. +const rows = (data && data.sensors) || []; +result = rows.map(withStatus); +pagingContext = rows.length ? '@above(' + Number(rows[rows.length - 1].objid_raw) + ')' : undefined; diff --git a/plugins/PRTG/v1/dataStreams/scripts/devices.js b/plugins/PRTG/v1/dataStreams/scripts/devices.js new file mode 100644 index 00000000..d1043eb8 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/devices.js @@ -0,0 +1,20 @@ +// PRTG's `status` text varies by version and language (a paused object reads +// "Paused (paused by user)" on some builds), so the state map in the stream's +// metadata could miss it. Rebuild it from `status_raw`, which is stable, and keep +// PRTG's own text only for a code this table does not know. +const STATUS_TEXT = { + 0: 'None', 1: 'Unknown', 2: 'Scanning', 3: 'Up', 4: 'Warning', 5: 'Down', 6: 'No Probe', + 7: 'Paused by User', 8: 'Paused by Dependency', 9: 'Paused by Schedule', 10: 'Unusual', + 11: 'Not Licensed', 12: 'Paused until', 13: 'Down (Acknowledged)', 14: 'Down (Partial)' +}; +const withStatus = (row) => { + const text = STATUS_TEXT[Number(row.status_raw)]; + return text && row.status_raw !== '' ? Object.assign({}, row, { status: text }) : row; +}; + +// table.json's `start` parameter clamps to the last page instead of returning an empty +// one once it runs past the end, so row-offset paging never terminates. Cursor on objid +// instead: PRTG's @above() filter genuinely returns nothing once none remain above it. +const rows = (data && data.devices) || []; +result = rows.map(withStatus); +pagingContext = rows.length ? '@above(' + Number(rows[rows.length - 1].objid_raw) + ')' : undefined; diff --git a/plugins/PRTG/v1/dataStreams/scripts/groups.js b/plugins/PRTG/v1/dataStreams/scripts/groups.js new file mode 100644 index 00000000..4f4732b9 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/groups.js @@ -0,0 +1,21 @@ +// PRTG's `status` text varies by version and language (a paused object reads +// "Paused (paused by user)" on some builds), so the state map in the stream's +// metadata could miss it. Rebuild it from `status_raw`, which is stable, and keep +// PRTG's own text only for a code this table does not know. +const STATUS_TEXT = { + 0: 'None', 1: 'Unknown', 2: 'Scanning', 3: 'Up', 4: 'Warning', 5: 'Down', 6: 'No Probe', + 7: 'Paused by User', 8: 'Paused by Dependency', 9: 'Paused by Schedule', 10: 'Unusual', + 11: 'Not Licensed', 12: 'Paused until', 13: 'Down (Acknowledged)', 14: 'Down (Partial)' +}; +const withStatus = (row) => { + const text = STATUS_TEXT[Number(row.status_raw)]; + return text && row.status_raw !== '' ? Object.assign({}, row, { status: text }) : row; +}; + +// filter_objid now carries the paging cursor (see paging.out below), so the root group's +// exclusion — previously a static filter_objid=@neq(0) getArg — happens here instead. +// Cursor is derived from the raw, unfiltered page: filtering out objid 0 before computing +// it would leave the next request's @above() one row short whenever 0 is not the last row. +const rawRows = (data && data.groups) || []; +result = rawRows.filter(function (row) { return Number(row.objid_raw) !== 0; }).map(withStatus); +pagingContext = rawRows.length ? '@above(' + Number(rawRows[rawRows.length - 1].objid_raw) + ')' : undefined; diff --git a/plugins/PRTG/v1/dataStreams/scripts/probes.js b/plugins/PRTG/v1/dataStreams/scripts/probes.js new file mode 100644 index 00000000..23dc9607 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/probes.js @@ -0,0 +1,20 @@ +// PRTG's `status` text varies by version and language (a paused object reads +// "Paused (paused by user)" on some builds), so the state map in the stream's +// metadata could miss it. Rebuild it from `status_raw`, which is stable, and keep +// PRTG's own text only for a code this table does not know. +const STATUS_TEXT = { + 0: 'None', 1: 'Unknown', 2: 'Scanning', 3: 'Up', 4: 'Warning', 5: 'Down', 6: 'No Probe', + 7: 'Paused by User', 8: 'Paused by Dependency', 9: 'Paused by Schedule', 10: 'Unusual', + 11: 'Not Licensed', 12: 'Paused until', 13: 'Down (Acknowledged)', 14: 'Down (Partial)' +}; +const withStatus = (row) => { + const text = STATUS_TEXT[Number(row.status_raw)]; + return text && row.status_raw !== '' ? Object.assign({}, row, { status: text }) : row; +}; + +// table.json's `start` parameter clamps to the last page instead of returning an empty +// one once it runs past the end, so row-offset paging never terminates. Cursor on objid +// instead: PRTG's @above() filter genuinely returns nothing once none remain above it. +const rows = (data && data.probes) || []; +result = rows.map(withStatus); +pagingContext = rows.length ? '@above(' + Number(rows[rows.length - 1].objid_raw) + ')' : undefined; diff --git a/plugins/PRTG/v1/dataStreams/scripts/sensors.js b/plugins/PRTG/v1/dataStreams/scripts/sensors.js new file mode 100644 index 00000000..abe377be --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/sensors.js @@ -0,0 +1,20 @@ +// PRTG's `status` text varies by version and language (a paused object reads +// "Paused (paused by user)" on some builds), so the state map in the stream's +// metadata could miss it. Rebuild it from `status_raw`, which is stable, and keep +// PRTG's own text only for a code this table does not know. +const STATUS_TEXT = { + 0: 'None', 1: 'Unknown', 2: 'Scanning', 3: 'Up', 4: 'Warning', 5: 'Down', 6: 'No Probe', + 7: 'Paused by User', 8: 'Paused by Dependency', 9: 'Paused by Schedule', 10: 'Unusual', + 11: 'Not Licensed', 12: 'Paused until', 13: 'Down (Acknowledged)', 14: 'Down (Partial)' +}; +const withStatus = (row) => { + const text = STATUS_TEXT[Number(row.status_raw)]; + return text && row.status_raw !== '' ? Object.assign({}, row, { status: text }) : row; +}; + +// table.json's `start` parameter clamps to the last page instead of returning an empty +// one once it runs past the end, so row-offset paging never terminates. Cursor on objid +// instead: PRTG's @above() filter genuinely returns nothing once none remain above it. +const rows = (data && data.sensors) || []; +result = rows.map(withStatus); +pagingContext = rows.length ? '@above(' + Number(rows[rows.length - 1].objid_raw) + ')' : undefined; diff --git a/plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js b/plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js deleted file mode 100644 index b959c99f..00000000 --- a/plugins/PRTG/v1/dataStreams/scripts/timeZoneCheck.js +++ /dev/null @@ -1,88 +0,0 @@ -// PRTG's legacy API interprets `filter_dstart`/`filter_dend` and reports historic -// data in the time zone of the *account* whose API key is in use, and offers no -// way to ask for UTC. That zone therefore has to be configured, and getting it -// wrong shifts Log and Sensor History by hours with nothing to show for it. -// -// It can, however, be read back: `getstatus.htm` reports the account's zone as a -// fixed offset in `UserTimeZone` (for example "UTC-03:00"). That is too coarse to -// replace the setting — Sensor History spans up to 30 days and needs the daylight -// saving transitions an IANA name carries — but it is enough to check it. This -// stream exists only for the `Time zone` step in configValidation.json, which -// treats no rows as a mismatch, so agreement has to be the non-empty case. -// -// IANA names contain only [A-Za-z0-9_+/-]; stripping other characters stops a -// quote in a custom value terminating the literal below. -const TIME_ZONE = - '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return String(tz || "UTC").replace(/[^A-Za-z0-9_+\/-]/g, "") || "UTC"; })() }}'; - -// Offset (minutes) that `zone` was running at the given instant. Read via -// formatToParts rather than a formatted string, so nothing depends on how a given -// ICU build separates the date from the time. Returns null if the parts do not -// come back as numbers, so no caller can reach `new Date(NaN)`. -const offsetAt = (instant, zone) => { - const parts = new Intl.DateTimeFormat('en-GB', { - timeZone: zone, - hourCycle: 'h23', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }).formatToParts(new Date(instant)); - - const p = {}; - for (const part of parts) p[part.type] = part.value; - - const wall = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)); - return isFinite(wall) ? Math.round((wall - instant) / 60000) : null; -}; - -// PRTG labels a zone by its *standard* offset in its own UI, so it is not safe to -// assume `UserTimeZone` reports the offset in force right now. Accept either the -// current offset or the standard one — daylight saving only ever adds to the -// offset, so the standard offset is the smaller of the two solstice readings in -// both hemispheres. Accepting both still catches the mistakes that matter (a zone -// picked on the wrong continent) without crying wolf every summer. -// -// An unusable zone falls back to UTC, matching what the request side in logs.json -// and sensorHistory.json does with one — the queries really are being sent as UTC -// in that case, so a non-UTC server should still be flagged. -const candidateOffsets = (zone) => { - try { - const now = Date.now(); - const year = new Date(now).getUTCFullYear(); - const winter = offsetAt(Date.UTC(year, 0, 1), zone); - const summer = offsetAt(Date.UTC(year, 6, 1), zone); - const current = offsetAt(now, zone); - if (winter === null || summer === null || current === null) return [0]; - return [current, Math.min(winter, summer)]; - } catch (e) { - return [0]; - } -}; - -// "UTC-03:00" -> -180. Also accepts a bare "UTC", tolerates the separator being -// dropped ("UTC+0530"), and reads the offset out of the bracketed form PRTG -// labels zones with in its own UI ("(UTC+01:00) Amsterdam, Berlin") in case this -// field ever carries that instead. Returns null for anything else, which the -// caller reads as "cannot tell" rather than "mismatch" — a PRTG version that -// words this field differently must not fail the check. -const parseReportedOffset = (raw) => { - const m = String(raw == null ? '' : raw) - .trim() - .match(/^\(?\s*(?:UTC|GMT)(?:\s*([+-])\s*(\d{1,2}):?(\d{2})?)?\s*(?:\)|$)/i); - if (!m) return null; - if (!m[1]) return 0; - const minutes = Number(m[2]) * 60 + Number(m[3] || 0); - return m[1] === '-' ? -minutes : minutes; -}; - -const reportedRaw = data && typeof data === 'object' ? data.UserTimeZone : null; -const reported = parseReportedOffset(reportedRaw); - -// No row means "mismatch" to configValidation, so every uncertain case has to -// return one: a value it cannot read, or a status body PRTG did not answer with. -const agrees = reported === null || candidateOffsets(TIME_ZONE).includes(reported); - -result = agrees ? [{ serverTimeZone: reportedRaw === null || reportedRaw === undefined ? TIME_ZONE : String(reportedRaw) }] : []; diff --git a/plugins/PRTG/v1/dataStreams/sensors.json b/plugins/PRTG/v1/dataStreams/sensors.json index 739778f5..41145456 100644 --- a/plugins/PRTG/v1/dataStreams/sensors.json +++ b/plugins/PRTG/v1/dataStreams/sensors.json @@ -24,27 +24,23 @@ "value": "objid" } ], - "pathToData": "sensors", "paging": { - "mode": "offset", + "mode": "token", "pageSize": { "realm": "queryArg", "path": "count", "value": "500" }, - "offset": { - "mode": "row", - "rowCountIn": { - "realm": "payloadArraySize", - "path": "sensors" - }, - "base": "0" + "in": { + "realm": "payload", + "path": "nextCursor" }, "out": { "realm": "queryArg", - "path": "start" + "path": "filter_objid" } - } + }, + "postRequestScript": "sensors.js" }, "matches": "none", "metadata": [ diff --git a/plugins/PRTG/v1/dataStreams/timeZoneCheck.json b/plugins/PRTG/v1/dataStreams/timeZoneCheck.json deleted file mode 100644 index 2c1012ea..00000000 --- a/plugins/PRTG/v1/dataStreams/timeZoneCheck.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "timeZoneCheck", - "displayName": "Time Zone Check", - "description": "Compares the configured PRTG time zone against the zone the PRTG server reports it is using", - "tags": ["Status"], - "baseDataSourceName": "httpRequestUnscoped", - "visibility": { "type": "hidden" }, - "config": { - "httpMethod": "get", - "endpointPath": "getstatus.htm", - "getArgs": [{ "key": "id", "value": "0" }], - "postRequestScript": "timeZoneCheck.js" - }, - "matches": "none", - "metadata": [ - { - "name": "serverTimeZone", - "displayName": "Server Time Zone", - "shape": "string", - "role": "label" - } - ], - "timeframes": false -} From eed3b802331304e4c9a54e0c49d3c637f8aac6f0 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 28 Sep 2026 10:23:37 +0100 Subject: [PATCH 12/13] Drop the PRTG time zone setting and trim widened windows by UTC raw dates PRTG reads date ranges in the API account's time zone. Log and Sensor History now widen each request by 12 hours before and 14 hours after, then trim the results using the UTC OLE _raw dates. Sensor History reads historicdata.csv, the only form that carries both the channel names and Date Time(RAW). This removes the time zone field, its save-time check and the timeZoneCheck stream. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/PRTG/v1/configValidation.json | 10 - plugins/PRTG/v1/dataStreams/logs.json | 8 +- plugins/PRTG/v1/dataStreams/scripts/logs.js | 15 ++ .../v1/dataStreams/scripts/sensorHistory.js | 217 ++++++++---------- .../PRTG/v1/dataStreams/sensorHistory.json | 14 +- plugins/PRTG/v1/dataStreams/systemStatus.json | 2 +- plugins/PRTG/v1/docs/README.md | 58 ++--- plugins/PRTG/v1/ui.json | 50 ---- 8 files changed, 142 insertions(+), 232 deletions(-) create mode 100644 plugins/PRTG/v1/dataStreams/scripts/logs.js diff --git a/plugins/PRTG/v1/configValidation.json b/plugins/PRTG/v1/configValidation.json index 1bb49670..c8f38187 100644 --- a/plugins/PRTG/v1/configValidation.json +++ b/plugins/PRTG/v1/configValidation.json @@ -6,16 +6,6 @@ "required": true, "error": "Could not connect to PRTG. Check the PRTG URL is reachable from SquaredUp, and that the API key is correct and has not been deleted.", "success": "Connected to PRTG successfully." - }, - { - "displayName": "Time zone", - "dataStream": { - "name": "timeZoneCheck", - "config": { "errorOnEmptyResults": true } - }, - "required": false, - "error": "The PRTG time zone selected does not match the one PRTG reports. Compare it against Setup → Account Settings → My Account → Time Zone in PRTG — until they agree, Log and Sensor History will be shifted by the difference.", - "success": "Time zone matches PRTG." } ] } diff --git a/plugins/PRTG/v1/dataStreams/logs.json b/plugins/PRTG/v1/dataStreams/logs.json index 7bf35ce3..cf75de00 100644 --- a/plugins/PRTG/v1/dataStreams/logs.json +++ b/plugins/PRTG/v1/dataStreams/logs.json @@ -21,21 +21,21 @@ }, { "key": "filter_dstart", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(timeframe.start)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ new Date(new Date(timeframe.start).getTime() - 12 * 3600000).toISOString().slice(0, 19).replace(/[T:]/g, '-') }}" }, { "key": "filter_dend", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(timeframe.end)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ new Date(new Date(timeframe.end).getTime() + 14 * 3600000).toISOString().slice(0, 19).replace(/[T:]/g, '-') }}" }, { "key": "count", "value": "5000" } ], - "pathToData": "messages", "paging": { "mode": "none" - } + }, + "postRequestScript": "logs.js" }, "matches": "none", "metadata": [ diff --git a/plugins/PRTG/v1/dataStreams/scripts/logs.js b/plugins/PRTG/v1/dataStreams/scripts/logs.js new file mode 100644 index 00000000..636cce56 --- /dev/null +++ b/plugins/PRTG/v1/dataStreams/scripts/logs.js @@ -0,0 +1,15 @@ +// PRTG reads `filter_dstart`/`filter_dend` in the time zone of the account whose +// API key is in use, with no way to ask for UTC. Rather than make that zone a +// setting, the request window is widened by 12 hours before and 14 hours after +// (the full range of real UTC offsets) and trimmed back here using `datetime_raw`, +// which PRTG always reports as a UTC OLE date. +const toMs = (v) => (typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : new Date(v).getTime()); +const tf = (context && context.timeframe) || {}; +const start = toMs(tf.start); +const end = toMs(tf.end); + +const rows = (data && data.messages) || []; +result = rows.filter((row) => { + const at = (Number(row.datetime_raw) - 25569) * 86400000; + return isFinite(at) && at >= start && at <= end; +}); diff --git a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js index 700d86ae..d5028d91 100644 --- a/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js +++ b/plugins/PRTG/v1/dataStreams/scripts/sensorHistory.js @@ -3,131 +3,116 @@ // yields "Free Space C:", a ping sensor "Response Time"). A declared-column data // stream cannot express that, so unpivot to one row per channel per interval. // -// Timestamps come from the `datetime` string. historicdata.json also returns a -// `datetime_raw` OLE date, but only when `usecaption` is omitted — a mode that -// collapses every channel into one unnamed `value` column — and it carries the -// bucket end rather than its start, so neither form is usable here (checked -// against PRTG 26.3.122.1665). +// This reads historicdata.csv rather than historicdata.json because only the CSV +// (and XML) carries both the channel names and `Date Time(RAW)`, a UTC OLE date. +// The JSON form either names every channel `value` or, with `usecaption`, drops +// the raw date and leaves only local wall-clock text. With a UTC timestamp on +// every row, nothing here needs to know the PRTG account's time zone. // -// `datetime` is a local wall clock, so convert it to UTC with the configured -// zone, or this stream sits an hour or more from `Last Check` and the Log stream -// on the same dashboard. IANA names contain only [A-Za-z0-9_+/-]; stripping -// other characters stops a quote in a custom value terminating the literal below. -const TIME_ZONE = - '{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; return String(tz || "UTC").replace(/[^A-Za-z0-9_+\/-]/g, "") || "UTC"; })() }}'; - -// PRTG formats `datetime` in the account's regional setting, so the shape varies -// by installation. Parse the known forms explicitly rather than leaning on -// `new Date(str)`, which rejects the European form outright and would otherwise -// resolve the others against the *host* time zone instead of PRTG's. -// Returns a wall-clock instant expressed as if UTC, or null if unrecognised. -const parseNaive = (text) => { - // M/D/YYYY h:mm:ss AM|PM - let m = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})(?:\s*([AaPp])\.?[Mm]\.?)?$/); - if (m) { - let hour = Number(m[4]); - if (m[7]) { - if (hour === 12) hour = 0; - if (m[7].toUpperCase() === 'P') hour += 12; +// `Date Time(RAW)` is the reading time for raw data, but the bucket *end* for +// averaged data, so the bucket start is found by subtracting the averaging +// interval (checked against PRTG 26.3.122.1665 for 5-minute, hourly and daily +// buckets). +// +// The request window is sent widened by 12 hours before and 14 hours after, +// because PRTG reads `sdate`/`edate` in the account's zone, which can be anywhere +// from UTC-12 to UTC+14. Rows outside the real timeframe are dropped here. + +const MAX_RANGE = 30 * 86400000; + +// Same rule as the `avg` getArg in sensorHistory.json. +const toMs = (v) => (typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : new Date(v).getTime()); +const tf = (context && context.timeframe) || {}; +const end = toMs(tf.end); +const start = Math.max(toMs(tf.start), end - MAX_RANGE); +const average = context && context.config ? context.config.average : undefined; +const avgSeconds = Number(average || (toMs(tf.end) - toMs(tf.start) > 604800000 ? 3600 : 300)) || 0; +const bucketMs = avgSeconds * 1000; + +// OLE automation date (days since 1899-12-30, UTC) to epoch milliseconds. +const oleToMs = (raw) => Math.round((Number(raw) - 25569) * 86400000); + +// Minimal CSV reader: quoted fields, doubled quotes inside them, and whichever +// delimiter follows the first header field (PRTG quotes every field). +const parseCsv = (text) => { + const firstQuote = text.indexOf('"', 1); + const delimiter = firstQuote > 0 && text[firstQuote + 1] && text[firstQuote + 1] !== '"' ? text[firstQuote + 1] : ','; + const rows = []; + let row = []; + let field = ''; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quoted) { + if (ch === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + quoted = false; + } + } else { + field += ch; + } + } else if (ch === '"') { + quoted = true; + } else if (ch === delimiter) { + row.push(field); + field = ''; + } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && text[i + 1] === '\n') i++; + row.push(field); + rows.push(row); + row = []; + field = ''; + } else { + field += ch; } - return Date.UTC(Number(m[3]), Number(m[1]) - 1, Number(m[2]), hour, Number(m[5]), Number(m[6])); } - // D.M.YYYY HH:mm:ss - m = text.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})$/); - if (m) return Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6])); - // YYYY-MM-DD HH:mm:ss - m = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2}):(\d{2})/); - if (m) return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); - return null; -}; - -// Offset (ms) that `zone` was running at the given instant. Read via -// formatToParts rather than a formatted string, so nothing depends on how a -// given ICU build separates the date from the time. Returns null if the parts -// do not come back as numbers, so no caller can reach `new Date(NaN)`. -const offsetAt = (instant, zone) => { - const parts = new Intl.DateTimeFormat('en-GB', { - timeZone: zone, - hourCycle: 'h23', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }).formatToParts(new Date(instant)); - - const p = {}; - for (const part of parts) p[part.type] = part.value; - - const wall = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)); - return isFinite(wall) ? wall - instant : null; -}; - -// Interpret a naive wall-clock instant as a time in `zone` and return real UTC. -// Applied twice so a reading near a DST transition resolves against the offset -// actually in force rather than the one on the other side of it. -const wallClockToUtc = (naive, zone) => { - const first = offsetAt(naive, zone); - if (first === null) return null; - const second = offsetAt(naive - first, zone); - return second === null ? null : naive - second; -}; - -// `datetime` is either a single stamp ("8/20/2026 9:04:15 PM", when avg=0) or a -// bucket range ("8/24/2026 11:00:00 AM - 11:05:00 AM"). Take the bucket start. -const parseWhen = (raw) => { - const text = String(raw || ''); - const start = text.includes(' - ') ? text.split(' - ')[0].trim() : text.trim(); - - const naive = parseNaive(start); - if (naive === null || !isFinite(naive)) return null; - - let utc = naive; - try { - const converted = wallClockToUtc(naive, TIME_ZONE); - // Unrecognised zone or unusable parts — fall back to treating the wall - // clock as UTC, matching the request side's own fallback. - if (converted !== null) utc = converted; - } catch (e) { - utc = naive; + if (field !== '' || row.length) { + row.push(field); + rows.push(row); } - - return isFinite(utc) ? new Date(utc).toISOString() : null; + return rows; }; -// "100 %" -> 100. Test for null/undefined rather than falsiness so a genuine -// zero reports as 0% rather than as no reading at all. -const parseCoverage = (raw) => { - if (raw === null || raw === undefined) return null; - const num = parseFloat(String(raw).replace('%', '').trim()); - return isNaN(num) ? null : num; -}; +// historicdata answers HTTP 200 with the bare text "Not enough monitoring data" +// when the window holds no retained readings, so check for the header first. +const body = response && typeof response.body === 'string' ? response.body : ''; +const table = body.startsWith('"') ? parseCsv(body) : []; +const header = table[0] || []; + +// Each channel appears as a "Name" / "Name(RAW)" pair; read the raw half. +const rawIndex = {}; +header.forEach((name, i) => { + if (name.endsWith('(RAW)')) rawIndex[name.slice(0, -5)] = i; +}); +const timeIndex = rawIndex['Date Time']; +const coverageIndex = rawIndex['Coverage']; +const channels = Object.keys(rawIndex).filter((name) => name !== 'Date Time' && name !== 'Coverage'); -// historicdata.json answers HTTP 200 with the bare text "Not enough monitoring -// data" when the window holds no retained readings, so `data` is not always an object. -const rows = (data && typeof data === 'object' && data.histdata) || []; const out = []; - -for (const row of rows) { - const timestamp = parseWhen(row.datetime); - if (!timestamp) continue; - - const coverage = parseCoverage(row.coverage); - - for (const [channel, raw] of Object.entries(row)) { - if (channel === 'datetime' || channel === 'coverage') continue; - // Only present when `usecaption` is omitted, which this stream never - // does — cheap insurance against them surfacing as channels. - if (channel.endsWith('_raw')) continue; - - // Intervals with no coverage come back as empty strings — drop them so - // they leave a genuine gap in the chart rather than plotting as zero. - if (raw === '' || raw === null || raw === undefined) continue; - - const value = Number(raw); +for (const cells of table.slice(1)) { + // Skips the trailing "Averages (of N values)" row, whose second cell is text. + const raw = cells[timeIndex]; + if (raw === undefined || raw === '' || isNaN(Number(raw))) continue; + + const reported = oleToMs(raw); + const bucketStart = reported - bucketMs; + // Keep any bucket that overlaps the timeframe; a raw reading is a point. + if (bucketMs > 0 ? reported <= start || bucketStart >= end : reported < start || reported > end) continue; + + const timestamp = new Date(bucketStart).toISOString(); + const coverageRaw = coverageIndex === undefined ? '' : cells[coverageIndex]; + const coverage = coverageRaw === '' || coverageRaw === undefined || isNaN(Number(coverageRaw)) ? null : Number(coverageRaw) / 100; + + for (const channel of channels) { + const cell = cells[rawIndex[channel]]; + // Intervals with no coverage come back empty — drop them so they leave + // a genuine gap in the chart rather than plotting as zero. + if (cell === undefined || cell === '') continue; + const value = Number(cell); if (isNaN(value)) continue; - out.push({ timestamp, channel, value, coverage }); } } diff --git a/plugins/PRTG/v1/dataStreams/sensorHistory.json b/plugins/PRTG/v1/dataStreams/sensorHistory.json index 3ee72b22..138a5f6e 100644 --- a/plugins/PRTG/v1/dataStreams/sensorHistory.json +++ b/plugins/PRTG/v1/dataStreams/sensorHistory.json @@ -9,27 +9,23 @@ "baseDataSourceName": "httpRequestScopedSingle", "config": { "httpMethod": "get", - "endpointPath": "historicdata.json", + "endpointPath": "historicdata.csv", "getArgs": [ { "key": "id", "value": "{{object.rawId}}" }, - { - "key": "usecaption", - "value": "1" - }, { "key": "sdate", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var MAX = 30 * 86400000; var start = new Date(timeframe.start).getTime(); var end = new Date(timeframe.end).getTime(); if (end - start > MAX) { start = end - MAX; } var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(start)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var MAX = 30 * 86400000; var s = new Date(timeframe.start).getTime(); var e = new Date(timeframe.end).getTime(); if (e - s > MAX) { s = e - MAX; } return new Date(s - 12 * 3600000).toISOString().slice(0, 19).replace(/[T:]/g, '-'); })() }}" }, { "key": "edate", - "value": "{{ (function(){ var v = dataSource.serverTimeZone; var tz = Array.isArray(v) ? (v[0] && v[0].value) : v; if (!tz) { tz = \"UTC\"; } var end = new Date(timeframe.end).getTime(); var fmt = function(zone){ var parts = new Intl.DateTimeFormat(\"en-GB\", { timeZone: zone, hourCycle: \"h23\", year: \"numeric\", month: \"2-digit\", day: \"2-digit\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }).formatToParts(new Date(end)); var p = {}; for (var i = 0; i < parts.length; i++) { p[parts[i].type] = parts[i].value; } return p.year + \"-\" + p.month + \"-\" + p.day + \"-\" + p.hour + \"-\" + p.minute + \"-\" + p.second; }; try { return fmt(tz); } catch (e) { return fmt(\"UTC\"); } })() }}" + "value": "{{ (function(){ var MAX = 30 * 86400000; var s = new Date(timeframe.start).getTime(); var e = new Date(timeframe.end).getTime(); if (e - s > MAX) { s = e - MAX; } return new Date(e + 14 * 3600000).toISOString().slice(0, 19).replace(/[T:]/g, '-'); })() }}" }, { "key": "avg", - "value": "{{ average || (new Date(timeframe.end) - new Date(timeframe.start) > 604800000 ? '3600' : null) }}" + "value": "{{ average || (new Date(timeframe.end) - new Date(timeframe.start) > 604800000 ? '3600' : '300') }}" } ], "postRequestScript": "sensorHistory.js" @@ -47,7 +43,7 @@ "type": "choiceChips", "name": "average", "label": "Averaging interval", - "help": "How PRTG aggregates the readings. Left unset, PRTG uses 5-minute buckets, or hourly on ranges longer than seven days. **Raw** returns every individual reading — only practical over short timeframes.", + "help": "How PRTG aggregates the readings. Left unset, the plugin uses 5-minute buckets, or hourly on ranges longer than seven days. **Raw** returns every individual reading — only practical over short timeframes.", "options": [ { "value": "0", diff --git a/plugins/PRTG/v1/dataStreams/systemStatus.json b/plugins/PRTG/v1/dataStreams/systemStatus.json index 9c8722f9..2a2bc391 100644 --- a/plugins/PRTG/v1/dataStreams/systemStatus.json +++ b/plugins/PRTG/v1/dataStreams/systemStatus.json @@ -116,7 +116,7 @@ }, { "name": "UserTimeZone", - "displayName": "Server Time Zone", + "displayName": "Account Time Zone", "shape": "string" } ], diff --git a/plugins/PRTG/v1/docs/README.md b/plugins/PRTG/v1/docs/README.md index 166509e2..059fb253 100644 --- a/plugins/PRTG/v1/docs/README.md +++ b/plugins/PRTG/v1/docs/README.md @@ -2,7 +2,7 @@ Monitor your [PRTG Network Monitor](https://www.paessler.com/prtg) installation devices and sensors, with current status, channel readings, historic sensor data and the PRTG log — via the [PRTG HTTP API](https://www.paessler.com/manuals/prtg/http_api). -> ⚠️ **This plugin uses the PRTG API v1** — `/api/table.json`, `/api/historicdata.json` and +> ⚠️ **This plugin uses the PRTG API v1** — `/api/table.json`, `/api/historicdata.csv` and > `/api/getstatus.htm`. It works against PRTG Network Monitor, PRTG Enterprise Monitor and PRTG Hosted > Monitor, and you do **not** need to enable the new UI or API v2. Any version supporting API keys is > expected to work; this plugin was built and tested against PRTG 26.3.122.1665. @@ -22,16 +22,6 @@ You will need the **URL** of your PRTG server and a PRTG **API key**. 6. Click **OK**, then copy the generated key immediately — PRTG will not show it again. If you lose it, delete the key and create a new one. 7. Paste the key into the **API key** field, and your PRTG address into **PRTG URL**. -8. Set **PRTG time zone** to the time zone of the account whose key you just created — it is shown in PRTG - under **Setup → Account Settings → My Account → Time Zone**. Saving checks your answer against the zone - PRTG reports, so a mismatch is flagged there and then. - -> **Tip: give SquaredUp its own PRTG account, set to UTC.** PRTG has no per-request time zone parameter — -> every response uses the time zone of the account the key belongs to. Change that account's time zone to -> **UTC** in PRTG, create the key there, and answer `UTC` here: daylight saving stops mattering, and nobody -> editing their personal time zone can silently shift your dashboards. Note this means genuinely setting the -> PRTG account to UTC — answering `UTC` for an account still on another zone is the mismatch the save-time -> check exists to catch. ## Configuration fields @@ -39,19 +29,10 @@ You will need the **URL** of your PRTG server and a PRTG **API key**. | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------- | | **PRTG URL** | The base address of your PRTG web interface — for example `https://prtg.example.com` or `https://yourname.my-prtg.com`. Include the sub-path if PRTG sits behind a reverse proxy, but no query string. | The address bar of your PRTG web interface. | Yes | | **API key** | Authenticates every request. Sent as the `apitoken` query parameter — PRTG rejects the key in an `Authorization` header. | PRTG → **Setup → Account Settings → API Keys**. | Yes | -| **PRTG time zone** | The time zone of the PRTG account whose API key you supplied, as an IANA name such as `Europe/London`. Only affects **Sensor History** and **Log**. Defaults to UTC. | PRTG → **Setup → Account Settings → My Account → Time Zone**. | No | | **Ignore certificate errors** | Skips TLS certificate validation. Only enable for an on-premise PRTG server using a self-signed certificate. | — | No | -On save, the plugin calls PRTG's status endpoint twice: once to confirm the URL and key — a failure there -means the URL is unreachable or the key is invalid, expired, or deleted — and once to compare **PRTG time -zone** against the zone PRTG says it is using. A zone mismatch is a warning rather than a failure, so it -will not stop you connecting. - -> ⚠️ **Get the time zone right.** PRTG interprets date ranges in its *own* time zone rather than UTC, so the -> wrong zone shifts **Sensor History** and **Log** — and on short timeframes can make them look empty. -> Everything else is unaffected. Note that PRTG labels a zone by its *standard* offset, so a UK server shown -> as "(UTC+00:00) … London" is really running an hour ahead during British Summer Time — pick -> `Europe/London` rather than `UTC` and daylight saving is handled for you. +On save, the plugin calls PRTG's status endpoint to confirm the URL and key. A failure there means the URL +is unreachable or the key is invalid, expired, or deleted. ## What this plugin monitors @@ -107,21 +88,14 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by code-based plugins, not low-code ones like this. The PRTG hierarchy is therefore expressed as properties (`parentId`, `deviceName`, `groupName`, `probeName`) and through dashboard scoping and drilldown, rather than as traversable parent/child links in the graph. -- **Date ranges follow the API account's time zone.** See the **PRTG time zone** field above. The zone is a - property of the PRTG *account* the key belongs to, and Paessler - [document no per-request override](https://helpdesk.paessler.com/en/support/solutions/articles/76000073098-output-of-api-table-json-content-messages-contains-datetime-is-it-in-gmt-utc-filter-dstart) — there is no - UTC parameter and no relative ranges — so the zone has to be supplied. (API v2 would remove the problem - entirely, since it returns ISO 8601 in UTC throughout, but it cannot yet run this plugin — see - [Why this plugin uses API v1](#why-this-plugin-uses-api-v1).) The zone is also used to convert - **Sensor History** timestamps to UTC, because - that endpoint reports times only as local text with no UTC equivalent — so the wrong zone shifts both the - range queried *and* the times plotted. An unrecognised zone name falls back to UTC rather than failing the - request. - - PRTG does report the zone it is using, as a fixed offset — the **System Status** data stream surfaces it as - **Server Time Zone**, and saving the configuration warns when it disagrees with the zone you picked. That - offset cannot replace the setting, though: it describes only the present moment, whereas **Sensor History** - covers up to 30 days and needs the daylight saving transitions an IANA name carries. +- **Log counts on past windows can include extra entries against the cap.** PRTG reads date ranges in the + time zone of the account the key belongs to, and Paessler + [document no per-request override](https://helpdesk.paessler.com/en/support/solutions/articles/76000073098-output-of-api-table-json-content-messages-contains-datetime-is-it-in-gmt-utc-filter-dstart). + So that you never have to tell the plugin that zone, **Log** and **Sensor History** widen each request + to cover every possible offset (12 hours before, 14 hours after), then trim the results to the exact + timeframe using the UTC timestamps PRTG includes on every row. Times are always correct. The one side + effect: **Log** asks PRTG for at most 5,000 entries, and on a timeframe that ends in the past, up to 14 + hours of newer entries count towards that limit before they are trimmed away. - **Very large installations may still hit a response size limit.** Probes, groups, devices and sensors are fetched page by page, 500 rows per request, so there is no per-request ceiling on how many objects can be imported. The combined result is still subject to the platform's overall response size limit, so an estate @@ -160,11 +134,11 @@ are the usual way to organise by site. The **Sites** dashboard groups devices by ## Why this plugin uses API v1 PRTG also has a newer [API v2](https://www.paessler.com/support/prtg/api/v2/overview/index.html), and where -it is stable it is the better API: ISO 8601 timestamps in UTC rather than Excel-style serial numbers in the -account's local time, typed status enumerations rather than numeric codes, native latitude and longitude, a +it is stable it is the better API: ISO 8601 timestamps rather than Excel-style serial dates alongside +local-time text, typed status enumerations rather than numeric codes, native latitude and longitude, a sensor status summary embedded in every probe, group and device, and a `path` array giving each object its -place in the tree. Adopting it would remove the **PRTG time zone** setting from this plugin entirely. It -still cannot run this plugin, for three reasons, in descending order of how binding they are. +place in the tree. It still cannot run this plugin, for three reasons, in descending order of how binding +they are. ### 1. API v2 is not available everywhere API v1 is @@ -189,7 +163,7 @@ Checked against the published | List every probe, group, device and sensor, for indexing | `table.json?content=…` — one request per type | `GET /experimental/objects` returns all four from one paged endpoint, channels included. 3,000 is the per-request page size rather than a ceiling, and responses carry RFC 5988 `Link` headers for the next page. **Better than API v1.** | | Everything beneath one probe, group or device | `table.json?content=sensors&id=…` — PRTG walks the subtree | The same endpoint's `filter` supports `parentid`, plus `children`, `descendants` and `ancestors` prefixes. Equivalent. | | Channel readings for one sensor | `table.json?content=channels&id=…` | `GET /sensors/{id}/data` — **stable, and better than API v1.** | -| Historic readings over an arbitrary window | `historicdata.json?sdate=&edate=&avg=` — any range, and a choice of raw, 5-minute, hourly or daily buckets | The supported `GET /experimental/timeseries/{id}/{type}` offers four fixed windows only: `live` (4 hours), `short` (2 days), `medium` (60 days), `long` (365 days). Arbitrary `from`/`to` exist only on `GET /experimental/timeseries/{id}`, which Paessler have deprecated and say will be removed. Neither form has an averaging control. | +| Historic readings over an arbitrary window | `historicdata.csv?sdate=&edate=&avg=` — any range, and a choice of raw, 5-minute, hourly or daily buckets | The supported `GET /experimental/timeseries/{id}/{type}` offers four fixed windows only: `live` (4 hours), `short` (2 days), `medium` (60 days), `long` (365 days). Arbitrary `from`/`to` exist only on `GET /experimental/timeseries/{id}`, which Paessler have deprecated and say will be removed. Neither form has an averaging control. | | PRTG log entries over a timeframe | `table.json?content=messages&filter_dstart=&filter_dend=` | **Nothing.** The specification contains no log, message or event endpoint at any maturity level. | | Installation-wide sensor counts, version and edition | `getstatus.htm?id=0` — one request | `GET /sensor-status-summary` and `GET /version` are stable and cover most of it, and the experimental `/license` covers the edition — but that is three requests instead of one, and the new-message count and new-alarm count have no equivalent. | diff --git a/plugins/PRTG/v1/ui.json b/plugins/PRTG/v1/ui.json index 013254da..bf4e90e6 100644 --- a/plugins/PRTG/v1/ui.json +++ b/plugins/PRTG/v1/ui.json @@ -21,56 +21,6 @@ "help": "A PRTG API key with **Read access**, created under **Setup → Account Settings → API Keys** using the **Scripting** token type. PRTG only shows the key once, so copy it when you create it.", "validation": { "required": true } }, - { - "name": "serverTimeZone", - "label": "PRTG time zone", - "type": "autocomplete", - "allowCustomValues": true, - "isClearable": true, - "help": "The time zone of the PRTG **account** whose API key you entered above — PRTG interprets date ranges in that zone rather than UTC. Find it in PRTG under **Setup → Account Settings → My Account → Time Zone**. Daylight saving is handled automatically, so pick the region (for example `Europe/London`) rather than a fixed offset. Any [IANA time zone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) can be typed in. Defaults to UTC if left empty. Saving checks this against the zone PRTG reports and warns if they disagree; the **System Status** data stream also shows it as **Server Time Zone**.", - "data": { - "source": "fixed", - "values": [ - { "value": "UTC", "label": "UTC" }, - { "value": "Europe/London", "label": "Europe/London (UK)" }, - { "value": "Europe/Dublin", "label": "Europe/Dublin" }, - { "value": "Europe/Lisbon", "label": "Europe/Lisbon" }, - { "value": "Europe/Paris", "label": "Europe/Paris" }, - { "value": "Europe/Berlin", "label": "Europe/Berlin" }, - { "value": "Europe/Madrid", "label": "Europe/Madrid" }, - { "value": "Europe/Amsterdam", "label": "Europe/Amsterdam" }, - { "value": "Europe/Stockholm", "label": "Europe/Stockholm" }, - { "value": "Europe/Warsaw", "label": "Europe/Warsaw" }, - { "value": "Europe/Athens", "label": "Europe/Athens" }, - { "value": "Europe/Helsinki", "label": "Europe/Helsinki" }, - { "value": "Europe/Moscow", "label": "Europe/Moscow" }, - { "value": "America/New_York", "label": "America/New_York (US Eastern)" }, - { "value": "America/Chicago", "label": "America/Chicago (US Central)" }, - { "value": "America/Denver", "label": "America/Denver (US Mountain)" }, - { "value": "America/Phoenix", "label": "America/Phoenix (no DST)" }, - { "value": "America/Los_Angeles", "label": "America/Los_Angeles (US Pacific)" }, - { "value": "America/Toronto", "label": "America/Toronto" }, - { "value": "America/Vancouver", "label": "America/Vancouver" }, - { "value": "America/Mexico_City", "label": "America/Mexico_City" }, - { "value": "America/Sao_Paulo", "label": "America/Sao_Paulo" }, - { "value": "Africa/Lagos", "label": "Africa/Lagos" }, - { "value": "Africa/Cairo", "label": "Africa/Cairo" }, - { "value": "Africa/Johannesburg", "label": "Africa/Johannesburg" }, - { "value": "Asia/Dubai", "label": "Asia/Dubai" }, - { "value": "Asia/Kolkata", "label": "Asia/Kolkata (India)" }, - { "value": "Asia/Singapore", "label": "Asia/Singapore" }, - { "value": "Asia/Hong_Kong", "label": "Asia/Hong_Kong" }, - { "value": "Asia/Shanghai", "label": "Asia/Shanghai" }, - { "value": "Asia/Tokyo", "label": "Asia/Tokyo" }, - { "value": "Asia/Seoul", "label": "Asia/Seoul" }, - { "value": "Australia/Perth", "label": "Australia/Perth" }, - { "value": "Australia/Brisbane", "label": "Australia/Brisbane" }, - { "value": "Australia/Sydney", "label": "Australia/Sydney" }, - { "value": "Australia/Melbourne", "label": "Australia/Melbourne" }, - { "value": "Pacific/Auckland", "label": "Pacific/Auckland" } - ] - } - }, { "type": "checkbox", "name": "ignoreCertificateErrors", From 4515b9267e59082f49cee648e4d03b655f6221d7 Mon Sep 17 00:00:00 2001 From: Dan Watts Date: Mon, 28 Sep 2026 11:33:02 +0100 Subject: [PATCH 13/13] Exclude paused sensors from attention tables and group sensor counts by device ID The Sensors Needing Attention tables filtered only statusText != Up, so deliberately paused sensors (codes 7, 8, 9, 12) were listed. Exclude Paused as well on the overview, probe and group dashboards. Sensors per Device grouped on the device display name, merging devices that share a name such as each probe's "Probe Device". Add a hidden computed deviceLabel column (name plus device ID) to both sensor streams and group the chart on it. Co-Authored-By: Claude Opus 5.5 --- plugins/PRTG/v1/dataStreams/containerSensors.json | 8 ++++++++ plugins/PRTG/v1/dataStreams/sensors.json | 8 ++++++++ plugins/PRTG/v1/defaultContent/group.dash.json | 5 +++++ plugins/PRTG/v1/defaultContent/overview.dash.json | 9 +++++++-- plugins/PRTG/v1/defaultContent/probe.dash.json | 5 +++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/plugins/PRTG/v1/dataStreams/containerSensors.json b/plugins/PRTG/v1/dataStreams/containerSensors.json index b2e10e45..a22d7a3e 100644 --- a/plugins/PRTG/v1/dataStreams/containerSensors.json +++ b/plugins/PRTG/v1/dataStreams/containerSensors.json @@ -80,6 +80,14 @@ "displayName": "Device", "shape": "string" }, + { + "name": "deviceLabel", + "displayName": "Device (ID)", + "computed": true, + "shape": "string", + "visible": false, + "valueExpression": "{{ $['device'] + ' (' + $['parentid'] + ')' }}" + }, { "name": "group", "displayName": "Group", diff --git a/plugins/PRTG/v1/dataStreams/sensors.json b/plugins/PRTG/v1/dataStreams/sensors.json index 41145456..48a22ee1 100644 --- a/plugins/PRTG/v1/dataStreams/sensors.json +++ b/plugins/PRTG/v1/dataStreams/sensors.json @@ -67,6 +67,14 @@ "displayName": "Device", "shape": "string" }, + { + "name": "deviceLabel", + "displayName": "Device (ID)", + "computed": true, + "shape": "string", + "visible": false, + "valueExpression": "{{ $['device'] + ' (' + $['parentid'] + ')' }}" + }, { "name": "group", "displayName": "Group", diff --git a/plugins/PRTG/v1/defaultContent/group.dash.json b/plugins/PRTG/v1/defaultContent/group.dash.json index 3b8c35bd..b0c60edd 100644 --- a/plugins/PRTG/v1/defaultContent/group.dash.json +++ b/plugins/PRTG/v1/defaultContent/group.dash.json @@ -221,6 +221,11 @@ "column": "statusText", "operation": "notequals", "value": "Up" + }, + { + "column": "statusText", + "operation": "notequals", + "value": "Paused" } ] } diff --git a/plugins/PRTG/v1/defaultContent/overview.dash.json b/plugins/PRTG/v1/defaultContent/overview.dash.json index 9396c17a..e176ba31 100644 --- a/plugins/PRTG/v1/defaultContent/overview.dash.json +++ b/plugins/PRTG/v1/defaultContent/overview.dash.json @@ -197,6 +197,11 @@ "column": "statusText", "operation": "notequals", "value": "Up" + }, + { + "column": "statusText", + "operation": "notequals", + "value": "Paused" } ] }, @@ -309,7 +314,7 @@ "name": "sensors", "pluginConfigId": "{{configId}}", "group": { - "by": [["device", "uniqueValues"]], + "by": [["deviceLabel", "uniqueValues"]], "aggregate": [{ "type": "count" }] }, "sort": { "by": [["count", "desc"]], "top": 10 } @@ -318,7 +323,7 @@ "type": "data-stream-bar-chart", "config": { "data-stream-bar-chart": { - "xAxisData": "device_uniqueValues", + "xAxisData": "deviceLabel_uniqueValues", "yAxisData": ["count"], "xAxisGroup": "none", "xAxisLabel": "", diff --git a/plugins/PRTG/v1/defaultContent/probe.dash.json b/plugins/PRTG/v1/defaultContent/probe.dash.json index 1cd9aa3d..ee19e2fe 100644 --- a/plugins/PRTG/v1/defaultContent/probe.dash.json +++ b/plugins/PRTG/v1/defaultContent/probe.dash.json @@ -221,6 +221,11 @@ "column": "statusText", "operation": "notequals", "value": "Up" + }, + { + "column": "statusText", + "operation": "notequals", + "value": "Paused" } ] }